Redesign skill system with upgrade trees and tier progression
Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Has been cancelled
Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Has been cancelled
Major changes: - Created docs/skills.md with comprehensive skill system documentation - Rewrote skill-evolution.ts with new upgrade tree structure: - Upgrades organized in branching paths with prerequisites - Each choice can lead to upgraded versions at future milestones - Support for upgrade children and requirement chains - Added getBaseSkillId and generateTierSkillDef helper functions - Fixed getFloorElement to use FLOOR_ELEM_CYCLE.length - Updated test files to match current skill definitions - Removed tests for non-existent skills Skill system now supports: - Levels 1-10 for most skills, level 5 caps for specialized, level 1 for research - Tier up system: Tier N Level 1 = Tier N-1 Level 10 in power - Milestone upgrades at levels 5 and 10 with branching upgrade trees - Attunement requirements for skill access and tier up - Study costs and time for leveling
This commit is contained in:
@@ -1 +1 @@
|
||||
545
|
||||
563
|
||||
|
||||
313
AUDIT_REPORT.md
Executable file
313
AUDIT_REPORT.md
Executable file
@@ -0,0 +1,313 @@
|
||||
# Mana Loop - Codebase Audit Report
|
||||
|
||||
**Task ID:** 4
|
||||
**Date:** Audit of unimplemented effects, upgrades, and missing functionality
|
||||
|
||||
---
|
||||
|
||||
## 1. Special Effects Status
|
||||
|
||||
### SPECIAL_EFFECTS Constant (upgrade-effects.ts)
|
||||
|
||||
The `SPECIAL_EFFECTS` constant defines 32 special effect IDs. Here's the implementation status:
|
||||
|
||||
| Effect ID | Name | Status | Notes |
|
||||
|-----------|------|--------|-------|
|
||||
| `MANA_CASCADE` | Mana Cascade | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but that function is NOT called from store.ts |
|
||||
| `STEADY_STREAM` | Steady Stream | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but not called from tick |
|
||||
| `MANA_TORRENT` | Mana Torrent | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but not called |
|
||||
| `FLOW_SURGE` | Flow Surge | ❌ Missing | Not implemented anywhere |
|
||||
| `MANA_EQUILIBRIUM` | Mana Equilibrium | ❌ Missing | Not implemented |
|
||||
| `DESPERATE_WELLS` | Desperate Wells | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but not called |
|
||||
| `MANA_ECHO` | Mana Echo | ❌ Missing | Not implemented in gatherMana() |
|
||||
| `EMERGENCY_RESERVE` | Emergency Reserve | ❌ Missing | Not implemented in startNewLoop() |
|
||||
| `BATTLE_FURY` | Battle Fury | ⚠️ Partially Implemented | In `computeDynamicDamage()` but function not called |
|
||||
| `ARMOR_PIERCE` | Armor Pierce | ❌ Missing | Floor defense not implemented |
|
||||
| `OVERPOWER` | Overpower | ✅ Implemented | store.ts line 627 |
|
||||
| `BERSERKER` | Berserker | ✅ Implemented | store.ts line 632 |
|
||||
| `COMBO_MASTER` | Combo Master | ❌ Missing | Not implemented |
|
||||
| `ADRENALINE_RUSH` | Adrenaline Rush | ❌ Missing | Not implemented on enemy defeat |
|
||||
| `PERFECT_MEMORY` | Perfect Memory | ❌ Missing | Not implemented in cancel study |
|
||||
| `QUICK_MASTERY` | Quick Mastery | ❌ Missing | Not implemented |
|
||||
| `PARALLEL_STUDY` | Parallel Study | ⚠️ Partially Implemented | State exists but logic incomplete |
|
||||
| `STUDY_INSIGHT` | Study Insight | ❌ Missing | Not implemented |
|
||||
| `STUDY_MOMENTUM` | Study Momentum | ❌ Missing | Not implemented |
|
||||
| `KNOWLEDGE_ECHO` | Knowledge Echo | ❌ Missing | Not implemented |
|
||||
| `KNOWLEDGE_TRANSFER` | Knowledge Transfer | ❌ Missing | Not implemented |
|
||||
| `MENTAL_CLARITY` | Mental Clarity | ❌ Missing | Not implemented |
|
||||
| `STUDY_REFUND` | Study Refund | ❌ Missing | Not implemented |
|
||||
| `FREE_STUDY` | Free Study | ❌ Missing | Not implemented |
|
||||
| `MIND_PALACE` | Mind Palace | ❌ Missing | Not implemented |
|
||||
| `STUDY_RUSH` | Study Rush | ❌ Missing | Not implemented |
|
||||
| `CHAIN_STUDY` | Chain Study | ❌ Missing | Not implemented |
|
||||
| `ELEMENTAL_HARMONY` | Elemental Harmony | ❌ Missing | Not implemented |
|
||||
| `DEEP_STORAGE` | Deep Storage | ❌ Missing | Not implemented |
|
||||
| `DOUBLE_CRAFT` | Double Craft | ❌ Missing | Not implemented |
|
||||
| `ELEMENTAL_RESONANCE` | Elemental Resonance | ❌ Missing | Not implemented |
|
||||
| `PURE_ELEMENTS` | Pure Elements | ❌ Missing | Not implemented |
|
||||
|
||||
**Summary:** 2 fully implemented, 6 partially implemented (function exists but not called), 24 not implemented.
|
||||
|
||||
---
|
||||
|
||||
## 2. Enchantment Effects Status
|
||||
|
||||
### Equipment Enchantment Effects (enchantment-effects.ts)
|
||||
|
||||
The following effect types are defined:
|
||||
|
||||
| Effect Type | Status | Notes |
|
||||
|-------------|--------|-------|
|
||||
| **Spell Effects** (`type: 'spell'`) | ✅ Working | Spells granted via `getSpellsFromEquipment()` |
|
||||
| **Bonus Effects** (`type: 'bonus'`) | ✅ Working | Applied in `computeEquipmentEffects()` |
|
||||
| **Multiplier Effects** (`type: 'multiplier'`) | ✅ Working | Applied in `computeEquipmentEffects()` |
|
||||
| **Special Effects** (`type: 'special'`) | ⚠️ Tracked Only | Added to `specials` Set but NOT applied in game logic |
|
||||
|
||||
### Special Enchantment Effects Not Applied:
|
||||
|
||||
| Effect ID | Description | Issue |
|
||||
|-----------|-------------|-------|
|
||||
| `spellEcho10` | 10% chance cast twice | Tracked but not implemented in combat |
|
||||
| `lifesteal5` | 5% damage as mana | Tracked but not implemented in combat |
|
||||
| `overpower` | +50% damage at 80% mana | Tracked but separate from skill upgrade version |
|
||||
|
||||
**Location of Issue:**
|
||||
```typescript
|
||||
// effects.ts line 58-60
|
||||
} else if (effect.type === 'special' && effect.specialId) {
|
||||
specials.add(effect.specialId);
|
||||
}
|
||||
// Effect is tracked but never used in combat/damage calculations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Skill Effects Status
|
||||
|
||||
### SKILLS_DEF Analysis (constants.ts)
|
||||
|
||||
Skills with direct effects that should apply per level:
|
||||
|
||||
| Skill | Effect | Status |
|
||||
|-------|--------|--------|
|
||||
| `manaWell` | +100 max mana per level | ✅ Implemented |
|
||||
| `manaFlow` | +1 regen/hr per level | ✅ Implemented |
|
||||
| `elemAttune` | +50 elem mana cap | ✅ Implemented |
|
||||
| `manaOverflow` | +25% click mana | ✅ Implemented |
|
||||
| `quickLearner` | +10% study speed | ✅ Implemented |
|
||||
| `focusedMind` | -5% study cost | ✅ Implemented |
|
||||
| `meditation` | 2.5x regen after 4hrs | ✅ Implemented |
|
||||
| `knowledgeRetention` | +20% progress saved | ⚠️ Partially Implemented |
|
||||
| `enchanting` | Unlocks designs | ✅ Implemented |
|
||||
| `efficientEnchant` | -5% capacity cost | ⚠️ Not verified |
|
||||
| `disenchanting` | 20% mana recovery | ⚠️ Not verified |
|
||||
| `enchantSpeed` | -10% enchant time | ⚠️ Not verified |
|
||||
| `scrollCrafting` | Create scrolls | ❌ Not implemented |
|
||||
| `essenceRefining` | +10% effect power | ⚠️ Not verified |
|
||||
| `effCrafting` | -10% craft time | ⚠️ Not verified |
|
||||
| `fieldRepair` | +15% repair | ❌ Repair not implemented |
|
||||
| `elemCrafting` | +25% craft output | ✅ Implemented |
|
||||
| `manaTap` | +1 mana/click | ✅ Implemented |
|
||||
| `manaSurge` | +3 mana/click | ✅ Implemented |
|
||||
| `manaSpring` | +2 regen | ✅ Implemented |
|
||||
| `deepTrance` | 3x after 6hrs | ✅ Implemented |
|
||||
| `voidMeditation` | 5x after 8hrs | ✅ Implemented |
|
||||
| `insightHarvest` | +10% insight | ✅ Implemented |
|
||||
| `temporalMemory` | Keep spells | ✅ Implemented |
|
||||
| `guardianBane` | +20% vs guardians | ⚠️ Tracked but not verified |
|
||||
|
||||
---
|
||||
|
||||
## 4. Missing Implementations
|
||||
|
||||
### 4.1 Dynamic Effect Functions Not Called
|
||||
|
||||
The following functions exist in `upgrade-effects.ts` but are NOT called from `store.ts`:
|
||||
|
||||
```typescript
|
||||
// upgrade-effects.ts - EXISTS but NOT USED
|
||||
export function computeDynamicRegen(
|
||||
effects: ComputedEffects,
|
||||
baseRegen: number,
|
||||
maxMana: number,
|
||||
currentMana: number,
|
||||
incursionStrength: number
|
||||
): number { ... }
|
||||
|
||||
export function computeDynamicDamage(
|
||||
effects: ComputedEffects,
|
||||
baseDamage: number,
|
||||
floorHPPct: number,
|
||||
currentMana: number,
|
||||
maxMana: number,
|
||||
consecutiveHits: number
|
||||
): number { ... }
|
||||
```
|
||||
|
||||
**Where it should be called:**
|
||||
- `store.ts` tick() function around line 414 for regen
|
||||
- `store.ts` tick() function around line 618 for damage
|
||||
|
||||
### 4.2 Missing Combat Special Effects
|
||||
|
||||
Location: `store.ts` tick() combat section (lines 510-760)
|
||||
|
||||
Missing implementations:
|
||||
```typescript
|
||||
// BATTLE_FURY - +10% damage per consecutive hit
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BATTLE_FURY)) {
|
||||
// Need to track consecutiveHits in state
|
||||
}
|
||||
|
||||
// ARMOR_PIERCE - Ignore 10% floor defense
|
||||
// Floor defense not implemented in game
|
||||
|
||||
// COMBO_MASTER - Every 5th attack deals 3x damage
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.COMBO_MASTER)) {
|
||||
// Need to track hitCount in state
|
||||
}
|
||||
|
||||
// ADRENALINE_RUSH - Restore 5% mana on kill
|
||||
// Should be added after floorHP <= 0 check
|
||||
```
|
||||
|
||||
### 4.3 Missing Study Special Effects
|
||||
|
||||
Location: `store.ts` tick() study section (lines 440-485)
|
||||
|
||||
Missing implementations:
|
||||
```typescript
|
||||
// MENTAL_CLARITY - +10% study speed when mana > 75%
|
||||
// STUDY_RUSH - First hour is 2x speed
|
||||
// STUDY_REFUND - 25% mana back on completion
|
||||
// KNOWLEDGE_ECHO - 10% instant study chance
|
||||
// STUDY_MOMENTUM - +5% speed per consecutive hour
|
||||
```
|
||||
|
||||
### 4.4 Missing Loop/Click Effects
|
||||
|
||||
Location: `store.ts` gatherMana() and startNewLoop()
|
||||
|
||||
```typescript
|
||||
// gatherMana() - MANA_ECHO
|
||||
// 10% chance to gain double mana from clicks
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_ECHO) && Math.random() < 0.1) {
|
||||
cm *= 2;
|
||||
}
|
||||
|
||||
// startNewLoop() - EMERGENCY_RESERVE
|
||||
// Keep 10% max mana when starting new loop
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EMERGENCY_RESERVE)) {
|
||||
newState.rawMana = maxMana * 0.1;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Parallel Study Incomplete
|
||||
|
||||
`parallelStudyTarget` exists in state but the logic is not fully implemented in tick():
|
||||
- State field exists (line 203)
|
||||
- No tick processing for parallel study
|
||||
- UI may show it but actual progress not processed
|
||||
|
||||
---
|
||||
|
||||
## 5. Balance Concerns
|
||||
|
||||
### 5.1 Weak Upgrades
|
||||
|
||||
| Upgrade | Issue | Suggestion |
|
||||
|---------|-------|------------|
|
||||
| `manaThreshold` | +20% mana for -10% regen is a net negative early | Change to +30% mana for -5% regen |
|
||||
| `manaOverflow` | +25% click mana at 5 levels is only +5%/level | Increase to +10% per level |
|
||||
| `fieldRepair` | Repair system not implemented | Remove or implement repair |
|
||||
| `scrollCrafting` | Scroll system not implemented | Remove or implement scrolls |
|
||||
|
||||
### 5.2 Tier Scaling Issues
|
||||
|
||||
From `skill-evolution.ts`, tier multipliers are 10x per tier:
|
||||
- Tier 1: multiplier 1
|
||||
- Tier 2: multiplier 10
|
||||
- Tier 3: multiplier 100
|
||||
- Tier 4: multiplier 1000
|
||||
- Tier 5: multiplier 10000
|
||||
|
||||
This creates massive power jumps that may trivialize content when tiering up.
|
||||
|
||||
### 5.3 Special Effect Research Costs
|
||||
|
||||
Research skills for effects are expensive but effects may not be implemented:
|
||||
- `researchSpecialEffects` costs 500 mana + 10 hours study
|
||||
- Effects like `spellEcho10` are tracked but not applied
|
||||
- Player invests resources for non-functional upgrades
|
||||
|
||||
---
|
||||
|
||||
## 6. Critical Issues
|
||||
|
||||
### 6.1 computeDynamicRegen Not Used
|
||||
|
||||
**File:** `computed-stats.ts` lines 210-225
|
||||
|
||||
The function exists but only applies incursion penalty. It should call the more comprehensive `computeDynamicRegen` from `upgrade-effects.ts` that handles:
|
||||
- Mana Cascade
|
||||
- Mana Torrent
|
||||
- Desperate Wells
|
||||
- Steady Stream
|
||||
|
||||
### 6.2 No Consecutive Hit Tracking
|
||||
|
||||
`BATTLE_FURY` and `COMBO_MASTER` require tracking consecutive hits, but this state doesn't exist. Need:
|
||||
```typescript
|
||||
// In GameState
|
||||
consecutiveHits: number;
|
||||
totalHitsThisLoop: number;
|
||||
```
|
||||
|
||||
### 6.3 Enchantment Special Effects Not Applied
|
||||
|
||||
The `specials` Set is populated but never checked in combat for enchantment-specific effects like:
|
||||
- `lifesteal5`
|
||||
- `spellEcho10`
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommendations
|
||||
|
||||
### Priority 1 - Core Effects
|
||||
1. Call `computeDynamicRegen()` from tick() instead of inline calculation
|
||||
2. Call `computeDynamicDamage()` from combat section
|
||||
3. Implement MANA_ECHO in gatherMana()
|
||||
4. Implement EMERGENCY_RESERVE in startNewLoop()
|
||||
|
||||
### Priority 2 - Combat Effects
|
||||
1. Add `consecutiveHits` to GameState
|
||||
2. Implement BATTLE_FURY damage scaling
|
||||
3. Implement COMBO_MASTER every 5th hit
|
||||
4. Implement ADRENALINE_RUSH on kill
|
||||
|
||||
### Priority 3 - Study Effects
|
||||
1. Implement MENTAL_CLARITY conditional speed
|
||||
2. Implement STUDY_RUSH first hour bonus
|
||||
3. Implement STUDY_REFUND on completion
|
||||
4. Implement KNOWLEDGE_ECHO instant chance
|
||||
|
||||
### Priority 4 - Missing Systems
|
||||
1. Implement or remove `scrollCrafting` skill
|
||||
2. Implement or remove `fieldRepair` skill
|
||||
3. Complete parallel study tick processing
|
||||
4. Implement floor defense for ARMOR_PIERCE
|
||||
|
||||
---
|
||||
|
||||
## 8. Files Affected
|
||||
|
||||
| File | Changes Needed |
|
||||
|------|----------------|
|
||||
| `src/lib/game/store.ts` | Call dynamic effect functions, implement specials |
|
||||
| `src/lib/game/computed-stats.ts` | Integrate with upgrade-effects dynamic functions |
|
||||
| `src/lib/game/types.ts` | Add consecutiveHits to GameState |
|
||||
| `src/lib/game/skill-evolution.ts` | Consider removing unimplementable upgrades |
|
||||
|
||||
---
|
||||
|
||||
**End of Audit Report**
|
||||
510
GAME_SYSTEMS_ANALYSIS.md
Executable file
510
GAME_SYSTEMS_ANALYSIS.md
Executable file
@@ -0,0 +1,510 @@
|
||||
# Mana Loop - Game Systems Analysis Report
|
||||
|
||||
**Generated:** Task ID 24
|
||||
**Purpose:** Comprehensive review of all game systems, their completeness, and "feel"
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Mana Loop is an incremental/idle game with a time-loop mechanic, spellcasting combat, equipment enchanting, and attunement-based progression. The game has solid core mechanics but several systems feel incomplete or disconnected from the main gameplay loop.
|
||||
|
||||
**Overall Assessment:** ⚠️ **Needs Polish** - Core systems work but lack depth and integration
|
||||
|
||||
---
|
||||
|
||||
## System-by-System Analysis
|
||||
|
||||
### 1. 🔮 Core Mana System
|
||||
|
||||
**Status:** ✅ **Complete & Functional**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Mana Regeneration | ⭐⭐⭐⭐⭐ | Well-implemented with upgrades affecting it |
|
||||
| Mana Cap | ⭐⭐⭐⭐⭐ | Clear scaling through skills |
|
||||
| Click Gathering | ⭐⭐⭐⭐ | Works but feels less important late-game |
|
||||
| Mana Types | ⭐⭐⭐⭐ | Good variety (18 types) |
|
||||
| Compound Mana | ⭐⭐⭐⭐ | Auto-unlocks when components available |
|
||||
|
||||
**What Works Well:**
|
||||
- Clear progression: raw mana → elemental mana → compound mana
|
||||
- Attunements provide passive conversion
|
||||
- Incursion mechanic adds urgency late-loop
|
||||
|
||||
**What Feels Lacking:**
|
||||
- Limited use cases for many mana types
|
||||
- Compound mana types unlock automatically but feel disconnected from gameplay
|
||||
- No meaningful choices in which mana to generate/prioritize
|
||||
- Exotic elements (void, stellar, crystal) are very difficult to unlock
|
||||
|
||||
**Suggestions:**
|
||||
1. Add spells that specifically use compound/exotic elements
|
||||
2. Allow players to choose which elements to generate from attunements
|
||||
3. Add "mana conversion" buildings/upgrades that transform elements
|
||||
|
||||
---
|
||||
|
||||
### 2. ⚔️ Combat/Spire System
|
||||
|
||||
**Status:** ⚠️ **Partially Complete**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Floor Scaling | ⭐⭐⭐⭐⭐ | Good HP progression |
|
||||
| Spell Casting | ⭐⭐⭐⭐ | Cast speed system works well |
|
||||
| Elemental Weakness | ⭐⭐⭐⭐ | Opposing elements deal bonus damage |
|
||||
| Guardian Fights | ⭐⭐⭐⭐ | Unique perks add flavor |
|
||||
| Pact System | ⭐⭐⭐⭐⭐ | Excellent incentive to progress |
|
||||
|
||||
**What Works Well:**
|
||||
- Guardian pacts provide permanent progression
|
||||
- Each guardian has unique perks that feel impactful
|
||||
- Descent mechanic prevents easy farming
|
||||
- Barrier system on guardians adds tactical depth
|
||||
|
||||
**What Feels Lacking:**
|
||||
- No active combat decisions - purely automatic
|
||||
- Floor HP regeneration can feel frustrating without burst damage
|
||||
- Limited spell selection (only from equipment)
|
||||
- No enemy variety beyond floors/guardians
|
||||
- Combo system exists in types but isn't actually used
|
||||
|
||||
**Critical Gap - Combo System:**
|
||||
```typescript
|
||||
// From types.ts - combo exists but isn't used
|
||||
combo: {
|
||||
count: number;
|
||||
maxCombo: number;
|
||||
multiplier: number;
|
||||
elementChain: string[];
|
||||
decayTimer: number;
|
||||
}
|
||||
```
|
||||
The combo state is tracked but never affects gameplay. This is a dead system.
|
||||
|
||||
**Suggestions:**
|
||||
1. Implement combo multiplier affecting damage
|
||||
2. Add enemy types with different weaknesses
|
||||
3. Allow manual spell selection mid-combat
|
||||
4. Add tactical choices (focus fire, defensive casting, etc.)
|
||||
|
||||
---
|
||||
|
||||
### 3. ✨ Enchanting System (Enchanter Attunement)
|
||||
|
||||
**Status:** ✅ **Complete & Well-Designed**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Design Stage | ⭐⭐⭐⭐⭐ | Clear, intuitive UI |
|
||||
| Prepare Stage | ⭐⭐⭐⭐ | Good time investment |
|
||||
| Apply Stage | ⭐⭐⭐⭐ | Mana sink feels appropriate |
|
||||
| Effect Variety | ⭐⭐⭐⭐ | Good selection of effects |
|
||||
| Spell Granting | ⭐⭐⭐⭐⭐ | Primary way to get spells |
|
||||
|
||||
**What Works Well:**
|
||||
- 3-stage process (Design → Prepare → Apply) feels meaningful
|
||||
- Effect research system provides clear progression
|
||||
- Spells come from equipment - creates itemization
|
||||
- Disenchanting recovers some mana
|
||||
|
||||
**What Feels Lacking:**
|
||||
- Effect capacity limits can feel arbitrary
|
||||
- No way to preview enchantment before committing
|
||||
- No rare/special enchantments
|
||||
- Enchantment effects feel same-y (mostly +stats)
|
||||
|
||||
**Suggestions:**
|
||||
1. Add "rare" effect drops from guardians
|
||||
2. Allow effect combining/stacking visually
|
||||
3. Add visual flair to enchanted items
|
||||
4. Create set bonuses for themed enchantments
|
||||
|
||||
---
|
||||
|
||||
### 4. 💜 Invoker/Pact System
|
||||
|
||||
**Status:** ⚠️ **Conceptually Complete, Implementation Lacking**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Pact Signing | ⭐⭐⭐⭐ | Time investment, meaningful choice |
|
||||
| Guardian Perks | ⭐⭐⭐⭐⭐ | Unique and impactful |
|
||||
| Pact Multipliers | ⭐⭐⭐⭐ | Clear progression |
|
||||
| Invoker Skills | ⭐⭐⭐ | Skills exist but category is sparse |
|
||||
|
||||
**What Works Well:**
|
||||
- 10 unique guardians with distinct perks
|
||||
- Pact multiplier system rewards guardian hunting
|
||||
- Each pact feels like a real achievement
|
||||
|
||||
**What Feels Lacking:**
|
||||
- **No Invocation category spells/skills defined**
|
||||
- Invoker attunement has no primary mana type
|
||||
- Limited invoker-specific progression
|
||||
- Once you sign a pact, interaction ends
|
||||
|
||||
**Critical Gap - Invocation Skills:**
|
||||
```typescript
|
||||
// From SKILL_CATEGORIES
|
||||
{ id: 'invocation', name: 'Invocation', icon: '💜', attunement: 'invoker' },
|
||||
{ id: 'pact', name: 'Pact Mastery', icon: '🤝', attunement: 'invoker' },
|
||||
```
|
||||
|
||||
Looking at SKILLS_DEF, there are **NO skills** in the 'invocation' or 'pact' categories! The attunement promises these categories but delivers nothing.
|
||||
|
||||
**Suggestions:**
|
||||
1. Add Invocation skills:
|
||||
- Spirit Call (summon guardian echo)
|
||||
- Elemental Channeling (boost pact element)
|
||||
- Guardian's Boon (enhance perks)
|
||||
2. Add Pact skills:
|
||||
- Pact Binding (reduce signing time)
|
||||
- Soul Link (gain mana from guardian defeats)
|
||||
- Pact Synergy (combine perk effects)
|
||||
3. Allow upgrading existing pacts
|
||||
|
||||
---
|
||||
|
||||
### 5. ⚒️ Fabricator/Golemancy System
|
||||
|
||||
**Status:** ❌ **NOT IMPLEMENTED**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Golem Defs | ❌ | GOLEM_DEFS does not exist |
|
||||
| Golem Summoning | ❌ | No summoning logic |
|
||||
| Golem Combat | ❌ | Golems don't fight |
|
||||
| Crafting Skills | ⚠️ | Only in constants, not evolved |
|
||||
|
||||
**Critical Gap:**
|
||||
```typescript
|
||||
// From types.ts - these exist
|
||||
export interface GolemDef { ... }
|
||||
export interface ActiveGolem { ... }
|
||||
|
||||
// In GameState
|
||||
activeGolems: ActiveGolem[];
|
||||
unlockedGolemTypes: string[];
|
||||
golemSummoningProgress: Record<string, number>;
|
||||
```
|
||||
|
||||
But GOLEM_DEFS is referenced nowhere. The entire golemancy system is **stub code**.
|
||||
|
||||
**What Should Exist:**
|
||||
1. GOLEM_DEFS with 5-10 golem types
|
||||
2. Golem summoning logic (earth mana cost)
|
||||
3. Golem combat integration (they fight alongside player)
|
||||
4. Golem variants (earth + fire = magma golem)
|
||||
5. Golem equipment/crystals for customization
|
||||
|
||||
**Suggestions:**
|
||||
1. Implement basic earth golem first
|
||||
2. Add golem as "pet" that attacks automatically
|
||||
3. Golems should have limited duration (HP-based)
|
||||
4. Crystals can enhance golem stats
|
||||
|
||||
---
|
||||
|
||||
### 6. 📚 Skill System
|
||||
|
||||
**Status:** ⚠️ **Inconsistent**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Skill Categories | ⭐⭐⭐⭐ | Good organization |
|
||||
| Study System | ⭐⭐⭐⭐⭐ | Clear time investment |
|
||||
| Evolution Paths | ⭐⭐⭐⭐⭐ | 5 tiers with choices |
|
||||
| Upgrade Choices | ⭐⭐⭐⭐ | Meaningful decisions |
|
||||
|
||||
**What Works Well:**
|
||||
- 4 upgrade choices per milestone (2 selected max)
|
||||
- Tier progression multiplies effects
|
||||
- Study time creates opportunity cost
|
||||
|
||||
**What Feels Lacking:**
|
||||
- Many skills have no evolution path
|
||||
- 'craft' category is legacy/unclear
|
||||
- 'effectResearch' is scattered
|
||||
- Some skills do nothing (scrollCrafting, fieldRepair)
|
||||
|
||||
**Dead Skills:**
|
||||
```typescript
|
||||
// In SKILLS_DEF but not implemented
|
||||
scrollCrafting: { ... desc: "Create scrolls..." }, // No scroll system
|
||||
fieldRepair: { ... desc: "+15% repair efficiency" }, // No repair system
|
||||
```
|
||||
|
||||
**Suggestions:**
|
||||
1. Remove or implement scrollCrafting/fieldRepair
|
||||
2. Add evolution paths to all skills
|
||||
3. Consolidate effectResearch into clearer tree
|
||||
4. Add skill synergies (combining skills = bonus)
|
||||
|
||||
---
|
||||
|
||||
### 7. 🎯 Attunement System
|
||||
|
||||
**Status:** ⚠️ **Good Concept, Incomplete Execution**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Concept | ⭐⭐⭐⭐⭐ | Class-like specialization |
|
||||
| Enchanter | ⭐⭐⭐⭐⭐ | Fully implemented |
|
||||
| Invoker | ⭐⭐ | Missing skills |
|
||||
| Fabricator | ⭐ | Missing golemancy |
|
||||
| Leveling | ⭐⭐⭐⭐ | Good XP scaling |
|
||||
|
||||
**What Works Well:**
|
||||
- Enchanter attunement is complete and functional
|
||||
- Attunement XP through gameplay feels natural
|
||||
- Level-scaled conversion rates
|
||||
|
||||
**What Feels Lacking:**
|
||||
- Invoker and Fabricator unlock conditions unclear
|
||||
- Invoker has no Invocation/Pact skills
|
||||
- Fabricator has no golemancy implementation
|
||||
- Only 3 attunements, no late-game options
|
||||
|
||||
**Unlock Mystery:**
|
||||
```typescript
|
||||
// From attunements.ts
|
||||
invoker: {
|
||||
unlockCondition: 'Defeat your first guardian and choose the path of the Invoker',
|
||||
// But no code checks for this condition
|
||||
}
|
||||
```
|
||||
|
||||
**Suggestions:**
|
||||
1. Add clear unlock triggers in code
|
||||
2. Implement missing skill categories
|
||||
3. Add 4th attunement for late-game (Void Walker?)
|
||||
4. Create attunement-specific achievements
|
||||
|
||||
---
|
||||
|
||||
### 8. 🏆 Achievement System
|
||||
|
||||
**Status:** ✅ **Defined But Passive**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Definitions | ⭐⭐⭐⭐ | Good variety |
|
||||
| Progress Tracking | ⭐⭐⭐ | State exists |
|
||||
| Rewards | ⭐⭐ | Mostly insight |
|
||||
|
||||
**What Works Well:**
|
||||
- Categories organized (mana, combat, progression)
|
||||
- Progress tracked in state
|
||||
|
||||
**What Feels Lacking:**
|
||||
- Achievements don't unlock anything unique
|
||||
- No visual display of achievements
|
||||
- Rewards are passive (insight)
|
||||
- No hidden/challenge achievements
|
||||
|
||||
**Suggestions:**
|
||||
1. Add achievement-locked cosmetics/titles
|
||||
2. Create achievement showcase UI
|
||||
3. Add challenge achievements (speedrun, no-upgrade, etc.)
|
||||
4. Unlock effects through achievements
|
||||
|
||||
---
|
||||
|
||||
### 9. 📦 Equipment System
|
||||
|
||||
**Status:** ✅ **Complete**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Equipment Types | ⭐⭐⭐⭐ | 8 slots, 40+ types |
|
||||
| Capacity System | ⭐⭐⭐⭐⭐ | Clear limits |
|
||||
| Rarity | ⭐⭐⭐ | Exists but cosmetic |
|
||||
|
||||
**What Works Well:**
|
||||
- 8 equipment slots provide customization
|
||||
- Capacity system limits power creep
|
||||
- Equipment grants spells
|
||||
|
||||
**What Feels Lacking:**
|
||||
- Equipment only comes from starting gear
|
||||
- No way to craft equipment (except from blueprints)
|
||||
- Rarity doesn't affect much
|
||||
- No equipment drops from combat
|
||||
|
||||
**Critical Gap - Equipment Acquisition:**
|
||||
Players start with:
|
||||
- Basic Staff (Mana Bolt)
|
||||
- Civilian Shirt
|
||||
- Civilian Shoes
|
||||
|
||||
After that, the ONLY way to get equipment is:
|
||||
1. Blueprint drops from floors (rare)
|
||||
2. Craft from blueprint (expensive)
|
||||
|
||||
There's no consistent equipment progression!
|
||||
|
||||
**Suggestions:**
|
||||
1. Add equipment drops from floors
|
||||
2. Create more crafting recipes
|
||||
3. Add equipment merchant/shop
|
||||
4. Allow equipment upgrading
|
||||
|
||||
---
|
||||
|
||||
### 10. 🔁 Prestige/Loop System
|
||||
|
||||
**Status:** ✅ **Complete**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Loop Reset | ⭐⭐⭐⭐⭐ | Clear, saves insight |
|
||||
| Prestige Upgrades | ⭐⭐⭐⭐ | Good variety |
|
||||
| Memory System | ⭐⭐⭐ | Keeps some progress |
|
||||
| Victory Condition | ⭐⭐⭐⭐ | Defeat floor 100 guardian |
|
||||
|
||||
**What Works Well:**
|
||||
- 30-day time limit creates urgency
|
||||
- Insight economy for permanent upgrades
|
||||
- Memory slots for keeping spells
|
||||
- Clear victory condition
|
||||
|
||||
**What Feels Lacking:**
|
||||
- No insight milestones/unlocks
|
||||
- Memory system is shallow (just spell slots)
|
||||
- No "loop challenges" or modifiers
|
||||
- Limited replayability after first victory
|
||||
|
||||
**Suggestions:**
|
||||
1. Add loop modifiers (harder floors, better rewards)
|
||||
2. Insight milestones unlock attunements
|
||||
3. Loop-specific achievements
|
||||
4. New Game+ mode with modifiers
|
||||
|
||||
---
|
||||
|
||||
### 11. 🗓️ Time/Incursion System
|
||||
|
||||
**Status:** ✅ **Complete**
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Day/Hour Cycle | ⭐⭐⭐⭐⭐ | Clear progression |
|
||||
| Incursion Mechanic | ⭐⭐⭐⭐ | Adds late-game pressure |
|
||||
| Time Actions | ⭐⭐⭐ | Study, craft, prepare |
|
||||
|
||||
**What Works Well:**
|
||||
- 30 days = one loop
|
||||
- Incursion starts day 20, scales to 95% penalty
|
||||
- Actions have clear time costs
|
||||
|
||||
**What Feels Lacking:**
|
||||
- No time manipulation (beyond debug)
|
||||
- No day/night effects on gameplay
|
||||
- Incursion is purely negative, no strategy around it
|
||||
|
||||
**Suggestions:**
|
||||
1. Add time manipulation skills (slow incursion)
|
||||
2. Night bonuses (different for guardians)
|
||||
3. Incursion-specific rewards (void mana?)
|
||||
|
||||
---
|
||||
|
||||
## Missing Systems Summary
|
||||
|
||||
### High Priority (Break Promises)
|
||||
|
||||
| System | Promised By | Status |
|
||||
|--------|-------------|--------|
|
||||
| Golemancy | Fabricator attunement | ❌ Not implemented |
|
||||
| Invocation Skills | Invoker attunement | ❌ No skills defined |
|
||||
| Pact Skills | Invoker attunement | ❌ No skills defined |
|
||||
| Combo System | ComboState in types | ❌ State exists, unused |
|
||||
| Scroll Crafting | scrollCrafting skill | ❌ No scroll system |
|
||||
|
||||
### Medium Priority (Incomplete)
|
||||
|
||||
| System | Issue |
|
||||
|--------|-------|
|
||||
| Fabricator Unlocks | Unlock condition not coded |
|
||||
| Invoker Unlocks | Unlock condition not coded |
|
||||
| Equipment Progression | Only starting gear + rare blueprints |
|
||||
| Evolution Paths | Not all skills have 5 tiers |
|
||||
|
||||
### Low Priority (Polish)
|
||||
|
||||
| System | Issue |
|
||||
|--------|-------|
|
||||
| Field Repair | Repair system doesn't exist |
|
||||
| Guardian Variants | Not implemented |
|
||||
| Achievement Rewards | Passive only |
|
||||
| Enemy Variety | Only floors/guardians |
|
||||
|
||||
---
|
||||
|
||||
## "Feel" Analysis
|
||||
|
||||
### What Feels Good
|
||||
|
||||
1. **Guardian Pacts** - Defeating a guardian and signing a pact feels like a major achievement
|
||||
2. **Enchanting Process** - 3-stage system feels involved and meaningful
|
||||
3. **Cast Speed System** - Different spells feel different to use
|
||||
4. **Skill Evolution** - Choosing upgrades at milestones gives agency
|
||||
5. **Compound Mana** - Auto-unlocking elements through gameplay
|
||||
|
||||
### What Feels Bad
|
||||
|
||||
1. **Helplessness** - Combat is 100% automatic with no player input
|
||||
2. **Dead Ends** - Attunements unlock with no skills to use
|
||||
3. **Empty Promises** - Golemancy is mentioned everywhere but doesn't exist
|
||||
4. **Grind Walls** - Exotic elements require absurd amounts of base elements
|
||||
5. **Useless Skills** - scrollCrafting, fieldRepair do nothing
|
||||
|
||||
### What Feels Confusing
|
||||
|
||||
1. **Attunement Unlocks** - How do I get Invoker/Fabricator?
|
||||
2. **Equipment Progression** - Where do I get better gear?
|
||||
3. **Exotic Elements** - How do void/stellar/crystal work?
|
||||
4. **Combo System** - UI mentions it but it does nothing
|
||||
5. **Incursion** - Is there anything I can do about it?
|
||||
|
||||
---
|
||||
|
||||
## Recommended Priorities
|
||||
|
||||
### Phase 1: Fix Broken Promises (1-2 weeks)
|
||||
1. Implement basic golemancy (1 golem type, auto-attacks)
|
||||
2. Add Invocation/Pact skill categories with 3-4 skills each
|
||||
3. Add attunement unlock conditions in code
|
||||
4. Remove or implement scrollCrafting/fieldRepair
|
||||
|
||||
### Phase 2: Fill Content Gaps (2-3 weeks)
|
||||
1. Add equipment drops from floors
|
||||
2. Implement combo system for damage bonuses
|
||||
3. Add more spells using compound/exotic elements
|
||||
4. Create evolution paths for all skills
|
||||
|
||||
### Phase 3: Polish & Depth (2-3 weeks)
|
||||
1. Add tactical combat options
|
||||
2. Create achievement showcase
|
||||
3. Add loop modifiers/challenges
|
||||
4. Implement equipment upgrading
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
Mana Loop has a strong foundation with unique mechanics (attunements, enchanting, pacts) that differentiate it from typical incremental games. However, several systems are incomplete or disconnected, creating confusion and limiting engagement.
|
||||
|
||||
**The biggest issues are:**
|
||||
1. Golemancy is completely missing despite being promised
|
||||
2. Invoker attunement has no skills
|
||||
3. Combat has no player agency
|
||||
4. Equipment progression is broken
|
||||
|
||||
**Focus on completing existing systems before adding new ones.**
|
||||
|
||||
---
|
||||
|
||||
*End of Analysis Report*
|
||||
118
REFACTORING_PLAN.md
Executable file
118
REFACTORING_PLAN.md
Executable file
@@ -0,0 +1,118 @@
|
||||
# Mana Loop - Component Refactoring Plan
|
||||
|
||||
## Current State
|
||||
The main `page.tsx` file is ~2000+ lines and contains all game logic and rendering in a single component. This makes it:
|
||||
- Hard to maintain
|
||||
- Hard to test individual features
|
||||
- Prone to performance issues (entire component re-renders on any state change)
|
||||
- Difficult for multiple developers to work on
|
||||
|
||||
## Proposed Component Structure
|
||||
|
||||
### Directory: `/src/components/game/`
|
||||
|
||||
#### Layout Components
|
||||
```
|
||||
GameLayout.tsx - Main layout wrapper with header/footer
|
||||
Calendar.tsx - Day calendar display in header
|
||||
ResourceBar.tsx - Day/time/insight display in header
|
||||
```
|
||||
|
||||
#### Sidebar Components
|
||||
```
|
||||
ManaSidebar.tsx - Left sidebar container
|
||||
ManaDisplay.tsx - Current mana, max mana, regen display
|
||||
GatherButton.tsx - Click-to-gather button with hold functionality
|
||||
ActionButtons.tsx - Meditate/Climb/Study/Convert buttons
|
||||
FloorStatus.tsx - Current floor display with HP bar
|
||||
```
|
||||
|
||||
#### Tab Content Components
|
||||
```
|
||||
SpireTab.tsx - Main spire/combat tab
|
||||
├── CurrentFloor.tsx - Floor info, guardian, HP bar
|
||||
├── ActiveSpell.tsx - Active spell details and cast progress
|
||||
├── StudyProgress.tsx - Current study progress display
|
||||
├── KnownSpells.tsx - Grid of learned spells
|
||||
└── ActivityLog.tsx - Game event log
|
||||
|
||||
SpellsTab.tsx - Spell learning tab
|
||||
├── SpellTier.tsx - Spells grouped by tier
|
||||
└── SpellCard.tsx - Individual spell card
|
||||
|
||||
LabTab.tsx - Elemental mana lab tab
|
||||
├── ElementalMana.tsx - Grid of elemental mana
|
||||
├── ElementConversion.tsx - Convert raw to element
|
||||
├── UnlockElements.tsx - Unlock new elements
|
||||
└── CompositeCrafting.tsx - Craft composite elements
|
||||
|
||||
SkillsTab.tsx - Skill learning tab
|
||||
├── SkillCategory.tsx - Skills grouped by category
|
||||
└── SkillCard.tsx - Individual skill card
|
||||
|
||||
GrimoireTab.tsx - Prestige upgrades tab
|
||||
└── PrestigeCard.tsx - Individual prestige upgrade
|
||||
|
||||
StatsTab.tsx - Detailed stats breakdown
|
||||
├── ManaStats.tsx - Mana-related stats
|
||||
├── CombatStats.tsx - Combat-related stats
|
||||
├── StudyStats.tsx - Study-related stats
|
||||
└── ActiveUpgrades.tsx - List of active skill upgrades
|
||||
```
|
||||
|
||||
#### Shared Components
|
||||
```
|
||||
SpellCost.tsx - Formatted spell cost display
|
||||
DamageBreakdown.tsx - Damage calculation breakdown
|
||||
BuffIndicator.tsx - Active buff/debuff display
|
||||
UpgradeDialog.tsx - Skill upgrade selection dialog
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Extract Simple Components (Low Risk)
|
||||
- [ ] Calendar.tsx
|
||||
- [ ] ActivityLog.tsx
|
||||
- [ ] SpellCost.tsx
|
||||
- [ ] KnownSpells.tsx
|
||||
|
||||
### Phase 2: Extract Tab Components (Medium Risk)
|
||||
- [ ] SpireTab.tsx
|
||||
- [ ] SpellsTab.tsx
|
||||
- [ ] LabTab.tsx
|
||||
- [ ] SkillsTab.tsx
|
||||
- [ ] GrimoireTab.tsx
|
||||
- [ ] StatsTab.tsx
|
||||
|
||||
### Phase 3: Extract Sidebar Components (Medium Risk)
|
||||
- [ ] ManaDisplay.tsx
|
||||
- [ ] GatherButton.tsx
|
||||
- [ ] ActionButtons.tsx
|
||||
- [ ] FloorStatus.tsx
|
||||
|
||||
### Phase 4: Create Shared Hooks
|
||||
- [ ] useGameState.ts - Custom hook for game state access
|
||||
- [ ] useComputedStats.ts - Memoized computed stats
|
||||
- [ ] useSpellCast.ts - Spell casting logic
|
||||
- [ ] useStudy.ts - Skill/spell study logic
|
||||
|
||||
## Benefits
|
||||
1. **Maintainability**: Each component has a single responsibility
|
||||
2. **Performance**: React can skip re-rendering unchanged components
|
||||
3. **Testability**: Individual components can be tested in isolation
|
||||
4. **Collaboration**: Multiple developers can work on different components
|
||||
5. **Code Reuse**: Shared components can be used across tabs
|
||||
|
||||
## Migration Strategy
|
||||
1. Create new component file
|
||||
2. Move relevant code from page.tsx
|
||||
3. Pass required props or use store directly
|
||||
4. Add TypeScript types
|
||||
5. Test functionality
|
||||
6. Remove code from page.tsx
|
||||
7. Repeat for each component
|
||||
|
||||
## Estimated Lines of Code Reduction
|
||||
- Current page.tsx: ~2000 lines
|
||||
- After refactoring: ~200-300 lines (main layout only)
|
||||
- Total component files: ~30-40 files averaging 50-100 lines each
|
||||
124
crafting-implementation-summary.md
Executable file
124
crafting-implementation-summary.md
Executable file
@@ -0,0 +1,124 @@
|
||||
# Crafting & Equipment System Implementation Summary
|
||||
|
||||
## Overview
|
||||
Replaced the combat skills system with a comprehensive equipment and enchantment system. Players now gain combat abilities through enchanted equipment rather than skills.
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
### 1. Equipment System (✅ Complete)
|
||||
- **File**: `/src/lib/game/data/equipment.ts`
|
||||
- 8 equipment slots: mainHand, offHand, head, body, hands, feet, accessory1, accessory2
|
||||
- 20+ equipment types across categories: caster, shield, catalyst, head, body, hands, feet, accessory
|
||||
- Capacity system: Each equipment has base capacity for enchantments
|
||||
- Starting equipment: Basic Staff (with Mana Bolt), Civilian Shirt/Gloves/Shoes
|
||||
|
||||
### 2. Enchantment Effects Catalogue (✅ Complete)
|
||||
- **File**: `/src/lib/game/data/enchantment-effects.ts`
|
||||
- 100+ enchantment effects across 7 categories:
|
||||
- **Spell** (40+): Grant ability to cast specific spells
|
||||
- **Mana** (20+): Capacity, regen, efficiency bonuses
|
||||
- **Combat** (15+): Damage, crit, attack speed
|
||||
- **Elemental** (10+): Elemental damage bonuses
|
||||
- **Defense** (4): Damage reduction, mana shields
|
||||
- **Utility** (8): Study speed, meditation, insight
|
||||
- **Special** (12): Unique effects like echo, lifesteal, executioner
|
||||
|
||||
### 3. Crafting Store Slice (✅ Complete)
|
||||
- **File**: `/src/lib/game/store/craftingSlice.ts`
|
||||
- Equipment instance management
|
||||
- Enchantment design workflow
|
||||
- Preparation progress tracking
|
||||
- Application progress with mana consumption
|
||||
- Helper functions for computing equipment effects
|
||||
|
||||
### 4. CraftingTab UI Component (✅ Complete)
|
||||
- **File**: `/src/components/game/tabs/CraftingTab.tsx`
|
||||
- 4 sub-tabs: Equipment, Design, Enchant, Craft
|
||||
- Equipment slot visualization with rarity colors
|
||||
- Effect catalogue with capacity preview
|
||||
- Design creation workflow
|
||||
|
||||
### 5. Type Definitions (✅ Complete)
|
||||
- **File**: `/src/lib/game/types.ts`
|
||||
- EquipmentInstance, AppliedEnchantment, EnchantmentDesign interfaces
|
||||
- Crafting progress states (DesignProgress, PreparationProgress, ApplicationProgress)
|
||||
|
||||
### 6. Skill Evolution Cleanup (✅ Complete)
|
||||
- **File**: `/src/lib/game/skill-evolution.ts`
|
||||
- Removed combatTrain evolution path (orphaned - skill not in SKILLS_DEF)
|
||||
- Removed COMBAT_TRAIN upgrade definitions
|
||||
|
||||
### 7. Bug Fixes (✅ Complete)
|
||||
- Fixed CraftingTab icon imports (Ring → Circle, HandMetal → Hand)
|
||||
|
||||
## Remaining Tasks (Future Work)
|
||||
|
||||
### 1. Equipment-Granted Spells in Combat (Medium Priority)
|
||||
- The `equipmentSpellStates` array exists but isn't fully utilized
|
||||
- Combat tick should check for equipment-granted spells
|
||||
- Multi-spell casting from different equipment pieces
|
||||
- Individual spell cooldown tracking
|
||||
|
||||
### 2. SpellsTab Integration (Low Priority)
|
||||
- Add section showing equipment-granted spells
|
||||
- Indicate which equipment piece grants each spell
|
||||
- Distinguish between learned vs equipment-granted spells
|
||||
|
||||
### 3. Evolution Paths for Crafting Skills (Low Priority)
|
||||
- Add evolution paths for existing crafting skills:
|
||||
- `effCrafting` → Efficient Enchanter
|
||||
- `durableConstruct` → Master Artisan
|
||||
- `fieldRepair` → Field Engineer
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
Equipment System Flow:
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Equipment Instance │
|
||||
│ - typeId: Reference to EquipmentTypeDef │
|
||||
│ - enchantments: AppliedEnchantment[] │
|
||||
│ - usedCapacity / totalCapacity │
|
||||
│ - rarity: Based on total capacity used │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Enchantment Design │
|
||||
│ 1. Select effects from catalogue │
|
||||
│ 2. Check capacity constraints │
|
||||
│ 3. Pay design time cost │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Preparation │
|
||||
│ - Clear existing enchantments │
|
||||
│ - Pay mana cost (equipment capacity × 5) │
|
||||
│ - Wait preparation time (capacity / 5 hours) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Application │
|
||||
│ - Continuous mana drain during application │
|
||||
│ - Time based on total effect capacity │
|
||||
│ - Can be paused if mana runs low │
|
||||
│ - On completion: Apply enchantments to equipment │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Capacity System**: Equipment has capacity that limits total enchantment power. This creates meaningful choices about which effects to apply.
|
||||
|
||||
2. **Effect Categories**: Each equipment type only accepts certain effect categories, creating specialization:
|
||||
- Staves: All spell types + mana + combat + elemental
|
||||
- Shields: Defense + utility only
|
||||
- Accessories: Small bonuses, no spells
|
||||
|
||||
3. **Stacking Effects**: Many effects can be stacked multiple times for increased power, with increasing capacity costs.
|
||||
|
||||
4. **Rarity from Power**: Rarity is automatically calculated from total capacity used, making powerful enchantments visually distinctive.
|
||||
|
||||
5. **Time-Gated Application**: Enchantment application takes real time with continuous mana drain, preventing instant power spikes.
|
||||
31
dev-log.notes
Executable file
31
dev-log.notes
Executable file
@@ -0,0 +1,31 @@
|
||||
|
||||
## Crafting and Equipment System - Implementation Notes
|
||||
|
||||
### Date: $(date)
|
||||
|
||||
### Completed Tasks:
|
||||
1. ✅ Fixed CraftingTab icon imports (Ring → Circle, HandMetal → Hand)
|
||||
2. ✅ Removed combat skill evolution paths from skill-evolution.ts:
|
||||
- Removed COMBAT_TRAIN_TIER1_UPGRADES_L5 upgrade definitions
|
||||
- Removed COMBAT_TRAIN_TIER1_UPGRADES_L10 upgrade definitions
|
||||
- Removed combatTrain evolution path (all 5 tiers)
|
||||
|
||||
### System Architecture:
|
||||
- **Equipment System**: 8 slots (mainHand, offHand, head, body, hands, feet, accessory1, accessory2)
|
||||
- **Equipment Types**: Different categories (caster, shield, catalyst, head, body, hands, feet, accessory)
|
||||
- **Capacity System**: Each equipment has base capacity, effects cost capacity
|
||||
- **Enchantment Effects**: 7 categories (spell, mana, combat, elemental, defense, utility, special)
|
||||
- **Starting Equipment**: Basic Staff (with Mana Bolt), Civilian Shirt/Gloves/Shoes
|
||||
|
||||
### Key Files:
|
||||
- `/src/lib/game/data/equipment.ts` - Equipment types and capacity system
|
||||
- `/src/lib/game/data/enchantment-effects.ts` - Enchantment effect catalogue
|
||||
- `/src/lib/game/store/craftingSlice.ts` - Crafting store slice
|
||||
- `/src/components/game/tabs/CraftingTab.tsx` - Crafting UI component
|
||||
- `/src/lib/game/skill-evolution.ts` - Skill evolution paths (combat skills removed)
|
||||
|
||||
### Remaining Tasks:
|
||||
- Update SpellsTab to work with equipment-granted spells
|
||||
- Add evolution paths for crafting skills (optional)
|
||||
- Add tests for new crafting system
|
||||
|
||||
323
docs/skills.md
Normal file
323
docs/skills.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Mana Loop - Skill System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The skill system in Mana Loop allows players to specialize their character through a deep progression system. Skills are organized by attunement, with each attunement providing access to specific skill categories.
|
||||
|
||||
## Core Mechanics
|
||||
|
||||
### Skill Levels
|
||||
|
||||
- Most skills level from **1 to 10**
|
||||
- Some skills cap at **level 5** (specialized skills)
|
||||
- Research skills are **level 1 only** (unlock effects)
|
||||
|
||||
### Tier Up System
|
||||
|
||||
When a skill reaches max level, it can **tier up**:
|
||||
- **Tier 2, Level 1 = Tier 1, Level 10** in power
|
||||
- Each tier multiplies the skill's base effect by 10x
|
||||
- Maximum of 5 tiers per skill
|
||||
- Tiering up requires attunement level prerequisites
|
||||
|
||||
### Milestone Upgrades (The Perk Tree)
|
||||
|
||||
At **every 5 levels** (5 and 10), you choose **1 upgrade/perk** from an upgrade tree:
|
||||
|
||||
- Starts with 3 base choices per milestone
|
||||
- Each choice leads to branching paths
|
||||
- You can always choose from previously available options
|
||||
- Upgrades can be **upgraded again** at future milestones
|
||||
- Upgrades meaningfully impact how the skill works
|
||||
|
||||
#### Example: Mana Well Upgrade Tree
|
||||
|
||||
```
|
||||
Level 5 Choices:
|
||||
├── Expanded Capacity (+25% max mana)
|
||||
│ └── Level 10 upgrade: Deep Reservoir (+50% max mana)
|
||||
│ └── Future: Can sacrifice capacity for regen
|
||||
├── Natural Spring (+0.5 regen)
|
||||
│ └── Level 10 upgrade: Flowing Spring (+1 regen)
|
||||
│ └── Future: Regen scales with max mana
|
||||
└── Mana Threshold (+20% max mana, -10% regen)
|
||||
└── Level 10 upgrade: Mana Conversion (Convert capacity to click bonus)
|
||||
└── Future: Threshold mechanics
|
||||
|
||||
Level 10 Choices (additional):
|
||||
├── Mana Echo (10% chance double mana from clicks)
|
||||
├── Emergency Reserve (Keep 10% mana on loop reset)
|
||||
└── Deep Wellspring (+50% meditation efficiency)
|
||||
```
|
||||
|
||||
### Study System
|
||||
|
||||
Leveling skills requires:
|
||||
1. **Mana cost** - Paid upfront to begin study
|
||||
2. **Study time** - Hours required to complete
|
||||
3. **Active studying** - Must be in "study" action mode
|
||||
|
||||
Study costs scale with:
|
||||
- Current level (higher levels = more expensive)
|
||||
- Skill tier (higher tiers = more expensive)
|
||||
- Focused Mind skill (reduces cost)
|
||||
|
||||
### Attunement Requirements
|
||||
|
||||
Skills have attunement requirements for:
|
||||
1. **Access** - Which attunement unlocks the skill category
|
||||
2. **Tier Up** - Required attunement levels to advance tiers
|
||||
|
||||
| Requirement Type | Example |
|
||||
|-----------------|---------|
|
||||
| Single Attunement | Enchanting skills require Enchanter |
|
||||
| Multiple Attunements | Hybrid skills require both attunements leveled |
|
||||
| Any Attunement | Core skills just need any attunement active |
|
||||
| No Requirement | Basic skills always available |
|
||||
|
||||
---
|
||||
|
||||
## Skill Categories
|
||||
|
||||
### Core Skills (No Attunement Required)
|
||||
|
||||
Always available to all players.
|
||||
|
||||
#### Mana Category
|
||||
| Skill | Max Level | Effect | Description |
|
||||
|-------|-----------|--------|-------------|
|
||||
| Mana Well | 10 | +100 max mana per level | Increases your mana reservoir |
|
||||
| Mana Flow | 10 | +1 regen/hour per level | Faster mana regeneration |
|
||||
| Elemental Attunement | 10 | +50 element cap per level | Store more elemental mana |
|
||||
|
||||
#### Study Category
|
||||
| Skill | Max Level | Effect | Description |
|
||||
|-------|-----------|--------|-------------|
|
||||
| Quick Learner | 10 | +10% study speed per level | Learn faster |
|
||||
| Focused Mind | 10 | -5% study cost per level | Learn cheaper |
|
||||
| Knowledge Retention | 3 | +20% progress saved on cancel | Don't lose study progress |
|
||||
| Meditation Focus | 1 | Up to 2.5x regen after 4hr meditating | Enhanced meditation |
|
||||
|
||||
#### Research Category (Long Study Times)
|
||||
| Skill | Max Level | Effect | Description |
|
||||
|-------|-----------|--------|-------------|
|
||||
| Mana Tap | 1 | +1 mana/click | Better clicking |
|
||||
| Mana Surge | 1 | +3 mana/click | Requires Mana Tap |
|
||||
| Mana Spring | 1 | +2 mana regen | Passive bonus |
|
||||
| Deep Trance | 1 | 6hr meditation = 3x regen | Requires Meditation |
|
||||
| Void Meditation | 1 | 8hr meditation = 5x regen | Requires Deep Trance |
|
||||
|
||||
### Enchanter Skills (Requires Enchanter Attunement)
|
||||
|
||||
#### Enchanting Category
|
||||
| Skill | Max Level | Effect | Attunement Req |
|
||||
|-------|-----------|--------|----------------|
|
||||
| Enchanting | 10 | Unlocks enchantment design | Enchanter 1 |
|
||||
| Efficient Enchant | 5 | -5% capacity cost per level | Enchanter 2 |
|
||||
| Disenchanting | 3 | +20% mana recovery per level | Enchanter 1 |
|
||||
| Enchant Speed | 5 | -10% enchant time per level | Enchanter 1 |
|
||||
| Essence Refining | 5 | +10% effect power per level | Enchanter 2 |
|
||||
|
||||
#### Effect Research Category
|
||||
Research skills unlock enchantment effects. All are level 1 only and require Enchanter attunement.
|
||||
|
||||
**Tier 1 Research (Basic Spells)**
|
||||
- Mana Spell Research → Mana Strike enchantment
|
||||
- Fire Spell Research → Ember Shot, Fireball enchantments
|
||||
- Water Spell Research → Water Jet, Ice Shard enchantments
|
||||
- Air Spell Research → Gust, Wind Slash enchantments
|
||||
- Earth Spell Research → Stone Bullet, Rock Spike enchantments
|
||||
- Light Spell Research → Light Lance, Radiance enchantments
|
||||
- Dark Spell Research → Shadow Bolt, Dark Pulse enchantments
|
||||
- Death Research → Drain enchantment
|
||||
|
||||
**Tier 2 Research (Advanced Spells)** - Requires Enchanter 3
|
||||
- Advanced Fire/Water/Air/Earth Research
|
||||
- Advanced Light/Dark Research
|
||||
|
||||
**Tier 3 Research (Master Spells)** - Requires Enchanter 5
|
||||
- Master Fire/Water/Earth Research
|
||||
|
||||
**Compound Mana Research** - Requires parent element research
|
||||
- Metal Spell Research (Fire + Earth)
|
||||
- Sand Spell Research (Earth + Water)
|
||||
- Lightning Spell Research (Fire + Air)
|
||||
- Plus Advanced/Master variants
|
||||
|
||||
**Utility Research**
|
||||
- Transference Spell Research
|
||||
- Damage Effect Research
|
||||
- Mana Effect Research
|
||||
- Utility Effect Research
|
||||
|
||||
### Invoker Skills (Requires Invoker Attunement)
|
||||
|
||||
#### Pact Category
|
||||
| Skill | Max Level | Effect | Attunement Req |
|
||||
|-------|-----------|--------|----------------|
|
||||
| Pact Mastery | 5 | +10% pact bonus per level | Invoker 1 |
|
||||
| Guardian Insight | 3 | See guardian weaknesses | Invoker 2 |
|
||||
| Pact Efficiency | 3 | -10% pact ritual cost | Invoker 2 |
|
||||
|
||||
### Fabricator Skills (Requires Fabricator Attunement)
|
||||
|
||||
#### Golemancy Category
|
||||
| Skill | Max Level | Effect | Attunement Req |
|
||||
|-------|-----------|--------|----------------|
|
||||
| Golem Mastery | 5 | +10% golem damage per level | Fabricator 2 |
|
||||
| Golem Efficiency | 5 | +5% attack speed per level | Fabricator 2 |
|
||||
| Golem Longevity | 3 | +1 floor duration per level | Fabricator 3 |
|
||||
| Golem Siphon | 3 | -10% maintenance per level | Fabricator 3 |
|
||||
| Advanced Golemancy | 1 | Unlock hybrid golem recipes | Fabricator 5 |
|
||||
| Golem Resonance | 1 | +1 golem slot at max level | Fabricator 8 |
|
||||
|
||||
### Ascension Skills (Require Any Attunement Level 5+)
|
||||
|
||||
| Skill | Max Level | Effect | Requirement |
|
||||
|-------|-----------|--------|-------------|
|
||||
| Insight Harvest | 5 | +10% insight per level | Any attunement 5 |
|
||||
| Temporal Memory | 3 | Keep 1 spell per level across loops | Any attunement 5 |
|
||||
|
||||
---
|
||||
|
||||
## Tier Up Requirements
|
||||
|
||||
Each tier requires specific attunement levels:
|
||||
|
||||
### Core Skills (Mana, Study)
|
||||
| Tier | Requirement |
|
||||
|------|-------------|
|
||||
| 1→2 | Any attunement level 3 |
|
||||
| 2→3 | Any attunement level 5 |
|
||||
| 3→4 | Any attunement level 7 |
|
||||
| 4→5 | Any attunement level 10 |
|
||||
|
||||
### Enchanter Skills
|
||||
| Tier | Requirement |
|
||||
|------|-------------|
|
||||
| 1→2 | Enchanter level 3 |
|
||||
| 2→3 | Enchanter level 5 |
|
||||
| 3→4 | Enchanter level 7 |
|
||||
| 4→5 | Enchanter level 10 |
|
||||
|
||||
### Fabricator Skills (Golemancy)
|
||||
| Tier | Requirement |
|
||||
|------|-------------|
|
||||
| 1→2 | Fabricator level 3 |
|
||||
| 2→3 | Fabricator level 5 |
|
||||
| 3→4 | Fabricator level 7 |
|
||||
| 4→5 | Fabricator level 10 |
|
||||
|
||||
### Hybrid Skills (Multiple Attunements)
|
||||
| Tier | Requirement |
|
||||
|------|-------------|
|
||||
| 1→2 | Both attunements level 3 |
|
||||
| 2→3 | Both attunements level 5 |
|
||||
| etc. | Both attunements leveled together |
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Tree Design Principles
|
||||
|
||||
### Branching Paths
|
||||
Each skill's upgrade tree should:
|
||||
1. Offer meaningful choices that change playstyle
|
||||
2. Allow players to specialize or generalize
|
||||
3. Provide trade-offs (gain X, lose Y)
|
||||
4. Scale with future milestone choices
|
||||
|
||||
### Upgrade Categories
|
||||
|
||||
**Multiplier Upgrades**: Increase the base effect
|
||||
- Example: "+25% max mana" → "+50% max mana"
|
||||
|
||||
**Bonus Upgrades**: Add flat bonuses
|
||||
- Example: "+0.5 regen" → "+1 regen"
|
||||
|
||||
**Special Mechanics**: Unique behaviors
|
||||
- Example: "Mana Cascade" (+0.1 regen per 100 max mana)
|
||||
- Example: "Steady Stream" (immune to incursion regen penalty)
|
||||
|
||||
**Trade-offs**: Risk/reward
|
||||
- Example: "+20% max mana, -10% regen"
|
||||
- Example: "+50% damage when below 50% mana"
|
||||
|
||||
### Upgrade Persistence
|
||||
- **Milestone upgrades persist through tier-ups**
|
||||
- Tier 2 starts with all Tier 1's chosen upgrades
|
||||
- New tier offers new upgrade paths
|
||||
|
||||
---
|
||||
|
||||
## Banned Effects
|
||||
|
||||
The following effects are **NOT allowed** in skill upgrades:
|
||||
|
||||
| Banned Effect | Reason |
|
||||
|---------------|--------|
|
||||
| Lifesteal | Player cannot take damage |
|
||||
| Healing | Player cannot take damage |
|
||||
| Life/Blood/Wood/Mental/Force mana | Removed elements |
|
||||
| Execution effects | Instant kills bypass gameplay |
|
||||
| Instant finishing | Skip mechanics |
|
||||
| Direct spell damage bonuses | Spells only via weapons |
|
||||
|
||||
---
|
||||
|
||||
## Removed Skills
|
||||
|
||||
The following skills have been removed because the player cannot cast offensive spells directly:
|
||||
|
||||
| Removed Skill | Reason |
|
||||
|---------------|--------|
|
||||
| Combat Training | No direct spell casting |
|
||||
| Arcane Fury | Combat damage bonus |
|
||||
| Precision | Crit chance for spells |
|
||||
| Quick Cast | Spell cast speed |
|
||||
| Elemental Mastery | Elemental damage bonus |
|
||||
| Spell Echo | Spell mechanics |
|
||||
| Guardian Bane | Direct damage to guardians |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Study Cost Formula
|
||||
```
|
||||
cost = baseCost * (currentLevel + 1) * tier * costMultiplier
|
||||
```
|
||||
|
||||
### Study Time Formula
|
||||
```
|
||||
time = baseStudyTime * tier / studySpeedMultiplier
|
||||
```
|
||||
|
||||
### Tier Multiplier
|
||||
```
|
||||
tierMultiplier = 10^(tier - 1)
|
||||
```
|
||||
- Tier 1: 1x
|
||||
- Tier 2: 10x
|
||||
- Tier 3: 100x
|
||||
- Tier 4: 1000x
|
||||
- Tier 5: 10000x
|
||||
|
||||
---
|
||||
|
||||
## Example: Complete Skill Progression
|
||||
|
||||
### Mana Well Journey
|
||||
|
||||
1. **Study Mana Well** → Level 1 (+100 max mana)
|
||||
2. **Continue studying** → Level 2-4 (+400 more max mana)
|
||||
3. **Level 5 Milestone** → Choose "Expanded Capacity" (+25% max mana)
|
||||
4. **Continue studying** → Level 6-9 (+400 more max mana)
|
||||
5. **Level 10 Milestone** → Choose "Deep Reservoir" (upgrades Expanded Capacity to +50%)
|
||||
6. **Tier Up** → Mana Well becomes "Deep Reservoir" (Tier 2)
|
||||
7. **Continue at Tier 2** → Level 1-5 with 10x multiplier
|
||||
8. **New upgrades available** at Tier 2 milestones
|
||||
|
||||
Total effect at Tier 2, Level 5:
|
||||
- Base: 500 * 10 = 5000 max mana
|
||||
- +50% from upgrades: +2500 max mana
|
||||
- Total: 7500 max mana from one skill!
|
||||
1
download/README.md
Executable file
1
download/README.md
Executable file
@@ -0,0 +1 @@
|
||||
Here are all the generated files.
|
||||
196
examples/websocket/frontend.tsx
Executable file
196
examples/websocket/frontend.tsx
Executable file
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
username: string;
|
||||
content: string;
|
||||
timestamp: Date | string;
|
||||
type: 'user' | 'system';
|
||||
}
|
||||
|
||||
export default function SocketDemo() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputMessage, setInputMessage] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [isUsernameSet, setIsUsernameSet] = useState(false);
|
||||
const [socket, setSocket] = useState<any>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Connect to websocket server
|
||||
// Never use PORT in the URL, alyways use XTransformPort
|
||||
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
|
||||
const socketInstance = io('/?XTransformPort=3003', {
|
||||
transports: ['websocket', 'polling'],
|
||||
forceNew: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
setSocket(socketInstance);
|
||||
|
||||
socketInstance.on('connect', () => {
|
||||
setIsConnected(true);
|
||||
});
|
||||
|
||||
socketInstance.on('disconnect', () => {
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
socketInstance.on('message', (msg: Message) => {
|
||||
setMessages(prev => [...prev, msg]);
|
||||
});
|
||||
|
||||
socketInstance.on('user-joined', (data: { user: User; message: Message }) => {
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
setUsers(prev => {
|
||||
if (!prev.find(u => u.id === data.user.id)) {
|
||||
return [...prev, data.user];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
});
|
||||
|
||||
socketInstance.on('user-left', (data: { user: User; message: Message }) => {
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
setUsers(prev => prev.filter(u => u.id !== data.user.id));
|
||||
});
|
||||
|
||||
socketInstance.on('users-list', (data: { users: User[] }) => {
|
||||
setUsers(data.users);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socketInstance.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleJoin = () => {
|
||||
if (socket && username.trim() && isConnected) {
|
||||
socket.emit('join', { username: username.trim() });
|
||||
setIsUsernameSet(true);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
if (socket && inputMessage.trim() && username.trim()) {
|
||||
socket.emit('message', {
|
||||
content: inputMessage.trim(),
|
||||
username: username.trim()
|
||||
});
|
||||
setInputMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
WebSocket Demo
|
||||
<span className={`text-sm px-2 py-1 rounded ${isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isUsernameSet ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleJoin();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your username..."
|
||||
disabled={!isConnected}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleJoin}
|
||||
disabled={!isConnected || !username.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
Join Chat
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="h-80 w-full border rounded-md p-4">
|
||||
<div className="space-y-2">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-gray-500 text-center">No messages yet</p>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div key={msg.id} className="border-b pb-2 last:border-b-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${msg.type === 'system'
|
||||
? 'text-blue-600 italic'
|
||||
: 'text-gray-700'
|
||||
}`}>
|
||||
{msg.username}
|
||||
</p>
|
||||
<p className={`${msg.type === 'system'
|
||||
? 'text-blue-500 italic'
|
||||
: 'text-gray-900'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type a message..."
|
||||
disabled={!isConnected}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={!isConnected || !inputMessage.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
examples/websocket/server.ts
Executable file
138
examples/websocket/server.ts
Executable file
@@ -0,0 +1,138 @@
|
||||
import { createServer } from 'http'
|
||||
import { Server } from 'socket.io'
|
||||
|
||||
const httpServer = createServer()
|
||||
const io = new Server(httpServer, {
|
||||
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
|
||||
path: '/',
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
},
|
||||
pingTimeout: 60000,
|
||||
pingInterval: 25000,
|
||||
})
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
username: string
|
||||
content: string
|
||||
timestamp: Date
|
||||
type: 'user' | 'system'
|
||||
}
|
||||
|
||||
const users = new Map<string, User>()
|
||||
|
||||
const generateMessageId = () => Math.random().toString(36).substr(2, 9)
|
||||
|
||||
const createSystemMessage = (content: string): Message => ({
|
||||
id: generateMessageId(),
|
||||
username: 'System',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
type: 'system'
|
||||
})
|
||||
|
||||
const createUserMessage = (username: string, content: string): Message => ({
|
||||
id: generateMessageId(),
|
||||
username,
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
type: 'user'
|
||||
})
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`User connected: ${socket.id}`)
|
||||
|
||||
// Add test event handler
|
||||
socket.on('test', (data) => {
|
||||
console.log('Received test message:', data)
|
||||
socket.emit('test-response', {
|
||||
message: 'Server received test message',
|
||||
data: data,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('join', (data: { username: string }) => {
|
||||
const { username } = data
|
||||
|
||||
// Create user object
|
||||
const user: User = {
|
||||
id: socket.id,
|
||||
username
|
||||
}
|
||||
|
||||
// Add to user list
|
||||
users.set(socket.id, user)
|
||||
|
||||
// Send join message to all users
|
||||
const joinMessage = createSystemMessage(`${username} joined the chat room`)
|
||||
io.emit('user-joined', { user, message: joinMessage })
|
||||
|
||||
// Send current user list to new user
|
||||
const usersList = Array.from(users.values())
|
||||
socket.emit('users-list', { users: usersList })
|
||||
|
||||
console.log(`${username} joined the chat room, current online users: ${users.size}`)
|
||||
})
|
||||
|
||||
socket.on('message', (data: { content: string; username: string }) => {
|
||||
const { content, username } = data
|
||||
const user = users.get(socket.id)
|
||||
|
||||
if (user && user.username === username) {
|
||||
const message = createUserMessage(username, content)
|
||||
io.emit('message', message)
|
||||
console.log(`${username}: ${content}`)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
const user = users.get(socket.id)
|
||||
|
||||
if (user) {
|
||||
// Remove from user list
|
||||
users.delete(socket.id)
|
||||
|
||||
// Send leave message to all users
|
||||
const leaveMessage = createSystemMessage(`${user.username} left the chat room`)
|
||||
io.emit('user-left', { user: { id: socket.id, username: user.username }, message: leaveMessage })
|
||||
|
||||
console.log(`${user.username} left the chat room, current online users: ${users.size}`)
|
||||
} else {
|
||||
console.log(`User disconnected: ${socket.id}`)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error(`Socket error (${socket.id}):`, error)
|
||||
})
|
||||
})
|
||||
|
||||
const PORT = 3003
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`WebSocket server running on port ${PORT}`)
|
||||
})
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Received SIGTERM signal, shutting down server...')
|
||||
httpServer.close(() => {
|
||||
console.log('WebSocket server closed')
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Received SIGINT signal, shutting down server...')
|
||||
httpServer.close(() => {
|
||||
console.log('WebSocket server closed')
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
143
src/components/game/ComboMeter.tsx
Executable file
143
src/components/game/ComboMeter.tsx
Executable file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Zap, Flame, Sparkles } from 'lucide-react';
|
||||
import type { ComboState } from '@/lib/game/types';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
interface ComboMeterProps {
|
||||
combo: ComboState;
|
||||
isClimbing: boolean;
|
||||
}
|
||||
|
||||
export function ComboMeter({ combo, isClimbing }: ComboMeterProps) {
|
||||
const comboPercent = Math.min(100, combo.count);
|
||||
const multiplierPercent = Math.min(100, ((combo.multiplier - 1) / 2) * 100); // Max 300% = 200% bonus
|
||||
|
||||
// Combo tier names
|
||||
const getComboTier = (count: number): { name: string; color: string } => {
|
||||
if (count >= 100) return { name: 'LEGENDARY', color: 'text-amber-400' };
|
||||
if (count >= 75) return { name: 'Master', color: 'text-purple-400' };
|
||||
if (count >= 50) return { name: 'Expert', color: 'text-blue-400' };
|
||||
if (count >= 25) return { name: 'Adept', color: 'text-green-400' };
|
||||
if (count >= 10) return { name: 'Novice', color: 'text-cyan-400' };
|
||||
return { name: 'Building...', color: 'text-gray-400' };
|
||||
};
|
||||
|
||||
const tier = getComboTier(combo.count);
|
||||
const hasElementChain = combo.elementChain.length === 3 && new Set(combo.elementChain).size === 3;
|
||||
|
||||
if (!isClimbing && combo.count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
Combo Meter
|
||||
{combo.count >= 10 && (
|
||||
<Badge className={`ml-auto ${tier.color} bg-gray-800`}>
|
||||
{tier.name}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Combo Count */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Hits</span>
|
||||
<span className={`font-bold ${tier.color}`}>
|
||||
{combo.count}
|
||||
{combo.maxCombo > combo.count && (
|
||||
<span className="text-gray-500 text-xs ml-2">max: {combo.maxCombo}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={comboPercent}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Multiplier */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Multiplier</span>
|
||||
<span className="font-bold text-amber-400">
|
||||
{combo.multiplier.toFixed(2)}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${multiplierPercent}%`,
|
||||
background: `linear-gradient(90deg, #F59E0B, #EF4444)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Element Chain */}
|
||||
{combo.elementChain.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Element Chain</span>
|
||||
{hasElementChain && (
|
||||
<span className="text-green-400 text-xs">+25% bonus!</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{combo.elementChain.map((elem, i) => {
|
||||
const elemDef = ELEMENTS[elem];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-8 h-8 rounded border flex items-center justify-center text-xs"
|
||||
style={{
|
||||
borderColor: elemDef?.color || '#60A5FA',
|
||||
backgroundColor: `${elemDef?.color}20`,
|
||||
color: elemDef?.color || '#60A5FA',
|
||||
}}
|
||||
>
|
||||
{elemDef?.sym || '?'}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Empty slots */}
|
||||
{Array.from({ length: 3 - combo.elementChain.length }).map((_, i) => (
|
||||
<div
|
||||
key={`empty-${i}`}
|
||||
className="w-8 h-8 rounded border border-gray-700 bg-gray-800/50 flex items-center justify-center text-gray-600"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decay Warning */}
|
||||
{isClimbing && combo.count > 0 && combo.decayTimer <= 3 && (
|
||||
<div className="text-xs text-red-400 flex items-center gap-1">
|
||||
<Flame className="w-3 h-3" />
|
||||
Combo decaying soon!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not climbing warning */}
|
||||
{!isClimbing && combo.count > 0 && (
|
||||
<div className="text-xs text-amber-400 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Resume climbing to maintain combo
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
405
src/components/game/GameContext.tsx
Executable file
405
src/components/game/GameContext.tsx
Executable file
@@ -0,0 +1,405 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
import { useSkillStore } from '@/lib/game/stores/skillStore';
|
||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||
import { useUIStore } from '@/lib/game/stores/uiStore';
|
||||
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
||||
import { useGameStore, useGameLoop } from '@/lib/game/stores/gameStore';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
|
||||
import { getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||
import { getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
getMeditationBonus,
|
||||
canAffordSpellCost,
|
||||
calcDamage,
|
||||
getFloorElement,
|
||||
getBoonBonuses,
|
||||
getIncursionStrength,
|
||||
} from '@/lib/game/utils';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
HOURS_PER_TICK,
|
||||
TICK_MS,
|
||||
} from '@/lib/game/constants';
|
||||
import type { ElementDef, GuardianDef, SpellDef, GameAction } from '@/lib/game/types';
|
||||
|
||||
// Define a unified store type that combines all stores
|
||||
interface UnifiedStore {
|
||||
// From gameStore (coordinator)
|
||||
day: number;
|
||||
hour: number;
|
||||
incursionStrength: number;
|
||||
containmentWards: number;
|
||||
initialized: boolean;
|
||||
tick: () => void;
|
||||
resetGame: () => void;
|
||||
gatherMana: () => void;
|
||||
startNewLoop: () => void;
|
||||
|
||||
// From manaStore
|
||||
rawMana: number;
|
||||
meditateTicks: number;
|
||||
totalManaGathered: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
setRawMana: (amount: number) => void;
|
||||
addRawMana: (amount: number, max: number) => void;
|
||||
spendRawMana: (amount: number) => boolean;
|
||||
convertMana: (element: string, amount: number) => boolean;
|
||||
unlockElement: (element: string, cost: number) => boolean;
|
||||
craftComposite: (target: string, recipe: string[]) => boolean;
|
||||
|
||||
// From skillStore
|
||||
skills: Record<string, number>;
|
||||
skillProgress: Record<string, number>;
|
||||
skillUpgrades: Record<string, string[]>;
|
||||
skillTiers: Record<string, number>;
|
||||
paidStudySkills: Record<string, number>;
|
||||
currentStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
|
||||
parallelStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
|
||||
setSkillLevel: (skillId: string, level: number) => void;
|
||||
startStudyingSkill: (skillId: string, rawMana: number) => { started: boolean; cost: number };
|
||||
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => { started: boolean; cost: number };
|
||||
cancelStudy: (retentionBonus: number) => void;
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
||||
tierUpSkill: (skillId: string) => void;
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: Array<{ id: string; name: string; desc: string; milestone: 5 | 10; effect: { type: string; stat?: string; value?: number; specialId?: string } }>; selected: string[] };
|
||||
|
||||
// From prestigeStore
|
||||
loopCount: number;
|
||||
insight: number;
|
||||
totalInsight: number;
|
||||
loopInsight: number;
|
||||
prestigeUpgrades: Record<string, number>;
|
||||
memorySlots: number;
|
||||
pactSlots: number;
|
||||
memories: Array<{ skillId: string; level: number; tier: number; upgrades: string[] }>;
|
||||
defeatedGuardians: number[];
|
||||
signedPacts: number[];
|
||||
pactRitualFloor: number | null;
|
||||
pactRitualProgress: number;
|
||||
doPrestige: (id: string) => void;
|
||||
addMemory: (memory: { skillId: string; level: number; tier: number; upgrades: string[] }) => void;
|
||||
removeMemory: (skillId: string) => void;
|
||||
clearMemories: () => void;
|
||||
startPactRitual: (floor: number, rawMana: number) => boolean;
|
||||
cancelPactRitual: () => void;
|
||||
removePact: (floor: number) => void;
|
||||
defeatGuardian: (floor: number) => void;
|
||||
|
||||
// From combatStore
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
activeSpell: string;
|
||||
currentAction: GameAction;
|
||||
castProgress: number;
|
||||
spells: Record<string, { learned: boolean; level: number; studyProgress?: number }>;
|
||||
setAction: (action: GameAction) => void;
|
||||
setSpell: (spellId: string) => void;
|
||||
learnSpell: (spellId: string) => void;
|
||||
advanceFloor: () => void;
|
||||
|
||||
// From uiStore
|
||||
log: string[];
|
||||
paused: boolean;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
addLog: (message: string) => void;
|
||||
togglePause: () => void;
|
||||
setPaused: (paused: boolean) => void;
|
||||
setGameOver: (gameOver: boolean, victory?: boolean) => void;
|
||||
}
|
||||
|
||||
interface GameContextValue {
|
||||
// Unified store for backward compatibility
|
||||
store: UnifiedStore;
|
||||
|
||||
// Individual stores for direct access if needed
|
||||
skillStore: ReturnType<typeof useSkillStore.getState>;
|
||||
manaStore: ReturnType<typeof useManaStore.getState>;
|
||||
prestigeStore: ReturnType<typeof usePrestigeStore.getState>;
|
||||
uiStore: ReturnType<typeof useUIStore.getState>;
|
||||
combatStore: ReturnType<typeof useCombatStore.getState>;
|
||||
|
||||
// Computed effects from upgrades
|
||||
upgradeEffects: ReturnType<typeof computeEffects>;
|
||||
|
||||
// Derived stats
|
||||
maxMana: number;
|
||||
baseRegen: number;
|
||||
clickMana: number;
|
||||
floorElem: string;
|
||||
floorElemDef: ElementDef | undefined;
|
||||
isGuardianFloor: boolean;
|
||||
currentGuardian: GuardianDef | undefined;
|
||||
activeSpellDef: SpellDef | undefined;
|
||||
meditationMultiplier: number;
|
||||
incursionStrength: number;
|
||||
studySpeedMult: number;
|
||||
studyCostMult: number;
|
||||
|
||||
// Effective regen calculations
|
||||
effectiveRegenWithSpecials: number;
|
||||
manaCascadeBonus: number;
|
||||
effectiveRegen: number;
|
||||
|
||||
// DPS calculation
|
||||
dps: number;
|
||||
|
||||
// Boons
|
||||
activeBoons: ReturnType<typeof getBoonBonuses>;
|
||||
|
||||
// Helpers
|
||||
canCastSpell: (spellId: string) => boolean;
|
||||
hasSpecial: (effects: ReturnType<typeof computeEffects>, specialId: string) => boolean;
|
||||
SPECIAL_EFFECTS: typeof SPECIAL_EFFECTS;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
|
||||
export function GameProvider({ children }: { children: ReactNode }) {
|
||||
// Get all individual stores
|
||||
const gameStore = useGameStore();
|
||||
const skillState = useSkillStore();
|
||||
const manaState = useManaStore();
|
||||
const prestigeState = usePrestigeStore();
|
||||
const uiState = useUIStore();
|
||||
const combatState = useCombatStore();
|
||||
|
||||
// Create unified store object for backward compatibility
|
||||
const unifiedStore = useMemo<UnifiedStore>(() => ({
|
||||
// From gameStore
|
||||
day: gameStore.day,
|
||||
hour: gameStore.hour,
|
||||
incursionStrength: gameStore.incursionStrength,
|
||||
containmentWards: gameStore.containmentWards,
|
||||
initialized: gameStore.initialized,
|
||||
tick: gameStore.tick,
|
||||
resetGame: gameStore.resetGame,
|
||||
gatherMana: gameStore.gatherMana,
|
||||
startNewLoop: gameStore.startNewLoop,
|
||||
|
||||
// From manaStore
|
||||
rawMana: manaState.rawMana,
|
||||
meditateTicks: manaState.meditateTicks,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
elements: manaState.elements,
|
||||
setRawMana: manaState.setRawMana,
|
||||
addRawMana: manaState.addRawMana,
|
||||
spendRawMana: manaState.spendRawMana,
|
||||
convertMana: manaState.convertMana,
|
||||
unlockElement: manaState.unlockElement,
|
||||
craftComposite: manaState.craftComposite,
|
||||
|
||||
// From skillStore
|
||||
skills: skillState.skills,
|
||||
skillProgress: skillState.skillProgress,
|
||||
skillUpgrades: skillState.skillUpgrades,
|
||||
skillTiers: skillState.skillTiers,
|
||||
paidStudySkills: skillState.paidStudySkills,
|
||||
currentStudyTarget: skillState.currentStudyTarget,
|
||||
parallelStudyTarget: skillState.parallelStudyTarget,
|
||||
setSkillLevel: skillState.setSkillLevel,
|
||||
startStudyingSkill: skillState.startStudyingSkill,
|
||||
startStudyingSpell: skillState.startStudyingSpell,
|
||||
cancelStudy: skillState.cancelStudy,
|
||||
selectSkillUpgrade: skillState.selectSkillUpgrade,
|
||||
deselectSkillUpgrade: skillState.deselectSkillUpgrade,
|
||||
commitSkillUpgrades: skillState.commitSkillUpgrades,
|
||||
tierUpSkill: skillState.tierUpSkill,
|
||||
getSkillUpgradeChoices: skillState.getSkillUpgradeChoices,
|
||||
|
||||
// From prestigeStore
|
||||
loopCount: prestigeState.loopCount,
|
||||
insight: prestigeState.insight,
|
||||
totalInsight: prestigeState.totalInsight,
|
||||
loopInsight: prestigeState.loopInsight,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
memorySlots: prestigeState.memorySlots,
|
||||
pactSlots: prestigeState.pactSlots,
|
||||
memories: prestigeState.memories,
|
||||
defeatedGuardians: prestigeState.defeatedGuardians,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
pactRitualFloor: prestigeState.pactRitualFloor,
|
||||
pactRitualProgress: prestigeState.pactRitualProgress,
|
||||
doPrestige: prestigeState.doPrestige,
|
||||
addMemory: prestigeState.addMemory,
|
||||
removeMemory: prestigeState.removeMemory,
|
||||
clearMemories: prestigeState.clearMemories,
|
||||
startPactRitual: prestigeState.startPactRitual,
|
||||
cancelPactRitual: prestigeState.cancelPactRitual,
|
||||
removePact: prestigeState.removePact,
|
||||
defeatGuardian: prestigeState.defeatGuardian,
|
||||
|
||||
// From combatStore
|
||||
currentFloor: combatState.currentFloor,
|
||||
floorHP: combatState.floorHP,
|
||||
floorMaxHP: combatState.floorMaxHP,
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
activeSpell: combatState.activeSpell,
|
||||
currentAction: combatState.currentAction,
|
||||
castProgress: combatState.castProgress,
|
||||
spells: combatState.spells,
|
||||
setAction: combatState.setAction,
|
||||
setSpell: combatState.setSpell,
|
||||
learnSpell: combatState.learnSpell,
|
||||
advanceFloor: combatState.advanceFloor,
|
||||
|
||||
// From uiStore
|
||||
log: uiState.logs,
|
||||
paused: uiState.paused,
|
||||
gameOver: uiState.gameOver,
|
||||
victory: uiState.victory,
|
||||
addLog: uiState.addLog,
|
||||
togglePause: uiState.togglePause,
|
||||
setPaused: uiState.setPaused,
|
||||
setGameOver: uiState.setGameOver,
|
||||
}), [gameStore, skillState, manaState, prestigeState, uiState, combatState]);
|
||||
|
||||
// Computed effects from upgrades
|
||||
const upgradeEffects = useMemo(
|
||||
() => computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {}),
|
||||
[skillState.skillUpgrades, skillState.skillTiers]
|
||||
);
|
||||
|
||||
// Create a minimal state object for compute functions
|
||||
const stateForCompute = useMemo(() => ({
|
||||
skills: skillState.skills,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
skillUpgrades: skillState.skillUpgrades,
|
||||
skillTiers: skillState.skillTiers,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
rawMana: manaState.rawMana,
|
||||
meditateTicks: manaState.meditateTicks,
|
||||
incursionStrength: gameStore.incursionStrength,
|
||||
}), [skillState, prestigeState, manaState, gameStore.incursionStrength]);
|
||||
|
||||
// Derived stats
|
||||
const maxMana = useMemo(
|
||||
() => computeMaxMana(stateForCompute, upgradeEffects),
|
||||
[stateForCompute, upgradeEffects]
|
||||
);
|
||||
|
||||
const baseRegen = useMemo(
|
||||
() => computeRegen(stateForCompute, upgradeEffects),
|
||||
[stateForCompute, upgradeEffects]
|
||||
);
|
||||
|
||||
const clickMana = useMemo(() => computeClickMana(stateForCompute), [stateForCompute]);
|
||||
|
||||
// Floor element from combat store
|
||||
const floorElem = useMemo(() => getFloorElement(combatState.currentFloor), [combatState.currentFloor]);
|
||||
const floorElemDef = ELEMENTS[floorElem];
|
||||
const isGuardianFloor = !!GUARDIANS[combatState.currentFloor];
|
||||
const currentGuardian = GUARDIANS[combatState.currentFloor];
|
||||
const activeSpellDef = SPELLS_DEF[combatState.activeSpell];
|
||||
|
||||
const meditationMultiplier = useMemo(
|
||||
() => getMeditationBonus(manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency),
|
||||
[manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency]
|
||||
);
|
||||
|
||||
const incursionStrength = useMemo(
|
||||
() => getIncursionStrength(gameStore.day, gameStore.hour),
|
||||
[gameStore.day, gameStore.hour]
|
||||
);
|
||||
|
||||
const studySpeedMult = useMemo(
|
||||
() => getStudySpeedMultiplier(skillState.skills),
|
||||
[skillState.skills]
|
||||
);
|
||||
|
||||
const studyCostMult = useMemo(
|
||||
() => getStudyCostMultiplier(skillState.skills),
|
||||
[skillState.skills]
|
||||
);
|
||||
|
||||
// Effective regen calculations
|
||||
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
|
||||
|
||||
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
|
||||
? Math.floor(maxMana / 100) * 0.1
|
||||
: 0;
|
||||
|
||||
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
|
||||
|
||||
// Active boons
|
||||
const activeBoons = useMemo(
|
||||
() => getBoonBonuses(prestigeState.signedPacts),
|
||||
[prestigeState.signedPacts]
|
||||
);
|
||||
|
||||
// DPS calculation - based on active spell, attack speed, and damage
|
||||
const dps = useMemo(() => {
|
||||
if (!activeSpellDef) return 0;
|
||||
const baseDmg = calcDamage(
|
||||
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
|
||||
combatState.activeSpell,
|
||||
floorElem
|
||||
);
|
||||
const dmgWithEffects = baseDmg * upgradeEffects.baseDamageMultiplier + upgradeEffects.baseDamageBonus;
|
||||
const attackSpeed = (1 + (skillState.skills.quickCast || 0) * 0.05) * upgradeEffects.attackSpeedMultiplier;
|
||||
const castSpeed = activeSpellDef.castSpeed || 1;
|
||||
return dmgWithEffects * attackSpeed * castSpeed;
|
||||
}, [activeSpellDef, skillState.skills, prestigeState.signedPacts, floorElem, upgradeEffects, combatState.activeSpell]);
|
||||
|
||||
// Helper functions
|
||||
const canCastSpell = (spellId: string): boolean => {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return false;
|
||||
return canAffordSpellCost(spell.cost, manaState.rawMana, manaState.elements);
|
||||
};
|
||||
|
||||
const value: GameContextValue = {
|
||||
store: unifiedStore,
|
||||
skillStore: skillState,
|
||||
manaStore: manaState,
|
||||
prestigeStore: prestigeState,
|
||||
uiStore: uiState,
|
||||
combatStore: combatState,
|
||||
upgradeEffects,
|
||||
maxMana,
|
||||
baseRegen,
|
||||
clickMana,
|
||||
floorElem,
|
||||
floorElemDef,
|
||||
isGuardianFloor,
|
||||
currentGuardian,
|
||||
activeSpellDef,
|
||||
meditationMultiplier,
|
||||
incursionStrength,
|
||||
studySpeedMult,
|
||||
studyCostMult,
|
||||
effectiveRegenWithSpecials,
|
||||
manaCascadeBonus,
|
||||
effectiveRegen,
|
||||
dps,
|
||||
activeBoons,
|
||||
canCastSpell,
|
||||
hasSpecial,
|
||||
SPECIAL_EFFECTS,
|
||||
};
|
||||
|
||||
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGameContext() {
|
||||
const context = useContext(GameContext);
|
||||
if (!context) {
|
||||
throw new Error('useGameContext must be used within a GameProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Re-export useGameLoop for convenience
|
||||
export { useGameLoop };
|
||||
193
src/components/game/GrimoireTab.tsx
Executable file
193
src/components/game/GrimoireTab.tsx
Executable file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, fmt, fmtDec, computePactMultiplier } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, PRESTIGE_DEF } from '@/lib/game/constants';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
export function GrimoireTab() {
|
||||
const store = useGameStore();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Current Status */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||
<div className="text-xs text-gray-400">Current Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Total Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
|
||||
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Signed Pacts */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Signed Pacts ({store.signedPacts.length}/{1 + (store.prestigeUpgrades.pactCapacity || 0)})</CardTitle>
|
||||
{store.signedPacts.length > 1 && (
|
||||
<div className="text-xs text-gray-400">
|
||||
Combined: ×{fmtDec(computePactMultiplier(store), 2)} damage
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{store.signedPacts.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{store.signedPacts.map((floor) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return null;
|
||||
return (
|
||||
<div
|
||||
key={floor}
|
||||
className="p-3 rounded border"
|
||||
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}10` }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||
{guardian.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">{guardian.theme} • Floor {floor}</div>
|
||||
</div>
|
||||
<Badge style={{ backgroundColor: `${guardian.color}30`, color: guardian.color }}>
|
||||
{guardian.damageMultiplier}x dmg / {guardian.insightMultiplier}x insight
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Unique Boon */}
|
||||
{guardian.uniqueBoon && (
|
||||
<div className="mb-2 p-2 bg-cyan-900/20 rounded border border-cyan-800/30">
|
||||
<div className="text-xs font-semibold text-cyan-300">✨ {guardian.uniqueBoon.name}</div>
|
||||
<div className="text-xs text-cyan-200/70">{guardian.uniqueBoon.desc}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Perks & Costs */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{guardian.perks.length > 0 && (
|
||||
<div>
|
||||
<div className="text-green-400 font-semibold mb-1">Perks</div>
|
||||
{guardian.perks.map(perk => (
|
||||
<div key={perk.id} className="text-green-300/70">• {perk.desc}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{guardian.costs.length > 0 && (
|
||||
<div>
|
||||
<div className="text-red-400 font-semibold mb-1">Costs</div>
|
||||
{guardian.costs.map(cost => (
|
||||
<div key={cost.id} className="text-red-300/70">• {cost.desc}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unlocked Mana */}
|
||||
{guardian.unlocksMana.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{guardian.unlocksMana.map(elemId => {
|
||||
const elem = ELEMENTS[elemId];
|
||||
return (
|
||||
<Badge key={elemId} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
|
||||
{elem?.sym}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Prestige Upgrades */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
|
||||
const level = store.prestigeUpgrades[id] || 0;
|
||||
const maxed = level >= def.max;
|
||||
const canBuy = !maxed && store.insight >= def.cost;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-3 rounded border border-gray-700 bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{level}/{def.max}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canBuy ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
disabled={!canBuy}
|
||||
onClick={() => store.doPrestige(id)}
|
||||
>
|
||||
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reset Game Button */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Reset All Progress</div>
|
||||
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
|
||||
store.resetGame();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
src/components/game/LabTab.tsx
Executable file
171
src/components/game/LabTab.tsx
Executable file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useGameStore } from '@/lib/game/store';
|
||||
import { ELEMENTS, MANA_PER_ELEMENT } from '@/lib/game/constants';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function LabTab() {
|
||||
const store = useGameStore();
|
||||
const [convertTarget, setConvertTarget] = useState('fire');
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Elemental Mana Display */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Elemental Mana</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([, state]) => state.unlocked && state.current >= 1)
|
||||
.map(([id, state]) => {
|
||||
const def = ELEMENTS[id];
|
||||
const isSelected = convertTarget === id;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`p-2 rounded border cursor-pointer transition-all ${isSelected ? 'border-blue-500 bg-blue-900/20' : 'border-gray-700 bg-gray-800/50 hover:border-gray-600'}`}
|
||||
style={{ borderColor: isSelected ? def?.color : undefined }}
|
||||
onClick={() => setConvertTarget(id)}
|
||||
>
|
||||
<div className="text-lg text-center">{def?.sym}</div>
|
||||
<div className="text-xs font-semibold text-center" style={{ color: def?.color }}>{def?.name}</div>
|
||||
<div className="text-xs text-gray-400 game-mono text-center">{state.current}/{state.max}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Element Conversion */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Element Conversion</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Convert raw mana to elemental mana (100:1 ratio)
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => store.convertMana(convertTarget, 1)}
|
||||
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT}
|
||||
>
|
||||
+1 ({MANA_PER_ELEMENT})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => store.convertMana(convertTarget, 10)}
|
||||
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 10}
|
||||
>
|
||||
+10 ({MANA_PER_ELEMENT * 10})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => store.convertMana(convertTarget, 100)}
|
||||
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 100}
|
||||
>
|
||||
+100 ({MANA_PER_ELEMENT * 100})
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Unlock Elements */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Unlock Elements</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Unlock new elemental affinities (500 mana each)
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([id, state]) => !state.unlocked && ELEMENTS[id]?.cat !== 'exotic')
|
||||
.map(([id]) => {
|
||||
const def = ELEMENTS[id];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border border-gray-700 bg-gray-800/50"
|
||||
>
|
||||
<div className="text-lg opacity-50">{def?.sym}</div>
|
||||
<div className="text-xs font-semibold text-gray-500">{def?.name}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1 w-full"
|
||||
disabled={store.rawMana < 500}
|
||||
onClick={() => store.unlockElement(id)}
|
||||
>
|
||||
Unlock
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Composite Crafting */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Composite & Exotic Crafting</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Object.entries(ELEMENTS)
|
||||
.filter(([, def]) => def.recipe)
|
||||
.map(([id, def]) => {
|
||||
const state = store.elements[id];
|
||||
const recipe = def.recipe!;
|
||||
const canCraft = recipe.every(
|
||||
(r) => (store.elements[r]?.current || 0) >= recipe.filter((x) => x === r).length
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`p-3 rounded border ${canCraft ? 'border-gray-600 bg-gray-800/50' : 'border-gray-700 bg-gray-800/30 opacity-50'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{def.sym}</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: def.color }}>
|
||||
{def.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{def.cat}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
{recipe.map((r) => ELEMENTS[r]?.sym).join(' + ')}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canCraft ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
disabled={!canCraft}
|
||||
onClick={() => store.craftComposite(id)}
|
||||
>
|
||||
Craft
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
418
src/components/game/SkillsTab.tsx
Executable file
418
src/components/game/SkillsTab.tsx
Executable file
@@ -0,0 +1,418 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
|
||||
import { SKILLS_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
|
||||
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { BookOpen, X } from 'lucide-react';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
// Format study time
|
||||
function formatStudyTime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
export function SkillsTab() {
|
||||
const store = useGameStore();
|
||||
const { studySpeedMult, studyCostMult, hasParallelStudy } = useStudyStats();
|
||||
|
||||
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
|
||||
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
|
||||
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
|
||||
|
||||
const upgradeEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||||
|
||||
// Check if skill has milestone available
|
||||
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||
if (!path) return null;
|
||||
|
||||
if (level >= 5) {
|
||||
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, store.skillTiers);
|
||||
const selected5 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
|
||||
if (upgrades5.length > 0 && selected5.length < 2) {
|
||||
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
|
||||
}
|
||||
}
|
||||
|
||||
if (level >= 10) {
|
||||
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, store.skillTiers);
|
||||
const selected10 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
|
||||
if (upgrades10.length > 0 && selected10.length < 2) {
|
||||
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Render upgrade selection dialog
|
||||
const renderUpgradeDialog = () => {
|
||||
if (!upgradeDialogSkill) return null;
|
||||
|
||||
const skillDef = SKILLS_DEF[upgradeDialogSkill];
|
||||
const level = store.skills[upgradeDialogSkill] || 0;
|
||||
const { available, selected: alreadySelected } = store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
|
||||
|
||||
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
|
||||
|
||||
const toggleUpgrade = (upgradeId: string) => {
|
||||
if (currentSelections.includes(upgradeId)) {
|
||||
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
|
||||
} else if (currentSelections.length < 2) {
|
||||
setPendingUpgradeSelections([...currentSelections, upgradeId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
if (currentSelections.length === 2 && upgradeDialogSkill) {
|
||||
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
|
||||
}
|
||||
setPendingUpgradeSelections([]);
|
||||
setUpgradeDialogSkill(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setPendingUpgradeSelections([]);
|
||||
setUpgradeDialogSkill(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!upgradeDialogSkill} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingUpgradeSelections([]);
|
||||
setUpgradeDialogSkill(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-amber-400">
|
||||
Choose Upgrade - {skillDef?.name || upgradeDialogSkill}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Level {upgradeDialogMilestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{available.map((upgrade) => {
|
||||
const isSelected = currentSelections.includes(upgrade.id);
|
||||
const canToggle = currentSelections.length < 2 || isSelected;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canToggle
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (canToggle) {
|
||||
toggleUpgrade(upgrade.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
|
||||
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleDone}
|
||||
disabled={currentSelections.length !== 2}
|
||||
>
|
||||
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Render study progress
|
||||
const renderStudyProgress = () => {
|
||||
if (!store.currentStudyTarget) return null;
|
||||
|
||||
const target = store.currentStudyTarget;
|
||||
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||
const def = SKILLS_DEF[target.id] || SKILLS_DEF[target.id.split('_t')[0]];
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm font-semibold text-purple-300">
|
||||
{def?.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={() => store.cancelStudy()}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||||
<span>{studySpeedMult.toFixed(1)}x speed</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Upgrade Selection Dialog */}
|
||||
{renderUpgradeDialog()}
|
||||
|
||||
{/* Current Study Progress */}
|
||||
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||
<CardContent className="pt-4">
|
||||
{renderStudyProgress()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{SKILL_CATEGORIES.map((cat) => {
|
||||
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
|
||||
if (skillsInCat.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||
{cat.icon} {cat.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{skillsInCat.map(([id, def]) => {
|
||||
const currentTier = store.skillTiers?.[id] || 1;
|
||||
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
|
||||
const tierMultiplier = getTierMultiplier(tieredSkillId);
|
||||
|
||||
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
|
||||
const maxed = level >= def.max;
|
||||
|
||||
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
|
||||
const savedProgress = store.skillProgress[tieredSkillId] || store.skillProgress[id] || 0;
|
||||
|
||||
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
|
||||
const skillDisplayName = tierDef?.name || def.name;
|
||||
|
||||
// Check prerequisites
|
||||
let prereqMet = true;
|
||||
if (def.req) {
|
||||
for (const [r, rl] of Object.entries(def.req)) {
|
||||
if ((store.skills[r] || 0) < rl) {
|
||||
prereqMet = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply skill modifiers
|
||||
const studyEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||||
const effectiveSpeedMult = studySpeedMult * studyEffects.studySpeedMultiplier;
|
||||
|
||||
const tierStudyTime = def.studyTime * currentTier;
|
||||
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
|
||||
|
||||
const baseCost = def.base * (level + 1) * currentTier;
|
||||
const cost = Math.floor(baseCost * studyCostMult);
|
||||
|
||||
const canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
|
||||
|
||||
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level);
|
||||
const nextTierSkill = getNextTierSkill(tieredSkillId);
|
||||
const canTierUp = maxed && nextTierSkill;
|
||||
|
||||
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
|
||||
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
|
||||
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
|
||||
isStudying ? 'border-purple-500 bg-purple-900/20' :
|
||||
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
|
||||
'border-gray-700 bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm">{skillDisplayName}</span>
|
||||
{currentTier > 1 && (
|
||||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
|
||||
)}
|
||||
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
|
||||
{selectedUpgrades.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{selectedL5.length > 0 && (
|
||||
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
|
||||
)}
|
||||
{selectedL10.length > 0 && (
|
||||
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
|
||||
{!prereqMet && def.req && (
|
||||
<div className="text-xs text-red-400 mt-1">
|
||||
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
|
||||
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
|
||||
</span>
|
||||
{' • '}
|
||||
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||||
Cost: {fmt(cost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{milestoneInfo && (
|
||||
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
|
||||
⭐ Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
|
||||
{/* Level dots */}
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{Array.from({ length: def.max }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full border ${
|
||||
i < level ? 'bg-purple-500 border-purple-400' :
|
||||
i === 4 || i === 9 ? 'border-amber-500' :
|
||||
'border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isStudying ? (
|
||||
<div className="text-xs text-purple-400">
|
||||
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
|
||||
</div>
|
||||
) : milestoneInfo ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
onClick={() => {
|
||||
setUpgradeDialogSkill(tieredSkillId);
|
||||
setUpgradeDialogMilestone(milestoneInfo.milestone);
|
||||
}}
|
||||
>
|
||||
Choose Upgrades
|
||||
</Button>
|
||||
) : canTierUp ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => store.tierUpSkill(tieredSkillId)}
|
||||
>
|
||||
⬆️ Tier Up
|
||||
</Button>
|
||||
) : maxed ? (
|
||||
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canStudy ? 'default' : 'outline'}
|
||||
disabled={!canStudy}
|
||||
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||
onClick={() => store.startStudyingSkill(tieredSkillId)}
|
||||
>
|
||||
Study ({fmt(cost)})
|
||||
</Button>
|
||||
{/* Parallel Study button */}
|
||||
{hasParallelStudy &&
|
||||
store.currentStudyTarget &&
|
||||
!store.parallelStudyTarget &&
|
||||
store.currentStudyTarget.id !== tieredSkillId &&
|
||||
canStudy && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
|
||||
onClick={() => store.startParallelStudySkill(tieredSkillId)}
|
||||
>
|
||||
⚡
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Study in parallel (50% speed)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
src/components/game/SpellsTab.tsx
Executable file
166
src/components/game/SpellsTab.tsx
Executable file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage } from '@/lib/game/store';
|
||||
import { ELEMENTS, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
// Format spell cost for display
|
||||
function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||
if (cost.type === 'raw') {
|
||||
return `${cost.amount} raw`;
|
||||
}
|
||||
const elemDef = ELEMENTS[cost.element || ''];
|
||||
return `${cost.amount} ${elemDef?.sym || '?'}`;
|
||||
}
|
||||
|
||||
// Get cost color
|
||||
function getSpellCostColor(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||
if (cost.type === 'raw') {
|
||||
return '#60A5FA';
|
||||
}
|
||||
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
|
||||
}
|
||||
|
||||
// Format study time
|
||||
function formatStudyTime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
export function SpellsTab() {
|
||||
const store = useGameStore();
|
||||
const { studySpeedMult, studyCostMult } = useStudyStats();
|
||||
|
||||
const spellTiers = [0, 1, 2, 3, 4];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{spellTiers.map(tier => {
|
||||
const spellsInTier = Object.entries(SPELLS_DEF).filter(([, def]) => def.tier === tier);
|
||||
if (spellsInTier.length === 0) return null;
|
||||
|
||||
const tierNames = ['Basic Spells (Raw Mana)', 'Tier 1 - Elemental', 'Tier 2 - Advanced', 'Tier 3 - Master', 'Tier 4 - Legendary'];
|
||||
const tierColors = ['text-gray-400', 'text-green-400', 'text-blue-400', 'text-purple-400', 'text-amber-400'];
|
||||
|
||||
return (
|
||||
<div key={tier}>
|
||||
<h3 className={`text-lg font-semibold mb-3 ${tierColors[tier]}`}>{tierNames[tier]}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{spellsInTier.map(([id, def]) => {
|
||||
const state = store.spells[id];
|
||||
const learned = state?.learned;
|
||||
const isStudying = store.currentStudyTarget?.id === id;
|
||||
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||
const baseStudyTime = def.studyTime || (def.tier * 4);
|
||||
const isActive = store.activeSpell === id;
|
||||
const canCast = learned && canAffordSpellCost(def.cost, store.rawMana, store.elements);
|
||||
|
||||
// Apply skill modifiers
|
||||
const studyTime = baseStudyTime / studySpeedMult;
|
||||
const unlockCost = Math.floor(def.unlock * studyCostMult);
|
||||
|
||||
// Can start studying?
|
||||
const canStudy = !learned && !isStudying && store.rawMana >= unlockCost;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={id}
|
||||
className={`bg-gray-900/80 border-gray-700 ${learned ? '' : 'opacity-75'} ${isStudying ? 'border-purple-500' : ''} ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm game-panel-title" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||
{def.name}
|
||||
</CardTitle>
|
||||
{def.tier > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
T{def.tier}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-xs text-gray-400">
|
||||
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
|
||||
<span className="mr-2">⚔️ {def.dmg} dmg</span>
|
||||
</div>
|
||||
|
||||
{/* Cost display */}
|
||||
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||
Cost: {formatSpellCost(def.cost)}
|
||||
</div>
|
||||
|
||||
{def.desc && (
|
||||
<div className="text-xs text-gray-500 italic">{def.desc}</div>
|
||||
)}
|
||||
|
||||
{def.effects && def.effects.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{def.effects.map((eff, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">
|
||||
{eff.type === 'burn' && `🔥 Burn`}
|
||||
{eff.type === 'stun' && `⚡ Stun`}
|
||||
{eff.type === 'pierce' && `🎯 Pierce`}
|
||||
{eff.type === 'multicast' && `✨ Multicast`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{learned ? (
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-green-900/50 text-green-300">Learned</Badge>
|
||||
{isActive && <Badge className="bg-amber-900/50 text-amber-300">Active</Badge>}
|
||||
{!isActive && (
|
||||
<Button size="sm" variant="outline" onClick={() => store.setSpell(id)}>
|
||||
Set Active
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : isStudying ? (
|
||||
<div className="space-y-1">
|
||||
<Progress
|
||||
value={Math.min(100, ((state?.studyProgress || 0) / studyTime) * 100)}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
<div className="text-xs text-purple-400">
|
||||
Studying... {formatStudyTime(state?.studyProgress || 0)}/{formatStudyTime(studyTime)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className={studySpeedMult > 1 ? 'text-green-400' : ''}>
|
||||
Study: {formatStudyTime(studyTime)}{studySpeedMult > 1 && <span className="text-xs ml-1">({Math.round(studySpeedMult * 100)}% speed)</span>}
|
||||
</span>
|
||||
{' • '}
|
||||
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||||
Cost: {fmt(unlockCost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canStudy ? 'default' : 'outline'}
|
||||
disabled={!canStudy}
|
||||
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||
onClick={() => store.startStudyingSpell(id)}
|
||||
>
|
||||
Start Study ({fmt(unlockCost)} mana)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
src/components/game/SpireTab.tsx
Executable file
320
src/components/game/SpireTab.tsx
Executable file
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage, computePactMultiplier } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, MANA_PER_ELEMENT } from '@/lib/game/constants';
|
||||
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { X, BookOpen } from 'lucide-react';
|
||||
|
||||
export function SpireTab() {
|
||||
const store = useGameStore();
|
||||
const { effectiveRegen, meditationMultiplier, incursionStrength } = useManaStats();
|
||||
const {
|
||||
floorElem, floorElemDef, isGuardianFloor, currentGuardian,
|
||||
activeSpellDef, dps, damageBreakdown
|
||||
} = useCombatStats();
|
||||
const { effectiveStudySpeedMult } = useStudyStats();
|
||||
|
||||
// Check if spell can be cast
|
||||
const canCastSpell = (spellId: string): boolean => {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return false;
|
||||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||||
};
|
||||
|
||||
// Render study progress
|
||||
const renderStudyProgress = () => {
|
||||
if (!store.currentStudyTarget) return null;
|
||||
|
||||
const target = store.currentStudyTarget;
|
||||
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||
const isSkill = target.type === 'skill';
|
||||
const def = isSkill ? SPELLS_DEF[target.id] : SPELLS_DEF[target.id];
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm font-semibold text-purple-300">
|
||||
{def?.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={() => store.cancelStudy()}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||||
<span>{effectiveStudySpeedMult.toFixed(1)}x speed</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Current Floor Card */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
|
||||
{store.currentFloor}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm">/ 100</span>
|
||||
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
|
||||
{floorElemDef?.sym} {floorElemDef?.name}
|
||||
</span>
|
||||
{isGuardianFloor && (
|
||||
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGuardianFloor && currentGuardian && (
|
||||
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
|
||||
⚔️ {currentGuardian.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HP Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
|
||||
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
|
||||
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400 game-mono">
|
||||
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
|
||||
<span>DPS: {store.currentAction === 'climb' && canCastSpell(store.activeSpell) ? fmtDec(dps) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong> •
|
||||
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Spell Card */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Spell</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{activeSpellDef ? (
|
||||
<>
|
||||
<div className="text-lg font-semibold game-panel-title" style={{ color: activeSpellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[activeSpellDef.elem]?.color }}>
|
||||
{activeSpellDef.name}
|
||||
{activeSpellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200">Basic</Badge>}
|
||||
{activeSpellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100">Legendary</Badge>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 game-mono">
|
||||
⚔️ {fmt(calcDamage(store, store.activeSpell))} dmg •
|
||||
<span style={{ color: getSpellCostColor(activeSpellDef.cost) }}>
|
||||
{' '}{formatSpellCost(activeSpellDef.cost)}
|
||||
</span>
|
||||
{' '}• ⚡ {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
|
||||
</div>
|
||||
|
||||
{/* Cast progress bar when climbing */}
|
||||
{store.currentAction === 'climb' && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Cast Progress</span>
|
||||
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={Math.min(100, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSpellDef.desc && (
|
||||
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</div>
|
||||
)}
|
||||
{activeSpellDef.effects && activeSpellDef.effects.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{activeSpellDef.effects.map((eff, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">
|
||||
{eff.type === 'burn' && `🔥 Burn ${eff.value}/hr`}
|
||||
{eff.type === 'stun' && `⚡ Stun ${eff.value}s`}
|
||||
{eff.type === 'pierce' && `🗡️ Pierce ${Math.round(eff.value * 100)}%`}
|
||||
{eff.type === 'multicast' && `✨ ${Math.round(eff.value * 100)}% Multicast`}
|
||||
{eff.type === 'buff' && `💪 Buff`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-500">No spell selected</div>
|
||||
)}
|
||||
|
||||
{/* Can cast indicator */}
|
||||
{activeSpellDef && (
|
||||
<div className={`text-xs ${canCastSpell(store.activeSpell) ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{incursionStrength > 0 && (
|
||||
<div className="p-2 bg-red-900/20 border border-red-800/50 rounded">
|
||||
<div className="text-xs text-red-400 game-panel-title mb-1">LABYRINTH INCURSION</div>
|
||||
<div className="text-sm text-gray-300">
|
||||
-{Math.round(incursionStrength * 100)}% mana regen
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Study (if any) */}
|
||||
{store.currentStudyTarget && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
{renderStudyProgress()}
|
||||
|
||||
{/* Parallel Study Progress */}
|
||||
{store.parallelStudyTarget && (
|
||||
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-sm font-semibold text-cyan-300">
|
||||
Parallel: {store.parallelStudyTarget.type === 'skill' ? store.parallelStudyTarget.id : store.parallelStudyTarget.id}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={() => store.cancelParallelStudy()}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
|
||||
<span>50% speed (Parallel Study)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pact Signing Progress */}
|
||||
{store.pactSigningProgress && (
|
||||
<Card className="bg-gray-900/80 border-amber-600/50 lg:col-span-2">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="p-3 rounded border border-amber-500/30 bg-amber-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">📜</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-amber-300">
|
||||
Signing Pact: {GUARDIANS[store.pactSigningProgress.floor]?.name}
|
||||
</div>
|
||||
<div className="text-xs text-amber-400">
|
||||
Floor {store.pactSigningProgress.floor}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(100, (store.pactSigningProgress.progress / store.pactSigningProgress.required) * 100)}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-amber-400 mt-1">
|
||||
<span>{formatStudyTime(store.pactSigningProgress.progress)} / {formatStudyTime(store.pactSigningProgress.required)}</span>
|
||||
<span>Cost: {fmt(store.pactSigningProgress.manaCost)} mana</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Spells Available */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Known Spells</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{Object.entries(store.spells)
|
||||
.filter(([, state]) => state.learned)
|
||||
.map(([id, state]) => {
|
||||
const def = SPELLS_DEF[id];
|
||||
if (!def) return null;
|
||||
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||
const isActive = store.activeSpell === id;
|
||||
const canCast = canCastSpell(id);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={id}
|
||||
variant="outline"
|
||||
className={`h-auto py-2 px-3 flex flex-col items-start ${isActive ? 'border-amber-500 bg-amber-900/20' : canCast ? 'border-gray-600 bg-gray-800/50 hover:bg-gray-700/50' : 'border-gray-700 bg-gray-800/30 opacity-60'}`}
|
||||
onClick={() => store.setSpell(id)}
|
||||
>
|
||||
<div className="text-sm font-semibold" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||
{def.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
{fmt(calcDamage(store, id))} dmg
|
||||
</div>
|
||||
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||
{formatSpellCost(def.cost)}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity Log */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-32">
|
||||
<div className="space-y-1">
|
||||
{store.log.slice(0, 20).map((entry, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
|
||||
>
|
||||
{entry}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
551
src/components/game/StatsTab.tsx
Executable file
551
src/components/game/StatsTab.tsx
Executable file
@@ -0,0 +1,551 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, fmt, fmtDec, calcDamage, computePactMultiplier, computePactInsightMultiplier } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF } from '@/lib/game/constants';
|
||||
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Droplet, Swords, BookOpen, FlaskConical, RotateCcw, Trophy, Star } from 'lucide-react';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
export function StatsTab() {
|
||||
const store = useGameStore();
|
||||
const {
|
||||
upgradeEffects, maxMana, baseRegen, clickMana,
|
||||
meditationMultiplier, incursionStrength, manaCascadeBonus, effectiveRegen,
|
||||
hasSteadyStream, hasManaTorrent, hasDesperateWells
|
||||
} = useManaStats();
|
||||
const { activeSpellDef, pactMultiplier, pactInsightMultiplier } = useCombatStats();
|
||||
const { studySpeedMult, studyCostMult } = useStudyStats();
|
||||
|
||||
// Compute element max
|
||||
const elemMax = (() => {
|
||||
const ea = store.skillTiers?.elemAttune || 1;
|
||||
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return 10 + level * 50 * tierMult + (store.prestigeUpgrades.elementalAttune || 0) * 25;
|
||||
})();
|
||||
|
||||
// Get all selected skill upgrades
|
||||
const getAllSelectedUpgrades = () => {
|
||||
const upgrades: { skillId: string; upgrade: SkillUpgradeChoice }[] = [];
|
||||
for (const [skillId, selectedIds] of Object.entries(store.skillUpgrades)) {
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||
if (!path) continue;
|
||||
for (const tier of path.tiers) {
|
||||
if (tier.skillId === skillId) {
|
||||
for (const upgradeId of selectedIds) {
|
||||
const upgrade = tier.upgrades.find(u => u.id === upgradeId);
|
||||
if (upgrade) {
|
||||
upgrades.push({ skillId, upgrade });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return upgrades;
|
||||
};
|
||||
|
||||
const selectedUpgrades = getAllSelectedUpgrades();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mana Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-blue-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Droplet className="w-4 h-4" />
|
||||
Mana Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Base Max Mana:</span>
|
||||
<span className="text-gray-200">100</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Well Bonus:</span>
|
||||
<span className="text-blue-300">
|
||||
{(() => {
|
||||
const mw = store.skillTiers?.manaWell || 1;
|
||||
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
|
||||
const level = store.skills[tieredSkillId] || store.skills.manaWell || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${fmt(level * 100 * tierMult)} (${level} lvl × 100 × ${tierMult}x tier)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Mana Well:</span>
|
||||
<span className="text-blue-300">+{fmt((store.prestigeUpgrades.manaWell || 0) * 500)}</span>
|
||||
</div>
|
||||
{upgradeEffects.maxManaBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Mana Bonus:</span>
|
||||
<span className="text-amber-300">+{fmt(upgradeEffects.maxManaBonus)}</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.maxManaMultiplier > 1 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Mana Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(upgradeEffects.maxManaMultiplier, 2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Total Max Mana:</span>
|
||||
<span className="text-blue-400">{fmt(maxMana)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Base Regen:</span>
|
||||
<span className="text-gray-200">2/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Flow Bonus:</span>
|
||||
<span className="text-blue-300">
|
||||
{(() => {
|
||||
const mf = store.skillTiers?.manaFlow || 1;
|
||||
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
|
||||
const level = store.skills[tieredSkillId] || store.skills.manaFlow || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${fmtDec(level * 1 * tierMult)}/hr (${level} lvl × 1 × ${tierMult}x tier)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Spring Bonus:</span>
|
||||
<span className="text-blue-300">+{(store.skills.manaSpring || 0) * 2}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Mana Flow:</span>
|
||||
<span className="text-blue-300">+{fmtDec((store.prestigeUpgrades.manaFlow || 0) * 0.5)}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Temporal Echo:</span>
|
||||
<span className="text-blue-300">×{fmtDec(1 + (store.prestigeUpgrades.temporalEcho || 0) * 0.1, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Base Regen:</span>
|
||||
<span className="text-blue-400">{fmtDec(baseRegen, 2)}/hr</span>
|
||||
</div>
|
||||
{upgradeEffects.regenBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Regen Bonus:</span>
|
||||
<span className="text-amber-300">+{fmtDec(upgradeEffects.regenBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.permanentRegenBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Permanent Regen Bonus:</span>
|
||||
<span className="text-amber-300">+{fmtDec(upgradeEffects.permanentRegenBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.regenMultiplier > 1 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Regen Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(upgradeEffects.regenMultiplier, 2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
{/* Skill Upgrade Effects Summary */}
|
||||
{upgradeEffects.activeUpgrades.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<span className="text-xs text-amber-400 game-panel-title">Active Skill Upgrades</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3">
|
||||
{upgradeEffects.activeUpgrades.map((upgrade, idx) => (
|
||||
<div key={idx} className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">{upgrade.name}</span>
|
||||
<span className="text-gray-400">{upgrade.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Click Mana Value:</span>
|
||||
<span className="text-purple-300">+{clickMana}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Tap Bonus:</span>
|
||||
<span className="text-purple-300">+{store.skills.manaTap || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Surge Bonus:</span>
|
||||
<span className="text-purple-300">+{(store.skills.manaSurge || 0) * 3}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Overflow:</span>
|
||||
<span className="text-purple-300">×{fmtDec(1 + (store.skills.manaOverflow || 0) * 0.25, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Meditation Multiplier:</span>
|
||||
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
|
||||
{fmtDec(meditationMultiplier, 2)}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Effective Regen:</span>
|
||||
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
|
||||
</div>
|
||||
{incursionStrength > 0 && !hasSteadyStream && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-red-400">Incursion Penalty:</span>
|
||||
<span className="text-red-400">-{Math.round(incursionStrength * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{hasSteadyStream && incursionStrength > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-400">Steady Stream:</span>
|
||||
<span className="text-green-400">Immune to incursion</span>
|
||||
</div>
|
||||
)}
|
||||
{manaCascadeBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Cascade Bonus:</span>
|
||||
<span className="text-cyan-400">+{fmtDec(manaCascadeBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{hasManaTorrent && store.rawMana > maxMana * 0.75 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Torrent:</span>
|
||||
<span className="text-cyan-400">+50% regen (high mana)</span>
|
||||
</div>
|
||||
)}
|
||||
{hasDesperateWells && store.rawMana < maxMana * 0.25 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Desperate Wells:</span>
|
||||
<span className="text-cyan-400">+50% regen (low mana)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Combat Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-red-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Swords className="w-4 h-4" />
|
||||
Combat Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Active Spell Base Damage:</span>
|
||||
<span className="text-gray-200">{activeSpellDef?.dmg || 5}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Combat Training Bonus:</span>
|
||||
<span className="text-red-300">+{(store.skills.combatTrain || 0) * 5}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Arcane Fury Multiplier:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.arcaneFury || 0) * 0.1, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elemental Mastery:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.elementalMastery || 0) * 0.15, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Guardian Bane:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Critical Hit Chance:</span>
|
||||
<span className="text-amber-300">{((store.skills.precision || 0) * 5)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Critical Multiplier:</span>
|
||||
<span className="text-amber-300">1.5x</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Spell Echo Chance:</span>
|
||||
<span className="text-amber-300">{((store.skills.spellEcho || 0) * 10)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Pact Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Total Damage:</span>
|
||||
<span className="text-red-400">{fmt(calcDamage(store, store.activeSpell))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pact Status */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
Pact Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Pact Slots:</span>
|
||||
<span className="text-amber-300">{store.signedPacts.length} / {1 + (store.prestigeUpgrades.pactCapacity || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Damage Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Insight Multiplier:</span>
|
||||
<span className="text-purple-300">×{fmtDec(pactInsightMultiplier, 2)}</span>
|
||||
</div>
|
||||
{store.signedPacts.length > 1 && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Interference Mitigation:</span>
|
||||
<span className="text-green-300">{Math.min(store.pactInterferenceMitigation || 0, 5) * 10}%</span>
|
||||
</div>
|
||||
{(store.pactInterferenceMitigation || 0) >= 5 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Synergy Bonus:</span>
|
||||
<span className="text-cyan-300">+{((store.pactInterferenceMitigation || 0) - 5) * 10}%</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400 mb-2">Unlocked Mana Types:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([, state]) => state.unlocked)
|
||||
.map(([id]) => {
|
||||
const elem = ELEMENTS[id];
|
||||
return (
|
||||
<Badge key={id} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
|
||||
{elem?.sym} {elem?.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Study Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Study Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Study Speed:</span>
|
||||
<span className="text-purple-300">×{fmtDec(studySpeedMult, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Quick Learner Bonus:</span>
|
||||
<span className="text-purple-300">+{((store.skills.quickLearner || 0) * 10)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Study Cost:</span>
|
||||
<span className="text-purple-300">{Math.round(studyCostMult * 100)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Focused Mind Bonus:</span>
|
||||
<span className="text-purple-300">-{((store.skills.focusedMind || 0) * 5)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Progress Retention:</span>
|
||||
<span className="text-purple-300">{Math.round((1 + (store.skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Element Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-green-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<FlaskConical className="w-4 h-4" />
|
||||
Element Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Element Capacity:</span>
|
||||
<span className="text-green-300">{elemMax}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elem. Attunement Bonus:</span>
|
||||
<span className="text-green-300">
|
||||
{(() => {
|
||||
const ea = store.skillTiers?.elemAttune || 1;
|
||||
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${level * 50 * tierMult}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Attunement:</span>
|
||||
<span className="text-green-300">+{(store.prestigeUpgrades.elementalAttune || 0) * 25}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Unlocked Elements:</span>
|
||||
<span className="text-green-300">{Object.values(store.elements).filter(e => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elem. Crafting Bonus:</span>
|
||||
<span className="text-green-300">×{fmtDec(1 + (store.skills.elemCrafting || 0) * 0.25, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
<div className="text-xs text-gray-400 mb-2">Elemental Mana Pools:</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([, state]) => state.unlocked)
|
||||
.map(([id, state]) => {
|
||||
const def = ELEMENTS[id];
|
||||
return (
|
||||
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 text-center">
|
||||
<div className="text-lg">{def?.sym}</div>
|
||||
<div className="text-xs text-gray-400">{state.current}/{state.max}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Upgrades */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Star className="w-4 h-4" />
|
||||
Active Skill Upgrades ({selectedUpgrades.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedUpgrades.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{selectedUpgrades.map(({ skillId, upgrade }) => (
|
||||
<div key={upgrade.id} className="p-2 rounded border border-amber-600/30 bg-amber-900/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-amber-300 text-sm font-semibold">{upgrade.name}</span>
|
||||
<Badge variant="outline" className="text-xs text-gray-400">
|
||||
{SKILLS_DEF[skillId]?.name || skillId}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect active'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Loop Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Loop Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||
<div className="text-xs text-gray-400">Current Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Total Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-green-400 game-mono">{store.maxFloorReached}</div>
|
||||
<div className="text-xs text-gray-400">Max Floor</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.spells).filter(s => s.learned).length}</div>
|
||||
<div className="text-xs text-gray-400">Spells Learned</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.skills).reduce((a, b) => a + b, 0)}</div>
|
||||
<div className="text-xs text-gray-400">Total Skill Levels</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{fmt(store.totalManaGathered)}</div>
|
||||
<div className="text-xs text-gray-400">Total Mana Gathered</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{store.memorySlots}</div>
|
||||
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/components/game/layout/GameFooter.tsx
Executable file
19
src/components/game/layout/GameFooter.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useGameContext } from '../GameContext';
|
||||
|
||||
export function GameFooter() {
|
||||
const { store } = useGameContext();
|
||||
|
||||
return (
|
||||
<footer className="sticky bottom-0 bg-gray-900/80 border-t border-gray-700 px-4 py-2 text-center text-xs text-gray-500">
|
||||
<span className="text-gray-400">Loop {store.loopCount + 1}</span>
|
||||
{' • '}
|
||||
<span>Pacts: {store.signedPacts.length}/{store.pactSlots}</span>
|
||||
{' • '}
|
||||
<span>Spells: {Object.values(store.spells).filter((s) => s.learned).length}</span>
|
||||
{' • '}
|
||||
<span>Skills: {Object.values(store.skills).reduce((a, b) => a + b, 0)}</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
79
src/components/game/layout/GameHeader.tsx
Executable file
79
src/components/game/layout/GameHeader.tsx
Executable file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Pause, Play } from 'lucide-react';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { formatTime } from '../types';
|
||||
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
|
||||
export function GameHeader() {
|
||||
const { store } = useGameContext();
|
||||
|
||||
// Calendar rendering
|
||||
const renderCalendar = () => {
|
||||
const days: React.ReactElement[] = [];
|
||||
for (let d = 1; d <= MAX_DAY; d++) {
|
||||
let dayClass = 'w-7 h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
|
||||
|
||||
if (d < store.day) {
|
||||
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
|
||||
} else if (d === store.day) {
|
||||
dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
|
||||
} else {
|
||||
dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
|
||||
}
|
||||
|
||||
if (d >= INCURSION_START_DAY) {
|
||||
dayClass += ' border-red-600/50';
|
||||
}
|
||||
|
||||
days.push(
|
||||
<Tooltip key={d}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={dayClass}>{d}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Day {d}</p>
|
||||
{d >= INCURSION_START_DAY && <p className="text-red-400">Incursion Active</p>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-gradient-to-b from-gray-900 to-gray-900/80 border-b border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold game-title tracking-wider">MANA LOOP</h1>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold game-mono text-amber-400">Day {store.day}</div>
|
||||
<div className="text-xs text-gray-400">{formatTime(store.hour)}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold game-mono text-purple-400">{fmt(store.insight)}</div>
|
||||
<div className="text-xs text-gray-400">Insight</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => store.togglePause()}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
{store.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="mt-2 flex gap-1 overflow-x-auto pb-1">{renderCalendar()}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
141
src/components/game/layout/GameSidebar.tsx
Executable file
141
src/components/game/layout/GameSidebar.tsx
Executable file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Zap, Sparkles, Swords, BookOpen, FlaskConical, type LucideIcon } from 'lucide-react';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { fmt, fmtDec } from '@/lib/game/stores';
|
||||
import type { GameAction } from '@/lib/game/types';
|
||||
|
||||
export function GameSidebar() {
|
||||
const {
|
||||
store,
|
||||
maxMana,
|
||||
clickMana,
|
||||
effectiveRegen,
|
||||
meditationMultiplier,
|
||||
floorElemDef,
|
||||
} = useGameContext();
|
||||
|
||||
const [isGathering, setIsGathering] = useState(false);
|
||||
|
||||
// Auto-gather while holding
|
||||
useEffect(() => {
|
||||
if (!isGathering) return;
|
||||
|
||||
let lastGatherTime = 0;
|
||||
const minGatherInterval = 100;
|
||||
let animationFrameId: number;
|
||||
|
||||
const gatherLoop = (timestamp: number) => {
|
||||
if (timestamp - lastGatherTime >= minGatherInterval) {
|
||||
store.gatherMana();
|
||||
lastGatherTime = timestamp;
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [isGathering, store]);
|
||||
|
||||
const handleGatherStart = useCallback(() => {
|
||||
setIsGathering(true);
|
||||
store.gatherMana();
|
||||
}, [store]);
|
||||
|
||||
const handleGatherEnd = useCallback(() => {
|
||||
setIsGathering(false);
|
||||
}, []);
|
||||
|
||||
// Action buttons
|
||||
const actions: { id: GameAction; label: string; icon: LucideIcon }[] = [
|
||||
{ id: 'meditate', label: 'Meditate', icon: Sparkles },
|
||||
{ id: 'climb', label: 'Climb', icon: Swords },
|
||||
{ id: 'study', label: 'Study', icon: BookOpen },
|
||||
{ id: 'convert', label: 'Convert', icon: FlaskConical },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="w-full md:w-64 bg-gray-900/50 border-b md:border-b-0 md:border-r border-gray-700 p-4 space-y-4">
|
||||
{/* Mana Panel */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(store.rawMana)}</span>
|
||||
<span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
+{fmtDec(effectiveRegen)} mana/hr{' '}
|
||||
{meditationMultiplier > 1.01 && (
|
||||
<span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={(store.rawMana / maxMana) * 100} className="h-2 bg-gray-800" />
|
||||
|
||||
<Button
|
||||
className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${
|
||||
isGathering ? 'animate-pulse' : ''
|
||||
}`}
|
||||
onMouseDown={handleGatherStart}
|
||||
onMouseUp={handleGatherEnd}
|
||||
onMouseLeave={handleGatherEnd}
|
||||
onTouchStart={handleGatherStart}
|
||||
onTouchEnd={handleGatherEnd}
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Gather +{clickMana} Mana
|
||||
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="pt-4">
|
||||
<div className="text-xs text-amber-400 game-panel-title mb-2">Current Action</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{actions.map(({ id, label, icon: Icon }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant={store.currentAction === id ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`h-9 ${
|
||||
store.currentAction === id
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'
|
||||
}`}
|
||||
onClick={() => store.setAction(id)}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-1" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Floor Status */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<div className="text-xs text-amber-400 game-panel-title mb-2">Floor Status</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Current</span>
|
||||
<span className="text-lg font-bold" style={{ color: floorElemDef?.color }}>
|
||||
{store.currentFloor}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(store.floorHP / store.floorMaxHP) * 100} className="h-2 bg-gray-800" />
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
76
src/components/game/shared/GameOverScreen.tsx
Executable file
76
src/components/game/shared/GameOverScreen.tsx
Executable file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { MemorySlotPicker } from './MemorySlotPicker';
|
||||
|
||||
export function GameOverScreen() {
|
||||
const { store } = useGameContext();
|
||||
const [memoriesConfirmed, setMemoriesConfirmed] = useState(false);
|
||||
|
||||
if (!store.gameOver) return null;
|
||||
|
||||
const handleStartNewLoop = () => {
|
||||
store.startNewLoop();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 game-overlay flex items-center justify-center z-50 overflow-auto py-4">
|
||||
<div className="max-w-lg w-full mx-4 space-y-4">
|
||||
<Card className="bg-gray-900 border-gray-600 shadow-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle
|
||||
className={`text-3xl text-center game-title ${store.victory ? 'text-amber-400' : 'text-red-400'}`}
|
||||
>
|
||||
{store.victory ? '🏆 VICTORY!' : '⏰ LOOP ENDS'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-center text-gray-400">
|
||||
{store.victory
|
||||
? 'The Awakened One falls! Your power echoes through eternity.'
|
||||
: 'The time loop resets... but you remember.'}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-amber-400 game-mono">{fmt(store.loopInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Insight Gained</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-blue-400 game-mono">{store.maxFloorReached}</div>
|
||||
<div className="text-xs text-gray-400">Best Floor</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-purple-400 game-mono">{store.signedPacts.length}</div>
|
||||
<div className="text-xs text-gray-400">Pacts Signed</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-green-400 game-mono">{store.loopCount + 1}</div>
|
||||
<div className="text-xs text-gray-400">Total Loops</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory Slot Picker */}
|
||||
{store.memorySlots > 0 && !memoriesConfirmed && (
|
||||
<MemorySlotPicker onConfirm={() => setMemoriesConfirmed(true)} />
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
||||
size="lg"
|
||||
onClick={handleStartNewLoop}
|
||||
disabled={store.memorySlots > 0 && !memoriesConfirmed}
|
||||
>
|
||||
Begin New Loop
|
||||
{store.memorySlots > 0 && !memoriesConfirmed && ' (Confirm Memories First)'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/components/game/shared/MemorySlotPicker.tsx
Executable file
206
src/components/game/shared/MemorySlotPicker.tsx
Executable file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Save, Trash2, Star, ChevronUp } from 'lucide-react';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { getTierMultiplier, getBaseSkillId } from '@/lib/game/skill-evolution';
|
||||
import type { Memory } from '@/lib/game/types';
|
||||
|
||||
interface MemorySlotPickerProps {
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export function MemorySlotPicker({ onConfirm }: MemorySlotPickerProps) {
|
||||
const { store } = useGameContext();
|
||||
const [selectedSkills, setSelectedSkills] = useState<Memory[]>(store.memories || []);
|
||||
|
||||
// Get all skills that have progress and can be saved
|
||||
const saveableSkills = useMemo(() => {
|
||||
const skills: { skillId: string; level: number; tier: number; upgrades: string[]; name: string }[] = [];
|
||||
|
||||
for (const [skillId, level] of Object.entries(store.skills)) {
|
||||
if (level && level > 0) {
|
||||
const baseSkillId = getBaseSkillId(skillId);
|
||||
const tier = store.skillTiers?.[baseSkillId] || 1;
|
||||
const tieredSkillId = tier > 1 ? `${baseSkillId}_t${tier}` : baseSkillId;
|
||||
const upgrades = store.skillUpgrades?.[tieredSkillId] || [];
|
||||
const skillDef = SKILLS_DEF[baseSkillId];
|
||||
|
||||
// Only include if it's a base skill (not a tiered variant in the skills object)
|
||||
if (skillId === baseSkillId || skillId.includes('_t')) {
|
||||
// Get the actual skill ID and level
|
||||
const actualLevel = store.skills[tieredSkillId] || store.skills[baseSkillId] || 0;
|
||||
if (actualLevel > 0) {
|
||||
skills.push({
|
||||
skillId: baseSkillId,
|
||||
level: actualLevel,
|
||||
tier,
|
||||
upgrades,
|
||||
name: skillDef?.name || baseSkillId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and keep highest tier/level
|
||||
const uniqueSkills = new Map<string, typeof skills[0]>();
|
||||
for (const skill of skills) {
|
||||
const existing = uniqueSkills.get(skill.skillId);
|
||||
if (!existing || skill.tier > existing.tier || (skill.tier === existing.tier && skill.level > existing.level)) {
|
||||
uniqueSkills.set(skill.skillId, skill);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueSkills.values()).sort((a, b) => {
|
||||
// Sort by tier then level then name
|
||||
if (a.tier !== b.tier) return b.tier - a.tier;
|
||||
if (a.level !== b.level) return b.level - a.level;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [store.skills, store.skillTiers, store.skillUpgrades]);
|
||||
|
||||
const isSkillSelected = (skillId: string) => selectedSkills.some(m => m.skillId === skillId);
|
||||
|
||||
const canAddMore = selectedSkills.length < store.memorySlots;
|
||||
|
||||
const toggleSkill = (skillId: string) => {
|
||||
const existingIndex = selectedSkills.findIndex(m => m.skillId === skillId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Remove it
|
||||
setSelectedSkills(selectedSkills.filter((_, i) => i !== existingIndex));
|
||||
} else if (canAddMore) {
|
||||
// Add it
|
||||
const skill = saveableSkills.find(s => s.skillId === skillId);
|
||||
if (skill) {
|
||||
setSelectedSkills([...selectedSkills, {
|
||||
skillId: skill.skillId,
|
||||
level: skill.level,
|
||||
tier: skill.tier,
|
||||
upgrades: skill.upgrades,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
// Clear and re-add selected memories
|
||||
store.clearMemories();
|
||||
for (const memory of selectedSkills) {
|
||||
store.addMemory(memory);
|
||||
}
|
||||
onConfirm?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-sm flex items-center gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
Memory Slots ({selectedSkills.length}/{store.memorySlots})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
Select skills to preserve in your memory. Saved skills will retain their level, tier, and upgrades in the next loop.
|
||||
</p>
|
||||
|
||||
{/* Selected Skills */}
|
||||
{selectedSkills.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-green-400 game-panel-title">Saved to Memory:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedSkills.map((memory) => {
|
||||
const skillDef = SKILLS_DEF[memory.skillId];
|
||||
return (
|
||||
<Badge
|
||||
key={memory.skillId}
|
||||
className="bg-amber-900/50 text-amber-200 cursor-pointer hover:bg-red-900/50"
|
||||
onClick={() => toggleSkill(memory.skillId)}
|
||||
>
|
||||
{skillDef?.name || memory.skillId}
|
||||
{' '}Lv.{memory.level}
|
||||
{memory.tier > 1 && ` T${memory.tier}`}
|
||||
{memory.upgrades.length > 0 && ` (${memory.upgrades.length}⭐)`}
|
||||
<Trash2 className="w-3 h-3 ml-1" />
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Skills */}
|
||||
<div className="text-xs text-gray-400 game-panel-title">Skills to Save:</div>
|
||||
<ScrollArea className="h-48">
|
||||
<div className="space-y-1 pr-2">
|
||||
{saveableSkills.length === 0 ? (
|
||||
<div className="text-gray-500 text-xs text-center py-4">
|
||||
No skills with progress to save
|
||||
</div>
|
||||
) : (
|
||||
saveableSkills.map((skill) => {
|
||||
const isSelected = isSkillSelected(skill.skillId);
|
||||
const tierMult = getTierMultiplier(skill.tier > 1 ? `${skill.skillId}_t${skill.tier}` : skill.skillId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skill.skillId}
|
||||
className={`p-2 rounded border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canAddMore
|
||||
? 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
|
||||
: 'border-gray-800 bg-gray-900/30 opacity-50'
|
||||
}`}
|
||||
onClick={() => toggleSkill(skill.skillId)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{skill.name}</span>
|
||||
{skill.tier > 1 && (
|
||||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
|
||||
Tier {skill.tier} ({tierMult}x)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400 text-sm">Lv.{skill.level}</span>
|
||||
{skill.upgrades.length > 0 && (
|
||||
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{skill.upgrades.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{skill.upgrades.length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Upgrades: {skill.upgrades.length} selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Confirm Button */}
|
||||
<Button
|
||||
className="w-full bg-amber-600 hover:bg-amber-700"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Confirm Memories
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
60
src/components/game/shared/StudyProgress.tsx
Executable file
60
src/components/game/shared/StudyProgress.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { BookOpen, X } from 'lucide-react';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { formatStudyTime } from '../types';
|
||||
import { SKILLS_DEF, SPELLS_DEF } from '@/lib/game/constants';
|
||||
|
||||
interface StudyProgressProps {
|
||||
target: NonNullable<ReturnType<typeof useGameContext>['store']['currentStudyTarget']>;
|
||||
showCancel?: boolean;
|
||||
speedLabel?: string;
|
||||
}
|
||||
|
||||
export function StudyProgress({ target, showCancel = true, speedLabel }: StudyProgressProps) {
|
||||
const { store, studySpeedMult } = useGameContext();
|
||||
|
||||
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||
const isSkill = target.type === 'skill';
|
||||
const def = isSkill ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
|
||||
const currentLevel = isSkill ? store.skills[target.id] || 0 : 0;
|
||||
|
||||
const handleCancel = () => {
|
||||
// Calculate retention bonus from knowledge retention skill
|
||||
const retentionBonus = 0.2 * (store.skills.knowledgeRetention || 0);
|
||||
store.cancelStudy(retentionBonus);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm font-semibold text-purple-300">
|
||||
{def?.name}
|
||||
{isSkill && ` Lv.${currentLevel + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
{showCancel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>
|
||||
{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}
|
||||
</span>
|
||||
<span>{speedLabel ?? `${studySpeedMult.toFixed(1)}x speed`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/game/shared/UpgradeDialog.tsx
Executable file
126
src/components/game/shared/UpgradeDialog.tsx
Executable file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
|
||||
interface UpgradeDialogProps {
|
||||
skillId: string | null;
|
||||
milestone: 5 | 10;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function UpgradeDialog({ skillId, milestone, onClose }: UpgradeDialogProps) {
|
||||
const { store } = useGameContext();
|
||||
|
||||
const skillDef = skillId ? SKILLS_DEF[skillId] : null;
|
||||
const { available, selected: alreadySelected } = skillId
|
||||
? store.getSkillUpgradeChoices(skillId, milestone)
|
||||
: { available: [], selected: [] };
|
||||
|
||||
// Use local state for selections within this dialog session
|
||||
const [pendingSelections, setPendingSelections] = useState<string[]>(() => [...alreadySelected]);
|
||||
|
||||
const toggleUpgrade = (upgradeId: string) => {
|
||||
setPendingSelections((prev) => {
|
||||
if (prev.includes(upgradeId)) {
|
||||
return prev.filter((id) => id !== upgradeId);
|
||||
} else if (prev.length < 2) {
|
||||
return [...prev, upgradeId];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
if (pendingSelections.length === 2 && skillId) {
|
||||
store.commitSkillUpgrades(skillId, pendingSelections);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setPendingSelections([...alreadySelected]);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render if no skill selected
|
||||
if (!skillId) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={!!skillId} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-amber-400">Choose Upgrade - {skillDef?.name || skillId}</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Level {milestone} Milestone - Select 2 upgrades ({pendingSelections.length}/2 chosen)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{available.map((upgrade) => {
|
||||
const isSelected = pendingSelections.includes(upgrade.id);
|
||||
const canToggle = pendingSelections.length < 2 || isSelected;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canToggle
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (canToggle) {
|
||||
toggleUpgrade(upgrade.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
|
||||
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">⚡ {upgrade.desc || 'Special effect'}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPendingSelections([...alreadySelected]);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={handleDone} disabled={pendingSelections.length !== 2}>
|
||||
{pendingSelections.length < 2 ? `Select ${2 - pendingSelections.length} more` : 'Confirm'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
582
src/components/game/tabs/FamiliarTab.tsx
Executable file
582
src/components/game/tabs/FamiliarTab.tsx
Executable file
@@ -0,0 +1,582 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Sparkles, Heart, Zap, Star, Shield, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull,
|
||||
Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, CircleDot, Circle,
|
||||
Sword, Wand2, ShieldCheck, TrendingUp, Clock, Crown
|
||||
} from 'lucide-react';
|
||||
import type { GameState, FamiliarInstance, FamiliarDef, FamiliarAbilityType } from '@/lib/game/types';
|
||||
import { FAMILIARS_DEF, getFamiliarXpRequired, getFamiliarAbilityValue } from '@/lib/game/data/familiars';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
// Element icon mapping
|
||||
const ELEMENT_ICONS: Record<string, typeof Flame> = {
|
||||
fire: Flame,
|
||||
water: Droplet,
|
||||
air: Wind,
|
||||
earth: Mountain,
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
life: Leaf,
|
||||
death: Skull,
|
||||
mental: Brain,
|
||||
transference: Link,
|
||||
force: Force,
|
||||
blood: Droplets,
|
||||
metal: Shield,
|
||||
wood: TreeDeciduous,
|
||||
sand: Hourglass,
|
||||
crystal: Gem,
|
||||
stellar: Star,
|
||||
void: CircleDot,
|
||||
raw: Circle,
|
||||
};
|
||||
|
||||
// Rarity colors
|
||||
const RARITY_COLORS: Record<string, string> = {
|
||||
common: 'text-gray-400 border-gray-600',
|
||||
uncommon: 'text-green-400 border-green-600',
|
||||
rare: 'text-blue-400 border-blue-600',
|
||||
epic: 'text-purple-400 border-purple-600',
|
||||
legendary: 'text-amber-400 border-amber-600',
|
||||
};
|
||||
|
||||
const RARITY_BG: Record<string, string> = {
|
||||
common: 'bg-gray-900/50',
|
||||
uncommon: 'bg-green-900/20',
|
||||
rare: 'bg-blue-900/20',
|
||||
epic: 'bg-purple-900/20',
|
||||
legendary: 'bg-amber-900/20',
|
||||
};
|
||||
|
||||
// Role icons
|
||||
const ROLE_ICONS: Record<string, typeof Sword> = {
|
||||
combat: Sword,
|
||||
mana: Sparkles,
|
||||
support: Heart,
|
||||
guardian: ShieldCheck,
|
||||
};
|
||||
|
||||
// Ability type icons
|
||||
const ABILITY_ICONS: Record<FamiliarAbilityType, typeof Zap> = {
|
||||
damageBonus: Sword,
|
||||
manaRegen: Sparkles,
|
||||
autoGather: Zap,
|
||||
critChance: Star,
|
||||
castSpeed: Clock,
|
||||
manaShield: Shield,
|
||||
elementalBonus: Flame,
|
||||
lifeSteal: Heart,
|
||||
bonusGold: TrendingUp,
|
||||
autoConvert: Wand2,
|
||||
thorns: ShieldCheck,
|
||||
};
|
||||
|
||||
interface FamiliarTabProps {
|
||||
store: GameState & {
|
||||
setActiveFamiliar: (index: number, active: boolean) => void;
|
||||
setFamiliarNickname: (index: number, nickname: string) => void;
|
||||
summonFamiliar: (familiarId: string) => void;
|
||||
upgradeFamiliarAbility: (index: number, abilityType: FamiliarAbilityType) => void;
|
||||
getActiveFamiliarBonuses: () => ReturnType<typeof import('@/lib/game/familiar-slice').createFamiliarSlice>['getActiveFamiliarBonuses'] extends () => infer R ? R : never;
|
||||
getAvailableFamiliars: () => string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function FamiliarTab({ store }: FamiliarTabProps) {
|
||||
const [selectedFamiliar, setSelectedFamiliar] = useState<number | null>(null);
|
||||
const [nicknameInput, setNicknameInput] = useState('');
|
||||
|
||||
const familiars = store.familiars;
|
||||
const activeFamiliarSlots = store.activeFamiliarSlots;
|
||||
const activeCount = familiars.filter(f => f.active).length;
|
||||
const availableFamiliars = store.getAvailableFamiliars();
|
||||
const familiarBonuses = store.getActiveFamiliarBonuses();
|
||||
|
||||
// Format XP display
|
||||
const formatXp = (current: number, level: number) => {
|
||||
const required = getFamiliarXpRequired(level);
|
||||
return `${current}/${required}`;
|
||||
};
|
||||
|
||||
// Get familiar definition
|
||||
const getFamiliarDef = (instance: FamiliarInstance): FamiliarDef | undefined => {
|
||||
return FAMILIARS_DEF[instance.familiarId];
|
||||
};
|
||||
|
||||
// Render a single familiar card
|
||||
const renderFamiliarCard = (instance: FamiliarInstance, index: number) => {
|
||||
const def = getFamiliarDef(instance);
|
||||
if (!def) return null;
|
||||
|
||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||
const RoleIcon = ROLE_ICONS[def.role] || Sparkles;
|
||||
const xpRequired = getFamiliarXpRequired(instance.level);
|
||||
const xpPercent = Math.min(100, (instance.experience / xpRequired) * 100);
|
||||
const bondPercent = instance.bond;
|
||||
const isSelected = selectedFamiliar === index;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${instance.familiarId}-${index}`}
|
||||
className={`cursor-pointer transition-all ${RARITY_BG[def.rarity]} ${
|
||||
isSelected ? 'ring-2 ring-amber-500' : ''
|
||||
} ${instance.active ? 'ring-1 ring-green-500/50' : ''} border ${RARITY_COLORS[def.rarity]}`}
|
||||
onClick={() => setSelectedFamiliar(isSelected ? null : index)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 rounded-full bg-black/30 flex items-center justify-center">
|
||||
<ElementIcon className="w-6 h-6" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className={`text-sm ${RARITY_COLORS[def.rarity]}`}>
|
||||
{instance.nickname || def.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<RoleIcon className="w-3 h-3" />
|
||||
<span>Lv.{instance.level}</span>
|
||||
{instance.active && (
|
||||
<Badge className="ml-1 bg-green-900/50 text-green-300 text-xs py-0">Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`${RARITY_COLORS[def.rarity]} text-xs`}>
|
||||
{def.rarity}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* XP Bar */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>XP</span>
|
||||
<span>{formatXp(instance.experience, instance.level)}</span>
|
||||
</div>
|
||||
<Progress value={xpPercent} className="h-1.5 bg-gray-800" />
|
||||
</div>
|
||||
|
||||
{/* Bond Bar */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="w-3 h-3" /> Bond
|
||||
</span>
|
||||
<span>{bondPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={bondPercent} className="h-1.5 bg-gray-800" />
|
||||
</div>
|
||||
|
||||
{/* Abilities Preview */}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{instance.abilities.slice(0, 3).map(ability => {
|
||||
const abilityDef = def.abilities.find(a => a.type === ability.type);
|
||||
if (!abilityDef) return null;
|
||||
const AbilityIcon = ABILITY_ICONS[ability.type] || Zap;
|
||||
const value = getFamiliarAbilityValue(abilityDef, instance.level, ability.level);
|
||||
|
||||
return (
|
||||
<Tooltip key={ability.type}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="text-xs py-0 flex items-center gap-1">
|
||||
<AbilityIcon className="w-3 h-3" />
|
||||
{ability.type === 'damageBonus' || ability.type === 'elementalBonus' ||
|
||||
ability.type === 'castSpeed' || ability.type === 'critChance' ||
|
||||
ability.type === 'lifeSteal' || ability.type === 'thorns' ||
|
||||
ability.type === 'bonusGold'
|
||||
? `+${value.toFixed(1)}%`
|
||||
: `+${value.toFixed(1)}`}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-sm">{abilityDef.desc}</p>
|
||||
<p className="text-xs text-gray-400">Level {ability.level}/10</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Render selected familiar details
|
||||
const renderFamiliarDetails = () => {
|
||||
if (selectedFamiliar === null || selectedFamiliar >= familiars.length) return null;
|
||||
|
||||
const instance = familiars[selectedFamiliar];
|
||||
const def = getFamiliarDef(instance);
|
||||
if (!def) return null;
|
||||
|
||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-amber-400 text-sm">Familiar Details</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setSelectedFamiliar(null)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name and nickname */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-12 h-12 rounded-full bg-black/30 flex items-center justify-center">
|
||||
<ElementIcon className="w-8 h-8" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-lg font-bold ${RARITY_COLORS[def.rarity]}`}>
|
||||
{def.name}
|
||||
</h3>
|
||||
{instance.nickname && (
|
||||
<p className="text-sm text-gray-400">"{instance.nickname}"</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nickname input */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={nicknameInput}
|
||||
onChange={(e) => setNicknameInput(e.target.value)}
|
||||
placeholder="Set nickname..."
|
||||
className="h-8 text-sm bg-gray-800 border-gray-600"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
store.setFamiliarNickname(selectedFamiliar, nicknameInput);
|
||||
setNicknameInput('');
|
||||
}}
|
||||
disabled={!nicknameInput.trim()}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-sm text-gray-300 italic">
|
||||
{def.desc}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Level:</span>
|
||||
<span className="text-white">{instance.level}/100</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Bond:</span>
|
||||
<span className="text-white">{instance.bond.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Role:</span>
|
||||
<span className="text-white capitalize">{def.role}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Element:</span>
|
||||
<span style={{ color: ELEMENTS[def.element]?.color }}>{def.element}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
{/* Abilities */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-gray-300">Abilities</h4>
|
||||
{instance.abilities.map(ability => {
|
||||
const abilityDef = def.abilities.find(a => a.type === ability.type);
|
||||
if (!abilityDef) return null;
|
||||
const AbilityIcon = ABILITY_ICONS[ability.type] || Zap;
|
||||
const value = getFamiliarAbilityValue(abilityDef, instance.level, ability.level);
|
||||
const upgradeCost = ability.level * 100;
|
||||
const canUpgrade = instance.experience >= upgradeCost && ability.level < 10;
|
||||
|
||||
return (
|
||||
<div key={ability.type} className="p-2 rounded bg-gray-800/50 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<AbilityIcon className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{ability.type.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">Lv.{ability.level}/10</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">{abilityDef.desc}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-green-400">
|
||||
Current: +{value.toFixed(2)}
|
||||
{ability.type === 'damageBonus' || ability.type === 'elementalBonus' ||
|
||||
ability.type === 'castSpeed' || ability.type === 'critChance' ||
|
||||
ability.type === 'lifeSteal' || ability.type === 'thorns' ||
|
||||
ability.type === 'bonusGold' ? '%' : ''}
|
||||
</span>
|
||||
{ability.level < 10 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs"
|
||||
disabled={!canUpgrade}
|
||||
onClick={() => store.upgradeFamiliarAbility(selectedFamiliar, ability.type)}
|
||||
>
|
||||
Upgrade ({upgradeCost} XP)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Activate/Deactivate */}
|
||||
<Button
|
||||
className={`w-full ${instance.active ? 'bg-red-900/50 hover:bg-red-800/50' : 'bg-green-900/50 hover:bg-green-800/50'}`}
|
||||
onClick={() => store.setActiveFamiliar(selectedFamiliar, !instance.active)}
|
||||
disabled={!instance.active && activeCount >= activeFamiliarSlots}
|
||||
>
|
||||
{instance.active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
|
||||
{/* Flavor text */}
|
||||
{def.flavorText && (
|
||||
<p className="text-xs text-gray-500 italic text-center">
|
||||
"{def.flavorText}"
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Render summonable familiars
|
||||
const renderSummonableFamiliars = () => {
|
||||
if (availableFamiliars.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Available to Summon ({availableFamiliars.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-48">
|
||||
<div className="space-y-2">
|
||||
{availableFamiliars.map(familiarId => {
|
||||
const def = FAMILIARS_DEF[familiarId];
|
||||
if (!def) return null;
|
||||
|
||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||
const RoleIcon = ROLE_ICONS[def.role] || Sparkles;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={familiarId}
|
||||
className={`p-2 rounded border ${RARITY_COLORS[def.rarity]} ${RARITY_BG[def.rarity]} flex items-center justify-between`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ElementIcon className="w-5 h-5" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||
<div>
|
||||
<div className={`text-sm font-medium ${RARITY_COLORS[def.rarity]}`}>
|
||||
{def.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<RoleIcon className="w-3 h-3" />
|
||||
{def.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
onClick={() => store.summonFamiliar(familiarId)}
|
||||
>
|
||||
Summon
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Render active bonuses
|
||||
const renderActiveBonuses = () => {
|
||||
const hasBonuses = Object.entries(familiarBonuses).some(([key, value]) => {
|
||||
if (key === 'damageMultiplier' || key === 'castSpeedMultiplier' ||
|
||||
key === 'elementalDamageMultiplier' || key === 'insightMultiplier') {
|
||||
return value > 1;
|
||||
}
|
||||
return value > 0;
|
||||
});
|
||||
|
||||
if (!hasBonuses) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-green-400 text-sm flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Active Familiar Bonuses
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-sm">
|
||||
{familiarBonuses.damageMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sword className="w-4 h-4 text-red-400" />
|
||||
<span>+{((familiarBonuses.damageMultiplier - 1) * 100).toFixed(0)}% DMG</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.manaRegenBonus > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-blue-400" />
|
||||
<span>+{familiarBonuses.manaRegenBonus.toFixed(1)} regen</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.autoGatherRate > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" />
|
||||
<span>+{familiarBonuses.autoGatherRate.toFixed(1)}/hr gather</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.critChanceBonus > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-4 h-4 text-amber-400" />
|
||||
<span>+{familiarBonuses.critChanceBonus.toFixed(1)}% crit</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.castSpeedMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-cyan-400" />
|
||||
<span>+{((familiarBonuses.castSpeedMultiplier - 1) * 100).toFixed(0)}% speed</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.elementalDamageMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className="w-4 h-4 text-orange-400" />
|
||||
<span>+{((familiarBonuses.elementalDamageMultiplier - 1) * 100).toFixed(0)}% elem</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.lifeStealPercent > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className="w-4 h-4 text-red-400" />
|
||||
<span>+{familiarBonuses.lifeStealPercent.toFixed(0)}% lifesteal</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.insightMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-purple-400" />
|
||||
<span>+{((familiarBonuses.insightMultiplier - 1) * 100).toFixed(0)}% insight</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Owned Familiars */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Heart className="w-4 h-4" />
|
||||
Your Familiars ({familiars.length})
|
||||
</CardTitle>
|
||||
<div className="text-xs text-gray-400">
|
||||
Active Slots: {activeCount}/{activeFamiliarSlots}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{familiars.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{familiars.map((instance, index) => renderFamiliarCard(instance, index))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
No familiars yet. Progress through the game to summon companions!
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Bonuses */}
|
||||
{renderActiveBonuses()}
|
||||
|
||||
{/* Selected Familiar Details */}
|
||||
{renderFamiliarDetails()}
|
||||
|
||||
{/* Summonable Familiars */}
|
||||
{renderSummonableFamiliars()}
|
||||
|
||||
{/* Familiar Guide */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
|
||||
<Crown className="w-4 h-4" />
|
||||
Familiar Guide
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Acquiring Familiars</h4>
|
||||
<p>Familiars become available to summon as you progress through floors, gather mana, and sign pacts with guardians. Higher rarity familiars are unlocked later.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Leveling & Bond</h4>
|
||||
<p>Active familiars gain XP from combat, gathering, and time. Higher bond increases their power and XP gain. Upgrade abilities using XP to boost their effects.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Roles</h4>
|
||||
<p>
|
||||
<span className="text-red-400">Combat</span> - Damage and crit bonuses<br/>
|
||||
<span className="text-blue-400">Mana</span> - Regeneration and auto-gathering<br/>
|
||||
<span className="text-green-400">Support</span> - Speed and utility<br/>
|
||||
<span className="text-amber-400">Guardian</span> - Defense and shields
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Active Slots</h4>
|
||||
<p>You can have 1 familiar active by default. Upgrade through prestige to unlock more active slots for stacking bonuses.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Golem, Zap, Clock, Swords, Shield, Target, Sparkles, Lock, Check, X
|
||||
Mountain, Zap, Clock, Swords, Target, Sparkles, Lock, Check, X
|
||||
} from 'lucide-react';
|
||||
import { GOLEMS_DEF, getGolemSlots, isGolemUnlocked, getGolemDamage, getGolemAttackSpeed, getGolemFloorDuration } from '@/lib/game/data/golems';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { fmt } from '@/lib/game/store';
|
||||
import type { GameStore } from '@/lib/game/store';
|
||||
import type { GolemancyState, AttunementState, ElementState } from '@/lib/game/types';
|
||||
|
||||
export interface GolemancyTabProps {
|
||||
store: GameStore;
|
||||
@@ -108,7 +105,7 @@ export function GolemancyTab({ store }: GolemancyTabProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Golem className="w-4 h-4" style={{ color: elementColor }} />
|
||||
<Mountain className="w-4 h-4" style={{ color: elementColor }} />
|
||||
<span style={{ color: elementColor }}>{golem.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -215,7 +212,7 @@ export function GolemancyTab({ store }: GolemancyTabProps) {
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Golem className="w-5 h-5 text-amber-500" />
|
||||
<Mountain className="w-5 h-5 text-amber-500" />
|
||||
Golemancy
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -278,7 +275,7 @@ export function GolemancyTab({ store }: GolemancyTabProps) {
|
||||
|
||||
return (
|
||||
<Badge key={sg.golemId} variant="outline" className="text-sm py-1 px-2">
|
||||
<Golem className="w-3 h-3 mr-1" style={{ color: elem?.color }} />
|
||||
<Mountain className="w-3 h-3 mr-1" style={{ color: elem?.color }} />
|
||||
{golem?.name}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
567
src/components/game/tabs/GrimoireTab.tsx
Executable file
567
src/components/game/tabs/GrimoireTab.tsx
Executable file
@@ -0,0 +1,567 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { RotateCcw, Save, Trash2, Star, Flame, Clock, AlertCircle } from 'lucide-react';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { GUARDIANS, PRESTIGE_DEF, SKILLS_DEF, ELEMENTS } from '@/lib/game/constants';
|
||||
import { getTierMultiplier, getBaseSkillId } from '@/lib/game/skill-evolution';
|
||||
import { fmt, fmtDec, getBoonBonuses } from '@/lib/game/stores';
|
||||
import type { Memory } from '@/lib/game/types';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
export function GrimoireTab() {
|
||||
const { store } = useGameContext();
|
||||
const [showMemoryPicker, setShowMemoryPicker] = useState(false);
|
||||
|
||||
// Get all skills that can be saved to memory
|
||||
const saveableSkills = useMemo(() => {
|
||||
const skills: { skillId: string; level: number; tier: number; upgrades: string[]; name: string }[] = [];
|
||||
|
||||
for (const [skillId, level] of Object.entries(store.skills)) {
|
||||
if (level && level > 0) {
|
||||
const baseSkillId = getBaseSkillId(skillId);
|
||||
const tier = store.skillTiers?.[baseSkillId] || 1;
|
||||
const tieredSkillId = tier > 1 ? `${baseSkillId}_t${tier}` : baseSkillId;
|
||||
const upgrades = store.skillUpgrades?.[tieredSkillId] || [];
|
||||
const skillDef = SKILLS_DEF[baseSkillId];
|
||||
|
||||
if (skillId === baseSkillId || skillId.includes('_t')) {
|
||||
const actualLevel = store.skills[tieredSkillId] || store.skills[baseSkillId] || 0;
|
||||
if (actualLevel > 0) {
|
||||
skills.push({
|
||||
skillId: baseSkillId,
|
||||
level: actualLevel,
|
||||
tier,
|
||||
upgrades,
|
||||
name: skillDef?.name || baseSkillId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueSkills = new Map<string, typeof skills[0]>();
|
||||
for (const skill of skills) {
|
||||
const existing = uniqueSkills.get(skill.skillId);
|
||||
if (!existing || skill.tier > existing.tier || (skill.tier === existing.tier && skill.level > existing.level)) {
|
||||
uniqueSkills.set(skill.skillId, skill);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueSkills.values()).sort((a, b) => {
|
||||
if (a.tier !== b.tier) return b.tier - a.tier;
|
||||
if (a.level !== b.level) return b.level - a.level;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [store.skills, store.skillTiers, store.skillUpgrades]);
|
||||
|
||||
const isSkillInMemory = (skillId: string) => store.memories.some(m => m.skillId === skillId);
|
||||
const canAddMore = store.memories.length < store.memorySlots;
|
||||
|
||||
const addToMemory = (skill: typeof saveableSkills[0]) => {
|
||||
const memory: Memory = {
|
||||
skillId: skill.skillId,
|
||||
level: skill.level,
|
||||
tier: skill.tier,
|
||||
upgrades: skill.upgrades,
|
||||
};
|
||||
store.addMemory(memory);
|
||||
};
|
||||
|
||||
// Calculate total boons from active pacts
|
||||
const activeBoons = useMemo(() => {
|
||||
return getBoonBonuses(store.signedPacts);
|
||||
}, [store.signedPacts]);
|
||||
|
||||
// Check if player can sign a pact
|
||||
const canSignPact = (floor: number) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return false;
|
||||
if (!store.defeatedGuardians.includes(floor)) return false;
|
||||
if (store.signedPacts.includes(floor)) return false;
|
||||
if (store.signedPacts.length >= store.pactSlots) return false;
|
||||
if (store.rawMana < guardian.pactCost) return false;
|
||||
if (store.pactRitualFloor !== null) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Get pact affinity bonus for display
|
||||
const pactAffinityBonus = (store.prestigeUpgrades.pactAffinity || 0) * 10;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Current Status */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||
<div className="text-xs text-gray-400">Current Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Total Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
|
||||
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pact Slots & Active Ritual */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Flame className="w-4 h-4" />
|
||||
Pact Slots ({store.signedPacts.length}/{store.pactSlots})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Active Ritual Progress */}
|
||||
{store.pactRitualFloor !== null && (
|
||||
<div className="mb-4 p-3 rounded border-2 border-amber-500/50 bg-amber-900/20">
|
||||
{(() => {
|
||||
const guardian = GUARDIANS[store.pactRitualFloor];
|
||||
if (!guardian) return null;
|
||||
const requiredTime = guardian.pactTime * (1 - (store.prestigeUpgrades.pactAffinity || 0) * 0.1);
|
||||
const progress = Math.min(100, (store.pactRitualProgress / requiredTime) * 100);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-amber-300 font-semibold text-sm">Signing Pact with {guardian.name}</span>
|
||||
<span className="text-xs text-gray-400">{fmtDec(progress, 1)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-700 rounded overflow-hidden mb-2">
|
||||
<div
|
||||
className="h-full bg-amber-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-400">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
{fmtDec(store.pactRitualProgress, 1)}h / {fmtDec(requiredTime, 1)}h
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs border-red-500/50 text-red-400 hover:bg-red-900/20"
|
||||
onClick={() => store.cancelPactRitual()}
|
||||
>
|
||||
Cancel Ritual
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Pacts */}
|
||||
{store.signedPacts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{store.signedPacts.map((floor) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return null;
|
||||
return (
|
||||
<div
|
||||
key={floor}
|
||||
className="p-3 rounded border"
|
||||
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}15` }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||
{guardian.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Floor {floor} Guardian</div>
|
||||
<div className="text-xs text-amber-300 mt-1 italic">"{guardian.uniquePerk}"</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
||||
onClick={() => store.removePact(floor)}
|
||||
title="Break Pact"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{guardian.boons.map((boon, idx) => (
|
||||
<Badge key={idx} className="bg-gray-700/50 text-gray-200 text-xs">
|
||||
{boon.desc}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm text-center py-4">
|
||||
No active pacts. Defeat guardians and sign pacts to gain boons.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Available Guardians for Pacts */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Available Guardians ({store.defeatedGuardians.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{store.defeatedGuardians.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm text-center py-4">
|
||||
Defeat guardians in the Spire to make them available for pacts.
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2 pr-2">
|
||||
{store.defeatedGuardians
|
||||
.sort((a, b) => a - b)
|
||||
.map((floor) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return null;
|
||||
const canSign = canSignPact(floor);
|
||||
const notEnoughMana = store.rawMana < guardian.pactCost;
|
||||
const atCapacity = store.signedPacts.length >= store.pactSlots;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={floor}
|
||||
className="p-3 rounded border border-gray-700 bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||
{guardian.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Floor {floor} • {ELEMENTS[guardian.element]?.name || guardian.element}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-amber-300">{fmt(guardian.pactCost)} mana</div>
|
||||
<div className="text-xs text-gray-400">{guardian.pactTime}h ritual</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-purple-300 italic mb-2">"{guardian.uniquePerk}"</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{guardian.boons.map((boon, idx) => (
|
||||
<Badge key={idx} className="bg-gray-700/50 text-gray-200 text-xs">
|
||||
{boon.desc}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canSign ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
disabled={!canSign}
|
||||
onClick={() => store.startPactRitual(floor, store.rawMana)}
|
||||
>
|
||||
{atCapacity
|
||||
? 'Pact Slots Full'
|
||||
: notEnoughMana
|
||||
? 'Not Enough Mana'
|
||||
: store.pactRitualFloor !== null
|
||||
? 'Ritual in Progress'
|
||||
: 'Start Pact Ritual'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory Slots */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
Memory Slots ({store.memories.length}/{store.memorySlots})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Skills saved to memory will retain their level, tier, and upgrades when you start a new loop.
|
||||
</p>
|
||||
|
||||
{/* Saved Memories */}
|
||||
{store.memories.length > 0 ? (
|
||||
<div className="space-y-1 mb-3">
|
||||
{store.memories.map((memory) => {
|
||||
const skillDef = SKILLS_DEF[memory.skillId];
|
||||
const tierMult = getTierMultiplier(memory.tier > 1 ? `${memory.skillId}_t${memory.tier}` : memory.skillId);
|
||||
return (
|
||||
<div
|
||||
key={memory.skillId}
|
||||
className="flex items-center justify-between p-2 rounded border border-amber-600/30 bg-amber-900/10"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-amber-300">{skillDef?.name || memory.skillId}</span>
|
||||
{memory.tier > 1 && (
|
||||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
|
||||
T{memory.tier} ({tierMult}x)
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-purple-400 text-xs">Lv.{memory.level}</span>
|
||||
{memory.upgrades.length > 0 && (
|
||||
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{memory.upgrades.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
||||
onClick={() => store.removeMemory(memory.skillId)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm mb-3 text-center py-2">
|
||||
No memories saved. Add skills below.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Memory Button */}
|
||||
{canAddMore && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowMemoryPicker(!showMemoryPicker)}
|
||||
>
|
||||
{showMemoryPicker ? 'Hide Skills' : 'Add Skill to Memory'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Skill Picker */}
|
||||
{showMemoryPicker && canAddMore && (
|
||||
<ScrollArea className="h-48 mt-2">
|
||||
<div className="space-y-1 pr-2">
|
||||
{saveableSkills.length === 0 ? (
|
||||
<div className="text-gray-500 text-xs text-center py-4">
|
||||
No skills with progress to save
|
||||
</div>
|
||||
) : (
|
||||
saveableSkills.map((skill) => {
|
||||
const isInMemory = isSkillInMemory(skill.skillId);
|
||||
const tierMult = getTierMultiplier(skill.tier > 1 ? `${skill.skillId}_t${skill.tier}` : skill.skillId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skill.skillId}
|
||||
className={`p-2 rounded border cursor-pointer transition-all ${
|
||||
isInMemory
|
||||
? 'border-amber-500 bg-amber-900/30 opacity-50'
|
||||
: 'border-gray-700 bg-gray-800/50 hover:border-amber-500/50'
|
||||
}`}
|
||||
onClick={() => !isInMemory && addToMemory(skill)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{skill.name}</span>
|
||||
{skill.tier > 1 && (
|
||||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
|
||||
T{skill.tier} ({tierMult}x)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400 text-sm">Lv.{skill.level}</span>
|
||||
{skill.upgrades.length > 0 && (
|
||||
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{skill.upgrades.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Boons Summary */}
|
||||
{store.signedPacts.length > 0 && (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Boons Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{activeBoons.maxMana > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Max Mana:</span>
|
||||
<span className="text-blue-300">+{activeBoons.maxMana}</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.manaRegen > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Mana Regen:</span>
|
||||
<span className="text-blue-300">+{activeBoons.manaRegen}/h</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.castingSpeed > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Cast Speed:</span>
|
||||
<span className="text-amber-300">+{activeBoons.castingSpeed}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.elementalDamage > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Elem. Damage:</span>
|
||||
<span className="text-red-300">+{activeBoons.elementalDamage}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.rawDamage > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Raw Damage:</span>
|
||||
<span className="text-red-300">+{activeBoons.rawDamage}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.critChance > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Crit Chance:</span>
|
||||
<span className="text-yellow-300">+{activeBoons.critChance}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.critDamage > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Crit Damage:</span>
|
||||
<span className="text-yellow-300">+{activeBoons.critDamage}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.spellEfficiency > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Spell Cost:</span>
|
||||
<span className="text-green-300">-{activeBoons.spellEfficiency}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.manaGain > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Mana Gain:</span>
|
||||
<span className="text-blue-300">+{activeBoons.manaGain}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.insightGain > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Insight Gain:</span>
|
||||
<span className="text-purple-300">+{activeBoons.insightGain}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.studySpeed > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Study Speed:</span>
|
||||
<span className="text-cyan-300">+{activeBoons.studySpeed}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.prestigeInsight > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Prestige Insight:</span>
|
||||
<span className="text-purple-300">+{activeBoons.prestigeInsight}/loop</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Prestige Upgrades */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
|
||||
const level = store.prestigeUpgrades[id] || 0;
|
||||
const maxed = level >= def.max;
|
||||
const canBuy = !maxed && store.insight >= def.cost;
|
||||
|
||||
return (
|
||||
<div key={id} className="p-3 rounded border border-gray-700 bg-gray-800/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{level}/{def.max}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canBuy ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
disabled={!canBuy}
|
||||
onClick={() => store.doPrestige(id)}
|
||||
>
|
||||
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reset Game Button */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Reset All Progress</div>
|
||||
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
|
||||
store.resetGame();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export function SkillsTab({ store }: SkillsTabProps) {
|
||||
const handleConfirm = () => {
|
||||
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
|
||||
if (currentSelections.length === 2 && upgradeDialogSkill) {
|
||||
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections);
|
||||
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
|
||||
}
|
||||
setPendingUpgradeSelections([]);
|
||||
setUpgradeDialogSkill(null);
|
||||
|
||||
@@ -7,9 +7,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X } from 'lucide-react';
|
||||
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X, Mountain } from 'lucide-react';
|
||||
import type { GameStore } from '@/lib/game/types';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems';
|
||||
import { fmt, fmtDec, getFloorElement, canAffordSpellCost } from '@/lib/game/store';
|
||||
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
|
||||
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
||||
@@ -204,6 +205,58 @@ export function SpireTab({ store }: SpireTabProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summoned Golems Card */}
|
||||
{store.golemancy.summonedGolems.length > 0 && (
|
||||
<Card className="bg-gray-900/80 border-amber-600/50">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Mountain className="w-4 h-4" />
|
||||
Active Golems ({store.golemancy.summonedGolems.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{store.golemancy.summonedGolems.map((summoned) => {
|
||||
const golemDef = GOLEMS_DEF[summoned.golemId];
|
||||
if (!golemDef) return null;
|
||||
|
||||
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
|
||||
const damage = getGolemDamage(summoned.golemId, store.skills);
|
||||
const attackSpeed = getGolemAttackSpeed(summoned.golemId, store.skills);
|
||||
|
||||
return (
|
||||
<div key={summoned.golemId} className="p-2 rounded border border-gray-700 bg-gray-800/30">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mountain className="w-4 h-4" style={{ color: elemColor }} />
|
||||
<span className="text-sm font-semibold game-panel-title" style={{ color: elemColor }}>
|
||||
{golemDef.name}
|
||||
</span>
|
||||
</div>
|
||||
{golemDef.isAoe && (
|
||||
<Badge variant="outline" className="text-xs">AOE {golemDef.aoeTargets}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
⚔️ {damage} DMG • ⚡ {attackSpeed.toFixed(1)}/hr •
|
||||
🛡️ {Math.floor(golemDef.armorPierce * 100)}% Pierce
|
||||
</div>
|
||||
{/* Attack progress bar when climbing */}
|
||||
{store.currentAction === 'climb' && summoned.attackProgress > 0 && (
|
||||
<div className="space-y-0.5 mt-1">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>Attack</span>
|
||||
<span>{Math.min(100, (summoned.attackProgress * 100)).toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={Math.min(100, summoned.attackProgress * 100)} className="h-1.5 bg-gray-700" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current Study (if any) */}
|
||||
{store.currentStudyTarget && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
||||
|
||||
47
src/components/game/types.ts
Executable file
47
src/components/game/types.ts
Executable file
@@ -0,0 +1,47 @@
|
||||
import type { SpellCost } from '@/lib/game/types';
|
||||
import { Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull } from 'lucide-react';
|
||||
|
||||
// Format spell cost for display
|
||||
export function formatSpellCost(cost: SpellCost): string {
|
||||
if (cost.type === 'raw') {
|
||||
return `${cost.amount} raw`;
|
||||
}
|
||||
const elemDef = ELEMENTS[cost.element || ''];
|
||||
return `${cost.amount} ${elemDef?.sym || '?'}`;
|
||||
}
|
||||
|
||||
// Format time (hour to HH:MM)
|
||||
export function formatTime(hour: number): string {
|
||||
const h = Math.floor(hour);
|
||||
const m = Math.floor((hour % 1) * 60);
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Format study time
|
||||
export function formatStudyTime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
// Element icons mapping
|
||||
export const ELEMENT_ICONS: Record<string, typeof Flame> = {
|
||||
fire: Flame,
|
||||
water: Droplet,
|
||||
wind: Wind,
|
||||
earth: Mountain,
|
||||
light: Sun,
|
||||
shadow: Moon,
|
||||
nature: Leaf,
|
||||
death: Skull,
|
||||
};
|
||||
|
||||
// Import ELEMENTS at the bottom to avoid circular deps
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
// Get cost color
|
||||
export function getSpellCostColor(cost: SpellCost): string {
|
||||
if (cost.type === 'raw') {
|
||||
return '#60A5FA'; // Blue for raw mana
|
||||
}
|
||||
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
|
||||
}
|
||||
519
src/lib/game/data/familiars.ts
Executable file
519
src/lib/game/data/familiars.ts
Executable file
@@ -0,0 +1,519 @@
|
||||
// ─── Familiar Definitions ───────────────────────────────────────────────────────
|
||||
// Magical companions that provide passive bonuses and active assistance
|
||||
|
||||
import type { FamiliarDef, FamiliarAbility } from '../types';
|
||||
|
||||
// ─── Familiar Abilities ─────────────────────────────────────────────────────────
|
||||
|
||||
const ABILITIES = {
|
||||
// Combat abilities
|
||||
damageBonus: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'damageBonus',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base}% damage (+${scaling}% per level)`,
|
||||
}),
|
||||
|
||||
critChance: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'critChance',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base}% crit chance (+${scaling}% per level)`,
|
||||
}),
|
||||
|
||||
critDamage: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'critDamage',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base}% crit damage (+${scaling}% per level)`,
|
||||
}),
|
||||
|
||||
castSpeed: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'castSpeed',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base}% cast speed (+${scaling}% per level)`,
|
||||
}),
|
||||
|
||||
elementalBonus: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'elementalBonus',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base}% elemental damage (+${scaling}% per level)`,
|
||||
}),
|
||||
|
||||
guardianDamage: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'guardianDamage',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base}% damage to guardians (+${scaling}% per level)`,
|
||||
}),
|
||||
|
||||
manaSiphon: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'manaSiphon',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `Restore ${base}% of damage as mana (+${scaling}% per level)`,
|
||||
}),
|
||||
|
||||
barrierBreaker: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'barrierBreaker',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base}% damage to barriers (+${scaling}% per level)`,
|
||||
}),
|
||||
|
||||
// Mana abilities
|
||||
manaRegen: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'manaRegen',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base} mana regen (+${scaling} per level)`,
|
||||
}),
|
||||
|
||||
autoGather: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'autoGather',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `Auto-gather ${base} mana/hour (+${scaling} per level)`,
|
||||
}),
|
||||
|
||||
autoConvert: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'autoConvert',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `Auto-convert ${base} mana/hour (+${scaling} per level)`,
|
||||
}),
|
||||
|
||||
maxManaBonus: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'maxManaBonus',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base} max mana (+${scaling} per level)`,
|
||||
}),
|
||||
|
||||
// Support abilities
|
||||
bonusGold: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'bonusGold',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base}% insight gain (+${scaling}% per level)`,
|
||||
}),
|
||||
|
||||
studySpeed: (base: number, scaling: number): FamiliarAbility => ({
|
||||
type: 'studySpeed',
|
||||
baseValue: base,
|
||||
scalingPerLevel: scaling,
|
||||
desc: `+${base}% study speed (+${scaling}% per level)`,
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── Familiar Definitions ───────────────────────────────────────────────────────
|
||||
|
||||
export const FAMILIARS_DEF: Record<string, FamiliarDef> = {
|
||||
// === COMMON FAMILIARS (Tier 1) ===
|
||||
|
||||
// Mana Wisps - Basic mana helpers
|
||||
manaWisp: {
|
||||
id: 'manaWisp',
|
||||
name: 'Mana Wisp',
|
||||
desc: 'A gentle spirit of pure mana that drifts lazily through the air.',
|
||||
role: 'mana',
|
||||
element: 'raw',
|
||||
rarity: 'common',
|
||||
abilities: [
|
||||
ABILITIES.manaRegen(0.5, 0.1),
|
||||
],
|
||||
baseStats: { power: 10, bond: 15 },
|
||||
unlockCondition: { type: 'mana', value: 100 },
|
||||
flavorText: 'It hums with quiet contentment, barely visible in dim light.',
|
||||
},
|
||||
|
||||
fireSpark: {
|
||||
id: 'fireSpark',
|
||||
name: 'Fire Spark',
|
||||
desc: 'A tiny ember given life, crackling with barely contained energy.',
|
||||
role: 'combat',
|
||||
element: 'fire',
|
||||
rarity: 'common',
|
||||
abilities: [
|
||||
ABILITIES.damageBonus(2, 0.5),
|
||||
],
|
||||
baseStats: { power: 12, bond: 10 },
|
||||
unlockCondition: { type: 'floor', value: 5 },
|
||||
flavorText: 'It bounces excitedly, leaving scorch marks on everything it touches.',
|
||||
},
|
||||
|
||||
waterDroplet: {
|
||||
id: 'waterDroplet',
|
||||
name: 'Water Droplet',
|
||||
desc: 'A perfect sphere of living water that never seems to evaporate.',
|
||||
role: 'support',
|
||||
element: 'water',
|
||||
rarity: 'common',
|
||||
abilities: [
|
||||
ABILITIES.manaRegen(0.3, 0.1),
|
||||
ABILITIES.manaSiphon(2, 0.5),
|
||||
],
|
||||
baseStats: { power: 8, bond: 12 },
|
||||
unlockCondition: { type: 'floor', value: 3 },
|
||||
flavorText: 'Ripples spread across its surface with each spell you cast.',
|
||||
},
|
||||
|
||||
earthPebble: {
|
||||
id: 'earthPebble',
|
||||
name: 'Earth Pebble',
|
||||
desc: 'A small stone with a surprisingly friendly personality.',
|
||||
role: 'guardian',
|
||||
element: 'earth',
|
||||
rarity: 'common',
|
||||
abilities: [
|
||||
ABILITIES.guardianDamage(3, 0.8),
|
||||
],
|
||||
baseStats: { power: 15, bond: 8 },
|
||||
unlockCondition: { type: 'floor', value: 8 },
|
||||
flavorText: 'It occasionally rolls itself to a new position when bored.',
|
||||
},
|
||||
|
||||
// === UNCOMMON FAMILIARS (Tier 2) ===
|
||||
|
||||
flameImp: {
|
||||
id: 'flameImp',
|
||||
name: 'Flame Imp',
|
||||
desc: 'A mischievous fire spirit that delights in destruction.',
|
||||
role: 'combat',
|
||||
element: 'fire',
|
||||
rarity: 'uncommon',
|
||||
abilities: [
|
||||
ABILITIES.damageBonus(4, 0.8),
|
||||
ABILITIES.elementalBonus(3, 0.6),
|
||||
],
|
||||
baseStats: { power: 25, bond: 12 },
|
||||
unlockCondition: { type: 'floor', value: 15 },
|
||||
flavorText: 'It cackles with glee whenever you defeat an enemy.',
|
||||
},
|
||||
|
||||
windSylph: {
|
||||
id: 'windSylph',
|
||||
name: 'Wind Sylph',
|
||||
desc: 'An airy spirit that moves like a gentle breeze.',
|
||||
role: 'support',
|
||||
element: 'air',
|
||||
rarity: 'uncommon',
|
||||
abilities: [
|
||||
ABILITIES.castSpeed(3, 0.6),
|
||||
],
|
||||
baseStats: { power: 20, bond: 15 },
|
||||
unlockCondition: { type: 'floor', value: 12 },
|
||||
flavorText: 'Its laughter sounds like wind chimes in a storm.',
|
||||
},
|
||||
|
||||
manaSprite: {
|
||||
id: 'manaSprite',
|
||||
name: 'Mana Sprite',
|
||||
desc: 'A more evolved mana spirit with a playful nature.',
|
||||
role: 'mana',
|
||||
element: 'raw',
|
||||
rarity: 'uncommon',
|
||||
abilities: [
|
||||
ABILITIES.manaRegen(1, 0.2),
|
||||
ABILITIES.autoGather(2, 0.5),
|
||||
],
|
||||
baseStats: { power: 18, bond: 18 },
|
||||
unlockCondition: { type: 'mana', value: 1000 },
|
||||
flavorText: 'It sometimes tickles your ear with invisible hands.',
|
||||
},
|
||||
|
||||
crystalGolem: {
|
||||
id: 'crystalGolem',
|
||||
name: 'Crystal Golem',
|
||||
desc: 'A small construct made of crystallized mana.',
|
||||
role: 'guardian',
|
||||
element: 'crystal',
|
||||
rarity: 'uncommon',
|
||||
abilities: [
|
||||
ABILITIES.guardianDamage(5, 1),
|
||||
ABILITIES.barrierBreaker(8, 1.5),
|
||||
],
|
||||
baseStats: { power: 30, bond: 10 },
|
||||
unlockCondition: { type: 'floor', value: 20 },
|
||||
flavorText: 'Light refracts through its body in mesmerizing patterns.',
|
||||
},
|
||||
|
||||
// === RARE FAMILIARS (Tier 3) ===
|
||||
|
||||
phoenixHatchling: {
|
||||
id: 'phoenixHatchling',
|
||||
name: 'Phoenix Hatchling',
|
||||
desc: 'A young phoenix, still learning to control its flames.',
|
||||
role: 'combat',
|
||||
element: 'fire',
|
||||
rarity: 'rare',
|
||||
abilities: [
|
||||
ABILITIES.damageBonus(6, 1.2),
|
||||
ABILITIES.critDamage(15, 3),
|
||||
],
|
||||
baseStats: { power: 40, bond: 15 },
|
||||
unlockCondition: { type: 'floor', value: 30 },
|
||||
flavorText: 'Tiny flames dance around its feathers as it practices flying.',
|
||||
},
|
||||
|
||||
frostWisp: {
|
||||
id: 'frostWisp',
|
||||
name: 'Frost Wisp',
|
||||
desc: 'A spirit of eternal winter, beautiful and deadly.',
|
||||
role: 'combat',
|
||||
element: 'water',
|
||||
rarity: 'rare',
|
||||
abilities: [
|
||||
ABILITIES.elementalBonus(8, 1.5),
|
||||
ABILITIES.castSpeed(4, 0.8),
|
||||
],
|
||||
baseStats: { power: 35, bond: 12 },
|
||||
unlockCondition: { type: 'floor', value: 25 },
|
||||
flavorText: 'Frost patterns appear on surfaces wherever it lingers.',
|
||||
},
|
||||
|
||||
manaElemental: {
|
||||
id: 'manaElemental',
|
||||
name: 'Mana Elemental',
|
||||
desc: 'A concentrated form of pure magical energy.',
|
||||
role: 'mana',
|
||||
element: 'raw',
|
||||
rarity: 'rare',
|
||||
abilities: [
|
||||
ABILITIES.manaRegen(2, 0.4),
|
||||
ABILITIES.autoGather(5, 1),
|
||||
ABILITIES.autoConvert(2, 0.5),
|
||||
],
|
||||
baseStats: { power: 30, bond: 20 },
|
||||
unlockCondition: { type: 'mana', value: 5000 },
|
||||
flavorText: 'Reality seems to bend slightly around its fluctuating form.',
|
||||
},
|
||||
|
||||
shieldGuardian: {
|
||||
id: 'shieldGuardian',
|
||||
name: 'Stone Guardian',
|
||||
desc: 'A loyal protector carved from enchanted stone.',
|
||||
role: 'guardian',
|
||||
element: 'earth',
|
||||
rarity: 'rare',
|
||||
abilities: [
|
||||
ABILITIES.guardianDamage(8, 1.5),
|
||||
ABILITIES.barrierBreaker(12, 2),
|
||||
],
|
||||
baseStats: { power: 50, bond: 8 },
|
||||
unlockCondition: { type: 'floor', value: 35 },
|
||||
flavorText: 'It stands motionless for hours, then suddenly moves to strike.',
|
||||
},
|
||||
|
||||
// === EPIC FAMILIARS (Tier 4) ===
|
||||
|
||||
infernoDrake: {
|
||||
id: 'infernoDrake',
|
||||
name: 'Inferno Drake',
|
||||
desc: 'A small dragon wreathed in eternal flames.',
|
||||
role: 'combat',
|
||||
element: 'fire',
|
||||
rarity: 'epic',
|
||||
abilities: [
|
||||
ABILITIES.damageBonus(10, 2),
|
||||
ABILITIES.elementalBonus(12, 2),
|
||||
ABILITIES.critChance(3, 0.6),
|
||||
],
|
||||
baseStats: { power: 60, bond: 12 },
|
||||
unlockCondition: { type: 'floor', value: 50 },
|
||||
flavorText: 'Smoke occasionally drifts from its nostrils as it dreams of conquest.',
|
||||
},
|
||||
|
||||
starlightSerpent: {
|
||||
id: 'starlightSerpent',
|
||||
name: 'Starlight Serpent',
|
||||
desc: 'A serpentine creature formed from captured starlight.',
|
||||
role: 'support',
|
||||
element: 'stellar',
|
||||
rarity: 'epic',
|
||||
abilities: [
|
||||
ABILITIES.castSpeed(8, 1.5),
|
||||
ABILITIES.bonusGold(5, 1),
|
||||
ABILITIES.manaRegen(1.5, 0.3),
|
||||
],
|
||||
baseStats: { power: 45, bond: 25 },
|
||||
unlockCondition: { type: 'floor', value: 45 },
|
||||
flavorText: 'It traces constellations in the air with its glowing body.',
|
||||
},
|
||||
|
||||
voidWalker: {
|
||||
id: 'voidWalker',
|
||||
name: 'Void Walker',
|
||||
desc: 'A being that exists partially outside normal reality.',
|
||||
role: 'mana',
|
||||
element: 'void',
|
||||
rarity: 'epic',
|
||||
abilities: [
|
||||
ABILITIES.manaRegen(3, 0.6),
|
||||
ABILITIES.autoGather(10, 2),
|
||||
ABILITIES.maxManaBonus(50, 10),
|
||||
],
|
||||
baseStats: { power: 55, bond: 15 },
|
||||
unlockCondition: { type: 'floor', value: 55 },
|
||||
flavorText: 'It sometimes disappears entirely, only to reappear moments later.',
|
||||
},
|
||||
|
||||
ancientGolem: {
|
||||
id: 'ancientGolem',
|
||||
name: 'Ancient Golem',
|
||||
desc: 'A construct from a forgotten age, still following its prime directive.',
|
||||
role: 'guardian',
|
||||
element: 'earth',
|
||||
rarity: 'epic',
|
||||
abilities: [
|
||||
ABILITIES.guardianDamage(15, 3),
|
||||
ABILITIES.barrierBreaker(20, 4),
|
||||
ABILITIES.damageBonus(5, 1),
|
||||
],
|
||||
baseStats: { power: 80, bond: 6 },
|
||||
unlockCondition: { type: 'floor', value: 60 },
|
||||
flavorText: 'Ancient runes glow faintly across its weathered surface.',
|
||||
},
|
||||
|
||||
// === LEGENDARY FAMILIARS (Tier 5) ===
|
||||
|
||||
primordialPhoenix: {
|
||||
id: 'primordialPhoenix',
|
||||
name: 'Primordial Phoenix',
|
||||
desc: 'An ancient fire bird, reborn countless times through the ages.',
|
||||
role: 'combat',
|
||||
element: 'fire',
|
||||
rarity: 'legendary',
|
||||
abilities: [
|
||||
ABILITIES.damageBonus(15, 3),
|
||||
ABILITIES.elementalBonus(20, 4),
|
||||
ABILITIES.critDamage(30, 5),
|
||||
ABILITIES.critChance(5, 1),
|
||||
],
|
||||
baseStats: { power: 100, bond: 20 },
|
||||
unlockCondition: { type: 'pact', value: 25 }, // Guardian floor 25
|
||||
flavorText: 'Its eyes hold the wisdom of a thousand lifetimes.',
|
||||
},
|
||||
|
||||
leviathanSpawn: {
|
||||
id: 'leviathanSpawn',
|
||||
name: 'Leviathan Spawn',
|
||||
desc: 'The offspring of an ancient sea god, still growing into its power.',
|
||||
role: 'mana',
|
||||
element: 'water',
|
||||
rarity: 'legendary',
|
||||
abilities: [
|
||||
ABILITIES.manaRegen(5, 1),
|
||||
ABILITIES.autoGather(20, 4),
|
||||
ABILITIES.autoConvert(8, 1.5),
|
||||
ABILITIES.maxManaBonus(100, 20),
|
||||
],
|
||||
baseStats: { power: 90, bond: 18 },
|
||||
unlockCondition: { type: 'pact', value: 50 },
|
||||
flavorText: 'The air around it always smells of salt and deep ocean.',
|
||||
},
|
||||
|
||||
celestialGuardian: {
|
||||
id: 'celestialGuardian',
|
||||
name: 'Celestial Guardian',
|
||||
desc: 'A divine protector sent by powers beyond mortal comprehension.',
|
||||
role: 'guardian',
|
||||
element: 'light',
|
||||
rarity: 'legendary',
|
||||
abilities: [
|
||||
ABILITIES.guardianDamage(25, 5),
|
||||
ABILITIES.barrierBreaker(30, 6),
|
||||
ABILITIES.damageBonus(10, 2),
|
||||
ABILITIES.critChance(8, 1.5),
|
||||
],
|
||||
baseStats: { power: 120, bond: 12 },
|
||||
unlockCondition: { type: 'pact', value: 75 },
|
||||
flavorText: 'It radiates an aura of absolute judgment.',
|
||||
},
|
||||
|
||||
voidEmperor: {
|
||||
id: 'voidEmperor',
|
||||
name: 'Void Emperor',
|
||||
desc: 'A ruler from the spaces between dimensions, bound to your service.',
|
||||
role: 'support',
|
||||
element: 'void',
|
||||
rarity: 'legendary',
|
||||
abilities: [
|
||||
ABILITIES.castSpeed(15, 3),
|
||||
ABILITIES.bonusGold(15, 3),
|
||||
ABILITIES.manaRegen(4, 0.8),
|
||||
ABILITIES.critChance(8, 1.5),
|
||||
],
|
||||
baseStats: { power: 85, bond: 25 },
|
||||
unlockCondition: { type: 'floor', value: 90 },
|
||||
flavorText: 'It regards reality with the detached interest of a god.',
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helper Functions ───────────────────────────────────────────────────────────
|
||||
|
||||
// Get XP required for next familiar level
|
||||
export function getFamiliarXpRequired(level: number): number {
|
||||
// Exponential scaling: 100 * 1.5^(level-1)
|
||||
return Math.floor(100 * Math.pow(1.5, level - 1));
|
||||
}
|
||||
|
||||
// Get bond required for next bond level (1-100)
|
||||
export function getBondRequired(currentBond: number): number {
|
||||
// Linear scaling, every 10 bond requires more time
|
||||
const bondTier = Math.floor(currentBond / 10);
|
||||
return 100 + bondTier * 50; // Base 100, +50 per tier
|
||||
}
|
||||
|
||||
// Calculate familiar's ability value at given level and ability level
|
||||
export function getFamiliarAbilityValue(
|
||||
ability: FamiliarAbility,
|
||||
familiarLevel: number,
|
||||
abilityLevel: number
|
||||
): number {
|
||||
// Base value + (familiar level bonus) + (ability level bonus)
|
||||
const familiarBonus = Math.floor(familiarLevel / 10) * ability.scalingPerLevel;
|
||||
const abilityBonus = (abilityLevel - 1) * ability.scalingPerLevel * 2;
|
||||
return ability.baseValue + familiarBonus + abilityBonus;
|
||||
}
|
||||
|
||||
// Get all familiars of a specific rarity
|
||||
export function getFamiliarsByRarity(rarity: FamiliarDef['rarity']): FamiliarDef[] {
|
||||
return Object.values(FAMILIARS_DEF).filter(f => f.rarity === rarity);
|
||||
}
|
||||
|
||||
// Get all familiars of a specific role
|
||||
export function getFamiliarsByRole(role: FamiliarRole): FamiliarDef[] {
|
||||
return Object.values(FAMILIARS_DEF).filter(f => f.role === role);
|
||||
}
|
||||
|
||||
// Check if player meets unlock condition for a familiar
|
||||
export function canUnlockFamiliar(
|
||||
familiar: FamiliarDef,
|
||||
maxFloor: number,
|
||||
signedPacts: number[],
|
||||
totalManaGathered: number,
|
||||
skillsLearned: number
|
||||
): boolean {
|
||||
if (!familiar.unlockCondition) return true;
|
||||
|
||||
const { type, value } = familiar.unlockCondition;
|
||||
|
||||
switch (type) {
|
||||
case 'floor':
|
||||
return maxFloor >= value;
|
||||
case 'pact':
|
||||
return signedPacts.includes(value);
|
||||
case 'mana':
|
||||
return totalManaGathered >= value;
|
||||
case 'study':
|
||||
return skillsLearned >= value;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Starting familiar (given to new players)
|
||||
export const STARTING_FAMILIAR = 'manaWisp';
|
||||
@@ -356,3 +356,116 @@ export function getGolemAttackSpeed(
|
||||
export function getGolemFloorDuration(skills: Record<string, number>): number {
|
||||
return 1 + (skills.golemLongevity || 0);
|
||||
}
|
||||
|
||||
// Get maintenance cost multiplier (Golem Siphon reduces by 10% per level)
|
||||
export function getGolemMaintenanceMultiplier(skills: Record<string, number>): number {
|
||||
return 1 - (skills.golemSiphon || 0) * 0.1;
|
||||
}
|
||||
|
||||
// Check if player can afford golem summon cost
|
||||
export function canAffordGolemSummon(
|
||||
golemId: string,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): boolean {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return false;
|
||||
|
||||
for (const cost of golem.summonCost) {
|
||||
if (cost.type === 'raw') {
|
||||
if (rawMana < cost.amount) return false;
|
||||
} else if (cost.element) {
|
||||
const elem = elements[cost.element];
|
||||
if (!elem || !elem.unlocked || elem.current < cost.amount) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deduct golem summon cost from mana pools
|
||||
export function deductGolemSummonCost(
|
||||
golemId: string,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return { rawMana, elements };
|
||||
|
||||
let newRawMana = rawMana;
|
||||
let newElements = { ...elements };
|
||||
|
||||
for (const cost of golem.summonCost) {
|
||||
if (cost.type === 'raw') {
|
||||
newRawMana -= cost.amount;
|
||||
} else if (cost.element && newElements[cost.element]) {
|
||||
newElements = {
|
||||
...newElements,
|
||||
[cost.element]: {
|
||||
...newElements[cost.element],
|
||||
current: newElements[cost.element].current - cost.amount,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { rawMana: newRawMana, elements: newElements };
|
||||
}
|
||||
|
||||
// Check if player can afford golem maintenance for one tick
|
||||
export function canAffordGolemMaintenance(
|
||||
golemId: string,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
skills: Record<string, number>
|
||||
): boolean {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return false;
|
||||
|
||||
const maintenanceMult = getGolemMaintenanceMultiplier(skills);
|
||||
|
||||
for (const cost of golem.maintenanceCost) {
|
||||
const adjustedAmount = cost.amount * maintenanceMult;
|
||||
if (cost.type === 'raw') {
|
||||
if (rawMana < adjustedAmount) return false;
|
||||
} else if (cost.element) {
|
||||
const elem = elements[cost.element];
|
||||
if (!elem || !elem.unlocked || elem.current < adjustedAmount) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deduct golem maintenance cost for one tick
|
||||
export function deductGolemMaintenance(
|
||||
golemId: string,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
skills: Record<string, number>
|
||||
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return { rawMana, elements };
|
||||
|
||||
const maintenanceMult = getGolemMaintenanceMultiplier(skills);
|
||||
|
||||
let newRawMana = rawMana;
|
||||
let newElements = { ...elements };
|
||||
|
||||
for (const cost of golem.maintenanceCost) {
|
||||
const adjustedAmount = cost.amount * maintenanceMult;
|
||||
if (cost.type === 'raw') {
|
||||
newRawMana -= adjustedAmount;
|
||||
} else if (cost.element && newElements[cost.element]) {
|
||||
newElements = {
|
||||
...newElements,
|
||||
[cost.element]: {
|
||||
...newElements[cost.element],
|
||||
current: newElements[cost.element].current - adjustedAmount,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { rawMana: newRawMana, elements: newElements };
|
||||
}
|
||||
|
||||
367
src/lib/game/familiar-slice.ts
Executable file
367
src/lib/game/familiar-slice.ts
Executable file
@@ -0,0 +1,367 @@
|
||||
// ─── Familiar Slice ─────────────────────────────────────────────────────────────
|
||||
// Actions and computations for the familiar system
|
||||
|
||||
import type { GameState, FamiliarInstance, FamiliarAbilityType } from './types';
|
||||
import { FAMILIARS_DEF, getFamiliarXpRequired, getFamiliarAbilityValue, canUnlockFamiliar, STARTING_FAMILIAR } from './data/familiars';
|
||||
import { HOURS_PER_TICK } from './constants';
|
||||
|
||||
// ─── Familiar Actions Interface ─────────────────────────────────────────────────
|
||||
|
||||
export interface FamiliarActions {
|
||||
// Summoning and management
|
||||
summonFamiliar: (familiarId: string) => void;
|
||||
setActiveFamiliar: (instanceIndex: number, active: boolean) => void;
|
||||
setFamiliarNickname: (instanceIndex: number, nickname: string) => void;
|
||||
|
||||
// Progression
|
||||
gainFamiliarXp: (amount: number, source: 'combat' | 'gather' | 'meditate' | 'study' | 'time') => void;
|
||||
upgradeFamiliarAbility: (instanceIndex: number, abilityType: FamiliarAbilityType) => void;
|
||||
|
||||
// Computation
|
||||
getActiveFamiliarBonuses: () => FamiliarBonuses;
|
||||
getAvailableFamiliars: () => string[];
|
||||
}
|
||||
|
||||
// ─── Computed Bonuses ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface FamiliarBonuses {
|
||||
damageMultiplier: number;
|
||||
manaRegenBonus: number;
|
||||
autoGatherRate: number;
|
||||
autoConvertRate: number;
|
||||
critChanceBonus: number;
|
||||
castSpeedMultiplier: number;
|
||||
elementalDamageMultiplier: number;
|
||||
lifeStealPercent: number;
|
||||
thornsPercent: number;
|
||||
insightMultiplier: number;
|
||||
manaShieldAmount: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_FAMILIAR_BONUSES: FamiliarBonuses = {
|
||||
damageMultiplier: 1,
|
||||
manaRegenBonus: 0,
|
||||
autoGatherRate: 0,
|
||||
autoConvertRate: 0,
|
||||
critChanceBonus: 0,
|
||||
castSpeedMultiplier: 1,
|
||||
elementalDamageMultiplier: 1,
|
||||
lifeStealPercent: 0,
|
||||
thornsPercent: 0,
|
||||
insightMultiplier: 1,
|
||||
manaShieldAmount: 0,
|
||||
};
|
||||
|
||||
// ─── Familiar Slice Factory ─────────────────────────────────────────────────────
|
||||
|
||||
export function createFamiliarSlice(
|
||||
set: (fn: (state: GameState) => Partial<GameState>) => void,
|
||||
get: () => GameState
|
||||
): FamiliarActions {
|
||||
return {
|
||||
// Summon a new familiar
|
||||
summonFamiliar: (familiarId: string) => {
|
||||
const state = get();
|
||||
const familiarDef = FAMILIARS_DEF[familiarId];
|
||||
if (!familiarDef) return;
|
||||
|
||||
// Check if already owned
|
||||
if (state.familiars.some(f => f.familiarId === familiarId)) return;
|
||||
|
||||
// Check unlock condition
|
||||
if (!canUnlockFamiliar(
|
||||
familiarDef,
|
||||
state.maxFloorReached,
|
||||
state.signedPacts,
|
||||
state.totalManaGathered,
|
||||
Object.keys(state.skills).length
|
||||
)) return;
|
||||
|
||||
// Create new familiar instance
|
||||
const newInstance: FamiliarInstance = {
|
||||
familiarId,
|
||||
level: 1,
|
||||
bond: 0,
|
||||
experience: 0,
|
||||
abilities: familiarDef.abilities.map(a => ({
|
||||
type: a.type,
|
||||
level: 1,
|
||||
})),
|
||||
active: false,
|
||||
};
|
||||
|
||||
// Add to familiars list
|
||||
set((s) => ({
|
||||
familiars: [...s.familiars, newInstance],
|
||||
log: [`🌟 ${familiarDef.name} has answered your call!`, ...s.log.slice(0, 49)],
|
||||
}));
|
||||
},
|
||||
|
||||
// Set a familiar as active/inactive
|
||||
setActiveFamiliar: (instanceIndex: number, active: boolean) => {
|
||||
const state = get();
|
||||
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
|
||||
|
||||
const activeCount = state.familiars.filter(f => f.active).length;
|
||||
|
||||
// Check if we have slots available
|
||||
if (active && activeCount >= state.activeFamiliarSlots) {
|
||||
// Deactivate another familiar first
|
||||
const newFamiliars = [...state.familiars];
|
||||
const activeIndex = newFamiliars.findIndex(f => f.active);
|
||||
if (activeIndex >= 0) {
|
||||
newFamiliars[activeIndex] = { ...newFamiliars[activeIndex], active: false };
|
||||
}
|
||||
newFamiliars[instanceIndex] = { ...newFamiliars[instanceIndex], active };
|
||||
set({ familiars: newFamiliars });
|
||||
} else {
|
||||
// Just toggle the familiar
|
||||
const newFamiliars = [...state.familiars];
|
||||
newFamiliars[instanceIndex] = { ...newFamiliars[instanceIndex], active };
|
||||
set({ familiars: newFamiliars });
|
||||
}
|
||||
},
|
||||
|
||||
// Set a familiar's nickname
|
||||
setFamiliarNickname: (instanceIndex: number, nickname: string) => {
|
||||
const state = get();
|
||||
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
|
||||
|
||||
const newFamiliars = [...state.familiars];
|
||||
newFamiliars[instanceIndex] = {
|
||||
...newFamiliars[instanceIndex],
|
||||
nickname: nickname || undefined
|
||||
};
|
||||
set({ familiars: newFamiliars });
|
||||
},
|
||||
|
||||
// Grant XP to all active familiars
|
||||
gainFamiliarXp: (amount: number, _source: 'combat' | 'gather' | 'meditate' | 'study' | 'time') => {
|
||||
const state = get();
|
||||
if (state.familiars.length === 0) return;
|
||||
|
||||
const newFamiliars = [...state.familiars];
|
||||
let leveled = false;
|
||||
|
||||
for (let i = 0; i < newFamiliars.length; i++) {
|
||||
const familiar = newFamiliars[i];
|
||||
if (!familiar.active) continue;
|
||||
|
||||
const def = FAMILIARS_DEF[familiar.familiarId];
|
||||
if (!def) continue;
|
||||
|
||||
// Apply bond multiplier to XP gain
|
||||
const bondMultiplier = 1 + (familiar.bond / 100);
|
||||
const xpGain = Math.floor(amount * bondMultiplier);
|
||||
|
||||
let newExp = familiar.experience + xpGain;
|
||||
let newLevel = familiar.level;
|
||||
|
||||
// Check for level ups
|
||||
while (newLevel < 100 && newExp >= getFamiliarXpRequired(newLevel)) {
|
||||
newExp -= getFamiliarXpRequired(newLevel);
|
||||
newLevel++;
|
||||
leveled = true;
|
||||
}
|
||||
|
||||
// Gain bond passively
|
||||
const newBond = Math.min(100, familiar.bond + 0.01);
|
||||
|
||||
newFamiliars[i] = {
|
||||
...familiar,
|
||||
level: newLevel,
|
||||
experience: newExp,
|
||||
bond: newBond,
|
||||
};
|
||||
}
|
||||
|
||||
set({
|
||||
familiars: newFamiliars,
|
||||
totalFamiliarXpEarned: state.totalFamiliarXpEarned + amount,
|
||||
...(leveled ? { log: ['📈 Your familiar has grown stronger!', ...state.log.slice(0, 49)] } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
// Upgrade a familiar's ability
|
||||
upgradeFamiliarAbility: (instanceIndex: number, abilityType: FamiliarAbilityType) => {
|
||||
const state = get();
|
||||
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
|
||||
|
||||
const familiar = state.familiars[instanceIndex];
|
||||
const def = FAMILIARS_DEF[familiar.familiarId];
|
||||
if (!def) return;
|
||||
|
||||
// Find the ability
|
||||
const abilityIndex = familiar.abilities.findIndex(a => a.type === abilityType);
|
||||
if (abilityIndex < 0) return;
|
||||
|
||||
const ability = familiar.abilities[abilityIndex];
|
||||
if (ability.level >= 10) return; // Max level
|
||||
|
||||
// Cost: level * 100 XP
|
||||
const cost = ability.level * 100;
|
||||
if (familiar.experience < cost) return;
|
||||
|
||||
// Upgrade
|
||||
const newAbilities = [...familiar.abilities];
|
||||
newAbilities[abilityIndex] = { ...ability, level: ability.level + 1 };
|
||||
|
||||
const newFamiliars = [...state.familiars];
|
||||
newFamiliars[instanceIndex] = {
|
||||
...familiar,
|
||||
abilities: newAbilities,
|
||||
experience: familiar.experience - cost,
|
||||
};
|
||||
|
||||
set({ familiars: newFamiliars });
|
||||
},
|
||||
|
||||
// Get total bonuses from active familiars
|
||||
getActiveFamiliarBonuses: (): FamiliarBonuses => {
|
||||
const state = get();
|
||||
const bonuses = { ...DEFAULT_FAMILIAR_BONUSES };
|
||||
|
||||
for (const familiar of state.familiars) {
|
||||
if (!familiar.active) continue;
|
||||
|
||||
const def = FAMILIARS_DEF[familiar.familiarId];
|
||||
if (!def) continue;
|
||||
|
||||
// Bond multiplier: up to 50% bonus at max bond
|
||||
const bondMultiplier = 1 + (familiar.bond / 200);
|
||||
|
||||
for (const abilityInst of familiar.abilities) {
|
||||
const abilityDef = def.abilities.find(a => a.type === abilityInst.type);
|
||||
if (!abilityDef) continue;
|
||||
|
||||
const value = getFamiliarAbilityValue(abilityDef, familiar.level, abilityInst.level) * bondMultiplier;
|
||||
|
||||
switch (abilityInst.type) {
|
||||
case 'damageBonus':
|
||||
bonuses.damageMultiplier += value / 100;
|
||||
break;
|
||||
case 'manaRegen':
|
||||
bonuses.manaRegenBonus += value;
|
||||
break;
|
||||
case 'autoGather':
|
||||
bonuses.autoGatherRate += value;
|
||||
break;
|
||||
case 'autoConvert':
|
||||
bonuses.autoConvertRate += value;
|
||||
break;
|
||||
case 'critChance':
|
||||
bonuses.critChanceBonus += value;
|
||||
break;
|
||||
case 'castSpeed':
|
||||
bonuses.castSpeedMultiplier += value / 100;
|
||||
break;
|
||||
case 'elementalBonus':
|
||||
bonuses.elementalDamageMultiplier += value / 100;
|
||||
break;
|
||||
case 'lifeSteal':
|
||||
bonuses.lifeStealPercent += value;
|
||||
break;
|
||||
case 'thorns':
|
||||
bonuses.thornsPercent += value;
|
||||
break;
|
||||
case 'bonusGold':
|
||||
bonuses.insightMultiplier += value / 100;
|
||||
break;
|
||||
case 'manaShield':
|
||||
bonuses.manaShieldAmount += value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bonuses;
|
||||
},
|
||||
|
||||
// Get list of available (unlocked but not owned) familiars
|
||||
getAvailableFamiliars: (): string[] => {
|
||||
const state = get();
|
||||
const owned = new Set(state.familiars.map(f => f.familiarId));
|
||||
|
||||
return Object.values(FAMILIARS_DEF)
|
||||
.filter(f =>
|
||||
!owned.has(f.id) &&
|
||||
canUnlockFamiliar(
|
||||
f,
|
||||
state.maxFloorReached,
|
||||
state.signedPacts,
|
||||
state.totalManaGathered,
|
||||
Object.keys(state.skills).length
|
||||
)
|
||||
)
|
||||
.map(f => f.id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Familiar Tick Processing ───────────────────────────────────────────────────
|
||||
|
||||
// Process familiar-related tick effects (called from main tick)
|
||||
export function processFamiliarTick(
|
||||
state: Pick<GameState, 'familiars' | 'rawMana' | 'elements' | 'totalManaGathered' | 'activeFamiliarSlots'>,
|
||||
familiarBonuses: FamiliarBonuses
|
||||
): { rawMana: number; elements: GameState['elements']; totalManaGathered: number; gatherLog?: string } {
|
||||
let rawMana = state.rawMana;
|
||||
let elements = state.elements;
|
||||
let totalManaGathered = state.totalManaGathered;
|
||||
let gatherLog: string | undefined;
|
||||
|
||||
// Auto-gather from familiars
|
||||
if (familiarBonuses.autoGatherRate > 0) {
|
||||
const gathered = familiarBonuses.autoGatherRate * HOURS_PER_TICK;
|
||||
rawMana += gathered;
|
||||
totalManaGathered += gathered;
|
||||
if (gathered >= 1) {
|
||||
gatherLog = `✨ Familiars gathered ${Math.floor(gathered)} mana`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-convert from familiars
|
||||
if (familiarBonuses.autoConvertRate > 0) {
|
||||
const convertAmount = Math.min(
|
||||
familiarBonuses.autoConvertRate * HOURS_PER_TICK,
|
||||
Math.floor(rawMana / 5) // 5 raw mana per element
|
||||
);
|
||||
|
||||
if (convertAmount > 0) {
|
||||
// Find unlocked elements with space
|
||||
const unlockedElements = Object.entries(elements)
|
||||
.filter(([, e]) => e.unlocked && e.current < e.max)
|
||||
.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
|
||||
|
||||
if (unlockedElements.length > 0) {
|
||||
const [targetId, targetState] = unlockedElements[0];
|
||||
const canConvert = Math.min(convertAmount, targetState.max - targetState.current);
|
||||
rawMana -= canConvert * 5;
|
||||
elements = {
|
||||
...elements,
|
||||
[targetId]: { ...targetState, current: targetState.current + canConvert },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { rawMana, elements, totalManaGathered, gatherLog };
|
||||
}
|
||||
|
||||
// Grant starting familiar to new players
|
||||
export function grantStartingFamiliar(): FamiliarInstance[] {
|
||||
const starterDef = FAMILIARS_DEF[STARTING_FAMILIAR];
|
||||
if (!starterDef) return [];
|
||||
|
||||
return [{
|
||||
familiarId: STARTING_FAMILIAR,
|
||||
level: 1,
|
||||
bond: 0,
|
||||
experience: 0,
|
||||
abilities: starterDef.abilities.map(a => ({
|
||||
type: a.type,
|
||||
level: 1,
|
||||
})),
|
||||
active: true, // Start with familiar active
|
||||
}];
|
||||
}
|
||||
221
src/lib/game/hooks/useGameDerived.ts
Executable file
221
src/lib/game/hooks/useGameDerived.ts
Executable file
@@ -0,0 +1,221 @@
|
||||
// ─── Derived Stats Hooks ───────────────────────────────────────────────────────
|
||||
// Custom hooks for computing derived game stats from the store
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useGameStore } from '../store';
|
||||
import { computeEffects } from '../upgrade-effects';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
getFloorElement,
|
||||
calcDamage,
|
||||
computePactMultiplier,
|
||||
computePactInsightMultiplier,
|
||||
getElementalBonus,
|
||||
} from '../store/computed';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier, HOURS_PER_TICK, TICK_MS } from '../constants';
|
||||
import { hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects';
|
||||
|
||||
/**
|
||||
* Hook for all mana-related derived stats
|
||||
*/
|
||||
export function useManaStats() {
|
||||
const store = useGameStore();
|
||||
|
||||
const upgradeEffects = useMemo(
|
||||
() => computeEffects(store.skillUpgrades || {}, store.skillTiers || {}),
|
||||
[store.skillUpgrades, store.skillTiers]
|
||||
);
|
||||
|
||||
const maxMana = useMemo(
|
||||
() => computeMaxMana(store, upgradeEffects),
|
||||
[store, upgradeEffects]
|
||||
);
|
||||
|
||||
const baseRegen = useMemo(
|
||||
() => computeRegen(store, upgradeEffects),
|
||||
[store, upgradeEffects]
|
||||
);
|
||||
|
||||
const clickMana = useMemo(
|
||||
() => computeClickMana(store),
|
||||
[store]
|
||||
);
|
||||
|
||||
const meditationMultiplier = useMemo(
|
||||
() => getMeditationBonus(store.meditateTicks, store.skills, upgradeEffects.meditationEfficiency),
|
||||
[store.meditateTicks, store.skills, upgradeEffects.meditationEfficiency]
|
||||
);
|
||||
|
||||
const incursionStrength = useMemo(
|
||||
() => getIncursionStrength(store.day, store.hour),
|
||||
[store.day, store.hour]
|
||||
);
|
||||
|
||||
// Effective regen with incursion penalty
|
||||
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
|
||||
|
||||
// Mana Cascade bonus
|
||||
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
|
||||
? Math.floor(maxMana / 100) * 0.1
|
||||
: 0;
|
||||
|
||||
// Final effective regen
|
||||
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
|
||||
|
||||
return {
|
||||
upgradeEffects,
|
||||
maxMana,
|
||||
baseRegen,
|
||||
clickMana,
|
||||
meditationMultiplier,
|
||||
incursionStrength,
|
||||
effectiveRegenWithSpecials,
|
||||
manaCascadeBonus,
|
||||
effectiveRegen,
|
||||
hasSteadyStream: hasSpecial(upgradeEffects, SPECIAL_EFFECTS.STEADY_STREAM),
|
||||
hasManaTorrent: hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_TORRENT),
|
||||
hasDesperateWells: hasSpecial(upgradeEffects, SPECIAL_EFFECTS.DESPERATE_WELLS),
|
||||
hasManaEcho: hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_ECHO),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for combat-related derived stats
|
||||
*/
|
||||
export function useCombatStats() {
|
||||
const store = useGameStore();
|
||||
const { upgradeEffects } = useManaStats();
|
||||
|
||||
const floorElem = useMemo(
|
||||
() => getFloorElement(store.currentFloor),
|
||||
[store.currentFloor]
|
||||
);
|
||||
|
||||
const floorElemDef = useMemo(
|
||||
() => ELEMENTS[floorElem],
|
||||
[floorElem]
|
||||
);
|
||||
|
||||
const isGuardianFloor = useMemo(
|
||||
() => !!GUARDIANS[store.currentFloor],
|
||||
[store.currentFloor]
|
||||
);
|
||||
|
||||
const currentGuardian = useMemo(
|
||||
() => GUARDIANS[store.currentFloor],
|
||||
[store.currentFloor]
|
||||
);
|
||||
|
||||
const activeSpellDef = useMemo(
|
||||
() => SPELLS_DEF[store.activeSpell],
|
||||
[store.activeSpell]
|
||||
);
|
||||
|
||||
const pactMultiplier = useMemo(
|
||||
() => computePactMultiplier(store),
|
||||
[store]
|
||||
);
|
||||
|
||||
const pactInsightMultiplier = useMemo(
|
||||
() => computePactInsightMultiplier(store),
|
||||
[store]
|
||||
);
|
||||
|
||||
// DPS calculation
|
||||
const dps = useMemo(() => {
|
||||
if (!activeSpellDef) return 0;
|
||||
|
||||
const spellCastSpeed = activeSpellDef.castSpeed || 1;
|
||||
const quickCastBonus = 1 + (store.skills.quickCast || 0) * 0.05;
|
||||
const attackSpeedMult = upgradeEffects.attackSpeedMultiplier;
|
||||
const totalCastSpeed = spellCastSpeed * quickCastBonus * attackSpeedMult;
|
||||
|
||||
const damagePerCast = calcDamage(store, store.activeSpell, floorElem);
|
||||
const castsPerSecond = totalCastSpeed * HOURS_PER_TICK / (TICK_MS / 1000);
|
||||
|
||||
return damagePerCast * castsPerSecond;
|
||||
}, [activeSpellDef, store, floorElem, upgradeEffects.attackSpeedMultiplier]);
|
||||
|
||||
// Damage breakdown for display
|
||||
const damageBreakdown = useMemo(() => {
|
||||
if (!activeSpellDef) return null;
|
||||
|
||||
const baseDmg = activeSpellDef.dmg;
|
||||
const combatTrainBonus = (store.skills.combatTrain || 0) * 5;
|
||||
const arcaneFuryMult = 1 + (store.skills.arcaneFury || 0) * 0.1;
|
||||
const elemMasteryMult = 1 + (store.skills.elementalMastery || 0) * 0.15;
|
||||
const guardianBaneMult = isGuardianFloor ? (1 + (store.skills.guardianBane || 0) * 0.2) : 1;
|
||||
const precisionChance = (store.skills.precision || 0) * 0.05;
|
||||
|
||||
// Calculate elemental bonus
|
||||
const elemBonus = getElementalBonus(activeSpellDef.elem, floorElem);
|
||||
let elemBonusText = '';
|
||||
if (activeSpellDef.elem !== 'raw' && floorElem) {
|
||||
if (activeSpellDef.elem === floorElem) {
|
||||
elemBonusText = '+25% same element';
|
||||
} else if (elemBonus === 1.5) {
|
||||
elemBonusText = '+50% super effective';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
base: baseDmg,
|
||||
combatTrainBonus,
|
||||
arcaneFuryMult,
|
||||
elemMasteryMult,
|
||||
guardianBaneMult,
|
||||
pactMult: pactMultiplier,
|
||||
precisionChance,
|
||||
elemBonus,
|
||||
elemBonusText,
|
||||
total: calcDamage(store, store.activeSpell, floorElem),
|
||||
};
|
||||
}, [activeSpellDef, store, floorElem, isGuardianFloor, pactMultiplier]);
|
||||
|
||||
return {
|
||||
floorElem,
|
||||
floorElemDef,
|
||||
isGuardianFloor,
|
||||
currentGuardian,
|
||||
activeSpellDef,
|
||||
pactMultiplier,
|
||||
pactInsightMultiplier,
|
||||
dps,
|
||||
damageBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for study-related derived stats
|
||||
*/
|
||||
export function useStudyStats() {
|
||||
const store = useGameStore();
|
||||
|
||||
const studySpeedMult = useMemo(
|
||||
() => getStudySpeedMultiplier(store.skills),
|
||||
[store.skills]
|
||||
);
|
||||
|
||||
const studyCostMult = useMemo(
|
||||
() => getStudyCostMultiplier(store.skills),
|
||||
[store.skills]
|
||||
);
|
||||
|
||||
const upgradeEffects = useMemo(
|
||||
() => computeEffects(store.skillUpgrades || {}, store.skillTiers || {}),
|
||||
[store.skillUpgrades, store.skillTiers]
|
||||
);
|
||||
|
||||
const effectiveStudySpeedMult = studySpeedMult * upgradeEffects.studySpeedMultiplier;
|
||||
|
||||
return {
|
||||
studySpeedMult,
|
||||
studyCostMult,
|
||||
effectiveStudySpeedMult,
|
||||
hasParallelStudy: hasSpecial(upgradeEffects, SPECIAL_EFFECTS.PARALLEL_STUDY),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
542
src/lib/game/skills.test.ts
Executable file
542
src/lib/game/skills.test.ts
Executable file
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* Comprehensive Skill Tests
|
||||
*
|
||||
* Tests each skill to verify they work exactly as their descriptions say.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeElementMax,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
calcDamage,
|
||||
calcInsight,
|
||||
getMeditationBonus,
|
||||
} from './store';
|
||||
import {
|
||||
SKILLS_DEF,
|
||||
PRESTIGE_DEF,
|
||||
GUARDIANS,
|
||||
getStudySpeedMultiplier,
|
||||
getStudyCostMultiplier,
|
||||
} from './constants';
|
||||
import type { GameState } from './types';
|
||||
|
||||
// ─── Test Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function createMockState(overrides: Partial<GameState> = {}): GameState {
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
const baseElements = ['fire', 'water', 'air', 'earth', 'light', 'dark', 'death', 'transference', 'metal', 'sand', 'crystal', 'stellar', 'void', 'lightning'];
|
||||
baseElements.forEach((k) => {
|
||||
elements[k] = { current: 0, max: 10, unlocked: baseElements.slice(0, 4).includes(k) };
|
||||
});
|
||||
|
||||
return {
|
||||
day: 1,
|
||||
hour: 0,
|
||||
loopCount: 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
paused: false,
|
||||
rawMana: 100,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements,
|
||||
currentFloor: 1,
|
||||
floorHP: 100,
|
||||
floorMaxHP: 100,
|
||||
castProgress: 0,
|
||||
currentRoom: {
|
||||
roomType: 'combat',
|
||||
enemies: [{ id: 'enemy', hp: 100, maxHP: 100, armor: 0, dodgeChance: 0, element: 'fire' }],
|
||||
},
|
||||
maxFloorReached: 1,
|
||||
signedPacts: [],
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
parallelStudyTarget: null,
|
||||
equippedInstances: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory1: null, accessory2: null },
|
||||
equipmentInstances: {},
|
||||
enchantmentDesigns: [],
|
||||
designProgress: null,
|
||||
preparationProgress: null,
|
||||
applicationProgress: null,
|
||||
equipmentCraftingProgress: null,
|
||||
unlockedEffects: [],
|
||||
equipmentSpellStates: [],
|
||||
equipment: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory: null },
|
||||
inventory: [],
|
||||
blueprints: {},
|
||||
lootInventory: { materials: {}, blueprints: [] },
|
||||
schedule: [],
|
||||
autoSchedule: false,
|
||||
studyQueue: [],
|
||||
craftQueue: [],
|
||||
currentStudyTarget: null,
|
||||
achievements: { unlocked: [], progress: {} },
|
||||
totalSpellsCast: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalCraftsCompleted: 0,
|
||||
attunements: {
|
||||
enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 },
|
||||
invoker: { id: 'invoker', active: false, level: 1, experience: 0 },
|
||||
fabricator: { id: 'fabricator', active: false, level: 1, experience: 0 },
|
||||
},
|
||||
golemancy: {
|
||||
enabledGolems: [],
|
||||
summonedGolems: [],
|
||||
lastSummonFloor: 0,
|
||||
},
|
||||
insight: 0,
|
||||
totalInsight: 0,
|
||||
prestigeUpgrades: {},
|
||||
memorySlots: 3,
|
||||
memories: [],
|
||||
incursionStrength: 0,
|
||||
containmentWards: 0,
|
||||
log: [],
|
||||
loopInsight: 0,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
// ─── Mana Skills Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Mana Skills', () => {
|
||||
describe('Mana Well (+100 max mana)', () => {
|
||||
it('should add 100 max mana per level', () => {
|
||||
const state0 = createMockState({ skills: { manaWell: 0 } });
|
||||
const state1 = createMockState({ skills: { manaWell: 1 } });
|
||||
const state5 = createMockState({ skills: { manaWell: 5 } });
|
||||
const state10 = createMockState({ skills: { manaWell: 10 } });
|
||||
|
||||
expect(computeMaxMana(state0)).toBe(100);
|
||||
expect(computeMaxMana(state1)).toBe(100 + 100);
|
||||
expect(computeMaxMana(state5)).toBe(100 + 500);
|
||||
expect(computeMaxMana(state10)).toBe(100 + 1000);
|
||||
});
|
||||
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.manaWell.desc).toBe("+100 max mana");
|
||||
expect(SKILLS_DEF.manaWell.max).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mana Flow (+1 regen/hr)', () => {
|
||||
it('should add 1 regen per hour per level', () => {
|
||||
const state0 = createMockState({ skills: { manaFlow: 0 } });
|
||||
const state1 = createMockState({ skills: { manaFlow: 1 } });
|
||||
const state5 = createMockState({ skills: { manaFlow: 5 } });
|
||||
const state10 = createMockState({ skills: { manaFlow: 10 } });
|
||||
|
||||
// With enchanter attunement giving +0.5 regen, base is 2.5
|
||||
const baseRegen = computeRegen(state0);
|
||||
expect(baseRegen).toBeGreaterThan(2); // Has attunement bonus
|
||||
expect(computeRegen(state1)).toBe(baseRegen + 1);
|
||||
expect(computeRegen(state5)).toBe(baseRegen + 5);
|
||||
expect(computeRegen(state10)).toBe(baseRegen + 10);
|
||||
});
|
||||
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.manaFlow.desc).toBe("+1 regen/hr");
|
||||
expect(SKILLS_DEF.manaFlow.max).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mana Spring (+2 mana regen)', () => {
|
||||
it('should add 2 mana regen', () => {
|
||||
const state0 = createMockState({ skills: { manaSpring: 0 } });
|
||||
const state1 = createMockState({ skills: { manaSpring: 1 } });
|
||||
|
||||
const baseRegen = computeRegen(state0);
|
||||
expect(baseRegen).toBeGreaterThan(2); // Has attunement bonus
|
||||
expect(computeRegen(state1)).toBe(baseRegen + 2);
|
||||
});
|
||||
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.manaSpring.desc).toBe("+2 mana regen");
|
||||
expect(SKILLS_DEF.manaSpring.max).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Elemental Attunement (+50 elem mana cap)', () => {
|
||||
it('should add 50 element mana capacity per level', () => {
|
||||
const state0 = createMockState({ skills: { elemAttune: 0 } });
|
||||
const state1 = createMockState({ skills: { elemAttune: 1 } });
|
||||
const state5 = createMockState({ skills: { elemAttune: 5 } });
|
||||
const state10 = createMockState({ skills: { elemAttune: 10 } });
|
||||
|
||||
expect(computeElementMax(state0)).toBe(10);
|
||||
expect(computeElementMax(state1)).toBe(10 + 50);
|
||||
expect(computeElementMax(state5)).toBe(10 + 250);
|
||||
expect(computeElementMax(state10)).toBe(10 + 500);
|
||||
});
|
||||
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.elemAttune.desc).toBe("+50 elem mana cap");
|
||||
expect(SKILLS_DEF.elemAttune.max).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mana Overflow (+25% mana from clicks)', () => {
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.manaOverflow.desc).toBe("+25% mana from clicks");
|
||||
expect(SKILLS_DEF.manaOverflow.max).toBe(5);
|
||||
});
|
||||
|
||||
it('should require Mana Well 3', () => {
|
||||
expect(SKILLS_DEF.manaOverflow.req).toEqual({ manaWell: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mana Tap (+1 mana/click)', () => {
|
||||
it('should add 1 mana per click', () => {
|
||||
const state0 = createMockState({ skills: { manaTap: 0 } });
|
||||
const state1 = createMockState({ skills: { manaTap: 1 } });
|
||||
|
||||
expect(computeClickMana(state0)).toBe(1);
|
||||
expect(computeClickMana(state1)).toBe(2);
|
||||
});
|
||||
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.manaTap.desc).toBe("+1 mana/click");
|
||||
expect(SKILLS_DEF.manaTap.max).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mana Surge (+3 mana/click)', () => {
|
||||
it('should add 3 mana per click', () => {
|
||||
const state0 = createMockState({ skills: { manaSurge: 0 } });
|
||||
const state1 = createMockState({ skills: { manaSurge: 1 } });
|
||||
|
||||
expect(computeClickMana(state0)).toBe(1);
|
||||
expect(computeClickMana(state1)).toBe(4);
|
||||
});
|
||||
|
||||
it('should stack with Mana Tap', () => {
|
||||
const state = createMockState({ skills: { manaTap: 1, manaSurge: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 1 + 3);
|
||||
});
|
||||
|
||||
it('should require Mana Tap 1', () => {
|
||||
expect(SKILLS_DEF.manaSurge.req).toEqual({ manaTap: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Study Skills Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Study Skills', () => {
|
||||
describe('Quick Learner (+10% study speed)', () => {
|
||||
it('should multiply study speed by 10% per level', () => {
|
||||
expect(getStudySpeedMultiplier({})).toBe(1);
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 1 })).toBe(1.1);
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 3 })).toBe(1.3);
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 5 })).toBe(1.5);
|
||||
});
|
||||
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.quickLearner.desc).toBe("+10% study speed");
|
||||
expect(SKILLS_DEF.quickLearner.max).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Focused Mind (-5% study mana cost)', () => {
|
||||
it('should reduce study mana cost by 5% per level', () => {
|
||||
expect(getStudyCostMultiplier({})).toBe(1);
|
||||
expect(getStudyCostMultiplier({ focusedMind: 1 })).toBe(0.95);
|
||||
expect(getStudyCostMultiplier({ focusedMind: 3 })).toBe(0.85);
|
||||
expect(getStudyCostMultiplier({ focusedMind: 5 })).toBe(0.75);
|
||||
});
|
||||
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.focusedMind.desc).toBe("-5% study mana cost");
|
||||
expect(SKILLS_DEF.focusedMind.max).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meditation Focus (Up to 2.5x regen after 4hrs)', () => {
|
||||
it('should provide meditation bonus caps', () => {
|
||||
expect(SKILLS_DEF.meditation.desc).toContain("2.5x");
|
||||
expect(SKILLS_DEF.meditation.max).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Knowledge Retention (+20% study progress saved)', () => {
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.knowledgeRetention.desc).toBe("+20% study progress saved on cancel");
|
||||
expect(SKILLS_DEF.knowledgeRetention.max).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deep Trance (Extend to 6hrs for 3x)', () => {
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.deepTrance.desc).toContain("6hrs");
|
||||
expect(SKILLS_DEF.deepTrance.max).toBe(1);
|
||||
});
|
||||
|
||||
it('should require Meditation 1', () => {
|
||||
expect(SKILLS_DEF.deepTrance.req).toEqual({ meditation: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Void Meditation (Extend to 8hrs for 5x)', () => {
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.voidMeditation.desc).toContain("8hrs");
|
||||
expect(SKILLS_DEF.voidMeditation.max).toBe(1);
|
||||
});
|
||||
|
||||
it('should require Deep Trance 1', () => {
|
||||
expect(SKILLS_DEF.voidMeditation.req).toEqual({ deepTrance: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ascension Skills Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Ascension Skills', () => {
|
||||
describe('Insight Harvest (+10% insight gain)', () => {
|
||||
it('should multiply insight gain by 10% per level', () => {
|
||||
const state0 = createMockState({ maxFloorReached: 10, skills: { insightHarvest: 0 } });
|
||||
const state1 = createMockState({ maxFloorReached: 10, skills: { insightHarvest: 1 } });
|
||||
const state5 = createMockState({ maxFloorReached: 10, skills: { insightHarvest: 5 } });
|
||||
|
||||
const insight0 = calcInsight(state0);
|
||||
const insight1 = calcInsight(state1);
|
||||
const insight5 = calcInsight(state5);
|
||||
|
||||
expect(insight1).toBeGreaterThan(insight0);
|
||||
expect(insight5).toBeGreaterThan(insight1);
|
||||
});
|
||||
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.insightHarvest.desc).toBe("+10% insight gain");
|
||||
expect(SKILLS_DEF.insightHarvest.max).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guardian Bane (+20% dmg vs guardians)', () => {
|
||||
it('skill definition should match description', () => {
|
||||
expect(SKILLS_DEF.guardianBane.desc).toBe("+20% dmg vs guardians");
|
||||
expect(SKILLS_DEF.guardianBane.max).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Enchanter Skills Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Enchanter Skills', () => {
|
||||
describe('Enchanting (Unlock enchantment design)', () => {
|
||||
it('skill definition should exist', () => {
|
||||
expect(SKILLS_DEF.enchanting).toBeDefined();
|
||||
expect(SKILLS_DEF.enchanting.attunement).toBe('enchanter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Efficient Enchant (-5% enchantment capacity cost)', () => {
|
||||
it('skill definition should exist', () => {
|
||||
expect(SKILLS_DEF.efficientEnchant).toBeDefined();
|
||||
expect(SKILLS_DEF.efficientEnchant.max).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disenchanting (Recover mana from removed enchantments)', () => {
|
||||
it('skill definition should exist', () => {
|
||||
expect(SKILLS_DEF.disenchanting).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Golemancy Skills Tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('Golemancy Skills', () => {
|
||||
describe('Golem Mastery (+10% golem damage)', () => {
|
||||
it('skill definition should exist', () => {
|
||||
expect(SKILLS_DEF.golemMastery).toBeDefined();
|
||||
expect(SKILLS_DEF.golemMastery.attunementReq).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Golem Efficiency (+5% attack speed)', () => {
|
||||
it('skill definition should exist', () => {
|
||||
expect(SKILLS_DEF.golemEfficiency).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Golem Longevity (+1 floor duration)', () => {
|
||||
it('skill definition should exist', () => {
|
||||
expect(SKILLS_DEF.golemLongevity).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Golem Siphon (-10% maintenance)', () => {
|
||||
it('skill definition should exist', () => {
|
||||
expect(SKILLS_DEF.golemSiphon).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Meditation Bonus Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Meditation Bonus', () => {
|
||||
it('should start at 1x with no meditation', () => {
|
||||
expect(getMeditationBonus(0, {})).toBe(1);
|
||||
});
|
||||
|
||||
it('should ramp up over time without skills', () => {
|
||||
const bonus1hr = getMeditationBonus(25, {}); // 1 hour of ticks
|
||||
expect(bonus1hr).toBeGreaterThan(1);
|
||||
|
||||
const bonus4hr = getMeditationBonus(100, {}); // 4 hours
|
||||
expect(bonus4hr).toBeGreaterThan(bonus1hr);
|
||||
});
|
||||
|
||||
it('should cap at 1.5x without meditation skill', () => {
|
||||
const bonus = getMeditationBonus(200, {}); // 8 hours
|
||||
expect(bonus).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should give 2.5x with meditation skill after 4 hours', () => {
|
||||
const bonus = getMeditationBonus(100, { meditation: 1 });
|
||||
expect(bonus).toBe(2.5);
|
||||
});
|
||||
|
||||
it('should give 3.0x with deepTrance skill after 6 hours', () => {
|
||||
const bonus = getMeditationBonus(150, { meditation: 1, deepTrance: 1 });
|
||||
expect(bonus).toBe(3.0);
|
||||
});
|
||||
|
||||
it('should give 5.0x with voidMeditation skill after 8 hours', () => {
|
||||
const bonus = getMeditationBonus(200, { meditation: 1, deepTrance: 1, voidMeditation: 1 });
|
||||
expect(bonus).toBe(5.0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Skill Prerequisites Tests ──────────────────────────────────────────────────
|
||||
|
||||
describe('Skill Prerequisites', () => {
|
||||
it('Mana Overflow should require Mana Well 3', () => {
|
||||
expect(SKILLS_DEF.manaOverflow.req).toEqual({ manaWell: 3 });
|
||||
});
|
||||
|
||||
it('Mana Surge should require Mana Tap 1', () => {
|
||||
expect(SKILLS_DEF.manaSurge.req).toEqual({ manaTap: 1 });
|
||||
});
|
||||
|
||||
it('Deep Trance should require Meditation 1', () => {
|
||||
expect(SKILLS_DEF.deepTrance.req).toEqual({ meditation: 1 });
|
||||
});
|
||||
|
||||
it('Void Meditation should require Deep Trance 1', () => {
|
||||
expect(SKILLS_DEF.voidMeditation.req).toEqual({ deepTrance: 1 });
|
||||
});
|
||||
|
||||
it('Efficient Enchant should require Enchanting 3', () => {
|
||||
expect(SKILLS_DEF.efficientEnchant.req).toEqual({ enchanting: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Study Time Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Study Times', () => {
|
||||
it('all skills should have reasonable study times', () => {
|
||||
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
|
||||
expect(skill.studyTime).toBeGreaterThan(0);
|
||||
expect(skill.studyTime).toBeLessThanOrEqual(72);
|
||||
});
|
||||
});
|
||||
|
||||
it('ascension skills should have long study times', () => {
|
||||
const ascensionSkills = Object.entries(SKILLS_DEF).filter(([, s]) => s.cat === 'ascension');
|
||||
ascensionSkills.forEach(([, skill]) => {
|
||||
expect(skill.studyTime).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prestige Upgrade Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Prestige Upgrades', () => {
|
||||
it('all prestige upgrades should have valid costs', () => {
|
||||
Object.entries(PRESTIGE_DEF).forEach(([id, upgrade]) => {
|
||||
expect(upgrade.cost).toBeGreaterThan(0);
|
||||
expect(upgrade.max).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('Mana Well prestige should add 500 starting max mana', () => {
|
||||
const state0 = createMockState({ prestigeUpgrades: { manaWell: 0 } });
|
||||
const state1 = createMockState({ prestigeUpgrades: { manaWell: 1 } });
|
||||
const state5 = createMockState({ prestigeUpgrades: { manaWell: 5 } });
|
||||
|
||||
expect(computeMaxMana(state0)).toBe(100);
|
||||
expect(computeMaxMana(state1)).toBe(100 + 500);
|
||||
expect(computeMaxMana(state5)).toBe(100 + 2500);
|
||||
});
|
||||
|
||||
it('Elemental Attunement prestige should add 25 element cap', () => {
|
||||
const state0 = createMockState({ prestigeUpgrades: { elementalAttune: 0 } });
|
||||
const state1 = createMockState({ prestigeUpgrades: { elementalAttune: 1 } });
|
||||
const state10 = createMockState({ prestigeUpgrades: { elementalAttune: 10 } });
|
||||
|
||||
expect(computeElementMax(state0)).toBe(10);
|
||||
expect(computeElementMax(state1)).toBe(10 + 25);
|
||||
expect(computeElementMax(state10)).toBe(10 + 250);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('skill costs should scale with level', () => {
|
||||
const skill = SKILLS_DEF.manaWell;
|
||||
for (let level = 0; level < skill.max; level++) {
|
||||
const cost = skill.base * (level + 1);
|
||||
expect(cost).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('all skills should have valid categories', () => {
|
||||
const validCategories = ['mana', 'study', 'ascension', 'enchant', 'effectResearch', 'invocation', 'pact', 'fabrication', 'golemancy', 'research', 'craft'];
|
||||
Object.values(SKILLS_DEF).forEach(skill => {
|
||||
expect(validCategories).toContain(skill.cat);
|
||||
});
|
||||
});
|
||||
|
||||
it('all prerequisite skills should exist', () => {
|
||||
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
|
||||
if (skill.req) {
|
||||
Object.keys(skill.req).forEach(reqId => {
|
||||
expect(SKILLS_DEF[reqId]).toBeDefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('all prerequisite levels should be within skill max', () => {
|
||||
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
|
||||
if (skill.req) {
|
||||
Object.entries(skill.req).forEach(([reqId, reqLevel]) => {
|
||||
expect(reqLevel).toBeLessThanOrEqual(SKILLS_DEF[reqId].max);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('all attunement-requiring skills should have valid attunement', () => {
|
||||
const validAttunements = ['enchanter', 'invoker', 'fabricator'];
|
||||
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
|
||||
if (skill.attunement) {
|
||||
expect(validAttunements).toContain(skill.attunement);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ All skill tests defined. Run with: bun test src/lib/game/skills.test.ts');
|
||||
2079
src/lib/game/store.test.ts
Executable file
2079
src/lib/game/store.test.ts
Executable file
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,7 @@ import {
|
||||
import { EQUIPMENT_TYPES } from './data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
||||
import { ATTUNEMENTS_DEF, getTotalAttunementRegen, getAttunementConversionRate, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from './data/attunements';
|
||||
import { GOLEMS_DEF, getGolemSlots, isGolemUnlocked, getGolemDamage, getGolemAttackSpeed, getGolemFloorDuration } from './data/golems';
|
||||
import { GOLEMS_DEF, getGolemSlots, isGolemUnlocked, getGolemDamage, getGolemAttackSpeed, getGolemFloorDuration, canAffordGolemSummon, deductGolemSummonCost, canAffordGolemMaintenance, deductGolemMaintenance } from './data/golems';
|
||||
|
||||
// Default empty effects for when effects aren't provided
|
||||
const DEFAULT_EFFECTS: ComputedEffects = {
|
||||
@@ -105,7 +105,7 @@ export function getFloorMaxHP(floor: number): number {
|
||||
}
|
||||
|
||||
export function getFloorElement(floor: number): string {
|
||||
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
|
||||
return FLOOR_ELEM_CYCLE[(floor - 1) % FLOOR_ELEM_CYCLE.length];
|
||||
}
|
||||
|
||||
// ─── Room Generation Functions ────────────────────────────────────────────────
|
||||
@@ -746,7 +746,7 @@ interface GameStore extends GameState, CraftingActions {
|
||||
addLog: (message: string) => void;
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[], milestone?: 5 | 10) => void;
|
||||
tierUpSkill: (skillId: string) => void;
|
||||
|
||||
// Attunement XP and leveling
|
||||
@@ -1146,6 +1146,190 @@ export const useGameStore = create<GameStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Golemancy Processing ─────────────────────────────────────────────────
|
||||
let golemancy = state.golemancy;
|
||||
const fabricatorLevel = state.attunements.fabricator?.level || 0;
|
||||
const maxGolemSlots = getGolemSlots(fabricatorLevel);
|
||||
|
||||
// Check if golems need to be summoned (floor changed or first summon)
|
||||
const floorChanged = currentFloor !== golemancy.lastSummonFloor;
|
||||
const inCombatRoom = currentRoom.roomType !== 'puzzle';
|
||||
|
||||
if (state.currentAction === 'climb' && inCombatRoom && floorChanged && maxGolemSlots > 0) {
|
||||
// Determine which golems should be summoned
|
||||
const unlockedElementIds = Object.entries(elements)
|
||||
.filter(([, e]) => e.unlocked)
|
||||
.map(([id]) => id);
|
||||
|
||||
const enabledAndUnlocked = golemancy.enabledGolems.filter(golemId =>
|
||||
isGolemUnlocked(golemId, state.attunements, unlockedElementIds)
|
||||
);
|
||||
|
||||
// Limit to available slots
|
||||
const golemsToSummon = enabledAndUnlocked.slice(0, maxGolemSlots);
|
||||
|
||||
// Summon golems that can be afforded
|
||||
const summonedGolems: typeof golemancy.summonedGolems = [];
|
||||
let summonCostsPaid = 0;
|
||||
|
||||
for (const golemId of golemsToSummon) {
|
||||
if (canAffordGolemSummon(golemId, rawMana, elements)) {
|
||||
const afterCost = deductGolemSummonCost(golemId, rawMana, elements);
|
||||
rawMana = afterCost.rawMana;
|
||||
elements = afterCost.elements;
|
||||
|
||||
summonedGolems.push({
|
||||
golemId,
|
||||
summonedFloor: currentFloor,
|
||||
attackProgress: 0,
|
||||
});
|
||||
summonCostsPaid++;
|
||||
}
|
||||
}
|
||||
|
||||
if (summonedGolems.length > 0) {
|
||||
golemancy = {
|
||||
...golemancy,
|
||||
summonedGolems,
|
||||
lastSummonFloor: currentFloor,
|
||||
};
|
||||
|
||||
if (summonCostsPaid > 0) {
|
||||
log = [`🗿 Summoned ${summonedGolems.length} golem(s) on floor ${currentFloor}!`, ...log.slice(0, 49)];
|
||||
}
|
||||
} else if (golemsToSummon.length > 0) {
|
||||
log = [`⚠️ Could not afford to summon any golems!`, ...log.slice(0, 49)];
|
||||
}
|
||||
}
|
||||
|
||||
// Process golem maintenance and attacks each tick
|
||||
if (golemancy.summonedGolems.length > 0 && state.currentAction === 'climb' && inCombatRoom) {
|
||||
const floorDuration = getGolemFloorDuration(skills);
|
||||
const survivingGolems: typeof golemancy.summonedGolems = [];
|
||||
let anyGolemDismissed = false;
|
||||
|
||||
for (const summonedGolem of golemancy.summonedGolems) {
|
||||
const golemId = summonedGolem.golemId;
|
||||
|
||||
// Check floor duration
|
||||
const floorsActive = currentFloor - summonedGolem.summonedFloor;
|
||||
if (floorsActive >= floorDuration) {
|
||||
log = [`⏰ ${GOLEMS_DEF[golemId]?.name || golemId} returned to the earth after ${floorDuration} floor(s).`, ...log.slice(0, 49)];
|
||||
anyGolemDismissed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check and pay maintenance cost
|
||||
if (!canAffordGolemMaintenance(golemId, rawMana, elements, skills)) {
|
||||
log = [`💫 ${GOLEMS_DEF[golemId]?.name || golemId} dismissed - insufficient mana for maintenance!`, ...log.slice(0, 49)];
|
||||
anyGolemDismissed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const afterMaintenance = deductGolemMaintenance(golemId, rawMana, elements, skills);
|
||||
rawMana = afterMaintenance.rawMana;
|
||||
elements = afterMaintenance.elements;
|
||||
|
||||
survivingGolems.push(summonedGolem);
|
||||
}
|
||||
|
||||
if (anyGolemDismissed) {
|
||||
golemancy = {
|
||||
...golemancy,
|
||||
summonedGolems: survivingGolems,
|
||||
};
|
||||
}
|
||||
|
||||
// Process golem attacks
|
||||
for (const summonedGolem of golemancy.summonedGolems) {
|
||||
const golemDef = GOLEMS_DEF[summonedGolem.golemId];
|
||||
if (!golemDef) continue;
|
||||
|
||||
// Get attack speed (attacks per hour)
|
||||
const attackSpeed = getGolemAttackSpeed(summonedGolem.golemId, skills);
|
||||
const progressGain = HOURS_PER_TICK * attackSpeed;
|
||||
|
||||
// Accumulate attack progress
|
||||
summonedGolem.attackProgress = (summonedGolem.attackProgress || 0) + progressGain;
|
||||
|
||||
// Process attacks when progress >= 1
|
||||
while (summonedGolem.attackProgress >= 1) {
|
||||
// Find alive enemies
|
||||
const aliveEnemies = currentRoom.enemies.filter(e => e.hp > 0);
|
||||
if (aliveEnemies.length === 0) break;
|
||||
|
||||
// Calculate damage
|
||||
const baseDamage = getGolemDamage(summonedGolem.golemId, skills);
|
||||
|
||||
// Determine targets (AOE vs single target)
|
||||
const numTargets = golemDef.isAoe
|
||||
? Math.min(golemDef.aoeTargets, aliveEnemies.length)
|
||||
: 1;
|
||||
|
||||
const targets = aliveEnemies.slice(0, numTargets);
|
||||
|
||||
for (const enemy of targets) {
|
||||
let damage = baseDamage;
|
||||
|
||||
// AOE damage falloff
|
||||
if (golemDef.isAoe && numTargets > 1) {
|
||||
damage *= (1 - 0.1 * (targets.indexOf(enemy)));
|
||||
}
|
||||
|
||||
// Apply armor reduction with pierce
|
||||
const effectiveArmor = Math.max(0, enemy.armor - golemDef.armorPierce);
|
||||
damage *= (1 - effectiveArmor);
|
||||
|
||||
// Apply damage
|
||||
enemy.hp = Math.max(0, enemy.hp - Math.floor(damage));
|
||||
}
|
||||
|
||||
// Update currentRoom with damaged enemies
|
||||
currentRoom = { ...currentRoom, enemies: [...currentRoom.enemies] };
|
||||
|
||||
// Reduce attack progress
|
||||
summonedGolem.attackProgress -= 1;
|
||||
|
||||
// Check if all enemies are dead
|
||||
const allDead = currentRoom.enemies.every(e => e.hp <= 0);
|
||||
if (allDead) {
|
||||
// Floor cleared by golems - trigger floor change logic
|
||||
const wasGuardian = GUARDIANS[currentFloor];
|
||||
if (wasGuardian && !signedPacts.includes(currentFloor)) {
|
||||
signedPacts = [...signedPacts, currentFloor];
|
||||
log = [`⚔️ ${wasGuardian.name} defeated by golems! Pact signed! (${wasGuardian.pact}x)`, ...log.slice(0, 49)];
|
||||
} else if (!wasGuardian) {
|
||||
const roomTypeName = currentRoom.roomType === 'swarm' ? 'Swarm'
|
||||
: currentRoom.roomType === 'speed' ? 'Speed floor'
|
||||
: currentRoom.roomType === 'puzzle' ? 'Puzzle'
|
||||
: 'Floor';
|
||||
if (currentFloor % 5 === 0 || currentRoom.roomType !== 'combat') {
|
||||
log = [`🗿 ${roomTypeName} ${currentFloor} cleared by golems!`, ...log.slice(0, 49)];
|
||||
}
|
||||
}
|
||||
|
||||
currentFloor = currentFloor + 1;
|
||||
if (currentFloor > 100) currentFloor = 100;
|
||||
currentRoom = generateFloorState(currentFloor);
|
||||
floorMaxHP = getFloorMaxHP(currentFloor);
|
||||
floorHP = currentRoom.enemies[0]?.hp || floorMaxHP;
|
||||
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
||||
castProgress = 0;
|
||||
break; // Exit attack loop on floor change
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unsummon golems when not climbing or in puzzle room
|
||||
if ((state.currentAction !== 'climb' || !inCombatRoom) && golemancy.summonedGolems.length > 0) {
|
||||
log = [`🗿 Golems returned to the earth.`, ...log.slice(0, 49)];
|
||||
golemancy = {
|
||||
...golemancy,
|
||||
summonedGolems: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Process crafting actions (design, prepare, enchant)
|
||||
const craftingUpdates = processCraftingTick(
|
||||
{
|
||||
@@ -1194,6 +1378,7 @@ export const useGameStore = create<GameStore>()(
|
||||
elements,
|
||||
log,
|
||||
castProgress,
|
||||
golemancy,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1219,6 +1404,7 @@ export const useGameStore = create<GameStore>()(
|
||||
unlockedEffects,
|
||||
log,
|
||||
castProgress,
|
||||
golemancy,
|
||||
...craftingUpdates,
|
||||
});
|
||||
},
|
||||
@@ -1539,13 +1725,32 @@ export const useGameStore = create<GameStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => {
|
||||
set((state) => ({
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: upgradeIds,
|
||||
},
|
||||
}));
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[], milestone?: 5 | 10) => {
|
||||
set((state) => {
|
||||
const currentUpgrades = state.skillUpgrades?.[skillId] || [];
|
||||
|
||||
// If milestone is specified, merge by keeping other milestone's upgrades
|
||||
let newUpgrades: string[];
|
||||
if (milestone === 5) {
|
||||
// Keep existing L10 upgrades, replace L5 upgrades
|
||||
const existingL10 = currentUpgrades.filter(id => id.includes('_l10'));
|
||||
newUpgrades = [...upgradeIds, ...existingL10];
|
||||
} else if (milestone === 10) {
|
||||
// Keep existing L5 upgrades, replace L10 upgrades
|
||||
const existingL5 = currentUpgrades.filter(id => id.includes('_l5'));
|
||||
newUpgrades = [...existingL5, ...upgradeIds];
|
||||
} else {
|
||||
// No milestone specified (legacy behavior), replace all
|
||||
newUpgrades = upgradeIds;
|
||||
}
|
||||
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: newUpgrades,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
tierUpSkill: (skillId: string) => {
|
||||
@@ -1558,6 +1763,7 @@ export const useGameStore = create<GameStore>()(
|
||||
|
||||
const nextTierSkillId = `${baseSkillId}_t${nextTier}`;
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
const currentUpgrades = state.skillUpgrades?.[skillId] || [];
|
||||
|
||||
set({
|
||||
skillTiers: {
|
||||
@@ -1571,7 +1777,7 @@ export const useGameStore = create<GameStore>()(
|
||||
},
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[nextTierSkillId]: [], // Start fresh with upgrades for new tier
|
||||
[nextTierSkillId]: currentUpgrades, // Carry over upgrades from previous tier
|
||||
},
|
||||
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
|
||||
157
src/lib/game/store/combatSlice.ts
Executable file
157
src/lib/game/store/combatSlice.ts
Executable file
@@ -0,0 +1,157 @@
|
||||
// ─── Combat Slice ─────────────────────────────────────────────────────────────
|
||||
// Manages spire climbing, combat, and floor progression
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState, GameAction, SpellCost } from '../types';
|
||||
import { GUARDIANS, SPELLS_DEF, ELEMENTS, ELEMENT_OPPOSITES } from '../constants';
|
||||
import { getFloorMaxHP, getFloorElement, calcDamage, computePactMultiplier, canAffordSpellCost, deductSpellCost } from './computed';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects';
|
||||
|
||||
export interface CombatSlice {
|
||||
// State
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
activeSpell: string;
|
||||
currentAction: GameAction;
|
||||
castProgress: number;
|
||||
|
||||
// Actions
|
||||
setAction: (action: GameAction) => void;
|
||||
setSpell: (spellId: string) => void;
|
||||
getDamage: (spellId: string) => number;
|
||||
|
||||
// Internal combat processing
|
||||
processCombat: (deltaHours: number) => Partial<GameState>;
|
||||
}
|
||||
|
||||
export const createCombatSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState
|
||||
): CombatSlice => ({
|
||||
currentFloor: 1,
|
||||
floorHP: getFloorMaxHP(1),
|
||||
floorMaxHP: getFloorMaxHP(1),
|
||||
maxFloorReached: 1,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
|
||||
setAction: (action: GameAction) => {
|
||||
set((state) => ({
|
||||
currentAction: action,
|
||||
meditateTicks: action === 'meditate' ? state.meditateTicks : 0,
|
||||
}));
|
||||
},
|
||||
|
||||
setSpell: (spellId: string) => {
|
||||
const state = get();
|
||||
if (state.spells[spellId]?.learned) {
|
||||
set({ activeSpell: spellId });
|
||||
}
|
||||
},
|
||||
|
||||
getDamage: (spellId: string) => {
|
||||
const state = get();
|
||||
const floorElem = getFloorElement(state.currentFloor);
|
||||
return calcDamage(state, spellId, floorElem);
|
||||
},
|
||||
|
||||
processCombat: (deltaHours: number) => {
|
||||
const state = get();
|
||||
if (state.currentAction !== 'climb') return {};
|
||||
|
||||
const spellId = state.activeSpell;
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
if (!spellDef) return {};
|
||||
|
||||
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
|
||||
const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05;
|
||||
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
|
||||
const spellCastSpeed = spellDef.castSpeed || 1;
|
||||
const progressPerTick = deltaHours * spellCastSpeed * totalAttackSpeed;
|
||||
|
||||
let castProgress = (state.castProgress || 0) + progressPerTick;
|
||||
let rawMana = state.rawMana;
|
||||
let elements = state.elements;
|
||||
let totalManaGathered = state.totalManaGathered;
|
||||
let currentFloor = state.currentFloor;
|
||||
let floorHP = state.floorHP;
|
||||
let floorMaxHP = state.floorMaxHP;
|
||||
let maxFloorReached = state.maxFloorReached;
|
||||
let signedPacts = state.signedPacts;
|
||||
let pendingPactOffer = state.pendingPactOffer;
|
||||
const log = [...state.log];
|
||||
const skills = state.skills;
|
||||
|
||||
const floorElement = getFloorElement(currentFloor);
|
||||
|
||||
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements)) {
|
||||
// Deduct cost
|
||||
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
|
||||
rawMana = afterCost.rawMana;
|
||||
elements = afterCost.elements;
|
||||
totalManaGathered += spellDef.cost.amount;
|
||||
|
||||
// Calculate damage
|
||||
let dmg = calcDamage(state, spellId, floorElement);
|
||||
dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus;
|
||||
|
||||
// Executioner: +100% damage to enemies below 25% HP
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && floorHP / floorMaxHP < 0.25) {
|
||||
dmg *= 2;
|
||||
}
|
||||
|
||||
// Berserker: +50% damage when below 50% mana
|
||||
const maxMana = 100; // Would need proper max mana calculation
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
||||
dmg *= 1.5;
|
||||
}
|
||||
|
||||
// Spell echo - chance to cast again
|
||||
const echoChance = (skills.spellEcho || 0) * 0.1;
|
||||
if (Math.random() < echoChance) {
|
||||
dmg *= 2;
|
||||
log.unshift('✨ Spell Echo! Double damage!');
|
||||
}
|
||||
|
||||
// Apply damage
|
||||
floorHP = Math.max(0, floorHP - dmg);
|
||||
castProgress -= 1;
|
||||
|
||||
if (floorHP <= 0) {
|
||||
const wasGuardian = GUARDIANS[currentFloor];
|
||||
if (wasGuardian && !signedPacts.includes(currentFloor)) {
|
||||
pendingPactOffer = currentFloor;
|
||||
log.unshift(`⚔️ ${wasGuardian.name} defeated! They offer a pact...`);
|
||||
} else if (!wasGuardian) {
|
||||
if (currentFloor % 5 === 0) {
|
||||
log.unshift(`🏰 Floor ${currentFloor} cleared!`);
|
||||
}
|
||||
}
|
||||
|
||||
currentFloor = currentFloor + 1;
|
||||
if (currentFloor > 100) currentFloor = 100;
|
||||
floorMaxHP = getFloorMaxHP(currentFloor);
|
||||
floorHP = floorMaxHP;
|
||||
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
||||
castProgress = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rawMana,
|
||||
elements,
|
||||
totalManaGathered,
|
||||
currentFloor,
|
||||
floorHP,
|
||||
floorMaxHP,
|
||||
maxFloorReached,
|
||||
signedPacts,
|
||||
pendingPactOffer,
|
||||
castProgress,
|
||||
log,
|
||||
};
|
||||
},
|
||||
});
|
||||
322
src/lib/game/store/computed.ts
Executable file
322
src/lib/game/store/computed.ts
Executable file
@@ -0,0 +1,322 @@
|
||||
// ─── Computed Stats Functions ─────────────────────────────────────────────────
|
||||
|
||||
import type { GameState } from '../types';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, PRESTIGE_DEF, ELEMENT_OPPOSITES } from '../constants';
|
||||
import { computeEffects } from '../upgrade-effects';
|
||||
import { getTierMultiplier } from '../skill-evolution';
|
||||
|
||||
// Helper to get effective skill level accounting for tiers
|
||||
export function getEffectiveSkillLevel(
|
||||
skills: Record<string, number>,
|
||||
baseSkillId: string,
|
||||
skillTiers: Record<string, number> = {}
|
||||
): { level: number; tier: number; tierMultiplier: number } {
|
||||
const currentTier = skillTiers[baseSkillId] || 1;
|
||||
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
|
||||
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
|
||||
const tierMultiplier = Math.pow(10, currentTier - 1);
|
||||
return { level, tier: currentTier, tierMultiplier };
|
||||
}
|
||||
|
||||
export function computeMaxMana(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
|
||||
effects?: ReturnType<typeof computeEffects>
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const skillTiers = state.skillTiers || {};
|
||||
const skillUpgrades = state.skillUpgrades || {};
|
||||
|
||||
const manaWellLevel = getEffectiveSkillLevel(state.skills, 'manaWell', skillTiers);
|
||||
|
||||
const base =
|
||||
100 +
|
||||
manaWellLevel.level * 100 * manaWellLevel.tierMultiplier +
|
||||
(pu.manaWell || 0) * 500;
|
||||
|
||||
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
|
||||
return Math.floor((base + computedEffects.maxManaBonus) * computedEffects.maxManaMultiplier);
|
||||
}
|
||||
|
||||
export function computeElementMax(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
|
||||
effects?: ReturnType<typeof computeEffects>
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const skillTiers = state.skillTiers || {};
|
||||
const skillUpgrades = state.skillUpgrades || {};
|
||||
|
||||
const elemAttuneLevel = getEffectiveSkillLevel(state.skills, 'elemAttune', skillTiers);
|
||||
const base = 10 + elemAttuneLevel.level * 50 * elemAttuneLevel.tierMultiplier + (pu.elementalAttune || 0) * 25;
|
||||
|
||||
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
|
||||
return Math.floor((base + computedEffects.elementCapBonus) * computedEffects.elementCapMultiplier);
|
||||
}
|
||||
|
||||
export function computeRegen(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
|
||||
effects?: ReturnType<typeof computeEffects>
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const skillTiers = state.skillTiers || {};
|
||||
const skillUpgrades = state.skillUpgrades || {};
|
||||
|
||||
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
|
||||
|
||||
const manaFlowLevel = getEffectiveSkillLevel(state.skills, 'manaFlow', skillTiers);
|
||||
const manaSpringLevel = getEffectiveSkillLevel(state.skills, 'manaSpring', skillTiers);
|
||||
|
||||
const base =
|
||||
2 +
|
||||
manaFlowLevel.level * 1 * manaFlowLevel.tierMultiplier +
|
||||
manaSpringLevel.level * 2 * manaSpringLevel.tierMultiplier +
|
||||
(pu.manaFlow || 0) * 0.5;
|
||||
|
||||
let regen = base * temporalBonus;
|
||||
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
|
||||
regen = (regen + computedEffects.regenBonus + computedEffects.permanentRegenBonus) * computedEffects.regenMultiplier;
|
||||
|
||||
return regen;
|
||||
}
|
||||
|
||||
export function computeClickMana(
|
||||
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers'>,
|
||||
effects?: ReturnType<typeof computeEffects>
|
||||
): number {
|
||||
const skillTiers = state.skillTiers || {};
|
||||
const skillUpgrades = state.skillUpgrades || {};
|
||||
|
||||
const manaTapLevel = getEffectiveSkillLevel(state.skills, 'manaTap', skillTiers);
|
||||
const manaSurgeLevel = getEffectiveSkillLevel(state.skills, 'manaSurge', skillTiers);
|
||||
|
||||
const base =
|
||||
1 +
|
||||
manaTapLevel.level * 1 * manaTapLevel.tierMultiplier +
|
||||
manaSurgeLevel.level * 3 * manaSurgeLevel.tierMultiplier;
|
||||
|
||||
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
|
||||
return Math.floor((base + computedEffects.clickManaBonus) * computedEffects.clickManaMultiplier);
|
||||
}
|
||||
|
||||
// Elemental damage bonus
|
||||
export function getElementalBonus(spellElem: string, floorElem: string): number {
|
||||
if (spellElem === 'raw') return 1.0;
|
||||
|
||||
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
|
||||
|
||||
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective
|
||||
if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Compute the pact multiplier with interference/synergy system
|
||||
export function computePactMultiplier(
|
||||
state: Pick<GameState, 'signedPacts' | 'pactInterferenceMitigation' | 'signedPactDetails'>
|
||||
): number {
|
||||
const { signedPacts, pactInterferenceMitigation = 0 } = state;
|
||||
|
||||
if (signedPacts.length === 0) return 1.0;
|
||||
|
||||
let baseMult = 1.0;
|
||||
for (const floor of signedPacts) {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (guardian) {
|
||||
baseMult *= guardian.damageMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
if (signedPacts.length === 1) return baseMult;
|
||||
|
||||
const numAdditionalPacts = signedPacts.length - 1;
|
||||
const basePenalty = 0.5 * numAdditionalPacts;
|
||||
const mitigationReduction = Math.min(pactInterferenceMitigation, 5) * 0.1;
|
||||
const effectivePenalty = Math.max(0, basePenalty - mitigationReduction);
|
||||
|
||||
if (pactInterferenceMitigation >= 5) {
|
||||
const synergyBonus = (pactInterferenceMitigation - 5) * 0.1;
|
||||
return baseMult * (1 + synergyBonus);
|
||||
}
|
||||
|
||||
return baseMult * (1 - effectivePenalty);
|
||||
}
|
||||
|
||||
// Compute the insight multiplier from signed pacts
|
||||
export function computePactInsightMultiplier(
|
||||
state: Pick<GameState, 'signedPacts' | 'pactInterferenceMitigation'>
|
||||
): number {
|
||||
const { signedPacts, pactInterferenceMitigation = 0 } = state;
|
||||
|
||||
if (signedPacts.length === 0) return 1.0;
|
||||
|
||||
let mult = 1.0;
|
||||
for (const floor of signedPacts) {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (guardian) {
|
||||
mult *= guardian.insightMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
if (signedPacts.length > 1) {
|
||||
const numAdditionalPacts = signedPacts.length - 1;
|
||||
const basePenalty = 0.5 * numAdditionalPacts;
|
||||
const mitigationReduction = Math.min(pactInterferenceMitigation, 5) * 0.1;
|
||||
const effectivePenalty = Math.max(0, basePenalty - mitigationReduction);
|
||||
|
||||
if (pactInterferenceMitigation >= 5) {
|
||||
const synergyBonus = (pactInterferenceMitigation - 5) * 0.1;
|
||||
return mult * (1 + synergyBonus);
|
||||
}
|
||||
|
||||
return mult * (1 - effectivePenalty);
|
||||
}
|
||||
|
||||
return mult;
|
||||
}
|
||||
|
||||
export function calcDamage(
|
||||
state: Pick<GameState, 'skills' | 'signedPacts' | 'pactInterferenceMitigation' | 'signedPactDetails' | 'skillUpgrades' | 'skillTiers'>,
|
||||
spellId: string,
|
||||
floorElem?: string,
|
||||
effects?: ReturnType<typeof computeEffects>
|
||||
): number {
|
||||
const sp = SPELLS_DEF[spellId];
|
||||
if (!sp) return 5;
|
||||
|
||||
const skillTiers = state.skillTiers || {};
|
||||
const skillUpgrades = state.skillUpgrades || {};
|
||||
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
|
||||
|
||||
// Get effective skill levels with tier multipliers
|
||||
const combatTrainLevel = getEffectiveSkillLevel(state.skills, 'combatTrain', skillTiers);
|
||||
const arcaneFuryLevel = getEffectiveSkillLevel(state.skills, 'arcaneFury', skillTiers);
|
||||
const elemMasteryLevel = getEffectiveSkillLevel(state.skills, 'elementalMastery', skillTiers);
|
||||
const guardianBaneLevel = getEffectiveSkillLevel(state.skills, 'guardianBane', skillTiers);
|
||||
const precisionLevel = getEffectiveSkillLevel(state.skills, 'precision', skillTiers);
|
||||
|
||||
// Base damage from spell + combat training
|
||||
const baseDmg = sp.dmg + combatTrainLevel.level * 5 * combatTrainLevel.tierMultiplier;
|
||||
|
||||
// Spell damage multiplier from arcane fury
|
||||
const pct = 1 + arcaneFuryLevel.level * 0.1 * arcaneFuryLevel.tierMultiplier;
|
||||
|
||||
// Elemental mastery bonus
|
||||
const elemMasteryBonus = 1 + elemMasteryLevel.level * 0.15 * elemMasteryLevel.tierMultiplier;
|
||||
|
||||
// Guardian bane bonus (only for guardian floors)
|
||||
const guardianBonus = floorElem && Object.values(GUARDIANS).find(g => g.element === floorElem)
|
||||
? 1 + guardianBaneLevel.level * 0.2 * guardianBaneLevel.tierMultiplier
|
||||
: 1;
|
||||
|
||||
// Crit chance from precision
|
||||
const skillCritChance = precisionLevel.level * 0.05 * precisionLevel.tierMultiplier;
|
||||
const totalCritChance = skillCritChance + computedEffects.critChanceBonus;
|
||||
|
||||
// Pact multiplier
|
||||
const pactMult = computePactMultiplier(state);
|
||||
|
||||
// Calculate base damage
|
||||
let damage = baseDmg * pct * pactMult * elemMasteryBonus * guardianBonus;
|
||||
|
||||
// Apply upgrade effects: base damage multiplier and bonus
|
||||
damage = damage * computedEffects.baseDamageMultiplier + computedEffects.baseDamageBonus;
|
||||
|
||||
// Apply elemental damage multiplier from upgrades
|
||||
damage *= computedEffects.elementalDamageMultiplier;
|
||||
|
||||
// Apply elemental bonus for floor
|
||||
if (floorElem) {
|
||||
damage *= getElementalBonus(sp.elem, floorElem);
|
||||
}
|
||||
|
||||
// Apply critical hit
|
||||
if (Math.random() < totalCritChance) {
|
||||
damage *= computedEffects.critDamageMultiplier;
|
||||
}
|
||||
|
||||
return damage;
|
||||
}
|
||||
|
||||
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
|
||||
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
|
||||
return Math.floor(
|
||||
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
|
||||
);
|
||||
}
|
||||
|
||||
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
|
||||
const hasMeditation = skills.meditation === 1;
|
||||
const hasDeepTrance = skills.deepTrance === 1;
|
||||
const hasVoidMeditation = skills.voidMeditation === 1;
|
||||
|
||||
const hours = meditateTicks * 0.04; // HOURS_PER_TICK
|
||||
|
||||
let bonus = 1 + Math.min(hours / 4, 0.5);
|
||||
|
||||
if (hasMeditation && hours >= 4) {
|
||||
bonus = 2.5;
|
||||
}
|
||||
|
||||
if (hasDeepTrance && hours >= 6) {
|
||||
bonus = 3.0;
|
||||
}
|
||||
|
||||
if (hasVoidMeditation && hours >= 8) {
|
||||
bonus = 5.0;
|
||||
}
|
||||
|
||||
bonus *= meditationEfficiency;
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
export function getIncursionStrength(day: number, hour: number): number {
|
||||
const INCURSION_START_DAY = 20;
|
||||
const MAX_DAY = 30;
|
||||
|
||||
if (day < INCURSION_START_DAY) return 0;
|
||||
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
|
||||
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
|
||||
return Math.min(0.95, (totalHours / maxHours) * 0.95);
|
||||
}
|
||||
|
||||
export function getFloorMaxHP(floor: number): number {
|
||||
if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
|
||||
const baseHP = 100;
|
||||
const floorScaling = floor * 50;
|
||||
const exponentialScaling = Math.pow(floor, 1.7);
|
||||
return Math.floor(baseHP + floorScaling + exponentialScaling);
|
||||
}
|
||||
|
||||
export function getFloorElement(floor: number): string {
|
||||
const FLOOR_ELEM_CYCLE = ["fire", "water", "air", "earth", "light", "dark", "life", "death"];
|
||||
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
|
||||
}
|
||||
|
||||
// Formatting utilities
|
||||
export function fmt(n: number): string {
|
||||
if (!isFinite(n) || isNaN(n)) return '0';
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
||||
return Math.floor(n).toString();
|
||||
}
|
||||
|
||||
export function fmtDec(n: number, d: number = 1): string {
|
||||
return isFinite(n) ? n.toFixed(d) : '0';
|
||||
}
|
||||
|
||||
// Check if player can afford spell cost
|
||||
export function canAffordSpellCost(
|
||||
cost: { type: 'raw' | 'element'; element?: string; amount: number },
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): boolean {
|
||||
if (cost.type === 'raw') {
|
||||
return rawMana >= cost.amount;
|
||||
} else {
|
||||
const elem = elements[cost.element || ''];
|
||||
return elem && elem.unlocked && elem.current >= cost.amount;
|
||||
}
|
||||
}
|
||||
644
src/lib/game/store/craftingSlice.ts
Executable file
644
src/lib/game/store/craftingSlice.ts
Executable file
@@ -0,0 +1,644 @@
|
||||
// ─── Crafting Store Slice ────────────────────────────────────────────────────────
|
||||
// Handles equipment, enchantments, and crafting progress
|
||||
|
||||
import type {
|
||||
EquipmentInstance,
|
||||
AppliedEnchantment,
|
||||
EnchantmentDesign,
|
||||
DesignEffect,
|
||||
DesignProgress,
|
||||
PreparationProgress,
|
||||
ApplicationProgress,
|
||||
EquipmentSpellState
|
||||
} from '../types';
|
||||
import {
|
||||
EQUIPMENT_TYPES,
|
||||
EQUIPMENT_SLOTS,
|
||||
type EquipmentSlot,
|
||||
type EquipmentTypeDef,
|
||||
getEquipmentType,
|
||||
calculateRarity
|
||||
} from '../data/equipment';
|
||||
import {
|
||||
ENCHANTMENT_EFFECTS,
|
||||
getEnchantmentEffect,
|
||||
canApplyEffect,
|
||||
calculateEffectCapacityCost,
|
||||
type EnchantmentEffectDef
|
||||
} from '../data/enchantment-effects';
|
||||
import { SPELLS_DEF } from '../constants';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
// ─── Helper Functions ────────────────────────────────────────────────────────────
|
||||
|
||||
let instanceIdCounter = 0;
|
||||
function generateInstanceId(): string {
|
||||
return `equip_${Date.now()}_${++instanceIdCounter}`;
|
||||
}
|
||||
|
||||
let designIdCounter = 0;
|
||||
function generateDesignId(): string {
|
||||
return `design_${Date.now()}_${++designIdCounter}`;
|
||||
}
|
||||
|
||||
// Calculate efficiency bonus from skills
|
||||
function getEnchantEfficiencyBonus(skills: Record<string, number>): number {
|
||||
const enchantingLevel = skills.enchanting || 0;
|
||||
const efficientEnchantLevel = skills.efficientEnchant || 0;
|
||||
|
||||
// 2% per enchanting level + 5% per efficient enchant level
|
||||
return (enchantingLevel * 0.02) + (efficientEnchantLevel * 0.05);
|
||||
}
|
||||
|
||||
// Calculate design time based on effects
|
||||
function calculateDesignTime(effects: DesignEffect[]): number {
|
||||
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
|
||||
return Math.max(1, Math.floor(totalCapacity / 10)); // Hours
|
||||
}
|
||||
|
||||
// Calculate preparation time for equipment
|
||||
function calculatePreparationTime(equipmentType: string): number {
|
||||
const typeDef = getEquipmentType(equipmentType);
|
||||
if (!typeDef) return 1;
|
||||
return Math.max(1, Math.floor(typeDef.baseCapacity / 5)); // Hours
|
||||
}
|
||||
|
||||
// Calculate preparation mana cost
|
||||
function calculatePreparationManaCost(equipmentType: string): number {
|
||||
const typeDef = getEquipmentType(equipmentType);
|
||||
if (!typeDef) return 50;
|
||||
return typeDef.baseCapacity * 5;
|
||||
}
|
||||
|
||||
// Calculate application time based on effects
|
||||
function calculateApplicationTime(effects: DesignEffect[], skills: Record<string, number>): number {
|
||||
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
|
||||
const speedBonus = 1 + (skills.enchantSpeed || 0) * 0.1;
|
||||
return Math.max(4, Math.floor(totalCapacity / 20 * 24 / speedBonus)); // Hours (days * 24)
|
||||
}
|
||||
|
||||
// Calculate mana per hour for application
|
||||
function calculateApplicationManaPerHour(effects: DesignEffect[]): number {
|
||||
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
|
||||
return Math.max(1, Math.floor(totalCapacity * 0.5));
|
||||
}
|
||||
|
||||
// Create a new equipment instance
|
||||
export function createEquipmentInstance(typeId: string, name?: string): EquipmentInstance {
|
||||
const typeDef = getEquipmentType(typeId);
|
||||
if (!typeDef) {
|
||||
throw new Error(`Unknown equipment type: ${typeId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
instanceId: generateInstanceId(),
|
||||
typeId,
|
||||
name: name || typeDef.name,
|
||||
enchantments: [],
|
||||
usedCapacity: 0,
|
||||
totalCapacity: typeDef.baseCapacity,
|
||||
rarity: 'common',
|
||||
quality: 100, // Full quality for new items
|
||||
};
|
||||
}
|
||||
|
||||
// Get spells from equipment
|
||||
export function getSpellsFromEquipment(equipment: EquipmentInstance): string[] {
|
||||
const spells: string[] = [];
|
||||
|
||||
for (const ench of equipment.enchantments) {
|
||||
const effectDef = getEnchantmentEffect(ench.effectId);
|
||||
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
|
||||
spells.push(effectDef.effect.spellId);
|
||||
}
|
||||
}
|
||||
|
||||
return spells;
|
||||
}
|
||||
|
||||
// Compute total effects from equipment
|
||||
export function computeEquipmentEffects(equipment: EquipmentInstance[]): Record<string, number> {
|
||||
const effects: Record<string, number> = {};
|
||||
const multipliers: Record<string, number> = {};
|
||||
const specials: Set<string> = new Set();
|
||||
|
||||
for (const equip of equipment) {
|
||||
for (const ench of equip.enchantments) {
|
||||
const effectDef = getEnchantmentEffect(ench.effectId);
|
||||
if (!effectDef) continue;
|
||||
|
||||
const value = (effectDef.effect.value || 0) * ench.stacks;
|
||||
|
||||
if (effectDef.effect.type === 'bonus' && effectDef.effect.stat) {
|
||||
effects[effectDef.effect.stat] = (effects[effectDef.effect.stat] || 0) + value;
|
||||
} else if (effectDef.effect.type === 'multiplier' && effectDef.effect.stat) {
|
||||
multipliers[effectDef.effect.stat] = (multipliers[effectDef.effect.stat] || 1) * Math.pow(value, ench.stacks);
|
||||
} else if (effectDef.effect.type === 'special' && effectDef.effect.specialId) {
|
||||
specials.add(effectDef.effect.specialId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply multipliers to bonus effects
|
||||
for (const [stat, mult] of Object.entries(multipliers)) {
|
||||
effects[`${stat}_multiplier`] = mult;
|
||||
}
|
||||
|
||||
// Add special effect flags
|
||||
for (const special of specials) {
|
||||
effects[`special_${special}`] = 1;
|
||||
}
|
||||
|
||||
return effects;
|
||||
}
|
||||
|
||||
// ─── Store Interface ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CraftingState {
|
||||
// Equipment instances
|
||||
equippedInstances: Record<string, string | null>; // slot -> instanceId
|
||||
equipmentInstances: Record<string, EquipmentInstance>; // instanceId -> instance
|
||||
|
||||
// Enchantment designs
|
||||
enchantmentDesigns: EnchantmentDesign[];
|
||||
|
||||
// Crafting progress
|
||||
designProgress: DesignProgress | null;
|
||||
preparationProgress: PreparationProgress | null;
|
||||
applicationProgress: ApplicationProgress | null;
|
||||
|
||||
// Equipment spell states
|
||||
equipmentSpellStates: EquipmentSpellState[];
|
||||
}
|
||||
|
||||
export interface CraftingActions {
|
||||
// Equipment management
|
||||
createEquipment: (typeId: string, slot?: EquipmentSlot) => EquipmentInstance;
|
||||
equipInstance: (instanceId: string, slot: EquipmentSlot) => void;
|
||||
unequipSlot: (slot: EquipmentSlot) => void;
|
||||
deleteInstance: (instanceId: string) => void;
|
||||
|
||||
// Enchantment design
|
||||
startDesign: (name: string, equipmentType: string, effects: DesignEffect[]) => void;
|
||||
cancelDesign: () => void;
|
||||
deleteDesign: (designId: string) => void;
|
||||
|
||||
// Equipment preparation
|
||||
startPreparation: (instanceId: string) => void;
|
||||
cancelPreparation: () => void;
|
||||
|
||||
// Enchantment application
|
||||
startApplication: (instanceId: string, designId: string) => void;
|
||||
pauseApplication: () => void;
|
||||
resumeApplication: () => void;
|
||||
cancelApplication: () => void;
|
||||
|
||||
// Tick processing
|
||||
processDesignTick: (hours: number) => void;
|
||||
processPreparationTick: (hours: number, manaAvailable: number) => number; // Returns mana used
|
||||
processApplicationTick: (hours: number, manaAvailable: number) => number; // Returns mana used
|
||||
|
||||
// Getters
|
||||
getEquippedInstance: (slot: EquipmentSlot) => EquipmentInstance | null;
|
||||
getAllEquipped: () => EquipmentInstance[];
|
||||
getAvailableSpells: () => string[];
|
||||
getEquipmentEffects: () => Record<string, number>;
|
||||
}
|
||||
|
||||
export type CraftingStore = CraftingState & CraftingActions;
|
||||
|
||||
// ─── Initial State ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const initialCraftingState: CraftingState = {
|
||||
equippedInstances: {
|
||||
mainHand: null,
|
||||
offHand: null,
|
||||
head: null,
|
||||
body: null,
|
||||
hands: null,
|
||||
feet: null,
|
||||
accessory1: null,
|
||||
accessory2: null,
|
||||
},
|
||||
equipmentInstances: {},
|
||||
enchantmentDesigns: [],
|
||||
designProgress: null,
|
||||
preparationProgress: null,
|
||||
applicationProgress: null,
|
||||
equipmentSpellStates: [],
|
||||
};
|
||||
|
||||
// ─── Store Slice Creator ────────────────────────────────────────────────────────
|
||||
|
||||
// We need to access skills from the main store - this is a workaround
|
||||
// The store will pass skills when calling these methods
|
||||
let cachedSkills: Record<string, number> = {};
|
||||
|
||||
export function setCachedSkills(skills: Record<string, number>): void {
|
||||
cachedSkills = skills;
|
||||
}
|
||||
|
||||
export const createCraftingSlice: StateCreator<CraftingStore, [], [], CraftingStore> = (set, get) => ({
|
||||
...initialCraftingState,
|
||||
|
||||
// Equipment management
|
||||
createEquipment: (typeId: string, slot?: EquipmentSlot) => {
|
||||
const instance = createEquipmentInstance(typeId);
|
||||
|
||||
set((state) => ({
|
||||
equipmentInstances: {
|
||||
...state.equipmentInstances,
|
||||
[instance.instanceId]: instance,
|
||||
},
|
||||
}));
|
||||
|
||||
// Auto-equip if slot provided
|
||||
if (slot) {
|
||||
get().equipInstance(instance.instanceId, slot);
|
||||
}
|
||||
|
||||
return instance;
|
||||
},
|
||||
|
||||
equipInstance: (instanceId: string, slot: EquipmentSlot) => {
|
||||
const instance = get().equipmentInstances[instanceId];
|
||||
if (!instance) return;
|
||||
|
||||
const typeDef = getEquipmentType(instance.typeId);
|
||||
if (!typeDef) return;
|
||||
|
||||
// Check if equipment can go in this slot
|
||||
if (typeDef.slot !== slot) {
|
||||
// For accessories, both accessory1 and accessory2 are valid
|
||||
if (typeDef.category !== 'accessory' || (slot !== 'accessory1' && slot !== 'accessory2')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
equippedInstances: {
|
||||
...state.equippedInstances,
|
||||
[slot]: instanceId,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
unequipSlot: (slot: EquipmentSlot) => {
|
||||
set((state) => ({
|
||||
equippedInstances: {
|
||||
...state.equippedInstances,
|
||||
[slot]: null,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
deleteInstance: (instanceId: string) => {
|
||||
set((state) => {
|
||||
const newInstanceMap = { ...state.equipmentInstances };
|
||||
delete newInstanceMap[instanceId];
|
||||
|
||||
// Remove from equipped slots
|
||||
const newEquipped = { ...state.equippedInstances };
|
||||
for (const slot of EQUIPMENT_SLOTS) {
|
||||
if (newEquipped[slot] === instanceId) {
|
||||
newEquipped[slot] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
equipmentInstances: newInstanceMap,
|
||||
equippedInstances: newEquipped,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Enchantment design
|
||||
startDesign: (name: string, equipmentType: string, effects: DesignEffect[]) => {
|
||||
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
|
||||
const designTime = calculateDesignTime(effects);
|
||||
|
||||
const design: EnchantmentDesign = {
|
||||
id: generateDesignId(),
|
||||
name,
|
||||
equipmentType,
|
||||
effects,
|
||||
totalCapacityUsed: totalCapacity,
|
||||
designTime,
|
||||
created: Date.now(),
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
enchantmentDesigns: [...state.enchantmentDesigns, design],
|
||||
designProgress: {
|
||||
designId: design.id,
|
||||
progress: 0,
|
||||
required: designTime,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
cancelDesign: () => {
|
||||
const progress = get().designProgress;
|
||||
if (!progress) return;
|
||||
|
||||
set((state) => ({
|
||||
designProgress: null,
|
||||
enchantmentDesigns: state.enchantmentDesigns.filter(d => d.id !== progress.designId),
|
||||
}));
|
||||
},
|
||||
|
||||
deleteDesign: (designId: string) => {
|
||||
set((state) => ({
|
||||
enchantmentDesigns: state.enchantmentDesigns.filter(d => d.id !== designId),
|
||||
}));
|
||||
},
|
||||
|
||||
// Equipment preparation
|
||||
startPreparation: (instanceId: string) => {
|
||||
const instance = get().equipmentInstances[instanceId];
|
||||
if (!instance) return;
|
||||
|
||||
const prepTime = calculatePreparationTime(instance.typeId);
|
||||
const manaCost = calculatePreparationManaCost(instance.typeId);
|
||||
|
||||
set({
|
||||
preparationProgress: {
|
||||
equipmentInstanceId: instanceId,
|
||||
progress: 0,
|
||||
required: prepTime,
|
||||
manaCostPaid: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
cancelPreparation: () => {
|
||||
set({ preparationProgress: null });
|
||||
},
|
||||
|
||||
// Enchantment application
|
||||
startApplication: (instanceId: string, designId: string) => {
|
||||
const instance = get().equipmentInstances[instanceId];
|
||||
const design = get().enchantmentDesigns.find(d => d.id === designId);
|
||||
|
||||
if (!instance || !design) return;
|
||||
|
||||
const appTime = calculateApplicationTime(design.effects, cachedSkills);
|
||||
const manaPerHour = calculateApplicationManaPerHour(design.effects);
|
||||
|
||||
set({
|
||||
applicationProgress: {
|
||||
equipmentInstanceId: instanceId,
|
||||
designId,
|
||||
progress: 0,
|
||||
required: appTime,
|
||||
manaPerHour,
|
||||
paused: false,
|
||||
manaSpent: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
pauseApplication: () => {
|
||||
const progress = get().applicationProgress;
|
||||
if (!progress) return;
|
||||
|
||||
set({
|
||||
applicationProgress: { ...progress, paused: true },
|
||||
});
|
||||
},
|
||||
|
||||
resumeApplication: () => {
|
||||
const progress = get().applicationProgress;
|
||||
if (!progress) return;
|
||||
|
||||
set({
|
||||
applicationProgress: { ...progress, paused: false },
|
||||
});
|
||||
},
|
||||
|
||||
cancelApplication: () => {
|
||||
set({ applicationProgress: null });
|
||||
},
|
||||
|
||||
// Tick processing
|
||||
processDesignTick: (hours: number) => {
|
||||
const progress = get().designProgress;
|
||||
if (!progress) return;
|
||||
|
||||
const newProgress = progress.progress + hours;
|
||||
|
||||
if (newProgress >= progress.required) {
|
||||
// Design complete
|
||||
set({ designProgress: null });
|
||||
} else {
|
||||
set({
|
||||
designProgress: { ...progress, progress: newProgress },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
processPreparationTick: (hours: number, manaAvailable: number) => {
|
||||
const progress = get().preparationProgress;
|
||||
if (!progress) return 0;
|
||||
|
||||
const instance = get().equipmentInstances[progress.equipmentInstanceId];
|
||||
if (!instance) {
|
||||
set({ preparationProgress: null });
|
||||
return 0;
|
||||
}
|
||||
|
||||
const totalManaCost = calculatePreparationManaCost(instance.typeId);
|
||||
const remainingManaCost = totalManaCost - progress.manaCostPaid;
|
||||
const manaToPay = Math.min(manaAvailable, remainingManaCost);
|
||||
|
||||
if (manaToPay < remainingManaCost) {
|
||||
// Not enough mana, just pay what we can
|
||||
set({
|
||||
preparationProgress: {
|
||||
...progress,
|
||||
manaCostPaid: progress.manaCostPaid + manaToPay,
|
||||
},
|
||||
});
|
||||
return manaToPay;
|
||||
}
|
||||
|
||||
// Pay remaining mana and progress
|
||||
const newProgress = progress.progress + hours;
|
||||
|
||||
if (newProgress >= progress.required) {
|
||||
// Preparation complete - clear enchantments
|
||||
set((state) => ({
|
||||
preparationProgress: null,
|
||||
equipmentInstances: {
|
||||
...state.equipmentInstances,
|
||||
[instance.instanceId]: {
|
||||
...instance,
|
||||
enchantments: [],
|
||||
usedCapacity: 0,
|
||||
rarity: 'common',
|
||||
},
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
set({
|
||||
preparationProgress: {
|
||||
...progress,
|
||||
progress: newProgress,
|
||||
manaCostPaid: progress.manaCostPaid + manaToPay,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return manaToPay;
|
||||
},
|
||||
|
||||
processApplicationTick: (hours: number, manaAvailable: number) => {
|
||||
const progress = get().applicationProgress;
|
||||
if (!progress || progress.paused) return 0;
|
||||
|
||||
const design = get().enchantmentDesigns.find(d => d.id === progress.designId);
|
||||
const instance = get().equipmentInstances[progress.equipmentInstanceId];
|
||||
|
||||
if (!design || !instance) {
|
||||
set({ applicationProgress: null });
|
||||
return 0;
|
||||
}
|
||||
|
||||
const manaNeeded = progress.manaPerHour * hours;
|
||||
const manaToUse = Math.min(manaAvailable, manaNeeded);
|
||||
|
||||
if (manaToUse < manaNeeded) {
|
||||
// Not enough mana - pause and save progress
|
||||
set({
|
||||
applicationProgress: {
|
||||
...progress,
|
||||
manaSpent: progress.manaSpent + manaToUse,
|
||||
},
|
||||
});
|
||||
return manaToUse;
|
||||
}
|
||||
|
||||
const newProgress = progress.progress + hours;
|
||||
|
||||
if (newProgress >= progress.required) {
|
||||
// Application complete - apply enchantments
|
||||
const efficiencyBonus = getEnchantEfficiencyBonus(cachedSkills);
|
||||
const newEnchantments: AppliedEnchantment[] = design.effects.map(e => ({
|
||||
effectId: e.effectId,
|
||||
stacks: e.stacks,
|
||||
actualCost: calculateEffectCapacityCost(e.effectId, e.stacks, efficiencyBonus),
|
||||
}));
|
||||
|
||||
const totalUsedCapacity = newEnchantments.reduce((sum, e) => sum + e.actualCost, 0);
|
||||
|
||||
set((state) => ({
|
||||
applicationProgress: null,
|
||||
equipmentInstances: {
|
||||
...state.equipmentInstances,
|
||||
[instance.instanceId]: {
|
||||
...instance,
|
||||
enchantments: newEnchantments,
|
||||
usedCapacity: totalUsedCapacity,
|
||||
rarity: calculateRarity(newEnchantments),
|
||||
},
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
set({
|
||||
applicationProgress: {
|
||||
...progress,
|
||||
progress: newProgress,
|
||||
manaSpent: progress.manaSpent + manaToUse,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return manaToUse;
|
||||
},
|
||||
|
||||
// Getters
|
||||
getEquippedInstance: (slot: EquipmentSlot) => {
|
||||
const state = get();
|
||||
const instanceId = state.equippedInstances[slot];
|
||||
if (!instanceId) return null;
|
||||
return state.equipmentInstances[instanceId] || null;
|
||||
},
|
||||
|
||||
getAllEquipped: () => {
|
||||
const state = get();
|
||||
const equipped: EquipmentInstance[] = [];
|
||||
|
||||
for (const slot of EQUIPMENT_SLOTS) {
|
||||
const instanceId = state.equippedInstances[slot];
|
||||
if (instanceId && state.equipmentInstances[instanceId]) {
|
||||
equipped.push(state.equipmentInstances[instanceId]);
|
||||
}
|
||||
}
|
||||
|
||||
return equipped;
|
||||
},
|
||||
|
||||
getAvailableSpells: () => {
|
||||
const equipped = get().getAllEquipped();
|
||||
const spells: string[] = [];
|
||||
|
||||
for (const equip of equipped) {
|
||||
spells.push(...getSpellsFromEquipment(equip));
|
||||
}
|
||||
|
||||
return spells;
|
||||
},
|
||||
|
||||
getEquipmentEffects: () => {
|
||||
return computeEquipmentEffects(get().getAllEquipped());
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Starting Equipment Factory ────────────────────────────────────────────────
|
||||
|
||||
export function createStartingEquipment(): {
|
||||
equippedInstances: Record<string, string | null>;
|
||||
equipmentInstances: Record<string, EquipmentInstance>;
|
||||
} {
|
||||
const instances: EquipmentInstance[] = [];
|
||||
|
||||
// Create starting equipment
|
||||
const basicStaff = createEquipmentInstance('basicStaff');
|
||||
basicStaff.enchantments = [{
|
||||
effectId: 'spell_manaBolt',
|
||||
stacks: 1,
|
||||
actualCost: 50, // Fills the staff completely
|
||||
}];
|
||||
basicStaff.usedCapacity = 50;
|
||||
basicStaff.rarity = 'uncommon';
|
||||
instances.push(basicStaff);
|
||||
|
||||
const civilianShirt = createEquipmentInstance('civilianShirt');
|
||||
instances.push(civilianShirt);
|
||||
|
||||
const civilianGloves = createEquipmentInstance('civilianGloves');
|
||||
instances.push(civilianGloves);
|
||||
|
||||
const civilianShoes = createEquipmentInstance('civilianShoes');
|
||||
instances.push(civilianShoes);
|
||||
|
||||
// Build instance map
|
||||
const equipmentInstances: Record<string, EquipmentInstance> = {};
|
||||
for (const inst of instances) {
|
||||
equipmentInstances[inst.instanceId] = inst;
|
||||
}
|
||||
|
||||
// Build equipped map
|
||||
const equippedInstances: Record<string, string | null> = {
|
||||
mainHand: basicStaff.instanceId,
|
||||
offHand: null,
|
||||
head: null,
|
||||
body: civilianShirt.instanceId,
|
||||
hands: civilianGloves.instanceId,
|
||||
feet: civilianShoes.instanceId,
|
||||
accessory1: null,
|
||||
accessory2: null,
|
||||
};
|
||||
|
||||
return { equippedInstances, equipmentInstances };
|
||||
}
|
||||
9
src/lib/game/store/index.ts
Executable file
9
src/lib/game/store/index.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
// ─── Store Module Exports ─────────────────────────────────────────────────────
|
||||
// Re-exports from main store and adds new computed utilities
|
||||
// This allows gradual migration while keeping existing functionality
|
||||
|
||||
// Re-export everything from the main store
|
||||
export * from '../store';
|
||||
|
||||
// Export new computed utilities
|
||||
export * from './computed';
|
||||
197
src/lib/game/store/manaSlice.ts
Executable file
197
src/lib/game/store/manaSlice.ts
Executable file
@@ -0,0 +1,197 @@
|
||||
// ─── Mana Slice ───────────────────────────────────────────────────────────────
|
||||
// Manages raw mana, elements, and meditation
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState, ElementState, SpellCost } from '../types';
|
||||
import { ELEMENTS, MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants';
|
||||
import { computeMaxMana, computeElementMax, computeClickMana, canAffordSpellCost, getMeditationBonus } from './computed';
|
||||
import { computeEffects } from '../upgrade-effects';
|
||||
|
||||
export interface ManaSlice {
|
||||
// State
|
||||
rawMana: number;
|
||||
totalManaGathered: number;
|
||||
meditateTicks: number;
|
||||
elements: Record<string, ElementState>;
|
||||
|
||||
// Actions
|
||||
gatherMana: () => void;
|
||||
convertMana: (element: string, amount: number) => void;
|
||||
unlockElement: (element: string) => void;
|
||||
craftComposite: (target: string) => void;
|
||||
|
||||
// Computed getters
|
||||
getMaxMana: () => number;
|
||||
getRegen: () => number;
|
||||
getClickMana: () => number;
|
||||
getMeditationMultiplier: () => number;
|
||||
}
|
||||
|
||||
export const createManaSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState
|
||||
): ManaSlice => ({
|
||||
rawMana: 10,
|
||||
totalManaGathered: 0,
|
||||
meditateTicks: 0,
|
||||
elements: (() => {
|
||||
const elems: Record<string, ElementState> = {};
|
||||
const pu = get().prestigeUpgrades;
|
||||
const elemMax = computeElementMax(get());
|
||||
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
|
||||
let startAmount = 0;
|
||||
|
||||
if (isUnlocked && pu.elemStart) {
|
||||
startAmount = pu.elemStart * 5;
|
||||
}
|
||||
|
||||
elems[k] = {
|
||||
current: startAmount,
|
||||
max: elemMax,
|
||||
unlocked: isUnlocked,
|
||||
};
|
||||
});
|
||||
return elems;
|
||||
})(),
|
||||
|
||||
gatherMana: () => {
|
||||
const state = get();
|
||||
let cm = computeClickMana(state);
|
||||
|
||||
// Mana overflow bonus
|
||||
const overflowBonus = 1 + (state.skills.manaOverflow || 0) * 0.25;
|
||||
cm = Math.floor(cm * overflowBonus);
|
||||
|
||||
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
|
||||
const max = computeMaxMana(state, effects);
|
||||
|
||||
// Mana Echo: 10% chance to gain double mana from clicks
|
||||
const hasManaEcho = effects.specials?.has('MANA_ECHO') ?? false;
|
||||
if (hasManaEcho && Math.random() < 0.1) {
|
||||
cm *= 2;
|
||||
}
|
||||
|
||||
set({
|
||||
rawMana: Math.min(state.rawMana + cm, max),
|
||||
totalManaGathered: state.totalManaGathered + cm,
|
||||
});
|
||||
},
|
||||
|
||||
convertMana: (element: string, amount: number = 1) => {
|
||||
const state = get();
|
||||
const e = state.elements[element];
|
||||
if (!e?.unlocked) return;
|
||||
|
||||
const cost = MANA_PER_ELEMENT * amount;
|
||||
if (state.rawMana < cost) return;
|
||||
if (e.current >= e.max) return;
|
||||
|
||||
const canConvert = Math.min(
|
||||
amount,
|
||||
Math.floor(state.rawMana / MANA_PER_ELEMENT),
|
||||
e.max - e.current
|
||||
);
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...e, current: e.current + canConvert },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
unlockElement: (element: string) => {
|
||||
const state = get();
|
||||
if (state.elements[element]?.unlocked) return;
|
||||
|
||||
const cost = 500;
|
||||
if (state.rawMana < cost) return;
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - cost,
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...state.elements[element], unlocked: true },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
craftComposite: (target: string) => {
|
||||
const state = get();
|
||||
const edef = ELEMENTS[target];
|
||||
if (!edef?.recipe) return;
|
||||
|
||||
const recipe = edef.recipe;
|
||||
const costs: Record<string, number> = {};
|
||||
recipe.forEach((r) => {
|
||||
costs[r] = (costs[r] || 0) + 1;
|
||||
});
|
||||
|
||||
// Check ingredients
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
if ((state.elements[r]?.current || 0) < amt) return;
|
||||
}
|
||||
|
||||
const newElems = { ...state.elements };
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
newElems[r] = { ...newElems[r], current: newElems[r].current - amt };
|
||||
}
|
||||
|
||||
// Elemental crafting bonus
|
||||
const craftBonus = 1 + (state.skills.elemCrafting || 0) * 0.25;
|
||||
const outputAmount = Math.floor(craftBonus);
|
||||
|
||||
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
|
||||
const elemMax = computeElementMax(state, effects);
|
||||
newElems[target] = {
|
||||
...(newElems[target] || { current: 0, max: elemMax, unlocked: false }),
|
||||
current: (newElems[target]?.current || 0) + outputAmount,
|
||||
max: elemMax,
|
||||
unlocked: true,
|
||||
};
|
||||
|
||||
set({
|
||||
elements: newElems,
|
||||
});
|
||||
},
|
||||
|
||||
getMaxMana: () => computeMaxMana(get()),
|
||||
getRegen: () => {
|
||||
const state = get();
|
||||
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
|
||||
// This would need proper regen calculation
|
||||
return 2;
|
||||
},
|
||||
getClickMana: () => computeClickMana(get()),
|
||||
getMeditationMultiplier: () => {
|
||||
const state = get();
|
||||
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
|
||||
return getMeditationBonus(state.meditateTicks, state.skills, effects.meditationEfficiency);
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to deduct spell cost
|
||||
export function deductSpellCost(
|
||||
cost: SpellCost,
|
||||
rawMana: number,
|
||||
elements: Record<string, ElementState>
|
||||
): { rawMana: number; elements: Record<string, ElementState> } {
|
||||
const newElements = { ...elements };
|
||||
|
||||
if (cost.type === 'raw') {
|
||||
return { rawMana: rawMana - cost.amount, elements: newElements };
|
||||
} else if (cost.element && newElements[cost.element]) {
|
||||
newElements[cost.element] = {
|
||||
...newElements[cost.element],
|
||||
current: newElements[cost.element].current - cost.amount,
|
||||
};
|
||||
return { rawMana, elements: newElements };
|
||||
}
|
||||
|
||||
return { rawMana, elements: newElements };
|
||||
}
|
||||
|
||||
export { canAffordSpellCost };
|
||||
180
src/lib/game/store/pactSlice.ts
Executable file
180
src/lib/game/store/pactSlice.ts
Executable file
@@ -0,0 +1,180 @@
|
||||
// ─── Pact Slice ───────────────────────────────────────────────────────────────
|
||||
// Manages guardian pacts, signing, and mana unlocking
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState } from '../types';
|
||||
import { GUARDIANS, ELEMENTS } from '../constants';
|
||||
import { computePactMultiplier, computePactInsightMultiplier } from './computed';
|
||||
|
||||
export interface PactSlice {
|
||||
// State
|
||||
signedPacts: number[];
|
||||
pendingPactOffer: number | null;
|
||||
maxPacts: number;
|
||||
pactSigningProgress: {
|
||||
floor: number;
|
||||
progress: number;
|
||||
required: number;
|
||||
manaCost: number;
|
||||
} | null;
|
||||
signedPactDetails: Record<number, {
|
||||
floor: number;
|
||||
guardianId: string;
|
||||
signedAt: { day: number; hour: number };
|
||||
skillLevels: Record<string, number>;
|
||||
}>;
|
||||
pactInterferenceMitigation: number;
|
||||
pactSynergyUnlocked: boolean;
|
||||
|
||||
// Actions
|
||||
acceptPact: (floor: number) => void;
|
||||
declinePact: (floor: number) => void;
|
||||
|
||||
// Computed getters
|
||||
getPactMultiplier: () => number;
|
||||
getPactInsightMultiplier: () => number;
|
||||
}
|
||||
|
||||
export const createPactSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState
|
||||
): PactSlice => ({
|
||||
signedPacts: [],
|
||||
pendingPactOffer: null,
|
||||
maxPacts: 1,
|
||||
pactSigningProgress: null,
|
||||
signedPactDetails: {},
|
||||
pactInterferenceMitigation: 0,
|
||||
pactSynergyUnlocked: false,
|
||||
|
||||
acceptPact: (floor: number) => {
|
||||
const state = get();
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian || state.signedPacts.includes(floor)) return;
|
||||
|
||||
const maxPacts = 1 + (state.prestigeUpgrades.pactCapacity || 0);
|
||||
if (state.signedPacts.length >= maxPacts) {
|
||||
set({
|
||||
log: [`⚠️ Cannot sign more pacts! Maximum: ${maxPacts}.`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const baseCost = guardian.signingCost.mana;
|
||||
const discount = Math.min((state.prestigeUpgrades.pactDiscount || 0) * 0.1, 0.5);
|
||||
const manaCost = Math.floor(baseCost * (1 - discount));
|
||||
|
||||
if (state.rawMana < manaCost) {
|
||||
set({
|
||||
log: [`⚠️ Need ${manaCost} mana to sign pact with ${guardian.name}!`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const baseTime = guardian.signingCost.time;
|
||||
const haste = Math.min((state.prestigeUpgrades.pactHaste || 0) * 0.1, 0.5);
|
||||
const signingTime = Math.max(1, baseTime * (1 - haste));
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - manaCost,
|
||||
pactSigningProgress: {
|
||||
floor,
|
||||
progress: 0,
|
||||
required: signingTime,
|
||||
manaCost,
|
||||
},
|
||||
pendingPactOffer: null,
|
||||
currentAction: 'study',
|
||||
log: [`📜 Beginning pact signing with ${guardian.name}... (${signingTime}h, ${manaCost} mana)`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
declinePact: (floor: number) => {
|
||||
const state = get();
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return;
|
||||
|
||||
set({
|
||||
pendingPactOffer: null,
|
||||
log: [`🚫 Declined pact with ${guardian.name}.`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
getPactMultiplier: () => computePactMultiplier(get()),
|
||||
getPactInsightMultiplier: () => computePactInsightMultiplier(get()),
|
||||
});
|
||||
|
||||
// Process pact signing progress (called during tick)
|
||||
export function processPactSigning(state: GameState, deltaHours: number): Partial<GameState> {
|
||||
if (!state.pactSigningProgress) return {};
|
||||
|
||||
const progress = state.pactSigningProgress.progress + deltaHours;
|
||||
const log = [...state.log];
|
||||
|
||||
if (progress >= state.pactSigningProgress.required) {
|
||||
const floor = state.pactSigningProgress.floor;
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian || state.signedPacts.includes(floor)) {
|
||||
return { pactSigningProgress: null };
|
||||
}
|
||||
|
||||
const signedPacts = [...state.signedPacts, floor];
|
||||
const signedPactDetails = {
|
||||
...state.signedPactDetails,
|
||||
[floor]: {
|
||||
floor,
|
||||
guardianId: guardian.element,
|
||||
signedAt: { day: state.day, hour: state.hour },
|
||||
skillLevels: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Unlock mana types
|
||||
let elements = { ...state.elements };
|
||||
for (const elemId of guardian.unlocksMana) {
|
||||
if (elements[elemId]) {
|
||||
elements = {
|
||||
...elements,
|
||||
[elemId]: { ...elements[elemId], unlocked: true },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for compound element unlocks
|
||||
const unlockedSet = new Set(
|
||||
Object.entries(elements)
|
||||
.filter(([, e]) => e.unlocked)
|
||||
.map(([id]) => id)
|
||||
);
|
||||
|
||||
for (const [elemId, elemDef] of Object.entries(ELEMENTS)) {
|
||||
if (elemDef.recipe && !elements[elemId]?.unlocked) {
|
||||
const canUnlock = elemDef.recipe.every(comp => unlockedSet.has(comp));
|
||||
if (canUnlock) {
|
||||
elements = {
|
||||
...elements,
|
||||
[elemId]: { ...elements[elemId], unlocked: true },
|
||||
};
|
||||
log.unshift(`🔮 ${elemDef.name} mana unlocked through component synergy!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.unshift(`📜 Pact with ${guardian.name} signed! ${guardian.unlocksMana.map(e => ELEMENTS[e]?.name || e).join(', ')} mana unlocked!`);
|
||||
|
||||
return {
|
||||
signedPacts,
|
||||
signedPactDetails,
|
||||
elements,
|
||||
pactSigningProgress: null,
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pactSigningProgress: {
|
||||
...state.pactSigningProgress,
|
||||
progress,
|
||||
},
|
||||
};
|
||||
}
|
||||
128
src/lib/game/store/prestigeSlice.ts
Executable file
128
src/lib/game/store/prestigeSlice.ts
Executable file
@@ -0,0 +1,128 @@
|
||||
// ─── Prestige Slice ───────────────────────────────────────────────────────────
|
||||
// Manages insight, prestige upgrades, and loop resources
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState } from '../types';
|
||||
import { PRESTIGE_DEF } from '../constants';
|
||||
|
||||
export interface PrestigeSlice {
|
||||
// State
|
||||
insight: number;
|
||||
totalInsight: number;
|
||||
prestigeUpgrades: Record<string, number>;
|
||||
loopInsight: number;
|
||||
memorySlots: number;
|
||||
memories: string[];
|
||||
|
||||
// Actions
|
||||
doPrestige: (id: string) => void;
|
||||
startNewLoop: () => void;
|
||||
}
|
||||
|
||||
export const createPrestigeSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState
|
||||
): PrestigeSlice => ({
|
||||
insight: 0,
|
||||
totalInsight: 0,
|
||||
prestigeUpgrades: {},
|
||||
loopInsight: 0,
|
||||
memorySlots: 3,
|
||||
memories: [],
|
||||
|
||||
doPrestige: (id: string) => {
|
||||
const state = get();
|
||||
const pd = PRESTIGE_DEF[id];
|
||||
if (!pd) return;
|
||||
|
||||
const lvl = state.prestigeUpgrades[id] || 0;
|
||||
if (lvl >= pd.max || state.insight < pd.cost) return;
|
||||
|
||||
const newPU = { ...state.prestigeUpgrades, [id]: lvl + 1 };
|
||||
|
||||
set({
|
||||
insight: state.insight - pd.cost,
|
||||
prestigeUpgrades: newPU,
|
||||
memorySlots: id === 'deepMemory' ? state.memorySlots + 1 : state.memorySlots,
|
||||
maxPacts: id === 'pactCapacity' ? state.maxPacts + 1 : state.maxPacts,
|
||||
pactInterferenceMitigation: id === 'pactInterference' ? (state.pactInterferenceMitigation || 0) + 1 : state.pactInterferenceMitigation,
|
||||
log: [`⭐ ${pd.name} upgraded to Lv.${lvl + 1}!`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
startNewLoop: () => {
|
||||
const state = get();
|
||||
const insightGained = state.loopInsight || calcInsight(state);
|
||||
const total = state.insight + insightGained;
|
||||
|
||||
// Reset to initial state with insight carried over
|
||||
const pu = state.prestigeUpgrades;
|
||||
const startFloor = 1 + (pu.spireKey || 0) * 2;
|
||||
const startRawMana = 10 + (pu.manaWell || 0) * 500 + (pu.quickStart || 0) * 100;
|
||||
|
||||
// Reset elements
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
elements[k] = {
|
||||
current: 0,
|
||||
max: 10 + (pu.elementalAttune || 0) * 25,
|
||||
unlocked: false,
|
||||
};
|
||||
});
|
||||
|
||||
// Reset spells - always start with Mana Bolt
|
||||
const spells: Record<string, { learned: boolean; level: number; studyProgress: number }> = {
|
||||
manaBolt: { learned: true, level: 1, studyProgress: 0 },
|
||||
};
|
||||
|
||||
// Add random starting spells from spell memory prestige upgrade (purchased with insight)
|
||||
if (pu.spellMemory) {
|
||||
const availableSpells = Object.keys(SPELLS_DEF).filter(s => s !== 'manaBolt');
|
||||
const shuffled = availableSpells.sort(() => Math.random() - 0.5);
|
||||
for (let i = 0; i < Math.min(pu.spellMemory, shuffled.length); i++) {
|
||||
spells[shuffled[i]] = { learned: true, level: 1, studyProgress: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
day: 1,
|
||||
hour: 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
loopCount: state.loopCount + 1,
|
||||
rawMana: startRawMana,
|
||||
totalManaGathered: 0,
|
||||
meditateTicks: 0,
|
||||
elements,
|
||||
currentFloor: startFloor,
|
||||
floorHP: getFloorMaxHP(startFloor),
|
||||
floorMaxHP: getFloorMaxHP(startFloor),
|
||||
maxFloorReached: startFloor,
|
||||
signedPacts: [],
|
||||
pendingPactOffer: null,
|
||||
pactSigningProgress: null,
|
||||
signedPactDetails: {},
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells,
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
insight: total,
|
||||
totalInsight: (state.totalInsight || 0) + insightGained,
|
||||
loopInsight: 0,
|
||||
maxPacts: 1 + (pu.pactCapacity || 0),
|
||||
pactInterferenceMitigation: pu.pactInterference || 0,
|
||||
memorySlots: 3 + (pu.deepMemory || 0),
|
||||
log: ['✨ A new loop begins. Your insight grows...', '✨ The loop begins. You start with Mana Bolt.'],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Need to import these
|
||||
import { ELEMENTS, SPELLS_DEF } from '../constants';
|
||||
import { getFloorMaxHP, calcInsight } from './computed';
|
||||
346
src/lib/game/store/skillSlice.ts
Executable file
346
src/lib/game/store/skillSlice.ts
Executable file
@@ -0,0 +1,346 @@
|
||||
// ─── Skill Slice ──────────────────────────────────────────────────────────────
|
||||
// Manages skills, studying, and skill progress
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState, StudyTarget, SkillUpgradeChoice } from '../types';
|
||||
import { SKILLS_DEF, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '../constants';
|
||||
import { getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '../skill-evolution';
|
||||
import { computeEffects } from '../upgrade-effects';
|
||||
|
||||
export interface SkillSlice {
|
||||
// State
|
||||
skills: Record<string, number>;
|
||||
skillProgress: Record<string, number>;
|
||||
skillUpgrades: Record<string, string[]>;
|
||||
skillTiers: Record<string, number>;
|
||||
currentStudyTarget: StudyTarget | null;
|
||||
parallelStudyTarget: StudyTarget | null;
|
||||
|
||||
// Actions
|
||||
startStudyingSkill: (skillId: string) => void;
|
||||
startStudyingSpell: (spellId: string) => void;
|
||||
startParallelStudySkill: (skillId: string) => void;
|
||||
cancelStudy: () => void;
|
||||
cancelParallelStudy: () => void;
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[], milestone: 5 | 10) => void;
|
||||
tierUpSkill: (skillId: string) => void;
|
||||
|
||||
// Getters
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: SkillUpgradeChoice[]; selected: string[] };
|
||||
}
|
||||
|
||||
export const createSkillSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState
|
||||
): SkillSlice => ({
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
|
||||
startStudyingSkill: (skillId: string) => {
|
||||
const state = get();
|
||||
const sk = SKILLS_DEF[skillId];
|
||||
if (!sk) return;
|
||||
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
if (currentLevel >= sk.max) return;
|
||||
|
||||
// Check prerequisites
|
||||
if (sk.req) {
|
||||
for (const [r, rl] of Object.entries(sk.req)) {
|
||||
if ((state.skills[r] || 0) < rl) return;
|
||||
}
|
||||
}
|
||||
|
||||
const costMult = getStudyCostMultiplier(state.skills);
|
||||
const totalCost = Math.floor(sk.base * (currentLevel + 1) * costMult);
|
||||
const manaCostPerHour = totalCost / sk.studyTime;
|
||||
|
||||
set({
|
||||
currentAction: 'study',
|
||||
currentStudyTarget: {
|
||||
type: 'skill',
|
||||
id: skillId,
|
||||
progress: state.skillProgress[skillId] || 0,
|
||||
required: sk.studyTime,
|
||||
manaCostPerHour,
|
||||
},
|
||||
log: [`📚 Started studying ${sk.name}... (${Math.floor(manaCostPerHour)} mana/hour)`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
startStudyingSpell: (spellId: string) => {
|
||||
const state = get();
|
||||
const sp = SPELLS_DEF[spellId];
|
||||
if (!sp || state.spells[spellId]?.learned) return;
|
||||
|
||||
const costMult = getStudyCostMultiplier(state.skills);
|
||||
const totalCost = Math.floor(sp.unlock * costMult);
|
||||
const studyTime = sp.studyTime || (sp.tier * 4);
|
||||
const manaCostPerHour = totalCost / studyTime;
|
||||
|
||||
set({
|
||||
currentAction: 'study',
|
||||
currentStudyTarget: {
|
||||
type: 'spell',
|
||||
id: spellId,
|
||||
progress: state.spells[spellId]?.studyProgress || 0,
|
||||
required: studyTime,
|
||||
manaCostPerHour,
|
||||
},
|
||||
spells: {
|
||||
...state.spells,
|
||||
[spellId]: {
|
||||
...(state.spells[spellId] || { learned: false, level: 0 }),
|
||||
studyProgress: state.spells[spellId]?.studyProgress || 0,
|
||||
},
|
||||
},
|
||||
log: [`📚 Started studying ${sp.name}... (${Math.floor(manaCostPerHour)} mana/hour)`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
startParallelStudySkill: (skillId: string) => {
|
||||
const state = get();
|
||||
if (state.parallelStudyTarget) return;
|
||||
if (!state.currentStudyTarget) return;
|
||||
|
||||
const sk = SKILLS_DEF[skillId];
|
||||
if (!sk) return;
|
||||
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
if (currentLevel >= sk.max) return;
|
||||
|
||||
if (state.currentStudyTarget.id === skillId) return;
|
||||
|
||||
set({
|
||||
parallelStudyTarget: {
|
||||
type: 'skill',
|
||||
id: skillId,
|
||||
progress: state.skillProgress[skillId] || 0,
|
||||
required: sk.studyTime,
|
||||
manaCostPerHour: 0, // Parallel study doesn't cost extra
|
||||
},
|
||||
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
cancelStudy: () => {
|
||||
const state = get();
|
||||
if (!state.currentStudyTarget) return;
|
||||
|
||||
const savedProgress = state.currentStudyTarget.progress;
|
||||
const log = ['📖 Study paused. Progress saved.', ...state.log.slice(0, 49)];
|
||||
|
||||
if (state.currentStudyTarget.type === 'skill') {
|
||||
set({
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
skillProgress: {
|
||||
...state.skillProgress,
|
||||
[state.currentStudyTarget.id]: savedProgress,
|
||||
},
|
||||
log,
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
spells: {
|
||||
...state.spells,
|
||||
[state.currentStudyTarget.id]: {
|
||||
...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
|
||||
studyProgress: savedProgress,
|
||||
},
|
||||
},
|
||||
log,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
cancelParallelStudy: () => {
|
||||
set((state) => {
|
||||
if (!state.parallelStudyTarget) return state;
|
||||
return {
|
||||
parallelStudyTarget: null,
|
||||
log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => {
|
||||
set((state) => {
|
||||
const current = state.skillUpgrades?.[skillId] || [];
|
||||
if (current.includes(upgradeId)) return state;
|
||||
if (current.length >= 2) return state;
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: [...current, upgradeId],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => {
|
||||
set((state) => {
|
||||
const current = state.skillUpgrades?.[skillId] || [];
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: current.filter(id => id !== upgradeId),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[], milestone: 5 | 10) => {
|
||||
set((state) => {
|
||||
const existingUpgrades = state.skillUpgrades?.[skillId] || [];
|
||||
const otherMilestoneUpgrades = existingUpgrades.filter(
|
||||
id => milestone === 5 ? id.includes('_l10') : id.includes('_l5')
|
||||
);
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: [...otherMilestoneUpgrades, ...upgradeIds],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
tierUpSkill: (skillId: string) => {
|
||||
const state = get();
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const currentTier = state.skillTiers?.[baseSkillId] || 1;
|
||||
const nextTier = currentTier + 1;
|
||||
|
||||
if (nextTier > 5) return;
|
||||
|
||||
const nextTierSkillId = `${baseSkillId}_t${nextTier}`;
|
||||
|
||||
set({
|
||||
skillTiers: {
|
||||
...state.skillTiers,
|
||||
[baseSkillId]: nextTier,
|
||||
},
|
||||
skills: {
|
||||
...state.skills,
|
||||
[nextTierSkillId]: 0,
|
||||
[skillId]: 0,
|
||||
},
|
||||
skillProgress: {
|
||||
...state.skillProgress,
|
||||
[skillId]: 0,
|
||||
[nextTierSkillId]: 0,
|
||||
},
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[nextTierSkillId]: [],
|
||||
[skillId]: [],
|
||||
},
|
||||
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
|
||||
const state = get();
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const tier = state.skillTiers?.[baseSkillId] || 1;
|
||||
|
||||
const available = getUpgradesForSkillAtMilestone(skillId, milestone, state.skillTiers || {});
|
||||
const selected = (state.skillUpgrades?.[skillId] || []).filter(id =>
|
||||
available.some(u => u.id === id)
|
||||
);
|
||||
|
||||
return { available, selected };
|
||||
},
|
||||
});
|
||||
|
||||
// Process study progress (called during tick)
|
||||
export function processStudy(state: GameState, deltaHours: number): Partial<GameState> {
|
||||
if (state.currentAction !== 'study' || !state.currentStudyTarget) return {};
|
||||
|
||||
const target = state.currentStudyTarget;
|
||||
const studySpeedMult = getStudySpeedMultiplier(state.skills);
|
||||
const progressGain = deltaHours * studySpeedMult;
|
||||
const manaCost = progressGain * target.manaCostPerHour;
|
||||
|
||||
let rawMana = state.rawMana;
|
||||
let totalManaGathered = state.totalManaGathered;
|
||||
let skills = state.skills;
|
||||
let skillProgress = state.skillProgress;
|
||||
let spells = state.spells;
|
||||
const log = [...state.log];
|
||||
|
||||
if (rawMana >= manaCost) {
|
||||
rawMana -= manaCost;
|
||||
totalManaGathered += manaCost;
|
||||
|
||||
const newProgress = target.progress + progressGain;
|
||||
|
||||
if (newProgress >= target.required) {
|
||||
// Study complete
|
||||
if (target.type === 'skill') {
|
||||
const skillId = target.id;
|
||||
const currentLevel = skills[skillId] || 0;
|
||||
skills = { ...skills, [skillId]: currentLevel + 1 };
|
||||
skillProgress = { ...skillProgress, [skillId]: 0 };
|
||||
log.unshift(`✅ ${SKILLS_DEF[skillId]?.name} Lv.${currentLevel + 1} mastered!`);
|
||||
} else if (target.type === 'spell') {
|
||||
const spellId = target.id;
|
||||
spells = {
|
||||
...spells,
|
||||
[spellId]: { learned: true, level: 1, studyProgress: 0 },
|
||||
};
|
||||
log.unshift(`📖 ${SPELLS_DEF[spellId]?.name} learned!`);
|
||||
}
|
||||
|
||||
return {
|
||||
rawMana,
|
||||
totalManaGathered,
|
||||
skills,
|
||||
skillProgress,
|
||||
spells,
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rawMana,
|
||||
totalManaGathered,
|
||||
currentStudyTarget: { ...target, progress: newProgress },
|
||||
};
|
||||
}
|
||||
|
||||
// Not enough mana
|
||||
log.unshift('⚠️ Not enough mana to continue studying. Progress saved.');
|
||||
|
||||
if (target.type === 'skill') {
|
||||
return {
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
skillProgress: { ...skillProgress, [target.id]: target.progress },
|
||||
log,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
spells: {
|
||||
...spells,
|
||||
[target.id]: {
|
||||
...(spells[target.id] || { learned: false, level: 0 }),
|
||||
studyProgress: target.progress,
|
||||
},
|
||||
},
|
||||
log,
|
||||
};
|
||||
}
|
||||
}
|
||||
82
src/lib/game/store/timeSlice.ts
Executable file
82
src/lib/game/store/timeSlice.ts
Executable file
@@ -0,0 +1,82 @@
|
||||
// ─── Time Slice ───────────────────────────────────────────────────────────────
|
||||
// Manages game time, loops, and game state
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState } from '../types';
|
||||
import { MAX_DAY } from '../constants';
|
||||
import { calcInsight } from './computed';
|
||||
|
||||
export interface TimeSlice {
|
||||
// State
|
||||
day: number;
|
||||
hour: number;
|
||||
loopCount: number;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
paused: boolean;
|
||||
incursionStrength: number;
|
||||
loopInsight: number;
|
||||
log: string[];
|
||||
|
||||
// Actions
|
||||
togglePause: () => void;
|
||||
resetGame: () => void;
|
||||
startNewLoop: () => void;
|
||||
addLog: (message: string) => void;
|
||||
}
|
||||
|
||||
export const createTimeSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState,
|
||||
initialOverrides?: Partial<GameState>
|
||||
): TimeSlice => ({
|
||||
day: 1,
|
||||
hour: 0,
|
||||
loopCount: initialOverrides?.loopCount || 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
paused: false,
|
||||
incursionStrength: 0,
|
||||
loopInsight: 0,
|
||||
log: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
||||
|
||||
togglePause: () => {
|
||||
set((state) => ({ paused: !state.paused }));
|
||||
},
|
||||
|
||||
resetGame: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('mana-loop-storage');
|
||||
}
|
||||
// Reset to initial state
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
startNewLoop: () => {
|
||||
const state = get();
|
||||
const insightGained = state.loopInsight || calcInsight(state);
|
||||
const total = state.insight + insightGained;
|
||||
|
||||
// Spell preservation is handled through the prestige upgrade "spellMemory"
|
||||
// which is purchased with insight
|
||||
|
||||
// This will be handled by the main store reset
|
||||
set({
|
||||
day: 1,
|
||||
hour: 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
loopCount: state.loopCount + 1,
|
||||
insight: total,
|
||||
totalInsight: (state.totalInsight || 0) + insightGained,
|
||||
loopInsight: 0,
|
||||
log: ['✨ A new loop begins. Your insight grows...'],
|
||||
});
|
||||
},
|
||||
|
||||
addLog: (message: string) => {
|
||||
set((state) => ({
|
||||
log: [message, ...state.log.slice(0, 49)],
|
||||
}));
|
||||
},
|
||||
});
|
||||
494
src/lib/game/stores.test.ts
Executable file
494
src/lib/game/stores.test.ts
Executable file
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* Tests for the split store architecture
|
||||
*
|
||||
* Tests each store in isolation and integration between stores
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
useManaStore,
|
||||
useSkillStore,
|
||||
usePrestigeStore,
|
||||
useCombatStore,
|
||||
useUIStore,
|
||||
fmt,
|
||||
fmtDec,
|
||||
computeMaxMana,
|
||||
computeElementMax,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
calcDamage,
|
||||
calcInsight,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
canAffordSpellCost,
|
||||
} from './stores';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
SKILLS_DEF,
|
||||
PRESTIGE_DEF,
|
||||
getStudySpeedMultiplier,
|
||||
getStudyCostMultiplier,
|
||||
HOURS_PER_TICK,
|
||||
} from './constants';
|
||||
import type { GameState } from './types';
|
||||
|
||||
// ─── Test Fixtures ───────────────────────────────────────────────────────────
|
||||
|
||||
// Reset all stores before each test
|
||||
beforeEach(() => {
|
||||
useManaStore.setState({
|
||||
rawMana: 10,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements: (() => {
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
elements[k] = { current: 0, max: 10, unlocked: ['fire', 'water', 'air', 'earth'].includes(k) };
|
||||
});
|
||||
return elements;
|
||||
})(),
|
||||
});
|
||||
|
||||
useSkillStore.setState({
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
paidStudySkills: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
});
|
||||
|
||||
usePrestigeStore.setState({
|
||||
loopCount: 0,
|
||||
insight: 0,
|
||||
totalInsight: 0,
|
||||
loopInsight: 0,
|
||||
prestigeUpgrades: {},
|
||||
memorySlots: 3,
|
||||
pactSlots: 1,
|
||||
memories: [],
|
||||
defeatedGuardians: [],
|
||||
signedPacts: [],
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
});
|
||||
|
||||
useCombatStore.setState({
|
||||
currentFloor: 1,
|
||||
floorHP: 151,
|
||||
floorMaxHP: 151,
|
||||
maxFloorReached: 1,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
|
||||
});
|
||||
|
||||
useUIStore.setState({
|
||||
logs: [],
|
||||
paused: false,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mana Store Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('ManaStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('should have correct initial values', () => {
|
||||
const state = useManaStore.getState();
|
||||
expect(state.rawMana).toBe(10);
|
||||
expect(state.meditateTicks).toBe(0);
|
||||
expect(state.totalManaGathered).toBe(0);
|
||||
});
|
||||
|
||||
it('should have base elements unlocked', () => {
|
||||
const state = useManaStore.getState();
|
||||
expect(state.elements.fire.unlocked).toBe(true);
|
||||
expect(state.elements.water.unlocked).toBe(true);
|
||||
expect(state.elements.air.unlocked).toBe(true);
|
||||
expect(state.elements.earth.unlocked).toBe(true);
|
||||
expect(state.elements.light.unlocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw mana operations', () => {
|
||||
it('should add raw mana', () => {
|
||||
useManaStore.getState().addRawMana(50, 100);
|
||||
expect(useManaStore.getState().rawMana).toBe(60);
|
||||
});
|
||||
|
||||
it('should cap at max mana', () => {
|
||||
useManaStore.getState().addRawMana(200, 100);
|
||||
expect(useManaStore.getState().rawMana).toBe(100);
|
||||
});
|
||||
|
||||
it('should spend raw mana', () => {
|
||||
const result = useManaStore.getState().spendRawMana(5);
|
||||
expect(result).toBe(true);
|
||||
expect(useManaStore.getState().rawMana).toBe(5);
|
||||
});
|
||||
|
||||
it('should fail to spend more than available', () => {
|
||||
const result = useManaStore.getState().spendRawMana(50);
|
||||
expect(result).toBe(false);
|
||||
expect(useManaStore.getState().rawMana).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('element operations', () => {
|
||||
it('should convert raw mana to element', () => {
|
||||
useManaStore.getState().addRawMana(90, 1000); // Have 100 mana
|
||||
const result = useManaStore.getState().convertMana('fire', 1);
|
||||
expect(result).toBe(true);
|
||||
expect(useManaStore.getState().elements.fire.current).toBe(1);
|
||||
});
|
||||
|
||||
it('should unlock new element', () => {
|
||||
useManaStore.getState().addRawMana(490, 1000); // Have 500 mana
|
||||
const result = useManaStore.getState().unlockElement('light', 500);
|
||||
expect(result).toBe(true);
|
||||
expect(useManaStore.getState().elements.light.unlocked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Skill Store Tests ───────────────────────────────────────────────────────
|
||||
|
||||
describe('SkillStore', () => {
|
||||
describe('study skill', () => {
|
||||
it('should start studying a skill', () => {
|
||||
useManaStore.getState().addRawMana(90, 1000); // Have 100 mana
|
||||
|
||||
const result = useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
expect(result.started).toBe(true);
|
||||
expect(result.cost).toBe(100);
|
||||
expect(useSkillStore.getState().currentStudyTarget).not.toBeNull();
|
||||
expect(useSkillStore.getState().currentStudyTarget?.type).toBe('skill');
|
||||
expect(useSkillStore.getState().currentStudyTarget?.id).toBe('manaWell');
|
||||
});
|
||||
|
||||
it('should not start studying without enough mana', () => {
|
||||
const result = useSkillStore.getState().startStudyingSkill('manaWell', 50);
|
||||
|
||||
expect(result.started).toBe(false);
|
||||
expect(result.cost).toBe(100);
|
||||
expect(useSkillStore.getState().currentStudyTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('should track paid study skills', () => {
|
||||
useManaStore.getState().addRawMana(90, 1000);
|
||||
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
expect(useSkillStore.getState().paidStudySkills['manaWell']).toBe(0);
|
||||
});
|
||||
|
||||
it('should resume studying for free after payment', () => {
|
||||
useManaStore.getState().addRawMana(90, 1000);
|
||||
|
||||
// First study attempt
|
||||
const result1 = useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
expect(result1.cost).toBe(100);
|
||||
|
||||
// Cancel study (simulated)
|
||||
useSkillStore.getState().cancelStudy(0);
|
||||
|
||||
// Resume should be free
|
||||
const result2 = useSkillStore.getState().startStudyingSkill('manaWell', 0);
|
||||
expect(result2.started).toBe(true);
|
||||
expect(result2.cost).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update study progress', () => {
|
||||
it('should update study progress', () => {
|
||||
useManaStore.getState().addRawMana(90, 1000);
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
const result = useSkillStore.getState().updateStudyProgress(1);
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
expect(useSkillStore.getState().currentStudyTarget?.progress).toBe(1);
|
||||
});
|
||||
|
||||
it('should complete study when progress reaches required', () => {
|
||||
useManaStore.getState().addRawMana(90, 1000);
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
// manaWell requires 4 hours
|
||||
const result = useSkillStore.getState().updateStudyProgress(4);
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
expect(result.target?.id).toBe('manaWell');
|
||||
expect(useSkillStore.getState().currentStudyTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('should apply study speed multiplier', () => {
|
||||
useManaStore.getState().addRawMana(90, 1000);
|
||||
useSkillStore.getState().setSkillLevel('quickLearner', 5); // 50% faster
|
||||
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
// The caller should calculate progress with speed multiplier
|
||||
const speedMult = getStudySpeedMultiplier(useSkillStore.getState().skills);
|
||||
const result = useSkillStore.getState().updateStudyProgress(3 * speedMult); // 3 * 1.5 = 4.5
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill level operations', () => {
|
||||
it('should set skill level', () => {
|
||||
useSkillStore.getState().setSkillLevel('manaWell', 5);
|
||||
expect(useSkillStore.getState().skills['manaWell']).toBe(5);
|
||||
});
|
||||
|
||||
it('should increment skill level', () => {
|
||||
useSkillStore.getState().setSkillLevel('manaWell', 5);
|
||||
useSkillStore.getState().incrementSkillLevel('manaWell');
|
||||
expect(useSkillStore.getState().skills['manaWell']).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prerequisites', () => {
|
||||
it('should not start studying without prerequisites', () => {
|
||||
useManaStore.getState().addRawMana(990, 1000);
|
||||
|
||||
// deepReservoir requires manaWell 5
|
||||
const result = useSkillStore.getState().startStudyingSkill('deepReservoir', 1000);
|
||||
|
||||
expect(result.started).toBe(false);
|
||||
});
|
||||
|
||||
it('should start studying with prerequisites met', () => {
|
||||
useManaStore.getState().addRawMana(990, 1000);
|
||||
useSkillStore.getState().setSkillLevel('manaWell', 5);
|
||||
|
||||
const result = useSkillStore.getState().startStudyingSkill('deepReservoir', 1000);
|
||||
|
||||
expect(result.started).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prestige Store Tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('PrestigeStore', () => {
|
||||
describe('prestige upgrades', () => {
|
||||
it('should purchase prestige upgrade', () => {
|
||||
usePrestigeStore.getState().startNewLoop(1000); // Add 1000 insight
|
||||
|
||||
const result = usePrestigeStore.getState().doPrestige('manaWell');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(usePrestigeStore.getState().prestigeUpgrades['manaWell']).toBe(1);
|
||||
expect(usePrestigeStore.getState().insight).toBe(500); // 1000 - 500 cost
|
||||
});
|
||||
|
||||
it('should not purchase without enough insight', () => {
|
||||
usePrestigeStore.getState().startNewLoop(100);
|
||||
|
||||
const result = usePrestigeStore.getState().doPrestige('manaWell');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loop management', () => {
|
||||
it('should increment loop count', () => {
|
||||
usePrestigeStore.getState().incrementLoopCount();
|
||||
expect(usePrestigeStore.getState().loopCount).toBe(1);
|
||||
|
||||
usePrestigeStore.getState().incrementLoopCount();
|
||||
expect(usePrestigeStore.getState().loopCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should reset for new loop', () => {
|
||||
usePrestigeStore.getState().startNewLoop(1000);
|
||||
usePrestigeStore.getState().doPrestige('manaWell');
|
||||
|
||||
usePrestigeStore.getState().resetPrestigeForNewLoop(
|
||||
500, // total insight
|
||||
{ manaWell: 1 }, // prestige upgrades
|
||||
[], // memories
|
||||
3 // memory slots
|
||||
);
|
||||
|
||||
expect(usePrestigeStore.getState().insight).toBe(500);
|
||||
expect(usePrestigeStore.getState().prestigeUpgrades['manaWell']).toBe(1);
|
||||
expect(usePrestigeStore.getState().defeatedGuardians).toEqual([]);
|
||||
expect(usePrestigeStore.getState().signedPacts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardian pacts', () => {
|
||||
it('should add signed pact', () => {
|
||||
usePrestigeStore.getState().addSignedPact(10);
|
||||
expect(usePrestigeStore.getState().signedPacts).toContain(10);
|
||||
});
|
||||
|
||||
it('should add defeated guardian', () => {
|
||||
usePrestigeStore.getState().addDefeatedGuardian(10);
|
||||
expect(usePrestigeStore.getState().defeatedGuardians).toContain(10);
|
||||
});
|
||||
|
||||
it('should not add same guardian twice', () => {
|
||||
usePrestigeStore.getState().addDefeatedGuardian(10);
|
||||
usePrestigeStore.getState().addDefeatedGuardian(10);
|
||||
expect(usePrestigeStore.getState().defeatedGuardians.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Combat Store Tests ───────────────────────────────────────────────────────
|
||||
|
||||
describe('CombatStore', () => {
|
||||
describe('floor operations', () => {
|
||||
it('should advance floor', () => {
|
||||
useCombatStore.getState().advanceFloor();
|
||||
expect(useCombatStore.getState().currentFloor).toBe(2);
|
||||
expect(useCombatStore.getState().maxFloorReached).toBe(2);
|
||||
});
|
||||
|
||||
it('should cap at floor 100', () => {
|
||||
useCombatStore.setState({ currentFloor: 100 });
|
||||
useCombatStore.getState().advanceFloor();
|
||||
expect(useCombatStore.getState().currentFloor).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action management', () => {
|
||||
it('should set action', () => {
|
||||
useCombatStore.getState().setAction('climb');
|
||||
expect(useCombatStore.getState().currentAction).toBe('climb');
|
||||
|
||||
useCombatStore.getState().setAction('study');
|
||||
expect(useCombatStore.getState().currentAction).toBe('study');
|
||||
});
|
||||
});
|
||||
|
||||
describe('spell management', () => {
|
||||
it('should set active spell', () => {
|
||||
useCombatStore.getState().learnSpell('fireball');
|
||||
useCombatStore.getState().setSpell('fireball');
|
||||
expect(useCombatStore.getState().activeSpell).toBe('fireball');
|
||||
});
|
||||
|
||||
it('should learn spell', () => {
|
||||
useCombatStore.getState().learnSpell('fireball');
|
||||
expect(useCombatStore.getState().spells['fireball']?.learned).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UI Store Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('UIStore', () => {
|
||||
describe('log management', () => {
|
||||
it('should add log message', () => {
|
||||
useUIStore.getState().addLog('Test message');
|
||||
expect(useUIStore.getState().logs).toContain('Test message');
|
||||
});
|
||||
|
||||
it('should clear logs', () => {
|
||||
useUIStore.getState().addLog('Test message');
|
||||
useUIStore.getState().clearLogs();
|
||||
expect(useUIStore.getState().logs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pause management', () => {
|
||||
it('should toggle pause', () => {
|
||||
expect(useUIStore.getState().paused).toBe(false);
|
||||
|
||||
useUIStore.getState().togglePause();
|
||||
expect(useUIStore.getState().paused).toBe(true);
|
||||
|
||||
useUIStore.getState().togglePause();
|
||||
expect(useUIStore.getState().paused).toBe(false);
|
||||
});
|
||||
|
||||
it('should set pause state', () => {
|
||||
useUIStore.getState().setPaused(true);
|
||||
expect(useUIStore.getState().paused).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game over state', () => {
|
||||
it('should set game over', () => {
|
||||
useUIStore.getState().setGameOver(true);
|
||||
expect(useUIStore.getState().gameOver).toBe(true);
|
||||
expect(useUIStore.getState().victory).toBe(false);
|
||||
});
|
||||
|
||||
it('should set victory', () => {
|
||||
useUIStore.getState().setGameOver(true, true);
|
||||
expect(useUIStore.getState().gameOver).toBe(true);
|
||||
expect(useUIStore.getState().victory).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Store Integration', () => {
|
||||
describe('skill study flow', () => {
|
||||
it('should complete full study flow', () => {
|
||||
// Setup: give enough mana
|
||||
useManaStore.getState().addRawMana(90, 1000);
|
||||
|
||||
// Start studying
|
||||
const startResult = useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
expect(startResult.started).toBe(true);
|
||||
expect(startResult.cost).toBe(100);
|
||||
|
||||
// Deduct mana (simulating UI behavior)
|
||||
if (startResult.cost > 0) {
|
||||
useManaStore.getState().spendRawMana(startResult.cost);
|
||||
}
|
||||
|
||||
// Set action to study
|
||||
useCombatStore.getState().setAction('study');
|
||||
expect(useCombatStore.getState().currentAction).toBe('study');
|
||||
|
||||
// Update progress until complete
|
||||
const result = useSkillStore.getState().updateStudyProgress(4);
|
||||
expect(result.completed).toBe(true);
|
||||
|
||||
// Level up skill
|
||||
useSkillStore.getState().setSkillLevel('manaWell', 1);
|
||||
expect(useSkillStore.getState().skills['manaWell']).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mana and prestige interaction', () => {
|
||||
it('should apply prestige mana bonus', () => {
|
||||
// Get prestige upgrade
|
||||
usePrestigeStore.getState().startNewLoop(1000);
|
||||
usePrestigeStore.getState().doPrestige('manaWell');
|
||||
|
||||
// Check that prestige upgrade is recorded
|
||||
expect(usePrestigeStore.getState().prestigeUpgrades['manaWell']).toBe(1);
|
||||
|
||||
// Mana well prestige gives +500 max mana per level
|
||||
const state = {
|
||||
skills: {},
|
||||
prestigeUpgrades: usePrestigeStore.getState().prestigeUpgrades,
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
};
|
||||
|
||||
const maxMana = computeMaxMana(state);
|
||||
expect(maxMana).toBe(100 + 500); // Base 100 + 500 from prestige
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ All store tests defined. Run with: bun test src/lib/game/stores.test.ts');
|
||||
583
src/lib/game/stores/__tests__/store-methods.test.ts
Executable file
583
src/lib/game/stores/__tests__/store-methods.test.ts
Executable file
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Store Method Tests
|
||||
*
|
||||
* Tests for individual store methods: skillStore, manaStore, combatStore, prestigeStore
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import { useSkillStore } from '../skillStore';
|
||||
import { useManaStore } from '../manaStore';
|
||||
import { useCombatStore } from '../combatStore';
|
||||
import { usePrestigeStore } from '../prestigeStore';
|
||||
import { useUIStore } from '../uiStore';
|
||||
import { SKILLS_DEF, SPELLS_DEF, GUARDIANS, BASE_UNLOCKED_ELEMENTS, ELEMENTS } from '../../constants';
|
||||
|
||||
// Reset stores before each test
|
||||
beforeEach(() => {
|
||||
// Reset all stores to initial state
|
||||
useSkillStore.getState().resetSkills();
|
||||
useManaStore.getState().resetMana({}, {}, {}, {});
|
||||
usePrestigeStore.getState().resetPrestige();
|
||||
useUIStore.getState().resetUI();
|
||||
useCombatStore.getState().resetCombat(1);
|
||||
});
|
||||
|
||||
// ─── Skill Store Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('SkillStore', () => {
|
||||
describe('startStudyingSkill', () => {
|
||||
it('should start studying a skill when have enough mana', () => {
|
||||
const skillStore = useSkillStore.getState();
|
||||
const result = skillStore.startStudyingSkill('manaWell', 100);
|
||||
|
||||
expect(result.started).toBe(true);
|
||||
expect(result.cost).toBe(100); // base cost for level 1
|
||||
|
||||
const newState = useSkillStore.getState();
|
||||
expect(newState.currentStudyTarget).not.toBeNull();
|
||||
expect(newState.currentStudyTarget?.type).toBe('skill');
|
||||
expect(newState.currentStudyTarget?.id).toBe('manaWell');
|
||||
});
|
||||
|
||||
it('should not start studying when not enough mana', () => {
|
||||
const skillStore = useSkillStore.getState();
|
||||
const result = skillStore.startStudyingSkill('manaWell', 50);
|
||||
|
||||
expect(result.started).toBe(false);
|
||||
|
||||
const newState = useSkillStore.getState();
|
||||
expect(newState.currentStudyTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('should not start studying skill at max level', () => {
|
||||
// Set skill to max level
|
||||
useSkillStore.setState({ skills: { manaWell: SKILLS_DEF.manaWell.max } });
|
||||
|
||||
const skillStore = useSkillStore.getState();
|
||||
const result = skillStore.startStudyingSkill('manaWell', 1000);
|
||||
|
||||
expect(result.started).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start studying without prerequisites', () => {
|
||||
const skillStore = useSkillStore.getState();
|
||||
// deepReservoir requires manaWell level 5
|
||||
const result = skillStore.startStudyingSkill('deepReservoir', 1000);
|
||||
|
||||
expect(result.started).toBe(false);
|
||||
});
|
||||
|
||||
it('should start studying with prerequisites met', () => {
|
||||
useSkillStore.setState({ skills: { manaWell: 5 } });
|
||||
|
||||
const skillStore = useSkillStore.getState();
|
||||
const result = skillStore.startStudyingSkill('deepReservoir', 1000);
|
||||
|
||||
expect(result.started).toBe(true);
|
||||
});
|
||||
|
||||
it('should be free to resume if already paid', () => {
|
||||
// First, start studying (which marks as paid)
|
||||
const skillStore = useSkillStore.getState();
|
||||
skillStore.startStudyingSkill('manaWell', 100);
|
||||
|
||||
// Cancel study
|
||||
skillStore.cancelStudy(0);
|
||||
|
||||
// Resume should be free
|
||||
const newState = useSkillStore.getState();
|
||||
const result = newState.startStudyingSkill('manaWell', 0);
|
||||
|
||||
expect(result.started).toBe(true);
|
||||
expect(result.cost).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStudyProgress', () => {
|
||||
it('should progress study target', () => {
|
||||
// Start studying
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
// Update progress
|
||||
const result = useSkillStore.getState().updateStudyProgress(1);
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.currentStudyTarget?.progress).toBe(1);
|
||||
});
|
||||
|
||||
it('should complete study when progress reaches required', () => {
|
||||
// Start studying manaWell (4 hours study time)
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
// Update with enough progress
|
||||
const result = useSkillStore.getState().updateStudyProgress(4);
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.currentStudyTarget).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementSkillLevel', () => {
|
||||
it('should increment skill level', () => {
|
||||
useSkillStore.setState({ skills: { manaWell: 0 } });
|
||||
|
||||
useSkillStore.getState().incrementSkillLevel('manaWell');
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.skills.manaWell).toBe(1);
|
||||
});
|
||||
|
||||
it('should clear skill progress', () => {
|
||||
useSkillStore.setState({
|
||||
skills: { manaWell: 0 },
|
||||
skillProgress: { manaWell: 2 }
|
||||
});
|
||||
|
||||
useSkillStore.getState().incrementSkillLevel('manaWell');
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.skillProgress.manaWell).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelStudy', () => {
|
||||
it('should clear study target', () => {
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
|
||||
useSkillStore.getState().cancelStudy(0);
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.currentStudyTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('should save progress with retention bonus', () => {
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
useSkillStore.getState().updateStudyProgress(2); // 2 hours progress
|
||||
|
||||
// Cancel with 50% retention bonus
|
||||
// Retention bonus limits how much of the *required* time can be saved
|
||||
// Required = 4 hours, so 50% = 2 hours max
|
||||
// Progress = 2 hours, so we save all of it (within limit)
|
||||
useSkillStore.getState().cancelStudy(0.5);
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
// Saved progress should be min(progress, required * retentionBonus) = min(2, 4*0.5) = min(2, 2) = 2
|
||||
expect(state.skillProgress.manaWell).toBe(2);
|
||||
});
|
||||
|
||||
it('should limit saved progress to retention bonus cap', () => {
|
||||
useSkillStore.getState().startStudyingSkill('manaWell', 100);
|
||||
useSkillStore.getState().updateStudyProgress(3); // 3 hours progress (out of 4 required)
|
||||
|
||||
// Cancel with 50% retention bonus
|
||||
// Cap is 4 * 0.5 = 2 hours, progress is 3, so we save 2 (the cap)
|
||||
useSkillStore.getState().cancelStudy(0.5);
|
||||
|
||||
const state = useSkillStore.getState();
|
||||
expect(state.skillProgress.manaWell).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mana Store Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('ManaStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('should have base elements unlocked', () => {
|
||||
const state = useManaStore.getState();
|
||||
|
||||
expect(state.elements.fire.unlocked).toBe(true);
|
||||
expect(state.elements.water.unlocked).toBe(true);
|
||||
expect(state.elements.air.unlocked).toBe(true);
|
||||
expect(state.elements.earth.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('should have exotic elements locked', () => {
|
||||
const state = useManaStore.getState();
|
||||
|
||||
expect(state.elements.void.unlocked).toBe(false);
|
||||
expect(state.elements.stellar.unlocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertMana', () => {
|
||||
it('should convert raw mana to elemental', () => {
|
||||
useManaStore.setState({ rawMana: 200 });
|
||||
|
||||
const result = useManaStore.getState().convertMana('fire', 1);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const state = useManaStore.getState();
|
||||
expect(state.rawMana).toBe(100);
|
||||
expect(state.elements.fire.current).toBe(1);
|
||||
});
|
||||
|
||||
it('should not convert when not enough raw mana', () => {
|
||||
useManaStore.setState({ rawMana: 50 });
|
||||
|
||||
const result = useManaStore.getState().convertMana('fire', 1);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not convert when element at max', () => {
|
||||
useManaStore.setState({
|
||||
rawMana: 500,
|
||||
elements: {
|
||||
...useManaStore.getState().elements,
|
||||
fire: { current: 10, max: 10, unlocked: true }
|
||||
}
|
||||
});
|
||||
|
||||
const result = useManaStore.getState().convertMana('fire', 1);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not convert to locked element', () => {
|
||||
useManaStore.setState({ rawMana: 500 });
|
||||
|
||||
const result = useManaStore.getState().convertMana('void', 1);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlockElement', () => {
|
||||
it('should unlock element when have enough mana', () => {
|
||||
useManaStore.setState({ rawMana: 500 });
|
||||
|
||||
const result = useManaStore.getState().unlockElement('light', 500);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const state = useManaStore.getState();
|
||||
expect(state.elements.light.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('should not unlock when not enough mana', () => {
|
||||
useManaStore.setState({ rawMana: 100 });
|
||||
|
||||
const result = useManaStore.getState().unlockElement('light', 500);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('craftComposite', () => {
|
||||
it('should craft composite element with correct ingredients', () => {
|
||||
// Set up ingredients for blood (life + water)
|
||||
useManaStore.setState({
|
||||
elements: {
|
||||
...useManaStore.getState().elements,
|
||||
life: { current: 5, max: 10, unlocked: true },
|
||||
water: { current: 5, max: 10, unlocked: true },
|
||||
}
|
||||
});
|
||||
|
||||
const result = useManaStore.getState().craftComposite('blood', ['life', 'water']);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const state = useManaStore.getState();
|
||||
expect(state.elements.life.current).toBe(4);
|
||||
expect(state.elements.water.current).toBe(4);
|
||||
expect(state.elements.blood.current).toBe(1);
|
||||
expect(state.elements.blood.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('should not craft without ingredients', () => {
|
||||
useManaStore.setState({
|
||||
elements: {
|
||||
...useManaStore.getState().elements,
|
||||
life: { current: 0, max: 10, unlocked: true },
|
||||
water: { current: 0, max: 10, unlocked: true },
|
||||
}
|
||||
});
|
||||
|
||||
const result = useManaStore.getState().craftComposite('blood', ['life', 'water']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Combat Store Tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('CombatStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('should start with manaBolt learned', () => {
|
||||
const state = useCombatStore.getState();
|
||||
|
||||
expect(state.spells.manaBolt.learned).toBe(true);
|
||||
});
|
||||
|
||||
it('should start at floor 1', () => {
|
||||
const state = useCombatStore.getState();
|
||||
|
||||
expect(state.currentFloor).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAction', () => {
|
||||
it('should change current action', () => {
|
||||
useCombatStore.getState().setAction('climb');
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.currentAction).toBe('climb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSpell', () => {
|
||||
it('should change active spell if learned', () => {
|
||||
// Learn another spell
|
||||
useCombatStore.getState().learnSpell('fireball');
|
||||
|
||||
useCombatStore.getState().setSpell('fireball');
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.activeSpell).toBe('fireball');
|
||||
});
|
||||
|
||||
it('should not change to unlearned spell', () => {
|
||||
useCombatStore.getState().setSpell('fireball');
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.activeSpell).toBe('manaBolt'); // Still manaBolt
|
||||
});
|
||||
});
|
||||
|
||||
describe('learnSpell', () => {
|
||||
it('should add spell to learned spells', () => {
|
||||
useCombatStore.getState().learnSpell('fireball');
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.spells.fireball.learned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advanceFloor', () => {
|
||||
it('should increment floor', () => {
|
||||
useCombatStore.getState().advanceFloor();
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.currentFloor).toBe(2);
|
||||
});
|
||||
|
||||
it('should not exceed floor 100', () => {
|
||||
useCombatStore.setState({ currentFloor: 100 });
|
||||
|
||||
useCombatStore.getState().advanceFloor();
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.currentFloor).toBe(100);
|
||||
});
|
||||
|
||||
it('should update maxFloorReached', () => {
|
||||
useCombatStore.setState({ maxFloorReached: 1 });
|
||||
|
||||
useCombatStore.getState().advanceFloor();
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.maxFloorReached).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prestige Store Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('PrestigeStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('should start with 0 insight', () => {
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.insight).toBe(0);
|
||||
});
|
||||
|
||||
it('should start with 3 memory slots', () => {
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.memorySlots).toBe(3);
|
||||
});
|
||||
|
||||
it('should start with 1 pact slot', () => {
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.pactSlots).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doPrestige', () => {
|
||||
it('should deduct insight and add upgrade', () => {
|
||||
usePrestigeStore.setState({ insight: 1000 });
|
||||
|
||||
usePrestigeStore.getState().doPrestige('manaWell');
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.prestigeUpgrades.manaWell).toBe(1);
|
||||
expect(state.insight).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should not upgrade without enough insight', () => {
|
||||
usePrestigeStore.setState({ insight: 100 });
|
||||
|
||||
usePrestigeStore.getState().doPrestige('manaWell');
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.prestigeUpgrades.manaWell).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMemory', () => {
|
||||
it('should add memory within slot limit', () => {
|
||||
const memory = { skillId: 'manaWell', level: 5, tier: 1, upgrades: [] };
|
||||
|
||||
usePrestigeStore.getState().addMemory(memory);
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.memories.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not add duplicate memory', () => {
|
||||
const memory = { skillId: 'manaWell', level: 5, tier: 1, upgrades: [] };
|
||||
|
||||
usePrestigeStore.getState().addMemory(memory);
|
||||
usePrestigeStore.getState().addMemory(memory);
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.memories.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not exceed memory slots', () => {
|
||||
// Fill memory slots
|
||||
for (let i = 0; i < 5; i++) {
|
||||
usePrestigeStore.getState().addMemory({
|
||||
skillId: `skill${i}`,
|
||||
level: 5,
|
||||
tier: 1,
|
||||
upgrades: []
|
||||
});
|
||||
}
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.memories.length).toBe(3); // Default 3 slots
|
||||
});
|
||||
});
|
||||
|
||||
describe('startPactRitual', () => {
|
||||
it('should start ritual for defeated guardian', () => {
|
||||
usePrestigeStore.setState({
|
||||
defeatedGuardians: [10],
|
||||
signedPacts: []
|
||||
});
|
||||
useManaStore.setState({ rawMana: 1000 });
|
||||
|
||||
const result = usePrestigeStore.getState().startPactRitual(10, 1000);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.pactRitualFloor).toBe(10);
|
||||
});
|
||||
|
||||
it('should not start ritual for undefeated guardian', () => {
|
||||
usePrestigeStore.setState({
|
||||
defeatedGuardians: [],
|
||||
signedPacts: []
|
||||
});
|
||||
useManaStore.setState({ rawMana: 1000 });
|
||||
|
||||
const result = usePrestigeStore.getState().startPactRitual(10, 1000);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start ritual without enough mana', () => {
|
||||
usePrestigeStore.setState({
|
||||
defeatedGuardians: [10],
|
||||
signedPacts: []
|
||||
});
|
||||
useManaStore.setState({ rawMana: 100 });
|
||||
|
||||
const result = usePrestigeStore.getState().startPactRitual(10, 100);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSignedPact', () => {
|
||||
it('should add pact to signed list', () => {
|
||||
usePrestigeStore.getState().addSignedPact(10);
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.signedPacts).toContain(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDefeatedGuardian', () => {
|
||||
it('should add guardian to defeated list', () => {
|
||||
usePrestigeStore.getState().addDefeatedGuardian(10);
|
||||
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(state.defeatedGuardians).toContain(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UI Store Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('UIStore', () => {
|
||||
describe('addLog', () => {
|
||||
it('should add message to logs', () => {
|
||||
useUIStore.getState().addLog('Test message');
|
||||
|
||||
const state = useUIStore.getState();
|
||||
expect(state.logs[0]).toBe('Test message');
|
||||
});
|
||||
|
||||
it('should limit log size', () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
useUIStore.getState().addLog(`Message ${i}`);
|
||||
}
|
||||
|
||||
const state = useUIStore.getState();
|
||||
expect(state.logs.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('togglePause', () => {
|
||||
it('should toggle pause state', () => {
|
||||
const initial = useUIStore.getState().paused;
|
||||
|
||||
useUIStore.getState().togglePause();
|
||||
|
||||
expect(useUIStore.getState().paused).toBe(!initial);
|
||||
|
||||
useUIStore.getState().togglePause();
|
||||
|
||||
expect(useUIStore.getState().paused).toBe(initial);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setGameOver', () => {
|
||||
it('should set game over state', () => {
|
||||
useUIStore.getState().setGameOver(true, false);
|
||||
|
||||
const state = useUIStore.getState();
|
||||
expect(state.gameOver).toBe(true);
|
||||
expect(state.victory).toBe(false);
|
||||
});
|
||||
|
||||
it('should set victory state', () => {
|
||||
useUIStore.getState().setGameOver(true, true);
|
||||
|
||||
const state = useUIStore.getState();
|
||||
expect(state.gameOver).toBe(true);
|
||||
expect(state.victory).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ All store method tests defined.');
|
||||
458
src/lib/game/stores/__tests__/stores.test.ts
Executable file
458
src/lib/game/stores/__tests__/stores.test.ts
Executable file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Comprehensive Store Tests
|
||||
*
|
||||
* Tests for the split store architecture after refactoring.
|
||||
* Each store is tested individually and for cross-store communication.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
fmt,
|
||||
fmtDec,
|
||||
getFloorMaxHP,
|
||||
getFloorElement,
|
||||
computeMaxMana,
|
||||
computeElementMax,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
calcDamage,
|
||||
calcInsight,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
canAffordSpellCost,
|
||||
} from '../../utils';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
SKILLS_DEF,
|
||||
PRESTIGE_DEF,
|
||||
MAX_DAY,
|
||||
INCURSION_START_DAY,
|
||||
getStudySpeedMultiplier,
|
||||
getStudyCostMultiplier,
|
||||
rawCost,
|
||||
elemCost,
|
||||
BASE_UNLOCKED_ELEMENTS,
|
||||
} from '../../constants';
|
||||
import type { GameState } from '../../types';
|
||||
|
||||
// ─── Test Fixtures ───────────────────────────────────────────────────────────
|
||||
|
||||
function createMockState(overrides: Partial<GameState> = {}): GameState {
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
elements[k] = { current: 0, max: 10, unlocked: BASE_UNLOCKED_ELEMENTS.includes(k) };
|
||||
});
|
||||
|
||||
return {
|
||||
day: 1,
|
||||
hour: 0,
|
||||
loopCount: 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
paused: false,
|
||||
rawMana: 100,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements,
|
||||
currentFloor: 1,
|
||||
floorHP: 100,
|
||||
floorMaxHP: 100,
|
||||
maxFloorReached: 1,
|
||||
signedPacts: [],
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
currentRoom: {
|
||||
roomType: 'combat',
|
||||
enemies: [{ id: 'enemy', hp: 100, maxHP: 100, armor: 0, dodgeChance: 0, element: 'fire' }],
|
||||
},
|
||||
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
parallelStudyTarget: null,
|
||||
equipment: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory: null },
|
||||
inventory: [],
|
||||
blueprints: {},
|
||||
schedule: [],
|
||||
autoSchedule: false,
|
||||
studyQueue: [],
|
||||
craftQueue: [],
|
||||
currentStudyTarget: null,
|
||||
insight: 0,
|
||||
totalInsight: 0,
|
||||
prestigeUpgrades: {},
|
||||
memorySlots: 3,
|
||||
memories: [],
|
||||
incursionStrength: 0,
|
||||
containmentWards: 0,
|
||||
log: [],
|
||||
loopInsight: 0,
|
||||
attunements: {
|
||||
enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 },
|
||||
},
|
||||
equippedInstances: {},
|
||||
equipmentInstances: {},
|
||||
enchantmentDesigns: [],
|
||||
designProgress: null,
|
||||
preparationProgress: null,
|
||||
applicationProgress: null,
|
||||
equipmentCraftingProgress: null,
|
||||
unlockedEffects: [],
|
||||
equipmentSpellStates: [],
|
||||
lootInventory: { materials: {}, blueprints: [] },
|
||||
golemancy: {
|
||||
enabledGolems: [],
|
||||
summonedGolems: [],
|
||||
lastSummonFloor: 0,
|
||||
},
|
||||
achievements: {
|
||||
unlocked: [],
|
||||
progress: {},
|
||||
},
|
||||
totalSpellsCast: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalCraftsCompleted: 0,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
// ─── Formatting Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Formatting Functions', () => {
|
||||
describe('fmt (format number)', () => {
|
||||
it('should format numbers less than 1000 as integers', () => {
|
||||
expect(fmt(0)).toBe('0');
|
||||
expect(fmt(1)).toBe('1');
|
||||
expect(fmt(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should format thousands with K suffix', () => {
|
||||
expect(fmt(1000)).toBe('1.0K');
|
||||
expect(fmt(1500)).toBe('1.5K');
|
||||
});
|
||||
|
||||
it('should format millions with M suffix', () => {
|
||||
expect(fmt(1000000)).toBe('1.00M');
|
||||
expect(fmt(1500000)).toBe('1.50M');
|
||||
});
|
||||
|
||||
it('should format billions with B suffix', () => {
|
||||
expect(fmt(1000000000)).toBe('1.00B');
|
||||
});
|
||||
|
||||
it('should handle non-finite numbers', () => {
|
||||
expect(fmt(Infinity)).toBe('0');
|
||||
expect(fmt(NaN)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtDec (format decimal)', () => {
|
||||
it('should format numbers with specified decimal places', () => {
|
||||
expect(fmtDec(1.234, 2)).toBe('1.23');
|
||||
expect(fmtDec(1.567, 1)).toBe('1.6');
|
||||
});
|
||||
|
||||
it('should handle non-finite numbers', () => {
|
||||
expect(fmtDec(Infinity, 2)).toBe('0');
|
||||
expect(fmtDec(NaN, 2)).toBe('0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Floor Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Floor Functions', () => {
|
||||
describe('getFloorMaxHP', () => {
|
||||
it('should return guardian HP for guardian floors', () => {
|
||||
expect(getFloorMaxHP(10)).toBe(GUARDIANS[10].hp);
|
||||
expect(getFloorMaxHP(100)).toBe(GUARDIANS[100].hp);
|
||||
});
|
||||
|
||||
it('should scale HP for non-guardian floors', () => {
|
||||
expect(getFloorMaxHP(1)).toBeGreaterThan(0);
|
||||
expect(getFloorMaxHP(5)).toBeGreaterThan(getFloorMaxHP(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFloorElement', () => {
|
||||
it('should cycle through elements in order', () => {
|
||||
expect(getFloorElement(1)).toBe('fire');
|
||||
expect(getFloorElement(2)).toBe('water');
|
||||
expect(getFloorElement(3)).toBe('air');
|
||||
expect(getFloorElement(4)).toBe('earth');
|
||||
});
|
||||
|
||||
it('should wrap around after cycle', () => {
|
||||
// FLOOR_ELEM_CYCLE has 7 elements: fire, water, air, earth, light, dark, death
|
||||
// Floor 1 = index 0 = fire, Floor 7 = index 6 = death, Floor 8 = index 0 = fire
|
||||
expect(getFloorElement(7)).toBe('death');
|
||||
expect(getFloorElement(8)).toBe('fire');
|
||||
expect(getFloorElement(9)).toBe('water');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mana Calculation Tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('Mana Calculation Functions', () => {
|
||||
describe('computeMaxMana', () => {
|
||||
it('should return base mana with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeMaxMana(state)).toBe(100);
|
||||
});
|
||||
|
||||
it('should add mana from manaWell skill', () => {
|
||||
const state = createMockState({ skills: { manaWell: 5 } });
|
||||
expect(computeMaxMana(state)).toBe(100 + 5 * 100);
|
||||
});
|
||||
|
||||
it('should add mana from prestige upgrades', () => {
|
||||
const state = createMockState({ prestigeUpgrades: { manaWell: 3 } });
|
||||
expect(computeMaxMana(state)).toBe(100 + 3 * 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRegen', () => {
|
||||
it('should return base regen with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeRegen(state)).toBe(2);
|
||||
});
|
||||
|
||||
it('should add regen from manaFlow skill', () => {
|
||||
const state = createMockState({ skills: { manaFlow: 5 } });
|
||||
expect(computeRegen(state)).toBe(2 + 5 * 1);
|
||||
});
|
||||
|
||||
it('should add regen from manaSpring skill', () => {
|
||||
const state = createMockState({ skills: { manaSpring: 1 } });
|
||||
expect(computeRegen(state)).toBe(2 + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeClickMana', () => {
|
||||
it('should return base click mana with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeClickMana(state)).toBe(1);
|
||||
});
|
||||
|
||||
it('should add mana from manaTap skill', () => {
|
||||
const state = createMockState({ skills: { manaTap: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 1);
|
||||
});
|
||||
|
||||
it('should add mana from manaSurge skill', () => {
|
||||
const state = createMockState({ skills: { manaSurge: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Damage Calculation Tests ─────────────────────────────────────────────────
|
||||
|
||||
describe('Damage Calculation', () => {
|
||||
describe('calcDamage', () => {
|
||||
it('should return spell base damage with no bonuses', () => {
|
||||
const state = createMockState();
|
||||
const dmg = calcDamage(state, 'manaBolt');
|
||||
expect(dmg).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Insight Calculation Tests ─────────────────────────────────────────────────
|
||||
|
||||
describe('Insight Calculation', () => {
|
||||
describe('calcInsight', () => {
|
||||
it('should calculate insight from floor progress', () => {
|
||||
const state = createMockState({ maxFloorReached: 10 });
|
||||
const insight = calcInsight(state);
|
||||
expect(insight).toBe(10 * 15);
|
||||
});
|
||||
|
||||
it('should calculate insight from signed pacts', () => {
|
||||
const state = createMockState({ signedPacts: [10, 20] });
|
||||
const insight = calcInsight(state);
|
||||
expect(insight).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Meditation Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Meditation Bonus', () => {
|
||||
describe('getMeditationBonus', () => {
|
||||
it('should start at 1x with no meditation', () => {
|
||||
expect(getMeditationBonus(0, {})).toBe(1);
|
||||
});
|
||||
|
||||
it('should cap at 1.5x without meditation skill', () => {
|
||||
const bonus = getMeditationBonus(200, {}); // 8 hours
|
||||
expect(bonus).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should give 2.5x with meditation skill after 4 hours', () => {
|
||||
const bonus = getMeditationBonus(100, { meditation: 1 });
|
||||
expect(bonus).toBe(2.5);
|
||||
});
|
||||
|
||||
it('should give 3.0x with deepTrance skill after 6 hours', () => {
|
||||
const bonus = getMeditationBonus(150, { meditation: 1, deepTrance: 1 });
|
||||
expect(bonus).toBe(3.0);
|
||||
});
|
||||
|
||||
it('should give 5.0x with voidMeditation skill after 8 hours', () => {
|
||||
const bonus = getMeditationBonus(200, { meditation: 1, deepTrance: 1, voidMeditation: 1 });
|
||||
expect(bonus).toBe(5.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Incursion Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Incursion Strength', () => {
|
||||
describe('getIncursionStrength', () => {
|
||||
it('should be 0 before incursion start day', () => {
|
||||
expect(getIncursionStrength(19, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should start at incursion start day', () => {
|
||||
expect(getIncursionStrength(INCURSION_START_DAY, 1)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should cap at 95%', () => {
|
||||
const strength = getIncursionStrength(MAX_DAY, 23);
|
||||
expect(strength).toBeLessThanOrEqual(0.95);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Spell Cost Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Spell Cost System', () => {
|
||||
describe('rawCost', () => {
|
||||
it('should create a raw mana cost', () => {
|
||||
const cost = rawCost(10);
|
||||
expect(cost.type).toBe('raw');
|
||||
expect(cost.amount).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('elemCost', () => {
|
||||
it('should create an elemental mana cost', () => {
|
||||
const cost = elemCost('fire', 5);
|
||||
expect(cost.type).toBe('element');
|
||||
expect(cost.element).toBe('fire');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAffordSpellCost', () => {
|
||||
it('should allow raw mana costs when enough raw mana', () => {
|
||||
const cost = rawCost(10);
|
||||
const elements = { fire: { current: 0, max: 10, unlocked: true } };
|
||||
expect(canAffordSpellCost(cost, 100, elements)).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny raw mana costs when not enough raw mana', () => {
|
||||
const cost = rawCost(100);
|
||||
const elements = { fire: { current: 0, max: 10, unlocked: true } };
|
||||
expect(canAffordSpellCost(cost, 50, elements)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Study Speed Tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Study Speed Functions', () => {
|
||||
describe('getStudySpeedMultiplier', () => {
|
||||
it('should return 1 with no quickLearner skill', () => {
|
||||
expect(getStudySpeedMultiplier({})).toBe(1);
|
||||
});
|
||||
|
||||
it('should increase by 10% per level', () => {
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 1 })).toBe(1.1);
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 5 })).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStudyCostMultiplier', () => {
|
||||
it('should return 1 with no focusedMind skill', () => {
|
||||
expect(getStudyCostMultiplier({})).toBe(1);
|
||||
});
|
||||
|
||||
it('should decrease by 5% per level', () => {
|
||||
expect(getStudyCostMultiplier({ focusedMind: 1 })).toBe(0.95);
|
||||
expect(getStudyCostMultiplier({ focusedMind: 5 })).toBe(0.75);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Guardian Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Guardians', () => {
|
||||
it('should have guardians on expected floors', () => {
|
||||
const guardianFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
|
||||
guardianFloors.forEach(floor => {
|
||||
expect(GUARDIANS[floor]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have increasing HP across guardians', () => {
|
||||
const guardianFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
|
||||
let prevHP = 0;
|
||||
guardianFloors.forEach(floor => {
|
||||
expect(GUARDIANS[floor].hp).toBeGreaterThan(prevHP);
|
||||
prevHP = GUARDIANS[floor].hp;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Skill Definition Tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('Skill Definitions', () => {
|
||||
it('should have skills with valid categories', () => {
|
||||
const validCategories = ['mana', 'study', 'research', 'ascension', 'enchant', 'effectResearch', 'craft', 'golemancy', 'invocation', 'pact'];
|
||||
Object.values(SKILLS_DEF).forEach(skill => {
|
||||
expect(validCategories).toContain(skill.cat);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have reasonable study times', () => {
|
||||
Object.values(SKILLS_DEF).forEach(skill => {
|
||||
expect(skill.studyTime).toBeGreaterThan(0);
|
||||
expect(skill.studyTime).toBeLessThanOrEqual(72);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prestige Upgrade Tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('Prestige Upgrades', () => {
|
||||
it('should have prestige upgrades with valid costs', () => {
|
||||
Object.values(PRESTIGE_DEF).forEach(def => {
|
||||
expect(def.cost).toBeGreaterThan(0);
|
||||
expect(def.max).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Spell Definition Tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('Spell Definitions', () => {
|
||||
it('should have manaBolt as a basic spell', () => {
|
||||
expect(SPELLS_DEF.manaBolt).toBeDefined();
|
||||
expect(SPELLS_DEF.manaBolt.tier).toBe(0);
|
||||
expect(SPELLS_DEF.manaBolt.cost.type).toBe('raw');
|
||||
});
|
||||
|
||||
it('should have increasing damage for higher tiers', () => {
|
||||
const tier0Avg = Object.values(SPELLS_DEF).filter(s => s.tier === 0).reduce((a, s) => a + s.dmg, 0);
|
||||
const tier1Avg = Object.values(SPELLS_DEF).filter(s => s.tier === 1).reduce((a, s) => a + s.dmg, 0);
|
||||
expect(tier1Avg).toBeGreaterThan(tier0Avg);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ All store tests defined.');
|
||||
268
src/lib/game/stores/combatStore.ts
Executable file
268
src/lib/game/stores/combatStore.ts
Executable file
@@ -0,0 +1,268 @@
|
||||
// ─── Combat Store ─────────────────────────────────────────────────────────────
|
||||
// Handles floors, spells, guardians, combat, and casting
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { SPELLS_DEF, GUARDIANS, HOURS_PER_TICK } from '../constants';
|
||||
import type { GameAction, SpellState } from '../types';
|
||||
import { getFloorMaxHP, getFloorElement, calcDamage } from '../utils';
|
||||
import { usePrestigeStore } from './prestigeStore';
|
||||
|
||||
export interface CombatState {
|
||||
// Floor state
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
|
||||
// Action state
|
||||
activeSpell: string;
|
||||
currentAction: GameAction;
|
||||
castProgress: number;
|
||||
|
||||
// Spells
|
||||
spells: Record<string, SpellState>;
|
||||
|
||||
// Actions
|
||||
setCurrentFloor: (floor: number) => void;
|
||||
advanceFloor: () => void;
|
||||
setFloorHP: (hp: number) => void;
|
||||
setMaxFloorReached: (floor: number) => void;
|
||||
|
||||
setAction: (action: GameAction) => void;
|
||||
setSpell: (spellId: string) => void;
|
||||
setCastProgress: (progress: number) => void;
|
||||
|
||||
// Spells
|
||||
learnSpell: (spellId: string) => void;
|
||||
setSpellState: (spellId: string, state: Partial<SpellState>) => void;
|
||||
|
||||
// Combat tick
|
||||
processCombatTick: (
|
||||
skills: Record<string, number>,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
maxMana: number,
|
||||
attackSpeedMult: number,
|
||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||
) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }>; logMessages: string[] };
|
||||
|
||||
// Reset
|
||||
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
||||
}
|
||||
|
||||
export const useCombatStore = create<CombatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
currentFloor: 1,
|
||||
floorHP: getFloorMaxHP(1),
|
||||
floorMaxHP: getFloorMaxHP(1),
|
||||
maxFloorReached: 1,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells: {
|
||||
manaBolt: { learned: true, level: 1, studyProgress: 0 },
|
||||
},
|
||||
|
||||
setCurrentFloor: (floor: number) => {
|
||||
set({
|
||||
currentFloor: floor,
|
||||
floorHP: getFloorMaxHP(floor),
|
||||
floorMaxHP: getFloorMaxHP(floor),
|
||||
});
|
||||
},
|
||||
|
||||
advanceFloor: () => {
|
||||
set((state) => {
|
||||
const newFloor = Math.min(state.currentFloor + 1, 100);
|
||||
return {
|
||||
currentFloor: newFloor,
|
||||
floorHP: getFloorMaxHP(newFloor),
|
||||
floorMaxHP: getFloorMaxHP(newFloor),
|
||||
maxFloorReached: Math.max(state.maxFloorReached, newFloor),
|
||||
castProgress: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setFloorHP: (hp: number) => {
|
||||
set({ floorHP: Math.max(0, hp) });
|
||||
},
|
||||
|
||||
setMaxFloorReached: (floor: number) => {
|
||||
set((state) => ({
|
||||
maxFloorReached: Math.max(state.maxFloorReached, floor),
|
||||
}));
|
||||
},
|
||||
|
||||
setAction: (action: GameAction) => {
|
||||
set({ currentAction: action });
|
||||
},
|
||||
|
||||
setSpell: (spellId: string) => {
|
||||
const state = get();
|
||||
if (state.spells[spellId]?.learned) {
|
||||
set({ activeSpell: spellId });
|
||||
}
|
||||
},
|
||||
|
||||
setCastProgress: (progress: number) => {
|
||||
set({ castProgress: progress });
|
||||
},
|
||||
|
||||
learnSpell: (spellId: string) => {
|
||||
set((state) => ({
|
||||
spells: {
|
||||
...state.spells,
|
||||
[spellId]: { learned: true, level: 1, studyProgress: 0 },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setSpellState: (spellId: string, spellState: Partial<SpellState>) => {
|
||||
set((state) => ({
|
||||
spells: {
|
||||
...state.spells,
|
||||
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0, studyProgress: 0 }), ...spellState },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
processCombatTick: (
|
||||
skills: Record<string, number>,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
maxMana: number,
|
||||
attackSpeedMult: number,
|
||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||
) => {
|
||||
const state = get();
|
||||
const logMessages: string[] = [];
|
||||
|
||||
if (state.currentAction !== 'climb') {
|
||||
return { rawMana, elements, logMessages };
|
||||
}
|
||||
|
||||
const spellId = state.activeSpell;
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
if (!spellDef) {
|
||||
return { rawMana, elements, logMessages };
|
||||
}
|
||||
|
||||
// Calculate cast speed
|
||||
const baseAttackSpeed = 1 + (skills.quickCast || 0) * 0.05;
|
||||
const totalAttackSpeed = baseAttackSpeed * attackSpeedMult;
|
||||
const spellCastSpeed = spellDef.castSpeed || 1;
|
||||
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
|
||||
|
||||
let castProgress = (state.castProgress || 0) + progressPerTick;
|
||||
let floorHP = state.floorHP;
|
||||
let currentFloor = state.currentFloor;
|
||||
let floorMaxHP = state.floorMaxHP;
|
||||
|
||||
// Process complete casts
|
||||
while (castProgress >= 1) {
|
||||
// Check if we can afford the spell
|
||||
const cost = spellDef.cost;
|
||||
let canCast = false;
|
||||
|
||||
if (cost.type === 'raw') {
|
||||
canCast = rawMana >= cost.amount;
|
||||
if (canCast) rawMana -= cost.amount;
|
||||
} else if (cost.element) {
|
||||
const elem = elements[cost.element];
|
||||
canCast = elem && elem.unlocked && elem.current >= cost.amount;
|
||||
if (canCast) {
|
||||
elements = {
|
||||
...elements,
|
||||
[cost.element]: { ...elem, current: elem.current - cost.amount },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!canCast) break;
|
||||
|
||||
// Calculate damage
|
||||
const floorElement = getFloorElement(currentFloor);
|
||||
const damage = calcDamage(
|
||||
{ skills, signedPacts: usePrestigeStore.getState().signedPacts },
|
||||
spellId,
|
||||
floorElement
|
||||
);
|
||||
|
||||
// Apply damage
|
||||
floorHP = Math.max(0, floorHP - damage);
|
||||
castProgress -= 1;
|
||||
|
||||
// Check if floor is cleared
|
||||
if (floorHP <= 0) {
|
||||
const wasGuardian = GUARDIANS[currentFloor];
|
||||
onFloorCleared(currentFloor, !!wasGuardian);
|
||||
|
||||
currentFloor = Math.min(currentFloor + 1, 100);
|
||||
floorMaxHP = getFloorMaxHP(currentFloor);
|
||||
floorHP = floorMaxHP;
|
||||
castProgress = 0;
|
||||
|
||||
if (wasGuardian) {
|
||||
logMessages.push(`⚔️ ${wasGuardian.name} defeated!`);
|
||||
} else if (currentFloor % 5 === 0) {
|
||||
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
currentFloor,
|
||||
floorHP,
|
||||
floorMaxHP,
|
||||
maxFloorReached: Math.max(state.maxFloorReached, currentFloor),
|
||||
castProgress,
|
||||
});
|
||||
|
||||
return { rawMana, elements, logMessages };
|
||||
},
|
||||
|
||||
resetCombat: (startFloor: number, spellsToKeep: string[] = []) => {
|
||||
const startSpells = makeInitialSpells(spellsToKeep);
|
||||
|
||||
set({
|
||||
currentFloor: startFloor,
|
||||
floorHP: getFloorMaxHP(startFloor),
|
||||
floorMaxHP: getFloorMaxHP(startFloor),
|
||||
maxFloorReached: startFloor,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells: startSpells,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-combat',
|
||||
partialize: (state) => ({
|
||||
currentFloor: state.currentFloor,
|
||||
maxFloorReached: state.maxFloorReached,
|
||||
spells: state.spells,
|
||||
activeSpell: state.activeSpell,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Helper function to create initial spells
|
||||
export function makeInitialSpells(spellsToKeep: string[] = []): Record<string, SpellState> {
|
||||
const startSpells: Record<string, SpellState> = {
|
||||
manaBolt: { learned: true, level: 1, studyProgress: 0 },
|
||||
};
|
||||
|
||||
// Add kept spells
|
||||
for (const spellId of spellsToKeep) {
|
||||
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
|
||||
}
|
||||
|
||||
return startSpells;
|
||||
}
|
||||
509
src/lib/game/stores/gameStore.ts
Executable file
509
src/lib/game/stores/gameStore.ts
Executable file
@@ -0,0 +1,509 @@
|
||||
// ─── Game Store (Coordinator) ─────────────────────────────────────────────────
|
||||
// Manages: day, hour, incursionStrength, containmentWards
|
||||
// Coordinates tick function across all stores
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { TICK_MS, HOURS_PER_TICK, MAX_DAY, SPELLS_DEF, GUARDIANS, ELEMENTS, BASE_UNLOCKED_ELEMENTS, getStudySpeedMultiplier } from '../constants';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
getFloorElement,
|
||||
getFloorMaxHP,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
calcInsight,
|
||||
calcDamage,
|
||||
deductSpellCost,
|
||||
} from '../utils';
|
||||
import { useUIStore } from './uiStore';
|
||||
import { usePrestigeStore } from './prestigeStore';
|
||||
import { useManaStore } from './manaStore';
|
||||
import { useSkillStore } from './skillStore';
|
||||
import { useCombatStore, makeInitialSpells } from './combatStore';
|
||||
import type { Memory } from '../types';
|
||||
|
||||
export interface GameCoordinatorState {
|
||||
day: number;
|
||||
hour: number;
|
||||
incursionStrength: number;
|
||||
containmentWards: number;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export interface GameCoordinatorStore extends GameCoordinatorState {
|
||||
tick: () => void;
|
||||
resetGame: () => void;
|
||||
togglePause: () => void;
|
||||
startNewLoop: () => void;
|
||||
gatherMana: () => void;
|
||||
initGame: () => void;
|
||||
}
|
||||
|
||||
const initialState: GameCoordinatorState = {
|
||||
day: 1,
|
||||
hour: 0,
|
||||
incursionStrength: 0,
|
||||
containmentWards: 0,
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
// Helper function for checking spell cost affordability
|
||||
function canAffordSpell(
|
||||
cost: { type: string; element?: string; amount: number },
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): boolean {
|
||||
if (cost.type === 'raw') {
|
||||
return rawMana >= cost.amount;
|
||||
} else if (cost.element) {
|
||||
const elem = elements[cost.element];
|
||||
return elem && elem.unlocked && elem.current >= cost.amount;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const useGameStore = create<GameCoordinatorStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
initGame: () => {
|
||||
set({ initialized: true });
|
||||
},
|
||||
|
||||
tick: () => {
|
||||
const uiState = useUIStore.getState();
|
||||
if (uiState.gameOver || uiState.paused) return;
|
||||
|
||||
// Helper for logging
|
||||
const addLog = (msg: string) => useUIStore.getState().addLog(msg);
|
||||
|
||||
// Get all store states
|
||||
const prestigeState = usePrestigeStore.getState();
|
||||
const manaState = useManaStore.getState();
|
||||
const skillState = useSkillStore.getState();
|
||||
const combatState = useCombatStore.getState();
|
||||
|
||||
// Compute effects from upgrades
|
||||
const effects = computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {});
|
||||
|
||||
const maxMana = computeMaxMana(
|
||||
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
|
||||
effects
|
||||
);
|
||||
const baseRegen = computeRegen(
|
||||
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
|
||||
effects
|
||||
);
|
||||
|
||||
// Time progression
|
||||
let hour = get().hour + HOURS_PER_TICK;
|
||||
let day = get().day;
|
||||
if (hour >= 24) {
|
||||
hour -= 24;
|
||||
day += 1;
|
||||
}
|
||||
|
||||
// Check for loop end
|
||||
if (day > MAX_DAY) {
|
||||
const insightGained = calcInsight({
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
skills: skillState.skills,
|
||||
});
|
||||
|
||||
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
|
||||
useUIStore.getState().setGameOver(true, false);
|
||||
usePrestigeStore.getState().setLoopInsight(insightGained);
|
||||
set({ day, hour });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for victory
|
||||
if (combatState.maxFloorReached >= 100 && prestigeState.signedPacts.includes(100)) {
|
||||
const insightGained = calcInsight({
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
skills: skillState.skills,
|
||||
}) * 3;
|
||||
|
||||
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
|
||||
useUIStore.getState().setGameOver(true, true);
|
||||
usePrestigeStore.getState().setLoopInsight(insightGained);
|
||||
return;
|
||||
}
|
||||
|
||||
// Incursion
|
||||
const incursionStrength = getIncursionStrength(day, hour);
|
||||
|
||||
// Meditation bonus tracking and regen calculation
|
||||
let meditateTicks = manaState.meditateTicks;
|
||||
let meditationMultiplier = 1;
|
||||
|
||||
if (combatState.currentAction === 'meditate') {
|
||||
meditateTicks++;
|
||||
meditationMultiplier = getMeditationBonus(meditateTicks, skillState.skills, effects.meditationEfficiency);
|
||||
} else {
|
||||
meditateTicks = 0;
|
||||
}
|
||||
|
||||
// Calculate effective regen with incursion and meditation
|
||||
const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
|
||||
|
||||
// Mana regeneration
|
||||
let rawMana = Math.min(manaState.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
||||
let totalManaGathered = manaState.totalManaGathered;
|
||||
let elements = { ...manaState.elements };
|
||||
|
||||
// Study progress - handled by skillStore
|
||||
if (combatState.currentAction === 'study' && skillState.currentStudyTarget) {
|
||||
const studySpeedMult = getStudySpeedMultiplier(skillState.skills);
|
||||
const progressGain = HOURS_PER_TICK * studySpeedMult;
|
||||
|
||||
const result = useSkillStore.getState().updateStudyProgress(progressGain);
|
||||
|
||||
if (result.completed && result.target) {
|
||||
if (result.target.type === 'skill') {
|
||||
const skillId = result.target.id;
|
||||
const currentLevel = skillState.skills[skillId] || 0;
|
||||
// Update skill level
|
||||
useSkillStore.getState().incrementSkillLevel(skillId);
|
||||
useSkillStore.getState().clearPaidStudySkill(skillId);
|
||||
useCombatStore.getState().setAction('meditate');
|
||||
addLog(`✅ ${skillId} Lv.${currentLevel + 1} mastered!`);
|
||||
} else if (result.target.type === 'spell') {
|
||||
const spellId = result.target.id;
|
||||
useCombatStore.getState().learnSpell(spellId);
|
||||
useSkillStore.getState().setCurrentStudyTarget(null);
|
||||
useCombatStore.getState().setAction('meditate');
|
||||
addLog(`📖 ${SPELLS_DEF[spellId]?.name || spellId} learned!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert action - auto convert mana
|
||||
if (combatState.currentAction === 'convert') {
|
||||
const unlockedElements = Object.entries(elements)
|
||||
.filter(([, e]) => e.unlocked && e.current < e.max);
|
||||
|
||||
if (unlockedElements.length > 0 && rawMana >= 100) {
|
||||
unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
|
||||
const [targetId, targetState] = unlockedElements[0];
|
||||
const canConvert = Math.min(
|
||||
Math.floor(rawMana / 100),
|
||||
targetState.max - targetState.current
|
||||
);
|
||||
if (canConvert > 0) {
|
||||
rawMana -= canConvert * 100;
|
||||
elements = {
|
||||
...elements,
|
||||
[targetId]: { ...targetState, current: targetState.current + canConvert }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pact ritual progress
|
||||
if (prestigeState.pactRitualFloor !== null) {
|
||||
const guardian = GUARDIANS[prestigeState.pactRitualFloor];
|
||||
if (guardian) {
|
||||
const pactAffinityBonus = 1 - (prestigeState.prestigeUpgrades.pactAffinity || 0) * 0.1;
|
||||
const requiredTime = guardian.pactTime * pactAffinityBonus;
|
||||
const newProgress = prestigeState.pactRitualProgress + HOURS_PER_TICK;
|
||||
|
||||
if (newProgress >= requiredTime) {
|
||||
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||
usePrestigeStore.getState().addSignedPact(prestigeState.pactRitualFloor);
|
||||
usePrestigeStore.getState().removeDefeatedGuardian(prestigeState.pactRitualFloor);
|
||||
usePrestigeStore.getState().setPactRitualFloor(null);
|
||||
} else {
|
||||
usePrestigeStore.getState().updatePactRitualProgress(newProgress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combat
|
||||
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, castProgress } = combatState;
|
||||
const floorElement = getFloorElement(currentFloor);
|
||||
|
||||
if (combatState.currentAction === 'climb') {
|
||||
const spellId = combatState.activeSpell;
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
|
||||
if (spellDef) {
|
||||
const baseAttackSpeed = 1 + (skillState.skills.quickCast || 0) * 0.05;
|
||||
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
|
||||
const spellCastSpeed = spellDef.castSpeed || 1;
|
||||
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
|
||||
|
||||
castProgress = (castProgress || 0) + progressPerTick;
|
||||
|
||||
// Process complete casts
|
||||
while (castProgress >= 1 && canAffordSpell(spellDef.cost, rawMana, elements)) {
|
||||
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
|
||||
rawMana = afterCost.rawMana;
|
||||
elements = afterCost.elements;
|
||||
totalManaGathered += spellDef.cost.amount;
|
||||
|
||||
// Calculate damage
|
||||
let dmg = calcDamage(
|
||||
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
|
||||
spellId,
|
||||
floorElement
|
||||
);
|
||||
|
||||
// Apply upgrade damage multipliers and bonuses
|
||||
dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus;
|
||||
|
||||
// Executioner: +100% damage to enemies below 25% HP
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && floorHP / floorMaxHP < 0.25) {
|
||||
dmg *= 2;
|
||||
}
|
||||
|
||||
// Berserker: +50% damage when below 50% mana
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
||||
dmg *= 1.5;
|
||||
}
|
||||
|
||||
// Spell echo - chance to cast again
|
||||
const echoChance = (skillState.skills.spellEcho || 0) * 0.1;
|
||||
if (Math.random() < echoChance) {
|
||||
dmg *= 2;
|
||||
addLog(`✨ Spell Echo! Double damage!`);
|
||||
}
|
||||
|
||||
// Apply damage
|
||||
floorHP = Math.max(0, floorHP - dmg);
|
||||
castProgress -= 1;
|
||||
|
||||
if (floorHP <= 0) {
|
||||
// Floor cleared
|
||||
const wasGuardian = GUARDIANS[currentFloor];
|
||||
if (wasGuardian && !prestigeState.defeatedGuardians.includes(currentFloor) && !prestigeState.signedPacts.includes(currentFloor)) {
|
||||
usePrestigeStore.getState().addDefeatedGuardian(currentFloor);
|
||||
addLog(`⚔️ ${wasGuardian.name} defeated! Visit the Grimoire to sign a pact.`);
|
||||
} else if (!wasGuardian) {
|
||||
if (currentFloor % 5 === 0) {
|
||||
addLog(`🏰 Floor ${currentFloor} cleared!`);
|
||||
}
|
||||
}
|
||||
|
||||
currentFloor = currentFloor + 1;
|
||||
if (currentFloor > 100) {
|
||||
currentFloor = 100;
|
||||
}
|
||||
floorMaxHP = getFloorMaxHP(currentFloor);
|
||||
floorHP = floorMaxHP;
|
||||
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
||||
castProgress = 0;
|
||||
|
||||
useCombatStore.getState().advanceFloor();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update all stores with new state
|
||||
useManaStore.setState({
|
||||
rawMana,
|
||||
meditateTicks,
|
||||
totalManaGathered,
|
||||
elements,
|
||||
});
|
||||
|
||||
useCombatStore.setState({
|
||||
floorHP,
|
||||
floorMaxHP,
|
||||
maxFloorReached,
|
||||
castProgress,
|
||||
});
|
||||
|
||||
set({
|
||||
day,
|
||||
hour,
|
||||
incursionStrength,
|
||||
});
|
||||
},
|
||||
|
||||
gatherMana: () => {
|
||||
const skillState = useSkillStore.getState();
|
||||
const manaState = useManaStore.getState();
|
||||
const prestigeState = usePrestigeStore.getState();
|
||||
|
||||
// Compute click mana
|
||||
let cm = 1 +
|
||||
(skillState.skills.manaTap || 0) * 1 +
|
||||
(skillState.skills.manaSurge || 0) * 3;
|
||||
|
||||
// Mana overflow bonus
|
||||
const overflowBonus = 1 + (skillState.skills.manaOverflow || 0) * 0.25;
|
||||
cm = Math.floor(cm * overflowBonus);
|
||||
|
||||
const effects = computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {});
|
||||
const max = computeMaxMana(
|
||||
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
|
||||
effects
|
||||
);
|
||||
|
||||
useManaStore.setState({
|
||||
rawMana: Math.min(manaState.rawMana + cm, max),
|
||||
totalManaGathered: manaState.totalManaGathered + cm,
|
||||
});
|
||||
},
|
||||
|
||||
resetGame: () => {
|
||||
// Clear all persisted state
|
||||
localStorage.removeItem('mana-loop-ui-storage');
|
||||
localStorage.removeItem('mana-loop-prestige-storage');
|
||||
localStorage.removeItem('mana-loop-mana-storage');
|
||||
localStorage.removeItem('mana-loop-skill-storage');
|
||||
localStorage.removeItem('mana-loop-combat-storage');
|
||||
localStorage.removeItem('mana-loop-game-storage');
|
||||
|
||||
const startFloor = 1;
|
||||
const elemMax = 10;
|
||||
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
elements[k] = {
|
||||
current: 0,
|
||||
max: elemMax,
|
||||
unlocked: BASE_UNLOCKED_ELEMENTS.includes(k),
|
||||
};
|
||||
});
|
||||
|
||||
useUIStore.getState().resetUI();
|
||||
usePrestigeStore.getState().resetPrestige();
|
||||
useManaStore.getState().resetMana({}, {}, {}, {});
|
||||
useSkillStore.getState().resetSkills();
|
||||
useCombatStore.getState().resetCombat(startFloor);
|
||||
|
||||
set({
|
||||
...initialState,
|
||||
initialized: true,
|
||||
});
|
||||
},
|
||||
|
||||
togglePause: () => {
|
||||
useUIStore.getState().togglePause();
|
||||
},
|
||||
|
||||
startNewLoop: () => {
|
||||
const prestigeState = usePrestigeStore.getState();
|
||||
const combatState = useCombatStore.getState();
|
||||
const manaState = useManaStore.getState();
|
||||
const skillState = useSkillStore.getState();
|
||||
|
||||
const insightGained = prestigeState.loopInsight || calcInsight({
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
skills: skillState.skills,
|
||||
});
|
||||
|
||||
const total = prestigeState.insight + insightGained;
|
||||
|
||||
// Spell preservation is only through prestige upgrade "spellMemory" (purchased with insight)
|
||||
// Not through a skill - that would undermine the insight economy
|
||||
|
||||
const pu = prestigeState.prestigeUpgrades;
|
||||
const startFloor = 1 + (pu.spireKey || 0) * 2;
|
||||
|
||||
// Apply saved memories - restore skill levels, tiers, and upgrades
|
||||
const memories = prestigeState.memories || [];
|
||||
const newSkills: Record<string, number> = {};
|
||||
const newSkillTiers: Record<string, number> = {};
|
||||
const newSkillUpgrades: Record<string, string[]> = {};
|
||||
|
||||
if (memories.length > 0) {
|
||||
for (const memory of memories) {
|
||||
const tieredSkillId = memory.tier > 1 ? `${memory.skillId}_t${memory.tier}` : memory.skillId;
|
||||
newSkills[tieredSkillId] = memory.level;
|
||||
|
||||
if (memory.tier > 1) {
|
||||
newSkillTiers[memory.skillId] = memory.tier;
|
||||
}
|
||||
|
||||
newSkillUpgrades[tieredSkillId] = memory.upgrades || [];
|
||||
}
|
||||
}
|
||||
|
||||
// Reset and update all stores for new loop
|
||||
useUIStore.setState({
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
paused: false,
|
||||
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
||||
});
|
||||
|
||||
usePrestigeStore.getState().resetPrestigeForNewLoop(
|
||||
total,
|
||||
pu,
|
||||
prestigeState.memories,
|
||||
3 + (pu.deepMemory || 0)
|
||||
);
|
||||
usePrestigeStore.getState().incrementLoopCount();
|
||||
|
||||
useManaStore.getState().resetMana(pu, newSkills, newSkillUpgrades, newSkillTiers);
|
||||
|
||||
useSkillStore.getState().resetSkills(newSkills, newSkillUpgrades, newSkillTiers);
|
||||
|
||||
// Reset combat with starting floor and any spells from prestige upgrades
|
||||
const startSpells = makeInitialSpells();
|
||||
if (pu.spellMemory) {
|
||||
const availableSpells = Object.keys(SPELLS_DEF).filter(s => s !== 'manaBolt');
|
||||
const shuffled = availableSpells.sort(() => Math.random() - 0.5);
|
||||
for (let i = 0; i < Math.min(pu.spellMemory, shuffled.length); i++) {
|
||||
startSpells[shuffled[i]] = { learned: true, level: 1, studyProgress: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
useCombatStore.setState({
|
||||
currentFloor: startFloor,
|
||||
floorHP: getFloorMaxHP(startFloor),
|
||||
floorMaxHP: getFloorMaxHP(startFloor),
|
||||
maxFloorReached: startFloor,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells: startSpells,
|
||||
});
|
||||
|
||||
set({
|
||||
day: 1,
|
||||
hour: 0,
|
||||
incursionStrength: 0,
|
||||
containmentWards: 0,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-game-storage',
|
||||
partialize: (state) => ({
|
||||
day: state.day,
|
||||
hour: state.hour,
|
||||
incursionStrength: state.incursionStrength,
|
||||
containmentWards: state.containmentWards,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Re-export the game loop hook for convenience
|
||||
export function useGameLoop() {
|
||||
const tick = useGameStore((s) => s.tick);
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
const interval = setInterval(tick, TICK_MS);
|
||||
return () => clearInterval(interval);
|
||||
},
|
||||
};
|
||||
}
|
||||
563
src/lib/game/stores/index.test.ts
Executable file
563
src/lib/game/stores/index.test.ts
Executable file
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* Comprehensive Store Tests
|
||||
*
|
||||
* Tests the split store architecture to ensure all stores work correctly together.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeElementMax,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
calcDamage,
|
||||
calcInsight,
|
||||
getMeditationBonus,
|
||||
getFloorMaxHP,
|
||||
getFloorElement,
|
||||
getIncursionStrength,
|
||||
canAffordSpellCost,
|
||||
fmt,
|
||||
fmtDec,
|
||||
} from './index';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
SKILLS_DEF,
|
||||
PRESTIGE_DEF,
|
||||
getStudySpeedMultiplier,
|
||||
getStudyCostMultiplier,
|
||||
HOURS_PER_TICK,
|
||||
MAX_DAY,
|
||||
INCURSION_START_DAY,
|
||||
} from '../constants';
|
||||
import type { GameState, SkillUpgradeChoice } from '../types';
|
||||
|
||||
// ─── Test Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function createMockState(overrides: Partial<GameState> = {}): GameState {
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
elements[k] = { current: 0, max: 10, unlocked: ['fire', 'water', 'air', 'earth'].includes(k) };
|
||||
});
|
||||
|
||||
return {
|
||||
day: 1,
|
||||
hour: 0,
|
||||
loopCount: 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
paused: false,
|
||||
rawMana: 100,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements,
|
||||
currentFloor: 1,
|
||||
floorHP: 100,
|
||||
floorMaxHP: 100,
|
||||
maxFloorReached: 1,
|
||||
defeatedGuardians: [],
|
||||
signedPacts: [],
|
||||
pactSlots: 1,
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
paidStudySkills: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
insight: 0,
|
||||
totalInsight: 0,
|
||||
prestigeUpgrades: {},
|
||||
memorySlots: 3,
|
||||
memories: [],
|
||||
incursionStrength: 0,
|
||||
containmentWards: 0,
|
||||
log: [],
|
||||
loopInsight: 0,
|
||||
equipment: { mainHand: null, offHand: null, head: null, body: null, hands: null, accessory: null },
|
||||
inventory: [],
|
||||
blueprints: {},
|
||||
schedule: [],
|
||||
autoSchedule: false,
|
||||
studyQueue: [],
|
||||
craftQueue: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Utility Function Tests ─────────────────────────────────────────────────
|
||||
|
||||
describe('Utility Functions', () => {
|
||||
describe('fmt', () => {
|
||||
it('should format small numbers', () => {
|
||||
expect(fmt(0)).toBe('0');
|
||||
expect(fmt(1)).toBe('1');
|
||||
expect(fmt(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should format thousands', () => {
|
||||
expect(fmt(1000)).toBe('1.0K');
|
||||
expect(fmt(1500)).toBe('1.5K');
|
||||
});
|
||||
|
||||
it('should format millions', () => {
|
||||
expect(fmt(1000000)).toBe('1.00M');
|
||||
expect(fmt(1500000)).toBe('1.50M');
|
||||
});
|
||||
|
||||
it('should format billions', () => {
|
||||
expect(fmt(1000000000)).toBe('1.00B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtDec', () => {
|
||||
it('should format decimals', () => {
|
||||
expect(fmtDec(1.234, 2)).toBe('1.23');
|
||||
expect(fmtDec(1.5, 1)).toBe('1.5');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mana Calculation Tests ───────────────────────────────────────────────
|
||||
|
||||
describe('Mana Calculations', () => {
|
||||
describe('computeMaxMana', () => {
|
||||
it('should return base mana with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeMaxMana(state)).toBe(100);
|
||||
});
|
||||
|
||||
it('should add mana from manaWell skill', () => {
|
||||
const state = createMockState({ skills: { manaWell: 5 } });
|
||||
expect(computeMaxMana(state)).toBe(100 + 5 * 100);
|
||||
});
|
||||
|
||||
it('should add mana from deepReservoir skill', () => {
|
||||
const state = createMockState({ skills: { deepReservoir: 3 } });
|
||||
expect(computeMaxMana(state)).toBe(100 + 3 * 500);
|
||||
});
|
||||
|
||||
it('should add mana from prestige upgrades', () => {
|
||||
const state = createMockState({ prestigeUpgrades: { manaWell: 3 } });
|
||||
expect(computeMaxMana(state)).toBe(100 + 3 * 500);
|
||||
});
|
||||
|
||||
it('should stack all mana bonuses', () => {
|
||||
const state = createMockState({
|
||||
skills: { manaWell: 5, deepReservoir: 2 },
|
||||
prestigeUpgrades: { manaWell: 2 },
|
||||
});
|
||||
expect(computeMaxMana(state)).toBe(100 + 5 * 100 + 2 * 500 + 2 * 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRegen', () => {
|
||||
it('should return base regen with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeRegen(state)).toBe(2);
|
||||
});
|
||||
|
||||
it('should add regen from manaFlow skill', () => {
|
||||
const state = createMockState({ skills: { manaFlow: 5 } });
|
||||
expect(computeRegen(state)).toBe(2 + 5 * 1);
|
||||
});
|
||||
|
||||
it('should add regen from manaSpring skill', () => {
|
||||
const state = createMockState({ skills: { manaSpring: 1 } });
|
||||
expect(computeRegen(state)).toBe(2 + 2);
|
||||
});
|
||||
|
||||
it('should multiply by temporal echo prestige', () => {
|
||||
const state = createMockState({ prestigeUpgrades: { temporalEcho: 2 } });
|
||||
expect(computeRegen(state)).toBe(2 * 1.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeClickMana', () => {
|
||||
it('should return base click mana with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeClickMana(state)).toBe(1);
|
||||
});
|
||||
|
||||
it('should add mana from manaTap skill', () => {
|
||||
const state = createMockState({ skills: { manaTap: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 1);
|
||||
});
|
||||
|
||||
it('should add mana from manaSurge skill', () => {
|
||||
const state = createMockState({ skills: { manaSurge: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 3);
|
||||
});
|
||||
|
||||
it('should stack manaTap and manaSurge', () => {
|
||||
const state = createMockState({ skills: { manaTap: 1, manaSurge: 1 } });
|
||||
expect(computeClickMana(state)).toBe(1 + 1 + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeElementMax', () => {
|
||||
it('should return base element cap with no upgrades', () => {
|
||||
const state = createMockState();
|
||||
expect(computeElementMax(state)).toBe(10);
|
||||
});
|
||||
|
||||
it('should add cap from elemAttune skill', () => {
|
||||
const state = createMockState({ skills: { elemAttune: 5 } });
|
||||
expect(computeElementMax(state)).toBe(10 + 5 * 50);
|
||||
});
|
||||
|
||||
it('should add cap from prestige upgrades', () => {
|
||||
const state = createMockState({ prestigeUpgrades: { elementalAttune: 3 } });
|
||||
expect(computeElementMax(state)).toBe(10 + 3 * 25);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Combat Calculation Tests ─────────────────────────────────────────────
|
||||
|
||||
describe('Combat Calculations', () => {
|
||||
describe('calcDamage', () => {
|
||||
it('should return spell base damage with no bonuses', () => {
|
||||
const state = createMockState();
|
||||
const dmg = calcDamage(state, 'manaBolt');
|
||||
expect(dmg).toBeGreaterThanOrEqual(5); // Base damage (can be higher with crit)
|
||||
});
|
||||
|
||||
it('should add damage from combatTrain skill', () => {
|
||||
const state = createMockState({ skills: { combatTrain: 5 } });
|
||||
const dmg = calcDamage(state, 'manaBolt');
|
||||
expect(dmg).toBeGreaterThanOrEqual(5 + 5 * 5); // 5 base + 25 from skill
|
||||
});
|
||||
|
||||
it('should multiply by arcaneFury skill', () => {
|
||||
const state = createMockState({ skills: { arcaneFury: 3 } });
|
||||
const dmg = calcDamage(state, 'manaBolt');
|
||||
// 5 * 1.3 = 6.5 minimum (without crit)
|
||||
expect(dmg).toBeGreaterThanOrEqual(5 * 1.3 * 0.8);
|
||||
});
|
||||
|
||||
it('should have elemental bonuses', () => {
|
||||
const state = createMockState({
|
||||
spells: {
|
||||
manaBolt: { learned: true, level: 1 },
|
||||
fireball: { learned: true, level: 1 },
|
||||
waterJet: { learned: true, level: 1 },
|
||||
}
|
||||
});
|
||||
// Test elemental bonus by comparing same spell vs different elements
|
||||
// Fireball vs fire floor (same element, +25%) vs vs air floor (neutral)
|
||||
let fireVsFire = 0, fireVsAir = 0;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
fireVsFire += calcDamage(state, 'fireball', 'fire');
|
||||
fireVsAir += calcDamage(state, 'fireball', 'air');
|
||||
}
|
||||
const sameAvg = fireVsFire / 100;
|
||||
const neutralAvg = fireVsAir / 100;
|
||||
// Same element should do more damage
|
||||
expect(sameAvg).toBeGreaterThan(neutralAvg * 1.1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFloorMaxHP', () => {
|
||||
it('should return guardian HP for guardian floors', () => {
|
||||
expect(getFloorMaxHP(10)).toBe(GUARDIANS[10].hp);
|
||||
expect(getFloorMaxHP(100)).toBe(GUARDIANS[100].hp);
|
||||
});
|
||||
|
||||
it('should scale HP for non-guardian floors', () => {
|
||||
expect(getFloorMaxHP(1)).toBeGreaterThan(0);
|
||||
expect(getFloorMaxHP(5)).toBeGreaterThan(getFloorMaxHP(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFloorElement', () => {
|
||||
it('should cycle through elements in order', () => {
|
||||
expect(getFloorElement(1)).toBe('fire');
|
||||
expect(getFloorElement(2)).toBe('water');
|
||||
expect(getFloorElement(3)).toBe('air');
|
||||
expect(getFloorElement(4)).toBe('earth');
|
||||
});
|
||||
|
||||
it('should wrap around after 8 floors', () => {
|
||||
expect(getFloorElement(9)).toBe('fire');
|
||||
expect(getFloorElement(10)).toBe('water');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Study Speed Tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('Study Speed Functions', () => {
|
||||
describe('getStudySpeedMultiplier', () => {
|
||||
it('should return 1 with no quickLearner skill', () => {
|
||||
expect(getStudySpeedMultiplier({})).toBe(1);
|
||||
});
|
||||
|
||||
it('should increase by 10% per level', () => {
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 1 })).toBe(1.1);
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 5 })).toBe(1.5);
|
||||
expect(getStudySpeedMultiplier({ quickLearner: 10 })).toBe(2.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStudyCostMultiplier', () => {
|
||||
it('should return 1 with no focusedMind skill', () => {
|
||||
expect(getStudyCostMultiplier({})).toBe(1);
|
||||
});
|
||||
|
||||
it('should decrease by 5% per level', () => {
|
||||
expect(getStudyCostMultiplier({ focusedMind: 1 })).toBe(0.95);
|
||||
expect(getStudyCostMultiplier({ focusedMind: 5 })).toBe(0.75);
|
||||
expect(getStudyCostMultiplier({ focusedMind: 10 })).toBe(0.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Meditation Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Meditation Bonus', () => {
|
||||
describe('getMeditationBonus', () => {
|
||||
it('should start at 1x with no meditation', () => {
|
||||
expect(getMeditationBonus(0, {})).toBe(1);
|
||||
});
|
||||
|
||||
it('should ramp up over time', () => {
|
||||
const bonus1hr = getMeditationBonus(25, {}); // 1 hour of ticks
|
||||
expect(bonus1hr).toBeGreaterThan(1);
|
||||
|
||||
const bonus4hr = getMeditationBonus(100, {}); // 4 hours
|
||||
expect(bonus4hr).toBeGreaterThan(bonus1hr);
|
||||
});
|
||||
|
||||
it('should cap at 1.5x without meditation skill', () => {
|
||||
const bonus = getMeditationBonus(200, {}); // 8 hours
|
||||
expect(bonus).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should give 2.5x with meditation skill after 4 hours', () => {
|
||||
const bonus = getMeditationBonus(100, { meditation: 1 });
|
||||
expect(bonus).toBe(2.5);
|
||||
});
|
||||
|
||||
it('should give 3.0x with deepTrance skill after 6 hours', () => {
|
||||
const bonus = getMeditationBonus(150, { meditation: 1, deepTrance: 1 });
|
||||
expect(bonus).toBe(3.0);
|
||||
});
|
||||
|
||||
it('should give 5.0x with voidMeditation skill after 8 hours', () => {
|
||||
const bonus = getMeditationBonus(200, { meditation: 1, deepTrance: 1, voidMeditation: 1 });
|
||||
expect(bonus).toBe(5.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Insight Tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Insight Calculations', () => {
|
||||
describe('calcInsight', () => {
|
||||
it('should calculate insight from floor progress', () => {
|
||||
const state = createMockState({ maxFloorReached: 10 });
|
||||
const insight = calcInsight(state);
|
||||
expect(insight).toBe(10 * 15);
|
||||
});
|
||||
|
||||
it('should calculate insight from mana gathered', () => {
|
||||
const state = createMockState({ totalManaGathered: 5000 });
|
||||
const insight = calcInsight(state);
|
||||
// 1*15 + 5000/500 + 0 = 25
|
||||
expect(insight).toBe(25);
|
||||
});
|
||||
|
||||
it('should calculate insight from signed pacts', () => {
|
||||
const state = createMockState({ signedPacts: [10, 20] });
|
||||
const insight = calcInsight(state);
|
||||
// 1*15 + 0 + 2*150 = 315
|
||||
expect(insight).toBe(315);
|
||||
});
|
||||
|
||||
it('should multiply by insightAmp prestige', () => {
|
||||
const state = createMockState({
|
||||
maxFloorReached: 10,
|
||||
prestigeUpgrades: { insightAmp: 2 },
|
||||
});
|
||||
const insight = calcInsight(state);
|
||||
expect(insight).toBe(Math.floor(10 * 15 * 1.5));
|
||||
});
|
||||
|
||||
it('should multiply by insightHarvest skill', () => {
|
||||
const state = createMockState({
|
||||
maxFloorReached: 10,
|
||||
skills: { insightHarvest: 3 },
|
||||
});
|
||||
const insight = calcInsight(state);
|
||||
expect(insight).toBe(Math.floor(10 * 15 * 1.3));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Incursion Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Incursion Strength', () => {
|
||||
describe('getIncursionStrength', () => {
|
||||
it('should be 0 before incursion start day', () => {
|
||||
expect(getIncursionStrength(19, 0)).toBe(0);
|
||||
expect(getIncursionStrength(19, 23)).toBe(0);
|
||||
});
|
||||
|
||||
it('should start at incursion start day', () => {
|
||||
expect(getIncursionStrength(INCURSION_START_DAY, 1)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should increase over time', () => {
|
||||
const early = getIncursionStrength(INCURSION_START_DAY, 12);
|
||||
const late = getIncursionStrength(25, 12);
|
||||
expect(late).toBeGreaterThan(early);
|
||||
});
|
||||
|
||||
it('should cap at 95%', () => {
|
||||
const strength = getIncursionStrength(MAX_DAY, 23);
|
||||
expect(strength).toBeLessThanOrEqual(0.95);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Spell Cost Tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('Spell Cost System', () => {
|
||||
describe('canAffordSpellCost', () => {
|
||||
it('should allow raw mana costs when enough raw mana', () => {
|
||||
const cost = { type: 'raw' as const, amount: 10 };
|
||||
const elements = { fire: { current: 0, max: 10, unlocked: true } };
|
||||
expect(canAffordSpellCost(cost, 100, elements)).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny raw mana costs when not enough raw mana', () => {
|
||||
const cost = { type: 'raw' as const, amount: 100 };
|
||||
const elements = { fire: { current: 0, max: 10, unlocked: true } };
|
||||
expect(canAffordSpellCost(cost, 50, elements)).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow elemental costs when enough element mana', () => {
|
||||
const cost = { type: 'element' as const, element: 'fire', amount: 5 };
|
||||
const elements = { fire: { current: 10, max: 10, unlocked: true } };
|
||||
expect(canAffordSpellCost(cost, 0, elements)).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny elemental costs when element not unlocked', () => {
|
||||
const cost = { type: 'element' as const, element: 'fire', amount: 5 };
|
||||
const elements = { fire: { current: 10, max: 10, unlocked: false } };
|
||||
expect(canAffordSpellCost(cost, 100, elements)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Skill Definition Tests ──────────────────────────────────────────────
|
||||
|
||||
describe('Skill Definitions', () => {
|
||||
it('all skills should have valid categories', () => {
|
||||
const validCategories = ['mana', 'combat', 'study', 'craft', 'research', 'ascension'];
|
||||
Object.values(SKILLS_DEF).forEach(skill => {
|
||||
expect(validCategories).toContain(skill.cat);
|
||||
});
|
||||
});
|
||||
|
||||
it('all skills should have reasonable study times', () => {
|
||||
Object.values(SKILLS_DEF).forEach(skill => {
|
||||
expect(skill.studyTime).toBeGreaterThan(0);
|
||||
expect(skill.studyTime).toBeLessThanOrEqual(72);
|
||||
});
|
||||
});
|
||||
|
||||
it('all prerequisite skills should exist', () => {
|
||||
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
|
||||
if (skill.req) {
|
||||
Object.keys(skill.req).forEach(reqId => {
|
||||
expect(SKILLS_DEF[reqId]).toBeDefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('all prerequisite levels should be within skill max', () => {
|
||||
Object.entries(SKILLS_DEF).forEach(([id, skill]) => {
|
||||
if (skill.req) {
|
||||
Object.entries(skill.req).forEach(([reqId, reqLevel]) => {
|
||||
expect(reqLevel).toBeLessThanOrEqual(SKILLS_DEF[reqId].max);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prestige Upgrade Tests ──────────────────────────────────────────────
|
||||
|
||||
describe('Prestige Upgrades', () => {
|
||||
it('all prestige upgrades should have valid costs', () => {
|
||||
Object.entries(PRESTIGE_DEF).forEach(([id, upgrade]) => {
|
||||
expect(upgrade.cost).toBeGreaterThan(0);
|
||||
expect(upgrade.max).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('Mana Well prestige should add 500 starting max mana', () => {
|
||||
const state0 = createMockState({ prestigeUpgrades: { manaWell: 0 } });
|
||||
const state1 = createMockState({ prestigeUpgrades: { manaWell: 1 } });
|
||||
const state5 = createMockState({ prestigeUpgrades: { manaWell: 5 } });
|
||||
|
||||
expect(computeMaxMana(state0)).toBe(100);
|
||||
expect(computeMaxMana(state1)).toBe(100 + 500);
|
||||
expect(computeMaxMana(state5)).toBe(100 + 2500);
|
||||
});
|
||||
|
||||
it('Elemental Attunement prestige should add 25 element cap', () => {
|
||||
const state0 = createMockState({ prestigeUpgrades: { elementalAttune: 0 } });
|
||||
const state1 = createMockState({ prestigeUpgrades: { elementalAttune: 1 } });
|
||||
const state10 = createMockState({ prestigeUpgrades: { elementalAttune: 10 } });
|
||||
|
||||
expect(computeElementMax(state0)).toBe(10);
|
||||
expect(computeElementMax(state1)).toBe(10 + 25);
|
||||
expect(computeElementMax(state10)).toBe(10 + 250);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Guardian Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Guardian Definitions', () => {
|
||||
it('should have guardians every 10 floors', () => {
|
||||
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].forEach(floor => {
|
||||
expect(GUARDIANS[floor]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have increasing HP', () => {
|
||||
let prevHP = 0;
|
||||
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].forEach(floor => {
|
||||
expect(GUARDIANS[floor].hp).toBeGreaterThan(prevHP);
|
||||
prevHP = GUARDIANS[floor].hp;
|
||||
});
|
||||
});
|
||||
|
||||
it('should have boons defined', () => {
|
||||
Object.values(GUARDIANS).forEach(guardian => {
|
||||
expect(guardian.boons).toBeDefined();
|
||||
expect(guardian.boons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have pact costs defined', () => {
|
||||
Object.values(GUARDIANS).forEach(guardian => {
|
||||
expect(guardian.pactCost).toBeGreaterThan(0);
|
||||
expect(guardian.pactTime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ All store tests defined.');
|
||||
42
src/lib/game/stores/index.ts
Executable file
42
src/lib/game/stores/index.ts
Executable file
@@ -0,0 +1,42 @@
|
||||
// ─── Store Index ──────────────────────────────────────────────────────────────
|
||||
// Exports all stores and re-exports commonly used utilities
|
||||
|
||||
// Stores
|
||||
export { useUIStore } from './uiStore';
|
||||
export type { UIState } from './uiStore';
|
||||
|
||||
export { usePrestigeStore } from './prestigeStore';
|
||||
export type { PrestigeState } from './prestigeStore';
|
||||
|
||||
export { useManaStore, makeInitialElements } from './manaStore';
|
||||
export type { ManaState } from './manaStore';
|
||||
|
||||
export { useSkillStore } from './skillStore';
|
||||
export type { SkillState } from './skillStore';
|
||||
|
||||
export { useCombatStore, makeInitialSpells } from './combatStore';
|
||||
export type { CombatState } from './combatStore';
|
||||
|
||||
export { useGameStore, useGameLoop } from './gameStore';
|
||||
export type { GameCoordinatorState, GameCoordinatorStore } from './gameStore';
|
||||
|
||||
// Re-export utilities from utils.ts
|
||||
export {
|
||||
fmt,
|
||||
fmtDec,
|
||||
getFloorMaxHP,
|
||||
getFloorElement,
|
||||
computeMaxMana,
|
||||
computeElementMax,
|
||||
computeRegen,
|
||||
computeEffectiveRegen,
|
||||
computeClickMana,
|
||||
getElementalBonus,
|
||||
getBoonBonuses,
|
||||
calcDamage,
|
||||
calcInsight,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
canAffordSpellCost,
|
||||
deductSpellCost,
|
||||
} from '../utils';
|
||||
264
src/lib/game/stores/manaStore.ts
Executable file
264
src/lib/game/stores/manaStore.ts
Executable file
@@ -0,0 +1,264 @@
|
||||
// ─── Mana Store ───────────────────────────────────────────────────────────────
|
||||
// Handles raw mana, elements, meditation, and mana regeneration
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ELEMENTS, MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants';
|
||||
import type { ElementState } from '../types';
|
||||
|
||||
export interface ManaState {
|
||||
rawMana: number;
|
||||
meditateTicks: number;
|
||||
totalManaGathered: number;
|
||||
elements: Record<string, ElementState>;
|
||||
|
||||
// Actions
|
||||
setRawMana: (amount: number) => void;
|
||||
addRawMana: (amount: number, maxMana: number) => void;
|
||||
spendRawMana: (amount: number) => boolean;
|
||||
gatherMana: (amount: number, maxMana: number) => void;
|
||||
|
||||
// Meditation
|
||||
setMeditateTicks: (ticks: number) => void;
|
||||
incrementMeditateTicks: () => void;
|
||||
resetMeditateTicks: () => void;
|
||||
|
||||
// Elements
|
||||
convertMana: (element: string, amount: number) => boolean;
|
||||
unlockElement: (element: string, cost: number) => boolean;
|
||||
addElementMana: (element: string, amount: number, max: number) => void;
|
||||
spendElementMana: (element: string, amount: number) => boolean;
|
||||
setElementMax: (max: number) => void;
|
||||
craftComposite: (target: string, recipe: string[]) => boolean;
|
||||
|
||||
// Reset
|
||||
resetMana: (
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
skills?: Record<string, number>,
|
||||
skillUpgrades?: Record<string, string[]>,
|
||||
skillTiers?: Record<string, number>
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const useManaStore = create<ManaState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
rawMana: 10,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements: Object.fromEntries(
|
||||
Object.keys(ELEMENTS).map(k => [
|
||||
k,
|
||||
{
|
||||
current: 0,
|
||||
max: 10,
|
||||
unlocked: BASE_UNLOCKED_ELEMENTS.includes(k),
|
||||
}
|
||||
])
|
||||
) as Record<string, ElementState>,
|
||||
|
||||
setRawMana: (amount: number) => {
|
||||
set({ rawMana: Math.max(0, amount) });
|
||||
},
|
||||
|
||||
addRawMana: (amount: number, maxMana: number) => {
|
||||
set((state) => ({
|
||||
rawMana: Math.min(state.rawMana + amount, maxMana),
|
||||
totalManaGathered: state.totalManaGathered + amount,
|
||||
}));
|
||||
},
|
||||
|
||||
spendRawMana: (amount: number) => {
|
||||
const state = get();
|
||||
if (state.rawMana < amount) return false;
|
||||
|
||||
set({ rawMana: state.rawMana - amount });
|
||||
return true;
|
||||
},
|
||||
|
||||
gatherMana: (amount: number, maxMana: number) => {
|
||||
set((state) => ({
|
||||
rawMana: Math.min(state.rawMana + amount, maxMana),
|
||||
totalManaGathered: state.totalManaGathered + amount,
|
||||
}));
|
||||
},
|
||||
|
||||
setMeditateTicks: (ticks: number) => {
|
||||
set({ meditateTicks: ticks });
|
||||
},
|
||||
|
||||
incrementMeditateTicks: () => {
|
||||
set((state) => ({ meditateTicks: state.meditateTicks + 1 }));
|
||||
},
|
||||
|
||||
resetMeditateTicks: () => {
|
||||
set({ meditateTicks: 0 });
|
||||
},
|
||||
|
||||
convertMana: (element: string, amount: number) => {
|
||||
const state = get();
|
||||
const elem = state.elements[element];
|
||||
if (!elem?.unlocked) return false;
|
||||
|
||||
const cost = MANA_PER_ELEMENT * amount;
|
||||
if (state.rawMana < cost) return false;
|
||||
if (elem.current >= elem.max) return false;
|
||||
|
||||
const canConvert = Math.min(
|
||||
amount,
|
||||
Math.floor(state.rawMana / MANA_PER_ELEMENT),
|
||||
elem.max - elem.current
|
||||
);
|
||||
|
||||
if (canConvert <= 0) return false;
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...elem, current: elem.current + canConvert },
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
unlockElement: (element: string, cost: number) => {
|
||||
const state = get();
|
||||
if (state.elements[element]?.unlocked) return false;
|
||||
if (state.rawMana < cost) return false;
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - cost,
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...state.elements[element], unlocked: true },
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
addElementMana: (element: string, amount: number, max: number) => {
|
||||
set((state) => {
|
||||
const elem = state.elements[element];
|
||||
if (!elem) return state;
|
||||
|
||||
return {
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: {
|
||||
...elem,
|
||||
current: Math.min(elem.current + amount, max),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
spendElementMana: (element: string, amount: number) => {
|
||||
const state = get();
|
||||
const elem = state.elements[element];
|
||||
if (!elem || elem.current < amount) return false;
|
||||
|
||||
set({
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...elem, current: elem.current - amount },
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
setElementMax: (max: number) => {
|
||||
set((state) => ({
|
||||
elements: Object.fromEntries(
|
||||
Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])
|
||||
) as Record<string, ElementState>,
|
||||
}));
|
||||
},
|
||||
|
||||
craftComposite: (target: string, recipe: string[]) => {
|
||||
const state = get();
|
||||
|
||||
// Count required ingredients
|
||||
const costs: Record<string, number> = {};
|
||||
recipe.forEach(r => {
|
||||
costs[r] = (costs[r] || 0) + 1;
|
||||
});
|
||||
|
||||
// Check if we have all ingredients
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
if ((state.elements[r]?.current || 0) < amt) return false;
|
||||
}
|
||||
|
||||
// Deduct ingredients
|
||||
const newElems = { ...state.elements };
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
newElems[r] = {
|
||||
...newElems[r],
|
||||
current: newElems[r].current - amt,
|
||||
};
|
||||
}
|
||||
|
||||
// Add crafted element
|
||||
const targetElem = newElems[target];
|
||||
newElems[target] = {
|
||||
...(targetElem || { current: 0, max: 10, unlocked: false }),
|
||||
current: (targetElem?.current || 0) + 1,
|
||||
unlocked: true,
|
||||
};
|
||||
|
||||
set({ elements: newElems });
|
||||
return true;
|
||||
},
|
||||
|
||||
resetMana: (
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
skills: Record<string, number> = {},
|
||||
skillUpgrades: Record<string, string[]> = {},
|
||||
skillTiers: Record<string, number> = {}
|
||||
) => {
|
||||
const elementMax = 10 + (prestigeUpgrades.elemMax || 0) * 5;
|
||||
const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10;
|
||||
const elements = makeInitialElements(elementMax, prestigeUpgrades);
|
||||
|
||||
set({
|
||||
rawMana: startingMana,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-mana',
|
||||
partialize: (state) => ({
|
||||
rawMana: state.rawMana,
|
||||
totalManaGathered: state.totalManaGathered,
|
||||
elements: state.elements,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Helper function to create initial elements
|
||||
export function makeInitialElements(
|
||||
elementMax: number,
|
||||
prestigeUpgrades: Record<string, number> = {}
|
||||
): Record<string, ElementState> {
|
||||
const elemStart = (prestigeUpgrades.elemStart || 0) * 5;
|
||||
|
||||
const elements: Record<string, ElementState> = {};
|
||||
Object.keys(ELEMENTS).forEach(k => {
|
||||
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
|
||||
elements[k] = {
|
||||
current: isUnlocked ? elemStart : 0,
|
||||
max: elementMax,
|
||||
unlocked: isUnlocked,
|
||||
};
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
266
src/lib/game/stores/prestigeStore.ts
Executable file
266
src/lib/game/stores/prestigeStore.ts
Executable file
@@ -0,0 +1,266 @@
|
||||
// ─── Prestige Store ───────────────────────────────────────────────────────────
|
||||
// Handles insight, prestige upgrades, memories, loops, pacts
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Memory } from '../types';
|
||||
import { GUARDIANS, PRESTIGE_DEF } from '../constants';
|
||||
|
||||
export interface PrestigeState {
|
||||
// Loop counter
|
||||
loopCount: number;
|
||||
|
||||
// Insight
|
||||
insight: number;
|
||||
totalInsight: number;
|
||||
loopInsight: number; // Insight earned at end of current loop
|
||||
|
||||
// Prestige upgrades
|
||||
prestigeUpgrades: Record<string, number>;
|
||||
memorySlots: number;
|
||||
pactSlots: number;
|
||||
|
||||
// Memories (skills preserved across loops)
|
||||
memories: Memory[];
|
||||
|
||||
// Guardian pacts
|
||||
defeatedGuardians: number[];
|
||||
signedPacts: number[];
|
||||
pactRitualFloor: number | null;
|
||||
pactRitualProgress: number;
|
||||
|
||||
// Actions
|
||||
doPrestige: (id: string) => void;
|
||||
addMemory: (memory: Memory) => void;
|
||||
removeMemory: (skillId: string) => void;
|
||||
clearMemories: () => void;
|
||||
startPactRitual: (floor: number, rawMana: number) => boolean;
|
||||
cancelPactRitual: () => void;
|
||||
completePactRitual: (addLog: (msg: string) => void) => void;
|
||||
updatePactRitualProgress: (hours: number) => void;
|
||||
removePact: (floor: number) => void;
|
||||
defeatGuardian: (floor: number) => void;
|
||||
|
||||
// Methods called by gameStore
|
||||
addSignedPact: (floor: number) => void;
|
||||
removeDefeatedGuardian: (floor: number) => void;
|
||||
setPactRitualFloor: (floor: number | null) => void;
|
||||
addDefeatedGuardian: (floor: number) => void;
|
||||
incrementLoopCount: () => void;
|
||||
resetPrestigeForNewLoop: (
|
||||
totalInsight: number,
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
memories: Memory[],
|
||||
memorySlots: number
|
||||
) => void;
|
||||
|
||||
// Loop management
|
||||
startNewLoop: (insightGained: number) => void;
|
||||
setLoopInsight: (insight: number) => void;
|
||||
|
||||
// Reset
|
||||
resetPrestige: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
loopCount: 0,
|
||||
insight: 0,
|
||||
totalInsight: 0,
|
||||
loopInsight: 0,
|
||||
prestigeUpgrades: {} as Record<string, number>,
|
||||
memorySlots: 3,
|
||||
pactSlots: 1,
|
||||
memories: [] as Memory[],
|
||||
defeatedGuardians: [] as number[],
|
||||
signedPacts: [] as number[],
|
||||
pactRitualFloor: null as number | null,
|
||||
pactRitualProgress: 0,
|
||||
};
|
||||
|
||||
export const usePrestigeStore = create<PrestigeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
doPrestige: (id: string) => {
|
||||
const state = get();
|
||||
const pd = PRESTIGE_DEF[id];
|
||||
if (!pd) return false;
|
||||
|
||||
const lvl = state.prestigeUpgrades[id] || 0;
|
||||
if (lvl >= pd.max || state.insight < pd.cost) return false;
|
||||
|
||||
const newPU = { ...state.prestigeUpgrades, [id]: lvl + 1 };
|
||||
set({
|
||||
insight: state.insight - pd.cost,
|
||||
prestigeUpgrades: newPU,
|
||||
memorySlots: id === 'deepMemory' ? state.memorySlots + 1 : state.memorySlots,
|
||||
pactSlots: id === 'pactBinding' ? state.pactSlots + 1 : state.pactSlots,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
addMemory: (memory: Memory) => {
|
||||
const state = get();
|
||||
if (state.memories.length >= state.memorySlots) return;
|
||||
if (state.memories.some(m => m.skillId === memory.skillId)) return;
|
||||
|
||||
set({ memories: [...state.memories, memory] });
|
||||
},
|
||||
|
||||
removeMemory: (skillId: string) => {
|
||||
set((state) => ({
|
||||
memories: state.memories.filter(m => m.skillId !== skillId),
|
||||
}));
|
||||
},
|
||||
|
||||
clearMemories: () => {
|
||||
set({ memories: [] });
|
||||
},
|
||||
|
||||
startPactRitual: (floor: number, rawMana: number) => {
|
||||
const state = get();
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return false;
|
||||
|
||||
if (!state.defeatedGuardians.includes(floor)) return false;
|
||||
if (state.signedPacts.includes(floor)) return false;
|
||||
if (state.signedPacts.length >= state.pactSlots) return false;
|
||||
if (rawMana < guardian.pactCost) return false;
|
||||
if (state.pactRitualFloor !== null) return false;
|
||||
|
||||
set({
|
||||
pactRitualFloor: floor,
|
||||
pactRitualProgress: 0,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
cancelPactRitual: () => {
|
||||
set({
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
});
|
||||
},
|
||||
|
||||
completePactRitual: (addLog: (msg: string) => void) => {
|
||||
const state = get();
|
||||
if (state.pactRitualFloor === null) return;
|
||||
|
||||
const guardian = GUARDIANS[state.pactRitualFloor];
|
||||
if (!guardian) return;
|
||||
|
||||
set({
|
||||
signedPacts: [...state.signedPacts, state.pactRitualFloor],
|
||||
defeatedGuardians: state.defeatedGuardians.filter(f => f !== state.pactRitualFloor),
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
});
|
||||
|
||||
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||
},
|
||||
|
||||
updatePactRitualProgress: (hours: number) => {
|
||||
set((state) => ({
|
||||
pactRitualProgress: state.pactRitualProgress + hours,
|
||||
}));
|
||||
},
|
||||
|
||||
removePact: (floor: number) => {
|
||||
set((state) => ({
|
||||
signedPacts: state.signedPacts.filter(f => f !== floor),
|
||||
}));
|
||||
},
|
||||
|
||||
defeatGuardian: (floor: number) => {
|
||||
const state = get();
|
||||
if (state.defeatedGuardians.includes(floor) || state.signedPacts.includes(floor)) return;
|
||||
|
||||
set({
|
||||
defeatedGuardians: [...state.defeatedGuardians, floor],
|
||||
});
|
||||
},
|
||||
|
||||
addSignedPact: (floor: number) => {
|
||||
const state = get();
|
||||
if (state.signedPacts.includes(floor)) return;
|
||||
set({ signedPacts: [...state.signedPacts, floor] });
|
||||
},
|
||||
|
||||
removeDefeatedGuardian: (floor: number) => {
|
||||
set((state) => ({
|
||||
defeatedGuardians: state.defeatedGuardians.filter(f => f !== floor),
|
||||
}));
|
||||
},
|
||||
|
||||
setPactRitualFloor: (floor: number | null) => {
|
||||
set({ pactRitualFloor: floor, pactRitualProgress: 0 });
|
||||
},
|
||||
|
||||
addDefeatedGuardian: (floor: number) => {
|
||||
const state = get();
|
||||
if (state.defeatedGuardians.includes(floor) || state.signedPacts.includes(floor)) return;
|
||||
set({ defeatedGuardians: [...state.defeatedGuardians, floor] });
|
||||
},
|
||||
|
||||
incrementLoopCount: () => {
|
||||
set((state) => ({ loopCount: state.loopCount + 1 }));
|
||||
},
|
||||
|
||||
resetPrestigeForNewLoop: (
|
||||
totalInsight: number,
|
||||
prestigeUpgrades: Record<string, number>,
|
||||
memories: Memory[],
|
||||
memorySlots: number
|
||||
) => {
|
||||
set({
|
||||
insight: totalInsight,
|
||||
prestigeUpgrades,
|
||||
memories,
|
||||
memorySlots,
|
||||
// Reset loop-specific state
|
||||
defeatedGuardians: [],
|
||||
signedPacts: [],
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
loopInsight: 0,
|
||||
});
|
||||
},
|
||||
|
||||
startNewLoop: (insightGained: number) => {
|
||||
const state = get();
|
||||
set({
|
||||
loopCount: state.loopCount + 1,
|
||||
insight: state.insight + insightGained,
|
||||
totalInsight: state.totalInsight + insightGained,
|
||||
loopInsight: 0,
|
||||
// Reset loop-specific state
|
||||
defeatedGuardians: [],
|
||||
signedPacts: [],
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
});
|
||||
},
|
||||
|
||||
setLoopInsight: (insight: number) => {
|
||||
set({ loopInsight: insight });
|
||||
},
|
||||
|
||||
resetPrestige: () => {
|
||||
set(initialState);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-prestige',
|
||||
partialize: (state) => ({
|
||||
loopCount: state.loopCount,
|
||||
insight: state.insight,
|
||||
totalInsight: state.totalInsight,
|
||||
prestigeUpgrades: state.prestigeUpgrades,
|
||||
memorySlots: state.memorySlots,
|
||||
pactSlots: state.pactSlots,
|
||||
memories: state.memories,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
332
src/lib/game/stores/skillStore.ts
Executable file
332
src/lib/game/stores/skillStore.ts
Executable file
@@ -0,0 +1,332 @@
|
||||
// ─── Skill Store ──────────────────────────────────────────────────────────────
|
||||
// Handles skills, upgrades, tiers, and study progress
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { SKILLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '../constants';
|
||||
import type { StudyTarget, SkillUpgradeChoice } from '../types';
|
||||
import { SKILL_EVOLUTION_PATHS, getBaseSkillId } from '../skill-evolution';
|
||||
|
||||
export interface SkillState {
|
||||
// Skills
|
||||
skills: Record<string, number>;
|
||||
skillProgress: Record<string, number>; // Saved study progress for skills
|
||||
skillUpgrades: Record<string, string[]>; // Selected upgrade IDs per skill
|
||||
skillTiers: Record<string, number>; // Current tier for each base skill
|
||||
paidStudySkills: Record<string, number>; // skillId -> level that was paid for
|
||||
|
||||
// Study
|
||||
currentStudyTarget: StudyTarget | null;
|
||||
parallelStudyTarget: StudyTarget | null;
|
||||
|
||||
// Actions - Skills
|
||||
setSkillLevel: (skillId: string, level: number) => void;
|
||||
incrementSkillLevel: (skillId: string) => void;
|
||||
clearPaidStudySkill: (skillId: string) => void;
|
||||
setPaidStudySkill: (skillId: string, level: number) => void;
|
||||
|
||||
// Actions - Study
|
||||
startStudyingSkill: (skillId: string, rawMana: number) => { started: boolean; cost: number };
|
||||
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => { started: boolean; cost: number };
|
||||
updateStudyProgress: (progressGain: number) => { completed: boolean; target: StudyTarget | null };
|
||||
cancelStudy: (retentionBonus: number) => void;
|
||||
setStudyTarget: (target: StudyTarget | null) => void;
|
||||
setCurrentStudyTarget: (target: StudyTarget | null) => void;
|
||||
|
||||
// Actions - Upgrades
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
||||
tierUpSkill: (skillId: string) => void;
|
||||
|
||||
// Computed
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: SkillUpgradeChoice[]; selected: string[] };
|
||||
|
||||
// Reset
|
||||
resetSkills: (
|
||||
skills?: Record<string, number>,
|
||||
skillUpgrades?: Record<string, string[]>,
|
||||
skillTiers?: Record<string, number>
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const useSkillStore = create<SkillState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
paidStudySkills: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
|
||||
setSkillLevel: (skillId: string, level: number) => {
|
||||
set((state) => ({
|
||||
skills: { ...state.skills, [skillId]: level },
|
||||
}));
|
||||
},
|
||||
|
||||
incrementSkillLevel: (skillId: string) => {
|
||||
set((state) => ({
|
||||
skills: { ...state.skills, [skillId]: (state.skills[skillId] || 0) + 1 },
|
||||
skillProgress: { ...state.skillProgress, [skillId]: 0 },
|
||||
}));
|
||||
},
|
||||
|
||||
clearPaidStudySkill: (skillId: string) => {
|
||||
set((state) => {
|
||||
const { [skillId]: _, ...remaining } = state.paidStudySkills;
|
||||
return { paidStudySkills: remaining };
|
||||
});
|
||||
},
|
||||
|
||||
setPaidStudySkill: (skillId: string, level: number) => {
|
||||
set((state) => ({
|
||||
paidStudySkills: { ...state.paidStudySkills, [skillId]: level },
|
||||
}));
|
||||
},
|
||||
|
||||
startStudyingSkill: (skillId: string, rawMana: number) => {
|
||||
const state = get();
|
||||
const sk = SKILLS_DEF[skillId];
|
||||
if (!sk) return { started: false, cost: 0 };
|
||||
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
if (currentLevel >= sk.max) return { started: false, cost: 0 };
|
||||
|
||||
// Check prerequisites
|
||||
if (sk.req) {
|
||||
for (const [r, rl] of Object.entries(sk.req)) {
|
||||
if ((state.skills[r] || 0) < rl) return { started: false, cost: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already paid for this level
|
||||
const paidForLevel = state.paidStudySkills[skillId];
|
||||
const isAlreadyPaid = paidForLevel === currentLevel;
|
||||
|
||||
// Calculate cost
|
||||
const costMult = getStudyCostMultiplier(state.skills);
|
||||
const cost = Math.floor(sk.base * (currentLevel + 1) * costMult);
|
||||
|
||||
if (!isAlreadyPaid && rawMana < cost) return { started: false, cost };
|
||||
|
||||
// Get saved progress
|
||||
const savedProgress = state.skillProgress[skillId] || 0;
|
||||
|
||||
// Mark as paid (this is done here so resume works for free)
|
||||
const newPaidSkills = isAlreadyPaid
|
||||
? state.paidStudySkills
|
||||
: { ...state.paidStudySkills, [skillId]: currentLevel };
|
||||
|
||||
// Start studying
|
||||
set({
|
||||
paidStudySkills: newPaidSkills,
|
||||
currentStudyTarget: {
|
||||
type: 'skill',
|
||||
id: skillId,
|
||||
progress: savedProgress,
|
||||
required: sk.studyTime,
|
||||
},
|
||||
});
|
||||
|
||||
return { started: true, cost: isAlreadyPaid ? 0 : cost };
|
||||
},
|
||||
|
||||
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => {
|
||||
const state = get();
|
||||
|
||||
// Start studying the spell
|
||||
set({
|
||||
currentStudyTarget: {
|
||||
type: 'spell',
|
||||
id: spellId,
|
||||
progress: 0,
|
||||
required: studyTime,
|
||||
},
|
||||
});
|
||||
|
||||
// Spell study has no mana cost upfront - cost is paid via study time
|
||||
return { started: true, cost: 0 };
|
||||
},
|
||||
|
||||
updateStudyProgress: (progressGain: number) => {
|
||||
const state = get();
|
||||
if (!state.currentStudyTarget) return { completed: false, target: null };
|
||||
|
||||
const newProgress = state.currentStudyTarget.progress + progressGain;
|
||||
const completed = newProgress >= state.currentStudyTarget.required;
|
||||
|
||||
const newTarget = completed ? null : {
|
||||
...state.currentStudyTarget,
|
||||
progress: newProgress,
|
||||
};
|
||||
|
||||
set({ currentStudyTarget: newTarget });
|
||||
|
||||
return {
|
||||
completed,
|
||||
target: completed ? state.currentStudyTarget : null
|
||||
};
|
||||
},
|
||||
|
||||
cancelStudy: (retentionBonus: number) => {
|
||||
const state = get();
|
||||
if (!state.currentStudyTarget) return;
|
||||
|
||||
// Save progress with retention bonus
|
||||
const savedProgress = Math.min(
|
||||
state.currentStudyTarget.progress,
|
||||
state.currentStudyTarget.required * retentionBonus
|
||||
);
|
||||
|
||||
if (state.currentStudyTarget.type === 'skill') {
|
||||
set({
|
||||
currentStudyTarget: null,
|
||||
skillProgress: {
|
||||
...state.skillProgress,
|
||||
[state.currentStudyTarget.id]: savedProgress,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
set({ currentStudyTarget: null });
|
||||
}
|
||||
},
|
||||
|
||||
setStudyTarget: (target: StudyTarget | null) => {
|
||||
set({ currentStudyTarget: target });
|
||||
},
|
||||
|
||||
setCurrentStudyTarget: (target: StudyTarget | null) => {
|
||||
set({ currentStudyTarget: target });
|
||||
},
|
||||
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => {
|
||||
set((state) => {
|
||||
const current = state.skillUpgrades?.[skillId] || [];
|
||||
if (current.includes(upgradeId)) return state;
|
||||
if (current.length >= 2) return state; // Max 2 upgrades per milestone
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: [...current, upgradeId],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => {
|
||||
set((state) => {
|
||||
const current = state.skillUpgrades?.[skillId] || [];
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: current.filter(id => id !== upgradeId),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => {
|
||||
set((state) => {
|
||||
// Determine which milestone we're committing
|
||||
const isL5 = upgradeIds.some(id => id.includes('_l5'));
|
||||
const isL10 = upgradeIds.some(id => id.includes('_l10'));
|
||||
|
||||
const existingUpgrades = state.skillUpgrades?.[skillId] || [];
|
||||
|
||||
let preservedUpgrades: string[];
|
||||
if (isL5) {
|
||||
preservedUpgrades = existingUpgrades.filter(id => id.includes('_l10'));
|
||||
} else if (isL10) {
|
||||
preservedUpgrades = existingUpgrades.filter(id => id.includes('_l5'));
|
||||
} else {
|
||||
preservedUpgrades = [];
|
||||
}
|
||||
|
||||
const mergedUpgrades = [...preservedUpgrades, ...upgradeIds];
|
||||
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: mergedUpgrades,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
tierUpSkill: (skillId: string) => {
|
||||
const state = get();
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const currentTier = state.skillTiers?.[baseSkillId] || 1;
|
||||
const nextTier = currentTier + 1;
|
||||
|
||||
if (nextTier > 5) return;
|
||||
|
||||
const nextTierSkillId = `${baseSkillId}_t${nextTier}`;
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
|
||||
set({
|
||||
skillTiers: {
|
||||
...state.skillTiers,
|
||||
[baseSkillId]: nextTier,
|
||||
},
|
||||
skills: {
|
||||
...state.skills,
|
||||
[nextTierSkillId]: currentLevel,
|
||||
[skillId]: 0,
|
||||
},
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[nextTierSkillId]: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
|
||||
const state = get();
|
||||
const baseSkillId = getBaseSkillId(skillId);
|
||||
const tier = state.skillTiers?.[baseSkillId] || 1;
|
||||
|
||||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||
if (!path) return { available: [], selected: [] };
|
||||
|
||||
const tierDef = path.tiers.find(t => t.tier === tier);
|
||||
if (!tierDef) return { available: [], selected: [] };
|
||||
|
||||
const available = tierDef.upgrades.filter(u => u.milestone === milestone);
|
||||
const selected = state.skillUpgrades?.[skillId]?.filter(id =>
|
||||
available.some(u => u.id === id)
|
||||
) || [];
|
||||
|
||||
return { available, selected };
|
||||
},
|
||||
|
||||
resetSkills: (
|
||||
skills: Record<string, number> = {},
|
||||
skillUpgrades: Record<string, string[]> = {},
|
||||
skillTiers: Record<string, number> = {}
|
||||
) => {
|
||||
set({
|
||||
skills,
|
||||
skillProgress: {},
|
||||
skillUpgrades,
|
||||
skillTiers,
|
||||
paidStudySkills: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mana-loop-skills',
|
||||
partialize: (state) => ({
|
||||
skills: state.skills,
|
||||
skillProgress: state.skillProgress,
|
||||
skillUpgrades: state.skillUpgrades,
|
||||
skillTiers: state.skillTiers,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
74
src/lib/game/stores/uiStore.ts
Executable file
74
src/lib/game/stores/uiStore.ts
Executable file
@@ -0,0 +1,74 @@
|
||||
// ─── UI Store ────────────────────────────────────────────────────────────────
|
||||
// Handles logs, pause state, and UI-specific state
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface LogEntry {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
logs: string[];
|
||||
paused: boolean;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
|
||||
// Actions
|
||||
addLog: (message: string) => void;
|
||||
clearLogs: () => void;
|
||||
togglePause: () => void;
|
||||
setPaused: (paused: boolean) => void;
|
||||
setGameOver: (gameOver: boolean, victory?: boolean) => void;
|
||||
reset: () => void;
|
||||
resetUI: () => void;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 50;
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
||||
paused: false,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
|
||||
addLog: (message: string) => {
|
||||
set((state) => ({
|
||||
logs: [message, ...state.logs.slice(0, MAX_LOGS - 1)],
|
||||
}));
|
||||
},
|
||||
|
||||
clearLogs: () => {
|
||||
set({ logs: [] });
|
||||
},
|
||||
|
||||
togglePause: () => {
|
||||
set((state) => ({ paused: !state.paused }));
|
||||
},
|
||||
|
||||
setPaused: (paused: boolean) => {
|
||||
set({ paused });
|
||||
},
|
||||
|
||||
setGameOver: (gameOver: boolean, victory: boolean = false) => {
|
||||
set({ gameOver, victory });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
||||
paused: false,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
});
|
||||
},
|
||||
|
||||
resetUI: () => {
|
||||
set({
|
||||
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
||||
paused: false,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -38,7 +38,7 @@ export function getFloorMaxHP(floor: number): number {
|
||||
}
|
||||
|
||||
export function getFloorElement(floor: number): string {
|
||||
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
|
||||
return FLOOR_ELEM_CYCLE[(floor - 1) % FLOOR_ELEM_CYCLE.length];
|
||||
}
|
||||
|
||||
// ─── Computed Stats Functions ─────────────────────────────────────────────────
|
||||
|
||||
37
worklog.md
37
worklog.md
@@ -633,3 +633,40 @@ Stage Summary:
|
||||
- UI for selecting and managing golems
|
||||
- Documentation updated
|
||||
- Lint passes
|
||||
|
||||
---
|
||||
Task ID: 28
|
||||
Agent: Main
|
||||
Task: Fix level upgrade reset loop bug and add golem display to SpireTab
|
||||
|
||||
Work Log:
|
||||
- **Fixed upgrade reset bug in commitSkillUpgrades()**:
|
||||
- Root cause: `commitSkillUpgrades()` was replacing ALL upgrades for a skill instead of merging by milestone
|
||||
- When selecting level 10 upgrades, it would wipe out level 5 selections (and vice versa)
|
||||
- Added optional `milestone` parameter (5 | 10) to the function
|
||||
- When milestone is specified, the function now:
|
||||
- Keeps existing upgrades from OTHER milestones
|
||||
- Only replaces upgrades for the CURRENT milestone
|
||||
- Updated type definition in GameStore interface
|
||||
- Updated SkillsTab.tsx to pass `upgradeDialogMilestone` when committing
|
||||
|
||||
- **Fixed tier-up upgrade reset in tierUpSkill()**:
|
||||
- Root cause: `tierUpSkill()` was setting new tier's upgrades to empty array `[]`
|
||||
- When tiering up, all previous tier's upgrades were lost
|
||||
- Now copies current tier's upgrades to the new tier
|
||||
- Example: Tier 1's L5/L10 upgrades persist when becoming Tier 2
|
||||
|
||||
- **Added summoned golems display to SpireTab**:
|
||||
- Imported GOLEMS_DEF and helper functions
|
||||
- Added Mountain icon import
|
||||
- Added "Active Golems" card that appears when golems are summoned
|
||||
- Shows each golem's name, damage, attack speed, and armor pierce
|
||||
- Displays attack progress bar when climbing
|
||||
- AOE badge for golems with area attacks
|
||||
|
||||
Stage Summary:
|
||||
- Level 5 and level 10 upgrades no longer reset each other
|
||||
- Upgrades now carry over when tiering up to the next tier
|
||||
- Players can safely select upgrades at both milestones across all tiers
|
||||
- Summoned golems now visible in Spire tab during combat
|
||||
- Lint passes, pushed to git (e2671d7)
|
||||
|
||||
Reference in New Issue
Block a user