Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 500955db16 | |||
| 3e70f481dc | |||
| 5578721992 | |||
| b5996d5b6e | |||
| 8cebea9586 | |||
| 27500f37b7 | |||
| 7279050101 | |||
| 5f8a860a3c | |||
| 5e76fe7145 | |||
| 9a2da67006 | |||
| 3f20991d2d | |||
| cbeb0b50ad | |||
| 2c88d3c395 | |||
| 5f46948568 | |||
| 78766d0722 | |||
| badd233c63 | |||
| a47d6568f7 | |||
| 32cebad403 | |||
| a6dd9479b3 | |||
| 428d308ed3 | |||
| a8fab1eb86 | |||
| 8df3be5628 | |||
| 964619b975 | |||
| 7962a4fdaa | |||
| 64b472572b | |||
| 707a1eef31 | |||
| 2fa16c5749 | |||
| 06c3fe4380 | |||
| 1aea72c013 | |||
| 02600754e7 | |||
| 46013a15c8 | |||
| 1c1bbf8017 | |||
| ef850e98e2 | |||
| da4f9eccb3 | |||
| ae30c4770c | |||
| b402b8f56e | |||
| 5c64bb00fa | |||
| 518961299a | |||
| fdc636faaa | |||
| 25ba565467 | |||
| 4aa12a10f0 | |||
| fdf3984e75 | |||
| 635b3b3f70 | |||
| 2c58186a67 | |||
| e9eb7d8b14 | |||
| cb78761e95 | |||
| f22ebf1b3b | |||
| 25109c920a | |||
| 23a83a04cf | |||
| 14f25fffda | |||
| 868dfb6225 | |||
| 4ee6222b0e | |||
| 513cab81a3 | |||
| d7b822d965 | |||
| feca7549ad | |||
| 5bc05ded6f | |||
| ca1709006f | |||
| 49f8de01ca | |||
| 8a7ddaae27 | |||
| ee893e8973 | |||
| ce084a61a3 | |||
| 53b3a94725 | |||
| 742a992d59 | |||
| df316c2865 | |||
| a49b8a8bef | |||
| cba42e01ff | |||
| 56ac50f465 | |||
| 7d56fc368f | |||
| 1c7fc8c551 | |||
| 9882578627 | |||
| 1cda85929d | |||
| 0b6ee15e9b | |||
| dbc1b5e02c | |||
| 1cd612193d | |||
| 5643a4c145 | |||
| 2c4dc82aad | |||
| 639d396f80 | |||
| 50a9a62060 | |||
| ebcaab62bf | |||
| 213425e6c9 | |||
| e259484b53 | |||
| 3dcd967949 | |||
| 48a5ad1855 | |||
| c3a5f333da | |||
| a9918e83a6 | |||
| 594eec1ab4 | |||
| 4f932b6810 | |||
| ff3a268358 | |||
| 92238e4dd8 | |||
| afbdb71548 | |||
| 14ba02d987 | |||
| 084fea2a25 | |||
| ea3035ec5e | |||
| ca86b6268c | |||
| 2805f75f5e | |||
| 20c2ebd7b5 | |||
| 67bd5b4a86 | |||
| 43856acd1e | |||
| 28d1a672da | |||
| 00650c82fd |
@@ -48,3 +48,4 @@ prompt
|
||||
|
||||
server.log
|
||||
# Skills directory
|
||||
.desloppify/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
/**
|
||||
* generate-dependency-graph.js
|
||||
*
|
||||
@@ -85,9 +86,13 @@ try {
|
||||
}
|
||||
|
||||
const lines = circularOutput.trim().split('\n').filter(Boolean);
|
||||
// madge circular output starts with "Found N circular dependencies!"
|
||||
// madge circular output format:
|
||||
// "Found N circular dependencies!" (summary)
|
||||
// "1) fileA > fileB > fileC" (chain lines start with number + ')')
|
||||
// "Processed N files ..." (info line to ignore)
|
||||
// "✔ No circular dependency found!" (clean result)
|
||||
const circularLines = lines.filter(
|
||||
(l) => !l.startsWith('Found') && !l.startsWith('✔') && l.trim()
|
||||
(l) => /^\d+\)/.test(l.trim())
|
||||
);
|
||||
|
||||
let content;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Mana Loop — Agent Guide
|
||||
|
||||
Browser incremental/idle game. Next.js 16 + Zustand, no backend.
|
||||
Browser incremental/idle game. Next.js 16 + Zustand, no backend, localStorage persistence.
|
||||
|
||||
## 🔑 Git
|
||||
|
||||
@@ -24,13 +24,13 @@ git add -A && git commit -m "type: desc" && git push origin master
|
||||
|
||||
1. `docs/project-structure.txt`
|
||||
2. `docs/dependency-graph.json`
|
||||
3. `get_repo_summary` → resume in-progress or pick top todo
|
||||
4. `update_issue_status` → `ai:in-progress`
|
||||
5. Work, log with `add_comment`, then `update_issue_status` → `ai:done`
|
||||
3. `gitea_get_project_boards` → resume in-progress or pick top todo
|
||||
4. `gitea_update_issue_status` → `ai_state: "in-progress"`
|
||||
5. Work, log with `gitea_add_comment`, then `gitea_update_issue_status` → `ai_state: "done"`
|
||||
|
||||
## Labels
|
||||
|
||||
`ai:todo` | `ai:in-progress` | `ai:review` | `ai:blocked` | `ai:done`
|
||||
`ai_state: todo` | `in_state: in-progress` | `ai_state: review` | `ai_state: blocked` | `ai_state: done`
|
||||
|
||||
## Terminal Tool
|
||||
|
||||
@@ -42,31 +42,118 @@ Use for 3+ sequential independent calls. Zero context from parent — paste ever
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Stack:** Next.js 16, TS 5, Tailwind 4 + shadcn/ui, Zustand+persist, Vitest/Playwright, Bun
|
||||
- **Active stores:** `src/lib/game/stores/{game,mana,combat,prestige,skill,ui}Store.ts`
|
||||
- **Legacy (migrating):** `src/lib/game/store/` and `store-modules/`
|
||||
- **Crafting:** 3-step flow — Design → Prepare → Apply via `crafting-actions/`
|
||||
- **Skills v2:** `constants/skills-v2.ts` + `computeStats()` in effects
|
||||
- **Effects:** All stat mods through `getUnifiedEffects()` — never read skill levels directly
|
||||
- **Stack:** Next.js 16, TS 5, Tailwind 4 + shadcn/ui, Zustand+persist, Vitest, Bun
|
||||
- **No backend:** Pure client-side. No Prisma, no database. State persisted to localStorage.
|
||||
- **Active stores (7 Zustand stores):**
|
||||
- `useGameStore` — Coordinator/tick pipeline, imports all other stores
|
||||
- `useManaStore` — Mana pools, regen, element conversion
|
||||
- `useCombatStore` — Spire/floors, combat, spells, achievements
|
||||
- `useCraftingStore` — Enchanting (Design/Prepare/Apply), equipment instances, loot
|
||||
- `useAttunementStore` — Enchanter/Invoker/Fabricator attunement levels & XP
|
||||
- `usePrestigeStore` — Insight, prestige upgrades, pact persistence, loop state
|
||||
- `useDisciplineStore` — Discipline activation, XP ticking, perk evaluation (slice)
|
||||
- `useUIStore` — Logs, pause, game over/victory flags
|
||||
- **Legacy:** Fully migrated. No legacy `store.ts`, `store/`, or `store-modules/` directories remain.
|
||||
|
||||
### Adding Effects
|
||||
1. `data/enchantment-effects.ts`
|
||||
2. `effects.ts` → `computeEquipmentEffects()`
|
||||
3. Access via `getUnifiedEffects(state)`
|
||||
1. `data/enchantments/` — Add effect definition in the appropriate category file
|
||||
2. `craftingStore.ts` → effects computation
|
||||
3. Equipment effects flow through `src/lib/game/effects.ts` → `getUnifiedEffects()`
|
||||
|
||||
### Adding Skills
|
||||
1. `constants/skills-v2.ts`
|
||||
2. `computeStats()` mapping
|
||||
### Adding Disciplines
|
||||
1. Choose the correct data file under `data/disciplines/`:
|
||||
- `base.ts` — Raw Mana Mastery (available to all)
|
||||
- `elemental.ts` — Elemental Attunement (7 base+ elements)
|
||||
- `elemental-regen.ts` — Elemental Regen (7 base + transference)
|
||||
- `elemental-regen-advanced.ts` — Advanced Regen (3 composite + 3 exotic)
|
||||
- `enchanter.ts` — Core Enchanter disciplines (4 disciplines)
|
||||
- `enchanter-utility.ts` — Utility enchantment disciplines (2 disciplines)
|
||||
- `enchanter-spells.ts` — Spell enchantment disciplines (3 disciplines)
|
||||
- `enchanter-special.ts` — Special enchantment disciplines (1 discipline)
|
||||
- `invoker.ts` — Invoker combat disciplines (2 disciplines)
|
||||
- `fabricator.ts` — Fabricator crafting/golem disciplines (2 disciplines)
|
||||
2. Define a `DisciplineDefinition` (see `types/disciplines.ts`):
|
||||
- `statBonus.stat` must match a key consumed by `computeDisciplineEffects()`
|
||||
- Set `difficultyFactor` and `scalingFactor` to control growth rate
|
||||
- Add perks (`once`, `capped`, or `infinite`)
|
||||
3. Re-export from `data/disciplines/index.ts` so it appears in `ALL_DISCIPLINES`
|
||||
4. Add any new `statBonus.stat` keys to `discipline-effects.ts` → `computeDisciplineEffects()`
|
||||
|
||||
### Discipline Math (quick reference)
|
||||
```
|
||||
StatBonus = baseValue × (XP / scalingFactor)^0.65
|
||||
ManaDrainPerTick = drainBase × (1 + (XP / difficultyFactor)^0.4)
|
||||
```
|
||||
- XP accrues every tick the discipline is active and mana drain is met
|
||||
- `concurrentLimit` starts at 1 and expands by 1 per 500 total XP (max 4)
|
||||
|
||||
### Adding Spells
|
||||
1. `constants/spells.ts`
|
||||
2. `data/enchantment-effects.ts`
|
||||
3. `constants/skills-v2.ts` research skill
|
||||
4. `EFFECT_RESEARCH_MAPPING`
|
||||
1. `constants/spells-modules/` — Add to the appropriate category file
|
||||
2. `data/enchantments/spell-effects/` — Add enchantment effect for the spell
|
||||
3. Re-export from barrel files
|
||||
|
||||
### Store Architecture (Key Files)
|
||||
- `stores/gameStore.ts` — Main coordinator, combines all stores, tick orchestration
|
||||
- `stores/tick-pipeline.ts` → `buildTickContext()` / `applyTickWrites()` pattern
|
||||
- `stores/combat-actions.ts` — Combat tick processing
|
||||
- `stores/gameLoopActions.ts` — Climb/spire actions
|
||||
- `stores/pipelines/[name].ts` — Individual pipeline phases
|
||||
|
||||
## Crafting System
|
||||
|
||||
### Enchanting: 3-Step Flow — Design → Prepare → Apply
|
||||
- **Design:** Select effects for a named design. Time: 1h + 0.5h per effect slot. Dual design slot with Enchant Mastery special.
|
||||
- **Prepare:** Clears existing enchantments, costs `capacity × 10` raw mana, time: `2h + 1h per 50 capacity`. ONLY stage where explicit disenchanting occurs.
|
||||
- **Apply:** Applies saved design to prepared equipment. Time: `2h + stacks` hours. Mana: `20 + 5×stacks` per hour.
|
||||
|
||||
### Equipment
|
||||
- 8 slots: mainHand, offHand, head, body, hands, feet, accessory1, accessory2
|
||||
- 50 equipment types across 9 categories (casters, swords, shields, catalysts, head, body, hands, feet, accessories)
|
||||
- Instance fields: `instanceId`, `typeId`, `name`, `enchantments[]`, `usedCapacity`, `totalCapacity`, `rarity`, `quality`
|
||||
- Stacking cost: each additional stack costs 20% more
|
||||
|
||||
### Golemancy
|
||||
- 10 golems total: 1 base (Earth) + 3 elemental (Steel, Crystal, Sand) + 6 hybrid (Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone)
|
||||
- Golems slots: `floor(fabricatorLevel / 2)`, max 5 at level 10
|
||||
- Hybrid golems require Enchanter 5 + Fabricator 5
|
||||
|
||||
### Guardian System
|
||||
- Guardians on every 10th floor
|
||||
- **Base (floors 10–80):** 7 base elements + Transference, static definitions with unique names
|
||||
- **Compound (floors 90–110):** Metal, Sand, Lightning — procedurally named
|
||||
- **Exotic (floors 120–140):** Crystal, Stellar, Void — procedurally named
|
||||
- **Combination bosses (floor 150+):** Dual-element procedural guardians cycling through 9 element pairs, scaling indefinitely
|
||||
- HP formula: `floor(5000 × (floor/10) ^ (1.1 + floor/200))`
|
||||
- Pact signing: costs raw mana + time, grants permanent boons
|
||||
|
||||
### Combat
|
||||
- Cast-speed based: `castProgress += HOURS_PER_TICK × spellCastSpeed × attackSpeedMult`
|
||||
- Elemental bonuses: super effective (1.5×), same element (1.25×), weak (0.75×), neutral (1.0×)
|
||||
- Element opposites: fire↔water, air↔earth, light↔dark, lightning→earth
|
||||
- Enemy modifiers (max 2 per enemy): Armored, Agile, Mage, Shield, Swarm
|
||||
- Room types: Combat (default), Guardian (every 10th), Swarm (15%), Speed (10%), Puzzle (20% on every 7th floor)
|
||||
- Floor HP: `100 + floor × 50 + floor^1.7` for non-guardian floors
|
||||
|
||||
### Time & Incursion
|
||||
- `TICK_MS`: 200ms, `HOURS_PER_TICK`: 0.04, `MAX_DAY`: 30
|
||||
- Incursion starts day 5 (not day 20)
|
||||
- Incursion strength: `min(0.95, (totalHours / maxHours) × 0.95)`
|
||||
|
||||
### Prestige (Insight)
|
||||
- `baseInsight = floor(maxFloorReached × 15 + totalManaGathered / 500 + signedPacts.length × 150)`
|
||||
- Multiplied by discipline and boon bonuses. ×3 for victory (floor 100 + pact signed)
|
||||
- 14 prestige upgrade types: manaWell, manaFlow, insightAmp, spireKey, temporalEcho, steadyHand, ancientKnowledge, elementalAttune, spellMemory, guardianPact, quickStart, elemStart, unlockedManaTypeCapacity
|
||||
- Signed pacts persist through prestige (bounded by `pactSlots`)
|
||||
|
||||
### Starting State
|
||||
- Attunement: Enchanter only (level 1)
|
||||
- Mana: Only Transference unlocked
|
||||
- Equipment: Basic Staff with Mana Bolt enchantment (mainHand), Civilian Shirt (body), Civilian Shoes (feet)
|
||||
- 1 discipline slot, 1 concurrent discipline
|
||||
|
||||
## Banned
|
||||
|
||||
Lifesteal/healing, scroll crafting, ascension skills, LabTab, pause, mana types: `life`, `blood`, `wood`, `mental`, `force`
|
||||
Lifesteal/healing, scroll crafting, ascension skills, LabTab, pause mechanics, familiar system, shields, mana types: `life`, `blood`, `wood`, `mental`, `force`
|
||||
|
||||
## File Limit
|
||||
|
||||
@@ -76,5 +163,5 @@ Lifesteal/healing, scroll crafting, ascension skills, LabTab, pause, mana types:
|
||||
|
||||
**Base (7):** Fire 🔥 Water 💧 Air 🌬️ Earth ⛰️ Light ☀️ Dark 🌑 Death 💀
|
||||
**Utility (1):** Transference 🔗
|
||||
**Compound (3):** Fire+Earth=Metal, Earth+Water=Sand, Fire+Air=Lightning
|
||||
**Exotic (3):** Sand+Sand+Light=Crystal, Fire+Fire+Light=Stellar, Dark+Dark+Death=Void
|
||||
**Composite (3):** Fire+Earth=Metal ⚙️, Earth+Water=Sand ⏳, Fire+Air=Lightning ⚡
|
||||
**Exotic (3):** Sand+Sand+Light=Crystal 💎, Fire+Fire+Light=Stellar ⭐, Dark+Dark+Death=Void 🕳️
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **Mana-Loop** (3795 symbols, 6409 relationships, 146 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/Mana-Loop/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/Mana-Loop/clusters` | All functional areas |
|
||||
| `gitnexus://repo/Mana-Loop/processes` | All execution flows |
|
||||
| `gitnexus://repo/Mana-Loop/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
@@ -3,7 +3,7 @@
|
||||
<p align="center">
|
||||
<img src="public/logo.svg" alt="Mana Loop Logo" width="200" />
|
||||
<br />
|
||||
<em>An incremental/idle game about climbing a magical spire, mastering skills, and uncovering ancient secrets.</em>
|
||||
<em>An incremental/idle game about climbing a magical spire, mastering disciplines, and uncovering ancient secrets.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,7 +15,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.2.0-blue" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/version-0.3.0-blue" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
|
||||
<img src="https://img.shields.io/badge/Next.js-16.1.1-black" alt="Next.js" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-5-blue" alt="TypeScript" />
|
||||
@@ -42,13 +42,13 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**Mana Loop** is a browser-based incremental/idle game where players gather mana, master skills, climb a mysterious 100-floor spire, craft enchanted equipment, and summon magical golems. The game features a unique time-loop prestige system (Insight) that provides permanent progression bonuses across playthroughs.
|
||||
**Mana Loop** is a browser-based incremental/idle game where players gather mana, practice disciplines, climb a mysterious spire, craft enchanted equipment, and summon magical golems. The game features a unique time-loop prestige system (Insight) that provides permanent progression bonuses across playthroughs.
|
||||
|
||||
### Core Game Loop
|
||||
|
||||
1. **Gather Mana** - Click to collect mana or let it regenerate automatically (14 total mana types)
|
||||
2. **Study Skills & Spells** - 20+ skills with 5-tier evolution system and milestone upgrades
|
||||
3. **Climb the Spire** - Battle through 100 procedurally-generated floors, defeat guardians, sign pacts
|
||||
2. **Practice Disciplines** - Continuously train abilities that drain mana each tick in exchange for growing stat bonuses
|
||||
3. **Climb the Spire** - Battle through procedurally-generated floors; every 10th floor is a guardian encounter
|
||||
4. **Craft & Enchant** - 3-stage equipment enchantment system with capacity limits
|
||||
5. **Summon Golems** - Magical constructs that fight alongside you (4 base + 6 hybrid types)
|
||||
6. **Prestige (Loop)** - Reset progress for Insight currency, gain permanent bonuses
|
||||
@@ -62,18 +62,19 @@
|
||||
- Elemental conversion, regeneration mechanics, and meditation bonuses
|
||||
- Mana types: Fire, Water, Air, Earth, Light, Dark, Death (base), Transference (utility), Metal, Sand, Lightning (compound), Crystal, Stellar, Void (exotic)
|
||||
|
||||
### 📜 Skill & Spell System
|
||||
- 20+ skills across multiple categories (mana, study, enchanting, golemancy)
|
||||
- 5-tier evolution system for each skill
|
||||
- Milestone upgrades at levels 5 and 10 per tier
|
||||
- Unique special effects unlocked through skill upgrades
|
||||
### 📜 Discipline System
|
||||
- Practice-based progression - no discrete levels, only continuous XP growth
|
||||
- Disciplines drain mana each tick; stat bonuses grow as a power curve of accumulated XP
|
||||
- Perks unlock at XP thresholds (once, capped, or infinite stacking)
|
||||
- Attunement-gated discipline pools (Base / Enchanter / Invoker / Fabricator)
|
||||
- Concurrent discipline slots unlock as total XP grows (max 4)
|
||||
|
||||
### ⚔️ Combat & Spire
|
||||
- Cast-speed based combat system
|
||||
- Cast-speed based combat system with elemental effectiveness
|
||||
- Multi-spell support from equipped weapons
|
||||
- 100-floor spire with elemental themes
|
||||
- Floor guardians with unique mechanics and pacts
|
||||
- Every 10th floor is a guardian: base elements (10–80), compound (90–110), exotic (120–140), then procedural combination bosses (150+)
|
||||
- Golem allies that deal automatic damage each tick
|
||||
- Enemy modifiers: Armored, Agile, Mage, Shield, Swarm
|
||||
|
||||
### 🛡️ Equipment & Enchanting
|
||||
- 3-stage enchantment process: Design → Prepare → Apply
|
||||
@@ -86,13 +87,12 @@
|
||||
- Summon magical constructs (Earth, Steel, Crystal, Sand + 6 hybrid types)
|
||||
- Golem slots unlock every 2 Fabricator levels (max 5 slots at Level 10)
|
||||
- Hybrid golems require Enchanter 5 + Fabricator 5
|
||||
- Golem maintenance costs and stat upgrades via skills
|
||||
|
||||
### 🔄 Prestige (Insight)
|
||||
- Reset progress for permanent Insight currency
|
||||
- Insight upgrades across multiple categories
|
||||
- Signed pacts and attunements persist through prestige
|
||||
- Three attunement classes: Enchanter (Transference), Invoker (Spells), Fabricator (Golems/Equipment)
|
||||
- Three attunement classes: Enchanter (Transference), Invoker (Spells/Pacts), Fabricator (Golems/Equipment)
|
||||
|
||||
---
|
||||
|
||||
@@ -176,50 +176,53 @@ Mana-Loop/
|
||||
├── src/ # Application source code
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── layout.tsx # Root layout (metadata, fonts, providers)
|
||||
│ │ ├── page.tsx # Main game UI (~583 lines)
|
||||
│ │ ├── page.tsx # Main game UI
|
||||
│ │ ├── globals.css # Global styles
|
||||
│ │ └── api/ # API routes (minimal)
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── ui/ # shadcn/ui components (20+ components)
|
||||
│ │ └── game/ # Game-specific components
|
||||
│ │ ├── tabs/ # Tab components (SpireTab, SkillsTab, etc.)
|
||||
│ │ ├── tabs/ # Tab components (SpireTab, DisciplinesTab, etc.)
|
||||
│ │ ├── ManaDisplay.tsx, ActionButtons.tsx, TimeDisplay.tsx
|
||||
│ │ └── crafting/, debug/, shared/, stats/ subdirectories
|
||||
│ ├── hooks/ # Custom React hooks (use-mobile, use-toast)
|
||||
│ ├── lib/ # Utility libraries
|
||||
│ │ ├── game/ # Core game logic
|
||||
│ │ │ ├── store.ts # Main Zustand store (~2862 lines)
|
||||
│ │ │ ├── crafting-slice.ts, study-slice.ts, navigation-slice.ts
|
||||
│ │ │ ├── effects.ts, upgrade-effects.ts
|
||||
│ │ │ ├── skill-evolution.ts (~3400 lines)
|
||||
│ │ │ ├── constants/ # Game definitions (elements, spells, skills)
|
||||
│ │ │ ├── data/ # Game data (equipment, golems, recipes)
|
||||
│ │ │ └── __tests__/ # Test files for game logic
|
||||
│ │ └── db.ts, utils.ts
|
||||
│ └── test/ # Test setup
|
||||
│ └── lib/ # Utility libraries
|
||||
│ └── game/ # Core game logic
|
||||
│ ├── stores/ # Modular Zustand stores
|
||||
│ │ ├── gameStore.ts # Core state & tick logic
|
||||
│ │ ├── manaStore.ts # Mana gathering & conversion
|
||||
│ │ ├── combatStore.ts # Combat, spells, floor progression
|
||||
│ │ ├── prestigeStore.ts # Prestige/loop & insight
|
||||
│ │ ├── discipline-slice.ts # Discipline activation & XP
|
||||
│ │ ├── attunementStore.ts # Attunement classes
|
||||
│ │ ├── craftingStore.ts # Crafting state
|
||||
│ │ └── uiStore.ts # UI state & modals
|
||||
│ ├── crafting-actions/ # Modular crafting stage handlers
|
||||
│ ├── constants/ # Elements, spells, rooms, prestige
|
||||
│ ├── data/ # Game data
|
||||
│ │ ├── disciplines/ # Per-attunement discipline definitions
|
||||
│ │ ├── enchantments/ # Enchantment effects by category
|
||||
│ │ ├── equipment/ # Equipment type definitions
|
||||
│ │ ├── golems/ # Golem definitions
|
||||
│ │ ├── guardian-data.ts # Static guardian definitions (floors 10–140)
|
||||
│ │ └── guardian-encounters.ts # Procedural guardian lookup & combo bosses
|
||||
│ ├── effects/ # Unified stat computation
|
||||
│ │ └── discipline-effects.ts # Discipline → getUnifiedEffects()
|
||||
│ ├── types/ # TypeScript types (disciplines, elements, etc.)
|
||||
│ └── utils/ # Combat, floor, enemy, discipline math helpers
|
||||
├── prisma/ # Database schema and migrations
|
||||
│ └── schema.prisma # SQLite schema
|
||||
├── public/ # Static assets (logo.svg, robots.txt)
|
||||
├── public/ # Static assets
|
||||
├── docs/ # Project documentation
|
||||
│ ├── AGENTS.md # Comprehensive architecture guide
|
||||
│ ├── GAME_BRIEFING.md # Game design document
|
||||
│ └── task/ # Task tracking documentation
|
||||
├── .next/ # Next.js build output (generated)
|
||||
├── node_modules/ # Dependencies (generated)
|
||||
├── Configuration Files:
|
||||
│ ├── package.json # Project metadata and scripts
|
||||
│ ├── tsconfig.json # TypeScript configuration
|
||||
│ ├── next.config.ts # Next.js config (standalone output)
|
||||
│ ├── vitest.config.ts # Vitest test configuration
|
||||
│ ├── eslint.config.mjs # ESLint configuration
|
||||
│ ├── Dockerfile # Docker multi-stage build
|
||||
│ ├── docker-compose.yml # Docker Compose setup
|
||||
│ ├── Caddyfile # Reverse proxy configuration
|
||||
│ └── .gitea/workflows/ # Gitea Actions CI/CD pipeline
|
||||
└── README.md # This file
|
||||
│ ├── AGENTS.md # Architecture guide for AI agents
|
||||
│ └── GAME_BRIEFING.md # Comprehensive game design document
|
||||
└── Configuration Files:
|
||||
├── package.json, tsconfig.json, next.config.ts
|
||||
├── vitest.config.ts, eslint.config.mjs
|
||||
├── Dockerfile, docker-compose.yml, Caddyfile
|
||||
└── .gitea/workflows/ # Gitea Actions CI/CD pipeline
|
||||
```
|
||||
|
||||
For detailed architecture patterns and coding guidelines, see [AGENTS.md](./docs/AGENTS.md).
|
||||
For detailed architecture patterns and coding guidelines, see [AGENTS.md](./AGENTS.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -232,36 +235,50 @@ The core resource of the game with 14 distinct types organized in a hierarchy:
|
||||
- **Compound (3)**: Metal (Fire+Earth), Sand (Earth+Water), Lightning (Fire+Air)
|
||||
- **Exotic (3)**: Crystal (Sand+Sand+Light), Stellar (Fire+Fire+Light), Void (Dark+Dark+Death)
|
||||
|
||||
**Key Files**: `src/lib/game/store.ts`, `src/lib/game/constants/elements.ts`
|
||||
**Key Files**: `src/lib/game/stores/manaStore.ts`, `src/lib/game/constants/elements.ts`
|
||||
|
||||
### Skill Evolution System
|
||||
Each skill progresses through 5 tiers with upgrades at levels 5 and 10 per tier:
|
||||
- **Tier 1**: Basic functionality
|
||||
- **Tier 2-5**: Unlock new mechanics and bonuses
|
||||
- **Evolution Paths**: Defined in `src/lib/game/skill-evolution.ts` (~3400 lines)
|
||||
### Discipline System
|
||||
Disciplines replace the old skill system entirely. There are no discrete levels - disciplines grow **continuously** through practice. The player activates a discipline and it drains mana each tick in exchange for permanent stat growth within the run.
|
||||
|
||||
- **Stat bonus** grows as a power curve of XP: `baseValue × (XP / scalingFactor)^0.65`
|
||||
- **Mana drain** also increases with XP: `drainBase × (1 + (XP / difficultyFactor)^0.4)`
|
||||
- **Perks** unlock at XP thresholds (`once`, `capped`, or `infinite`)
|
||||
- **Concurrent slots** start at 1 and unlock as total XP grows (max 4)
|
||||
|
||||
**Key Files**: `src/lib/game/data/disciplines/`, `src/lib/game/stores/discipline-slice.ts`, `src/lib/game/utils/discipline-math.ts`
|
||||
|
||||
### Guardian & Spire System
|
||||
Every 10th floor is a guardian encounter. Guardians progress through four tiers of complexity:
|
||||
|
||||
1. **Base Elements (Floors 10–80)**: One guardian per base element + Transference. Static definitions with named guardians (Ignis Prime, Aqua Regia, etc.). Defeating them unlocks their associated mana types.
|
||||
2. **Compound Elements (Floors 90–110)**: Metal, Sand, and Lightning guardians with procedurally generated names.
|
||||
3. **Exotic Elements (Floors 120–140)**: Crystal, Stellar, and Void guardians - the most powerful single-element encounters.
|
||||
4. **Combination Bosses (Floor 150+)**: Fully procedural dual-element guardians. Each one wields two base elements simultaneously (e.g. Fire+Water, Light+Dark) and grows stronger every 10 floors.
|
||||
|
||||
**Key Files**: `src/lib/game/data/guardian-data.ts`, `src/lib/game/data/guardian-encounters.ts`
|
||||
|
||||
### Combat System
|
||||
- Cast-speed based spell casting with DPS calculations
|
||||
- Elemental damage bonuses and effectiveness
|
||||
- Multi-spell support from equipped weapons
|
||||
- Cast-speed based spell casting with elemental effectiveness multipliers
|
||||
- Enemy modifiers: Armored, Agile, Mage (barrier), Shielded, Swarm
|
||||
- Golem allies deal automatic damage each tick
|
||||
- Discipline bonuses feed into damage via `getUnifiedEffects()`
|
||||
|
||||
**Key Files**: `src/lib/game/store.ts` (combat tick logic), `src/lib/game/constants/spells.ts`
|
||||
**Key Files**: `src/lib/game/stores/combatStore.ts`, `src/lib/game/utils/combat-utils.ts`, `src/lib/game/utils/enemy-generator.ts`
|
||||
|
||||
### Enchanting System
|
||||
3-stage equipment enchantment process:
|
||||
1. **Design**: Choose effects for your equipment type
|
||||
2. **Prepare**: Prepare equipment (ONLY way to disenchant existing enchantments)
|
||||
2. **Prepare**: Ready equipment (ONLY stage where disenchanting is possible)
|
||||
3. **Apply**: Apply designed enchantments (cannot re-enchant already enchanted gear)
|
||||
|
||||
**Key Files**: `src/lib/game/crafting-slice.ts`, `src/lib/game/data/enchantment-effects.ts`
|
||||
**Key Files**: `src/lib/game/crafting-actions/`, `src/lib/game/data/enchantments/`
|
||||
|
||||
### Golemancy System
|
||||
- **Base Golems**: Earth (Fabricator 2), Steel (Metal), Crystal, Sand
|
||||
- **Hybrid Golems** (Enchanter 5 + Fabricator 5): Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone
|
||||
- **Golem Slots**: 1 slot at Fabricator Level 2, +1 every 2 levels (max 5 at Level 10)
|
||||
|
||||
**Key Files**: `src/lib/game/data/golems.ts`, `src/lib/game/store.ts`
|
||||
**Key Files**: `src/lib/game/data/golems/`, `src/lib/game/stores/gameStore.ts`
|
||||
|
||||
### Prestige (Insight)
|
||||
Reset progress to gain Insight currency for permanent upgrades:
|
||||
@@ -274,7 +291,6 @@ Reset progress to gain Insight currency for permanent upgrades:
|
||||
## Deployment
|
||||
|
||||
### Docker Deployment
|
||||
The project includes Docker configuration for containerized deployment:
|
||||
|
||||
```bash
|
||||
# Build and run with Docker Compose
|
||||
@@ -286,7 +302,7 @@ docker run -p 3000:3000 mana-loop
|
||||
```
|
||||
|
||||
### CI/CD Pipeline
|
||||
- **Gitea Actions**: `.gitea/workflows/docker-build.yaml` automatically builds and pushes Docker images to `gitea.tailf367e3.ts.net/anexim/mana-loop:latest` on push to `master`/`main` branches
|
||||
- **Gitea Actions**: `.gitea/workflows/docker-build.yaml` automatically builds and pushes Docker images to `gitea.tailf367e3.ts.net/anexim/mana-loop:latest` on push to `master`/`main`
|
||||
- **Multi-platform**: Builds for linux/amd64 architecture
|
||||
- **Image Tags**: Branch name, commit SHA, "latest"
|
||||
|
||||
@@ -316,26 +332,24 @@ We welcome contributions! Please follow these guidelines:
|
||||
### Code Style
|
||||
- TypeScript throughout with strict typing
|
||||
- Use existing shadcn/ui components over custom implementations
|
||||
- Follow the slice pattern for Zustand store actions
|
||||
- Keep components focused (extract to separate files when >50 lines)
|
||||
- Follow the modular store pattern (`src/lib/game/stores/`)
|
||||
- Keep files under 400 lines (enforced by pre-commit hook)
|
||||
- Use path aliases: `@/*` maps to `./src/*`
|
||||
|
||||
### Adding New Features
|
||||
For detailed patterns on adding new effects, skills, spells, or systems, see the comprehensive [AGENTS.md](./docs/AGENTS.md) guide, which includes:
|
||||
- Architecture overview
|
||||
- Coding patterns
|
||||
- Git workflow (mandatory pull before work, commit & push after)
|
||||
- Credentials for automation (if applicable)
|
||||
For detailed patterns on adding new effects, disciplines, spells, or systems, see the comprehensive [AGENTS.md](./AGENTS.md) guide, which includes architecture overview, coding patterns, and git workflow.
|
||||
|
||||
---
|
||||
|
||||
## Banned Content
|
||||
|
||||
The following content has been removed from the game and should not be re-added:
|
||||
The following content has been removed from the game and must not be re-added:
|
||||
|
||||
### Banned Mechanics
|
||||
- **Lifesteal** - Player cannot heal from dealing damage
|
||||
- **Healing** - Player cannot heal themselves (floors take damage, not player)
|
||||
- **Healing** - Player cannot heal themselves (floors take damage, not the player)
|
||||
- **Scroll crafting** - Violates the no-instant-finishing design pillar
|
||||
- **Ascension skills** - Removed; no replacement
|
||||
|
||||
### Banned Mana Types
|
||||
- **Life** - Removed (healing theme conflicts with core design)
|
||||
@@ -345,14 +359,13 @@ The following content has been removed from the game and should not be re-added:
|
||||
- **Force** - Removed
|
||||
|
||||
### Banned Systems
|
||||
- **Familiar System** - Removed in favor of Golemancy and Pact systems
|
||||
- **Familiar System** - Removed in favour of Golemancy and Pact systems
|
||||
- **Skill System** (study, tiers T1–T5, milestone upgrades) - Fully replaced by the Discipline System
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE section below for details.
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
@@ -377,8 +390,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
**Note**: A `LICENSE` file is not currently present in the project root. It is recommended to create one with the above MIT License text.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
+622
-726
File diff suppressed because it is too large
Load Diff
+2
-15
@@ -1,17 +1,4 @@
|
||||
# Circular Dependencies
|
||||
Generated: 2026-05-17T17:39:56.862Z
|
||||
Found: 7 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||
Generated: 2026-05-28T10:11:54.061Z
|
||||
|
||||
1. Processed 151 files (1.4s) (37 warnings)
|
||||
2. 1) data/equipment/index.ts > data/equipment/utils.ts
|
||||
3. 2) data/golems/index.ts > data/golems/utils.ts
|
||||
4. 3) stores/combat-actions.ts > stores/combatStore.ts
|
||||
5. 4) stores/combatStore.ts > stores/gameStore.ts
|
||||
6. 5) stores/combatStore.ts > stores/gameStore.ts > stores/gameActions.ts
|
||||
7. 6) stores/combatStore.ts > stores/gameStore.ts > stores/gameLoopActions.ts
|
||||
|
||||
## How to fix
|
||||
1. Identify which import in the chain can be extracted to a shared types/utils file.
|
||||
2. Move the shared type or function there.
|
||||
3. Both files import from the new shared module instead of each other.
|
||||
4. Run: bunx madge --circular src/lib/game (should return clean)
|
||||
No circular dependencies found. ✅
|
||||
|
||||
+328
-349
@@ -1,26 +1,10 @@
|
||||
{
|
||||
"_meta": {
|
||||
"generated": "2026-05-17T17:39:55.251Z",
|
||||
"generated": "2026-05-28T10:11:52.202Z",
|
||||
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||
},
|
||||
"graph": {
|
||||
"attunements/data.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"attunements/index.ts": [
|
||||
"attunements/data.ts",
|
||||
"attunements/types.ts",
|
||||
"attunements/utils.ts"
|
||||
],
|
||||
"attunements/types.ts": [],
|
||||
"attunements/utils.ts": [
|
||||
"attunements/data.ts",
|
||||
"attunements/types.ts"
|
||||
],
|
||||
"computed-stats.ts": [
|
||||
"utils/index.ts"
|
||||
],
|
||||
"constants.ts": [
|
||||
"constants/index.ts"
|
||||
],
|
||||
@@ -28,21 +12,20 @@
|
||||
"constants/elements.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"constants/guardians.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"constants/index.ts": [
|
||||
"constants/core.ts",
|
||||
"constants/elements.ts",
|
||||
"constants/guardians.ts",
|
||||
"constants/prestige.ts",
|
||||
"constants/rooms.ts",
|
||||
"constants/spells.ts"
|
||||
"constants/spells.ts",
|
||||
"types/game.ts"
|
||||
],
|
||||
"constants/prestige.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"constants/rooms.ts": [],
|
||||
"constants/rooms.ts": [
|
||||
"types/game.ts"
|
||||
],
|
||||
"constants/spells-modules/advanced-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
@@ -98,28 +81,38 @@
|
||||
],
|
||||
"crafting-actions/application-actions.ts": [
|
||||
"crafting-apply.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-actions/computed-getters.ts": [
|
||||
"data/enchantment-effects.ts",
|
||||
"types.ts"
|
||||
"stores/craftingStore.types.ts"
|
||||
],
|
||||
"crafting-actions/crafting-equipment-actions.ts": [
|
||||
"crafting-equipment.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-actions/crafting-material-actions.ts": [
|
||||
"crafting-fabricator.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/uiStore.ts"
|
||||
],
|
||||
"crafting-actions/design-actions.ts": [
|
||||
"crafting-design.ts",
|
||||
"crafting-utils.ts",
|
||||
"special-effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts"
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-actions/disenchant-actions.ts": [
|
||||
"types.ts"
|
||||
"stores/craftingStore.types.ts"
|
||||
],
|
||||
"crafting-actions/equipment-actions.ts": [
|
||||
"crafting-utils.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-actions/index.ts": [
|
||||
@@ -133,32 +126,42 @@
|
||||
],
|
||||
"crafting-actions/preparation-actions.ts": [
|
||||
"crafting-prep.ts",
|
||||
"types.ts"
|
||||
"stores/craftingStore.types.ts"
|
||||
],
|
||||
"crafting-apply.ts": [
|
||||
"constants.ts",
|
||||
"crafting-utils.ts",
|
||||
"data/attunements.ts",
|
||||
"data/enchantment-effects.ts",
|
||||
"special-effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts"
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-attunements.ts": [
|
||||
"data/attunements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-design.ts": [
|
||||
"constants.ts",
|
||||
"data/attunements.ts",
|
||||
"data/enchantment-effects.ts",
|
||||
"data/equipment/index.ts",
|
||||
"special-effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts"
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-equipment.ts": [
|
||||
"crafting-utils.ts",
|
||||
"constants.ts",
|
||||
"data/crafting-recipes.ts",
|
||||
"data/equipment/index.ts",
|
||||
"types.ts",
|
||||
"utils/result.ts"
|
||||
],
|
||||
"crafting-fabricator.ts": [
|
||||
"data/fabricator-recipes.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-loot.ts": [
|
||||
@@ -166,31 +169,12 @@
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-prep.ts": [
|
||||
"constants.ts",
|
||||
"crafting-utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-slice.ts": [
|
||||
"constants.ts",
|
||||
"crafting-actions/index.ts",
|
||||
"crafting-apply.ts",
|
||||
"crafting-attunements.ts",
|
||||
"crafting-design.ts",
|
||||
"crafting-equipment.ts",
|
||||
"crafting-loot.ts",
|
||||
"crafting-prep.ts",
|
||||
"crafting-utils.ts",
|
||||
"data/attunements.ts",
|
||||
"data/crafting-recipes.ts",
|
||||
"data/enchantment-effects.ts",
|
||||
"data/equipment/index.ts",
|
||||
"special-effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts",
|
||||
"upgrade-effects.types.ts"
|
||||
],
|
||||
"crafting-utils.ts": [
|
||||
"data/crafting-recipes.ts",
|
||||
"data/enchantment-effects.ts",
|
||||
"data/equipment/index.ts",
|
||||
"types.ts"
|
||||
],
|
||||
@@ -200,30 +184,52 @@
|
||||
"data/attunements.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"data/crafting-recipes.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/disciplines/base-disciplines.ts": [],
|
||||
"data/crafting-recipes.ts": [],
|
||||
"data/disciplines/base.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/enchanter-disciplines.ts": [],
|
||||
"data/disciplines/enchanter.ts": [
|
||||
"data/disciplines/elemental-regen-advanced.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/fabricator-disciplines.ts": [
|
||||
"data/disciplines/elemental-regen.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/elemental.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/enchanter-special.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/enchanter-spells.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/enchanter-utility.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/enchanter.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/fabricator.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/invoker-disciplines.ts": [
|
||||
"data/disciplines/index.ts": [
|
||||
"data/disciplines/base.ts",
|
||||
"data/disciplines/elemental-regen-advanced.ts",
|
||||
"data/disciplines/elemental-regen.ts",
|
||||
"data/disciplines/elemental.ts",
|
||||
"data/disciplines/enchanter-special.ts",
|
||||
"data/disciplines/enchanter-spells.ts",
|
||||
"data/disciplines/enchanter-utility.ts",
|
||||
"data/disciplines/enchanter.ts",
|
||||
"data/disciplines/fabricator.ts",
|
||||
"data/disciplines/invoker.ts",
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/invoker.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/enchantment-effects.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
"data/enchantments/index.ts"
|
||||
],
|
||||
"data/enchantment-types.ts": [
|
||||
@@ -234,8 +240,7 @@
|
||||
"data/equipment/index.ts"
|
||||
],
|
||||
"data/enchantments/defense-effects.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
"data/equipment/index.ts"
|
||||
"data/enchantment-types.ts"
|
||||
],
|
||||
"data/enchantments/elemental-effects.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
@@ -249,7 +254,8 @@
|
||||
"data/enchantments/mana-effects.ts",
|
||||
"data/enchantments/special-effects.ts",
|
||||
"data/enchantments/spell-effects/index.ts",
|
||||
"data/enchantments/utility-effects.ts"
|
||||
"data/enchantments/utility-effects.ts",
|
||||
"data/equipment/index.ts"
|
||||
],
|
||||
"data/enchantments/mana-effects.ts": [
|
||||
"constants.ts",
|
||||
@@ -264,7 +270,9 @@
|
||||
"data/enchantments/spell-effects/types.ts"
|
||||
],
|
||||
"data/enchantments/spell-effects/index.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
"data/enchantments/spell-effects/basic-spells.ts",
|
||||
"data/enchantments/spell-effects/legendary-spells.ts",
|
||||
"data/enchantments/spell-effects/lightning-spells.ts",
|
||||
"data/enchantments/spell-effects/metal-spells.ts",
|
||||
"data/enchantments/spell-effects/sand-spells.ts",
|
||||
@@ -272,6 +280,9 @@
|
||||
"data/enchantments/spell-effects/tier3-spells.ts",
|
||||
"data/enchantments/spell-effects/types.ts"
|
||||
],
|
||||
"data/enchantments/spell-effects/legendary-spells.ts": [
|
||||
"data/enchantments/spell-effects/types.ts"
|
||||
],
|
||||
"data/enchantments/spell-effects/lightning-spells.ts": [
|
||||
"data/enchantments/spell-effects/types.ts"
|
||||
],
|
||||
@@ -287,7 +298,10 @@
|
||||
"data/enchantments/spell-effects/tier3-spells.ts": [
|
||||
"data/enchantments/spell-effects/types.ts"
|
||||
],
|
||||
"data/enchantments/spell-effects/types.ts": [],
|
||||
"data/enchantments/spell-effects/types.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
"data/equipment/index.ts"
|
||||
],
|
||||
"data/enchantments/utility-effects.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
"data/equipment/index.ts"
|
||||
@@ -304,6 +318,16 @@
|
||||
"data/equipment/catalysts.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/equipment/equipment-types-data.ts": [
|
||||
"data/equipment/accessories.ts",
|
||||
"data/equipment/body.ts",
|
||||
"data/equipment/casters.ts",
|
||||
"data/equipment/catalysts.ts",
|
||||
"data/equipment/feet.ts",
|
||||
"data/equipment/hands.ts",
|
||||
"data/equipment/head.ts",
|
||||
"data/equipment/swords.ts"
|
||||
],
|
||||
"data/equipment/feet.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
@@ -318,10 +342,10 @@
|
||||
"data/equipment/body.ts",
|
||||
"data/equipment/casters.ts",
|
||||
"data/equipment/catalysts.ts",
|
||||
"data/equipment/equipment-types-data.ts",
|
||||
"data/equipment/feet.ts",
|
||||
"data/equipment/hands.ts",
|
||||
"data/equipment/head.ts",
|
||||
"data/equipment/shields.ts",
|
||||
"data/equipment/swords.ts",
|
||||
"data/equipment/types.ts",
|
||||
"data/equipment/utils.ts"
|
||||
@@ -332,295 +356,208 @@
|
||||
"data/equipment/swords.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/equipment/types.ts": [],
|
||||
"data/equipment/types.ts": [
|
||||
"types/equipmentSlot.ts"
|
||||
],
|
||||
"data/equipment/utils.ts": [
|
||||
"data/equipment/index.ts",
|
||||
"data/equipment/equipment-types-data.ts",
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/fabricator-material-recipes.ts": [
|
||||
"data/fabricator-recipe-types.ts"
|
||||
],
|
||||
"data/fabricator-physical-recipes.ts": [
|
||||
"data/fabricator-recipe-types.ts"
|
||||
],
|
||||
"data/fabricator-recipe-types.ts": [
|
||||
"data/equipment/types.ts",
|
||||
"types/equipment.ts"
|
||||
],
|
||||
"data/fabricator-recipes.ts": [
|
||||
"data/fabricator-material-recipes.ts",
|
||||
"data/fabricator-physical-recipes.ts",
|
||||
"data/fabricator-recipe-types.ts",
|
||||
"data/fabricator-wizard-recipes.ts"
|
||||
],
|
||||
"data/fabricator-wizard-recipes.ts": [
|
||||
"data/fabricator-recipe-types.ts"
|
||||
],
|
||||
"data/golems/base-golems.ts": [
|
||||
"data/golems/types.ts"
|
||||
],
|
||||
"data/golems/elemental-golems.ts": [
|
||||
"data/golems/types.ts"
|
||||
],
|
||||
"data/golems/golems-data.ts": [
|
||||
"data/golems/base-golems.ts",
|
||||
"data/golems/elemental-golems.ts",
|
||||
"data/golems/hybrid-golems.ts"
|
||||
],
|
||||
"data/golems/hybrid-golems.ts": [
|
||||
"data/golems/types.ts"
|
||||
],
|
||||
"data/golems/index.ts": [
|
||||
"data/golems/base-golems.ts",
|
||||
"data/golems/elemental-golems.ts",
|
||||
"data/golems/hybrid-golems.ts",
|
||||
"data/golems/golems-data.ts",
|
||||
"data/golems/types.ts",
|
||||
"data/golems/utils.ts"
|
||||
],
|
||||
"data/golems/types.ts": [],
|
||||
"data/golems/utils.ts": [
|
||||
"data/golems/index.ts",
|
||||
"data/golems/types.ts"
|
||||
],
|
||||
"data/loot-drops.ts": [
|
||||
"data/golems/types.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"debug-context.tsx": [],
|
||||
"dynamic-compute.ts": [
|
||||
"special-effects.ts",
|
||||
"upgrade-effects.types.ts"
|
||||
"data/golems/utils.ts": [
|
||||
"data/golems/golems-data.ts",
|
||||
"data/golems/types.ts"
|
||||
],
|
||||
"data/guardian-data.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"data/guardian-encounters.ts": [
|
||||
"data/guardian-data.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"data/loot-drops.ts": [
|
||||
"types/game.ts"
|
||||
],
|
||||
"effects.ts": [
|
||||
"data/enchantment-effects.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"special-effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts",
|
||||
"upgrade-effects.types.ts"
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.ts",
|
||||
"effects/upgrade-effects.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"effects/discipline-effects.ts": [
|
||||
"data/disciplines/index.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"types.ts",
|
||||
"utils/discipline-math.ts"
|
||||
],
|
||||
"formatting.ts": [
|
||||
"computed-stats.ts"
|
||||
],
|
||||
"hooks/useGameDerived.ts": [
|
||||
"constants.ts",
|
||||
"special-effects.ts",
|
||||
"store.ts",
|
||||
"store/computed.ts",
|
||||
"upgrade-effects.ts"
|
||||
],
|
||||
"navigation-slice.ts": [
|
||||
"computed-stats.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"special-effects.ts": [
|
||||
"upgrade-effects.types.ts"
|
||||
],
|
||||
"store-modules/activity-log.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"store-modules/computed-stats.ts": [
|
||||
"constants.ts",
|
||||
"data/attunements.ts",
|
||||
"effects.ts",
|
||||
"special-effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.types.ts"
|
||||
],
|
||||
"store-modules/enemy-utils.ts": [
|
||||
"constants.ts",
|
||||
"types.ts",
|
||||
"utils/floor-utils.ts"
|
||||
],
|
||||
"store-modules/initial-state.ts": [
|
||||
"constants.ts",
|
||||
"crafting-slice.ts",
|
||||
"store-modules/computed-stats.ts",
|
||||
"store-modules/room-utils.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts",
|
||||
"utils/floor-utils.ts"
|
||||
],
|
||||
"store-modules/room-utils.ts": [
|
||||
"constants.ts",
|
||||
"store-modules/enemy-utils.ts",
|
||||
"types.ts",
|
||||
"utils/floor-utils.ts"
|
||||
],
|
||||
"store-modules/store-actions.ts": [
|
||||
"constants.ts",
|
||||
"crafting-slice.ts",
|
||||
"data/attunements.ts",
|
||||
"data/enchantment-effects.ts",
|
||||
"data/equipment/index.ts",
|
||||
"data/golems/index.ts",
|
||||
"effects.ts",
|
||||
"special-effects.ts",
|
||||
"store-modules/activity-log.ts",
|
||||
"store-modules/computed-stats.ts",
|
||||
"store-modules/enemy-utils.ts",
|
||||
"store-modules/initial-state.ts",
|
||||
"store-modules/room-utils.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts",
|
||||
"upgrade-effects.types.ts",
|
||||
"utils/combat-utils.ts"
|
||||
],
|
||||
"store-modules/tick-logic.ts": [
|
||||
"constants.ts",
|
||||
"crafting-slice.ts",
|
||||
"data/attunements.ts",
|
||||
"data/golems/index.ts",
|
||||
"effects.ts",
|
||||
"special-effects.ts",
|
||||
"store-modules/activity-log.ts",
|
||||
"store-modules/computed-stats.ts",
|
||||
"store-modules/room-utils.ts",
|
||||
"types.ts",
|
||||
"utils/combat-utils.ts",
|
||||
"utils/floor-utils.ts"
|
||||
],
|
||||
"store.ts": [
|
||||
"store-modules/activity-log.ts",
|
||||
"store-modules/computed-stats.ts",
|
||||
"store-modules/initial-state.ts",
|
||||
"store-modules/room-utils.ts",
|
||||
"types.ts",
|
||||
"utils/floor-utils.ts",
|
||||
"utils/formatting.ts"
|
||||
],
|
||||
"store/combatSlice.ts": [
|
||||
"constants.ts",
|
||||
"special-effects.ts",
|
||||
"store/computed.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts"
|
||||
],
|
||||
"store/computed.ts": [
|
||||
"constants.ts",
|
||||
"effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts",
|
||||
"upgrade-effects.types.ts"
|
||||
],
|
||||
"store/crafting-modules/initial-state.ts": [
|
||||
"data/equipment/index.ts",
|
||||
"store/crafting-modules/types.ts"
|
||||
],
|
||||
"store/crafting-modules/selectors.ts": [
|
||||
"data/equipment/index.ts",
|
||||
"store/crafting-modules/types.ts",
|
||||
"store/crafting-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"store/crafting-modules/slice-logic.ts": [
|
||||
"data/equipment/index.ts",
|
||||
"store/crafting-modules/initial-state.ts",
|
||||
"store/crafting-modules/selectors.ts",
|
||||
"store/crafting-modules/tick-processors.ts",
|
||||
"store/crafting-modules/types.ts",
|
||||
"store/crafting-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"store/crafting-modules/starting-equipment.ts": [
|
||||
"store/crafting-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"store/crafting-modules/tick-processors.ts": [
|
||||
"data/enchantment-effects.ts",
|
||||
"store/crafting-modules/types.ts",
|
||||
"store/crafting-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"store/crafting-modules/types.ts": [
|
||||
"data/equipment/index.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"store/crafting-modules/utils.ts": [
|
||||
"data/enchantment-effects.ts",
|
||||
"data/equipment/index.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"store/craftingSlice.ts": [
|
||||
"store/crafting-modules/initial-state.ts",
|
||||
"store/crafting-modules/slice-logic.ts",
|
||||
"store/crafting-modules/starting-equipment.ts",
|
||||
"store/crafting-modules/types.ts",
|
||||
"store/crafting-modules/utils.ts"
|
||||
],
|
||||
"store/index.ts": [
|
||||
"store.ts",
|
||||
"store/computed.ts"
|
||||
],
|
||||
"store/manaSlice.ts": [
|
||||
"constants.ts",
|
||||
"special-effects.ts",
|
||||
"store.ts",
|
||||
"store/computed.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts"
|
||||
],
|
||||
"store/pactSlice.ts": [
|
||||
"constants.ts",
|
||||
"store/computed.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"store/prestigeSlice.ts": [
|
||||
"constants.ts",
|
||||
"store/computed.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"store/timeSlice.ts": [
|
||||
"constants.ts",
|
||||
"store/computed.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"stores/attunementStore.ts": [
|
||||
"data/attunements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"stores/combat-actions.ts": [
|
||||
"constants.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"types.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/combatStore.ts": [
|
||||
"stores/combat-actions.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"types.ts",
|
||||
"utils/activity-log.ts",
|
||||
"utils/index.ts",
|
||||
"utils/room-utils.ts"
|
||||
],
|
||||
"stores/craftingStore.ts": [
|
||||
"crafting-actions/application-actions.ts",
|
||||
"crafting-actions/preparation-actions.ts",
|
||||
"crafting-apply.ts",
|
||||
"crafting-design.ts",
|
||||
"crafting-equipment.ts",
|
||||
"crafting-utils.ts",
|
||||
"special-effects.ts",
|
||||
"store/crafting-modules/starting-equipment.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts"
|
||||
],
|
||||
"stores/discipline-slice.ts": [
|
||||
"data/disciplines/base.ts",
|
||||
"data/disciplines/enchanter.ts",
|
||||
"data/disciplines/fabricator.ts",
|
||||
"data/disciplines/invoker.ts",
|
||||
"types/disciplines.ts",
|
||||
"utils/discipline-math.ts"
|
||||
],
|
||||
"stores/gameActions.ts": [
|
||||
"effects/dynamic-compute.ts": [
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.types.ts"
|
||||
],
|
||||
"effects/special-effects.ts": [
|
||||
"effects/upgrade-effects.types.ts"
|
||||
],
|
||||
"effects/upgrade-effects.ts": [
|
||||
"effects/upgrade-effects.types.ts"
|
||||
],
|
||||
"effects/upgrade-effects.types.ts": [],
|
||||
"hooks/useGameDerived.ts": [
|
||||
"constants.ts",
|
||||
"data/guardian-encounters.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"utils/index.ts",
|
||||
"utils/pact-utils.ts"
|
||||
],
|
||||
"stores/attunementStore.ts": [
|
||||
"data/attunements.ts",
|
||||
"types.ts",
|
||||
"utils/safe-persist.ts"
|
||||
],
|
||||
"stores/combat-actions.ts": [
|
||||
"constants.ts",
|
||||
"data/guardian-encounters.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"stores/combat-state.types.ts",
|
||||
"types.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/combat-state.types.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"stores/combatStore.ts": [
|
||||
"stores/combat-actions.ts",
|
||||
"stores/combat-state.types.ts",
|
||||
"types.ts",
|
||||
"utils/activity-log.ts",
|
||||
"utils/index.ts",
|
||||
"utils/room-utils.ts",
|
||||
"utils/safe-persist.ts",
|
||||
"utils/spire-utils.ts"
|
||||
],
|
||||
"stores/crafting-equipment-tick.ts": [
|
||||
"constants.ts",
|
||||
"crafting-equipment.ts",
|
||||
"data/crafting-recipes.ts",
|
||||
"data/fabricator-recipes.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"types/equipment.ts"
|
||||
],
|
||||
"stores/crafting-initial-state.ts": [
|
||||
"crafting-utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"stores/craftingStore.ts": [
|
||||
"crafting-actions/application-actions.ts",
|
||||
"crafting-actions/crafting-material-actions.ts",
|
||||
"crafting-actions/equipment-actions.ts",
|
||||
"crafting-actions/preparation-actions.ts",
|
||||
"crafting-design.ts",
|
||||
"crafting-equipment.ts",
|
||||
"crafting-fabricator.ts",
|
||||
"crafting-utils.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/crafting-equipment-tick.ts",
|
||||
"stores/crafting-initial-state.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"types/equipmentSlot.ts",
|
||||
"utils/result.ts",
|
||||
"utils/safe-persist.ts"
|
||||
],
|
||||
"stores/craftingStore.types.ts": [
|
||||
"types.ts",
|
||||
"types/equipmentSlot.ts"
|
||||
],
|
||||
"stores/discipline-slice.ts": [
|
||||
"data/disciplines/base.ts",
|
||||
"data/disciplines/elemental-regen-advanced.ts",
|
||||
"data/disciplines/elemental-regen.ts",
|
||||
"data/disciplines/elemental.ts",
|
||||
"data/disciplines/enchanter-special.ts",
|
||||
"data/disciplines/enchanter-spells.ts",
|
||||
"data/disciplines/enchanter-utility.ts",
|
||||
"data/disciplines/enchanter.ts",
|
||||
"data/disciplines/fabricator.ts",
|
||||
"data/disciplines/invoker.ts",
|
||||
"types.ts",
|
||||
"types/disciplines.ts",
|
||||
"utils/discipline-math.ts",
|
||||
"utils/safe-persist.ts"
|
||||
],
|
||||
"stores/gameActions.ts": [
|
||||
"effects/discipline-effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/gameStore.types.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"upgrade-effects.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/gameHooks.ts": [
|
||||
"constants.ts",
|
||||
"effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"stores/craftingStore.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/gameLoopActions.ts": [
|
||||
"constants.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/gameStore.types.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
@@ -629,25 +566,37 @@
|
||||
"stores/gameStore.ts": [
|
||||
"constants.ts",
|
||||
"data/attunements.ts",
|
||||
"special-effects.ts",
|
||||
"stores/attunementStore.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/gameActions.ts",
|
||||
"stores/gameLoopActions.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"upgrade-effects.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/index.ts": [
|
||||
"constants.ts",
|
||||
"store-modules/computed-stats.ts",
|
||||
"data/guardian-encounters.ts",
|
||||
"effects.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.types.ts",
|
||||
"stores/attunementStore.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/craftingStore.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameActions.ts",
|
||||
"stores/gameLoopActions.ts",
|
||||
"stores/gameStore.types.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/tick-pipeline.ts",
|
||||
"stores/uiStore.ts",
|
||||
"utils/index.ts",
|
||||
"utils/safe-persist.ts"
|
||||
],
|
||||
"stores/gameStore.types.ts": [],
|
||||
"stores/index.ts": [
|
||||
"constants.ts",
|
||||
"stores/attunementStore.ts",
|
||||
"stores/combat-state.types.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/craftingStore.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameHooks.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/gameStore.types.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
@@ -655,24 +604,36 @@
|
||||
],
|
||||
"stores/manaStore.ts": [
|
||||
"constants.ts",
|
||||
"types.ts"
|
||||
"types.ts",
|
||||
"utils/result.ts",
|
||||
"utils/safe-persist.ts"
|
||||
],
|
||||
"stores/prestigeStore.ts": [
|
||||
"constants.ts",
|
||||
"types.ts"
|
||||
"data/guardian-encounters.ts",
|
||||
"stores/manaStore.ts",
|
||||
"utils/result.ts",
|
||||
"utils/safe-persist.ts"
|
||||
],
|
||||
"stores/uiStore.ts": [],
|
||||
"study-slice.ts": [
|
||||
"constants.ts",
|
||||
"special-effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts"
|
||||
"stores/tick-pipeline.ts": [
|
||||
"stores/attunementStore.ts",
|
||||
"stores/combat-state.types.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameStore.types.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts"
|
||||
],
|
||||
"stores/uiStore.ts": [
|
||||
"utils/safe-persist.ts"
|
||||
],
|
||||
"types.ts": [
|
||||
"data/equipment/types.ts",
|
||||
"types/attunements.ts",
|
||||
"types/elements.ts",
|
||||
"types/equipment.ts",
|
||||
"types/equipmentSlot.ts",
|
||||
"types/game.ts",
|
||||
"types/spells.ts"
|
||||
],
|
||||
@@ -681,7 +642,9 @@
|
||||
"types/elements.ts"
|
||||
],
|
||||
"types/elements.ts": [],
|
||||
"types/equipment.ts": [],
|
||||
"types/equipment.ts": [
|
||||
"types/equipmentSlot.ts"
|
||||
],
|
||||
"types/equipmentSlot.ts": [],
|
||||
"types/game.ts": [
|
||||
"types/attunements.ts",
|
||||
@@ -692,54 +655,70 @@
|
||||
"types/index.ts": [
|
||||
"types/attunements.ts",
|
||||
"types/elements.ts",
|
||||
"types/equipment.ts",
|
||||
"types/equipmentSlot.ts",
|
||||
"types/game.ts",
|
||||
"types/spells.ts"
|
||||
],
|
||||
"types/spells.ts": [],
|
||||
"upgrade-effects.ts": [
|
||||
"dynamic-compute.ts",
|
||||
"special-effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.types.ts"
|
||||
],
|
||||
"upgrade-effects.types.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"utils/activity-log.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"utils/combat-utils.ts": [
|
||||
"constants.ts",
|
||||
"data/enchantment-effects.ts",
|
||||
"types.ts"
|
||||
"data/guardian-data.ts",
|
||||
"data/guardian-encounters.ts",
|
||||
"types.ts",
|
||||
"utils/mana-utils.ts"
|
||||
],
|
||||
"utils/discipline-math.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"utils/enemy-generator.ts": [
|
||||
"types.ts",
|
||||
"utils/enemy-utils.ts",
|
||||
"utils/floor-utils.ts"
|
||||
],
|
||||
"utils/enemy-utils.ts": [
|
||||
"constants.ts",
|
||||
"types.ts",
|
||||
"utils/floor-utils.ts"
|
||||
],
|
||||
"utils/floor-utils.ts": [
|
||||
"constants.ts"
|
||||
"constants.ts",
|
||||
"data/guardian-encounters.ts"
|
||||
],
|
||||
"utils/formatting.ts": [],
|
||||
"utils/index.ts": [
|
||||
"utils/combat-utils.ts",
|
||||
"utils/floor-utils.ts",
|
||||
"utils/formatting.ts",
|
||||
"utils/mana-utils.ts"
|
||||
"utils/mana-utils.ts",
|
||||
"utils/result.ts",
|
||||
"utils/safe-persist.ts"
|
||||
],
|
||||
"utils/mana-utils.ts": [
|
||||
"constants.ts",
|
||||
"data/attunements.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.types.ts"
|
||||
"effects/upgrade-effects.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"utils/pact-utils.ts": [
|
||||
"data/guardian-encounters.ts"
|
||||
],
|
||||
"utils/result.ts": [],
|
||||
"utils/room-utils.ts": [
|
||||
"constants.ts",
|
||||
"data/guardian-encounters.ts",
|
||||
"types.ts",
|
||||
"utils/enemy-utils.ts",
|
||||
"utils/floor-utils.ts"
|
||||
],
|
||||
"utils/safe-persist.ts": [],
|
||||
"utils/spire-utils.ts": [
|
||||
"constants.ts",
|
||||
"data/guardian-encounters.ts",
|
||||
"types.ts",
|
||||
"utils/enemy-utils.ts",
|
||||
"utils/floor-utils.ts"
|
||||
|
||||
+133
-100
@@ -10,17 +10,11 @@ Mana-Loop/
|
||||
│ ├── post-merge
|
||||
│ └── pre-commit
|
||||
├── docs/
|
||||
│ ├── strategy/
|
||||
│ │ └── overall-remediation-plan.md
|
||||
│ ├── GAME_BRIEFING.md
|
||||
│ ├── circular-deps.txt
|
||||
│ ├── dependency-graph.json
|
||||
│ ├── project-structure.txt
|
||||
│ └── skills.md
|
||||
│ └── project-structure.txt
|
||||
├── e2e/
|
||||
│ ├── combat.spec.ts
|
||||
│ ├── enchanting.spec.ts
|
||||
│ └── equipment.spec.ts
|
||||
├── playwright-report/
|
||||
│ ├── data/
|
||||
│ │ ├── 1513ea5b9ea5985996f67ca36f2bc4d34add51f1.webm
|
||||
@@ -58,34 +52,20 @@ Mana-Loop/
|
||||
│ ├── app/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── GameOverScreen.tsx
|
||||
│ │ │ ├── GrimoireTab.tsx
|
||||
│ │ │ └── LeftPanel.tsx
|
||||
│ │ ├── globals.css
|
||||
│ │ ├── layout.tsx
|
||||
│ │ └── page.tsx
|
||||
│ ├── components/
|
||||
│ │ ├── game/
|
||||
│ │ │ ├── GameContext/
|
||||
│ │ │ │ ├── Provider.tsx
|
||||
│ │ │ │ ├── context-create.ts
|
||||
│ │ │ │ ├── hooks.ts
|
||||
│ │ │ │ └── types.ts
|
||||
│ │ │ ├── LootInventory/
|
||||
│ │ │ │ ├── BlueprintsSection.tsx
|
||||
│ │ │ │ ├── EquipmentItem.tsx
|
||||
│ │ │ │ ├── EssenceItem.tsx
|
||||
│ │ │ │ ├── LootInventoryDisplay.tsx
|
||||
│ │ │ │ ├── MaterialItem.tsx
|
||||
│ │ │ │ ├── icons.ts
|
||||
│ │ │ │ ├── index.tsx
|
||||
│ │ │ │ └── types.ts
|
||||
│ │ │ ├── StatsTab/
|
||||
│ │ │ │ ├── ActiveUpgradesSection.tsx
|
||||
│ │ │ │ ├── CombatStatsSection.tsx
|
||||
│ │ │ │ ├── ElementStatsSection.tsx
|
||||
│ │ │ │ ├── LoopStatsSection.tsx
|
||||
│ │ │ │ ├── ManaStatsSection.tsx
|
||||
│ │ │ │ ├── PactStatusSection.tsx
|
||||
│ │ │ │ └── StudyStatsSection.tsx
|
||||
│ │ │ ├── crafting/
|
||||
│ │ │ │ ├── EnchantmentDesigner/
|
||||
│ │ │ │ │ ├── DesignForm.tsx
|
||||
@@ -105,36 +85,71 @@ Mana-Loop/
|
||||
│ │ │ │ ├── GameStateDebug.tsx
|
||||
│ │ │ │ ├── GolemDebug.tsx
|
||||
│ │ │ │ ├── PactDebug.tsx
|
||||
│ │ │ │ ├── debug-context.tsx
|
||||
│ │ │ │ └── index.tsx
|
||||
│ │ │ ├── layout/
|
||||
│ │ │ │ ├── Header.tsx
|
||||
│ │ │ │ └── TabBar.tsx
|
||||
│ │ │ ├── shared/
|
||||
│ │ │ │ ├── MemorySlotPicker.tsx
|
||||
│ │ │ │ ├── StudyProgress.tsx
|
||||
│ │ │ │ └── UpgradeDialog.tsx
|
||||
│ │ │ ├── stats/
|
||||
│ │ │ │ ├── CombatStatsSection.tsx
|
||||
│ │ │ │ ├── ManaStatsSection.tsx
|
||||
│ │ │ │ ├── ManaTypeBreakdown.tsx
|
||||
│ │ │ │ ├── StudyStatsSection.tsx
|
||||
│ │ │ │ ├── UpgradeEffectsSection.tsx
|
||||
│ │ │ │ └── index.tsx
|
||||
│ │ │ ├── tabs/
|
||||
│ │ │ │ └── DisciplinesTab.tsx
|
||||
│ │ │ ├── AchievementsDisplay.tsx
|
||||
│ │ │ │ ├── CraftingTab/
|
||||
│ │ │ │ │ ├── EnchanterSubTab.tsx
|
||||
│ │ │ │ │ ├── FabricatorSubTab.tsx
|
||||
│ │ │ │ │ └── MaterialRecipeCard.tsx
|
||||
│ │ │ │ ├── DebugTab/
|
||||
│ │ │ │ │ ├── AchievementDebugSection.tsx
|
||||
│ │ │ │ │ ├── AttunementDebugSection.tsx
|
||||
│ │ │ │ │ ├── DisciplineDebugSection.tsx
|
||||
│ │ │ │ │ ├── ElementDebugSection.tsx
|
||||
│ │ │ │ │ ├── GameStateDebugSection.tsx
|
||||
│ │ │ │ │ ├── GolemDebugSection.tsx
|
||||
│ │ │ │ │ ├── PactDebugSection.tsx
|
||||
│ │ │ │ │ └── SpireDebugSection.tsx
|
||||
│ │ │ │ ├── EquipmentTab/
|
||||
│ │ │ │ │ ├── EquipmentEffectsSummary.tsx
|
||||
│ │ │ │ │ ├── EquipmentSlotGrid.tsx
|
||||
│ │ │ │ │ └── InventoryList.tsx
|
||||
│ │ │ │ ├── SpireCombatPage/
|
||||
│ │ │ │ │ ├── RoomDisplay.tsx
|
||||
│ │ │ │ │ ├── SpireActivityLog.tsx
|
||||
│ │ │ │ │ ├── SpireCombatControls.tsx
|
||||
│ │ │ │ │ ├── SpireCombatPage.tsx
|
||||
│ │ │ │ │ ├── SpireHeader.tsx
|
||||
│ │ │ │ │ ├── SpireManaDisplay.tsx
|
||||
│ │ │ │ │ └── index.ts
|
||||
│ │ │ │ ├── StatsTab/
|
||||
│ │ │ │ │ ├── CombatStatsSection.tsx
|
||||
│ │ │ │ │ ├── DisciplineStatsSection.tsx
|
||||
│ │ │ │ │ ├── ElementStatsSection.tsx
|
||||
│ │ │ │ │ ├── LoopStatsSection.tsx
|
||||
│ │ │ │ │ ├── ManaStatsSection.tsx
|
||||
│ │ │ │ │ ├── PactStatusSection.tsx
|
||||
│ │ │ │ │ └── StudyStatsSection.tsx
|
||||
│ │ │ │ ├── AchievementsTab.tsx
|
||||
│ │ │ │ ├── ActivityLog.tsx
|
||||
│ │ │ │ ├── AttunementsTab.test.ts
|
||||
│ │ │ │ ├── AttunementsTab.tsx
|
||||
│ │ │ │ ├── CraftingTab.test.ts
|
||||
│ │ │ │ ├── CraftingTab.tsx
|
||||
│ │ │ │ ├── DebugTab.test.ts
|
||||
│ │ │ │ ├── DebugTab.tsx
|
||||
│ │ │ │ ├── DisciplinesTab.tsx
|
||||
│ │ │ │ ├── EquipmentTab.test.ts
|
||||
│ │ │ │ ├── EquipmentTab.tsx
|
||||
│ │ │ │ ├── GolemancyTab.test.ts
|
||||
│ │ │ │ ├── GolemancyTab.tsx
|
||||
│ │ │ │ ├── GuardianPactsTab.test.ts
|
||||
│ │ │ │ ├── GuardianPactsTab.tsx
|
||||
│ │ │ │ ├── PrestigeTab.test.ts
|
||||
│ │ │ │ ├── PrestigeTab.tsx
|
||||
│ │ │ │ ├── SpellsTab.tsx
|
||||
│ │ │ │ ├── SpireSummaryTab.test.ts
|
||||
│ │ │ │ ├── SpireSummaryTab.tsx
|
||||
│ │ │ │ ├── StatsTab.tsx
|
||||
│ │ │ │ ├── guardian-pacts-components.tsx
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── ActionButtons.tsx
|
||||
│ │ │ ├── ActivityLogPanel.tsx
|
||||
│ │ │ ├── AttunementStatus.tsx
|
||||
│ │ │ ├── CalendarDisplay.tsx
|
||||
│ │ │ ├── ConfirmDialog.tsx
|
||||
│ │ │ ├── CraftingProgress.tsx
|
||||
│ │ │ ├── GameContext.tsx
|
||||
│ │ │ ├── GameToast.tsx
|
||||
│ │ │ ├── ManaDisplay.tsx
|
||||
│ │ │ ├── SpellsTab.tsx
|
||||
│ │ │ ├── StatsTab.tsx
|
||||
│ │ │ ├── StudyProgress.tsx
|
||||
│ │ │ ├── TimeDisplay.tsx
|
||||
│ │ │ ├── UpgradeDialog.tsx
|
||||
│ │ │ ├── index.ts
|
||||
@@ -168,6 +183,7 @@ Mana-Loop/
|
||||
│ │ │ ├── toggle.tsx
|
||||
│ │ │ ├── tooltip-info.tsx
|
||||
│ │ │ ├── tooltip.tsx
|
||||
│ │ │ ├── ui-components.test.tsx
|
||||
│ │ │ └── value-display.tsx
|
||||
│ │ └── ErrorBoundary.tsx
|
||||
│ ├── hooks/
|
||||
@@ -177,13 +193,42 @@ Mana-Loop/
|
||||
│ ├── game/
|
||||
│ │ ├── __tests__/
|
||||
│ │ │ ├── store-method-tests/
|
||||
│ │ │ ├── achievements.test.ts
|
||||
│ │ │ ├── activity-log.test.ts
|
||||
│ │ │ ├── bug-fixes.test.ts
|
||||
│ │ │ └── computed-stats.test.ts
|
||||
│ │ ├── attunements/
|
||||
│ │ │ ├── data.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ └── utils.ts
|
||||
│ │ │ ├── combat-actions.test.ts
|
||||
│ │ │ ├── combat-utils.test.ts
|
||||
│ │ │ ├── computed-stats.test.ts
|
||||
│ │ │ ├── crafting-utils-basic.test.ts
|
||||
│ │ │ ├── crafting-utils-equipment.test.ts
|
||||
│ │ │ ├── crafting-utils-recipe.test.ts
|
||||
│ │ │ ├── crafting-utils-time.test.ts
|
||||
│ │ │ ├── cross-module-combat-meditation.test.ts
|
||||
│ │ │ ├── cross-module-helpers.ts
|
||||
│ │ │ ├── cross-module-lifecycle-consistency.test.ts
|
||||
│ │ │ ├── cross-module-prestige-discipline.test.ts
|
||||
│ │ │ ├── discipline-math.test.ts
|
||||
│ │ │ ├── discipline-prerequisites.test.ts
|
||||
│ │ │ ├── discipline-reactivate-bug.test.ts
|
||||
│ │ │ ├── enemy-barrier-utils.test.ts
|
||||
│ │ │ ├── enemy-generator.test.ts
|
||||
│ │ │ ├── enemy-utils.test.ts
|
||||
│ │ │ ├── floor-utils.test.ts
|
||||
│ │ │ ├── floor-utils.upgraded.test.ts
|
||||
│ │ │ ├── formatting.test.ts
|
||||
│ │ │ ├── guardian-names.test.ts
|
||||
│ │ │ ├── mana-utils.test.ts
|
||||
│ │ │ ├── pact-utils.test.ts
|
||||
│ │ │ ├── persistence.test.ts
|
||||
│ │ │ ├── regression-fixes.test.ts
|
||||
│ │ │ ├── room-utils-floor-state.test.ts
|
||||
│ │ │ ├── room-utils.test.ts
|
||||
│ │ │ ├── spire-utils.test.ts
|
||||
│ │ │ ├── store-actions-combat-prestige.test.ts
|
||||
│ │ │ ├── store-actions-discipline.test.ts
|
||||
│ │ │ ├── store-actions-mana.test.ts
|
||||
│ │ │ ├── store-actions.test.ts
|
||||
│ │ │ └── tick-integration.test.ts
|
||||
│ │ ├── constants/
|
||||
│ │ │ ├── spells-modules/
|
||||
│ │ │ │ ├── advanced-spells.ts
|
||||
@@ -198,7 +243,6 @@ Mana-Loop/
|
||||
│ │ │ │ └── utility-spells.ts
|
||||
│ │ │ ├── core.ts
|
||||
│ │ │ ├── elements.ts
|
||||
│ │ │ ├── guardians.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── prestige.ts
|
||||
│ │ │ ├── rooms.ts
|
||||
@@ -207,6 +251,7 @@ Mana-Loop/
|
||||
│ │ │ ├── application-actions.ts
|
||||
│ │ │ ├── computed-getters.ts
|
||||
│ │ │ ├── crafting-equipment-actions.ts
|
||||
│ │ │ ├── crafting-material-actions.ts
|
||||
│ │ │ ├── design-actions.ts
|
||||
│ │ │ ├── disenchant-actions.ts
|
||||
│ │ │ ├── equipment-actions.ts
|
||||
@@ -214,18 +259,22 @@ Mana-Loop/
|
||||
│ │ │ └── preparation-actions.ts
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── disciplines/
|
||||
│ │ │ │ ├── base-disciplines.ts
|
||||
│ │ │ │ ├── base.ts
|
||||
│ │ │ │ ├── enchanter-disciplines.ts
|
||||
│ │ │ │ ├── elemental-regen-advanced.ts
|
||||
│ │ │ │ ├── elemental-regen.ts
|
||||
│ │ │ │ ├── elemental.ts
|
||||
│ │ │ │ ├── enchanter-special.ts
|
||||
│ │ │ │ ├── enchanter-spells.ts
|
||||
│ │ │ │ ├── enchanter-utility.ts
|
||||
│ │ │ │ ├── enchanter.ts
|
||||
│ │ │ │ ├── fabricator-disciplines.ts
|
||||
│ │ │ │ ├── fabricator.ts
|
||||
│ │ │ │ ├── invoker-disciplines.ts
|
||||
│ │ │ │ ├── index.ts
|
||||
│ │ │ │ └── invoker.ts
|
||||
│ │ │ ├── enchantments/
|
||||
│ │ │ │ ├── spell-effects/
|
||||
│ │ │ │ │ ├── basic-spells.ts
|
||||
│ │ │ │ │ ├── index.ts
|
||||
│ │ │ │ │ ├── legendary-spells.ts
|
||||
│ │ │ │ │ ├── lightning-spells.ts
|
||||
│ │ │ │ │ ├── metal-spells.ts
|
||||
│ │ │ │ │ ├── sand-spells.ts
|
||||
@@ -244,6 +293,7 @@ Mana-Loop/
|
||||
│ │ │ │ ├── body.ts
|
||||
│ │ │ │ ├── casters.ts
|
||||
│ │ │ │ ├── catalysts.ts
|
||||
│ │ │ │ ├── equipment-types-data.ts
|
||||
│ │ │ │ ├── feet.ts
|
||||
│ │ │ │ ├── hands.ts
|
||||
│ │ │ │ ├── head.ts
|
||||
@@ -255,6 +305,7 @@ Mana-Loop/
|
||||
│ │ │ ├── golems/
|
||||
│ │ │ │ ├── base-golems.ts
|
||||
│ │ │ │ ├── elemental-golems.ts
|
||||
│ │ │ │ ├── golems-data.ts
|
||||
│ │ │ │ ├── hybrid-golems.ts
|
||||
│ │ │ │ ├── index.ts
|
||||
│ │ │ │ ├── types.ts
|
||||
@@ -264,51 +315,41 @@ Mana-Loop/
|
||||
│ │ │ ├── crafting-recipes.ts
|
||||
│ │ │ ├── enchantment-effects.ts
|
||||
│ │ │ ├── enchantment-types.ts
|
||||
│ │ │ ├── fabricator-material-recipes.ts
|
||||
│ │ │ ├── fabricator-physical-recipes.ts
|
||||
│ │ │ ├── fabricator-recipe-types.ts
|
||||
│ │ │ ├── fabricator-recipes.ts
|
||||
│ │ │ ├── fabricator-wizard-recipes.ts
|
||||
│ │ │ ├── guardian-data.ts
|
||||
│ │ │ ├── guardian-encounters.ts
|
||||
│ │ │ └── loot-drops.ts
|
||||
│ │ ├── effects/
|
||||
│ │ │ └── discipline-effects.ts
|
||||
│ │ │ ├── discipline-effects.ts
|
||||
│ │ │ ├── dynamic-compute.ts
|
||||
│ │ │ ├── special-effects.ts
|
||||
│ │ │ ├── upgrade-effects.ts
|
||||
│ │ │ └── upgrade-effects.types.ts
|
||||
│ │ ├── hooks/
|
||||
│ │ │ └── useGameDerived.ts
|
||||
│ │ ├── store/
|
||||
│ │ │ ├── crafting-modules/
|
||||
│ │ │ │ ├── initial-state.ts
|
||||
│ │ │ │ ├── selectors.ts
|
||||
│ │ │ │ ├── slice-logic.ts
|
||||
│ │ │ │ ├── starting-equipment.ts
|
||||
│ │ │ │ ├── tick-processors.ts
|
||||
│ │ │ │ ├── types.ts
|
||||
│ │ │ │ └── utils.ts
|
||||
│ │ │ ├── combatSlice.ts
|
||||
│ │ │ ├── computed.ts
|
||||
│ │ │ ├── craftingSlice.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── manaSlice.ts
|
||||
│ │ │ ├── pactSlice.ts
|
||||
│ │ │ ├── prestigeSlice.ts
|
||||
│ │ │ └── timeSlice.ts
|
||||
│ │ ├── store-modules/
|
||||
│ │ │ ├── {room-utils,enemy-utils,initial-state,activity-log,store-actions}/
|
||||
│ │ │ ├── activity-log.ts
|
||||
│ │ │ ├── computed-stats.ts
|
||||
│ │ │ ├── enemy-utils.ts
|
||||
│ │ │ ├── initial-state.ts
|
||||
│ │ │ ├── room-utils.ts
|
||||
│ │ │ ├── store-actions.ts
|
||||
│ │ │ └── tick-logic.ts
|
||||
│ │ ├── stores/
|
||||
│ │ │ ├── attunementStore.ts
|
||||
│ │ │ ├── combat-actions.ts
|
||||
│ │ │ ├── combat-state.types.ts
|
||||
│ │ │ ├── combatStore.ts
|
||||
│ │ │ ├── crafting-equipment-tick.ts
|
||||
│ │ │ ├── crafting-initial-state.ts
|
||||
│ │ │ ├── craftingStore.ts
|
||||
│ │ │ ├── craftingStore.types.ts
|
||||
│ │ │ ├── discipline-slice.ts
|
||||
│ │ │ ├── gameActions.ts
|
||||
│ │ │ ├── gameHooks.ts
|
||||
│ │ │ ├── gameLoopActions.ts
|
||||
│ │ │ ├── gameStore.ts
|
||||
│ │ │ ├── index.test.ts
|
||||
│ │ │ ├── gameStore.types.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── manaStore.ts
|
||||
│ │ │ ├── prestigeStore.ts
|
||||
│ │ │ ├── tick-pipeline.ts
|
||||
│ │ │ └── uiStore.ts
|
||||
│ │ ├── types/
|
||||
│ │ │ ├── attunements.ts
|
||||
@@ -323,43 +364,34 @@ Mana-Loop/
|
||||
│ │ │ ├── activity-log.ts
|
||||
│ │ │ ├── combat-utils.ts
|
||||
│ │ │ ├── discipline-math.ts
|
||||
│ │ │ ├── enemy-generator.ts
|
||||
│ │ │ ├── enemy-utils.ts
|
||||
│ │ │ ├── floor-utils.ts
|
||||
│ │ │ ├── formatting.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── mana-utils.ts
|
||||
│ │ │ └── room-utils.ts
|
||||
│ │ ├── computed-stats.ts
|
||||
│ │ │ ├── pact-utils.ts
|
||||
│ │ │ ├── result.ts
|
||||
│ │ │ ├── room-utils.ts
|
||||
│ │ │ ├── safe-persist.ts
|
||||
│ │ │ └── spire-utils.ts
|
||||
│ │ ├── constants.ts
|
||||
│ │ ├── crafting-apply.ts
|
||||
│ │ ├── crafting-attunements.ts
|
||||
│ │ ├── crafting-design.ts
|
||||
│ │ ├── crafting-equipment.ts
|
||||
│ │ ├── crafting-fabricator.ts
|
||||
│ │ ├── crafting-loot.ts
|
||||
│ │ ├── crafting-prep.ts
|
||||
│ │ ├── crafting-slice.ts
|
||||
│ │ ├── crafting-utils.ts
|
||||
│ │ ├── debug-context.tsx
|
||||
│ │ ├── dynamic-compute.ts
|
||||
│ │ ├── effects.ts
|
||||
│ │ ├── effects.ts.fix
|
||||
│ │ ├── formatting.ts
|
||||
│ │ ├── navigation-slice.ts
|
||||
│ │ ├── special-effects.ts
|
||||
│ │ ├── store.test.ts
|
||||
│ │ ├── store.ts
|
||||
│ │ ├── stores.test.ts
|
||||
│ │ ├── study-slice.ts
|
||||
│ │ ├── types.ts
|
||||
│ │ ├── upgrade-effects.ts
|
||||
│ │ └── upgrade-effects.types.ts
|
||||
│ │ └── types.ts
|
||||
│ └── utils.ts
|
||||
├── test-results/
|
||||
│ └── .last-run.json
|
||||
├── .dockerignore
|
||||
├── .gitignore
|
||||
├── AGENTS.md
|
||||
├── CLAUDE.md
|
||||
├── Caddyfile
|
||||
├── Dockerfile
|
||||
├── README.md
|
||||
@@ -373,6 +405,7 @@ Mana-Loop/
|
||||
├── package.json
|
||||
├── playwright.config.ts
|
||||
├── postcss.config.mjs
|
||||
├── scorecard.png
|
||||
├── tailwind.config.ts
|
||||
├── tsconfig.json
|
||||
└── vitest.config.ts
|
||||
|
||||
-726
@@ -1,726 +0,0 @@
|
||||
# Mana Loop - Complete Skill System Documentation
|
||||
|
||||
## Table of Contents
|
||||
1. [Overview](#overview)
|
||||
2. [Core Mechanics](#core-mechanics)
|
||||
3. [Skill Categories](#skill-categories)
|
||||
4. [All Skills Reference](#all-skills-reference)
|
||||
5. [Upgrade Trees](#upgrade-trees)
|
||||
6. [Tier System](#tier-system)
|
||||
7. [Banned Content](#banned-content)
|
||||
8. [Code Architecture](#code-architecture)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The skill system in Mana Loop provides deep character customization through a branching upgrade tree system. Skills are organized by attunement, with each attunement granting access to specific skill categories.
|
||||
|
||||
### Skill Level Types
|
||||
|
||||
| Max Level | Description | Example Skills |
|
||||
|-----------|-------------|----------------|
|
||||
| 10 | Standard skills with full upgrade trees | Mana Well, Mana Flow, Enchanting |
|
||||
| 5 | Specialized skills with limited upgrades | Efficient Enchant, Golem Mastery |
|
||||
| 3 | Focused skills with no upgrades | Knowledge Retention, Golem Longevity |
|
||||
| 1 | Effect research skills (unlock only) | All research skills |
|
||||
|
||||
---
|
||||
|
||||
## Core Mechanics
|
||||
|
||||
### 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 Cost Formula
|
||||
```
|
||||
cost = baseCost × (currentLevel + 1) × tier × costMultiplier
|
||||
```
|
||||
|
||||
#### Study Time Formula
|
||||
```
|
||||
time = baseStudyTime × tier / studySpeedMultiplier
|
||||
```
|
||||
|
||||
### Milestone Upgrades
|
||||
|
||||
At **levels 5 and 10**, you choose **1 upgrade** from an upgrade tree:
|
||||
- Each skill has its own unique upgrade tree
|
||||
- Trees have branching paths with prerequisites
|
||||
- Choices are permanent for that tier
|
||||
- Upgrades persist when tiering up
|
||||
|
||||
---
|
||||
|
||||
## Skill Categories
|
||||
|
||||
### Core Categories (No Attunement Required)
|
||||
|
||||
| Category | Icon | Description |
|
||||
|----------|------|-------------|
|
||||
| Mana | 💧 | Mana pool and regeneration |
|
||||
| Study | 📚 | Learning speed and efficiency |
|
||||
| Research | 🔮 | Permanent bonuses |
|
||||
|
||||
### Attunement Categories
|
||||
|
||||
| Category | Icon | Attunement | Description | Status |
|
||||
|----------|------|------------|-------------|---------|
|
||||
| Enchanting | ✨ | Enchanter | Enchantment design and efficiency | ✅ Implemented (T1-T5) |
|
||||
| Effect Research | 🔬 | Enchanter | Unlock spell enchantments | ✅ Implemented (max:1) |
|
||||
| Invocation | 💜 | Invoker | Pact-based abilities | ✅ Implemented (T1-T5) |
|
||||
| Pact Mastery | 🤝 | Invoker | Guardian pact bonuses | ✅ Implemented (T1-T5) |
|
||||
| Fabrication | ⚒️ | Fabricator | Crafting and construction | ✅ Implemented (T1-T5) |
|
||||
| Golemancy | 🗿 | Fabricator | Golem summoning and control | ✅ Implemented (T1-T5) |
|
||||
| Hybrid Skills | 🔮 | Dual Attunement | Cross-attunement powers | ✅ Implemented (T1-T5) |
|
||||
|
||||
---
|
||||
|
||||
## All Skills Reference
|
||||
|
||||
### Mana Skills (Core)
|
||||
|
||||
| Skill | Max | Effect | Base Cost | Study Time |
|
||||
|-------|-----|--------|-----------|------------|
|
||||
| Mana Well | 10 | +100 max mana/level | 100 | 4h |
|
||||
| Mana Flow | 10 | +1 regen/hour/level | 150 | 5h |
|
||||
| Elemental Attunement | 10 | +50 element cap/level | 200 | 4h |
|
||||
| Mana Overflow | 5 | +25% click mana/level | 400 | 6h |
|
||||
|
||||
**Prerequisites:**
|
||||
- Mana Overflow: Mana Well 3
|
||||
|
||||
### Study Skills (Core)
|
||||
|
||||
| Skill | Max | Effect | Base Cost | Study Time |
|
||||
|-------|-----|--------|-----------|------------|
|
||||
| Quick Learner | 10 | +10% study speed/level | 250 | 4h |
|
||||
| Focused Mind | 10 | -5% study cost/level | 300 | 5h |
|
||||
| Meditation Focus | 1 | Up to 2.5x regen after 4hrs | 400 | 6h |
|
||||
| Knowledge Retention | 3 | +20% progress saved on cancel/level | 350 | 5h |
|
||||
|
||||
### Research Skills (Core)
|
||||
|
||||
| Skill | Max | Effect | Base Cost | Study Time |
|
||||
|-------|-----|--------|-----------|------------|
|
||||
| Mana Tap | 1 | +1 mana/click | 300 | 12h |
|
||||
| Mana Surge | 1 | +3 mana/click | 800 | 36h |
|
||||
| Mana Spring | 1 | +2 mana regen | 600 | 24h |
|
||||
| Deep Trance | 1 | 6hr meditation = 3x regen | 900 | 48h |
|
||||
| Void Meditation | 1 | 8hr meditation = 5x regen | 1500 | 72h |
|
||||
|
||||
**Prerequisites:**
|
||||
- Mana Surge: Mana Tap 1
|
||||
- Deep Trance: Meditation 1
|
||||
- Void Meditation: Deep Trance 1
|
||||
|
||||
### Enchanting Skills (Enchanter)
|
||||
|
||||
| Skill | Max | Effect | Base Cost | Study Time | Attunement Req |
|
||||
|-------|-----|--------|-----------|------------|----------------|
|
||||
| Enchanting | 10 | Unlocks enchantment design | 200 | 5h | Enchanter 1 |
|
||||
| Efficient Enchant | 5 | -5% capacity cost/level | 350 | 6h | Enchanter 2 |
|
||||
| Disenchanting | 3 | +20% mana recovery/level | 400 | 6h | Enchanter 1 |
|
||||
| Enchant Speed | 5 | -10% enchant time/level | 300 | 4h | Enchanter 1 |
|
||||
| Essence Refining | 1 | +10% effect power | 450 | 7h | Enchanter 2 |
|
||||
|
||||
**Prerequisites:**
|
||||
- Efficient Enchant: Enchanting 3
|
||||
- Disenchanting: Enchanting 2
|
||||
- Enchant Speed: Enchanting 2
|
||||
- Essence Refining: Enchanting 4
|
||||
|
||||
### Golemancy Skills (Fabricator)
|
||||
|
||||
| Skill | Max | Effect | Base Cost | Study Time | Attunement Req |
|
||||
|-------|-----|--------|-----------|------------|----------------|
|
||||
| Golem Mastery | 5 | +10% golem damage/level | 300 | 6h | Fabricator 2 |
|
||||
| Golem Efficiency | 5 | +5% attack speed/level | 350 | 6h | Fabricator 2 |
|
||||
| Golem Longevity | 3 | +1 floor duration/level | 500 | 8h | Fabricator 3 |
|
||||
| Golem Siphon | 3 | -10% maintenance/level | 400 | 8h | Fabricator 3 |
|
||||
| Advanced Golemancy | 1 | Unlock hybrid recipes | 800 | 16h | Fabricator 5 |
|
||||
| Golem Resonance | 1 | +1 golem slot | 1200 | 24h | Fabricator 8 |
|
||||
|
||||
**Prerequisites:**
|
||||
- Advanced Golemancy: Golem Mastery 3
|
||||
- Golem Resonance: Golem Mastery 5
|
||||
|
||||
---
|
||||
|
||||
## Hybrid Skills
|
||||
|
||||
Hybrid Skills require two attunements and combine their powers into advanced abilities.
|
||||
|
||||
**Code Location:** All hybrid skills are defined in `src/lib/game/skill-evolution-modules/hybrid-skills.ts`
|
||||
|
||||
### Pact-Weaving (Invoker + Enchanter)
|
||||
|
||||
**Requirement:** Invoker 3 + Enchanter 3
|
||||
**Max Level:** 5 (with Elite Perk at Level 5)
|
||||
**Location:** `skill-evolution-modules/hybrid-skills.ts`
|
||||
|
||||
**Paths:**
|
||||
- **Path A: The Weaver** - Enhanced enchantment power through pact bonuses
|
||||
- **Path B: The Warp** - Unpredictable magic blending pacts and enchantments
|
||||
- **Path C: The World-Weaver** - Ultimate hybrid combining all powers
|
||||
|
||||
**5-Tier Talent Tree:**
|
||||
|
||||
| Tier | Level | Effect |
|
||||
|------|-------|--------|
|
||||
| 1 | 1-2 | +10% enchantment power when pact active |
|
||||
| 2 | 3-4 | +25% enchantment power when pact active |
|
||||
| 3 | 5-6 | Pact boons apply to enchanted equipment |
|
||||
| 4 | 7-8 | +50% enchantment power when pact active |
|
||||
| 5 | 9-10 | Elite Perk: Choose one |
|
||||
|
||||
**Elite Perks (Choose at Tier 5 Level 10):**
|
||||
- **Eternal Weave:** Enchantments persist through loops
|
||||
- **Pactbound Power:** All pact multipliers doubled for enchanted items
|
||||
- **Weaver's Boon:** 25% chance to double enchantment effect
|
||||
|
||||
**Level 5 Upgrade Choices:**
|
||||
- +50% enchantment power when pact active
|
||||
- Pact boons apply to all equipment slots
|
||||
- 10% chance to trigger pact effect on enchant
|
||||
|
||||
**Level 10 Upgrade Choices:**
|
||||
- Elite Perk (choose one from above)
|
||||
- +100% enchantment power when pact active
|
||||
- All pacts active simultaneously
|
||||
|
||||
---
|
||||
|
||||
### Guardian Constructs (Fabricator + Invoker)
|
||||
|
||||
**Requirement:** Fabricator 3 + Invoker 3
|
||||
**Max Level:** 5 (with Elite Perk at Level 5)
|
||||
**Location:** `skill-evolution-modules/hybrid-skills.ts`
|
||||
|
||||
**Paths:**
|
||||
- **Path A: The Architect** - Durable constructs with enhanced defenses
|
||||
- **Path B: The Monumentalist** - Massive single construct with supreme power
|
||||
- **Path C: The Eternal** - Constructs that never expire
|
||||
|
||||
**Special Rules:**
|
||||
- Only **1 active at a time** (replaces golems)
|
||||
- **More durable** than golems (2x HP, 1.5x duration)
|
||||
- Uses both Earth and Pact mana for summoning
|
||||
|
||||
**5-Tier Talent Tree:**
|
||||
|
||||
| Tier | Level | Effect |
|
||||
|------|-------|--------|
|
||||
| 1 | 1-2 | +25% construct HP |
|
||||
| 2 | 3-4 | Construct lasts +2 floors |
|
||||
| 3 | 5-6 | Construct gains pact bonuses |
|
||||
| 4 | 7-8 | +50% construct damage |
|
||||
| 5 | 9-10 | Elite Perk: Choose one |
|
||||
|
||||
**Elite Perks (Choose at Tier 5 Level 10):**
|
||||
- **Living Monument:** Construct HP +500%, never expires
|
||||
- **Guardian's Might:** Construct gains all pact multipliers
|
||||
- **Architect's Dream:** Can have 2 constructs (reduces HP by 50% each)
|
||||
|
||||
**Level 5 Upgrade Choices:**
|
||||
- +50% construct HP
|
||||
- Construct immune to floor effects
|
||||
- +25% construct damage
|
||||
|
||||
**Level 10 Upgrade Choices:**
|
||||
- Elite Perk (choose one from above)
|
||||
- Construct gains 100% of your pact multipliers
|
||||
- +500% construct HP
|
||||
|
||||
---
|
||||
|
||||
### Enchanted Golemancy (Fabricator + Enchanter)
|
||||
|
||||
**Requirement:** Fabricator 3 + Enchanter 3
|
||||
**Max Level:** 5 (with Elite Perk at Level 5)
|
||||
**Location:** `skill-evolution-modules/hybrid-skills.ts`
|
||||
|
||||
**Paths:**
|
||||
- **Path A: The Battle-Smith** - Combat-focused enchanted golems
|
||||
- **Path B: The Enchanter-Smith** - Golems with powerful enchantments
|
||||
- **Path C: The Spell-Smith** - Golems that cast elemental spells
|
||||
|
||||
**Special Rules:**
|
||||
- Imbues golems with **elemental spell logic**
|
||||
- Golems gain spell abilities from enchantments
|
||||
- Combines golem durability with spell power
|
||||
|
||||
**5-Tier Talent Tree:**
|
||||
|
||||
| Tier | Level | Effect |
|
||||
|------|-------|--------|
|
||||
| 1 | 1-2 | Golems gain 1 spell slot |
|
||||
| 2 | 3-4 | +25% golem spell damage |
|
||||
| 3 | 5-6 | Golems gain 2 spell slots |
|
||||
| 4 | 7-8 | +50% golem spell damage |
|
||||
| 5 | 9-10 | Elite Perk: Choose one |
|
||||
|
||||
**Elite Perks (Choose at Tier 5 Level 10):**
|
||||
- **Arcane Golem:** Golems cast spells at 3x speed
|
||||
- **Elemental Master:** Golem spells gain +100% elemental bonus
|
||||
- **Living Spellforge:** Golems create temporary enchantments
|
||||
|
||||
**Level 5 Upgrade Choices:**
|
||||
- +50% golem spell damage
|
||||
- Golems gain 3 spell slots
|
||||
- Golem spells gain pact bonuses
|
||||
|
||||
**Level 10 Upgrade Choices:**
|
||||
- Elite Perk (choose one from above)
|
||||
- Golem spells deal +200% damage
|
||||
- Golems permanently enchanted
|
||||
|
||||
---
|
||||
|
||||
### Effect Research Skills (Enchanter)
|
||||
|
||||
All effect research skills are **max level 1** and unlock specific enchantment effects.
|
||||
|
||||
**Code Location:** Skill definitions in `src/lib/game/constants/skills.ts`, research logic in `src/lib/game/skill-evolution-modules/enchanting-skills.ts`
|
||||
|
||||
#### Tier1 Research (Basic Spells)
|
||||
| Skill | Unlocks | Study Time |
|
||||
|-------|---------|------------|
|
||||
| Mana Spell Research | Mana Strike enchantment | 4h |
|
||||
| Fire Spell Research | Ember Shot, Fireball | 6h |
|
||||
| Water Spell Research | Water Jet, Ice Shard | 6h |
|
||||
| Air Spell Research | Gust, Wind Slash | 6h |
|
||||
| Earth Spell Research | Stone Bullet, Rock Spike | 6h |
|
||||
| Light Spell Research | Light Lance, Radiance | 8h |
|
||||
| Dark Spell Research | Shadow Bolt, Dark Pulse | 8h |
|
||||
| Death Research | Drain enchantment | 8h |
|
||||
|
||||
#### Tier2 Research (Advanced Spells)
|
||||
Requires Enchanter 3+ and parent element research.
|
||||
|
||||
| Skill | Unlocks | Study Time |
|
||||
|-------|---------|------------|
|
||||
| Advanced Fire Research | Inferno, Flame Wave | 12h |
|
||||
| Advanced Water Research | Tidal Wave, Ice Storm | 12h |
|
||||
| Advanced Air Research | Hurricane, Wind Blade | 12h |
|
||||
| Advanced Earth Research | Earthquake, Stone Barrage | 12h |
|
||||
| Advanced Light Research | Solar Flare, Divine Smite | 14h |
|
||||
| Advanced Dark Research | Void Rift, Shadow Storm | 14h |
|
||||
|
||||
#### Tier3 Research (Master Spells)
|
||||
Requires Enchanter 5+ and advanced research.
|
||||
|
||||
| Skill | Unlocks | Study Time |
|
||||
|-------|---------|------------|
|
||||
| Master Fire Research | Pyroclasm | 24h |
|
||||
| Master Water Research | Tsunami | 24h |
|
||||
| Master Earth Research | Meteor Strike | 26h |
|
||||
|
||||
#### Compound Element Research
|
||||
Requires parent element research + Enchanter 3+.
|
||||
|
||||
| Skill | Unlocks | Study Time |
|
||||
|-------|---------|------------|
|
||||
| Metal Spell Research | Metal Shard, Iron Fist | 6h |
|
||||
| Sand Spell Research | Sand Blast, Sandstorm | 6h |
|
||||
| Lightning Spell Research | Spark, Lightning Bolt | 6h |
|
||||
| Advanced Metal Research | Steel Tempest | 12h |
|
||||
| Advanced Sand Research | Desert Wind | 12h |
|
||||
| Advanced Lightning Research | Chain Lightning, Storm Call | 12h |
|
||||
| Master Metal Research | Furnace Blast | 26h |
|
||||
| Master Sand Research | Dune Collapse | 26h |
|
||||
| Master Lightning Research | Thunder Strike | 26h |
|
||||
|
||||
#### Utility Research
|
||||
|
||||
| Skill | Unlocks | Study Time |
|
||||
|-------|---------|------------|
|
||||
| Transference Spell Research | Transfer Strike, Mana Rip | 5h |
|
||||
| Advanced Transference Research | Essence Drain | 12h |
|
||||
| Master Transference Research | Soul Transfer | 26h |
|
||||
|
||||
#### Effect Research
|
||||
|
||||
| Skill | Unlocks | Study Time |
|
||||
|-------|---------|------------|
|
||||
| Damage Effect Research | Minor/Moderate Power, Amplification | 5h |
|
||||
| Combat Effect Research | Sharp Edge, Swift Casting | 6h |
|
||||
| Mana Effect Research | Mana Reserve, Trickle, Mana Tap | 4h |
|
||||
| Advanced Mana Research | Mana Reservoir, Stream, River | 8h |
|
||||
| Utility Effect Research | Meditative Focus, Quick Study | 6h |
|
||||
| Special Effect Research | Echo Chamber, Siphoning, Bane | 10h |
|
||||
| Overpower Research | Overpower effect | 12h |
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Trees
|
||||
|
||||
**Code Location:** All upgrade trees are defined in `src/lib/game/skill-evolution-modules/`:
|
||||
- `mana-well-flow.ts` - Mana Well and Mana Flow upgrades
|
||||
- `enchanting-skills.ts` - Enchanting skill upgrades
|
||||
- `quick-learner.ts` - Quick Learner upgrades
|
||||
- `focused-mind.ts` - Focused Mind upgrades
|
||||
- And more...
|
||||
|
||||
### Mana Well Upgrade Tree
|
||||
|
||||
#### Tier1 Upgrades
|
||||
|
||||
**Level 5 Choices:**
|
||||
```
|
||||
├── Expanded Capacity (+25% max mana)
|
||||
│ └── Level 10: Deep Reservoir (+50% max mana) [replaces]
|
||||
│
|
||||
├── Natural Spring (+0.5 regen/hour)
|
||||
│ └── Level 10: Flowing Spring (+1.5 regen) [replaces]
|
||||
│
|
||||
├── Mana Threshold (+30% max mana, -10% regen)
|
||||
│ └── Level 10: Mana Conversion (5% max → click bonus)
|
||||
│
|
||||
└── Desperate Wells (+50% regen when below 25% mana)
|
||||
└── Level 10: Panic Reserve (+100% regen below 10%)
|
||||
```
|
||||
|
||||
**Level 10 Additional Choices:**
|
||||
- Mana Echo (10% chance double mana from clicks)
|
||||
- Emergency Reserve (Keep 10% mana on loop reset)
|
||||
- Deep Wellspring (+50% meditation efficiency)
|
||||
|
||||
#### Tier2 Upgrades (Deep Reservoir)
|
||||
- Abyssal Depth (+50% max mana)
|
||||
- Ancient Well (+500 starting mana per loop)
|
||||
- Mana Condense (+1% max per 1000 gathered)
|
||||
- Deep Reserve (+0.5 regen per 100 max mana)
|
||||
- Ocean of Mana (+1000 max mana)
|
||||
- Mana Tide (Regen pulses ±50%)
|
||||
- Void Storage (Store 150% max temporarily)
|
||||
- Mana Core (0.5% max mana as regen)
|
||||
|
||||
---
|
||||
|
||||
### Mana Flow Upgrade Tree
|
||||
|
||||
#### Tier1 Upgrades
|
||||
|
||||
**Level 5 Choices:**
|
||||
```
|
||||
├── Rapid Flow (+25% regen speed)
|
||||
│ └── Level 10: Mana Torrent (+50% regen above 75% mana)
|
||||
│
|
||||
├── Steady Stream (Immune to incursion penalty)
|
||||
│ └── Level 10: Eternal Flow (Immune to all penalties)
|
||||
│
|
||||
├── Mana Cascade (+0.1 regen per 100 max mana)
|
||||
│ └── Level 10: Mana Waterfall (+0.25 per 100 max) [replaces]
|
||||
│
|
||||
└── Mana Overflow (Raw mana can exceed max by 20%)
|
||||
```
|
||||
|
||||
**Level 10 Additional Choices:**
|
||||
- Ambient Absorption (+1 permanent regen)
|
||||
- Flow Surge (Clicks boost regen for 1 hour)
|
||||
- Flow Mastery (+10% mana from all sources)
|
||||
|
||||
---
|
||||
|
||||
### Elemental Attunement Upgrade Tree
|
||||
|
||||
#### Tier1 Upgrades
|
||||
|
||||
**Level 5 Choices:**
|
||||
```
|
||||
├── Expanded Attunement (+25% element cap)
|
||||
│ └── Level 10: Element Master (+50% element cap) [replaces]
|
||||
│
|
||||
├── Elemental Surge (+15% elemental spell damage)
|
||||
│ └── Level 10: Elemental Power (+30% damage) [replaces]
|
||||
│
|
||||
└── Elemental Affinity (New elements start with 10 capacity)
|
||||
```
|
||||
|
||||
**Level 10 Additional Choices:**
|
||||
- Elemental Resonance (Spell use restores element)
|
||||
- Exotic Mastery (+20% exotic element damage)
|
||||
|
||||
---
|
||||
|
||||
### Quick Learner Upgrade Tree
|
||||
|
||||
#### Tier1 Upgrades
|
||||
|
||||
**Level 5 Choices:**
|
||||
```
|
||||
├── Deep Focus (+25% study speed)
|
||||
│ └── Level 10: Deep Concentration (+50% speed) [replaces]
|
||||
│
|
||||
├── Quick Grasp (5% chance double study progress)
|
||||
│ └── Level 10: Knowledge Echo (15% instant complete)
|
||||
│
|
||||
├── Parallel Study (Study 2 things at 50% speed each)
|
||||
│
|
||||
└── Quick Mastery (-20% time for final 3 levels)
|
||||
```
|
||||
|
||||
**Level 10 Additional Choices:**
|
||||
- Study Momentum (+5% speed per hour, max 50%)
|
||||
- Knowledge Transfer (New skills start at 10% progress)
|
||||
|
||||
---
|
||||
|
||||
### Focused Mind Upgrade Tree
|
||||
|
||||
#### Tier1 Upgrades
|
||||
|
||||
**Level 5 Choices:**
|
||||
```
|
||||
├── Mind Efficiency (+25% cost reduction)
|
||||
│ └── Level 10: Efficient Learning (-15% study cost) [replaces]
|
||||
│
|
||||
├── Mental Clarity (+10% speed when mana > 75%)
|
||||
│ └── Level 10: Study Rush (First hour 2x speed)
|
||||
│
|
||||
└── Study Refund (25% mana back on completion)
|
||||
└── Level 10: Deep Understanding (+10% skill bonuses)
|
||||
```
|
||||
|
||||
**Level 10 Additional Choices:**
|
||||
- Chain Study (-5% cost per maxed skill)
|
||||
|
||||
---
|
||||
|
||||
### Enchanting Upgrade Tree
|
||||
|
||||
#### Tier1 Upgrades
|
||||
|
||||
**Level 5 Choices:**
|
||||
```
|
||||
├── Enchantment Capacity (+20% equipment capacity)
|
||||
├── Swift Enchanting (-15% design time)
|
||||
│
|
||||
└── Quality Control (+10% effect power)
|
||||
└── Level 10: Perfect Refinement (+25% power) [replaces]
|
||||
```
|
||||
|
||||
**Level 10 Additional Choices:**
|
||||
- Enchantment Mastery (2 designs in progress)
|
||||
- Mana Preservation (25% chance free enchant)
|
||||
|
||||
---
|
||||
|
||||
### Golem Mastery Upgrade Tree
|
||||
|
||||
#### Tier1 Upgrades
|
||||
|
||||
**Level 5 Choices:**
|
||||
```
|
||||
├── Golem Power (+25% golem damage)
|
||||
├── Golem Durability (+1 floor duration)
|
||||
│
|
||||
└── Efficient Summons (-20% summon cost)
|
||||
└── Level 10: Golem Siphon (-30% maintenance)
|
||||
```
|
||||
|
||||
**Level 10 Additional Choices:**
|
||||
- Golem Fury (+50% attack speed for first 2 floors)
|
||||
- Golem Resonance (Golems share 10% damage)
|
||||
|
||||
---
|
||||
|
||||
### Other Skill Upgrade Trees
|
||||
|
||||
#### Mana Overflow (Max 5)
|
||||
- **Level 5:** Click Surge (+50% click mana above 90% mana)
|
||||
- **Tier 2 Level 5:** Mana Flood (+75% click mana above 75% mana)
|
||||
|
||||
#### Efficient Enchant (Max 5)
|
||||
- **Level 5:** Thrifty Enchanter (+10% free enchant chance)
|
||||
- **Tier 2 Level 5:** Optimized Enchanting (+25% free chance)
|
||||
|
||||
#### Enchant Speed (Max 5)
|
||||
- **Level 5:** Hasty Enchanter (+25% speed for repeat designs)
|
||||
- **Tier 2 Level 5:** Instant Designs (10% instant completion)
|
||||
|
||||
#### Essence Refining (Max 1)
|
||||
- Research skill (max level 1, no upgrades)
|
||||
|
||||
#### Efficient Crafting (Max 5)
|
||||
- **Level 5:** Batch Crafting (2 items at 75% speed each)
|
||||
- **Tier 2 Level 5:** Mass Production (3 items at full speed)
|
||||
|
||||
#### Field Repair (Max 5)
|
||||
- **Level 5:** Scavenge (Recover 10% materials from broken items)
|
||||
- **Tier 2 Level 5:** Reclaim (Recover 25% materials)
|
||||
|
||||
#### Golem Efficiency (Max 5)
|
||||
- **Level 5:** Rapid Strikes (+25% speed for first 3 floors)
|
||||
- **Tier 2 Level 5:** Blitz Attack (+50% speed for first 5 floors)
|
||||
|
||||
---
|
||||
|
||||
## Tier System
|
||||
|
||||
### How Tiers Work
|
||||
|
||||
1. **Reach max level** (10 for most skills, 5 for specialized)
|
||||
2. **Meet attunement requirements**
|
||||
3. **Tier up** - Skill resets to level 1 with 10x power multiplier
|
||||
|
||||
### Tier Power Scaling
|
||||
|
||||
| Tier | Multiplier | Level 1 Power = |
|
||||
|------|------------|-----------------|
|
||||
| 1 | 1x | Base |
|
||||
| 2 | 10x | Tier 1 Level 10 |
|
||||
| 3 | 100x | Tier 2 Level 10 |
|
||||
| 4 | 1000x | Tier 3 Level 10 |
|
||||
| 5 | 10000x | Tier 4 Level 10 |
|
||||
|
||||
### Tier Up Requirements
|
||||
|
||||
#### 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 |
|
||||
|
||||
---
|
||||
|
||||
## Banned Content
|
||||
|
||||
The following effects/mechanics are **NOT allowed** in skill upgrades:
|
||||
|
||||
| Banned Effect | Reason |
|
||||
|---------------|--------|
|
||||
| Lifesteal | Player cannot take damage |
|
||||
| Healing (for player) | Player cannot take damage |
|
||||
| Life/Blood/Wood/Mental/Force mana | Removed elements |
|
||||
| Execution effects | Bypasses gameplay mechanics |
|
||||
| Instant finishing | Skips mechanics |
|
||||
| Direct spell damage bonuses | Spells only via weapons |
|
||||
| Familiar system | Replaced by golemancy |
|
||||
|
||||
### Design Philosophy
|
||||
|
||||
1. **Player cannot take damage** - Only floors/enemies have HP
|
||||
2. **No healing needed** - Player health doesn't exist
|
||||
3. **Weapons matter** - Player attacks through enchanted weapons
|
||||
4. **Golems fight** - Fabricator's constructs do the combat
|
||||
5. **Enchantments empower** - Enchanter enhances equipment
|
||||
6. **Pacts grant power** - Invoker makes deals with guardians
|
||||
|
||||
---
|
||||
|
||||
## Code Architecture
|
||||
|
||||
### Modular Structure
|
||||
|
||||
The skill system has been refactored into a modular architecture for better maintainability:
|
||||
|
||||
#### Skill Definitions (`src/lib/game/constants/skills.ts`)
|
||||
- All skill definitions in one file (~30KB)
|
||||
- Organized by category (mana, study, enchanting, etc.)
|
||||
- Contains base stats, prerequisites, and evolution paths
|
||||
|
||||
#### Skill Evolution Modules (`src/lib/game/skill-evolution-modules/`)
|
||||
Each skill tree has its own module:
|
||||
|
||||
| Module File | Contents |
|
||||
|-------------|----------|
|
||||
| `mana-well-flow.ts` | Mana Well, Mana Flow, Elemental Attunement |
|
||||
| `quick-learner.ts` | Quick Learner, Knowledge Retention |
|
||||
| `focused-mind.ts` | Focused Mind, Meditation skills |
|
||||
| `enchanting-skills.ts` | Enchanting, Efficient Enchant, Disenchanting |
|
||||
| `invocation-skills.ts` | Invocation, Pact Mastery trees |
|
||||
| `hybrid-skills.ts` | Pact-Weaving, Guardian Constructs, Enchanted Golemancy |
|
||||
| `guardian-skills.ts` | Guardian Bane, related skills |
|
||||
| `insight-harvest.ts` | Insight, Deep Memory skills |
|
||||
| `mana-utility-skills.ts` | Mana Overflow, Mana Tap, etc. |
|
||||
| `elemental-attunement.ts` | Elemental skill upgrades |
|
||||
| `knowledge-retention.ts` | Knowledge retention mechanics |
|
||||
| `learning-skills.ts` | Learning speed skills |
|
||||
| `magic-skills.ts` | Magic-related skills |
|
||||
| `utils.ts` | Shared utility functions |
|
||||
| `types.ts` | TypeScript interfaces |
|
||||
| `index.ts` | Main export combining all modules (~11KB) |
|
||||
|
||||
#### Skill State Management
|
||||
Skill state is managed in the store layer:
|
||||
- **New Modular Store:** `src/lib/game/stores/skillStore.ts` (~11KB) - Active skill state, studying, evolution
|
||||
- **Legacy Slice:** `src/lib/game/store/skillSlice.ts` - Being migrated to `skillStore.ts`
|
||||
- **Skill state includes:** `skills` (levels), `skillUpgrades` (chosen upgrades), `skillTiers` (current tier)
|
||||
|
||||
### Adding a New Skill (Updated Process)
|
||||
|
||||
1. **Define in `constants/skills.ts`** (NEW location)
|
||||
- Add to `SKILLS_DEF` object
|
||||
- Define base cost, study time, max level, category
|
||||
|
||||
2. **Add evolution path in `skill-evolution-modules/`** (NEW location)
|
||||
- Create new module or add to existing module
|
||||
- Define upgrade trees for levels 5 and 10
|
||||
- Export upgrade functions
|
||||
|
||||
3. **Export from `skill-evolution-modules/index.ts`**
|
||||
- Import and re-export new module
|
||||
- Ensure all upgrade functions are accessible
|
||||
|
||||
4. **Update UI in `components/game/tabs/SkillsTab.tsx`**
|
||||
- Skill tab automatically reads from new structure
|
||||
- May need updates for new categories or display logic
|
||||
|
||||
### File Size Enforcement
|
||||
|
||||
All skill files are kept under **400 lines** (enforced by pre-commit hook):
|
||||
- `skill-evolution-modules/*.ts` - Focused modules, typically 100-600 lines
|
||||
- `constants/skills.ts` - Largest file at ~1000 lines ( acceptable as it's mostly data)
|
||||
- Better code organization and maintainability
|
||||
- Faster for AI agents to read and understand
|
||||
|
||||
---
|
||||
|
||||
## Example Progression
|
||||
|
||||
### Mana Well Complete Journey
|
||||
|
||||
1. **Level 1-4:** +400 max mana (100 per level)
|
||||
2. **Level 5:** Choose "Expanded Capacity" (+25% max)
|
||||
- Total: 500 base + 125 bonus = 625 max mana
|
||||
3. **Level 6-9:** +400 more max mana
|
||||
4. **Level 10:** Choose "Deep Reservoir" (replaces to +50%)
|
||||
- Total: 1000 base + 500 bonus = 1500 max mana
|
||||
5. **Tier Up to Tier 2:** Mana Well becomes "Deep Reservoir"
|
||||
6. **Tier 2 Level 1:** 100 × 10 = 1000 base (same as T1 L10)
|
||||
7. **Tier 2 Level 5:** Choose "Abyssal Depth" (+50% max)
|
||||
8. **Continue progression...**
|
||||
|
||||
### Total Power at Tier 2 Level 5:
|
||||
- Base: 500 × 10 = 5000 max mana
|
||||
- Upgrades: +50% from Tier 1 +50% from Tier 2 = +100%
|
||||
- Total: 5000 × 2 = **10,000 max mana**
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.1 (Updated for Modular Architecture)*
|
||||
*Code has been refactored - game mechanics unchanged*
|
||||
@@ -1,650 +0,0 @@
|
||||
# Mana Loop — Remediation & Redesign Strategy
|
||||
|
||||
**Document Status:** Working Draft
|
||||
**Purpose:** Systematic plan to stabilise the game, redesign broken systems, and deliver a genuinely good product.
|
||||
|
||||
---
|
||||
|
||||
## The Current State
|
||||
|
||||
The codebase arrived in a state where several systems need attention:
|
||||
|
||||
1. **The skill system is incoherent** — it evolved without a clear design philosophy and the attunement pivot was never cleanly landed.
|
||||
2. **The UI is visually unacceptable** — generic AI-generated aesthetics, not a designed game.
|
||||
|
||||
These problems require focused solutions. This document covers all of them in a prioritised, structured way.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Skill System Redesign
|
||||
|
||||
### Philosophy: Trash and Restart
|
||||
|
||||
The existing system has 15 skill evolution modules, 5 tiers with 10,000x scaling, milestone upgrade trees, hybrid skills, and research unlocks. It grew organically and now no one — including the AI agent — can reliably predict what a skill change does.
|
||||
|
||||
The new system has one guiding principle: **every skill is just a collection of named effects, and every effect has a single number that says how much it changes.**
|
||||
|
||||
---
|
||||
|
||||
### New Skill Architecture
|
||||
|
||||
#### Concept: Skills as Effect Bundles
|
||||
|
||||
```typescript
|
||||
// Every skill is just metadata + an array of effects
|
||||
interface SkillDef {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: SkillCategory;
|
||||
attunementRequired?: string; // Which attunement unlocks this
|
||||
maxLevel: number; // Usually 10
|
||||
studyCost: (level: number) => number;
|
||||
studyTime: (level: number) => number; // hours
|
||||
effects: SkillEffect[]; // Applied at level 1, scale linearly
|
||||
}
|
||||
|
||||
// An effect is a single stat change
|
||||
interface SkillEffect {
|
||||
stat: StatKey; // e.g. 'maxMana', 'regenRate', 'damageMultiplier'
|
||||
mode: 'add' | 'multiply';
|
||||
valuePerLevel: number; // e.g. 100 (add 100 per level) or 0.05 (add 5% per level)
|
||||
}
|
||||
|
||||
// The full set of game stats
|
||||
type StatKey =
|
||||
| 'maxMana'
|
||||
| 'manaRegen'
|
||||
| 'clickMana'
|
||||
| 'elementCap'
|
||||
| 'studySpeed'
|
||||
| 'studyCostMult'
|
||||
| 'meditationMult'
|
||||
| 'enchantCapacity'
|
||||
| 'enchantSpeed'
|
||||
| 'enchantPower'
|
||||
| 'disenchantRecovery'
|
||||
| 'baseDamage'
|
||||
| 'damageMultiplier'
|
||||
| 'attackSpeed'
|
||||
| 'critChance'
|
||||
| 'critMultiplier'
|
||||
| 'armorPierce'
|
||||
| 'insightGain'
|
||||
| 'golemDamage'
|
||||
| 'golemDuration'
|
||||
| 'pactMultiplier'
|
||||
| 'conversionRate';
|
||||
```
|
||||
|
||||
#### Concept: Milestone Choices (Simplified)
|
||||
|
||||
Keep milestone choices at level 5 — they're fun and create build identity. Simplify to 3 choices max:
|
||||
|
||||
```typescript
|
||||
interface SkillMilestone {
|
||||
atLevel: number; // 5 or 10
|
||||
choices: MilestoneChoice[]; // Always exactly 2-3 options
|
||||
}
|
||||
|
||||
interface MilestoneChoice {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
effects: SkillEffect[]; // Same format as skill effects
|
||||
}
|
||||
```
|
||||
|
||||
No upgrade paths, no prerequisite trees within milestones. Choose once. Done.
|
||||
|
||||
#### Concept: Tiers as New Skills, Not Multipliers
|
||||
|
||||
Tiers-as-10,000x-multipliers is a design smell. It makes early choices feel irrelevant and creates absurd numbers. Instead:
|
||||
|
||||
**Tiering up unlocks a new skill in the same category, not a multiplied version of the old one.**
|
||||
|
||||
```
|
||||
Mana Well (max 10)
|
||||
→ Tier-up unlocks: "Deep Reservoir" skill (a genuinely different bonus)
|
||||
|
||||
Deep Reservoir (max 5)
|
||||
→ Tier-up unlocks: "Mana Conduit" skill (yet another distinct ability)
|
||||
```
|
||||
|
||||
Each tier-unlocked skill has its own effects, its own flavour. Power grows because you're stacking multiple skills, not because a single skill has a 10,000x internal multiplier.
|
||||
|
||||
---
|
||||
|
||||
### New Skill Categories
|
||||
|
||||
#### Core (No Attunement)
|
||||
|
||||
| Skill | Effect | Max |
|
||||
|-------|--------|-----|
|
||||
| Mana Well | +100 maxMana/level | 10 |
|
||||
| Mana Flow | +1 manaRegen/level | 10 |
|
||||
| Elemental Affinity | +50 elementCap/level | 10 |
|
||||
| Quick Learner | +10% studySpeed/level | 10 |
|
||||
| Focused Mind | -5% studyCost/level | 10 |
|
||||
| Meditation Mastery | +15% meditationMult/level | 5 |
|
||||
|
||||
#### Enchanter Attunement
|
||||
|
||||
| Skill | Effect | Max | Requires |
|
||||
|-------|--------|-----|---------|
|
||||
| Enchanting | Unlocks 3-step enchant | 10 | Enchanter 1 |
|
||||
| Efficient Enchant | -5% enchantCapacity cost/level | 5 | Enchanting 3 |
|
||||
| Enchant Speed | -10% enchantSpeed/level | 5 | Enchanting 2 |
|
||||
| Essence Refining | +10% enchantPower/level | 3 | Enchanting 5 |
|
||||
| Disenchanting | +20% disenchantRecovery/level | 3 | Enchanting 2 |
|
||||
|
||||
#### Invoker Attunement
|
||||
|
||||
| Skill | Effect | Max | Requires |
|
||||
|-------|--------|-----|---------|
|
||||
| Pact Binding | +10% pactMultiplier/level | 10 | Invoker 1 |
|
||||
| Invocation Mastery | +5% damageMultiplier/level | 10 | Invoker 2 |
|
||||
| Guardian Lore | +20% damage vs guardians/level | 5 | Invoker 3 |
|
||||
| Ritual Speed | -15% pact ritual time/level | 3 | Invoker 2 |
|
||||
|
||||
#### Fabricator Attunement
|
||||
|
||||
| Skill | Effect | Max | Requires |
|
||||
|-------|--------|-----|---------|
|
||||
| Golem Mastery | +10% golemDamage/level | 10 | Fabricator 2 |
|
||||
| Golem Efficiency | +5% attackSpeed (golems)/level | 5 | Fabricator 2 |
|
||||
| Golem Longevity | +1 golemDuration/level | 3 | Fabricator 3 |
|
||||
| Crafting Mastery | -10% craft time/level | 5 | Fabricator 1 |
|
||||
|
||||
#### Attunement-Specific Research (Unlock Skills)
|
||||
|
||||
These are `max: 1` skills that unlock new capabilities. They don't need tiers or upgrade trees:
|
||||
|
||||
```typescript
|
||||
// Flat unlock structure — no evolution needed
|
||||
const RESEARCH_SKILLS: ResearchSkill[] = [
|
||||
{ id: 'fireResearch', unlocks: ['emberShot', 'fireball'], req: { enchanting: 1 } },
|
||||
{ id: 'waterResearch', unlocks: ['waterJet', 'iceShard'], req: { enchanting: 1 } },
|
||||
{ id: 'lightningResearch', unlocks: ['spark', 'lightningBolt'], req: { enchanting: 3 } },
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Computed Stats: Single Source of Truth
|
||||
|
||||
All these skills feed into one `computeStats(state)` function that returns a flat `ComputedStats` object. Nothing reads from individual skill levels directly — everything reads from `ComputedStats`.
|
||||
|
||||
```typescript
|
||||
function computeStats(state: GameState): ComputedStats {
|
||||
const stats: ComputedStats = { ...BASE_STATS };
|
||||
|
||||
// Apply every skill level × its effects
|
||||
for (const [skillId, level] of Object.entries(state.skills)) {
|
||||
const def = SKILLS[skillId];
|
||||
if (!def || level === 0) continue;
|
||||
|
||||
for (const effect of def.effects) {
|
||||
if (effect.mode === 'add') {
|
||||
stats[effect.stat] += effect.valuePerLevel * level;
|
||||
} else {
|
||||
stats[effect.stat] *= 1 + (effect.valuePerLevel * level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply milestone choices
|
||||
for (const choiceId of state.skillUpgrades) {
|
||||
const choice = MILESTONE_CHOICES[choiceId];
|
||||
if (!choice) continue;
|
||||
for (const effect of choice.effects) {
|
||||
// same logic
|
||||
}
|
||||
}
|
||||
|
||||
// Apply equipment enchantments
|
||||
// Apply prestige upgrades
|
||||
|
||||
return stats;
|
||||
}
|
||||
```
|
||||
|
||||
This is **testable by design**. Every skill test is: given skill X at level Y, `computeStats()` returns Z.
|
||||
|
||||
---
|
||||
|
||||
### Migration Plan
|
||||
|
||||
1. Write `computeStats()` with tests (TDD).
|
||||
2. Define all skills in the new flat format in `constants/skills-v2.ts`.
|
||||
3. Keep the old skill IDs — just change how they're computed. The existing `state.skills` shape doesn't change.
|
||||
4. Delete `skill-evolution-modules/` entirely.
|
||||
5. Delete `skill-evolution.ts`.
|
||||
6. Update all callers of computed stats to use the new function.
|
||||
7. Run all existing tests. Fix any that fail.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Attunement Expansion
|
||||
|
||||
### Vision: Many Paths, Player Chooses
|
||||
|
||||
Current state: 3 attunements, all unlocked via linear progression.
|
||||
|
||||
Target state: **8–10 attunements** grouped into paths. Player picks one path at each milestone. Paths are:
|
||||
|
||||
- **Combat Path** — focus on raw damage, speed, and floor clearing
|
||||
- **Crafting Path** — focus on enchantments, equipment power, and golemancy
|
||||
- **Utility Path** — focus on mana generation, study speed, and loop efficiency
|
||||
|
||||
---
|
||||
|
||||
### Attunement Redesign
|
||||
|
||||
#### The 3 Existing (Reworked)
|
||||
|
||||
| Attunement | Path | Slot | Primary Grant |
|
||||
|------------|------|------|---------------|
|
||||
| Enchanter | Crafting | Right Hand | Transference mana + enchanting access |
|
||||
| Invoker | Combat | Chest | Pact power + guardian damage |
|
||||
| Fabricator | Crafting | Left Hand | Earth mana + golem access |
|
||||
|
||||
#### New Attunements (Phase 2 additions)
|
||||
|
||||
| Attunement | Path | Slot | Primary Grant | Unlock Condition |
|
||||
|------------|------|------|---------------|-----------------|
|
||||
| **Battle Mage** | Combat | Head | +damage, attackSpeed | Reach floor 20 |
|
||||
| **Arcanist** | Utility | Back | +mana cap, conversion rate | Study 5 skills to max |
|
||||
| **Sage** | Utility | Head | +study speed, insight gain | Complete 3 loops |
|
||||
| **Runesmith** | Crafting | Left Leg | +enchant capacity, crafting speed | Enchant 5 items |
|
||||
| **Warden** | Combat | Right Leg | +elemental resist, armor pierce | Sign 3 pacts |
|
||||
| **Timeweaver** | Utility | Back | -incursion penalty, +loop bonuses | Survive incursion |
|
||||
|
||||
#### Path Selection Moment
|
||||
|
||||
At **first prestige** (loop completion), player is presented with their first **Path Choice**:
|
||||
|
||||
> "Your magic has matured. Choose how to develop it:"
|
||||
>
|
||||
> 🗡️ **Combat Path** — Unlock Battle Mage + Warden attunements first. Focus: raw power, floor clearing.
|
||||
> ✨ **Crafting Path** — Unlock Runesmith + Fabricator advanced tiers first. Focus: equipment domination.
|
||||
> 🔮 **Utility Path** — Unlock Sage + Arcanist attunements first. Focus: meta progression, loop efficiency.
|
||||
|
||||
This choice doesn't lock out the other attunements permanently — it determines **unlock order and starting bonuses**. By loop 5, most players will have all attunements. The path just shapes the early and mid game.
|
||||
|
||||
---
|
||||
|
||||
### Attunement State Structure
|
||||
|
||||
Keep the existing `AttunementState` shape. Add:
|
||||
|
||||
```typescript
|
||||
interface AttunementState {
|
||||
id: string;
|
||||
active: boolean;
|
||||
level: number;
|
||||
experience: number;
|
||||
title?: string;
|
||||
// NEW:
|
||||
path?: 'combat' | 'crafting' | 'utility'; // For path-specific bonuses
|
||||
unlockedAt?: number; // Loop number when this was unlocked
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Enchanting System (Stable)
|
||||
|
||||
### Keep the 3-Step Flow
|
||||
|
||||
The 3-step flow is well-designed. Here is what each step does, stated precisely:
|
||||
|
||||
**Step 1 — Design**
|
||||
- Player selects a piece of owned equipment.
|
||||
- Player picks effects from their **unlocked pool** (what they've researched).
|
||||
- System previews: total capacity cost, time to enchant.
|
||||
- Player confirms → `startDesign(gearInstanceId, selectedEffects[])` is called.
|
||||
- Transitions to `currentAction: 'designing'`.
|
||||
- On completion → transitions to `currentAction: 'meditate'`. Design is saved.
|
||||
|
||||
**Step 2 — Prepare**
|
||||
- Player selects the piece of gear they want to prepare (the one they designed for).
|
||||
- If gear already has enchantments → they are removed, mana is returned (scaled by Disenchanting skill).
|
||||
- System shows mana cost for preparation.
|
||||
- Player confirms → `startPreparation(gearInstanceId, designId)`.
|
||||
- Transitions to `currentAction: 'preparing'`.
|
||||
- On completion → transitions to `currentAction: 'meditate'`. Gear is marked "prepared".
|
||||
|
||||
**Step 3 — Apply**
|
||||
- Player selects the prepared gear + matching design.
|
||||
- System shows time cost, mana cost, XP gain.
|
||||
- Player confirms → `startApplication(gearInstanceId, designId)`.
|
||||
- Transitions to `currentAction: 'enchanting'`.
|
||||
- On completion → enchantment applied, Enchanter XP gained, transitions to `currentAction: 'meditate'`.
|
||||
|
||||
---
|
||||
|
||||
### UI for Enchanting
|
||||
|
||||
The selection implementation must use the store as the single source of truth. Audit the `EnchantmentDesigner` component:
|
||||
|
||||
```typescript
|
||||
// WRONG pattern — local state doesn't sync with store
|
||||
const [selectedEffects, setSelectedEffects] = useState([]);
|
||||
// ...
|
||||
<EffectButton onClick={() => setSelectedEffects([...selectedEffects, effect])} />
|
||||
|
||||
// CORRECT pattern — store is the single source of truth
|
||||
const selectedEffects = useCraftingStore(s => s.enchantmentDesignState.selectedEffects);
|
||||
const toggleEffect = useCraftingStore(s => s.toggleEffectSelection);
|
||||
// ...
|
||||
<EffectButton onClick={() => toggleEffect(effect.id)} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Prestige System Rework
|
||||
|
||||
### Vision: Loop Memories + Path Bonuses
|
||||
|
||||
Instead of a generic idle-game upgrade shop, prestige is split into two parts:
|
||||
|
||||
#### Part A: Loop Memories (Keep)
|
||||
|
||||
The Memory system (preserving spells/skills between loops) is the best part of the prestige system. Keep it. Expand it slightly:
|
||||
|
||||
- **Memory Slots** persist across loops (deep memory prestige upgrade is fine).
|
||||
- Memories can be: a skill level, a spell, a completed enchantment design, or an attunement XP chunk.
|
||||
- Add "Memory Imprinting" — at loop end, player chooses which memories to keep.
|
||||
|
||||
#### Part B: Path Bonuses
|
||||
|
||||
Instead of one flat upgrade shop, give each **path** its own upgrade tree that unlocks when you commit to that path:
|
||||
|
||||
```
|
||||
Combat Path Permanents:
|
||||
- Veteran's Edge: Start each loop at floor 5 instead of 1
|
||||
- Battle-Hardened: +10% pact multipliers carry forward
|
||||
- Guardian's Boon: Guardian XP from last loop carries forward 25%
|
||||
|
||||
Crafting Path Permanents:
|
||||
- Master Craftsman: 1 enchantment design persists across loops
|
||||
- Runework Memory: Enchanter XP carries forward 30%
|
||||
- Crafting Legacy: 1 crafted item persists per loop
|
||||
|
||||
Utility Path Permanents:
|
||||
- Eternal Scholar: +20% starting mana per loop
|
||||
- Time Mastery: Incursion starts 2 days later
|
||||
- Insight Cascade: +15% insight per loop permanently
|
||||
```
|
||||
|
||||
#### Part C: Universal Upgrades (Minimal)
|
||||
|
||||
Keep a small set of universal upgrades that any path can buy. These are just QoL, not power:
|
||||
|
||||
- Extra memory slot (+insight cost)
|
||||
- UI options (loop history, achievement display)
|
||||
- Starting equipment quality (common → uncommon after loop 5)
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — UI Redesign
|
||||
|
||||
### Design Direction: Dark Arcane Codex
|
||||
|
||||
The game is about a mage in a time loop. The UI should feel like **a wizard's spellbook interface** — dark, deliberate, with glowing mana colors and a sense of weight and history.
|
||||
|
||||
**NOT:** Material Design, rounded pastel cards, generic dashboards, or Bootstrap tables.
|
||||
|
||||
**YES:** Dark background, warm amber/teal accent colors tied to the mana system, monospaced numbers for game stats, subtle texture via border treatments, clear information hierarchy.
|
||||
|
||||
---
|
||||
|
||||
### Design System
|
||||
|
||||
Define these tokens in `globals.css` before writing any component:
|
||||
|
||||
```css
|
||||
/* Mana Loop Design Tokens */
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--bg-void: #0d0d0f; /* Page background */
|
||||
--bg-panel: #141418; /* Panel background */
|
||||
--bg-surface: #1c1c22; /* Card/surface background */
|
||||
--bg-raised: #242430; /* Elevated elements */
|
||||
|
||||
/* Text */
|
||||
--text-primary: #e8e6dc; /* Main content */
|
||||
--text-secondary: #9e9c90; /* Labels, captions */
|
||||
--text-muted: #5e5c56; /* Disabled, placeholder */
|
||||
|
||||
/* Mana Colors (tie to game elements) */
|
||||
--mana-raw: #8b7fd4; /* Raw mana — purple */
|
||||
--mana-fire: #e85d24; /* Fire — orange-red */
|
||||
--mana-water: #2ea8c4; /* Water — teal */
|
||||
--mana-air: #a8d4e8; /* Air — pale blue */
|
||||
--mana-earth: #b07d3c; /* Earth — amber-brown */
|
||||
--mana-light: #e8c84a; /* Light — gold */
|
||||
--mana-dark: #7a4db0; /* Dark — deep purple */
|
||||
--mana-death: #6e8a96; /* Death — grey-blue */
|
||||
--mana-transference: #1abc9c;/* Transference — teal-green */
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #4caf7d;
|
||||
--color-warning: #e8a84a;
|
||||
--color-danger: #c44b3a;
|
||||
--color-info: var(--mana-raw);
|
||||
|
||||
/* Borders */
|
||||
--border-subtle: rgba(255,255,255,0.06);
|
||||
--border-default: rgba(255,255,255,0.12);
|
||||
--border-accent: rgba(255,255,255,0.22);
|
||||
|
||||
/* Typography */
|
||||
--font-display: 'Cinzel', serif; /* Headings, tab names */
|
||||
--font-body: 'Source Serif 4', serif; /* Prose text, descriptions */
|
||||
--font-ui: 'JetBrains Mono', monospace; /* Stats, numbers, game values */
|
||||
|
||||
/* Spacing */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
}
|
||||
```
|
||||
|
||||
**Font sourcing:** All available via Google Fonts. Add to `layout.tsx`:
|
||||
```typescript
|
||||
import { Cinzel, Source_Serif_4, JetBrains_Mono } from 'next/font/google';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Component Guidelines
|
||||
|
||||
**Stats and numbers** → always `font-family: var(--font-ui)`. Numbers should look precise, not soft.
|
||||
|
||||
**Tab headers** → `font-family: var(--font-display)`, muted color normally, accent color when active. No underlines or pills — use a subtle left or bottom border.
|
||||
|
||||
**Descriptions and lore** → `font-family: var(--font-body)`. The game has narrative flavor; let descriptions read like a spellbook.
|
||||
|
||||
**Progress bars** → use the element colors. A mana bar is `--mana-raw`. A fire element bar is `--mana-fire`. The color is the information.
|
||||
|
||||
**Panels** → `--bg-panel` background with a `1px solid var(--border-subtle)` border. No drop shadows. Use spacing to create hierarchy, not shadows.
|
||||
|
||||
**Buttons** — Three variants:
|
||||
```
|
||||
Primary: bg --bg-raised, border --border-accent, text --text-primary
|
||||
Secondary: bg transparent, border --border-default, text --text-secondary
|
||||
Danger: bg transparent, border --color-danger, text --color-danger
|
||||
```
|
||||
|
||||
**Never use:** shadcn default styles without overriding, `rounded-full` for non-pill elements, white backgrounds, blue link colors, or any stock Tailwind color like `bg-blue-500`.
|
||||
|
||||
---
|
||||
|
||||
### Layout Rework
|
||||
|
||||
The current layout has a LeftPanel + main tabbed area. Keep this structure but rework the visual language:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ MANA LOOP Day 12 / 30 │ ← Top bar: game title, time
|
||||
├──────────┬───────────────────────────────────────────────────┤
|
||||
│ │ [Skills] [Spire] [Crafting] [Equipment] [...] │ ← Tab bar
|
||||
│ STATUS ├───────────────────────────────────────────────────┤
|
||||
│ PANEL │ │
|
||||
│ │ ACTIVE TAB CONTENT │
|
||||
│ Mana │ │
|
||||
│ Elements│ │
|
||||
│ Action │ │
|
||||
│ Activity│ │
|
||||
│ Log │ │
|
||||
└──────────┴───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Left panel content (from top):
|
||||
1. Mana display (raw mana bar + current/max)
|
||||
2. Elemental mana bars (only show unlocked elements)
|
||||
3. Current action with progress bar
|
||||
4. Attunement status strip
|
||||
5. Activity log (scrollable, last 20 events)
|
||||
|
||||
---
|
||||
|
||||
### UI Implementation Order
|
||||
|
||||
1. `globals.css` — design tokens only. No component styles yet.
|
||||
2. Left panel redesign (most-seen element).
|
||||
3. Tab bar redesign.
|
||||
4. Mana display component.
|
||||
5. Skill tab (most complex, do last after skill system redesign).
|
||||
6. Equipment tab.
|
||||
7. Enchanting crafting tab.
|
||||
|
||||
Each component gets its own TASK.md. The agent must not redesign multiple components in one task.
|
||||
|
||||
---
|
||||
|
||||
## Execution Sequence
|
||||
|
||||
Work in this order. Do not start a phase until the previous phase's acceptance criteria are met.
|
||||
|
||||
```
|
||||
Phase 0 ── E2E test coverage + validate existing systems
|
||||
│ DONE WHEN: enchanting flow, gear equipping, and combat all have passing E2E tests
|
||||
│ GATE: all E2E tests green, no regressions
|
||||
│
|
||||
Phase 1 ── Skill system redesign (Part 1 above)
|
||||
│ DONE WHEN: computeStats() replaces all skill-evolution-modules/
|
||||
│ GATE: all unit tests pass, no regression in game behaviour
|
||||
│
|
||||
Phase 2 ── Enchanting UI (Part 3 above)
|
||||
│ DONE WHEN: 3-step flow works with store as single source of truth
|
||||
│ GATE: enchanting E2E test passes
|
||||
│
|
||||
Phase 3 ── UI design system (Part 5 above — tokens + left panel only)
|
||||
│ DONE WHEN: design tokens defined, left panel redesigned
|
||||
│ GATE: no functional regression
|
||||
│
|
||||
Phase 4 ── Attunement expansion (Part 2 above)
|
||||
│ DONE WHEN: new attunements defined, path choice works at prestige
|
||||
│ GATE: attunement store tests pass
|
||||
│
|
||||
Phase 5 ── Prestige rework (Part 4 above — path bonuses)
|
||||
│ DONE WHEN: path bonuses replace generic shop (or coexist cleanly)
|
||||
│ GATE: prestige store tests pass
|
||||
│
|
||||
Phase 6 ── Full UI redesign (Part 5 above — all remaining tabs)
|
||||
DONE WHEN: all tabs use new design system
|
||||
GATE: visual review + E2E tests still pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E2E Test Plan (Playwright) — Priority Order
|
||||
|
||||
These tests validate that core gameplay loops work correctly and remain stable. Each test should be written **before** any related implementation work begins (TDD).
|
||||
|
||||
```typescript
|
||||
// e2e/enchanting.spec.ts
|
||||
test('can select enchantment effect from unlocked pool', async ({ page }) => {
|
||||
// Navigate to enchanting tab
|
||||
// Click an available effect
|
||||
// Assert it appears in the design panel with correct capacity cost
|
||||
});
|
||||
|
||||
test('can complete full 3-step enchant flow', async ({ page }) => {
|
||||
// Design → Prepare → Apply
|
||||
// Assert enchantment is applied to the gear and Enchanter XP increased
|
||||
});
|
||||
|
||||
test('cannot select locked enchantment effects', async ({ page }) => {
|
||||
// Assert unresearched effects are visually disabled / non-interactive
|
||||
});
|
||||
|
||||
// e2e/equipment.spec.ts
|
||||
test('equipping item updates the correct equipment slot', async ({ page }) => {
|
||||
// Pick up an item → click a slot → assert slot shows the item
|
||||
});
|
||||
|
||||
test('2-handed weapon blocks offhand slot', async ({ page }) => {
|
||||
// Equip 2H weapon → assert offhand is greyed out / blocked
|
||||
});
|
||||
|
||||
test('unequipping item returns it to inventory', async ({ page }) => {
|
||||
// Remove item from slot → assert it appears in inventory
|
||||
});
|
||||
|
||||
// e2e/combat.spec.ts
|
||||
test('spell cast progress advances over time during combat', async ({ page }) => {
|
||||
// Enter combat → wait → assert cast progress bar has advanced
|
||||
});
|
||||
|
||||
test('enemy HP decreases on spell completion', async ({ page }) => {
|
||||
// Complete a spell cast → assert enemy HP is reduced by expected amount
|
||||
});
|
||||
|
||||
test('defeating all enemies on a floor advances to next floor', async ({ page }) => {
|
||||
// Kill last enemy → assert floor counter increments and new enemies appear
|
||||
});
|
||||
|
||||
test('death resets to correct floor on reincarnation', async ({ page }) => {
|
||||
// Die → reincarnate → assert floor reset matches prestige expectations
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Structure for the Agent
|
||||
|
||||
For each phase, create individual TASK.md files. Keep each task under 200 lines of code change. Example structure:
|
||||
|
||||
```
|
||||
docs/tasks/
|
||||
TASK-001-playwright-setup.md
|
||||
TASK-002-enchanting-e2e-tests.md
|
||||
TASK-003-equipment-e2e-tests.md
|
||||
TASK-004-combat-e2e-tests.md
|
||||
TASK-005-globals-css-tokens.md
|
||||
TASK-006-left-panel-redesign.md
|
||||
...
|
||||
```
|
||||
|
||||
Each task file follows the TASK_TEMPLATE.md format. The agent receives ONE task at a time. After it's committed, you verify it, then send the next task.
|
||||
|
||||
**Prevent blast radius:** The "Files NOT to Touch" field in each task is critical. The combat tests should not touch the enchanting files. The UI redesign should not touch the store. Explicit constraints prevent the agent from "helpfully" refactoring adjacent code.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: First 5 Tasks
|
||||
|
||||
If you're starting today, create these tasks in order:
|
||||
|
||||
1. **TASK-001-playwright-setup.md** — Add Playwright to the project, configure `playwright.config.ts`, establish baseline test runner.
|
||||
2. **TASK-002-enchanting-e2e-tests.md** — Write E2E tests covering the 3-step enchant flow and effect selection. Must pass.
|
||||
3. **TASK-003-equipment-e2e-tests.md** — Write E2E tests for gear equipping, 2H weapon slot blocking, and unequip-to-inventory. Must pass.
|
||||
4. **TASK-004-combat-e2e-tests.md** — Write E2E tests for spell casting progression, enemy HP reduction, and floor advancement. Must pass.
|
||||
5. **TASK-005-globals-css-tokens.md** — Define the design tokens in `globals.css`. No component styles yet.
|
||||
|
||||
Get those 5 done and you'll have validated gameplay with a solid test safety net and the foundation for the visual redesign. Everything else is iterative improvement.
|
||||
@@ -1,80 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for combat system:
|
||||
* - Entering spire mode (climbing)
|
||||
* - Casting spells and seeing progress
|
||||
*/
|
||||
|
||||
test.describe('Combat System', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Clear game state to ensure a fresh start
|
||||
await page.evaluate(() => {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith('mana-loop-'))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('can see the Spire tab and "Climb the Spire" button', async ({ page }) => {
|
||||
// Verify Spire tab exists (uses ⚔️ icon)
|
||||
const spireTab = page.getByRole('tab').filter({ hasText: '⚔️' });
|
||||
await expect(spireTab).toBeVisible();
|
||||
|
||||
// Main page should show "Climb the Spire" button
|
||||
const climbBtn = page.getByRole('button', { name: 'Climb the Spire' });
|
||||
await expect(climbBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('can enter Spire mode by clicking Climb button', async ({ page }) => {
|
||||
// Click "Climb the Spire" button on the main page
|
||||
await page.getByRole('button', { name: 'Climb the Spire' }).click();
|
||||
|
||||
// After clicking, spire mode activates and tab auto-switches to Spire tab.
|
||||
// Since spireMode is now true, the Spire tab shows "Exit Spire Mode"
|
||||
const exitBtn = page.getByRole('button', { name: 'Exit Spire Mode' });
|
||||
await expect(exitBtn).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('can navigate to Spire tab and enter spire mode', async ({ page }) => {
|
||||
// Click the Spire tab
|
||||
await page.getByRole('tab').filter({ hasText: '⚔️' }).click();
|
||||
|
||||
// Should see the "Enter Spire Mode" button
|
||||
const enterBtn = page.getByRole('button', { name: 'Enter Spire Mode' });
|
||||
await expect(enterBtn).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('shows floor information after entering spire mode', async ({ page }) => {
|
||||
// Navigate to spire mode first
|
||||
await page.getByRole('button', { name: 'Climb the Spire' }).click();
|
||||
|
||||
// Now on spire tab with spire mode active
|
||||
// The SpireHeader in simpleMode shows "Current Floor" section
|
||||
// with the floor number, room badge, and stats
|
||||
|
||||
// Check that we're on the spire tab
|
||||
const spireTab = page.getByRole('tab', { name: /⚔️ Spire/ });
|
||||
await expect(spireTab).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The SpireHeader shows "Current Floor" in spire mode
|
||||
const currentFloorLabel = page.getByText('Current Floor');
|
||||
await expect(currentFloorLabel).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The floor number should be displayed (it's a text element)
|
||||
// And "Best:" label is rendered alongside the floor count
|
||||
const bestLabel = page.locator('text=Best:').first();
|
||||
await expect(bestLabel).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('can navigate to Spire tab and see stats', async ({ page }) => {
|
||||
await page.getByRole('tab').filter({ hasText: '⚔️' }).click();
|
||||
|
||||
// Spire stats section shows key info
|
||||
expect(await page.getByText('Best Floor').count()).toBeGreaterThan(0);
|
||||
expect(await page.getByText('Pacts Signed').count()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for the 3-step enchantment flow:
|
||||
* Design → Prepare → Apply
|
||||
*
|
||||
* These tests validate the core crafting loop works end-to-end.
|
||||
*/
|
||||
|
||||
test.describe('Enchanting Flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith('mana-loop-'))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('can navigate to Crafting tab', async ({ page }) => {
|
||||
const craftTab = page.getByRole('tab').filter({ hasText: '🔧' });
|
||||
await expect(craftTab).toBeVisible();
|
||||
await craftTab.click();
|
||||
|
||||
// Should see the Crafting tab sub-tabs: Fabricate and Enchant
|
||||
const fabricateBtn = page.getByRole('button', { name: 'Fabricate' });
|
||||
const enchantBtn = page.getByRole('button', { name: 'Enchant' });
|
||||
await expect(fabricateBtn).toBeVisible();
|
||||
await expect(enchantBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('can switch to Enchant sub-tab and see design UI', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith('mana-loop-'))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('tab').filter({ hasText: '🔧' }).click();
|
||||
await page.getByRole('button', { name: 'Enchant' }).click();
|
||||
|
||||
// Should see the design stage buttons
|
||||
const designBtn = page.getByRole('button', { name: 'Design' });
|
||||
const prepareBtn = page.getByRole('button', { name: 'Prepare' });
|
||||
const applyBtn = page.getByRole('button', { name: 'Apply' });
|
||||
await expect(designBtn).toBeVisible();
|
||||
await expect(prepareBtn).toBeVisible();
|
||||
await expect(applyBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('can select equipment type in Design stage', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith('mana-loop-'))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('tab').filter({ hasText: '🔧' }).click();
|
||||
await page.getByRole('button', { name: 'Enchant' }).click();
|
||||
|
||||
// Look for equipment type selector showing available staff types
|
||||
// The EnchantmentDesigner shows equipment type options
|
||||
const staffOption = page.locator('text=Basic Staff');
|
||||
await expect(staffOption).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('can navigate through all 3 enchant stages', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith('mana-loop-'))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('tab').filter({ hasText: '🔧' }).click();
|
||||
await page.getByRole('button', { name: 'Enchant' }).click();
|
||||
|
||||
// Verify Design stage is active
|
||||
await expect(page.getByRole('button', { name: 'Design' })).toBeVisible();
|
||||
|
||||
// Switch to Prepare stage
|
||||
await page.getByRole('button', { name: 'Prepare' }).click();
|
||||
|
||||
// Should see preparation UI
|
||||
// Use role=heading to target the SectionHeader h3, not the empty state div
|
||||
const prepareHeading = page.getByRole('heading', { name: 'Select Equipment to Prepare' });
|
||||
await expect(prepareHeading).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Switch to Apply stage
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
|
||||
// Should see application UI
|
||||
const applyHeading = page.locator('text=Select Equipment & Design');
|
||||
await expect(applyHeading).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for equipment management:
|
||||
* - Navigating to Equipment tab
|
||||
* - 2-handed weapon blocking offhand slot
|
||||
* - Equipment slots visible with labels
|
||||
*/
|
||||
|
||||
test.describe('Equipment Management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith('mana-loop-'))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('can navigate to Equipment tab', async ({ page }) => {
|
||||
// Use the tab with the shield icon
|
||||
const gearTab = page.getByRole('tab').filter({ hasText: '🛡️' });
|
||||
await expect(gearTab).toBeVisible();
|
||||
await gearTab.click();
|
||||
|
||||
// Verify we're on the equipment tab by checking for section headers
|
||||
await expect(page.getByText('Equipped Gear')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('shows equipment slots with labels', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith('mana-loop-'))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('tab').filter({ hasText: '🛡️' }).click();
|
||||
|
||||
// Check for the grouped slot labels
|
||||
await expect(page.getByText('Weapon & Shield')).toBeVisible();
|
||||
await expect(page.getByText('Armor')).toBeVisible();
|
||||
await expect(page.getByText('Accessories')).toBeVisible();
|
||||
|
||||
// Individual slot labels within groups
|
||||
const slotLabels = ['Main Hand', 'Off Hand', 'Head', 'Body', 'Hands', 'Feet', 'Accessory 1', 'Accessory 2'];
|
||||
for (const label of slotLabels) {
|
||||
const loc = page.getByText(label).first();
|
||||
await expect(loc).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('shows starting equipment already equipped', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith('mana-loop-'))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('tab').filter({ hasText: '🛡️' }).click();
|
||||
|
||||
// The player starts with Basic Staff in main hand
|
||||
// Check that main hand slot contains an item with a name
|
||||
const mainHandSlot = page.locator('text=Main Hand').first();
|
||||
await expect(mainHandSlot).toBeVisible();
|
||||
|
||||
// Body slot should have civilian clothing
|
||||
const bodySlot = page.locator('text=Body').first();
|
||||
await expect(bodySlot).toBeVisible();
|
||||
});
|
||||
|
||||
test('2-handed weapon blocks offhand slot', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith('mana-loop-'))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('tab').filter({ hasText: '🛡️' }).click();
|
||||
|
||||
// The starting basic staff is 2-handed (twoHanded: true)
|
||||
// The Off Hand slot should show the "Occupied — 2H Weapon" badge
|
||||
const offHandBlocker = page.locator('text=Occupied').first();
|
||||
await expect(offHandBlocker).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Also check the blocked slot has the right tooltip/message
|
||||
const twoHWeaponBadge = page.locator('text=2-Handed').first();
|
||||
await expect(twoHWeaponBadge).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
Generated
+18
-3201
File diff suppressed because it is too large
Load Diff
+58
-57
@@ -17,80 +17,81 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@reactuses/core": "^6.0.5",
|
||||
"@tanstack/react-query": "^5.82.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@reactuses/core": "^6.3.1",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.23.2",
|
||||
"framer-motion": "^12.38.0",
|
||||
"husky": "^9.1.7",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^16.1.1",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"recharts": "^2.15.4",
|
||||
"sharp": "^0.34.3",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.1.0",
|
||||
"uuid": "^11.1.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.2",
|
||||
"zustand": "^5.0.6"
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"bun-types": "^1.3.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^16.1.1",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"bun-types": "^1.3.14",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.5",
|
||||
"madge": "^8.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.2"
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { SPELLS_DEF } from '@/lib/game/constants';
|
||||
import type { SpellDef } from '@/lib/game/types';
|
||||
|
||||
export function GrimoireTab() {
|
||||
const [grimoireSpells, setGrimoireSpells] = useState<[string, SpellDef][]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && SPELLS_DEF) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setGrimoireSpells(
|
||||
Object.entries(SPELLS_DEF).filter((entry): entry is [string, SpellDef] => !!entry[1].grimoire)
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (grimoireSpells.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-400">
|
||||
No grimoire spells available yet. Defeat guardians to unlock spells.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const availablePages = Math.ceil(grimoireSpells.length / 12);
|
||||
|
||||
return (
|
||||
<DebugName name="GrimoireTab">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-400">
|
||||
<p className="mb-2">A vast tome of arcane knowledge. Study carefully — each spell costs insight to transcribe into your repertoire.</p>
|
||||
<p>Available pages: {availablePages}. Spells in grimoire: {grimoireSpells.length}.</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[600px] rounded border border-gray-700 p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{grimoireSpells.map(([id, spell]) => (
|
||||
<div
|
||||
key={id}
|
||||
className="p-4 bg-gray-800/50 rounded border border-gray-600 hover:border-gray-500 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<span className="font-bold text-gray-100">{spell.name}</span>
|
||||
<Badge variant="outline" className="border-gray-600">
|
||||
{spell.elem}
|
||||
</Badge>
|
||||
</div>
|
||||
{spell.desc && <p className="text-sm text-gray-400 mb-3">{spell.desc}</p>}
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div>Cost: {spell.cost.amount} {
|
||||
spell.cost.type === 'element'
|
||||
? spell.cost.element
|
||||
: 'raw mana'
|
||||
}</div>
|
||||
<div>Power: {spell.dmg}</div>
|
||||
{spell.effects && spell.effects.length > 0 && (
|
||||
<div>Effects: {spell.effects.map(e => e.type).join(', ')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,12 @@ import { ManaDisplay } from '@/components/game';
|
||||
import { ActionButtons } from '@/components/game';
|
||||
import { AttunementStatus } from '@/components/game/AttunementStatus';
|
||||
import { ActivityLogPanel } from '@/components/game/ActivityLogPanel';
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
import { useGameStore, useManaStore, useSkillStore, useCombatStore, useCraftingStore, usePrestigeStore } from '@/lib/game/stores';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { useGameStore, useManaStore, useCombatStore, useCraftingStore, usePrestigeStore } from '@/lib/game/stores';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
import { getMeditationBonus, getIncursionStrength } from '@/lib/game/stores';
|
||||
import { computeTotalMaxMana, computeTotalRegen, computeTotalClickMana } from '@/lib/game/effects';
|
||||
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
|
||||
|
||||
export function LeftPanel() {
|
||||
const [isGathering, setIsGathering] = useState(false);
|
||||
@@ -20,9 +21,6 @@ export function LeftPanel() {
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const meditateTicks = useManaStore((s) => s.meditateTicks);
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const skillTiers = useSkillStore((s) => s.skillTiers);
|
||||
const skillUpgrades = useSkillStore((s) => s.skillUpgrades);
|
||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
||||
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
@@ -30,7 +28,6 @@ export function LeftPanel() {
|
||||
const spireMode = useCombatStore((s) => s.spireMode);
|
||||
const enterSpireMode = useCombatStore((s) => s.enterSpireMode);
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
const currentStudyTarget = useSkillStore((s) => s.currentStudyTarget);
|
||||
const designProgress = useCraftingStore((s) => s.designProgress);
|
||||
const designProgress2 = useCraftingStore((s) => s.designProgress2);
|
||||
const preparationProgress = useCraftingStore((s) => s.preparationProgress);
|
||||
@@ -56,11 +53,12 @@ export function LeftPanel() {
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [isGathering, gatherMana]);
|
||||
|
||||
const upgradeEffects = getUnifiedEffects({ skillUpgrades, skillTiers, equippedInstances, equipmentInstances });
|
||||
const maxMana = computeTotalMaxMana({ skills, prestigeUpgrades, skillUpgrades, skillTiers, equippedInstances, equipmentInstances }, upgradeEffects);
|
||||
const baseRegen = computeTotalRegen({ skills, prestigeUpgrades, skillUpgrades, skillTiers, equippedInstances, equipmentInstances }, upgradeEffects);
|
||||
const clickMana = computeTotalClickMana({ skills, skillUpgrades, skillTiers, equippedInstances, equipmentInstances }, upgradeEffects);
|
||||
const meditationMultiplier = getMeditationBonus(meditateTicks, skills, upgradeEffects.meditationEfficiency);
|
||||
const upgradeEffects = getUnifiedEffects({ skillUpgrades: {}, skillTiers: {}, equippedInstances, equipmentInstances });
|
||||
const disciplineEffects = computeDisciplineEffects();
|
||||
const maxMana = computeTotalMaxMana({ skills: {}, prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, equippedInstances, equipmentInstances }, upgradeEffects);
|
||||
const baseRegen = computeTotalRegen({ skills: {}, prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, equippedInstances, equipmentInstances }, upgradeEffects);
|
||||
const clickMana = computeTotalClickMana({ skills: {}, skillUpgrades: {}, skillTiers: {}, equippedInstances, equipmentInstances }, upgradeEffects);
|
||||
const meditationMultiplier = getMeditationBonus(meditateTicks, {}, upgradeEffects.meditationEfficiency, disciplineEffects.meditationCapBonus);
|
||||
const incursionStrength = getIncursionStrength(useGameStore((s) => s.day), useGameStore((s) => s.hour));
|
||||
const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
|
||||
|
||||
@@ -84,7 +82,7 @@ export function LeftPanel() {
|
||||
{/* 2. Spire Entry */}
|
||||
{!spireMode && (
|
||||
<DebugName name="ClimbSpireButton">
|
||||
<Button className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700 text-white" size="lg" onClick={enterSpireMode}>
|
||||
<Button className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-600 text-white" size="lg" onClick={enterSpireMode}>
|
||||
<Mountain className="w-5 h-5 mr-2" />
|
||||
Climb the Spire
|
||||
</Button>
|
||||
@@ -98,7 +96,6 @@ export function LeftPanel() {
|
||||
<CardContent className="pt-3">
|
||||
<ActionButtons
|
||||
currentAction={currentAction}
|
||||
currentStudyTarget={currentStudyTarget as any}
|
||||
designProgress={designProgress}
|
||||
designProgress2={designProgress2}
|
||||
preparationProgress={preparationProgress}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { GameToaster } from "@/components/game/GameToast";
|
||||
import { DebugProvider } from "@/lib/game/debug-context";
|
||||
import { DebugProvider } from "@/components/game/debug/debug-context";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: '../../public/fonts/GeistVF.woff',
|
||||
|
||||
+134
-268
@@ -1,218 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, lazy, Suspense } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
// Import from new modular stores
|
||||
import {
|
||||
useGameStore,
|
||||
useUIStore,
|
||||
useManaStore,
|
||||
useSkillStore,
|
||||
useCombatStore,
|
||||
usePrestigeStore,
|
||||
useCraftingStore,
|
||||
fmt,
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
getMeditationBonus,
|
||||
getIncursionStrength,
|
||||
getIncursionStrength
|
||||
} from '@/lib/game/stores';
|
||||
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
|
||||
import { useGameLoop } from '@/lib/game/stores/gameHooks';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
import {
|
||||
getStudySpeedMultiplier,
|
||||
getStudyCostMultiplier,
|
||||
SPELLS_DEF,
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
} from '@/lib/game/constants';
|
||||
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
|
||||
import { TimeDisplay } from '@/components/game';
|
||||
import { hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
|
||||
import { TimeDisplay } from '@/components/game';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { RotateCcw, Mountain } from 'lucide-react';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
|
||||
// Import extracted components
|
||||
import { GameOverScreen } from './components/GameOverScreen';
|
||||
import { LeftPanel } from './components/LeftPanel';
|
||||
import { GrimoireTab } from './components/GrimoireTab';
|
||||
|
||||
// Lazy load tab components
|
||||
const SpireTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.SpireTab })));
|
||||
const SkillsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.SkillsTab })));
|
||||
const SpellsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.SpellsTab })));
|
||||
const DisciplinesTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.DisciplinesTab })));
|
||||
const SpellsTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.SpellsTab })));
|
||||
const StatsTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.StatsTab })));
|
||||
const DebugTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.DebugTab })));
|
||||
const AchievementsTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.AchievementsTab })));
|
||||
const AttunementsTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.AttunementsTab })));
|
||||
const PrestigeTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.PrestigeTab })));
|
||||
const EquipmentTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.EquipmentTab })));
|
||||
const GolemancyTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.GolemancyTab })));
|
||||
const GuardianPactsTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.GuardianPactsTab })));
|
||||
const SpireSummaryTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.SpireSummaryTab })));
|
||||
const CraftingTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.CraftingTab })));
|
||||
const SpireCombatPage = lazy(() => import('@/components/game/tabs/SpireCombatPage').then(m => ({ default: m.SpireCombatPage })));
|
||||
|
||||
const StatsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.StatsTab })));
|
||||
const EquipmentTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.EquipmentTab })));
|
||||
const AttunementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AttunementsTab })));
|
||||
const DebugTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.DebugTab })));
|
||||
const LootTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.LootTab })));
|
||||
const AchievementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AchievementsTab })));
|
||||
const GolemancyTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GolemancyTab })));
|
||||
const CraftingTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.CraftingTab })));
|
||||
const TabFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||
|
||||
const TabLoadingFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||
|
||||
// ============================================================================
|
||||
// Grimoire Tab Component
|
||||
// ============================================================================
|
||||
|
||||
function GrimoireTab() {
|
||||
const [grimoireSpells, setGrimoireSpells] = useState<any[]>(() => {
|
||||
if (typeof window !== 'undefined' && SPELLS_DEF) {
|
||||
return Object.values(SPELLS_DEF).filter((s: any) => s.grimoire);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const loaded = typeof window !== 'undefined';
|
||||
|
||||
if (!loaded) {
|
||||
return <div className="p-4 text-center text-gray-400">Loading grimoire...</div>;
|
||||
function TabErrorFallback({ name }: { name: string }) {
|
||||
return <div className="p-4 text-red-400">{name} tab failed to load.</div>;
|
||||
}
|
||||
|
||||
if (grimoireSpells.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-400">
|
||||
No grimoire spells available yet. Defeat guardians to unlock spells.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
|
||||
|
||||
const availablePages = Math.ceil(grimoireSpells.length / 12);
|
||||
|
||||
return (
|
||||
<DebugName name="GrimoireTab">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-400">
|
||||
<p className="mb-2">A vast tome of arcane knowledge. Study carefully — each spell costs insight to transcribe into your repertoire.</p>
|
||||
<p>Available pages: {availablePages}. Spells in grimoire: {grimoireSpells.length}.</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[600px] rounded border border-gray-700 p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{grimoireSpells.map((spell: any) => (
|
||||
<div
|
||||
key={spell.id}
|
||||
className="p-4 bg-gray-800/50 rounded border border-gray-600 hover:border-gray-500 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<span className="font-bold text-gray-100">{spell.name}</span>
|
||||
<Badge variant="outline" className="border-gray-600">
|
||||
{spell.element}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mb-3">{spell.desc}</p>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div>Cost: {spell.cost.amount} {
|
||||
spell.cost.type === 'element'
|
||||
? spell.cost.element
|
||||
: 'raw mana'
|
||||
}</div>
|
||||
<div>Power: {spell.power}</div>
|
||||
{spell.effect && <div>Effect: {spell.effect}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Game Component
|
||||
// ============================================================================
|
||||
|
||||
export default function ManaLoopGame() {
|
||||
const [selectedManaType, setSelectedManaType] = useState<string>('');
|
||||
const [activeTab, setActiveTab] = useState('spire');
|
||||
|
||||
// ALL hooks must be called before any conditional returns
|
||||
const day = useGameStore((s) => s.day);
|
||||
const hour = useGameStore((s) => s.hour);
|
||||
const initGame = useGameStore((s) => s.initGame);
|
||||
useGameLoop();
|
||||
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const skillTiers = useSkillStore((s) => s.skillTiers);
|
||||
const skillUpgrades = useSkillStore((s) => s.skillUpgrades);
|
||||
|
||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
||||
const insight = usePrestigeStore((s) => s.insight);
|
||||
const loopInsight = usePrestigeStore((s) => s.loopInsight);
|
||||
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const meditateTicks = useManaStore((s) => s.meditateTicks);
|
||||
|
||||
const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
|
||||
const spells = useCombatStore((s) => s.spells);
|
||||
const spireMode = useCombatStore((s) => s.spireMode);
|
||||
|
||||
const gameOver = useUIStore((s) => s.gameOver);
|
||||
|
||||
// Get equipment state from crafting store
|
||||
function useGameDerivedStats() {
|
||||
const { prestigeUpgrades } = usePrestigeStore(useShallow(s => ({
|
||||
prestigeUpgrades: s.prestigeUpgrades,
|
||||
})));
|
||||
const { meditateTicks } = useManaStore(useShallow(s => ({
|
||||
meditateTicks: s.meditateTicks,
|
||||
})));
|
||||
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
const day = useGameStore((s) => s.day);
|
||||
const hour = useGameStore((s) => s.hour);
|
||||
|
||||
// Derived state
|
||||
const upgradeEffects = getUnifiedEffects({
|
||||
skillUpgrades,
|
||||
skillTiers,
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
equippedInstances,
|
||||
equipmentInstances
|
||||
equipmentInstances,
|
||||
});
|
||||
|
||||
const disciplineEffects = computeDisciplineEffects();
|
||||
|
||||
const maxMana = computeMaxMana({
|
||||
skills,
|
||||
skills: {},
|
||||
prestigeUpgrades,
|
||||
skillUpgrades,
|
||||
skillTiers
|
||||
}, upgradeEffects);
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
}, upgradeEffects, disciplineEffects);
|
||||
|
||||
const baseRegen = computeRegen({
|
||||
skills,
|
||||
skills: {},
|
||||
prestigeUpgrades,
|
||||
skillUpgrades,
|
||||
skillTiers
|
||||
}, upgradeEffects);
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
attunements: {},
|
||||
}, upgradeEffects, disciplineEffects);
|
||||
|
||||
const clickMana = computeClickMana({
|
||||
skills,
|
||||
prestigeUpgrades,
|
||||
skillUpgrades,
|
||||
skillTiers
|
||||
});
|
||||
|
||||
const meditationMultiplier = getMeditationBonus(meditateTicks, skills, upgradeEffects.meditationEfficiency);
|
||||
const clickMana = computeClickMana({}, disciplineEffects);
|
||||
const meditationMultiplier = getMeditationBonus(meditateTicks, {}, upgradeEffects.meditationEfficiency, disciplineEffects.meditationCapBonus);
|
||||
const incursionStrength = getIncursionStrength(day, 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;
|
||||
|
||||
// Mana Waterfall bonus
|
||||
const manaWaterfallBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_WATERFALL)
|
||||
? Math.floor(maxMana / 100) * 0.25
|
||||
: 0;
|
||||
|
||||
// Effective regen
|
||||
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus + manaWaterfallBonus) * meditationMultiplier;
|
||||
|
||||
// Initialize game on mount
|
||||
return { maxMana, effectiveRegen, clickMana, meditationMultiplier };
|
||||
}
|
||||
|
||||
// ─── Tab Triggers ────────────────────────────────────────────────────────────
|
||||
|
||||
function TabTriggers() {
|
||||
return (
|
||||
<TabsList className="flex flex-wrap gap-1 w-full mb-4 h-auto">
|
||||
<TabsTrigger value="spells" className="text-xs px-2 py-1">🔮 Spells</TabsTrigger>
|
||||
<TabsTrigger value="stats" className="text-xs px-2 py-1">📊 Stats</TabsTrigger>
|
||||
<TabsTrigger value="disciplines" className="text-xs px-2 py-1">📚 Disciplines</TabsTrigger>
|
||||
<TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
|
||||
<TabsTrigger value="debug" className="text-xs px-2 py-1">🐛 Debug</TabsTrigger>
|
||||
<TabsTrigger value="attunements" className="text-xs px-2 py-1">⚗️ Attunements</TabsTrigger>
|
||||
<TabsTrigger value="achievements" className="text-xs px-2 py-1">🏆 Achievements</TabsTrigger>
|
||||
<TabsTrigger value="prestige" className="text-xs px-2 py-1">✨ Prestige</TabsTrigger>
|
||||
<TabsTrigger value="equipment" className="text-xs px-2 py-1">⚔️ Equipment</TabsTrigger>
|
||||
<TabsTrigger value="golemancy" className="text-xs px-2 py-1">🗿 Golemancy</TabsTrigger>
|
||||
<TabsTrigger value="pacts" className="text-xs px-2 py-1">📜 Pacts</TabsTrigger>
|
||||
<TabsTrigger value="spire" className="text-xs px-2 py-1">🏔️ Spire</TabsTrigger>
|
||||
<TabsTrigger value="crafting" className="text-xs px-2 py-1">⚒️ Crafting</TabsTrigger>
|
||||
</TabsList>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Lazy Tab Content ────────────────────────────────────────────────────────
|
||||
|
||||
function LazyTab({ name, children }: { name: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<ErrorBoundary fallback={<TabErrorFallback name={name} />}>
|
||||
<Suspense fallback={<TabFallback />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Game Component ─────────────────────────────────────────────────────
|
||||
|
||||
export default function ManaLoopGame() {
|
||||
const [activeTab, setActiveTab] = useState('spells');
|
||||
|
||||
useGameLoop();
|
||||
|
||||
const { day, hour, initGame } = useGameStore(useShallow(s => ({
|
||||
day: s.day,
|
||||
hour: s.hour,
|
||||
initGame: s.initGame,
|
||||
})));
|
||||
const { insight, loopInsight } = usePrestigeStore(useShallow(s => ({
|
||||
insight: s.insight,
|
||||
loopInsight: s.loopInsight,
|
||||
})));
|
||||
const spireMode = useCombatStore((s) => s.spireMode);
|
||||
const gameOver = useUIStore((s) => s.gameOver);
|
||||
|
||||
useGameDerivedStats();
|
||||
|
||||
useEffect(() => {
|
||||
initGame();
|
||||
}, [initGame]);
|
||||
@@ -220,25 +170,36 @@ export default function ManaLoopGame() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
|
||||
// React to spireMode changes from combat store
|
||||
useEffect(() => {
|
||||
if (spireMode) {
|
||||
setActiveTab('spire'); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setActiveTab('spells'); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
}
|
||||
}, [spireMode]);
|
||||
|
||||
// Conditional returns AFTER all hooks
|
||||
if (gameOver) {
|
||||
return <GameOverScreen day={day} hour={hour} insightGained={loopInsight} totalInsight={insight} />;
|
||||
}
|
||||
|
||||
if (!mounted) return <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||
|
||||
if (spireMode) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
useCombatStore.getState().exitSpireMode();
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<div className="p-4 text-center text-gray-400">Loading spire...</div>}>
|
||||
<SpireCombatPage />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<TooltipProvider>
|
||||
<div className="game-root min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<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>
|
||||
@@ -248,121 +209,26 @@ export default function ManaLoopGame() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col md:flex-row gap-4 p-4">
|
||||
<LeftPanel />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="flex flex-wrap gap-1 w-full mb-4 h-auto">
|
||||
<TabsTrigger value="spire" className="text-xs px-2 py-1">⚔️ Spire</TabsTrigger>
|
||||
<TabsTrigger value="attunements" className="text-xs px-2 py-1">✨ Attune</TabsTrigger>
|
||||
<TabsTrigger value="golemancy" className="text-xs px-2 py-1">🗿 Golems</TabsTrigger>
|
||||
<TabsTrigger value="skills" className="text-xs px-2 py-1">📚 Skills</TabsTrigger>
|
||||
<TabsTrigger value="spells" className="text-xs px-2 py-1">🔮 Spells</TabsTrigger>
|
||||
<TabsTrigger value="equipment" className="text-xs px-2 py-1">🛡️ Gear</TabsTrigger>
|
||||
<TabsTrigger value="crafting" className="text-xs px-2 py-1">🔧 Craft</TabsTrigger>
|
||||
<TabsTrigger value="loot" className="text-xs px-2 py-1">💎 Loot</TabsTrigger>
|
||||
<TabsTrigger value="achievements" className="text-xs px-2 py-1">🏆 Achieve</TabsTrigger>
|
||||
<TabTriggers />
|
||||
|
||||
<TabsTrigger value="stats" className="text-xs px-2 py-1">📊 Stats</TabsTrigger>
|
||||
<TabsTrigger value="debug" className="text-xs px-2 py-1">🐛 Debug</TabsTrigger>
|
||||
<TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="spire">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">spire tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<SpireTab simpleMode={spireMode} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="attunements">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">attunements tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<AttunementsTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="golemancy">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">golemancy tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<GolemancyTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="skills">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">skills tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<SkillsTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="spells">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">spells tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<SpellsTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="equipment">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">equipment tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<EquipmentTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="crafting">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">crafting tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<CraftingTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="loot">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">loot tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<LootTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="achievements">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">achievements tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<AchievementsTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
|
||||
<TabsContent value="stats">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">stats tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<StatsTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="debug">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">debug tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<DebugTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="grimoire">
|
||||
<GrimoireTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="spells"><LazyTab name="spells"><SpellsTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="stats"><LazyTab name="stats"><StatsTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="disciplines"><LazyTab name="disciplines"><DisciplinesTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="grimoire"><GrimoireTab /></TabsContent>
|
||||
<TabsContent value="debug"><LazyTab name="debug"><DebugTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="attunements"><LazyTab name="attunements"><AttunementsTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="achievements"><LazyTab name="achievements"><AchievementsTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="prestige"><LazyTab name="prestige"><PrestigeTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="equipment"><LazyTab name="equipment"><EquipmentTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="golemancy"><LazyTab name="golemancy"><GolemancyTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="pacts"><LazyTab name="pacts"><GuardianPactsTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="spire"><LazyTab name="spire"><SpireSummaryTab /></LazyTab></TabsContent>
|
||||
<TabsContent value="crafting"><LazyTab name="crafting"><CraftingTab /></LazyTab></TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Component, ReactNode } from 'react';
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
@@ -24,11 +25,20 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
return (
|
||||
<div className="p-4 bg-red-900/20 border border-red-600/50 rounded">
|
||||
<h3 className="text-red-400 font-bold mb-2">Something went wrong:</h3>
|
||||
<pre className="text-xs text-red-300">{this.state.error?.message}</pre>
|
||||
<pre className="text-xs text-gray-500 mt-2">{this.state.error?.stack}</pre>
|
||||
{this.props.onReset && (
|
||||
<button
|
||||
onClick={this.props.onReset}
|
||||
className="mt-3 px-3 py-1 bg-red-700 hover:bg-red-600 text-white text-xs rounded"
|
||||
>
|
||||
Reset & Recover
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ManaBar } from '@/components/ui/mana-bar';
|
||||
import { Trophy, Lock, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { AchievementState } from '@/lib/game/types';
|
||||
import { ACHIEVEMENTS, getAchievementsByCategory, isAchievementRevealed } from '@/lib/game/data/achievements';
|
||||
import { GameState } from '@/lib/game/types';
|
||||
|
||||
// Map achievement categories to CSS variables for colors
|
||||
const CATEGORY_COLOR_MAP: Record<string, string> = {
|
||||
combat: 'var(--color-danger)',
|
||||
progression: 'var(--rarity-legendary)',
|
||||
crafting: 'var(--mana-dark)',
|
||||
magic: 'var(--mana-water)',
|
||||
special: 'var(--mana-stellar)',
|
||||
};
|
||||
|
||||
interface AchievementsProps {
|
||||
achievements: AchievementState;
|
||||
gameState: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'totalSpellsCast' | 'totalDamageDealt' | 'totalCraftsCompleted'>;
|
||||
}
|
||||
|
||||
export function AchievementsDisplay({ achievements, gameState }: AchievementsProps) {
|
||||
const [expandedCategory, setExpandedCategory] = useState<string | null>('combat');
|
||||
|
||||
const categories = getAchievementsByCategory();
|
||||
const unlockedCount = achievements.unlocked.length;
|
||||
const totalCount = Object.keys(ACHIEVEMENTS).length;
|
||||
|
||||
// Calculate progress for each achievement
|
||||
const getProgress = (achievementId: string): number => {
|
||||
const achievement = ACHIEVEMENTS[achievementId];
|
||||
if (!achievement) return 0;
|
||||
if (achievements.unlocked.includes(achievementId)) return achievement.requirement.value;
|
||||
|
||||
const { type, subType } = achievement.requirement;
|
||||
|
||||
switch (type) {
|
||||
case 'floor':
|
||||
if (subType === 'noPacts') {
|
||||
return gameState.maxFloorReached >= achievement.requirement.value && gameState.signedPacts.length === 0
|
||||
? achievement.requirement.value
|
||||
: gameState.maxFloorReached;
|
||||
}
|
||||
return gameState.maxFloorReached;
|
||||
case 'spells':
|
||||
return gameState.totalSpellsCast || 0;
|
||||
case 'damage':
|
||||
return gameState.totalDamageDealt || 0;
|
||||
case 'mana':
|
||||
return gameState.totalManaGathered || 0;
|
||||
case 'pact':
|
||||
return gameState.signedPacts.length;
|
||||
case 'craft':
|
||||
return gameState.totalCraftsCompleted || 0;
|
||||
default:
|
||||
return achievements.progress[achievementId] || 0;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Trophy className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Achievements
|
||||
</h3>
|
||||
<Badge
|
||||
className="ml-auto bg-[var(--bg-sunken)] text-[var(--text-secondary)] border-[var(--border-subtle)]"
|
||||
aria-label={`${unlockedCount} out of ${totalCount} achievements unlocked`}
|
||||
>
|
||||
{unlockedCount} / {totalCount}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-64 w-full">
|
||||
<div className="space-y-2 pr-2">
|
||||
{Object.entries(categories).map(([category, categoryAchievements]) => (
|
||||
<div key={category} className="space-y-1">
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between text-xs hover:bg-[var(--bg-sunken)]"
|
||||
onClick={() => setExpandedCategory(expandedCategory === category ? null : category)}
|
||||
aria-expanded={expandedCategory === category}
|
||||
aria-label={`${category} category - ${categoryAchievements.filter(a => achievements.unlocked.includes(a.id)).length} of ${categoryAchievements.length} unlocked`}
|
||||
>
|
||||
<span style={{ color: CATEGORY_COLOR_MAP[category] || 'var(--text-primary)' }}>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
</span>
|
||||
<span className="text-[var(--text-muted)]">
|
||||
{categoryAchievements.filter(a => achievements.unlocked.includes(a.id)).length} / {categoryAchievements.length}
|
||||
</span>
|
||||
{expandedCategory === category ? (
|
||||
<ChevronUp className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
)}
|
||||
</ActionButton>
|
||||
|
||||
{expandedCategory === category && (
|
||||
<div className="pl-2 space-y-2">
|
||||
{categoryAchievements.map((achievement) => {
|
||||
const isUnlocked = achievements.unlocked.includes(achievement.id);
|
||||
const progress = getProgress(achievement.id);
|
||||
const isRevealed = isAchievementRevealed(achievement, progress);
|
||||
const progressPercent = Math.min(100, (progress / achievement.requirement.value) * 100);
|
||||
|
||||
if (!isRevealed && !isUnlocked) {
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className="p-2 rounded bg-[var(--bg-sunken)] border border-[var(--border-subtle)]"
|
||||
aria-label="Locked achievement - details hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[var(--text-muted)]">
|
||||
<Lock className="w-4 h-4" aria-hidden="true" />
|
||||
<span className="text-sm">???</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`p-2 rounded border ${
|
||||
isUnlocked
|
||||
? 'bg-[var(--rarity-legendary-glow)] border-[var(--rarity-legendary)]/50'
|
||||
: 'bg-[var(--bg-sunken)] border-[var(--border-subtle)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{isUnlocked ? (
|
||||
<CheckCircle className="w-4 h-4 text-[var(--mana-light)]" aria-hidden="true" />
|
||||
) : (
|
||||
<Trophy className="w-4 h-4 text-[var(--text-muted)]" aria-hidden="true" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm font-semibold ${
|
||||
isUnlocked ? 'text-[var(--mana-light)]' : 'text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{achievement.name}
|
||||
</span>
|
||||
</div>
|
||||
{achievement.reward.title && isUnlocked && (
|
||||
<Badge
|
||||
className="text-xs bg-[var(--mana-dark)]/20 text-[var(--mana-dark)] border-[var(--mana-dark)]/40"
|
||||
aria-label="Title reward"
|
||||
>
|
||||
Title
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2">
|
||||
{achievement.desc}
|
||||
</div>
|
||||
|
||||
{!isUnlocked && (
|
||||
<div className="space-y-1">
|
||||
<ManaBar
|
||||
value={progress}
|
||||
max={achievement.requirement.value}
|
||||
manaType="light"
|
||||
className="h-1.5"
|
||||
aria-label={`Progress: ${Math.round(progressPercent)}%`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
||||
<span>{progress.toLocaleString()} / {achievement.requirement.value.toLocaleString()}</span>
|
||||
<span>{progressPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUnlocked && achievement.reward && (
|
||||
<div className="text-xs text-[var(--mana-light)]/70">
|
||||
Reward:
|
||||
{achievement.reward.insight && ` +${achievement.reward.insight} Insight`}
|
||||
{achievement.reward.manaBonus && ` +${achievement.reward.manaBonus} Max Mana`}
|
||||
{achievement.reward.damageBonus && ` +${(achievement.reward.damageBonus * 100).toFixed(0)}% Damage`}
|
||||
{achievement.reward.title && ` "${achievement.reward.title}"`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
AchievementsDisplay.displayName = "AchievementsDisplay";
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Sparkles, Swords, BookOpen, Target, FlaskConical, Cog, Hammer } from 'lucide-react';
|
||||
import { Sparkles, Swords, BookOpen, Target, FlaskConical, Cog, Hammer, Dumbbell } from 'lucide-react';
|
||||
import type { GameAction } from '@/lib/game/types';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
currentAction: GameAction;
|
||||
currentStudyTarget: { type: 'skill' | 'spell'; id: string; progress: number; required: number } | null;
|
||||
currentStudyTarget?: { type: 'skill' | 'spell'; id: string; progress: number; required: number } | null;
|
||||
designProgress: { progress: number; required: number } | null;
|
||||
designProgress2: { progress: number; required: number } | null;
|
||||
preparationProgress: { progress: number; required: number } | null;
|
||||
@@ -16,6 +16,7 @@ interface ActionButtonsProps {
|
||||
// Map action IDs to labels and icons
|
||||
const ACTION_CONFIG: Record<string, { label: string; icon: typeof Sparkles; color: string }> = {
|
||||
meditate: { label: 'Meditating', icon: Sparkles, color: 'text-blue-400' },
|
||||
practicing: { label: 'Practicing Discipline', icon: Dumbbell, color: 'text-amber-400' },
|
||||
climb: { label: 'Climbing', icon: Swords, color: 'text-green-400' },
|
||||
study: { label: 'Studying', icon: BookOpen, color: 'text-yellow-400' },
|
||||
design: { label: 'Designing Enchantment', icon: Target, color: 'text-purple-400' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useAttunementStore } from '@/lib/game/stores';
|
||||
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -18,13 +19,17 @@ const SLOT_LABELS: Record<string, string> = {
|
||||
export function AttunementStatus() {
|
||||
const attunements = useAttunementStore((s) => s.attunements);
|
||||
|
||||
const activeAttunements = Object.entries(attunements)
|
||||
const attunementOrder = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
Object.values(ATTUNEMENTS_DEF).forEach((d, i) => map.set(d.id, i));
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
const activeAttunements = useMemo(() => {
|
||||
return Object.entries(attunements)
|
||||
.filter(([, state]) => state.active)
|
||||
.sort(([, a], [, b]) => {
|
||||
const orderA = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === a.id);
|
||||
const orderB = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === b.id);
|
||||
return orderA - orderB;
|
||||
});
|
||||
.sort(([, a], [, b]) => (attunementOrder.get(a.id) ?? 0) - (attunementOrder.get(b.id) ?? 0));
|
||||
}, [attunements, attunementOrder]);
|
||||
|
||||
const xpForNext = (level: number) => {
|
||||
if (level <= 1) return 0;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
|
||||
|
||||
interface CalendarDisplayProps {
|
||||
day: number;
|
||||
hour: number;
|
||||
incursionStrength?: number;
|
||||
}
|
||||
|
||||
export function CalendarDisplay({ day }: CalendarDisplayProps) {
|
||||
const days: React.ReactElement[] = [];
|
||||
|
||||
for (let d = 1; d <= MAX_DAY; d++) {
|
||||
let dayClass = 'w-6 h-6 sm:w-7 sm:h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
|
||||
|
||||
if (d < day) {
|
||||
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
|
||||
} else if (d === 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 (
|
||||
<div className="grid grid-cols-7 sm:grid-cols-7 md:grid-cols-14 gap-1">
|
||||
{days}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CalendarDisplay.displayName = "CalendarDisplay";
|
||||
CalendarDisplay.displayName = "CalendarDisplay";
|
||||
@@ -1,184 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { AlertTriangle, AlertCircle, Info, CheckCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type ConfirmDialogVariant = 'danger' | 'warning' | 'info' | 'success';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
/** Whether the dialog is open */
|
||||
open: boolean;
|
||||
/** Callback when open state changes */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Dialog title */
|
||||
title: string;
|
||||
/** Dialog description/content */
|
||||
description: ReactNode;
|
||||
/** Cancel button text (default: "Cancel") */
|
||||
cancelText?: string;
|
||||
/** Confirm button text (default: "Confirm") */
|
||||
confirmText?: string;
|
||||
/** Dialog variant/type */
|
||||
variant?: ConfirmDialogVariant;
|
||||
/** Callback when user confirms */
|
||||
onConfirm: () => void | Promise<void>;
|
||||
/** Callback when user cancels */
|
||||
onCancel?: () => void;
|
||||
/** Whether the confirm action is destructive */
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
const VARIANT_ICONS = {
|
||||
danger: AlertTriangle,
|
||||
warning: AlertCircle,
|
||||
info: Info,
|
||||
success: CheckCircle,
|
||||
};
|
||||
|
||||
const VARIANT_TITLE_COLORS = {
|
||||
danger: 'text-[var(--color-danger)]',
|
||||
warning: 'text-[var(--color-warning)]',
|
||||
info: 'text-[var(--color-info)]',
|
||||
success: 'text-[var(--color-success)]',
|
||||
};
|
||||
|
||||
const VARIANT_ACTION_COLORS = {
|
||||
danger: 'bg-[var(--color-danger)] hover:bg-[var(--interactive-danger-hover)] text-white',
|
||||
warning: 'bg-[var(--color-warning)] hover:opacity-90 text-black',
|
||||
info: 'bg-[var(--color-info)] hover:opacity-90 text-white',
|
||||
success: 'bg-[var(--color-success)] hover:opacity-90 text-white',
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable confirmation dialog component.
|
||||
* Uses the existing shadcn/ui AlertDialog.
|
||||
*
|
||||
* @example
|
||||
* <ConfirmDialog
|
||||
* open={showDialog}
|
||||
* onOpenChange={setShowDialog}
|
||||
* title="Delete Item"
|
||||
* description="Are you sure you want to delete this item? This action cannot be undone."
|
||||
* variant="danger"
|
||||
* onConfirm={handleDelete}
|
||||
* />
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
cancelText = 'Cancel',
|
||||
confirmText = 'Confirm',
|
||||
variant = 'warning',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
destructive = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const Icon = VARIANT_ICONS[variant];
|
||||
const titleColor = VARIANT_TITLE_COLORS[variant];
|
||||
const actionClass = destructive ? VARIANT_ACTION_COLORS.danger : VARIANT_ACTION_COLORS[variant];
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className={cn('flex items-center gap-2', titleColor)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[var(--text-secondary)]">
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{cancelText}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(actionClass, isLoading && 'opacity-50 cursor-not-allowed')}
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Processing...' : confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to easily manage a confirmation dialog state.
|
||||
*
|
||||
* @example
|
||||
* const { dialogProps, showConfirm } = useConfirmDialog();
|
||||
*
|
||||
* showConfirm({
|
||||
* title: "Delete Item",
|
||||
* description: "Are you sure?",
|
||||
* onConfirm: () => deleteItem(),
|
||||
* });
|
||||
*/
|
||||
export function useConfirmDialog() {
|
||||
const [dialogState, setDialogState] = useState<{
|
||||
open: boolean;
|
||||
props: Omit<ConfirmDialogProps, 'open' | 'onOpenChange'>;
|
||||
}>({
|
||||
open: false,
|
||||
props: {
|
||||
title: '',
|
||||
description: '',
|
||||
onConfirm: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const showConfirm = (props: Omit<ConfirmDialogProps, 'open' | 'onOpenChange'>) => {
|
||||
setDialogState({ open: true, props });
|
||||
};
|
||||
|
||||
const dialogProps: ConfirmDialogProps = {
|
||||
open: dialogState.open,
|
||||
onOpenChange: (open: boolean) => setDialogState(prev => ({ ...prev, open })),
|
||||
...dialogState.props,
|
||||
};
|
||||
|
||||
return {
|
||||
dialogProps,
|
||||
showConfirm,
|
||||
ConfirmDialogComponent: <ConfirmDialog {...dialogProps} />,
|
||||
};
|
||||
}
|
||||
|
||||
export default ConfirmDialog;
|
||||
@@ -1,163 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Target, FlaskConical, Sparkles, Play, Pause, X } from 'lucide-react';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { formatStudyTime } from '@/lib/game/formatting';
|
||||
import type { EquipmentInstance, EnchantmentDesign } from '@/lib/game/types';
|
||||
|
||||
interface CraftingProgressProps {
|
||||
designProgress: { designId: string; progress: number; required: number } | null;
|
||||
preparationProgress: { equipmentInstanceId: string; progress: number; required: number; manaCostPaid: number } | null;
|
||||
applicationProgress: { equipmentInstanceId: string; designId: string; progress: number; required: number; manaPerHour: number; paused: boolean } | null;
|
||||
equipmentInstances: Record<string, EquipmentInstance>;
|
||||
enchantmentDesigns: EnchantmentDesign[];
|
||||
cancelDesign: () => void;
|
||||
cancelPreparation: () => void;
|
||||
pauseApplication: () => void;
|
||||
resumeApplication: () => void;
|
||||
cancelApplication: () => void;
|
||||
}
|
||||
|
||||
export function CraftingProgress({
|
||||
designProgress,
|
||||
preparationProgress,
|
||||
applicationProgress,
|
||||
equipmentInstances,
|
||||
enchantmentDesigns,
|
||||
cancelDesign,
|
||||
cancelPreparation,
|
||||
pauseApplication,
|
||||
resumeApplication,
|
||||
cancelApplication,
|
||||
}: CraftingProgressProps) {
|
||||
const progressSections: React.ReactNode[] = [];
|
||||
|
||||
// Design progress
|
||||
if (designProgress) {
|
||||
const progressPct = Math.min(100, (designProgress.progress / designProgress.required) * 100);
|
||||
progressSections.push(
|
||||
<div key="design" 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">
|
||||
<Target className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-sm font-semibold text-cyan-300">
|
||||
Designing Enchantment
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={cancelDesign}
|
||||
>
|
||||
<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(designProgress.progress)} / {formatStudyTime(designProgress.required)}</span>
|
||||
<span>Design Time</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Preparation progress
|
||||
if (preparationProgress) {
|
||||
const progressPct = Math.min(100, (preparationProgress.progress / preparationProgress.required) * 100);
|
||||
const instance = equipmentInstances[preparationProgress.equipmentInstanceId];
|
||||
progressSections.push(
|
||||
<div key="prepare" className="p-3 rounded border border-green-600/50 bg-green-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="w-4 h-4 text-green-400" />
|
||||
<span className="text-sm font-semibold text-green-300">
|
||||
Preparing {instance?.name || 'Equipment'}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={cancelPreparation}
|
||||
>
|
||||
<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(preparationProgress.progress)} / {formatStudyTime(preparationProgress.required)}</span>
|
||||
<span>Mana spent: {fmt(preparationProgress.manaCostPaid)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Application progress
|
||||
if (applicationProgress) {
|
||||
const progressPct = Math.min(100, (applicationProgress.progress / applicationProgress.required) * 100);
|
||||
const instance = equipmentInstances[applicationProgress.equipmentInstanceId];
|
||||
const design = enchantmentDesigns.find(d => d.id === applicationProgress.designId);
|
||||
progressSections.push(
|
||||
<div key="enchant" className="p-3 rounded border border-amber-600/50 bg-amber-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm font-semibold text-amber-300">
|
||||
Enchanting {instance?.name || 'Equipment'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{applicationProgress.paused ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-green-400 hover:text-green-300"
|
||||
onClick={resumeApplication}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-yellow-400 hover:text-yellow-300"
|
||||
onClick={pauseApplication}
|
||||
>
|
||||
<Pause className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={cancelApplication}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(applicationProgress.progress)} / {formatStudyTime(applicationProgress.required)}</span>
|
||||
<span>Mana/hr: {fmt(applicationProgress.manaPerHour)}</span>
|
||||
</div>
|
||||
{design && (
|
||||
<div className="text-xs text-amber-400/70 mt-1">
|
||||
Applying: {design.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return progressSections.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{progressSections}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
CraftingProgress.displayName = "CraftingProgress";
|
||||
@@ -1,10 +0,0 @@
|
||||
'use client';
|
||||
|
||||
// Re-export everything from the modular GameContext files
|
||||
export { GameProvider, GameProvider as default } from './GameContext/Provider';
|
||||
export { useGameContext } from './GameContext/hooks';
|
||||
export { GameContext } from './GameContext/context-create';
|
||||
export type { GameContextValue, UnifiedStore } from './GameContext/types';
|
||||
|
||||
// Re-export useGameLoop for convenience
|
||||
export { useGameLoop } from '@/lib/game/stores/gameHooks';
|
||||
@@ -1,288 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { 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 } from '@/lib/game/stores/gameStore';
|
||||
import { computeEffects } from '@/lib/game/upgrade-effects';
|
||||
import { hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/special-effects';
|
||||
import { getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
getMeditationBonus,
|
||||
canAffordSpellCost,
|
||||
calcDamage,
|
||||
getFloorElement,
|
||||
getBoonBonuses,
|
||||
getIncursionStrength,
|
||||
} from '@/lib/game/utils';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
} from '@/lib/game/constants';
|
||||
import type { ElementDef, GuardianDef, SpellDef, GameAction } from '@/lib/game/types';
|
||||
import type { UnifiedStore, GameContextValue } from './types';
|
||||
import { GameContext } from './context-create';
|
||||
|
||||
function createUnifiedStore(
|
||||
gameStore: ReturnType<typeof useGameStore.getState>,
|
||||
skillState: ReturnType<typeof useSkillStore.getState>,
|
||||
manaState: ReturnType<typeof useManaStore.getState>,
|
||||
prestigeState: ReturnType<typeof usePrestigeStore.getState>,
|
||||
uiState: ReturnType<typeof useUIStore.getState>,
|
||||
combatState: ReturnType<typeof useCombatStore.getState>
|
||||
): UnifiedStore {
|
||||
return {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
() => createUnifiedStore(gameStore, skillState, manaState, prestigeState, uiState, combatState),
|
||||
[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 manaWaterfallBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_WATERFALL)
|
||||
? Math.floor(maxMana / 100) * 0.25
|
||||
: 0;
|
||||
|
||||
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus + manaWaterfallBonus) * meditationMultiplier;
|
||||
|
||||
// Has special flags for UI
|
||||
const hasManaWaterfall = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_WATERFALL);
|
||||
const hasFlowSurge = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.FLOW_SURGE);
|
||||
const hasManaOverflow = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_OVERFLOW);
|
||||
const hasEternalFlow = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.ETERNAL_FLOW);
|
||||
|
||||
// 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,
|
||||
manaWaterfallBonus,
|
||||
effectiveRegen,
|
||||
hasManaWaterfall,
|
||||
hasFlowSurge,
|
||||
hasManaOverflow,
|
||||
hasEternalFlow,
|
||||
dps,
|
||||
activeBoons,
|
||||
canCastSpell,
|
||||
hasSpecial,
|
||||
SPECIAL_EFFECTS,
|
||||
};
|
||||
|
||||
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
|
||||
}
|
||||
|
||||
GameProvider.displayName = "GameProvider";
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
import type { GameContextValue } from './types';
|
||||
|
||||
export const GameContext = createContext<GameContextValue | null>(null);
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useContext } from 'react';
|
||||
import { GameContext } from './context-create';
|
||||
import type { GameContextValue } from './types';
|
||||
|
||||
export function useGameContext(): GameContextValue {
|
||||
const context = useContext(GameContext);
|
||||
if (!context) {
|
||||
throw new Error('useGameContext must be used within a GameProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import type { ElementDef, GuardianDef, SpellDef, GameAction } from '@/lib/game/types';
|
||||
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 { computeEffects } from '@/lib/game/upgrade-effects';
|
||||
import { hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/special-effects';
|
||||
import { getBoonBonuses } from '@/lib/game/utils';
|
||||
|
||||
// Define a unified store type that combines all stores
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
manaWaterfallBonus: number;
|
||||
effectiveRegen: number;
|
||||
|
||||
// Has special flags
|
||||
hasManaWaterfall: boolean;
|
||||
hasFlowSurge: boolean;
|
||||
hasManaOverflow: boolean;
|
||||
hasEternalFlow: boolean;
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -124,16 +124,9 @@ export function useGameToast() {
|
||||
const toastTypeClass = `toast-type-${type}`;
|
||||
|
||||
return toast({
|
||||
title,
|
||||
description,
|
||||
title: title as string,
|
||||
description: description as string,
|
||||
className: toastTypeClass,
|
||||
// Store the type for styling
|
||||
...{ toastType: type },
|
||||
} as {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
toastType?: ToastType;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Gem, Search, ArrowUpDown, AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { ElementBadge } from '@/components/ui/element-badge';
|
||||
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import { type SortMode, type FilterMode, RARITY_ORDER } from './types';
|
||||
import { MaterialsSection } from './MaterialItem';
|
||||
import { EssenceSection } from './EssenceItem';
|
||||
import { BlueprintsSection } from './BlueprintsSection';
|
||||
import { EquipmentSection } from './EquipmentItem';
|
||||
|
||||
interface LootInventoryProps {
|
||||
inventory: LootInventoryType;
|
||||
elements?: Record<string, ElementState>;
|
||||
equipmentInstances?: Record<string, EquipmentInstance>;
|
||||
onDeleteMaterial?: (materialId: string, amount: number) => void;
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function LootInventoryDisplay({
|
||||
inventory,
|
||||
elements,
|
||||
equipmentInstances = {},
|
||||
onDeleteMaterial,
|
||||
onDeleteEquipment,
|
||||
}: LootInventoryProps) {
|
||||
const showToast = useGameToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('rarity');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'material' | 'equipment'; id: string; name: string } | null>(null);
|
||||
|
||||
// Count items
|
||||
const materialCount = Object.values(inventory.materials || {}).reduce((a, b) => a + b, 0);
|
||||
const essenceCount = elements ? Object.entries(elements).reduce((a, [id, e]) => id === 'transference' ? a : a + e.current, 0) : 0;
|
||||
const blueprintCount = inventory.blueprints.length;
|
||||
const equipmentCount = Object.keys(equipmentInstances).length;
|
||||
const totalItems = materialCount + blueprintCount + equipmentCount;
|
||||
|
||||
// Filter and sort materials
|
||||
const filteredMaterials = Object.entries(inventory.materials)
|
||||
.filter(([id, count]) => {
|
||||
if (count <= 0) return false;
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return false;
|
||||
if (searchTerm && !drop.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aCount], [bId, bCount]) => {
|
||||
const aDrop = LOOT_DROPS[aId];
|
||||
const bDrop = LOOT_DROPS[bId];
|
||||
if (!aDrop || !bDrop) return 0;
|
||||
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return aDrop.name.localeCompare(bDrop.name);
|
||||
case 'rarity':
|
||||
return RARITY_ORDER[bDrop.rarity] - RARITY_ORDER[aDrop.rarity];
|
||||
case 'count':
|
||||
return bCount - aCount;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter and sort essence
|
||||
const filteredEssence = elements
|
||||
? Object.entries(elements)
|
||||
.filter(([id, state]) => {
|
||||
if (!state.unlocked || state.current <= 0) return false;
|
||||
if (id === 'transference') return false;
|
||||
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aState], [bId, bState]) => {
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return (ELEMENTS[aId]?.name || aId).localeCompare(ELEMENTS[bId]?.name || bId);
|
||||
case 'count':
|
||||
return bState.current - aState.current;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
|
||||
// Filter and sort equipment
|
||||
const filteredEquipment = Object.entries(equipmentInstances)
|
||||
.filter(([id, instance]) => {
|
||||
if (searchTerm && !instance.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aInst], [bId, bInst]) => {
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return aInst.name.localeCompare(bInst.name);
|
||||
case 'rarity':
|
||||
return RARITY_ORDER[bInst.rarity] - RARITY_ORDER[aInst.rarity];
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if we have anything to show
|
||||
const hasItems = totalItems > 0 || essenceCount > 0;
|
||||
|
||||
const handleDeleteMaterial = (materialId: string) => {
|
||||
const drop = LOOT_DROPS[materialId];
|
||||
if (drop) {
|
||||
setDeleteConfirm({ type: 'material', id: materialId, name: drop.name });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEquipment = (instanceId: string) => {
|
||||
const instance = equipmentInstances[instanceId];
|
||||
if (instance) {
|
||||
setDeleteConfirm({ type: 'equipment', id: instanceId, name: instance.name });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteConfirm) return;
|
||||
|
||||
if (deleteConfirm.type === 'material' && onDeleteMaterial) {
|
||||
const amount = inventory.materials[deleteConfirm.id] || 0;
|
||||
onDeleteMaterial(deleteConfirm.id, amount);
|
||||
showToast('success', 'Material Deleted', `${deleteConfirm.name} removed from inventory`);
|
||||
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
|
||||
onDeleteEquipment(deleteConfirm.id);
|
||||
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
|
||||
}
|
||||
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
if (!hasItems) {
|
||||
return (
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[var(--text-muted)] text-sm text-center py-4">
|
||||
No items collected yet. Defeat floors and guardians to find loot!
|
||||
</div>
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
<Badge
|
||||
className="ml-auto bg-[var(--bg-sunken)] text-[var(--text-secondary)] text-xs border-[var(--border-subtle)]"
|
||||
aria-label={`${totalItems} items in inventory`}
|
||||
>
|
||||
{totalItems} items
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--text-muted)]" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-7 pl-7 bg-[var(--bg-sunken)] border-[var(--border-subtle)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-disabled)]"
|
||||
aria-label="Search inventory"
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
|
||||
aria-label={`Sort by ${sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity'}`}
|
||||
>
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-1 flex-wrap mb-3">
|
||||
{[
|
||||
{ mode: 'all' as FilterMode, label: 'All' },
|
||||
{ mode: 'materials' as FilterMode, label: `Materials (${materialCount})` },
|
||||
{ mode: 'essence' as FilterMode, label: `Essence (${essenceCount})` },
|
||||
{ mode: 'blueprints' as FilterMode, label: `Blueprints (${blueprintCount})` },
|
||||
{ mode: 'equipment' as FilterMode, label: `Equipment (${equipmentCount})` },
|
||||
].map(({ mode, label }) => (
|
||||
<ActionButton
|
||||
key={mode}
|
||||
variant={filterMode === mode ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${filterMode === mode ? '' : 'bg-[var(--bg-sunken)]'}`}
|
||||
onClick={() => setFilterMode(mode)}
|
||||
aria-pressed={filterMode === mode}
|
||||
aria-label={`Filter by ${label}`}
|
||||
>
|
||||
{label}
|
||||
</ActionButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="bg-[var(--border-subtle)] mb-3" />
|
||||
|
||||
<ScrollArea className="h-64 w-full">
|
||||
<div className="space-y-3 pr-2">
|
||||
{/* Materials */}
|
||||
{(filterMode === 'all' || filterMode === 'materials') && (
|
||||
<MaterialsSection
|
||||
materials={filteredMaterials}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Essence */}
|
||||
{(filterMode === 'all' || filterMode === 'essence') && (
|
||||
<EssenceSection essence={filteredEssence} />
|
||||
)}
|
||||
|
||||
{/* Blueprints */}
|
||||
{(filterMode === 'all' || filterMode === 'blueprints') && (
|
||||
<BlueprintsSection blueprints={inventory.blueprints} />
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{(filterMode === 'all' || filterMode === 'equipment') && (
|
||||
<EquipmentSection
|
||||
equipment={filteredEquipment}
|
||||
onDeleteEquipment={handleDeleteEquipment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</GameCard>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<AlertDialogContent className="bg-[var(--bg-surface)] border-[var(--border-default)]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-[var(--mana-light)] flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Delete Item
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[var(--text-secondary)]">
|
||||
Are you sure you want to delete <strong className="text-[var(--text-primary)]">{deleteConfirm?.name}</strong>?
|
||||
{deleteConfirm?.type === 'material' && (
|
||||
<span className="block mt-2 text-[var(--color-danger)]">
|
||||
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
|
||||
</span>
|
||||
)}
|
||||
{deleteConfirm?.type === 'equipment' && (
|
||||
<span className="block mt-2 text-[var(--color-danger)]">
|
||||
This equipment and all its enchantments will be permanently lost!
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-[var(--interactive-danger)] hover:bg-[var(--interactive-danger-hover)] text-white"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LootInventoryDisplay.displayName = "LootInventoryDisplay";
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Gem, Sparkles, Scroll, Droplet, Trash2, Search,
|
||||
Package, Sword, Shield, Shirt, Crown, ArrowUpDown,
|
||||
Wrench, AlertTriangle } from 'lucide-react';
|
||||
import type { EquipmentCategory } from '@/lib/game/data/equipment';
|
||||
import {
|
||||
Gem,
|
||||
Sparkles,
|
||||
Package,
|
||||
Sword,
|
||||
Shirt,
|
||||
Crown,
|
||||
Wrench
|
||||
} from 'lucide-react';
|
||||
|
||||
export const CATEGORY_ICONS: Record<string, typeof Sword> = {
|
||||
caster: Sword,
|
||||
shield: Shield,
|
||||
catalyst: Sparkles,
|
||||
head: Crown,
|
||||
body: Shirt,
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Gem, Search, ArrowUpDown, AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { ElementBadge } from '@/components/ui/element-badge';
|
||||
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import { type SortMode, type FilterMode, RARITY_ORDER, RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { MaterialsSection } from './MaterialItem';
|
||||
import { EssenceSection } from './EssenceItem';
|
||||
import { BlueprintsSection } from './BlueprintsSection';
|
||||
import { EquipmentSection } from './EquipmentItem';
|
||||
|
||||
interface LootInventoryProps {
|
||||
inventory: LootInventoryType;
|
||||
elements?: Record<string, ElementState>;
|
||||
equipmentInstances?: Record<string, EquipmentInstance>;
|
||||
onDeleteMaterial?: (materialId: string, amount: number) => void;
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function LootInventoryDisplay({
|
||||
inventory,
|
||||
elements,
|
||||
equipmentInstances = {},
|
||||
onDeleteMaterial,
|
||||
onDeleteEquipment,
|
||||
}: LootInventoryProps) {
|
||||
const showToast = useGameToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('rarity');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'material' | 'equipment'; id: string; name: string } | null>(null);
|
||||
|
||||
// Count items
|
||||
const materialCount = Object.values(inventory.materials || {}).reduce((a: number, b: number) => a + b, 0);
|
||||
|
||||
// Calculate essence count
|
||||
let essenceCount = 0;
|
||||
if (elements) {
|
||||
essenceCount = Object.entries(elements).reduce((acc: number, [id, state]) => {
|
||||
if (id === 'transference') return acc;
|
||||
return acc + (state.current || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const blueprintCount = inventory.blueprints.length;
|
||||
const equipmentCount = Object.keys(equipmentInstances).length;
|
||||
const totalItems = materialCount + blueprintCount + equipmentCount;
|
||||
|
||||
// Filter and sort materials
|
||||
const filteredMaterials = Object.entries(inventory.materials)
|
||||
.filter(([id, count]) => {
|
||||
if (count <= 0) return false;
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return false;
|
||||
if (searchTerm && !drop.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aCount], [bId, bCount]) => {
|
||||
const aDrop = LOOT_DROPS[aId];
|
||||
const bDrop = LOOT_DROPS[bId];
|
||||
if (!aDrop || !bDrop) return 0;
|
||||
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return aDrop.name.localeCompare(bDrop.name);
|
||||
case 'rarity':
|
||||
return RARITY_ORDER[bDrop.rarity] - RARITY_ORDER[aDrop.rarity];
|
||||
case 'count':
|
||||
return bCount - aCount;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter and sort essence
|
||||
const filteredEssence = elements
|
||||
? Object.entries(elements)
|
||||
.filter(([id, state]) => {
|
||||
if (!state.unlocked || state.current <= 0) return false;
|
||||
if (id === 'transference') return false;
|
||||
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aState], [bId, bState]) => {
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return (ELEMENTS[aId]?.name || aId).localeCompare(ELEMENTS[bId]?.name || bId);
|
||||
case 'count':
|
||||
return bState.current - aState.current;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
|
||||
// Filter and sort equipment
|
||||
const filteredEquipment = Object.entries(equipmentInstances)
|
||||
.filter(([id, instance]) => {
|
||||
if (searchTerm && !instance.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aInst], [bId, bInst]) => {
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return aInst.name.localeCompare(bInst.name);
|
||||
case 'rarity':
|
||||
return RARITY_ORDER[bInst.rarity] - RARITY_ORDER[aInst.rarity];
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const hasItems = totalItems > 0 || essenceCount > 0;
|
||||
|
||||
const handleDeleteMaterial = (materialId: string) => {
|
||||
const drop = LOOT_DROPS[materialId];
|
||||
if (drop) {
|
||||
setDeleteConfirm({ type: 'material', id: materialId, name: drop.name });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEquipment = (instanceId: string) => {
|
||||
const instance = equipmentInstances[instanceId];
|
||||
if (instance) {
|
||||
setDeleteConfirm({ type: 'equipment', id: instanceId, name: instance.name });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteConfirm) return;
|
||||
|
||||
if (deleteConfirm.type === 'material' && onDeleteMaterial) {
|
||||
const amount = inventory.materials[deleteConfirm.id] || 0;
|
||||
onDeleteMaterial(deleteConfirm.id, amount);
|
||||
showToast('success', 'Material Deleted', `${deleteConfirm.name} removed from inventory`);
|
||||
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
|
||||
onDeleteEquipment(deleteConfirm.id);
|
||||
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
|
||||
}
|
||||
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
if (!hasItems) {
|
||||
return (
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[var(--text-muted)] text-sm text-center py-4">
|
||||
No items collected yet. Defeat floors and guardians to find loot!
|
||||
</div>
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
<Badge
|
||||
className="ml-auto bg-[var(--bg-sunken)] text-[var(--text-secondary)] text-xs border-[var(--border-subtle)]"
|
||||
aria-label={`${totalItems} items in inventory`}
|
||||
>
|
||||
{totalItems} items
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--text-muted)]" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-7 pl-7 bg-[var(--bg-sunken)] border-[var(--border-subtle)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-disabled)]"
|
||||
aria-label="Search inventory"
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
|
||||
aria-label={`Sort by ${sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity'}`}
|
||||
>
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-1 flex-wrap mb-3">
|
||||
{[
|
||||
{ mode: 'all' as FilterMode, label: 'All' },
|
||||
{ mode: 'materials' as FilterMode, label: `Materials (${materialCount})` },
|
||||
{ mode: 'essence' as FilterMode, label: `Essence (${essenceCount})` },
|
||||
{ mode: 'blueprints' as FilterMode, label: `Blueprints (${blueprintCount})` },
|
||||
{ mode: 'equipment' as FilterMode, label: `Equipment (${equipmentCount})` },
|
||||
].map(({ mode, label }) => (
|
||||
<ActionButton
|
||||
key={mode}
|
||||
variant={filterMode === mode ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${filterMode === mode ? '' : 'bg-[var(--bg-sunken)]'}`}
|
||||
onClick={() => setFilterMode(mode)}
|
||||
aria-pressed={filterMode === mode}
|
||||
aria-label={`Filter by ${label}`}
|
||||
>
|
||||
{label}
|
||||
</ActionButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="bg-[var(--border-subtle)] mb-3" />
|
||||
|
||||
<ScrollArea className="h-64 w-full">
|
||||
<div className="space-y-3 pr-2">
|
||||
{/* Materials */}
|
||||
{(filterMode === 'all' || filterMode === 'materials') && (
|
||||
<MaterialsSection
|
||||
materials={filteredMaterials}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Essence */}
|
||||
{(filterMode === 'all' || filterMode === 'essence') && (
|
||||
<EssenceSection essence={filteredEssence} />
|
||||
)}
|
||||
|
||||
{/* Blueprints */}
|
||||
{(filterMode === 'all' || filterMode === 'blueprints') && (
|
||||
<BlueprintsSection blueprints={inventory.blueprints} />
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{(filterMode === 'all' || filterMode === 'equipment') && (
|
||||
<EquipmentSection
|
||||
equipment={filteredEquipment}
|
||||
onDeleteEquipment={handleDeleteEquipment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</GameCard>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<AlertDialogContent className="bg-[var(--bg-surface)] border-[var(--border-default)]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-[var(--mana-light)] flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Delete Item
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[var(--text-secondary)]">
|
||||
Are you sure you want to delete <strong className="text-[var(--text-primary)]">{deleteConfirm?.name}</strong>?
|
||||
{deleteConfirm?.type === 'material' && (
|
||||
<span className="block mt-2 text-[var(--color-danger)]">
|
||||
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
|
||||
</span>
|
||||
)}
|
||||
{deleteConfirm?.type === 'equipment' && (
|
||||
<span className="block mt-2 text-[var(--color-danger)]">
|
||||
This equipment and all its enchantments will be permanently lost!
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-[var(--interactive-danger)] hover:bg-[var(--interactive-danger-hover)] text-white"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LootInventoryDisplay.displayName = "LootInventoryDisplay";
|
||||
@@ -1,11 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
export type SortMode = 'name' | 'rarity' | 'count';
|
||||
export type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ManaDisplay({
|
||||
style={{
|
||||
background: 'var(--mana-raw)',
|
||||
border: '1px solid var(--border-accent)',
|
||||
color: '#0C1020',
|
||||
color: 'var(--bg-gather-btn)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
onMouseDown={onGatherStart}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSkillStore, usePrestigeStore, fmt, fmtDec } from '@/lib/game/stores';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { ManaStatsSection } from './StatsTab/ManaStatsSection';
|
||||
import { CombatStatsSection } from './StatsTab/CombatStatsSection';
|
||||
import { PactStatusSection } from './StatsTab/PactStatusSection';
|
||||
import { StudyStatsSection } from './StatsTab/StudyStatsSection';
|
||||
import { ElementStatsSection } from './StatsTab/ElementStatsSection';
|
||||
import { ActiveUpgradesSection } from './StatsTab/ActiveUpgradesSection';
|
||||
import { LoopStatsSection } from './StatsTab/LoopStatsSection';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
export function StatsTab() {
|
||||
const skillUpgrades = useSkillStore((s) => s.skillUpgrades);
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const skillTiers = useSkillStore((s) => s.skillTiers);
|
||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
||||
|
||||
const manaStats = useManaStats();
|
||||
const combatStats = useCombatStats();
|
||||
const studyStats = useStudyStats();
|
||||
|
||||
// Compute element max
|
||||
const elemMax = (() => {
|
||||
const ea = skillTiers?.elemAttune || 1;
|
||||
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||
const level = skills[tieredSkillId] || skills.elemAttune || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return 10 + level * 50 * tierMult + (prestigeUpgrades.elementalAttune || 0) * 25;
|
||||
})();
|
||||
|
||||
// Get all selected skill upgrades
|
||||
const getAllSelectedUpgrades = (): { skillId: string; upgrade: SkillUpgradeChoice }[] => {
|
||||
const upgrades: { skillId: string; upgrade: SkillUpgradeChoice }[] = [];
|
||||
for (const [skillId, selectedIds] of Object.entries(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 as any).upgrades?.find((u: any) => u.id === upgradeId);
|
||||
if (upgrade) {
|
||||
upgrades.push({ skillId, upgrade });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return upgrades;
|
||||
};
|
||||
|
||||
const selectedUpgrades = getAllSelectedUpgrades();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ManaStatsSection
|
||||
maxMana={manaStats.maxMana}
|
||||
baseRegen={manaStats.baseRegen}
|
||||
effectiveRegen={manaStats.effectiveRegen}
|
||||
clickMana={manaStats.clickMana}
|
||||
meditationMultiplier={manaStats.meditationMultiplier}
|
||||
upgradeEffects={manaStats.upgradeEffects}
|
||||
elemMax={elemMax}
|
||||
selectedUpgrades={selectedUpgrades}
|
||||
/>
|
||||
<CombatStatsSection
|
||||
activeSpellDef={combatStats.activeSpellDef}
|
||||
pactMultiplier={combatStats.pactMultiplier}
|
||||
/>
|
||||
<PactStatusSection
|
||||
pactMultiplier={combatStats.pactMultiplier}
|
||||
pactInsightMultiplier={combatStats.pactInsightMultiplier}
|
||||
/>
|
||||
<StudyStatsSection
|
||||
studySpeedMult={studyStats.studySpeedMult}
|
||||
studyCostMult={studyStats.studyCostMult}
|
||||
/>
|
||||
<ElementStatsSection
|
||||
elemMax={elemMax}
|
||||
/>
|
||||
<ActiveUpgradesSection selectedUpgrades={selectedUpgrades} />
|
||||
<LoopStatsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
StatsTab.displayName = "StatsTab";
|
||||
@@ -1,71 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Star } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { SKILL_EVOLUTION_PATHS } from '@/lib/game/skill-evolution';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
interface ActiveUpgradesSectionProps {
|
||||
selectedUpgrades: { skillId: string; upgrade: SkillUpgradeChoice }[];
|
||||
}
|
||||
|
||||
export function ActiveUpgradesSection({ selectedUpgrades }: ActiveUpgradesSectionProps) {
|
||||
if (selectedUpgrades.length === 0) {
|
||||
return (
|
||||
<Card className="bg-[var(--bg-panel)] border-[var(--border-subtle)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-[var(--mana-light)] game-panel-title text-xs flex items-center gap-2">
|
||||
<Star className="w-4 h-4" />
|
||||
Active Skill Upgrades (0)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div style={{ color: 'var(--text-muted)' }} className="text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--bg-panel)] border-[var(--border-subtle)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-[var(--mana-light)] game-panel-title text-xs flex items-center gap-2">
|
||||
<Star className="w-4 h-4" />
|
||||
Active Skill Upgrades ({selectedUpgrades.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{selectedUpgrades.map(({ skillId, upgrade }) => (
|
||||
<div key={upgrade.id} className="p-2 rounded transition-colors" style={{ border: '1px solid var(--mana-light)/30', background: 'var(--mana-light)/10' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ color: 'var(--mana-light)' }} className="text-sm font-semibold">{upgrade.name}</span>
|
||||
<Badge variant="outline" className="text-xs" style={{ color: 'var(--text-muted)', borderColor: 'var(--border-subtle)' }}>
|
||||
{SKILLS_DEF[skillId]?.name || skillId}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs mt-1" style={{ color: 'var(--color-success)' }}>
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs mt-1" style={{ color: 'var(--mana-water)' }}>
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs mt-1" style={{ color: 'var(--mana-crystal)' }}>
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect active'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Swords } from 'lucide-react';
|
||||
import { fmt, fmtDec } from '@/lib/game/stores';
|
||||
import { useSkillStore } from '@/lib/game/stores';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
|
||||
interface CombatStatsSectionProps {
|
||||
activeSpellDef: any;
|
||||
pactMultiplier: number;
|
||||
}
|
||||
|
||||
export function CombatStatsSection({ activeSpellDef, pactMultiplier }: CombatStatsSectionProps) {
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const skillUpgrades = useSkillStore((s) => s.skillUpgrades);
|
||||
const skillTiers = useSkillStore((s) => s.skillTiers);
|
||||
|
||||
const upgradeEffects = getUnifiedEffects({
|
||||
skillUpgrades,
|
||||
skillTiers,
|
||||
equippedInstances: {},
|
||||
equipmentInstances: {},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--bg-panel)] border-[var(--border-subtle)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-[var(--mana-fire)] 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 style={{ color: 'var(--text-muted)' }}>Active Spell Base Damage:</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{activeSpellDef?.dmg || 5}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: 'var(--text-muted)' }}>Combat Training Bonus:</span>
|
||||
<span style={{ color: 'var(--mana-fire)' }}>+{(skills.combatTrain || 0) * 5}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: 'var(--text-muted)' }}>Arcane Fury Multiplier:</span>
|
||||
<span style={{ color: 'var(--mana-fire)' }}>×{fmtDec(1 + (skills.arcaneFury || 0) * 0.1, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: 'var(--text-muted)' }}>Elemental Mastery:</span>
|
||||
<span style={{ color: 'var(--mana-fire)' }}>×{fmtDec(1 + (skills.elementalMastery || 0) * 0.15, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: 'var(--text-muted)' }}>Guardian Bane:</span>
|
||||
<span style={{ color: 'var(--mana-fire)' }}>×{fmtDec(1 + (skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: 'var(--text-muted)' }}>Critical Hit Chance:</span>
|
||||
<span style={{ color: 'var(--mana-light)' }}>{((skills.precision || 0) * 5)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: 'var(--text-muted)' }}>Critical Multiplier:</span>
|
||||
<span style={{ color: 'var(--mana-light)' }}>1.5x</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: 'var(--text-muted)' }}>Spell Echo Chance:</span>
|
||||
<span style={{ color: 'var(--mana-light)' }}>{((skills.spellEcho || 0) * 10)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: 'var(--text-muted)' }}>Pact Multiplier:</span>
|
||||
<span style={{ color: 'var(--mana-light)' }}>×{fmtDec(pactMultiplier, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-[var(--border-subtle)] pt-2">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Total Damage:</span>
|
||||
<span style={{ color: 'var(--mana-fire)' }}>{fmt(activeSpellDef ? activeSpellDef.dmg * pactMultiplier : 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { BookOpen, X } from 'lucide-react';
|
||||
import { SKILLS_DEF, SPELLS_DEF } from '@/lib/game/constants';
|
||||
import { formatStudyTime } from '@/lib/game/formatting';
|
||||
import type { StudyTarget } from '@/lib/game/types';
|
||||
|
||||
interface StudyProgressProps {
|
||||
currentStudyTarget: StudyTarget | null;
|
||||
skills: Record<string, number>;
|
||||
studySpeedMult: number;
|
||||
cancelStudy: () => void;
|
||||
}
|
||||
|
||||
export function StudyProgress({
|
||||
currentStudyTarget,
|
||||
skills,
|
||||
studySpeedMult,
|
||||
cancelStudy,
|
||||
}: StudyProgressProps) {
|
||||
if (!currentStudyTarget) return null;
|
||||
|
||||
const target = currentStudyTarget;
|
||||
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 ? (skills[target.id] || 0) : 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}
|
||||
{isSkill && ` Lv.${currentLevel + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={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>
|
||||
);
|
||||
}
|
||||
|
||||
StudyProgress.displayName = "StudyProgress";
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { formatHour } from '@/lib/game/formatting';
|
||||
import { formatHour } from '@/lib/game/utils/formatting';
|
||||
|
||||
interface TimeDisplayProps {
|
||||
day: number;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -33,7 +32,6 @@ export function UpgradeDialog({
|
||||
}: UpgradeDialogProps) {
|
||||
if (!skillId) return null;
|
||||
|
||||
const skillDef = SKILLS_DEF[skillId];
|
||||
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
|
||||
|
||||
return (
|
||||
@@ -41,7 +39,7 @@ export function UpgradeDialog({
|
||||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-amber-400">
|
||||
Choose Upgrade - {skillDef?.name || skillId}
|
||||
Choose Upgrade - {skillId}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Level {milestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { StatRow } from '@/components/ui/stat-row';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||
import type { EquipmentInstance, EnchantmentDesign, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||
import type { EquipmentSlot } from '@/lib/game/data/equipment';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { CheckCircle, Sparkles } from 'lucide-react';
|
||||
import { useGameStore, useCraftingStore, useManaStore } from '@/lib/game/stores';
|
||||
import { useCraftingStore, useManaStore } from '@/lib/game/stores';
|
||||
|
||||
export interface EnchantmentApplierProps {
|
||||
selectedEquipmentInstance: string | null;
|
||||
@@ -36,7 +33,7 @@ export function EnchantmentApplier({
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
const enchantmentDesigns = useCraftingStore((s) => s.enchantmentDesigns);
|
||||
const applicationProgress = useCraftingStore((s) => s.applicationProgress);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const _rawMana = useManaStore((s) => s.rawMana);
|
||||
const startApplying = useCraftingStore((s) => s.startApplying);
|
||||
const pauseApplication = useCraftingStore((s) => s.pauseApplication);
|
||||
const resumeApplication = useCraftingStore((s) => s.resumeApplication);
|
||||
@@ -112,7 +109,7 @@ export function EnchantmentApplier({
|
||||
</div>
|
||||
<ScrollArea className="h-32">
|
||||
<div className="space-y-1">
|
||||
{equippedItems.map(({ slot, instance }) => (
|
||||
{equippedItems.map(({ slot: _slot, instance }) => (
|
||||
<div
|
||||
key={instance.instanceId}
|
||||
className={`p-2 rounded border cursor-pointer text-sm transition-all
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, EquipmentCraftingProgress, EquipmentCategory } from '@/lib/game/types';
|
||||
import type { EnchantmentDesignerProps } from './EnchantmentDesigner/types';
|
||||
import { EquipmentTypeSelector } from './EnchantmentDesigner/EquipmentTypeSelector';
|
||||
import { EffectSelector } from './EnchantmentDesigner/EffectSelector';
|
||||
@@ -23,7 +20,6 @@ import {
|
||||
removeEffectFromDesign,
|
||||
} from './EnchantmentDesigner/utils';
|
||||
import { useCraftingStore } from '@/lib/game/stores';
|
||||
import { useSkillStore } from '@/lib/game/stores';
|
||||
|
||||
export function EnchantmentDesigner({
|
||||
selectedEquipmentType,
|
||||
@@ -44,15 +40,8 @@ export function EnchantmentDesigner({
|
||||
const unlockedEffects = useCraftingStore((s) => s.unlockedEffects);
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
|
||||
// Skill store selectors
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const skillUpgrades = useSkillStore((s) => s.skillUpgrades);
|
||||
|
||||
const enchantingLevel = skills?.enchanting || 0;
|
||||
const efficiencyBonus = (skillUpgrades?.['efficientEnchant'] || []).length * 0.05 || 0;
|
||||
|
||||
// Calculate total capacity cost for current design
|
||||
const designCapacityCost = calculateDesignCapacityCost(selectedEffects, efficiencyBonus);
|
||||
const designCapacityCost = calculateDesignCapacityCost(selectedEffects, 0);
|
||||
|
||||
// Get capacity limit for selected equipment type
|
||||
const selectedEquipmentCapacity = getEquipmentCapacity(selectedEquipmentType);
|
||||
@@ -62,7 +51,7 @@ export function EnchantmentDesigner({
|
||||
|
||||
// Add effect to design
|
||||
const addEffect = (effectId: string) => {
|
||||
addEffectToDesign(effectId, selectedEffects, efficiencyBonus, setSelectedEffects);
|
||||
addEffectToDesign(effectId, selectedEffects, 0, setSelectedEffects);
|
||||
};
|
||||
|
||||
// Remove effect from design
|
||||
@@ -93,7 +82,7 @@ export function EnchantmentDesigner({
|
||||
const ownedEquipmentTypes = getOwnedEquipmentTypes(equipmentInstances);
|
||||
|
||||
// Get the reason why an effect is incompatible
|
||||
const getIncompatibilityReasonWrapper = (effect: { id: string; name: string; description: string; allowedEquipmentCategories: any[] }) => {
|
||||
const getIncompatibilityReasonWrapper = (effect: { id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] }) => {
|
||||
return getIncompatibilityReason(effect, selectedEquipmentType);
|
||||
};
|
||||
|
||||
@@ -117,8 +106,8 @@ export function EnchantmentDesigner({
|
||||
setSelectedEffects={setSelectedEffects}
|
||||
availableEffects={availableEffects}
|
||||
incompatibleEffects={incompatibleEffects}
|
||||
enchantingLevel={enchantingLevel}
|
||||
efficiencyBonus={efficiencyBonus}
|
||||
enchantingLevel={0}
|
||||
efficiencyBonus={0}
|
||||
designProgress={designProgress}
|
||||
addEffect={addEffect}
|
||||
removeEffect={removeEffect}
|
||||
|
||||
@@ -12,7 +12,6 @@ export function DesignForm({
|
||||
selectedEquipmentCapacity,
|
||||
isOverCapacity,
|
||||
designTime,
|
||||
selectedEquipmentType,
|
||||
handleCreateDesign,
|
||||
}: DesignFormProps) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -14,7 +12,6 @@ import type { EffectSelectorProps } from './types';
|
||||
export function EffectSelector({
|
||||
selectedEquipmentType,
|
||||
selectedEffects,
|
||||
setSelectedEffects,
|
||||
availableEffects,
|
||||
incompatibleEffects,
|
||||
enchantingLevel,
|
||||
@@ -55,7 +52,7 @@ export function EffectSelector({
|
||||
{/* Compatible Effects */}
|
||||
{availableEffects.map(effect => {
|
||||
const selected = selectedEffects.find(e => e.effectId === effect.id);
|
||||
const cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
|
||||
const _cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -78,7 +75,7 @@ export function EffectSelector({
|
||||
{selected && (
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeEffect(effect.id)}
|
||||
>
|
||||
@@ -87,7 +84,7 @@ export function EffectSelector({
|
||||
)}
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => addEffect(effect.id)}
|
||||
disabled={!selected && selectedEffects.length >= 5}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function EquipmentTypeSelector({
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
||||
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
|
||||
<ActionButton size="sm" variant="outline" onClick={cancelDesign}>Cancel</ActionButton>
|
||||
<ActionButton size="sm" variant="ghost" onClick={cancelDesign}>Cancel</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, EquipmentCraftingProgress, EquipmentCategory } from '@/lib/game/types';
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, DesignProgress, EquipmentCategory } from '@/lib/game/types';
|
||||
|
||||
export interface EnchantmentDesignerProps {
|
||||
selectedEquipmentType: string | null;
|
||||
@@ -15,7 +15,7 @@ export interface EquipmentTypeSelectorProps {
|
||||
ownedEquipmentTypes: Array<{ id: string; name: string; baseCapacity: number }>;
|
||||
selectedEquipmentType: string | null;
|
||||
setSelectedEquipmentType: (type: string | null) => void;
|
||||
designProgress: EquipmentCraftingProgress | null;
|
||||
designProgress: DesignProgress | null;
|
||||
cancelDesign: () => void;
|
||||
}
|
||||
|
||||
@@ -24,10 +24,10 @@ export interface EffectSelectorProps {
|
||||
selectedEffects: DesignEffect[];
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void;
|
||||
availableEffects: Array<{ id: string; name: string; description: string; baseCapacityCost: number; maxStacks: number }>;
|
||||
incompatibleEffects: Array<{ id: string; name: string; description: string }>;
|
||||
incompatibleEffects: Array<{ id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] }>;
|
||||
enchantingLevel: number;
|
||||
efficiencyBonus: number;
|
||||
designProgress: EquipmentCraftingProgress | null;
|
||||
designProgress: DesignProgress | null;
|
||||
addEffect: (effectId: string) => void;
|
||||
removeEffect: (effectId: string) => void;
|
||||
getIncompatibilityReason: (effect: { id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] }) => string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
|
||||
import type { DesignEffect, EquipmentInstance, EquipmentCategory } from '@/lib/game/types';
|
||||
import { calculateDesignCapacityCost as calcCapacityCost, calculateDesignTime as calcDesignTime } from '@/lib/game/crafting-design';
|
||||
|
||||
/**
|
||||
* Get available effects for selected equipment type (only unlocked ones)
|
||||
@@ -85,15 +86,13 @@ export function getIncompatibilityReason(
|
||||
|
||||
/**
|
||||
* Calculate total capacity cost for current design
|
||||
* Delegates to canonical calculateDesignCapacityCost from crafting-design
|
||||
*/
|
||||
export function calculateDesignCapacityCost(
|
||||
selectedEffects: DesignEffect[],
|
||||
efficiencyBonus: number
|
||||
): number {
|
||||
return selectedEffects.reduce(
|
||||
(total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus),
|
||||
0
|
||||
);
|
||||
return calcCapacityCost(selectedEffects, efficiencyBonus);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,9 +104,10 @@ export function getEquipmentCapacity(selectedEquipmentType: string | null): numb
|
||||
|
||||
/**
|
||||
* Calculate design time
|
||||
* Delegates to canonical calculateDesignTime from crafting-design
|
||||
*/
|
||||
export function calculateDesignTime(selectedEffects: DesignEffect[]): number {
|
||||
return selectedEffects.reduce((total, eff) => total + 0.5 * eff.stacks, 1);
|
||||
return calcDesignTime(selectedEffects);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,11 +10,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Trash2, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import type { EquipmentInstance, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||
import type { EquipmentSlot } from '@/lib/game/types';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { useGameStore, useCraftingStore, useManaStore, useSkillStore } from '@/lib/game/stores';
|
||||
import { useCraftingStore, useManaStore } from '@/lib/game/stores';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
|
||||
export interface EnchantmentPreparerProps {
|
||||
@@ -31,7 +29,6 @@ export function EnchantmentPreparer({
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
const preparationProgress = useCraftingStore((s) => s.preparationProgress);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const startPreparing = useCraftingStore((s) => s.startPreparing);
|
||||
const cancelPreparation = useCraftingStore((s) => s.cancelPreparation);
|
||||
|
||||
@@ -94,7 +91,7 @@ export function EnchantmentPreparer({
|
||||
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
|
||||
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
|
||||
</div>
|
||||
<ActionButton size="sm" variant="outline" onClick={() => {
|
||||
<ActionButton size="sm" variant="ghost" onClick={() => {
|
||||
cancelPreparation();
|
||||
showToast('warning', 'Preparation Cancelled', 'Equipment preparation was cancelled.');
|
||||
}}>Cancel</ActionButton>
|
||||
|
||||
@@ -8,68 +8,49 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Package, Sparkles, Trash2, Anvil } from 'lucide-react';
|
||||
import { CRAFTING_RECIPES, canCraftRecipe } from '@/lib/game/data/crafting-recipes';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import type { EquipmentInstance, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||
import { LOOT_DROPS, LOOT_RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import type { LootInventory } from '@/lib/game/types';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { useCraftingStore, useCombatStore, useManaStore } from '@/lib/game/stores';
|
||||
|
||||
export function EquipmentCrafter() {
|
||||
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
||||
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
const startCraftingEquipment = useCraftingStore((s) => s.startCraftingEquipment);
|
||||
// ─── Crafting Progress ───────────────────────────────────────────────────────
|
||||
|
||||
function CraftingProgress({ progress }: { progress: { blueprintId: string; progress: number; required: number; manaSpent: number } }) {
|
||||
const recipe = CRAFTING_RECIPES[progress.blueprintId];
|
||||
const cancelEquipmentCrafting = useCraftingStore((s) => s.cancelEquipmentCrafting);
|
||||
const deleteMaterial = useCraftingStore((s) => s.deleteMaterial);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Blueprint Selection */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Anvil className="w-4 h-4" />
|
||||
Available Blueprints
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{equipmentCraftingProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Crafting: {CRAFTING_RECIPES[equipmentCraftingProgress.blueprintId]?.name}
|
||||
Crafting: {recipe?.name}
|
||||
</div>
|
||||
<Progress value={(equipmentCraftingProgress.progress / equipmentCraftingProgress.required) * 100} className="h-3" />
|
||||
<Progress value={(progress.progress / progress.required) * 100} className="h-3" />
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{equipmentCraftingProgress.progress.toFixed(1)}h / {equipmentCraftingProgress.required.toFixed(1)}h</span>
|
||||
<span>Mana spent: {fmt(equipmentCraftingProgress.manaSpent)}</span>
|
||||
<span>{progress.progress.toFixed(1)}h / {progress.required.toFixed(1)}h</span>
|
||||
<span>Mana spent: {fmt(progress.manaSpent)}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2">
|
||||
{lootInventory.blueprints.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No blueprints discovered yet.</p>
|
||||
<p className="text-xs mt-1">Defeat guardians to find blueprints!</p>
|
||||
</div>
|
||||
) : (
|
||||
lootInventory.blueprints.map(bpId => {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blueprint Card ───────────────────────────────────────────────────────────
|
||||
|
||||
function BlueprintCard({ bpId, lootInventory, rawMana, isCrafting, startCraftingEquipment }: {
|
||||
bpId: string;
|
||||
lootInventory: LootInventory;
|
||||
rawMana: number;
|
||||
isCrafting: boolean;
|
||||
startCraftingEquipment: (id: string) => void;
|
||||
}) {
|
||||
const recipe = CRAFTING_RECIPES[bpId];
|
||||
if (!recipe) return null;
|
||||
|
||||
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
|
||||
recipe,
|
||||
lootInventory.materials,
|
||||
rawMana
|
||||
);
|
||||
|
||||
const rarityStyle = RARITY_COLORS[recipe.rarity];
|
||||
const { canCraft } = canCraftRecipe(recipe, lootInventory.materials, rawMana);
|
||||
const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={bpId}
|
||||
className="p-3 rounded border bg-gray-800/50"
|
||||
style={{ borderColor: rarityStyle?.color }}
|
||||
>
|
||||
@@ -122,49 +103,56 @@ export function EquipmentCrafter() {
|
||||
<Button
|
||||
className="w-full mt-3"
|
||||
size="sm"
|
||||
disabled={!canCraft || currentAction === 'craft'}
|
||||
disabled={!canCraft || isCrafting}
|
||||
onClick={() => startCraftingEquipment(bpId)}
|
||||
>
|
||||
{canCraft ? 'Craft Equipment' : 'Missing Resources'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
}
|
||||
|
||||
// ─── Blueprint List ───────────────────────────────────────────────────────────
|
||||
|
||||
function BlueprintList({ lootInventory, rawMana, startCraftingEquipment, currentAction }: { lootInventory: LootInventory; rawMana: number; startCraftingEquipment: (id: string) => void; currentAction: string | null }) {
|
||||
if (lootInventory.blueprints.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No blueprints discovered yet.</p>
|
||||
<p className="text-xs mt-1">Defeat guardians to find blueprints!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2">
|
||||
{lootInventory.blueprints.map(bpId => (
|
||||
<BlueprintCard
|
||||
key={bpId}
|
||||
bpId={bpId}
|
||||
lootInventory={lootInventory}
|
||||
rawMana={rawMana}
|
||||
isCrafting={currentAction === 'craft'}
|
||||
startCraftingEquipment={startCraftingEquipment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Materials Inventory */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
Materials ({Object.values(lootInventory.materials).reduce((a, b) => a + b, 0)})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-64">
|
||||
{Object.keys(lootInventory.materials).length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Sparkles className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No materials collected yet.</p>
|
||||
<p className="text-xs mt-1">Defeat floors to gather materials!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(lootInventory.materials).map(([matId, count]) => {
|
||||
if (count <= 0) return null;
|
||||
// ─── Material Card ────────────────────────────────────────────────────────────
|
||||
|
||||
function MaterialCard({ matId, count, deleteMaterial }: { matId: string; count: number; deleteMaterial: (id: string, count: number) => void }) {
|
||||
const drop = LOOT_DROPS[matId];
|
||||
if (!drop) return null;
|
||||
|
||||
const rarityStyle = RARITY_COLORS[drop.rarity];
|
||||
const rarityStyle = LOOT_RARITY_COLORS[drop.rarity];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={matId}
|
||||
className="p-2 rounded border bg-gray-800/50 group relative"
|
||||
style={{ borderColor: rarityStyle?.color }}
|
||||
>
|
||||
@@ -186,14 +174,74 @@ export function EquipmentCrafter() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Materials Inventory ─────────────────────────────────────────────────────
|
||||
|
||||
function MaterialsInventory({ materials, deleteMaterial }: { materials: Record<string, number>; deleteMaterial: (id: string, count: number) => void }) {
|
||||
const totalCount = Object.values(materials).reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
Materials ({totalCount})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-64">
|
||||
{Object.keys(materials).length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Sparkles className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No materials collected yet.</p>
|
||||
<p className="text-xs mt-1">Defeat floors to gather materials!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(materials).map(([matId, count]) => {
|
||||
if (count <= 0) return null;
|
||||
return <MaterialCard key={matId} matId={matId} count={count} deleteMaterial={deleteMaterial} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function EquipmentCrafter() {
|
||||
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
||||
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
||||
const startCraftingEquipment = useCraftingStore((s) => s.startCraftingEquipment);
|
||||
const deleteMaterial = useCraftingStore((s) => s.deleteMaterial);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Anvil className="w-4 h-4" />
|
||||
Available Blueprints
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{equipmentCraftingProgress ? (
|
||||
<CraftingProgress progress={equipmentCraftingProgress} />
|
||||
) : (
|
||||
<BlueprintList lootInventory={lootInventory} rawMana={rawMana} startCraftingEquipment={startCraftingEquipment} currentAction={currentAction} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<MaterialsInventory materials={lootInventory.materials} deleteMaterial={deleteMaterial} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EquipmentCrafter.displayName = "EquipmentCrafter";
|
||||
EquipmentCrafter.displayName = 'EquipmentCrafter';
|
||||
|
||||
@@ -9,55 +9,14 @@ import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
RotateCcw, AlertTriangle, Zap, Clock, Settings, Eye,
|
||||
} from 'lucide-react';
|
||||
import { useDebug } from '@/lib/game/debug-context';
|
||||
import { useDebug } from '@/components/game/debug/debug-context';
|
||||
import { useGameStore, useManaStore, useUIStore, useCombatStore } from '@/lib/game/stores';
|
||||
import { computeMaxMana } from '@/lib/game/stores';
|
||||
|
||||
export function GameStateDebug() {
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const { showComponentNames, toggleComponentNames } = useDebug();
|
||||
|
||||
// Get state from modular stores
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const unlockElement = useManaStore((s) => s.unlockElement);
|
||||
const gatherMana = useGameStore((s) => s.gatherMana);
|
||||
const day = useGameStore((s) => s.day);
|
||||
const hour = useGameStore((s) => s.hour);
|
||||
const paused = useUIStore((s) => s.paused);
|
||||
const togglePause = useUIStore((s) => s.togglePause);
|
||||
|
||||
// Get actions from stores
|
||||
const resetGame = useGameStore((s) => s.resetGame);
|
||||
const debugSetFloor = useCombatStore((s) => s.debugSetFloor);
|
||||
const resetFloorHP = useCombatStore((s) => s.resetFloorHP);
|
||||
const debugSetTime = useCombatStore((s) => s.debugSetTime);
|
||||
|
||||
const handleReset = () => {
|
||||
if (confirmReset) {
|
||||
resetGame();
|
||||
setConfirmReset(false);
|
||||
} else {
|
||||
setConfirmReset(true);
|
||||
setTimeout(() => setConfirmReset(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMana = (amount: number) => {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
gatherMana();
|
||||
}
|
||||
};
|
||||
|
||||
const getMaxMana = () => {
|
||||
return computeMaxMana(
|
||||
{ skills: {}, prestigeUpgrades: {}, skillUpgrades: {}, skillTiers: {} }
|
||||
);
|
||||
};
|
||||
// ─── Warning Banner ──────────────────────────────────────────────────────────
|
||||
|
||||
function WarningBanner() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Warning Banner */}
|
||||
<Card className="bg-amber-900/20 border-amber-600/50">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2 text-amber-400">
|
||||
@@ -69,8 +28,15 @@ export function GameStateDebug() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Display Options */}
|
||||
// ─── Display Options ─────────────────────────────────────────────────────────
|
||||
|
||||
function DisplayOptions() {
|
||||
const { showComponentNames, toggleComponentNames } = useDebug();
|
||||
|
||||
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">
|
||||
@@ -94,9 +60,13 @@ export function GameStateDebug() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Game Reset */}
|
||||
// ─── Game Reset Section ──────────────────────────────────────────────────────
|
||||
|
||||
function GameResetSection({ confirmReset, onReset }: { confirmReset: boolean; onReset: () => void }) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-red-400 text-sm flex items-center gap-2">
|
||||
@@ -110,7 +80,7 @@ export function GameStateDebug() {
|
||||
</p>
|
||||
<Button
|
||||
className={`w-full ${confirmReset ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'}`}
|
||||
onClick={handleReset}
|
||||
onClick={onReset}
|
||||
>
|
||||
{confirmReset ? (
|
||||
<>
|
||||
@@ -126,8 +96,21 @@ export function GameStateDebug() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Mana Debug */}
|
||||
// ─── Mana Debug Section ──────────────────────────────────────────────────────
|
||||
|
||||
function ManaDebugSection({ rawMana, onAddMana, onFillMana }: {
|
||||
rawMana: number;
|
||||
onAddMana: (amount: number) => void;
|
||||
onFillMana: () => void;
|
||||
}) {
|
||||
const maxMana = computeMaxMana(
|
||||
{ skills: {}, prestigeUpgrades: {}, skillUpgrades: {}, skillTiers: {} }
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-blue-400 text-sm flex items-center gap-2">
|
||||
@@ -137,41 +120,42 @@ export function GameStateDebug() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
Current: {rawMana} / {getMaxMana() || '?'}
|
||||
Current: {rawMana} / {maxMana || '?'}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddMana(10)}>
|
||||
<Button size="sm" variant="outline" onClick={() => onAddMana(10)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +10
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddMana(100)}>
|
||||
<Button size="sm" variant="outline" onClick={() => onAddMana(100)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +100
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddMana(1000)}>
|
||||
<Button size="sm" variant="outline" onClick={() => onAddMana(1000)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +1K
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddMana(10000)}>
|
||||
<Button size="sm" variant="outline" onClick={() => onAddMana(10000)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +10K
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-gray-700" />
|
||||
<div className="text-xs text-gray-400 mb-2">Fill to max:</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
const max = getMaxMana() || 100;
|
||||
const current = rawMana;
|
||||
for (let i = 0; i < Math.floor(max - current); i++) {
|
||||
gatherMana();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button size="sm" className="w-full bg-blue-600 hover:bg-blue-700" onClick={onFillMana}>
|
||||
Fill Mana
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Time Control */}
|
||||
// ─── Time Control Section ────────────────────────────────────────────────────
|
||||
|
||||
function TimeControlSection({ day, hour, paused, onSetDay, onTogglePause }: {
|
||||
day: number;
|
||||
hour: number;
|
||||
paused: boolean;
|
||||
onSetDay: (day: number) => void;
|
||||
onTogglePause: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
@@ -184,33 +168,31 @@ export function GameStateDebug() {
|
||||
Current: Day {day}, Hour {hour}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={() => useGameStore.setState({ day: 1, hour: 0 })}>
|
||||
Day 1
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => debugSetTime?.(10, 0)}>
|
||||
Day 10
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => debugSetTime?.(20, 0)}>
|
||||
Day 20
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => debugSetTime?.(30, 0)}>
|
||||
Day 30
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onSetDay(1)}>Day 1</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onSetDay(10)}>Day 10</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onSetDay(20)}>Day 20</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onSetDay(30)}>Day 30</Button>
|
||||
</div>
|
||||
<Separator className="bg-gray-700" />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={togglePause}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={onTogglePause}>
|
||||
{paused ? '▶ Resume' : '⏸ Pause'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Skills Debug - Quick Actions */}
|
||||
// ─── Quick Actions Section ───────────────────────────────────────────────────
|
||||
|
||||
function QuickActionsSection({ onUnlockBase, onUnlockUtility, onSkipToFloor, onResetFloorHP }: {
|
||||
onUnlockBase: () => void;
|
||||
onUnlockUtility: () => void;
|
||||
onSkipToFloor: () => void;
|
||||
onResetFloorHP: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
|
||||
@@ -220,54 +202,103 @@ export function GameStateDebug() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Unlock all base elements
|
||||
['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'].forEach(e => {
|
||||
if (!elements[e]?.unlocked) {
|
||||
unlockElement(e, 500);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={onUnlockBase}>
|
||||
Unlock All Base Elements
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Unlock utility elements
|
||||
['transference'].forEach(e => {
|
||||
if (!elements[e]?.unlocked) {
|
||||
unlockElement(e, 500);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={onUnlockUtility}>
|
||||
Unlock Utility Elements
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => debugSetFloor?.(100)}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={onSkipToFloor}>
|
||||
Skip to Floor 100
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => resetFloorHP?.()}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={onResetFloorHP}>
|
||||
Reset Floor HP
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function GameStateDebug() {
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const { showComponentNames, toggleComponentNames } = useDebug();
|
||||
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const unlockElement = useManaStore((s) => s.unlockElement);
|
||||
const gatherMana = useGameStore((s) => s.gatherMana);
|
||||
const day = useGameStore((s) => s.day);
|
||||
const hour = useGameStore((s) => s.hour);
|
||||
const paused = useUIStore((s) => s.paused);
|
||||
const togglePause = useUIStore((s) => s.togglePause);
|
||||
const resetGame = useGameStore((s) => s.resetGame);
|
||||
const debugSetFloor = useCombatStore((s) => s.debugSetFloor);
|
||||
const resetFloorHP = useCombatStore((s) => s.resetFloorHP);
|
||||
|
||||
const handleReset = () => {
|
||||
if (confirmReset) {
|
||||
resetGame();
|
||||
setConfirmReset(false);
|
||||
} else {
|
||||
setConfirmReset(true);
|
||||
setTimeout(() => setConfirmReset(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMana = (amount: number) => {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
gatherMana();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFillMana = () => {
|
||||
const maxMana = computeMaxMana(
|
||||
{ skills: {}, prestigeUpgrades: {}, skillUpgrades: {}, skillTiers: {} }
|
||||
) || 100;
|
||||
useManaStore.setState((s) => ({ rawMana: Math.max(s.rawMana, maxMana) }));
|
||||
};
|
||||
|
||||
const handleSetDay = (d: number) => {
|
||||
useGameStore.setState({ day: d, hour: 0 });
|
||||
};
|
||||
|
||||
const handleUnlockBase = () => {
|
||||
['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'].forEach(e => {
|
||||
if (!elements[e]?.unlocked) {
|
||||
unlockElement(e, 500);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnlockUtility = () => {
|
||||
['transference'].forEach(e => {
|
||||
if (!elements[e]?.unlocked) {
|
||||
unlockElement(e, 500);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<WarningBanner />
|
||||
<DisplayOptions />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<GameResetSection confirmReset={confirmReset} onReset={handleReset} />
|
||||
<ManaDebugSection rawMana={rawMana} onAddMana={handleAddMana} onFillMana={handleFillMana} />
|
||||
<TimeControlSection day={day} hour={hour} paused={paused} onSetDay={handleSetDay} onTogglePause={togglePause} />
|
||||
<QuickActionsSection
|
||||
onUnlockBase={handleUnlockBase}
|
||||
onUnlockUtility={handleUnlockUtility}
|
||||
onSkipToFloor={() => debugSetFloor?.(100)}
|
||||
onResetFloorHP={() => resetFloorHP?.()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GameStateDebug.displayName = "GameStateDebug";
|
||||
GameStateDebug.displayName = 'GameStateDebug';
|
||||
|
||||
@@ -3,51 +3,110 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bug } from 'lucide-react';
|
||||
import { usePrestigeStore, useManaStore, useUIStore, useGameStore } from '@/lib/game/stores';
|
||||
import { GUARDIANS, ELEMENTS } from '@/lib/game/constants';
|
||||
import { usePrestigeStore, useUIStore, useGameStore } from '@/lib/game/stores';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { getGuardianForFloor, getAllGuardianFloors } from '@/lib/game/data/guardian-encounters';
|
||||
|
||||
// ─── Guardian Pact Row ───────────────────────────────────────────────────────
|
||||
|
||||
function GuardianPactRow({ floor, isSigned, onForceSign, onRemove }: {
|
||||
floor: number;
|
||||
isSigned: boolean;
|
||||
onForceSign: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
if (!guardian) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-2 rounded border flex items-center justify-between ${
|
||||
isSigned ? 'border-green-600/50 bg-green-900/20' : 'border-gray-700'
|
||||
}`}
|
||||
style={{ borderColor: isSigned ? undefined : guardian.color, borderWidth: '1px' }}
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: guardian.color }}>
|
||||
{guardian.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Floor {floor} | {guardian.pact}x multiplier
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Element: {ELEMENTS[guardian.element]?.name || guardian.element}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{isSigned ? (
|
||||
<Button size="sm" variant="destructive" onClick={onRemove} className="text-xs">
|
||||
Remove
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="default" onClick={onForceSign} className="text-xs bg-amber-600 hover:bg-amber-700">
|
||||
Force Sign
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Guardian Pact List ──────────────────────────────────────────────────────
|
||||
|
||||
function GuardianPactList({ signedPacts, onForceSign, onRemove }: {
|
||||
signedPacts: number[];
|
||||
onForceSign: (floor: number) => void;
|
||||
onRemove: (floor: number) => void;
|
||||
}) {
|
||||
const guardianFloors = getAllGuardianFloors();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{guardianFloors.map((floor) => (
|
||||
<GuardianPactRow
|
||||
key={floor}
|
||||
floor={floor}
|
||||
isSigned={signedPacts.includes(floor)}
|
||||
onForceSign={() => onForceSign(floor)}
|
||||
onRemove={() => onRemove(floor)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function PactDebug() {
|
||||
// Get state from modular stores
|
||||
const signedPacts = usePrestigeStore((s) => s.signedPacts);
|
||||
const signedPactDetails = usePrestigeStore((s) => s.signedPactDetails);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
||||
|
||||
// Get actions
|
||||
const addSignedPact = usePrestigeStore((s) => s.addSignedPact);
|
||||
const removePact = usePrestigeStore((s) => s.removePact);
|
||||
const debugSetSignedPacts = usePrestigeStore((s) => s.debugSetSignedPacts);
|
||||
const debugSetPactDetails = usePrestigeStore((s) => s.debugSetPactDetails);
|
||||
const unlockElement = useManaStore((s) => s.unlockElement);
|
||||
|
||||
// Get log function from uiStore
|
||||
|
||||
const addLog = useUIStore((s) => s.addLog);
|
||||
|
||||
// Get all guardian floors
|
||||
const guardianFloors = Object.keys(GUARDIANS || {}).map(Number).sort((a, b) => a - b);
|
||||
|
||||
// Force sign a pact with a guardian (bypass costs and time)
|
||||
const forcePact = (floor: number) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
if (!guardian) return;
|
||||
|
||||
// Check if already signed
|
||||
if (signedPacts.includes(floor)) {
|
||||
addLog(`⚠️ Already signed pact with ${guardian.name}!`);
|
||||
addLog(`\u26a0\ufe0f Already signed pact with ${guardian.name}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check max pacts
|
||||
const maxPacts = 1 + (prestigeUpgrades?.pactCapacity || 0);
|
||||
if (signedPacts.length >= maxPacts) {
|
||||
addLog(`⚠️ Cannot sign more pacts! Maximum: ${maxPacts}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Force sign the pact
|
||||
addSignedPact(floor);
|
||||
|
||||
// Add pact details
|
||||
const newSignedPactDetails = {
|
||||
...signedPactDetails,
|
||||
[floor]: {
|
||||
@@ -62,21 +121,18 @@ export function PactDebug() {
|
||||
addLog(`📜 DEBUG: Pact with ${guardian.name} force-signed!`);
|
||||
};
|
||||
|
||||
// Remove a pact
|
||||
const removePactHandler = (floor: number) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
|
||||
removePact(floor);
|
||||
|
||||
// Remove pact details
|
||||
const newSignedPactDetails = { ...signedPactDetails };
|
||||
delete newSignedPactDetails[floor];
|
||||
debugSetPactDetails(newSignedPactDetails);
|
||||
|
||||
addLog(`📜 DEBUG: Removed pact with ${guardian?.name || 'Unknown'}!`);
|
||||
addLog(`\ud83d\udcdc DEBUG: Removed pact with ${guardian ? guardian.name : 'Unknown'}!`);
|
||||
};
|
||||
|
||||
// Clear all pacts
|
||||
const clearAllPacts = () => {
|
||||
addLog(`📜 DEBUG: Cleared all pacts!`);
|
||||
debugSetSignedPacts([]);
|
||||
@@ -97,71 +153,20 @@ export function PactDebug() {
|
||||
Force sign pacts with guardians (bypasses mana costs and signing time)
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{guardianFloors.map((floor) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
const isSigned = signedPacts.includes(floor);
|
||||
<GuardianPactList
|
||||
signedPacts={signedPacts}
|
||||
onForceSign={forcePact}
|
||||
onRemove={removePactHandler}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={floor}
|
||||
className={`p-2 rounded border flex items-center justify-between ${
|
||||
isSigned ? 'border-green-600/50 bg-green-900/20' : 'border-gray-700'
|
||||
}`}
|
||||
style={{ borderColor: isSigned ? undefined : guardian.color, borderWidth: '1px' }}
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: guardian.color }}>
|
||||
{guardian.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Floor {floor} | {guardian.pact}x multiplier
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Element: {ELEMENTS[guardian.element]?.name || guardian.element}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{isSigned ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removePactHandler(floor)}
|
||||
className="text-xs"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => forcePact(floor)}
|
||||
className="text-xs bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
Force Sign
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Clear All Button */}
|
||||
{signedPacts.length > 0 && (
|
||||
<div className="pt-2 border-t border-gray-700">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={clearAllPacts}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<Button size="sm" variant="destructive" onClick={clearAllPacts} className="w-full text-xs">
|
||||
Clear All Pacts ({signedPacts.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div className="text-xs text-gray-400 pt-2 border-t border-gray-700">
|
||||
Signed Pacts: {signedPacts.length} |
|
||||
Max Pacts: {1 + (prestigeUpgrades?.pactCapacity || 0)}
|
||||
@@ -172,4 +177,4 @@ export function PactDebug() {
|
||||
);
|
||||
}
|
||||
|
||||
PactDebug.displayName = "PactDebug";
|
||||
PactDebug.displayName = 'PactDebug';
|
||||
|
||||
Executable → Regular
+6
-1
@@ -1,6 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react';
|
||||
|
||||
interface DebugContextType {
|
||||
showComponentNames: boolean;
|
||||
@@ -1,5 +1,4 @@
|
||||
export { GameStateDebug } from './GameStateDebug';
|
||||
export { SkillDebug } from './SkillDebug';
|
||||
export { ElementDebug } from './ElementDebug';
|
||||
export { AttunementDebug } from './AttunementDebug';
|
||||
export { GolemDebug } from './GolemDebug';
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
// ─── Game Components Index ──────────────────────────────────────────────────────
|
||||
// Re-exports all game tab components for cleaner imports
|
||||
|
||||
// Tab components
|
||||
export { CraftingTab } from './tabs/CraftingTab';
|
||||
export { SpireTab } from './tabs/SpireTab';
|
||||
// Tab components (consolidated in tabs/ subfolder)
|
||||
export { SpellsTab } from './tabs/SpellsTab';
|
||||
export { SkillsTab } from './SkillsTab';
|
||||
export { StatsTab } from './tabs/StatsTab';
|
||||
|
||||
// UI components
|
||||
export { ActionButtons } from './ActionButtons';
|
||||
export { CalendarDisplay } from './CalendarDisplay';
|
||||
export { CraftingProgress } from './CraftingProgress';
|
||||
export { StudyProgress } from './StudyProgress';
|
||||
export { ManaDisplay } from './ManaDisplay';
|
||||
export { TimeDisplay } from './TimeDisplay';
|
||||
export { UpgradeDialog } from './UpgradeDialog';
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { formatHour } from '@/lib/game/formatting';
|
||||
import { TimeDisplay } from '@/components/game/TimeDisplay';
|
||||
|
||||
interface HeaderProps {
|
||||
day: number;
|
||||
hour: number;
|
||||
insight: number;
|
||||
}
|
||||
|
||||
export function Header({ day, hour, insight }: HeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-[var(--bg-surface)]/95 backdrop-blur-sm border-b border-[var(--border-subtle)] px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Game Title - always visible */}
|
||||
<h1 className="text-xl font-bold game-title tracking-wider">MANA LOOP</h1>
|
||||
|
||||
{/* Desktop header content */}
|
||||
<div className="hidden md:flex items-center gap-4">
|
||||
<TimeDisplay
|
||||
day={day}
|
||||
hour={hour}
|
||||
insight={insight}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile header content - compact */}
|
||||
<div className="flex md:hidden items-center gap-2">
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-bold game-mono text-[var(--mana-light)]">
|
||||
D{day} {formatHour(hour)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{fmt(insight)} 💎
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
Header.displayName = "Header";
|
||||
@@ -1,142 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Mountain,
|
||||
Sparkles,
|
||||
Brain,
|
||||
Wand2,
|
||||
Bone,
|
||||
Shield,
|
||||
Hammer,
|
||||
Gem,
|
||||
Trophy,
|
||||
FlaskConical,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Wrench
|
||||
} from 'lucide-react';
|
||||
|
||||
interface TabBarProps {
|
||||
activeTab: string;
|
||||
onTabChange: (value: string) => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
// Tab configuration with groups
|
||||
const TAB_GROUPS = [
|
||||
{
|
||||
name: 'World',
|
||||
tabs: [
|
||||
{ value: 'spire', label: 'Spire', icon: Mountain, mobileLabel: 'Spire' },
|
||||
{ value: 'attunements', label: 'Attune', icon: Sparkles, mobileLabel: 'Attune' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Power',
|
||||
tabs: [
|
||||
{ value: 'skills', label: 'Skills', icon: Brain, mobileLabel: 'Skills' },
|
||||
{ value: 'spells', label: 'Spells', icon: Wand2, mobileLabel: 'Spells' },
|
||||
{ value: 'golemancy', label: 'Golems', icon: Bone, mobileLabel: 'Golems' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Gear',
|
||||
tabs: [
|
||||
{ value: 'equipment', label: 'Gear', icon: Shield, mobileLabel: 'Gear' },
|
||||
{ value: 'crafting', label: 'Craft', icon: Hammer, mobileLabel: 'Craft' },
|
||||
{ value: 'loot', label: 'Loot', icon: Gem, mobileLabel: 'Loot' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Meta',
|
||||
tabs: [
|
||||
{ value: 'achievements', label: 'Achieve', icon: Trophy, mobileLabel: 'Achieve' },
|
||||
{ value: 'stats', label: 'Stats', icon: BarChart3, mobileLabel: 'Stats' },
|
||||
{ value: 'debug', label: 'Debug', icon: Wrench, mobileLabel: 'Debug' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function TabBar({ activeTab, onTabChange, isMobile = false }: TabBarProps) {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex overflow-x-auto scrollbar-thin gap-1 pb-2" style={{ flexWrap: 'nowrap' }}>
|
||||
{TAB_GROUPS.map((group, groupIndex) => (
|
||||
<div key={group.name} className="flex items-center flex-shrink-0">
|
||||
{groupIndex > 0 && (
|
||||
<Separator orientation="vertical" className="h-6 mx-1 bg-[var(--border-subtle)]" />
|
||||
)}
|
||||
{group.tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.value;
|
||||
return (
|
||||
<Tooltip key={tab.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onTabChange(tab.value)}
|
||||
className={`
|
||||
flex items-center justify-center p-2 flex-shrink-0 transition-all border text-[var(--font-display)]
|
||||
${isActive
|
||||
? 'border-[var(--border-accent)] bg-[var(--bg-raised)] text-[var(--interactive-primary)]'
|
||||
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-panel)] hover:border-[var(--border-subtle)]'
|
||||
}
|
||||
`}
|
||||
aria-label={tab.label}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{tab.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop view - grouped tabs with separators
|
||||
return (
|
||||
<div className="flex items-center gap-1 w-full" style={{ flexWrap: 'nowrap' }}>
|
||||
{TAB_GROUPS.map((group, groupIndex) => (
|
||||
<div key={group.name} className="flex items-center flex-shrink-0">
|
||||
{groupIndex > 0 && (
|
||||
<Separator orientation="vertical" className="h-6 mx-2 bg-[var(--border-subtle)]" />
|
||||
)}
|
||||
{group.tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.value;
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={`
|
||||
text-xs px-3 py-1.5 relative transition-all whitespace-nowrap text-[var(--font-display)] tracking-wider
|
||||
${isActive
|
||||
? 'text-[var(--interactive-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}
|
||||
`}
|
||||
style={isActive ? {
|
||||
borderBottom: '2px solid var(--border-accent)',
|
||||
} : {}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TabBar.displayName = "TabBar";
|
||||
@@ -1,208 +0,0 @@
|
||||
'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 } 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>
|
||||
);
|
||||
}
|
||||
|
||||
MemorySlotPicker.displayName = "MemorySlotPicker";
|
||||
@@ -1,62 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
StudyProgress.displayName = "StudyProgress";
|
||||
@@ -1,128 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeDialog.displayName = "UpgradeDialog";
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { fmtDec } from '@/lib/game/stores';
|
||||
import { GUARDIANS } from '@/lib/game/constants';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Swords } from 'lucide-react';
|
||||
|
||||
// Modular stores
|
||||
import { useSkillStore, usePrestigeStore } from '@/lib/game/stores';
|
||||
|
||||
export function CombatStatsSection() {
|
||||
// Get state from modular stores
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const signedPacts = usePrestigeStore((s) => s.signedPacts);
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-red-400 text-sm 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">Combat Training Bonus:</span>
|
||||
<span className="text-red-300">+{(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 + (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 + (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 + (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">{(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">{(skills.spellEcho || 0) * 10}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-300">×{fmtDec(signedPacts.reduce((m, f) => m * (GUARDIANS[f]?.pact || 1), 1), 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
CombatStatsSection.displayName = "CombatStatsSection";
|
||||
@@ -1,268 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
|
||||
import { fmt, fmtDec } from '@/lib/game/stores';
|
||||
import type { UnifiedEffects } from '@/lib/game/effects';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Droplet } from 'lucide-react';
|
||||
|
||||
// Modular stores
|
||||
import { useSkillStore, usePrestigeStore, useManaStore } from '@/lib/game/stores';
|
||||
|
||||
export interface ManaStatsSectionProps {
|
||||
upgradeEffects: UnifiedEffects;
|
||||
maxMana: number;
|
||||
baseRegen: number;
|
||||
clickMana: number;
|
||||
meditationMultiplier: number;
|
||||
effectiveRegen: number;
|
||||
incursionStrength: number;
|
||||
manaCascadeBonus: number;
|
||||
manaWaterfallBonus: number;
|
||||
hasManaWaterfall: boolean;
|
||||
hasFlowSurge: boolean;
|
||||
hasManaOverflow: boolean;
|
||||
hasEternalFlow: boolean;
|
||||
}
|
||||
|
||||
export function ManaStatsSection({
|
||||
upgradeEffects,
|
||||
maxMana,
|
||||
baseRegen,
|
||||
clickMana,
|
||||
meditationMultiplier,
|
||||
effectiveRegen,
|
||||
incursionStrength,
|
||||
manaCascadeBonus,
|
||||
manaWaterfallBonus,
|
||||
hasManaWaterfall,
|
||||
hasFlowSurge,
|
||||
hasManaOverflow,
|
||||
hasEternalFlow,
|
||||
}: ManaStatsSectionProps) {
|
||||
// Get state from modular stores
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const skillTiers = useSkillStore((s) => s.skillTiers);
|
||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
|
||||
return (
|
||||
<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 = skillTiers?.manaWell || 1;
|
||||
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
|
||||
const level = skills[tieredSkillId] || 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((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 = skillTiers?.manaFlow || 1;
|
||||
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
|
||||
const level = skills[tieredSkillId] || 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">+{(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((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 + (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" />
|
||||
{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">+{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">+{(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 + (skills.manaOverflow || 0) * 0.25, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
|
||||
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-300">Effective Regen:</span>
|
||||
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
|
||||
</div>
|
||||
{incursionStrength > 0 && !hasSpecial(upgradeEffects, SPECIAL_EFFECTS.STEADY_STREAM) && (
|
||||
<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>
|
||||
)}
|
||||
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.STEADY_STREAM) && 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>
|
||||
)}
|
||||
{manaWaterfallBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Waterfall Bonus:</span>
|
||||
<span className="text-cyan-400">+{fmtDec(manaWaterfallBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{hasManaWaterfall && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Waterfall:</span>
|
||||
<span className="text-cyan-400">+0.25 regen per 100 max mana</span>
|
||||
</div>
|
||||
)}
|
||||
{hasFlowSurge && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Flow Surge:</span>
|
||||
<span className="text-cyan-400">Clicks activate +100% regen for 1hr</span>
|
||||
</div>
|
||||
)}
|
||||
{hasManaOverflow && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Overflow:</span>
|
||||
<span className="text-cyan-400">Raw mana can exceed max by 20%</span>
|
||||
</div>
|
||||
)}
|
||||
{hasEternalFlow && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-400">Eternal Flow:</span>
|
||||
<span className="text-green-400">Regen immune to ALL penalties</span>
|
||||
</div>
|
||||
)}
|
||||
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_TORRENT) && 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>
|
||||
)}
|
||||
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.DESPERATE_WELLS) && 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>
|
||||
);
|
||||
}
|
||||
|
||||
ManaStatsSection.displayName = "ManaStatsSection";
|
||||
@@ -1,239 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { fmt, fmtDec } from '@/lib/game/stores';
|
||||
import { ATTUNEMENTS_DEF, getAttunementConversionRate } from '@/lib/game/data/attunements';
|
||||
import { computeMaxMana, computeElementMax } from '@/lib/game/stores';
|
||||
import { computeEffectiveRegenForDisplay } from '@/lib/game/store-modules/computed-stats';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Droplet } from 'lucide-react';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
// Modular stores
|
||||
import { useManaStore, useSkillStore, usePrestigeStore } from '@/lib/game/stores';
|
||||
|
||||
export function ManaTypeBreakdown() {
|
||||
// Get state from modular stores
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const skillTiers = useSkillStore((s) => s.skillTiers);
|
||||
const skillUpgrades = useSkillStore((s) => s.skillUpgrades);
|
||||
|
||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
// attunements is not in modular stores - using empty object as fallback
|
||||
const attunements: Record<string, { active: boolean; level: number; experience: number }> = {};
|
||||
|
||||
// Compute unified effects for regen calculations
|
||||
const effects = getUnifiedEffects({
|
||||
skillUpgrades,
|
||||
skillTiers,
|
||||
equippedInstances: {},
|
||||
equipmentInstances: {}
|
||||
});
|
||||
|
||||
// Get effective regen info for raw mana
|
||||
const regenInfo = computeEffectiveRegenForDisplay({
|
||||
skills,
|
||||
prestigeUpgrades,
|
||||
skillUpgrades,
|
||||
skillTiers,
|
||||
elements,
|
||||
rawMana,
|
||||
attunements
|
||||
} as any, effects);
|
||||
|
||||
// Compute max mana
|
||||
const maxMana = computeMaxMana({
|
||||
skills,
|
||||
prestigeUpgrades,
|
||||
skillUpgrades,
|
||||
skillTiers
|
||||
}, effects);
|
||||
|
||||
// Get unlocked elements sorted by category then name
|
||||
const unlockedElements = Object.entries(elements)
|
||||
.filter(([, state]) => state.unlocked)
|
||||
.map(([id, state]) => {
|
||||
const def = ELEMENTS[id];
|
||||
if (!def) return null;
|
||||
const elemMax = computeElementMax({
|
||||
skills,
|
||||
prestigeUpgrades,
|
||||
skillUpgrades,
|
||||
skillTiers
|
||||
} as any, effects, id);
|
||||
return {
|
||||
id,
|
||||
name: def.name,
|
||||
sym: def.sym,
|
||||
color: def.color,
|
||||
current: state.current,
|
||||
max: elemMax,
|
||||
cat: def.cat,
|
||||
recipe: def.recipe,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
if (!a || !b) return 0;
|
||||
if (a.cat !== b.cat) return a.cat.localeCompare(b.cat);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Droplet className="w-4 h-4" />
|
||||
Mana Type Breakdown
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Raw Mana Section */}
|
||||
<div className="p-3 bg-gray-800/50 rounded border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🌀</span>
|
||||
<span className="font-semibold text-purple-300">Raw Mana</span>
|
||||
<span className="text-xs text-gray-500">(base)</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-300">{fmt(rawMana)}</span>
|
||||
<span className="text-gray-500"> / </span>
|
||||
<span className="text-gray-400">{fmt(maxMana)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-gray-700 rounded-full h-2 mb-3">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all duration-300 bg-purple-500"
|
||||
style={{ width: `${Math.min(100, (rawMana / maxMana) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Regen info */}
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Base Regen:</span>
|
||||
<span className="text-green-400">{fmtDec(regenInfo.rawRegen, 2)}/hr</span>
|
||||
</div>
|
||||
{regenInfo.conversionDrain > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Conversion Drain:</span>
|
||||
<span className="text-red-400">-{fmtDec(regenInfo.conversionDrain, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator className="bg-gray-700 my-1" />
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span className="text-gray-300">Effective Regen:</span>
|
||||
<span className="text-green-400">{fmtDec(regenInfo.effectiveRegen, 2)}/hr</span>
|
||||
</div>
|
||||
|
||||
{/* Show conversion drains by attunement */}
|
||||
{attunements && Object.keys(attunements).length > 0 && (
|
||||
<>
|
||||
<Separator className="bg-gray-700 my-1" />
|
||||
<div className="text-gray-400 mb-1">Conversion Drains:</div>
|
||||
{Object.entries(attunements).map(([attId, attState]) => {
|
||||
const attDef = ATTUNEMENTS_DEF[attId];
|
||||
if (!attDef || attState.level === 0) return null;
|
||||
const rate = getAttunementConversionRate(attId, attState.level);
|
||||
if (rate <= 0) return null;
|
||||
return (
|
||||
<div key={attId} className="flex justify-between pl-2">
|
||||
<span className="text-gray-500">{attDef.name}:</span>
|
||||
<span className="text-red-400">-{fmtDec(rate, 2)}/hr</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
{/* Elemental Mana Sections */}
|
||||
{unlockedElements.map((elem) => {
|
||||
if (!elem) return null;
|
||||
|
||||
// Find attunements that convert TO this element
|
||||
const convertingAttunements = Object.entries(attunements || {})
|
||||
.filter(([attId, attState]) => {
|
||||
if (!attState.active) return false;
|
||||
const attDef = ATTUNEMENTS_DEF[attId];
|
||||
return attDef?.primaryManaType === elem.id && attDef.conversionRate > 0;
|
||||
});
|
||||
|
||||
// Calculate total conversion rate TO this element
|
||||
const totalConversionRate = convertingAttunements.reduce((total, [attId, attState]) => {
|
||||
return total + getAttunementConversionRate(attId, attState.level || 1);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div key={elem.id} className="p-3 bg-gray-800/50 rounded border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{elem.sym}</span>
|
||||
<span className="font-semibold" style={{ color: elem.color }}>{elem.name}</span>
|
||||
<span className="text-xs text-gray-500">({elem.cat})</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-300">{fmt(elem.current)}</span>
|
||||
<span className="text-gray-500"> / </span>
|
||||
<span className="text-gray-400">{fmt(elem.max)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-gray-700 rounded-full h-2 mb-3">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, (elem.current / elem.max) * 100)}%`,
|
||||
backgroundColor: elem.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conversion info */}
|
||||
{totalConversionRate > 0 ? (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Conversion Rate:</span>
|
||||
<span className="text-green-400">+{fmtDec(totalConversionRate, 2)}/hr</span>
|
||||
</div>
|
||||
{convertingAttunements.length > 0 && (
|
||||
<div className="text-gray-500 pl-2">
|
||||
Source: {convertingAttunements.map(([attId]) => {
|
||||
const attDef = ATTUNEMENTS_DEF[attId];
|
||||
const level = attunements[attId]?.level || 1;
|
||||
return `${attDef?.name} (Lv.${level})`;
|
||||
}).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">No active conversion to this element</div>
|
||||
)}
|
||||
|
||||
{/* Show recipe for composite/exotic elements */}
|
||||
{elem.recipe && (
|
||||
<div className="text-xs text-gray-500 mt-2 pt-2 border-t border-gray-700">
|
||||
Recipe: {elem.recipe.map(r => `${ELEMENTS[r]?.sym} ${ELEMENTS[r]?.name || r}`).join(' + ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
ManaTypeBreakdown.displayName = "ManaTypeBreakdown";
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { fmtDec } from '@/lib/game/stores';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
|
||||
// Modular stores
|
||||
import { useSkillStore, usePrestigeStore } from '@/lib/game/stores';
|
||||
|
||||
export interface StudyStatsSectionProps {
|
||||
studySpeedMult: number;
|
||||
studyCostMult: number;
|
||||
}
|
||||
|
||||
export function StudyStatsSection({ studySpeedMult, studyCostMult }: StudyStatsSectionProps) {
|
||||
// Get state from modular stores
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
||||
|
||||
return (
|
||||
<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">+{((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">-{((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 + (skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
StudyStatsSection.displayName = "StudyStatsSection";
|
||||
@@ -1,86 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { SKILL_EVOLUTION_PATHS } from '@/lib/game/skill-evolution';
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
// Modular stores
|
||||
import { useSkillStore } from '@/lib/game/stores';
|
||||
|
||||
export function UpgradeEffectsSection() {
|
||||
// Get state from modular stores
|
||||
const skillUpgrades = useSkillStore((s) => s.skillUpgrades);
|
||||
|
||||
// Helper function to get all selected skill upgrades
|
||||
function getAllSelectedUpgrades() {
|
||||
const upgrades = [];
|
||||
for (const [skillId, selectedIds] of Object.entries(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 (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm 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 && 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 && upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect && upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect active'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeEffectsSection.displayName = "UpgradeEffectsSection";
|
||||
@@ -1,11 +0,0 @@
|
||||
export { ManaStatsSection } from './ManaStatsSection';
|
||||
export type { ManaStatsSectionProps } from './ManaStatsSection';
|
||||
|
||||
export { CombatStatsSection } from './CombatStatsSection';
|
||||
export type { CombatStatsSectionProps } from './CombatStatsSection';
|
||||
|
||||
export { StudyStatsSection } from './StudyStatsSection';
|
||||
export type { StudyStatsSectionProps } from './StudyStatsSection';
|
||||
|
||||
export { UpgradeEffectsSection } from './UpgradeEffectsSection';
|
||||
export type { UpgradeEffectsSectionProps } from './UpgradeEffectsSection';
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useCombatStore } from '@/lib/game/stores';
|
||||
import {
|
||||
ACHIEVEMENTS,
|
||||
ACHIEVEMENT_CATEGORY_COLORS,
|
||||
getAchievementsByCategory,
|
||||
isAchievementRevealed,
|
||||
} from '@/lib/game/data/achievements';
|
||||
import type { AchievementDef } from '@/lib/game/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
combat: '⚔️ Combat',
|
||||
progression: '📈 Progression',
|
||||
crafting: '🔨 Crafting',
|
||||
magic: '✨ Magic',
|
||||
special: '🌟 Special',
|
||||
};
|
||||
|
||||
function getProgressForAchievement(
|
||||
achievement: AchievementDef,
|
||||
progress: Record<string, number>,
|
||||
): number {
|
||||
return progress[achievement.id] ?? 0;
|
||||
}
|
||||
|
||||
function getProgressPercent(achievement: AchievementDef, current: number): number {
|
||||
if (achievement.requirement.value <= 0) return 100;
|
||||
return Math.min(100, Math.round((current / achievement.requirement.value) * 100));
|
||||
}
|
||||
|
||||
function formatReward(reward: AchievementDef['reward']): string {
|
||||
const parts: string[] = [];
|
||||
if (reward.insight) parts.push(`${reward.insight} insight`);
|
||||
if (reward.manaBonus) parts.push(`+${reward.manaBonus} mana`);
|
||||
if (reward.damageBonus) parts.push(`+${(reward.damageBonus * 100).toFixed(0)}% dmg`);
|
||||
if (reward.regenBonus) parts.push(`+${reward.regenBonus} regen`);
|
||||
if (reward.title) parts.push(`title: "${reward.title}"`);
|
||||
if (reward.unlockEffect) parts.push(`unlock: ${reward.unlockEffect}`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
// ─── Category Section ────────────────────────────────────────────────────────
|
||||
|
||||
interface CategorySectionProps {
|
||||
category: string;
|
||||
achievements: AchievementDef[];
|
||||
unlocked: string[];
|
||||
progress: Record<string, number>;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
category,
|
||||
achievements,
|
||||
unlocked,
|
||||
progress,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: CategorySectionProps) {
|
||||
const color = ACHIEVEMENT_CATEGORY_COLORS[category] ?? '#9CA3AF';
|
||||
const label = CATEGORY_LABELS[category] ?? category;
|
||||
const unlockedCount = achievements.filter((a) => unlocked.includes(a.id)).length;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/60 border-gray-700">
|
||||
<SectionHeader
|
||||
title={`${label} (${unlockedCount}/${achievements.length})`}
|
||||
action={
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{collapsed ? 'Expand ▼' : 'Collapse ▲'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<CardContent className="space-y-3">
|
||||
{achievements.map((achievement) => {
|
||||
const isUnlocked = unlocked.includes(achievement.id);
|
||||
const currentProgress = getProgressForAchievement(achievement, progress);
|
||||
const percent = getProgressPercent(achievement, currentProgress);
|
||||
const revealed = isAchievementRevealed(achievement, currentProgress);
|
||||
|
||||
if (!revealed) {
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className="p-3 rounded border border-gray-700/50 bg-gray-800/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500">???</span>
|
||||
<span className="text-xs text-gray-600 italic">
|
||||
Hidden achievement — keep progressing to reveal
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Progress value={percent} className="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`p-3 rounded border ${
|
||||
isUnlocked
|
||||
? 'border-green-700/50 bg-green-900/20'
|
||||
: 'border-gray-700/50 bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm" style={{ color }}>
|
||||
{achievement.name}
|
||||
</span>
|
||||
{isUnlocked && (
|
||||
<Badge className="bg-green-900/50 text-green-300 text-xs">
|
||||
✓ Unlocked
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{fmt(currentProgress)} / {fmt(achievement.requirement.value)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mb-2">{achievement.desc}</p>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Progress value={percent} className="h-1.5 flex-1" />
|
||||
<span className="text-xs text-gray-500 w-10 text-right">
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="text-gray-400">Reward:</span>{' '}
|
||||
{formatReward(achievement.reward)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function AchievementsTab() {
|
||||
const achievements = useCombatStore((s) => s.achievements);
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
|
||||
|
||||
const byCategory = useMemo(() => getAchievementsByCategory(), []);
|
||||
const categories = useMemo(
|
||||
() => Object.keys(byCategory).sort(),
|
||||
[byCategory],
|
||||
);
|
||||
|
||||
const totalAchievements = Object.keys(ACHIEVEMENTS).length;
|
||||
const unlockedCount = achievements.unlocked.length;
|
||||
|
||||
const toggleCollapse = (category: string) => {
|
||||
setCollapsedCategories((prev) => ({
|
||||
...prev,
|
||||
[category]: !prev[category],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<DebugName name="AchievementsTab">
|
||||
<div className="space-y-4">
|
||||
{/* Summary header */}
|
||||
<Card className="bg-gray-900/60 border-gray-700">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-100">
|
||||
Achievements
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Track your progress and unlock rewards
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-amber-400">
|
||||
{unlockedCount}
|
||||
<span className="text-sm text-gray-500 font-normal">
|
||||
/{totalAchievements}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.round((unlockedCount / totalAchievements) * 100)}
|
||||
className="h-2 w-32 mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category sections */}
|
||||
<ScrollArea className="h-[600px] pr-2">
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => (
|
||||
<CategorySection
|
||||
key={category}
|
||||
category={category}
|
||||
achievements={byCategory[category]}
|
||||
unlocked={achievements.unlocked}
|
||||
progress={achievements.progress}
|
||||
collapsed={collapsedCategories[category] ?? false}
|
||||
onToggleCollapse={() => toggleCollapse(category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
AchievementsTab.displayName = 'AchievementsTab';
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ActivityLogEntry } from '@/lib/game/types';
|
||||
|
||||
interface ActivityLogProps {
|
||||
activityLog: ActivityLogEntry[];
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
export function ActivityLog({ activityLog, maxEntries = 20 }: ActivityLogProps) {
|
||||
const entries = activityLog.slice(0, maxEntries);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-gray-500 italic p-2">
|
||||
No activity yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="text-xs text-gray-300 border-b border-gray-700 pb-1 last:border-0"
|
||||
>
|
||||
<span className="text-gray-500 mr-1">
|
||||
[{entry.eventType}]
|
||||
</span>
|
||||
{entry.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// ─── Test: AttunementsTab barrel export ───────────────────────────────────────
|
||||
|
||||
describe('AttunementsTab module structure', () => {
|
||||
it('exports AttunementsTab from barrel index', async () => {
|
||||
const mod = await import('./AttunementsTab');
|
||||
expect(mod.AttunementsTab).toBeDefined();
|
||||
expect(typeof mod.AttunementsTab).toBe('function');
|
||||
});
|
||||
|
||||
it('AttunementsTab has correct displayName', async () => {
|
||||
const { AttunementsTab } = await import('./AttunementsTab');
|
||||
expect(AttunementsTab.displayName).toBe('AttunementsTab');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Barrel export includes AttunementsTab ──────────────────────────────
|
||||
|
||||
describe('Tab barrel export', () => {
|
||||
it('includes AttunementsTab in the tabs index', async () => {
|
||||
const mod = await import('@/components/game/tabs');
|
||||
expect(mod.AttunementsTab).toBeDefined();
|
||||
expect(typeof mod.AttunementsTab).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Attunement data integrity ──────────────────────────────────────────
|
||||
|
||||
describe('Attunement data', () => {
|
||||
it('all attunements have required fields', async () => {
|
||||
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
||||
for (const [id, def] of Object.entries(ATTUNEMENTS_DEF)) {
|
||||
expect(def.id).toBe(id);
|
||||
expect(def.name).toBeTruthy();
|
||||
expect(def.desc).toBeTruthy();
|
||||
expect(def.slot).toBeTruthy();
|
||||
expect(def.icon).toBeTruthy();
|
||||
expect(def.color).toBeTruthy();
|
||||
expect(def.rawManaRegen).toBeGreaterThanOrEqual(0);
|
||||
expect(def.conversionRate).toBeGreaterThanOrEqual(0);
|
||||
expect(def.capabilities.length).toBeGreaterThan(0);
|
||||
expect(def.skillCategories.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('enchanter is unlocked by default', async () => {
|
||||
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
||||
expect(ATTUNEMENTS_DEF.enchanter.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('invoker and fabricator are locked by default', async () => {
|
||||
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
||||
expect(ATTUNEMENTS_DEF.invoker.unlocked).toBe(false);
|
||||
expect(ATTUNEMENTS_DEF.fabricator.unlocked).toBe(false);
|
||||
});
|
||||
|
||||
it('each attunement has a unique slot', async () => {
|
||||
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
||||
const slots = Object.values(ATTUNEMENTS_DEF).map((d) => d.slot);
|
||||
const uniqueSlots = new Set(slots);
|
||||
expect(uniqueSlots.size).toBe(slots.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: XP curve ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Attunement XP curve', () => {
|
||||
it('level 1 requires 0 XP', async () => {
|
||||
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
|
||||
expect(getAttunementXPForLevel(1)).toBe(0);
|
||||
});
|
||||
|
||||
it('level 2 requires 1000 XP', async () => {
|
||||
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
|
||||
expect(getAttunementXPForLevel(2)).toBe(1000);
|
||||
});
|
||||
|
||||
it('XP requirements increase with level', async () => {
|
||||
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
|
||||
const xp2 = getAttunementXPForLevel(2);
|
||||
const xp3 = getAttunementXPForLevel(3);
|
||||
const xp4 = getAttunementXPForLevel(4);
|
||||
expect(xp3).toBeGreaterThan(xp2);
|
||||
expect(xp4).toBeGreaterThan(xp3);
|
||||
});
|
||||
|
||||
it('MAX_ATTUNEMENT_LEVEL is 10', async () => {
|
||||
const { MAX_ATTUNEMENT_LEVEL } = await import('@/lib/game/data/attunements');
|
||||
expect(MAX_ATTUNEMENT_LEVEL).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Attunement store interactions ──────────────────────────────────────
|
||||
|
||||
describe('Attunement store interactions', () => {
|
||||
it('addAttunementXP is callable', async () => {
|
||||
const mockAddXP = await vi.fn();
|
||||
mockAddXP('enchanter', 100);
|
||||
expect(mockAddXP).toHaveBeenCalledWith('enchanter', 100);
|
||||
});
|
||||
|
||||
it('debugUnlockAttunement is callable', async () => {
|
||||
const mockUnlock = await vi.fn();
|
||||
mockUnlock('invoker');
|
||||
expect(mockUnlock).toHaveBeenCalledWith('invoker');
|
||||
});
|
||||
|
||||
it('setAttunements is callable', async () => {
|
||||
const mockSet = await vi.fn();
|
||||
mockSet({ enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 } });
|
||||
expect(mockSet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resetAttunements is callable', async () => {
|
||||
const mockReset = await vi.fn();
|
||||
mockReset();
|
||||
expect(mockReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Slot name mapping ──────────────────────────────────────────────────
|
||||
|
||||
describe('Attunement slot names', () => {
|
||||
it('all slots used by attunements have display names', async () => {
|
||||
const { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES } = await import('@/lib/game/data/attunements');
|
||||
for (const def of Object.values(ATTUNEMENTS_DEF)) {
|
||||
expect(ATTUNEMENT_SLOT_NAMES[def.slot]).toBeDefined();
|
||||
expect(ATTUNEMENT_SLOT_NAMES[def.slot].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: File size limit ────────────────────────────────────────────────────
|
||||
|
||||
describe('File size limits (400 lines max)', () => {
|
||||
it('AttunementsTab.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'AttunementsTab.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useAttunementStore } from '@/lib/game/stores';
|
||||
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements';
|
||||
import type { AttunementDef, AttunementState } from '@/lib/game/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getXpForNextLevel(level: number): number {
|
||||
if (level >= MAX_ATTUNEMENT_LEVEL) return 0;
|
||||
return getAttunementXPForLevel(level + 1);
|
||||
}
|
||||
|
||||
function getXpProgress(state: AttunementState): number {
|
||||
const nextXp = getXpForNextLevel(state.level);
|
||||
if (nextXp <= 0) return 100;
|
||||
return Math.min(100, Math.round((state.experience / nextXp) * 100));
|
||||
}
|
||||
|
||||
function isAttunementUnlocked(id: string, attunements: Record<string, AttunementState>): boolean {
|
||||
return id in attunements;
|
||||
}
|
||||
|
||||
// ─── Attunement Card ─────────────────────────────────────────────────────────
|
||||
|
||||
interface AttunementCardProps {
|
||||
def: AttunementDef;
|
||||
state?: AttunementState;
|
||||
}
|
||||
|
||||
function AttunementCard({ def, state }: AttunementCardProps) {
|
||||
const unlocked = !!state;
|
||||
const xpProgress = state ? getXpProgress(state) : 0;
|
||||
const nextXp = state ? getXpForNextLevel(state.level) : 0;
|
||||
|
||||
return (
|
||||
<Card className={`bg-gray-900/60 ${unlocked ? 'border-gray-700' : 'border-gray-800 opacity-60'}`}>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{def.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-100">{def.name}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{ATTUNEMENT_SLOT_NAMES[def.slot] ?? def.slot}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{unlocked ? (
|
||||
<Badge className="bg-teal-900/50 text-teal-300 text-xs">
|
||||
Lv.{state.level}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-gray-700 text-gray-500 text-xs">
|
||||
Locked
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-gray-400 leading-relaxed">{def.desc}</p>
|
||||
|
||||
{/* XP Progress (unlocked only) */}
|
||||
{unlocked && state && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">XP Progress</span>
|
||||
<span className="text-gray-400 font-mono">
|
||||
{fmt(state.experience)} / {fmt(nextXp)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={xpProgress} className="h-2" />
|
||||
{state.level >= MAX_ATTUNEMENT_LEVEL && (
|
||||
<p className="text-xs text-amber-400 italic">Maximum level reached</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unlock condition (locked only) */}
|
||||
{!unlocked && def.unlockCondition && (
|
||||
<div className="text-xs text-gray-500 italic border-t border-gray-800 pt-2">
|
||||
🔒 {def.unlockCondition}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs border-t border-gray-800 pt-3">
|
||||
<div>
|
||||
<span className="text-gray-500">Mana Type</span>
|
||||
<p className="text-gray-300 capitalize">
|
||||
{def.primaryManaType ?? 'None (pact-based)'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Raw Regen</span>
|
||||
<p className="text-gray-300">+{def.rawManaRegen}/hr</p>
|
||||
</div>
|
||||
{def.conversionRate > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-500">Conversion</span>
|
||||
<p className="text-gray-300">{def.conversionRate}/hr</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500">Status</span>
|
||||
<p className={state?.active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{state?.active ? 'Active' : unlocked ? 'Inactive' : 'Locked'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div className="border-t border-gray-800 pt-3">
|
||||
<span className="text-xs text-gray-500 block mb-1.5">Capabilities</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{def.capabilities.map((cap) => (
|
||||
<Badge
|
||||
key={cap}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-400 text-[10px]"
|
||||
>
|
||||
{cap}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill Categories */}
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 block mb-1.5">Skill Categories</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{def.skillCategories.map((cat) => (
|
||||
<Badge
|
||||
key={cat}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-400 text-[10px]"
|
||||
>
|
||||
{cat}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function AttunementsTab() {
|
||||
const attunements = useAttunementStore((s) => s.attunements);
|
||||
|
||||
const allDefs = Object.values(ATTUNEMENTS_DEF);
|
||||
const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length;
|
||||
|
||||
return (
|
||||
<DebugName name="AttunementsTab">
|
||||
<div className="space-y-4">
|
||||
{/* Summary header */}
|
||||
<Card className="bg-gray-900/60 border-gray-700">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-100">Attunements</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Class-like abilities tied to body locations
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-teal-400">
|
||||
{unlockedCount}
|
||||
<span className="text-sm text-gray-500 font-normal">
|
||||
/{allDefs.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Unlocked</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Attunement cards */}
|
||||
<ScrollArea className="h-[600px] pr-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{allDefs.map((def) => (
|
||||
<AttunementCard
|
||||
key={def.id}
|
||||
def={def}
|
||||
state={attunements[def.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
AttunementsTab.displayName = 'AttunementsTab';
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ─── Test: CraftingTab barrel export ──────────────────────────────────────────
|
||||
|
||||
describe('CraftingTab module structure', () => {
|
||||
it('exports CraftingTab from its module', async () => {
|
||||
const mod = await import('./CraftingTab');
|
||||
expect(mod.CraftingTab).toBeDefined();
|
||||
expect(typeof mod.CraftingTab).toBe('function');
|
||||
});
|
||||
|
||||
it('CraftingTab has correct displayName', async () => {
|
||||
const { CraftingTab } = await import('./CraftingTab');
|
||||
expect(CraftingTab.displayName).toBe('CraftingTab');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: CraftingTab in tabs barrel index ────────────────────────────────────
|
||||
|
||||
describe('Tab barrel export', () => {
|
||||
it('includes CraftingTab in the tabs index', async () => {
|
||||
const mod = await import('@/components/game/tabs');
|
||||
expect(mod.CraftingTab).toBeDefined();
|
||||
expect(typeof mod.CraftingTab).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: FabricatorSubTab ────────────────────────────────────────────────────
|
||||
|
||||
describe('FabricatorSubTab module', () => {
|
||||
it('exports FabricatorSubTab', async () => {
|
||||
const mod = await import('./CraftingTab/FabricatorSubTab');
|
||||
expect(mod.FabricatorSubTab).toBeDefined();
|
||||
expect(typeof mod.FabricatorSubTab).toBe('function');
|
||||
});
|
||||
|
||||
it('FabricatorSubTab has correct displayName', async () => {
|
||||
const { FabricatorSubTab } = await import('./CraftingTab/FabricatorSubTab');
|
||||
expect(FabricatorSubTab.displayName).toBe('FabricatorSubTab');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: EnchanterSubTab ─────────────────────────────────────────────────────
|
||||
|
||||
describe('EnchanterSubTab module', () => {
|
||||
it('exports EnchanterSubTab', async () => {
|
||||
const mod = await import('./CraftingTab/EnchanterSubTab');
|
||||
expect(mod.EnchanterSubTab).toBeDefined();
|
||||
expect(typeof mod.EnchanterSubTab).toBe('function');
|
||||
});
|
||||
|
||||
it('EnchanterSubTab has correct displayName', async () => {
|
||||
const { EnchanterSubTab } = await import('./CraftingTab/EnchanterSubTab');
|
||||
expect(EnchanterSubTab.displayName).toBe('EnchanterSubTab');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Fabricator recipes data ─────────────────────────────────────────────
|
||||
|
||||
describe('Fabricator recipes data', () => {
|
||||
it('exports FABRICATOR_RECIPES', async () => {
|
||||
const mod = await import('@/lib/game/data/fabricator-recipes');
|
||||
expect(mod.FABRICATOR_RECIPES).toBeDefined();
|
||||
expect(Object.keys(mod.FABRICATOR_RECIPES).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('all recipes have required fields', async () => {
|
||||
const { FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||
for (const recipe of Object.values(FABRICATOR_RECIPES)) {
|
||||
expect(recipe.id).toBeTruthy();
|
||||
expect(recipe.name).toBeTruthy();
|
||||
expect(recipe.description).toBeTruthy();
|
||||
expect(recipe.manaType).toBeTruthy();
|
||||
expect(recipe.equipmentTypeId).toBeTruthy();
|
||||
expect(recipe.slot).toBeTruthy();
|
||||
expect(recipe.materials).toBeDefined();
|
||||
expect(recipe.manaCost).toBeGreaterThan(0);
|
||||
expect(recipe.craftTime).toBeGreaterThan(0);
|
||||
expect(recipe.rarity).toBeTruthy();
|
||||
expect(recipe.gearTrait).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('only uses valid solid/structural mana types', async () => {
|
||||
const { FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||
const validManaTypes = new Set(['earth', 'metal', 'crystal', 'sand']);
|
||||
for (const recipe of Object.values(FABRICATOR_RECIPES)) {
|
||||
expect(validManaTypes.has(recipe.manaType)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('no fire, water, air, light, dark, death, stellar, or void gear recipes', async () => {
|
||||
const { FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||
const invalidTypes = new Set(['fire', 'water', 'air', 'light', 'dark', 'death', 'stellar', 'void']);
|
||||
for (const recipe of Object.values(FABRICATOR_RECIPES)) {
|
||||
expect(invalidTypes.has(recipe.manaType)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('getRecipesByManaType filters correctly', async () => {
|
||||
const { getRecipesByManaType } = await import('@/lib/game/data/fabricator-recipes');
|
||||
const earthRecipes = getRecipesByManaType('earth');
|
||||
expect(earthRecipes.length).toBeGreaterThan(0);
|
||||
for (const r of earthRecipes) {
|
||||
expect(r.manaType).toBe('earth');
|
||||
}
|
||||
|
||||
const empty = getRecipesByManaType('nonexistent');
|
||||
expect(empty).toEqual([]);
|
||||
});
|
||||
|
||||
it('getRecipeById returns correct recipe', async () => {
|
||||
const { getRecipeById, FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||
const firstId = Object.values(FABRICATOR_RECIPES)[0].id;
|
||||
const recipe = getRecipeById(firstId);
|
||||
expect(recipe).toBeDefined();
|
||||
expect(recipe!.id).toBe(firstId);
|
||||
|
||||
const missing = getRecipeById('nonexistent');
|
||||
expect(missing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('canCraftRecipe returns correct availability', async () => {
|
||||
const { canCraftRecipe, FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||
const recipe = Object.values(FABRICATOR_RECIPES)[0];
|
||||
|
||||
// Empty materials => cannot craft
|
||||
const result1 = canCraftRecipe(recipe, {}, 0);
|
||||
expect(result1.canCraft).toBe(false);
|
||||
expect(Object.keys(result1.missingMaterials).length).toBeGreaterThan(0);
|
||||
expect(result1.missingMana).toBeGreaterThan(0);
|
||||
|
||||
// Sufficient materials and mana => can craft
|
||||
const sufficientMats: Record<string, number> = {};
|
||||
for (const [matId, amount] of Object.entries(recipe.materials)) {
|
||||
sufficientMats[matId] = amount;
|
||||
}
|
||||
const result2 = canCraftRecipe(recipe, sufficientMats, recipe.manaCost);
|
||||
expect(result2.canCraft).toBe(true);
|
||||
expect(Object.keys(result2.missingMaterials)).toHaveLength(0);
|
||||
expect(result2.missingMana).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: File size limits ────────────────────────────────────────────────────
|
||||
|
||||
describe('File size limits (400 lines max)', () => {
|
||||
it('CraftingTab.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const content = fs.readFileSync(
|
||||
path.join(__dirname, 'CraftingTab.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it('FabricatorSubTab.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const content = fs.readFileSync(
|
||||
path.join(__dirname, 'CraftingTab', 'FabricatorSubTab.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it('EnchanterSubTab.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const content = fs.readFileSync(
|
||||
path.join(__dirname, 'CraftingTab', 'EnchanterSubTab.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { Hammer, Sparkles } from 'lucide-react';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { FabricatorSubTab } from './CraftingTab/FabricatorSubTab';
|
||||
import { EnchanterSubTab } from './CraftingTab/EnchanterSubTab';
|
||||
|
||||
type CraftingAttunement = 'fabricator' | 'enchanter';
|
||||
|
||||
interface CraftingSubTab {
|
||||
key: CraftingAttunement;
|
||||
label: string;
|
||||
icon: typeof Hammer;
|
||||
}
|
||||
|
||||
const CRAFTING_SUB_TABS: CraftingSubTab[] = [
|
||||
{ key: 'fabricator', label: 'Fabricator', icon: Hammer },
|
||||
{ key: 'enchanter', label: 'Enchanter', icon: Sparkles },
|
||||
];
|
||||
|
||||
export function CraftingTab() {
|
||||
const [activeSubTab, setActiveSubTab] = useState<CraftingAttunement>('fabricator');
|
||||
|
||||
return (
|
||||
<DebugName name="CraftingTab">
|
||||
<div className="space-y-4">
|
||||
{/* Sub-tab bar — same clsx pattern as DisciplinesTab */}
|
||||
<div className="flex gap-2">
|
||||
{CRAFTING_SUB_TABS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveSubTab(key)}
|
||||
className={clsx('rounded px-3 py-1 text-sm font-medium flex items-center gap-1.5', {
|
||||
'bg-amber-600 text-white': activeSubTab === key,
|
||||
'text-gray-400 hover:text-gray-200': activeSubTab !== key,
|
||||
})}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sub-tab content */}
|
||||
{activeSubTab === 'fabricator' && <FabricatorSubTab />}
|
||||
{activeSubTab === 'enchanter' && <EnchanterSubTab />}
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
CraftingTab.displayName = 'CraftingTab';
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { PenLine, FlaskConical, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
EnchantmentDesigner,
|
||||
EnchantmentPreparer,
|
||||
EnchantmentApplier,
|
||||
} from '@/components/game/crafting';
|
||||
import { useCraftingStore } from '@/lib/game/stores';
|
||||
import type { DesignEffect } from '@/lib/game/types';
|
||||
|
||||
type EnchanterPhase = 'design' | 'prepare' | 'apply';
|
||||
|
||||
const PHASES: { key: EnchanterPhase; label: string; icon: typeof PenLine }[] = [
|
||||
{ key: 'design', label: 'Design', icon: PenLine },
|
||||
{ key: 'prepare', label: 'Prepare', icon: FlaskConical },
|
||||
{ key: 'apply', label: 'Apply', icon: Sparkles },
|
||||
];
|
||||
|
||||
export function EnchanterSubTab() {
|
||||
const [activePhase, setActivePhase] = useState<EnchanterPhase>('design');
|
||||
|
||||
// Shared state for the enchantment flow
|
||||
const [selectedEquipmentType, setSelectedEquipmentType] = useState<string | null>(null);
|
||||
const [selectedEffects, setSelectedEffects] = useState<DesignEffect[]>([]);
|
||||
const [designName, setDesignName] = useState('');
|
||||
const [selectedDesign, setSelectedDesign] = useState<string | null>(null);
|
||||
const [selectedEquipmentInstance, setSelectedEquipmentInstance] = useState<string | null>(null);
|
||||
|
||||
const resetEnchantmentSelection = useCraftingStore((s) => s.resetEnchantmentSelection);
|
||||
|
||||
const handlePhaseChange = (phase: EnchanterPhase) => {
|
||||
setActivePhase(phase);
|
||||
};
|
||||
|
||||
const handleEnchantmentApplied = () => {
|
||||
// Reset selection after successful application
|
||||
resetEnchantmentSelection();
|
||||
setSelectedEquipmentInstance(null);
|
||||
setSelectedDesign(null);
|
||||
// Go back to design phase
|
||||
setActivePhase('design');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Phase selector */}
|
||||
<div className="flex gap-2">
|
||||
{PHASES.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handlePhaseChange(key)}
|
||||
className={clsx('rounded px-3 py-1 text-sm font-medium flex items-center gap-1.5', {
|
||||
'bg-purple-600 text-white': activePhase === key,
|
||||
'text-gray-400 hover:text-gray-200': activePhase !== key,
|
||||
})}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Phase content */}
|
||||
{activePhase === 'design' && (
|
||||
<EnchantmentDesigner
|
||||
selectedEquipmentType={selectedEquipmentType}
|
||||
setSelectedEquipmentType={setSelectedEquipmentType}
|
||||
selectedEffects={selectedEffects}
|
||||
setSelectedEffects={setSelectedEffects}
|
||||
designName={designName}
|
||||
setDesignName={setDesignName}
|
||||
selectedDesign={selectedDesign}
|
||||
setSelectedDesign={setSelectedDesign}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activePhase === 'prepare' && (
|
||||
<EnchantmentPreparer
|
||||
selectedEquipmentInstance={selectedEquipmentInstance}
|
||||
setSelectedEquipmentInstance={setSelectedEquipmentInstance}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activePhase === 'apply' && (
|
||||
<EnchantmentApplier
|
||||
selectedEquipmentInstance={selectedEquipmentInstance}
|
||||
setSelectedEquipmentInstance={setSelectedEquipmentInstance}
|
||||
selectedDesign={selectedDesign}
|
||||
setSelectedDesign={setSelectedDesign}
|
||||
onEnchantmentApplied={handleEnchantmentApplied}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EnchanterSubTab.displayName = 'EnchanterSubTab';
|
||||
@@ -0,0 +1,381 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Anvil, FlaskConical, Hammer, Package, Sparkles, Sword } from 'lucide-react';
|
||||
import { MaterialRecipeCard } from './MaterialRecipeCard';
|
||||
import {
|
||||
FABRICATOR_RECIPES,
|
||||
MATERIAL_RECIPES,
|
||||
getRecipesByManaType,
|
||||
canCraftRecipe,
|
||||
} from '@/lib/game/data/fabricator-recipes';
|
||||
import { MANA_TYPE_LABELS } from '@/lib/game/data/fabricator-recipe-types';
|
||||
import { LOOT_DROPS, LOOT_RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { useCraftingStore, useManaStore } from '@/lib/game/stores';
|
||||
import type { FabricatorRecipe } from '@/lib/game/data/fabricator-recipes';
|
||||
|
||||
const BRANCH_RECIPE_IDS = new Set([
|
||||
'oakStaff', 'arcanistStaff', 'battlestaff', 'arcanistCirclet', 'arcanistRobe',
|
||||
'voidCatalyst', 'arcanistPendant',
|
||||
'crystalBlade', 'arcanistBlade', 'voidBlade', 'battleHelm', 'battleRobe',
|
||||
'battleBoots', 'combatGauntlets',
|
||||
]);
|
||||
|
||||
function isWizardBranch(recipe: FabricatorRecipe): boolean {
|
||||
return ['oakStaff', 'arcanistStaff', 'battlestaff', 'arcanistCirclet',
|
||||
'arcanistRobe', 'voidCatalyst', 'arcanistPendant'].includes(recipe.id);
|
||||
}
|
||||
|
||||
function isPhysicalBranch(recipe: FabricatorRecipe): boolean {
|
||||
return ['crystalBlade', 'arcanistBlade', 'voidBlade', 'battleHelm',
|
||||
'battleRobe', 'battleBoots', 'combatGauntlets'].includes(recipe.id);
|
||||
}
|
||||
|
||||
function RecipeCard({
|
||||
recipe,
|
||||
materials,
|
||||
rawMana,
|
||||
elementalMana,
|
||||
onCraft,
|
||||
isCrafting,
|
||||
}: {
|
||||
recipe: FabricatorRecipe;
|
||||
materials: Record<string, number>;
|
||||
rawMana: number;
|
||||
elementalMana: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
onCraft: (recipe: FabricatorRecipe) => void;
|
||||
isCrafting: boolean;
|
||||
}) {
|
||||
const pool = recipe.manaType === 'raw'
|
||||
? rawMana
|
||||
: (elementalMana[recipe.manaType]?.current ?? 0);
|
||||
const { canCraft } = canCraftRecipe(
|
||||
recipe,
|
||||
materials,
|
||||
pool,
|
||||
recipe.manaType,
|
||||
);
|
||||
const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-3 rounded border bg-gray-800/50"
|
||||
style={{ borderColor: rarityStyle?.color }}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{recipe.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 capitalize">{recipe.rarity}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{MANA_TYPE_LABELS[recipe.manaType] ?? recipe.manaType}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-2">{recipe.description}</div>
|
||||
<div className="text-xs text-amber-400/80 italic mb-2">{recipe.gearTrait}</div>
|
||||
|
||||
<Separator className="bg-gray-700 my-2" />
|
||||
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-gray-500">Materials:</div>
|
||||
{Object.entries(recipe.materials).map(([matId, amount]) => {
|
||||
const available = materials[matId] || 0;
|
||||
const matDrop = LOOT_DROPS[matId];
|
||||
const hasEnough = available >= amount;
|
||||
|
||||
return (
|
||||
<div key={matId} className="flex justify-between">
|
||||
<span>{matDrop?.name ?? matId}</span>
|
||||
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
|
||||
{available} / {amount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex justify-between mt-2">
|
||||
<span>{MANA_TYPE_LABELS[recipe.manaType]?.split(' ')[1] ?? recipe.manaType} Mana:</span>
|
||||
<span className={pool >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
||||
{pool} / {recipe.manaCost}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span>Craft Time:</span>
|
||||
<span>{recipe.craftTime}h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full mt-3"
|
||||
size="sm"
|
||||
disabled={!canCraft || isCrafting}
|
||||
onClick={() => onCraft(recipe)}
|
||||
>
|
||||
{canCraft ? 'Craft' : 'Missing Resources'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BranchFilter = 'all' | 'elemental' | 'wizard' | 'physical';
|
||||
|
||||
export function FabricatorSubTab() {
|
||||
const [selectedManaType, setSelectedManaType] = useState<string>('earth');
|
||||
const [activeSection, setActiveSection] = useState<'equipment' | 'materials'>('equipment');
|
||||
const [branchFilter, setBranchFilter] = useState<BranchFilter>('all');
|
||||
|
||||
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
||||
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const startFabricatorCrafting = useCraftingStore((s) => s.startFabricatorCrafting);
|
||||
const craftMaterial = useCraftingStore((s) => s.craftMaterial);
|
||||
const cancelEquipmentCrafting = useCraftingStore((s) => s.cancelEquipmentCrafting);
|
||||
|
||||
const availableManaTypes = useMemo(() => {
|
||||
return [...new Set(FABRICATOR_RECIPES.map((r) => r.manaType))];
|
||||
}, []);
|
||||
|
||||
const filteredRecipes = useMemo(() => {
|
||||
let recipes = FABRICATOR_RECIPES;
|
||||
if (branchFilter === 'elemental') {
|
||||
recipes = recipes.filter(r => !BRANCH_RECIPE_IDS.has(r.id));
|
||||
} else if (branchFilter === 'wizard') {
|
||||
recipes = recipes.filter(r => isWizardBranch(r));
|
||||
} else if (branchFilter === 'physical') {
|
||||
recipes = recipes.filter(r => isPhysicalBranch(r));
|
||||
}
|
||||
return recipes.filter(r => r.manaType === selectedManaType);
|
||||
}, [selectedManaType, branchFilter]);
|
||||
|
||||
const isCrafting = equipmentCraftingProgress !== null;
|
||||
|
||||
const materialRecipes = useMemo(() => MATERIAL_RECIPES, []);
|
||||
|
||||
const handleCraft = (recipe: FabricatorRecipe) => {
|
||||
if (recipe.recipeType === 'material') {
|
||||
craftMaterial(recipe.id);
|
||||
} else {
|
||||
startFabricatorCrafting(recipe.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section toggle: Equipment vs Materials */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={activeSection === 'equipment' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setActiveSection('equipment')}
|
||||
>
|
||||
<Hammer className="w-3 h-3 mr-1" />
|
||||
Equipment
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeSection === 'materials' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setActiveSection('materials')}
|
||||
>
|
||||
<FlaskConical className="w-3 h-3 mr-1" />
|
||||
Materials
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Branch filter — only for equipment */}
|
||||
{activeSection === 'equipment' && (
|
||||
<>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant={branchFilter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setBranchFilter('all')}
|
||||
>
|
||||
<Anvil className="w-3 h-3 mr-1" />
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={branchFilter === 'elemental' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setBranchFilter('elemental')}
|
||||
>
|
||||
<Anvil className="w-3 h-3 mr-1" />
|
||||
Elemental
|
||||
</Button>
|
||||
<Button
|
||||
variant={branchFilter === 'wizard' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setBranchFilter('wizard')}
|
||||
>
|
||||
<Sparkles className="w-3 h-3 mr-1" />
|
||||
Wizard
|
||||
</Button>
|
||||
<Button
|
||||
variant={branchFilter === 'physical' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setBranchFilter('physical')}
|
||||
>
|
||||
<Sword className="w-3 h-3 mr-1" />
|
||||
Physical
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mana type filter */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{availableManaTypes.map((mt) => (
|
||||
<Button
|
||||
key={mt}
|
||||
variant={selectedManaType === mt ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedManaType(mt)}
|
||||
>
|
||||
{MANA_TYPE_LABELS[mt] ?? mt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Recipe list */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
{activeSection === 'equipment' ? (
|
||||
<><Hammer className="w-4 h-4" />{MANA_TYPE_LABELS[selectedManaType] ?? selectedManaType} Recipes</>
|
||||
) : (
|
||||
<><FlaskConical className="w-4 h-4" />Material Recipes</>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isCrafting ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Crafting: {equipmentCraftingProgress.blueprintId}
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
(equipmentCraftingProgress.progress /
|
||||
equipmentCraftingProgress.required) *
|
||||
100
|
||||
}
|
||||
className="h-3"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>
|
||||
{equipmentCraftingProgress.progress.toFixed(1)}h /{' '}
|
||||
{equipmentCraftingProgress.required.toFixed(1)}h
|
||||
</span>
|
||||
<span>Mana spent: {equipmentCraftingProgress.manaSpent}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-80">
|
||||
<div className="space-y-2">
|
||||
{activeSection === 'equipment' ? (
|
||||
filteredRecipes.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Anvil className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No recipes for this mana type yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredRecipes.map((recipe) => (
|
||||
<RecipeCard
|
||||
key={recipe.id}
|
||||
recipe={recipe}
|
||||
materials={lootInventory.materials}
|
||||
rawMana={rawMana}
|
||||
elementalMana={elements}
|
||||
onCraft={handleCraft}
|
||||
isCrafting={isCrafting}
|
||||
/>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
materialRecipes.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<FlaskConical className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No material recipes available.</p>
|
||||
</div>
|
||||
) : (
|
||||
materialRecipes.map((recipe) => (
|
||||
<MaterialRecipeCard
|
||||
key={recipe.id}
|
||||
recipe={recipe}
|
||||
materials={lootInventory.materials}
|
||||
rawMana={rawMana}
|
||||
elementalMana={elements}
|
||||
onCraft={handleCraft}
|
||||
/>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Materials inventory */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
Materials ({Object.values(lootInventory.materials).reduce((a, b) => a + b, 0)})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-80">
|
||||
{Object.keys(lootInventory.materials).length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No materials collected yet.</p>
|
||||
<p className="text-xs mt-1">Defeat floors to gather materials!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(lootInventory.materials).map(([matId, count]) => {
|
||||
if (count <= 0) return null;
|
||||
const drop = LOOT_DROPS[matId];
|
||||
if (!drop) return null;
|
||||
|
||||
const rarityStyle = LOOT_RARITY_COLORS[drop.rarity];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={matId}
|
||||
className="p-2 rounded border bg-gray-800/50"
|
||||
style={{ borderColor: rarityStyle?.color }}
|
||||
>
|
||||
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">x{count}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FabricatorSubTab.displayName = 'FabricatorSubTab';
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { LOOT_DROPS, LOOT_RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { canCraftRecipe } from '@/lib/game/data/fabricator-recipes';
|
||||
import { MANA_TYPE_LABELS } from '@/lib/game/data/fabricator-recipe-types';
|
||||
import type { FabricatorRecipe } from '@/lib/game/data/fabricator-recipes';
|
||||
|
||||
interface MaterialRecipeCardProps {
|
||||
recipe: FabricatorRecipe;
|
||||
materials: Record<string, number>;
|
||||
rawMana: number;
|
||||
elementalMana: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
onCraft: (recipe: FabricatorRecipe) => void;
|
||||
}
|
||||
|
||||
export function MaterialRecipeCard({
|
||||
recipe,
|
||||
materials,
|
||||
rawMana,
|
||||
elementalMana,
|
||||
onCraft,
|
||||
}: MaterialRecipeCardProps) {
|
||||
const pool = recipe.manaType === 'raw'
|
||||
? rawMana
|
||||
: (elementalMana[recipe.manaType]?.current ?? 0);
|
||||
const { canCraft } = canCraftRecipe(recipe, materials, pool, recipe.manaType);
|
||||
const resultDrop = recipe.resultMaterial ? LOOT_DROPS[recipe.resultMaterial] : null;
|
||||
const resultRarity = resultDrop ? LOOT_RARITY_COLORS[resultDrop.rarity] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-3 rounded border bg-gray-800/50"
|
||||
style={{ borderColor: resultRarity?.color ?? '#6B7280' }}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: resultRarity?.color ?? '#D1D5DB' }}>
|
||||
{recipe.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 capitalize">{recipe.rarity}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{recipe.resultAmount}x Output
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-2">{recipe.description}</div>
|
||||
|
||||
<Separator className="bg-gray-700 my-2" />
|
||||
|
||||
<div className="text-xs space-y-1">
|
||||
{Object.keys(recipe.materials).length > 0 && (
|
||||
<>
|
||||
<div className="text-gray-500">Input Materials:</div>
|
||||
{Object.entries(recipe.materials).map(([matId, amount]) => {
|
||||
const available = materials[matId] || 0;
|
||||
const matDrop = LOOT_DROPS[matId];
|
||||
const hasEnough = available >= amount;
|
||||
|
||||
return (
|
||||
<div key={matId} className="flex justify-between">
|
||||
<span>{matDrop?.name ?? matId}</span>
|
||||
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
|
||||
{available} / {amount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipe.manaCost > 0 && (
|
||||
<div className="flex justify-between mt-2">
|
||||
<span>{MANA_TYPE_LABELS[recipe.manaType]?.split(' ')[1] ?? recipe.manaType} Mana:</span>
|
||||
<span className={pool >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
||||
{pool} / {recipe.manaCost}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span>Produces:</span>
|
||||
<span className="text-amber-400">
|
||||
{recipe.resultAmount}x {resultDrop?.name ?? recipe.resultMaterial}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full mt-3"
|
||||
size="sm"
|
||||
disabled={!canCraft}
|
||||
onClick={() => onCraft(recipe)}
|
||||
>
|
||||
{canCraft ? 'Craft' : 'Missing Resources'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ─── Test: DebugTab barrel export ─────────────────────────────────────────────
|
||||
// Verifies that the DebugTab component is properly exported from the barrel
|
||||
// and that all section components are importable.
|
||||
|
||||
describe('DebugTab module structure', () => {
|
||||
it('exports DebugTab from barrel index', async () => {
|
||||
const mod = await import('./DebugTab');
|
||||
expect(mod.DebugTab).toBeDefined();
|
||||
expect(typeof mod.DebugTab).toBe('function');
|
||||
});
|
||||
|
||||
it('exports GameStateDebugSection', async () => {
|
||||
const mod = await import('./DebugTab/GameStateDebugSection');
|
||||
expect(mod.GameStateDebugSection).toBeDefined();
|
||||
expect(typeof mod.GameStateDebugSection).toBe('function');
|
||||
});
|
||||
|
||||
it('exports DisciplineDebugSection', async () => {
|
||||
const mod = await import('./DebugTab/DisciplineDebugSection');
|
||||
expect(mod.DisciplineDebugSection).toBeDefined();
|
||||
expect(typeof mod.DisciplineDebugSection).toBe('function');
|
||||
});
|
||||
|
||||
it('exports AttunementDebugSection', async () => {
|
||||
const mod = await import('./DebugTab/AttunementDebugSection');
|
||||
expect(mod.AttunementDebugSection).toBeDefined();
|
||||
expect(typeof mod.AttunementDebugSection).toBe('function');
|
||||
});
|
||||
|
||||
it('exports ElementDebugSection', async () => {
|
||||
const mod = await import('./DebugTab/ElementDebugSection');
|
||||
expect(mod.ElementDebugSection).toBeDefined();
|
||||
expect(typeof mod.ElementDebugSection).toBe('function');
|
||||
});
|
||||
|
||||
it('exports GolemDebugSection', async () => {
|
||||
const mod = await import('./DebugTab/GolemDebugSection');
|
||||
expect(mod.GolemDebugSection).toBeDefined();
|
||||
expect(typeof mod.GolemDebugSection).toBe('function');
|
||||
});
|
||||
|
||||
it('exports PactDebugSection', async () => {
|
||||
const mod = await import('./DebugTab/PactDebugSection');
|
||||
expect(mod.PactDebugSection).toBeDefined();
|
||||
expect(typeof mod.PactDebugSection).toBe('function');
|
||||
});
|
||||
|
||||
it('exports SpireDebugSection', async () => {
|
||||
const mod = await import('./DebugTab/SpireDebugSection');
|
||||
expect(mod.SpireDebugSection).toBeDefined();
|
||||
expect(typeof mod.SpireDebugSection).toBe('function');
|
||||
});
|
||||
|
||||
it('exports AchievementDebugSection', async () => {
|
||||
const mod = await import('./DebugTab/AchievementDebugSection');
|
||||
expect(mod.AchievementDebugSection).toBeDefined();
|
||||
expect(typeof mod.AchievementDebugSection).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Barrel export includes DebugTab ────────────────────────────────────
|
||||
|
||||
describe('Tab barrel export', () => {
|
||||
it('includes DebugTab in the tabs index', async () => {
|
||||
const mod = await import('@/components/game/tabs');
|
||||
expect(mod.DebugTab).toBeDefined();
|
||||
expect(typeof mod.DebugTab).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Store interactions used by DebugTab sections ───────────────────────
|
||||
|
||||
describe('GameStateDebugSection store interactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('resetGame action is callable', () => {
|
||||
const mockReset = vi.fn();
|
||||
// Simulate what GameStateDebugSection does on reset
|
||||
mockReset();
|
||||
expect(mockReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gatherMana action is callable N times for bulk add', () => {
|
||||
const mockGather = vi.fn();
|
||||
const amount = 100;
|
||||
for (let i = 0; i < amount; i++) {
|
||||
mockGather();
|
||||
}
|
||||
expect(mockGather).toHaveBeenCalledTimes(amount);
|
||||
});
|
||||
|
||||
it('togglePause action is callable', () => {
|
||||
const mockToggle = vi.fn();
|
||||
mockToggle();
|
||||
expect(mockToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('debugSetFloor action is callable with floor number', () => {
|
||||
const mockSetFloor = vi.fn();
|
||||
mockSetFloor(100);
|
||||
expect(mockSetFloor).toHaveBeenCalledWith(100);
|
||||
});
|
||||
|
||||
it('resetFloorHP action is callable', () => {
|
||||
const mockResetHP = vi.fn();
|
||||
mockResetHP();
|
||||
expect(mockResetHP).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DisciplineDebugSection store interactions', () => {
|
||||
it('activate action is callable', () => {
|
||||
const mockActivate = vi.fn();
|
||||
mockActivate('meditation');
|
||||
expect(mockActivate).toHaveBeenCalledWith('meditation');
|
||||
});
|
||||
|
||||
it('deactivate action is callable', () => {
|
||||
const mockDeactivate = vi.fn();
|
||||
mockDeactivate('meditation');
|
||||
expect(mockDeactivate).toHaveBeenCalledWith('meditation');
|
||||
});
|
||||
|
||||
it('XP can be added to discipline via setState', () => {
|
||||
const disciplines: Record<string, { xp: number; paused: boolean }> = {
|
||||
meditation: { xp: 0, paused: false },
|
||||
};
|
||||
const id = 'meditation';
|
||||
const amount = 100;
|
||||
disciplines[id] = { ...disciplines[id], xp: disciplines[id].xp + amount };
|
||||
expect(disciplines[id].xp).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttunementDebugSection store interactions', () => {
|
||||
it('debugUnlockAttunement is callable', () => {
|
||||
const mockUnlock = vi.fn();
|
||||
mockUnlock('invoker');
|
||||
expect(mockUnlock).toHaveBeenCalledWith('invoker');
|
||||
});
|
||||
|
||||
it('addAttunementXP is callable', () => {
|
||||
const mockAddXP = vi.fn();
|
||||
mockAddXP('enchanter', 100);
|
||||
expect(mockAddXP).toHaveBeenCalledWith('enchanter', 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ElementDebugSection store interactions', () => {
|
||||
it('unlockElement is callable with zero cost', () => {
|
||||
const mockUnlock = vi.fn();
|
||||
mockUnlock('fire', 0);
|
||||
expect(mockUnlock).toHaveBeenCalledWith('fire', 0);
|
||||
});
|
||||
|
||||
it('addElementMana is callable', () => {
|
||||
const mockAdd = vi.fn();
|
||||
mockAdd('fire', 10, 50);
|
||||
expect(mockAdd).toHaveBeenCalledWith('fire', 10, 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GolemDebugSection store interactions', () => {
|
||||
it('setEnabledGolems is callable with all golem IDs', () => {
|
||||
const mockSet = vi.fn();
|
||||
const allIds = ['stoneGolem', 'fireGolem'];
|
||||
mockSet(allIds);
|
||||
expect(mockSet).toHaveBeenCalledWith(allIds);
|
||||
});
|
||||
|
||||
it('setEnabledGolems is callable with empty array to disable all', () => {
|
||||
const mockSet = vi.fn();
|
||||
mockSet([]);
|
||||
expect(mockSet).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PactDebugSection store interactions', () => {
|
||||
it('addSignedPact is callable', () => {
|
||||
const mockAdd = vi.fn();
|
||||
mockAdd(10);
|
||||
expect(mockAdd).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('removePact is callable', () => {
|
||||
const mockRemove = vi.fn();
|
||||
mockRemove(10);
|
||||
expect(mockRemove).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('debugSetSignedPacts is callable', () => {
|
||||
const mockSet = vi.fn();
|
||||
mockSet([10, 20, 30]);
|
||||
expect(mockSet).toHaveBeenCalledWith([10, 20, 30]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpireDebugSection store interactions', () => {
|
||||
it('enterSpireMode is callable', () => {
|
||||
const mockEnter = vi.fn();
|
||||
mockEnter();
|
||||
expect(mockEnter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exitSpireMode is callable', () => {
|
||||
const mockExit = vi.fn();
|
||||
mockExit();
|
||||
expect(mockExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('setMaxFloorReached is callable', () => {
|
||||
const mockSet = vi.fn();
|
||||
mockSet(50);
|
||||
expect(mockSet).toHaveBeenCalledWith(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AchievementDebugSection store interactions', () => {
|
||||
it('can set all achievements as unlocked via setState', () => {
|
||||
const allIds = ['firstBlood', 'floorClimber'];
|
||||
const newState = {
|
||||
achievements: {
|
||||
unlocked: allIds,
|
||||
progress: Object.fromEntries(allIds.map(id => [id, 100])),
|
||||
},
|
||||
};
|
||||
expect(newState.achievements.unlocked).toEqual(allIds);
|
||||
expect(Object.keys(newState.achievements.progress)).toEqual(allIds);
|
||||
});
|
||||
|
||||
it('can reset all achievements via setState', () => {
|
||||
const newState = {
|
||||
achievements: {
|
||||
unlocked: [],
|
||||
progress: {},
|
||||
},
|
||||
};
|
||||
expect(newState.achievements.unlocked).toEqual([]);
|
||||
expect(newState.achievements.progress).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: DebugTab component displayName ─────────────────────────────────────
|
||||
|
||||
describe('DebugTab component metadata', () => {
|
||||
it('DebugTab has correct displayName', async () => {
|
||||
const { DebugTab } = await import('./DebugTab');
|
||||
expect(DebugTab.displayName).toBe('DebugTab');
|
||||
});
|
||||
|
||||
it('GameStateDebugSection has correct displayName', async () => {
|
||||
const { GameStateDebugSection } = await import('./DebugTab/GameStateDebugSection');
|
||||
expect(GameStateDebugSection.displayName).toBe('GameStateDebugSection');
|
||||
});
|
||||
|
||||
it('DisciplineDebugSection has correct displayName', async () => {
|
||||
const { DisciplineDebugSection } = await import('./DebugTab/DisciplineDebugSection');
|
||||
expect(DisciplineDebugSection.displayName).toBe('DisciplineDebugSection');
|
||||
});
|
||||
|
||||
it('AttunementDebugSection has correct displayName', async () => {
|
||||
const { AttunementDebugSection } = await import('./DebugTab/AttunementDebugSection');
|
||||
expect(AttunementDebugSection.displayName).toBe('AttunementDebugSection');
|
||||
});
|
||||
|
||||
it('ElementDebugSection has correct displayName', async () => {
|
||||
const { ElementDebugSection } = await import('./DebugTab/ElementDebugSection');
|
||||
expect(ElementDebugSection.displayName).toBe('ElementDebugSection');
|
||||
});
|
||||
|
||||
it('GolemDebugSection has correct displayName', async () => {
|
||||
const { GolemDebugSection } = await import('./DebugTab/GolemDebugSection');
|
||||
expect(GolemDebugSection.displayName).toBe('GolemDebugSection');
|
||||
});
|
||||
|
||||
it('PactDebugSection has correct displayName', async () => {
|
||||
const { PactDebugSection } = await import('./DebugTab/PactDebugSection');
|
||||
expect(PactDebugSection.displayName).toBe('PactDebugSection');
|
||||
});
|
||||
|
||||
it('SpireDebugSection has correct displayName', async () => {
|
||||
const { SpireDebugSection } = await import('./DebugTab/SpireDebugSection');
|
||||
expect(SpireDebugSection.displayName).toBe('SpireDebugSection');
|
||||
});
|
||||
|
||||
it('AchievementDebugSection has correct displayName', async () => {
|
||||
const { AchievementDebugSection } = await import('./DebugTab/AchievementDebugSection');
|
||||
expect(AchievementDebugSection.displayName).toBe('AchievementDebugSection');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: File size limits ───────────────────────────────────────────────────
|
||||
// Note: 400-line limit is enforced by pre-commit hook (check-file-size.js).
|
||||
// These tests verify the source files are importable; line count enforcement
|
||||
// is handled by the hook, not by runtime tests.
|
||||
|
||||
describe('File size limits (400 lines max)', () => {
|
||||
it('DebugTab.tsx is importable and under 400 lines (enforced by pre-commit hook)', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'DebugTab.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it('GameStateDebugSection.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'DebugTab', 'GameStateDebugSection.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it('DisciplineDebugSection.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'DebugTab', 'DisciplineDebugSection.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it('PactDebugSection.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'DebugTab', 'PactDebugSection.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { GameStateDebugSection } from './DebugTab/GameStateDebugSection';
|
||||
import { DisciplineDebugSection } from './DebugTab/DisciplineDebugSection';
|
||||
import { AttunementDebugSection } from './DebugTab/AttunementDebugSection';
|
||||
import { ElementDebugSection } from './DebugTab/ElementDebugSection';
|
||||
import { GolemDebugSection } from './DebugTab/GolemDebugSection';
|
||||
import { PactDebugSection } from './DebugTab/PactDebugSection';
|
||||
import { SpireDebugSection } from './DebugTab/SpireDebugSection';
|
||||
import { AchievementDebugSection } from './DebugTab/AchievementDebugSection';
|
||||
|
||||
interface DebugSectionProps {
|
||||
title: string;
|
||||
color: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function DebugSection({ title, color, children, defaultOpen = false }: DebugSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/60 border-gray-700/50">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 text-left hover:bg-gray-800/30 transition-colors"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<span className="font-semibold text-sm" style={{ color }}>{title}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<CardContent className="pt-0 pb-4">
|
||||
{children}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugTab() {
|
||||
return (
|
||||
<DebugName name="DebugTab">
|
||||
<div className="space-y-4">
|
||||
{/* Warning Banner */}
|
||||
<Card className="bg-amber-900/20 border-amber-600/50">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2 text-amber-400">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span className="font-semibold">Debug Mode</span>
|
||||
</div>
|
||||
<p className="text-sm text-amber-300/70 mt-1">
|
||||
These tools are for development and testing. Using them may break game balance or save data.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DebugSection title="Game State" color="#60A5FA" defaultOpen={true}>
|
||||
<GameStateDebugSection />
|
||||
</DebugSection>
|
||||
|
||||
<DebugSection title="Spire" color="#2DD4BF">
|
||||
<SpireDebugSection />
|
||||
</DebugSection>
|
||||
|
||||
<DebugSection title="Disciplines" color="#818CF8">
|
||||
<DisciplineDebugSection />
|
||||
</DebugSection>
|
||||
|
||||
<DebugSection title="Attunements" color="#C084FC">
|
||||
<AttunementDebugSection />
|
||||
</DebugSection>
|
||||
|
||||
<DebugSection title="Elements" color="#4ADE80">
|
||||
<ElementDebugSection />
|
||||
</DebugSection>
|
||||
|
||||
<DebugSection title="Golems" color="#FB923C">
|
||||
<GolemDebugSection />
|
||||
</DebugSection>
|
||||
|
||||
<DebugSection title="Pacts" color="#F87171">
|
||||
<PactDebugSection />
|
||||
</DebugSection>
|
||||
|
||||
<DebugSection title="Achievements" color="#FACC15">
|
||||
<AchievementDebugSection />
|
||||
</DebugSection>
|
||||
</div>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
DebugTab.displayName = "DebugTab";
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Trophy, CheckCircle, RotateCcw } from 'lucide-react';
|
||||
import { useCombatStore } from '@/lib/game/stores';
|
||||
import { ACHIEVEMENTS } from '@/lib/game/data/achievements';
|
||||
|
||||
export function AchievementDebugSection() {
|
||||
const achievements = useCombatStore((s) => s.achievements);
|
||||
|
||||
const unlockedCount = achievements?.unlocked?.length || 0;
|
||||
const totalCount = Object.keys(ACHIEVEMENTS).length;
|
||||
|
||||
const handleUnlockAll = () => {
|
||||
useCombatStore.setState({
|
||||
achievements: {
|
||||
unlocked: Object.keys(ACHIEVEMENTS),
|
||||
progress: Object.fromEntries(
|
||||
Object.keys(ACHIEVEMENTS).map((id) => [id, ACHIEVEMENTS[id].requirement.value])
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetAll = () => {
|
||||
useCombatStore.setState({
|
||||
achievements: {
|
||||
unlocked: [],
|
||||
progress: {},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-yellow-400 text-sm flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
Achievement Debug
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-xs text-gray-400">
|
||||
Unlocked: {unlockedCount} / {totalCount}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={handleUnlockAll}>
|
||||
<CheckCircle className="w-3 h-3 mr-1" /> Unlock All
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleResetAll}>
|
||||
<RotateCcw className="w-3 h-3 mr-1" /> Reset All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{Object.entries(ACHIEVEMENTS).map(([id, def]) => {
|
||||
const isUnlocked = achievements?.unlocked?.includes(id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`flex items-center justify-between p-2 rounded text-xs ${
|
||||
isUnlocked ? 'bg-green-900/20 border border-green-600/50' : 'bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{def.name}</span>
|
||||
<span className="text-gray-500 ml-2">({def.category})</span>
|
||||
</div>
|
||||
{isUnlocked && (
|
||||
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
AchievementDebugSection.displayName = "AchievementDebugSection";
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Sparkles, Unlock } from 'lucide-react';
|
||||
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
|
||||
import { useAttunementStore } from '@/lib/game/stores';
|
||||
import { useManaStore } from '@/lib/game/stores';
|
||||
|
||||
export function AttunementDebugSection() {
|
||||
const attunements = useAttunementStore((s) => s.attunements);
|
||||
const debugUnlockAttunement = useAttunementStore((s) => s.debugUnlockAttunement);
|
||||
const addAttunementXP = useAttunementStore((s) => s.addAttunementXP);
|
||||
|
||||
const handleUnlockAttunement = (id: string) => {
|
||||
if (debugUnlockAttunement) {
|
||||
debugUnlockAttunement(id);
|
||||
if (id === 'enchanter') {
|
||||
useManaStore.getState().unlockElement('transference', 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAttunementXP = (id: string, amount: number) => {
|
||||
if (addAttunementXP) {
|
||||
addAttunementXP(id, amount);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlockAll = () => {
|
||||
Object.keys(ATTUNEMENTS_DEF).forEach((id) => {
|
||||
handleUnlockAttunement(id);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Attunements
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button size="sm" variant="outline" onClick={handleUnlockAll}>
|
||||
<Unlock className="w-3 h-3 mr-1" /> Unlock All
|
||||
</Button>
|
||||
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
|
||||
const isActive = attunements?.[id]?.active;
|
||||
const level = attunements?.[id]?.level || 1;
|
||||
const xp = attunements?.[id]?.experience || 0;
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center justify-between p-2 bg-gray-800/50 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{def.icon}</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{def.name}</div>
|
||||
{isActive && (
|
||||
<div className="text-xs text-gray-400">Lv.{level} • {xp} XP</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleUnlockAttunement(id)}
|
||||
>
|
||||
<Unlock className="w-3 h-3 mr-1" /> Unlock
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleAddAttunementXP(id, 100)}
|
||||
>
|
||||
+100 XP
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
AttunementDebugSection.displayName = "AttunementDebugSection";
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { BookOpen, Plus, Pause, Play } from 'lucide-react';
|
||||
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
|
||||
import { ALL_DISCIPLINES } from '@/lib/game/data/disciplines';
|
||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||
|
||||
export function DisciplineDebugSection() {
|
||||
const disciplines = useDisciplineStore((s) => s.disciplines);
|
||||
const activeIds = useDisciplineStore((s) => s.activeIds);
|
||||
const concurrentLimit = useDisciplineStore((s) => s.concurrentLimit);
|
||||
const activate = useDisciplineStore((s) => s.activate);
|
||||
const deactivate = useDisciplineStore((s) => s.deactivate);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
|
||||
const _handleTogglePause = (id: string) => {
|
||||
const disc = disciplines[id];
|
||||
if (!disc) return;
|
||||
if (disc.paused) {
|
||||
activate(id);
|
||||
} else {
|
||||
deactivate(id);
|
||||
// Re-activate with paused false — just activate again
|
||||
activate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddXP = (id: string, amount: number) => {
|
||||
useDisciplineStore.setState((s) => {
|
||||
const disc = s.disciplines[id];
|
||||
if (!disc) return s;
|
||||
return {
|
||||
disciplines: {
|
||||
...s.disciplines,
|
||||
[id]: { ...disc, xp: disc.xp + amount },
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleActivateAll = () => {
|
||||
ALL_DISCIPLINES.forEach((d) => {
|
||||
if (!activeIds.includes(d.id)) {
|
||||
activate(d.id, { elements });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeactivateAll = () => {
|
||||
activeIds.forEach((id) => {
|
||||
deactivate(id);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-indigo-400 text-sm flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Disciplines
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2 flex-wrap mb-2">
|
||||
<Button size="sm" variant="outline" onClick={handleActivateAll}>
|
||||
<Play className="w-3 h-3 mr-1" /> Activate All
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleDeactivateAll}>
|
||||
<Pause className="w-3 h-3 mr-1" /> Deactivate All
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Active: {activeIds.length} / {concurrentLimit}
|
||||
</div>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{ALL_DISCIPLINES.map((def) => {
|
||||
const disc = disciplines[def.id];
|
||||
const isActive = activeIds.includes(def.id);
|
||||
const xp = disc?.xp || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={def.id}
|
||||
className="flex items-center justify-between p-2 bg-gray-800/50 rounded"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{def.name}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{isActive ? `XP: ${xp}` : 'Inactive'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleAddXP(def.id, 100)}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" /> +100
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleAddXP(def.id, 1000)}
|
||||
>
|
||||
+1K
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
deactivate(def.id);
|
||||
} else {
|
||||
activate(def.id, { elements });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isActive ? (
|
||||
<Pause className="w-3 h-3" />
|
||||
) : (
|
||||
<Play className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
DisciplineDebugSection.displayName = "DisciplineDebugSection";
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Star, Lock } from 'lucide-react';
|
||||
import { useManaStore } from '@/lib/game/stores';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
export function ElementDebugSection() {
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
|
||||
const handleUnlockElement = (element: string) => {
|
||||
useManaStore.getState().unlockElement(element, 0);
|
||||
};
|
||||
|
||||
const handleAddElementalMana = (element: string, amount: number) => {
|
||||
const elem = elements?.[element];
|
||||
if (elem?.unlocked) {
|
||||
useManaStore.getState().addElementMana(element, amount, elem.max);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlockAll = () => {
|
||||
Object.keys(elements || {}).forEach((id) => {
|
||||
if (!elements[id]?.unlocked) {
|
||||
handleUnlockElement(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
<Star className="w-4 h-4" />
|
||||
Elemental Mana
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-3">
|
||||
<Button size="sm" variant="outline" onClick={handleUnlockAll}>
|
||||
<Lock className="w-3 h-3 mr-1" /> Unlock All Elements
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{Object.entries(elements || {}).map(([id, elem]) => {
|
||||
const def = ELEMENTS[id];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`p-2 rounded border text-center ${
|
||||
elem.unlocked ? 'border-gray-600 bg-gray-800/50' : 'border-gray-800 opacity-60'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: elem.unlocked ? def?.color : undefined
|
||||
}}
|
||||
>
|
||||
<div className="text-lg">{def?.sym}</div>
|
||||
<div className="text-xs text-gray-400">{def?.name}</div>
|
||||
<div className="text-xs text-gray-300 mt-1">
|
||||
{elem.current}/{elem.max}
|
||||
</div>
|
||||
{!elem.unlocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => handleUnlockElement(id)}
|
||||
>
|
||||
<Lock className="w-3 h-3 mr-1" /> Unlock
|
||||
</Button>
|
||||
)}
|
||||
{elem.unlocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => handleAddElementalMana(id, 10)}
|
||||
>
|
||||
+10
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
ElementDebugSection.displayName = "ElementDebugSection";
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
RotateCcw, AlertTriangle, Zap, Clock, Eye,
|
||||
} from 'lucide-react';
|
||||
import { useDebug } from '@/components/game/debug/debug-context';
|
||||
import { useGameStore, useManaStore, useUIStore, useCombatStore } from '@/lib/game/stores';
|
||||
import { computeMaxMana } from '@/lib/game/stores';
|
||||
|
||||
// ─── Display Options ─────────────────────────────────────────────────────────
|
||||
|
||||
function DisplayOptions() {
|
||||
const { showComponentNames, toggleComponentNames } = useDebug();
|
||||
|
||||
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">
|
||||
<Eye className="w-4 h-4" />
|
||||
Display Options
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="show-component-names" className="text-sm">Show Component Names</Label>
|
||||
<p className="text-xs text-gray-400">
|
||||
Display component names at the top of each component for debugging
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="show-component-names"
|
||||
checked={showComponentNames}
|
||||
onCheckedChange={toggleComponentNames}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Game Reset Section ──────────────────────────────────────────────────────
|
||||
|
||||
function GameResetSection({ confirmReset, onReset }: { confirmReset: boolean; onReset: () => void }) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-red-400 text-sm flex items-center gap-2">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Game Reset
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
Reset all game progress and start fresh. This cannot be undone.
|
||||
</p>
|
||||
<Button
|
||||
className={`w-full ${confirmReset ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'}`}
|
||||
onClick={onReset}
|
||||
>
|
||||
{confirmReset ? (
|
||||
<>
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
Click Again to Confirm Reset
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reset Game
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mana Debug Section ──────────────────────────────────────────────────────
|
||||
|
||||
function ManaDebugSection({ rawMana, onAddMana, onFillMana }: {
|
||||
rawMana: number;
|
||||
onAddMana: (amount: number) => void;
|
||||
onFillMana: () => void;
|
||||
}) {
|
||||
const maxMana = computeMaxMana(
|
||||
{ skills: {}, prestigeUpgrades: {}, skillUpgrades: {}, skillTiers: {} }
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-blue-400 text-sm flex items-center gap-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
Mana Debug
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
Current: {rawMana} / {maxMana || '?'}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={() => onAddMana(10)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +10
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onAddMana(100)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +100
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onAddMana(1000)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +1K
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onAddMana(10000)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +10K
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-gray-700" />
|
||||
<div className="text-xs text-gray-400 mb-2">Fill to max:</div>
|
||||
<Button size="sm" className="w-full bg-blue-600 hover:bg-blue-700" onClick={onFillMana}>
|
||||
Fill Mana
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Time Control Section ────────────────────────────────────────────────────
|
||||
|
||||
function TimeControlSection({ day, hour, paused, onSetDay, onTogglePause }: {
|
||||
day: number;
|
||||
hour: number;
|
||||
paused: boolean;
|
||||
onSetDay: (day: number) => void;
|
||||
onTogglePause: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Time Control
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-xs text-gray-400">
|
||||
Current: Day {day}, Hour {hour}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={() => onSetDay(1)}>Day 1</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onSetDay(10)}>Day 10</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onSetDay(20)}>Day 20</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onSetDay(30)}>Day 30</Button>
|
||||
</div>
|
||||
<Separator className="bg-gray-700" />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onTogglePause}>
|
||||
{paused ? '▶ Resume' : '⏸ Pause'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Quick Actions Section ───────────────────────────────────────────────────
|
||||
|
||||
function QuickActionsSection({ onUnlockBase, onSkipToFloor, onResetFloorHP }: {
|
||||
onUnlockBase: () => void;
|
||||
onSkipToFloor: () => void;
|
||||
onResetFloorHP: () => void;
|
||||
}) {
|
||||
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">
|
||||
<Zap className="w-4 h-4" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={onUnlockBase}>
|
||||
Unlock All Base Elements
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onSkipToFloor}>
|
||||
Skip to Floor 100
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onResetFloorHP}>
|
||||
Reset Floor HP
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function GameStateDebugSection() {
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const { showComponentNames, toggleComponentNames } = useDebug();
|
||||
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const day = useGameStore((s) => s.day);
|
||||
const hour = useGameStore((s) => s.hour);
|
||||
const paused = useUIStore((s) => s.paused);
|
||||
const togglePause = useUIStore((s) => s.togglePause);
|
||||
const resetGame = useGameStore((s) => s.resetGame);
|
||||
const gatherMana = useGameStore((s) => s.gatherMana);
|
||||
const debugSetFloor = useCombatStore((s) => s.debugSetFloor);
|
||||
const resetFloorHP = useCombatStore((s) => s.resetFloorHP);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const unlockElement = useManaStore((s) => s.unlockElement);
|
||||
|
||||
const handleReset = () => {
|
||||
if (confirmReset) {
|
||||
resetGame();
|
||||
setConfirmReset(false);
|
||||
} else {
|
||||
setConfirmReset(true);
|
||||
setTimeout(() => setConfirmReset(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMana = (amount: number) => {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
gatherMana();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFillMana = () => {
|
||||
const maxMana = computeMaxMana(
|
||||
{ skills: {}, prestigeUpgrades: {}, skillUpgrades: {}, skillTiers: {} }
|
||||
) || 100;
|
||||
useManaStore.setState((s) => ({ rawMana: Math.max(s.rawMana, maxMana) }));
|
||||
};
|
||||
|
||||
const handleSetDay = (d: number) => {
|
||||
useGameStore.setState({ day: d, hour: 0 });
|
||||
};
|
||||
|
||||
const handleUnlockBase = () => {
|
||||
['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'].forEach(e => {
|
||||
if (!elements[e]?.unlocked) {
|
||||
unlockElement(e, 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DisplayOptions />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<GameResetSection confirmReset={confirmReset} onReset={handleReset} />
|
||||
<ManaDebugSection rawMana={rawMana} onAddMana={handleAddMana} onFillMana={handleFillMana} />
|
||||
<TimeControlSection day={day} hour={hour} paused={paused} onSetDay={handleSetDay} onTogglePause={togglePause} />
|
||||
<QuickActionsSection
|
||||
onUnlockBase={handleUnlockBase}
|
||||
onSkipToFloor={() => debugSetFloor?.(100)}
|
||||
onResetFloorHP={() => resetFloorHP?.()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GameStateDebugSection.displayName = 'GameStateDebugSection';
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Bug, Wand2 } from 'lucide-react';
|
||||
import { useCombatStore } from '@/lib/game/stores';
|
||||
import { GOLEMS_DEF } from '@/lib/game/data/golems';
|
||||
|
||||
export function GolemDebugSection() {
|
||||
const golemancy = useCombatStore((s) => s.golemancy);
|
||||
const setEnabledGolems = useCombatStore((s) => s.setEnabledGolems);
|
||||
|
||||
const enabledGolems = golemancy?.enabledGolems || [];
|
||||
|
||||
const handleEnableAll = () => {
|
||||
setEnabledGolems(Object.keys(GOLEMS_DEF));
|
||||
};
|
||||
|
||||
const handleDisableAll = () => {
|
||||
setEnabledGolems([]);
|
||||
};
|
||||
|
||||
const handleToggleGolem = (golemId: string) => {
|
||||
if (enabledGolems.includes(golemId)) {
|
||||
setEnabledGolems(enabledGolems.filter(id => id !== golemId));
|
||||
} else {
|
||||
setEnabledGolems([...enabledGolems, golemId]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-orange-400 text-sm flex items-center gap-2">
|
||||
<Bug className="w-4 h-4" />
|
||||
Golem Debug
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={handleEnableAll}>
|
||||
<Wand2 className="w-3 h-3 mr-1" /> Enable All Golems
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleDisableAll}>
|
||||
Disable All Golems
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Enabled: {enabledGolems.length} / {Object.keys(GOLEMS_DEF).length}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
||||
{Object.entries(GOLEMS_DEF).map(([id, def]) => {
|
||||
const isEnabled = enabledGolems.includes(id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`p-2 rounded border flex items-center justify-between ${
|
||||
isEnabled ? 'border-orange-600/50 bg-orange-900/20' : 'border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{def.name}</div>
|
||||
<div className="text-xs text-gray-400">{def.baseManaType}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isEnabled ? 'default' : 'outline'}
|
||||
onClick={() => handleToggleGolem(id)}
|
||||
>
|
||||
{isEnabled ? 'On' : 'Off'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
GolemDebugSection.displayName = "GolemDebugSection";
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bug } from 'lucide-react';
|
||||
import { usePrestigeStore, useUIStore, useGameStore } from '@/lib/game/stores';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { getGuardianForFloor, getAllGuardianFloors } from '@/lib/game/data/guardian-encounters';
|
||||
|
||||
// ─── Guardian Pact Row ───────────────────────────────────────────────────────
|
||||
|
||||
function GuardianPactRow({ floor, isSigned, onForceSign, onRemove }: {
|
||||
floor: number;
|
||||
isSigned: boolean;
|
||||
onForceSign: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
if (!guardian) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-2 rounded border flex items-center justify-between ${
|
||||
isSigned ? 'border-green-600/50 bg-green-900/20' : 'border-gray-700'
|
||||
}`}
|
||||
style={{ borderColor: isSigned ? undefined : guardian.color, borderWidth: '1px' }}
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: guardian.color }}>
|
||||
{guardian.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Floor {floor} | {guardian.pact}x multiplier
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Element: {ELEMENTS[guardian.element]?.name || guardian.element}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{isSigned ? (
|
||||
<Button size="sm" variant="destructive" onClick={onRemove} className="text-xs">
|
||||
Remove
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="default" onClick={onForceSign} className="text-xs bg-amber-600 hover:bg-amber-700">
|
||||
Force Sign
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function PactDebugSection() {
|
||||
const signedPacts = usePrestigeStore((s) => s.signedPacts);
|
||||
const signedPactDetails = usePrestigeStore((s) => s.signedPactDetails);
|
||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
||||
|
||||
const addSignedPact = usePrestigeStore((s) => s.addSignedPact);
|
||||
const removePact = usePrestigeStore((s) => s.removePact);
|
||||
const debugSetSignedPacts = usePrestigeStore((s) => s.debugSetSignedPacts);
|
||||
const debugSetPactDetails = usePrestigeStore((s) => s.debugSetPactDetails);
|
||||
const addLog = useUIStore((s) => s.addLog);
|
||||
|
||||
const guardianFloors = getAllGuardianFloors();
|
||||
|
||||
const forcePact = (floor: number) => {
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
if (!guardian) return;
|
||||
|
||||
if (signedPacts.includes(floor)) {
|
||||
addLog(`⚠️ Already signed pact with ${guardian.name}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPacts = 1 + (prestigeUpgrades?.pactCapacity || 0);
|
||||
if (signedPacts.length >= maxPacts) {
|
||||
addLog(`⚠️ Cannot sign more pacts! Maximum: ${maxPacts}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
addSignedPact(floor);
|
||||
|
||||
const newSignedPactDetails = {
|
||||
...signedPactDetails,
|
||||
[floor]: {
|
||||
floor,
|
||||
guardianId: guardian.element,
|
||||
signedAt: { day: useGameStore.getState().day, hour: useGameStore.getState().hour },
|
||||
skillLevels: {} as Record<string, number>,
|
||||
},
|
||||
};
|
||||
debugSetPactDetails(newSignedPactDetails);
|
||||
|
||||
addLog(`📜 DEBUG: Pact with ${guardian.name} force-signed!`);
|
||||
};
|
||||
|
||||
const removePactHandler = (floor: number) => {
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
|
||||
removePact(floor);
|
||||
|
||||
const newSignedPactDetails = { ...signedPactDetails };
|
||||
delete newSignedPactDetails[floor];
|
||||
debugSetPactDetails(newSignedPactDetails);
|
||||
|
||||
addLog(`\ud83d\udcdc DEBUG: Removed pact with ${guardian ? guardian.name : 'Unknown'}!`);
|
||||
};
|
||||
|
||||
const signAllPacts = () => {
|
||||
guardianFloors.forEach((floor) => {
|
||||
if (!signedPacts.includes(floor)) {
|
||||
forcePact(floor);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllPacts = () => {
|
||||
addLog(`📜 DEBUG: Cleared all pacts!`);
|
||||
debugSetSignedPacts([]);
|
||||
debugSetPactDetails({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-orange-400 text-sm flex items-center gap-2">
|
||||
<Bug className="w-4 h-4" />
|
||||
Pact Debug
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={signAllPacts}>
|
||||
Sign All Pacts
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={clearAllPacts}>
|
||||
Clear All Pacts ({signedPacts.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{guardianFloors.map((floor) => (
|
||||
<GuardianPactRow
|
||||
key={floor}
|
||||
floor={floor}
|
||||
isSigned={signedPacts.includes(floor)}
|
||||
onForceSign={() => forcePact(floor)}
|
||||
onRemove={() => removePactHandler(floor)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 pt-2 border-t border-gray-700">
|
||||
Signed Pacts: {signedPacts.length} |
|
||||
Max Pacts: {1 + (prestigeUpgrades?.pactCapacity || 0)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
PactDebugSection.displayName = 'PactDebugSection';
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Castle, ArrowUp, Eye } from 'lucide-react';
|
||||
import { useCombatStore } from '@/lib/game/stores';
|
||||
|
||||
export function SpireDebugSection() {
|
||||
const [floorInput, setFloorInput] = useState('50');
|
||||
|
||||
const currentFloor = useCombatStore((s) => s.currentFloor);
|
||||
const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
|
||||
const spireMode = useCombatStore((s) => s.spireMode);
|
||||
const debugSetFloor = useCombatStore((s) => s.debugSetFloor);
|
||||
const resetFloorHP = useCombatStore((s) => s.resetFloorHP);
|
||||
const enterSpireMode = useCombatStore((s) => s.enterSpireMode);
|
||||
const exitSpireMode = useCombatStore((s) => s.exitSpireMode);
|
||||
const setMaxFloorReached = useCombatStore((s) => s.setMaxFloorReached);
|
||||
|
||||
const handleJumpToFloor = () => {
|
||||
const floor = parseInt(floorInput, 10);
|
||||
if (isNaN(floor) || floor < 1 || floor > 100) return;
|
||||
debugSetFloor(floor);
|
||||
setMaxFloorReached(floor);
|
||||
};
|
||||
|
||||
const handleClearFloor = () => {
|
||||
resetFloorHP();
|
||||
};
|
||||
|
||||
const handleToggleSpireMode = () => {
|
||||
if (spireMode) {
|
||||
exitSpireMode();
|
||||
} else {
|
||||
enterSpireMode();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-teal-400 text-sm flex items-center gap-2">
|
||||
<Castle className="w-4 h-4" />
|
||||
Spire Debug
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-xs text-gray-400">
|
||||
Current Floor: {currentFloor} | Max Reached: {maxFloorReached} | Spire Mode: {spireMode ? 'ON' : 'OFF'}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-400 mb-1 block">Floor (1-100)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={floorInput}
|
||||
onChange={(e) => setFloorInput(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleJumpToFloor}>
|
||||
<ArrowUp className="w-3 h-3 mr-1" /> Jump
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={handleClearFloor}>
|
||||
Reset Floor HP
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={spireMode ? 'default' : 'outline'}
|
||||
onClick={handleToggleSpireMode}
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
{spireMode ? 'Exit Spire Mode' : 'Enter Spire Mode'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[10, 25, 50, 75, 100].map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFloorInput(String(f));
|
||||
debugSetFloor(f);
|
||||
setMaxFloorReached(f);
|
||||
}}
|
||||
>
|
||||
Floor {f}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
SpireDebugSection.displayName = "SpireDebugSection";
|
||||
@@ -1,206 +1,250 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
|
||||
import type { DisciplineDefinition } from '@/types/disciplines';
|
||||
import baseDisciplines from '../data/disciplines/base';
|
||||
import enchanterDisciplines from '../data/disciplines/enchanter';
|
||||
import fabricatorDisciplines from '../data/disciplines/fabricator';
|
||||
import invokerDisciplines from '../data/disciplines/invoker';
|
||||
import { calculateStatBonus, calculateManaDrain } from '@/lib/game/utils/discipline-math';
|
||||
import { useRef } from 'react';
|
||||
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
|
||||
import type { ManaType } from '@/lib/game/types/elements';
|
||||
import { ELEMENTS } from '@/lib/game/constants/elements';
|
||||
import { baseDisciplines } from '@/lib/game/data/disciplines/base';
|
||||
import { elementalAttunementDisciplines } from '@/lib/game/data/disciplines/elemental';
|
||||
import { elementalRegenDisciplines } from '@/lib/game/data/disciplines/elemental-regen';
|
||||
import { elementalRegenAdvancedDisciplines } from '@/lib/game/data/disciplines/elemental-regen-advanced';
|
||||
import { enchanterDisciplines } from '@/lib/game/data/disciplines/enchanter';
|
||||
import { fabricatorDisciplines } from '@/lib/game/data/disciplines/fabricator';
|
||||
import { invokerDisciplines } from '@/lib/game/data/disciplines/invoker';
|
||||
import { calculateStatBonus, calculateManaDrain, checkDisciplinePrerequisites } from '@/lib/game/utils/discipline-math';
|
||||
import { ALL_DISCIPLINES } from '@/lib/game/data/disciplines';
|
||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export const DisciplinesTab: React.FC = () => {
|
||||
const store = useDisciplineStore();
|
||||
const { disciplines, activeIds, concurrentLimit } = store;
|
||||
// ─── Attunement Tabs ─────────────────────────────────────────────────────────
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const allDisciplines: DisciplineDefinition[] = [
|
||||
...baseDisciplines,
|
||||
...enchanterDisciplines,
|
||||
...fabricatorDisciplines,
|
||||
...invokerDisciplines,
|
||||
];
|
||||
|
||||
// Group disciplines by attunement for tab rendering
|
||||
const attunementTabs: {
|
||||
interface AttunementTab {
|
||||
key: string;
|
||||
label: string;
|
||||
items: DisciplineDefinition[];
|
||||
}[] = [
|
||||
{ label: 'Base', items: baseDisciplines },
|
||||
{ label: 'Enchanter', items: enchanterDisciplines },
|
||||
{ label: 'Fabricator', items: fabricatorDisciplines },
|
||||
{ label: 'Invoker', items: invokerDisciplines },
|
||||
}
|
||||
|
||||
const ATTUNEMENT_TABS: AttunementTab[] = [
|
||||
{ key: 'base', label: 'Base', items: baseDisciplines },
|
||||
{ key: 'elements', label: 'Mana Types', items: elementalAttunementDisciplines },
|
||||
{ key: 'elemental-regen', label: 'Elemental Flow', items: elementalRegenDisciplines },
|
||||
{ key: 'elemental-regen-advanced', label: 'Advanced Flow', items: elementalRegenAdvancedDisciplines },
|
||||
{ key: 'enchanter', label: 'Enchanter', items: enchanterDisciplines },
|
||||
{ key: 'fabricator', label: 'Fabricator', items: fabricatorDisciplines },
|
||||
{ key: 'invoker', label: 'Invoker', items: invokerDisciplines },
|
||||
];
|
||||
|
||||
// Helper to render a single discipline card
|
||||
const DisciplineCard: React.FC<{
|
||||
// ─── Discipline Card Props (split from monolithic 15-field interface) ────────
|
||||
|
||||
export interface DisciplineCardDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
xp: number;
|
||||
manaType: ManaType;
|
||||
baseCost: number;
|
||||
perkThresholds?: number[];
|
||||
perkValues?: number[];
|
||||
perkTypes?: string[];
|
||||
perkDescriptions?: string[];
|
||||
statBonus: string;
|
||||
statBonusLabel: string;
|
||||
requires?: string[];
|
||||
baseValue: number;
|
||||
drainBase: number;
|
||||
difficultyFactor: number;
|
||||
scalingFactor: number;
|
||||
}> = ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
xp,
|
||||
perkThresholds,
|
||||
perkValues,
|
||||
perkTypes,
|
||||
statBonus,
|
||||
baseValue,
|
||||
drainBase,
|
||||
difficultyFactor,
|
||||
scalingFactor,
|
||||
}) => {
|
||||
if (!mounted) return null;
|
||||
|
||||
const state = useDisciplineStore().getState();
|
||||
const currentDisc = state.disciplines[id] ?? { xp: 0, paused: true };
|
||||
const isActive = activeIds.includes(id);
|
||||
const canActivate = concurrentLimit > activeIds.filter(a => state.disciplines[a]?.paused !== true).length;
|
||||
|
||||
// Calculate displayed stats
|
||||
const displayXp = currentDisc.xp;
|
||||
const progressPercent = Math.min(displayXp / Math.max(1, (concurrentLimit * 100) ?? 1), 100);
|
||||
const isPaused = currentDisc.paused;
|
||||
const hasPendingPerk = perkThresholds?.some((t, i) => displayXp >= t && perkTypes?.[i] !== 'capped');
|
||||
|
||||
const activeStatBonus = calculateStatBonus(
|
||||
parseInt(baseValue) || 0,
|
||||
displayXp,
|
||||
scalingFactor
|
||||
);
|
||||
|
||||
// Simple visual for drain per tick
|
||||
const estimatedDrain = calculateManaDrain(
|
||||
drainBase,
|
||||
displayXp,
|
||||
difficultyFactor
|
||||
);
|
||||
|
||||
// Determine unlocked perks
|
||||
const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => {
|
||||
if (typ === 'once' || typ === 'infinite') {
|
||||
if (displayXp >= perkThresholds?.[idx] ?? 0) {
|
||||
acc.push(`${typ}-${idx}`);
|
||||
sourceManaTypes?: ManaType[];
|
||||
conversionRate?: number;
|
||||
}
|
||||
|
||||
export interface DisciplineCardRuntime {
|
||||
xp: number;
|
||||
paused: boolean;
|
||||
concurrentLimit: number;
|
||||
isLocked: boolean;
|
||||
missingPrereqs: string[];
|
||||
missingSourceMana: string[];
|
||||
}
|
||||
|
||||
export interface DisciplineCardCallbacks {
|
||||
onToggle: (id: string, paused: boolean) => void;
|
||||
}
|
||||
|
||||
interface DisciplineCardProps {
|
||||
definition: DisciplineCardDefinition;
|
||||
runtime: DisciplineCardRuntime;
|
||||
callbacks: DisciplineCardCallbacks;
|
||||
}
|
||||
|
||||
// ─── Discipline Card Component ───────────────────────────────────────────────
|
||||
|
||||
const DisciplineCard: React.FC<DisciplineCardProps> = ({ definition, runtime, callbacks }) => {
|
||||
const {
|
||||
id, name, description, manaType, baseCost, perkThresholds, perkValues, perkTypes, perkDescriptions,
|
||||
statBonusLabel, baseValue, drainBase, difficultyFactor, scalingFactor,
|
||||
} = definition;
|
||||
const { xp, paused: isPaused, concurrentLimit, isLocked, missingPrereqs, missingSourceMana } = runtime;
|
||||
const { onToggle } = callbacks;
|
||||
|
||||
const displayXp = xp;
|
||||
const progressPercent = Math.min(displayXp / Math.max(1, concurrentLimit * 100), 100);
|
||||
|
||||
const activeStatBonus = calculateStatBonus(baseValue, displayXp, scalingFactor);
|
||||
const estimatedDrain = calculateManaDrain(drainBase, displayXp, difficultyFactor);
|
||||
|
||||
const elementDef = ELEMENTS[manaType];
|
||||
const manaColor = elementDef?.color ?? '#888888';
|
||||
const manaIcon = elementDef?.sym ?? '✦';
|
||||
const manaName = elementDef?.name ?? manaType;
|
||||
|
||||
const effectiveIsLocked = isLocked || missingSourceMana.length > 0;
|
||||
|
||||
const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => {
|
||||
const threshold = perkThresholds?.[idx];
|
||||
if (threshold === undefined) return acc;
|
||||
const desc = perkDescriptions?.[idx];
|
||||
if (typ === 'once' || typ === 'infinite') {
|
||||
if (displayXp >= threshold && desc) acc.push(desc);
|
||||
} else if (typ === 'capped') {
|
||||
const tier = Math.max(0, Math.floor((displayXp - perkThresholds?.[idx] ?? 0) / perkValues?.[idx] ?? 1) + 1);
|
||||
if (tier > 0) acc.push(`${typ}-${idx}`);
|
||||
const interval = perkValues?.[idx] ?? 1;
|
||||
const tier = Math.max(0, Math.floor((displayXp - threshold) / interval) + 1);
|
||||
if (tier > 0 && desc) acc.push(desc);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Helper to decide button action
|
||||
const toggleAction = () => {
|
||||
if (isPaused) {
|
||||
// Resume – activate
|
||||
const storeDispatch = useDisciplineStore().getState().activate as any;
|
||||
storeDispatch(id);
|
||||
} else {
|
||||
// Pause – deactivate
|
||||
const storeDispatch = useDisciplineStore().getState().deactivate as any;
|
||||
storeDispatch(id);
|
||||
}
|
||||
onToggle(id, isPaused);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={id} className="border rounded-lg p-4 shadow-sm space-y-3">
|
||||
<div key={id} className={clsx('border rounded-lg p-4 shadow-sm space-y-3', effectiveIsLocked && 'opacity-60 border-gray-600')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-lg font-medium">{name}</h3>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${manaColor}20`,
|
||||
borderColor: `${manaColor}60`,
|
||||
borderWidth: 1,
|
||||
color: manaColor,
|
||||
}}
|
||||
>
|
||||
<span>{manaIcon}</span>
|
||||
<span>{manaName}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{description}</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono whitespace-nowrap">{Math.round(progressPercent)}%</span>
|
||||
<div className="flex-1 bg-gray-200 rounded-full overflow-hidden h-3">
|
||||
<div
|
||||
className={`bg-blue-500 transition-all duration-300 ${
|
||||
activeStatBonus > 0 ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
className={`transition-all duration-300 ${activeStatBonus > 0 ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.round(progressPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
<strong>Drain:</strong> {estimatedDrain.toFixed(1)} ✦{' '}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-400">
|
||||
<span>
|
||||
<strong>Drain:</strong> {estimatedDrain.toFixed(1)}/tick
|
||||
</span>
|
||||
<span>
|
||||
<strong>Base Cost:</strong> {baseCost}
|
||||
</span>
|
||||
<span>
|
||||
<strong>XP:</strong> {displayXp}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bonus display */}
|
||||
{definition.conversionRate != null && definition.sourceManaTypes && (
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
<strong>Converts:</strong> {definition.sourceManaTypes.map(s => s === 'raw' ? 'raw' : ELEMENTS[s]?.name ?? s).join(' + ')} → {manaName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-sm">
|
||||
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonus}
|
||||
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonusLabel}
|
||||
</div>
|
||||
|
||||
{/* Perks */}
|
||||
<div className="mt-2">
|
||||
<strong>Perks:</strong>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
|
||||
{unlockedPerks?.map((p) => (
|
||||
<li key={p} className="text-green-500">{p.replace(/-([0-9]+)$/, ' $1')}</li>
|
||||
)) : (
|
||||
{unlockedPerks && unlockedPerks.length > 0 ? (
|
||||
unlockedPerks.map((p) => (
|
||||
<li key={p} className="text-green-500">{p}</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-gray-400">—locked—</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && (
|
||||
<div className="mt-2 text-xs text-red-400">
|
||||
<strong>Requires:</strong> {[...missingPrereqs, ...missingSourceMana].join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={toggleAction}
|
||||
disabled={effectiveIsLocked}
|
||||
className={clsx(
|
||||
'rounded px-3 py-1 text-sm font-medium',
|
||||
isPaused
|
||||
isLocked
|
||||
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||
: isPaused
|
||||
? 'bg-yellow-600 text-white hover:bg-yellow-500'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-500',
|
||||
)}
|
||||
>
|
||||
{isPaused ? 'Activate' : 'Pause'}
|
||||
{effectiveIsLocked ? 'Locked' : isPaused ? 'Start Practicing' : 'Stop Practicing'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
Loading disciplines…
|
||||
</div>
|
||||
);
|
||||
// ─── Disciplines Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
export const DisciplinesTab: React.FC = () => {
|
||||
const activeIds = useDisciplineStore((s) => s.activeIds);
|
||||
const concurrentLimit = useDisciplineStore((s) => s.concurrentLimit);
|
||||
const disciplines = useDisciplineStore((s) => s.disciplines);
|
||||
const activate = useDisciplineStore((s) => s.activate);
|
||||
const deactivate = useDisciplineStore((s) => s.deactivate);
|
||||
|
||||
const [activeAttunement, setActiveAttunement] = useState<string>('base');
|
||||
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const signedPacts = usePrestigeStore((s) => s.signedPacts);
|
||||
|
||||
const handleToggle = useCallback((id: string, paused: boolean) => {
|
||||
if (paused) {
|
||||
activate(id, { rawMana, elements, signedPacts });
|
||||
} else {
|
||||
deactivate(id);
|
||||
}
|
||||
}, [activate, deactivate, rawMana, elements, signedPacts]);
|
||||
|
||||
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{attunementTabs.map((tab) => {
|
||||
const isActiveTab = store
|
||||
.getState()
|
||||
.activeAttunement === tab.label.toLowerCase();
|
||||
{ATTUNEMENT_TABS.map((tab) => {
|
||||
const isActiveTab = activeAttunement === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.label}
|
||||
onClick={() =>
|
||||
// Here you could dispatch an action to switch tabs if needed
|
||||
// For simplicity, we just render the tabs
|
||||
console.log(`Switch to ${tab.label}`);
|
||||
}
|
||||
key={tab.key}
|
||||
onClick={() => setActiveAttunement(tab.key)}
|
||||
className={clsx('rounded px-3 py-1', {
|
||||
'bg-blue-600 text-white': tab.label === 'Base', // highlight first for demo
|
||||
'text-gray-600': tab.label !== 'Base',
|
||||
'bg-blue-600 text-white': isActiveTab,
|
||||
'text-gray-600': !isActiveTab,
|
||||
})}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -209,36 +253,52 @@ export const DisciplinesTab: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Discipline cards */}
|
||||
{/* Discipline cards — only render active tab */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{attunementTabs
|
||||
.map((tab) =>
|
||||
tab.items.map((disc) => {
|
||||
const state = useDisciplineStore().getState();
|
||||
const discState = state.disciplines[disc.id] ?? { xp: 0, paused: true };
|
||||
const isActive = activeIds.includes(disc.id);
|
||||
const isVisible = attunementTabs.find((t) => t.label === tab.label)?.items === tab.items;
|
||||
return isVisible && (
|
||||
{activeTab?.items.map((disc) => {
|
||||
const discState = disciplines[disc.id] ?? { xp: 0, paused: true };
|
||||
const prereqCheck = checkDisciplinePrerequisites(disc, disciplines, ALL_DISCIPLINES, elements);
|
||||
return (
|
||||
<DisciplineCard
|
||||
key={disc.id}
|
||||
id={disc.id}
|
||||
name={disc.name}
|
||||
description={disc.description}
|
||||
xp={discState.xp}
|
||||
perkThresholds={disc.perks?.map((p) => p.threshold)}
|
||||
perkValues={disc.perks?.map((p) => p.value)}
|
||||
perkTypes={disc.perks?.map((p) => p.type)}
|
||||
statBonus={disc.statBonus}
|
||||
baseValue={disc.statBonus.baseValue?.toString() ?? '0'}
|
||||
drainBase={disc.drainBase}
|
||||
difficultyFactor={disc.difficultyFactor}
|
||||
scalingFactor={disc.scalingFactor}
|
||||
definition={{
|
||||
id: disc.id,
|
||||
name: disc.name,
|
||||
description: disc.description,
|
||||
perkThresholds: disc.perks?.map((p) => p.threshold),
|
||||
perkValues: disc.perks?.map((p) => p.value),
|
||||
perkTypes: disc.perks?.map((p) => p.type),
|
||||
perkDescriptions: disc.perks?.map((p) => p.description),
|
||||
manaType: disc.manaType,
|
||||
baseCost: disc.baseCost,
|
||||
statBonus: disc.statBonus.stat,
|
||||
statBonusLabel: disc.statBonus.label,
|
||||
requires: disc.requires,
|
||||
sourceManaTypes: disc.sourceManaTypes,
|
||||
conversionRate: disc.conversionRate,
|
||||
baseValue: disc.statBonus.baseValue,
|
||||
drainBase: disc.drainBase,
|
||||
difficultyFactor: disc.difficultyFactor,
|
||||
scalingFactor: disc.scalingFactor,
|
||||
}}
|
||||
runtime={{
|
||||
xp: discState.xp,
|
||||
paused: discState.paused,
|
||||
concurrentLimit,
|
||||
isLocked: !prereqCheck.canProceed,
|
||||
missingPrereqs: prereqCheck.missingPrereqs,
|
||||
missingSourceMana: disc.sourceManaTypes
|
||||
? disc.sourceManaTypes.filter(
|
||||
(src) => src !== 'raw' && (!elements[src] || !elements[src].unlocked),
|
||||
).map((src) => `${src} mana`)
|
||||
: [],
|
||||
}}
|
||||
callbacks={{
|
||||
onToggle: handleToggle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)
|
||||
.flat()
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary info */}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ─── Test: EquipmentTab barrel export ──────────────────────────────────────────
|
||||
|
||||
describe('EquipmentTab module structure', () => {
|
||||
it('exports EquipmentTab from barrel index', async () => {
|
||||
const mod = await import('./EquipmentTab');
|
||||
expect(mod.EquipmentTab).toBeDefined();
|
||||
expect(typeof mod.EquipmentTab).toBe('function');
|
||||
});
|
||||
|
||||
it('EquipmentTab has correct displayName', async () => {
|
||||
const { EquipmentTab } = await import('./EquipmentTab');
|
||||
expect(EquipmentTab.displayName).toBe('EquipmentTab');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Barrel export includes EquipmentTab ─────────────────────────────────
|
||||
|
||||
describe('Tab barrel export', () => {
|
||||
it('includes EquipmentTab in the tabs index', async () => {
|
||||
const mod = await import('@/components/game/tabs');
|
||||
expect(mod.EquipmentTab).toBeDefined();
|
||||
expect(typeof mod.EquipmentTab).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Equipment slot definitions ──────────────────────────────────────────
|
||||
|
||||
describe('Equipment slot definitions', () => {
|
||||
it('has exactly 8 equipment slots', async () => {
|
||||
const { EQUIPMENT_SLOTS } = await import('@/lib/game/data/equipment');
|
||||
expect(EQUIPMENT_SLOTS).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('all slots have display names', async () => {
|
||||
const { SLOT_NAMES, EQUIPMENT_SLOTS } = await import('@/lib/game/data/equipment');
|
||||
for (const slot of EQUIPMENT_SLOTS) {
|
||||
expect(SLOT_NAMES[slot]).toBeDefined();
|
||||
expect(SLOT_NAMES[slot].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes mainHand, offHand, head, body, hands, feet, accessory1, accessory2', async () => {
|
||||
const { EQUIPMENT_SLOTS } = await import('@/lib/game/data/equipment');
|
||||
expect(EQUIPMENT_SLOTS).toContain('mainHand');
|
||||
expect(EQUIPMENT_SLOTS).toContain('offHand');
|
||||
expect(EQUIPMENT_SLOTS).toContain('head');
|
||||
expect(EQUIPMENT_SLOTS).toContain('body');
|
||||
expect(EQUIPMENT_SLOTS).toContain('hands');
|
||||
expect(EQUIPMENT_SLOTS).toContain('feet');
|
||||
expect(EQUIPMENT_SLOTS).toContain('accessory1');
|
||||
expect(EQUIPMENT_SLOTS).toContain('accessory2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Equipment type definitions ──────────────────────────────────────────
|
||||
|
||||
describe('Equipment type definitions', () => {
|
||||
it('all equipment types have required fields', async () => {
|
||||
const { EQUIPMENT_TYPES } = await import('@/lib/game/data/equipment');
|
||||
for (const [id, type] of Object.entries(EQUIPMENT_TYPES)) {
|
||||
expect(type.id).toBe(id);
|
||||
expect(type.name).toBeTruthy();
|
||||
expect(type.category).toBeTruthy();
|
||||
expect(type.slot).toBeTruthy();
|
||||
expect(type.baseCapacity).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('accessory category types can equip in accessory slots', async () => {
|
||||
const { EQUIPMENT_TYPES, getValidSlotsForEquipmentType } = await import('@/lib/game/data/equipment');
|
||||
const accessories = Object.values(EQUIPMENT_TYPES).filter((t) => t.category === 'accessory');
|
||||
expect(accessories.length).toBeGreaterThan(0);
|
||||
for (const acc of accessories) {
|
||||
const slots = getValidSlotsForEquipmentType(acc);
|
||||
expect(slots).toContain('accessory1');
|
||||
expect(slots).toContain('accessory2');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Starting equipment ──────────────────────────────────────────────────
|
||||
|
||||
describe('Starting equipment', () => {
|
||||
it('crafting store initial state has valid equippedInstances', async () => {
|
||||
const { useCraftingStore } = await import('@/lib/game/stores/craftingStore');
|
||||
const state = useCraftingStore.getState();
|
||||
|
||||
expect(state.equippedInstances.mainHand).toBeTruthy();
|
||||
expect(state.equippedInstances.body).toBeTruthy();
|
||||
expect(state.equippedInstances.feet).toBeTruthy();
|
||||
expect(state.equippedInstances.offHand).toBeNull();
|
||||
expect(state.equippedInstances.head).toBeNull();
|
||||
expect(state.equippedInstances.hands).toBeNull();
|
||||
expect(state.equippedInstances.accessory1).toBeNull();
|
||||
expect(state.equippedInstances.accessory2).toBeNull();
|
||||
|
||||
expect(Object.keys(state.equipmentInstances).length).toBe(3);
|
||||
});
|
||||
|
||||
it('starting equipment instances have valid fields', async () => {
|
||||
const { useCraftingStore } = await import('@/lib/game/stores/craftingStore');
|
||||
const { equipmentInstances } = useCraftingStore.getState();
|
||||
|
||||
for (const instance of Object.values(equipmentInstances)) {
|
||||
expect(instance.instanceId).toBeTruthy();
|
||||
expect(instance.typeId).toBeTruthy();
|
||||
expect(instance.name).toBeTruthy();
|
||||
expect(instance.rarity).toBe('common');
|
||||
expect(instance.quality).toBe(100);
|
||||
expect(instance.usedCapacity).toBeGreaterThanOrEqual(0);
|
||||
expect(instance.totalCapacity).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Equipment actions ───────────────────────────────────────────────────
|
||||
|
||||
describe('Equipment actions', () => {
|
||||
it('equipItem is a function', async () => {
|
||||
const { equipItem } = await import('@/lib/game/crafting-actions/equipment-actions');
|
||||
expect(typeof equipItem).toBe('function');
|
||||
});
|
||||
|
||||
it('unequipItem is a function', async () => {
|
||||
const { unequipItem } = await import('@/lib/game/crafting-actions/equipment-actions');
|
||||
expect(typeof unequipItem).toBe('function');
|
||||
});
|
||||
|
||||
it('deleteEquipmentInstance is a function', async () => {
|
||||
const { deleteEquipmentInstance } = await import('@/lib/game/crafting-actions/equipment-actions');
|
||||
expect(typeof deleteEquipmentInstance).toBe('function');
|
||||
});
|
||||
|
||||
it('createEquipmentInstance is a function', async () => {
|
||||
const { createEquipmentInstance } = await import('@/lib/game/crafting-actions/equipment-actions');
|
||||
expect(typeof createEquipmentInstance).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Equipment effects ───────────────────────────────────────────────────
|
||||
|
||||
describe('Equipment effects computation', () => {
|
||||
it('computeEquipmentEffects returns empty for no equipment', async () => {
|
||||
const { computeEquipmentEffects } = await import('@/lib/game/effects');
|
||||
const result = computeEquipmentEffects({}, { mainHand: null, offHand: null, head: null, body: null, hands: null, feet: null, accessory1: null, accessory2: null });
|
||||
expect(Object.keys(result.bonuses)).toHaveLength(0);
|
||||
expect(Object.keys(result.multipliers)).toHaveLength(0);
|
||||
expect(result.specials.size).toBe(0);
|
||||
});
|
||||
|
||||
it('computeEquipmentEffects detects enchantment bonuses', async () => {
|
||||
const { computeEquipmentEffects } = await import('@/lib/game/effects');
|
||||
const instances = {
|
||||
test1: {
|
||||
instanceId: 'test1',
|
||||
typeId: 'basicStaff',
|
||||
name: 'Test Staff',
|
||||
enchantments: [{ effectId: 'spell_manaBolt', stacks: 1, actualCost: 50 }],
|
||||
usedCapacity: 50,
|
||||
totalCapacity: 50,
|
||||
rarity: 'common' as const,
|
||||
quality: 100,
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
const equipped = { mainHand: 'test1', offHand: null, head: null, body: null, hands: null, feet: null, accessory1: null, accessory2: null };
|
||||
const result = computeEquipmentEffects(instances, equipped);
|
||||
// Should at least not crash and return a valid structure
|
||||
expect(result).toHaveProperty('bonuses');
|
||||
expect(result).toHaveProperty('multipliers');
|
||||
expect(result).toHaveProperty('specials');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: File size limits ────────────────────────────────────────────────────
|
||||
|
||||
describe('File size limits (400 lines max)', () => {
|
||||
it('EquipmentTab.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'EquipmentTab.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it('EquipmentSlotGrid.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'EquipmentTab/EquipmentSlotGrid.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it('InventoryList.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'EquipmentTab/InventoryList.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it('EquipmentEffectsSummary.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'EquipmentTab/EquipmentEffectsSummary.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCraftingStore } from '@/lib/game/stores/craftingStore';
|
||||
import type { EquipmentSlot } from '@/lib/game/types';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { EquipmentSlotGrid } from './EquipmentTab/EquipmentSlotGrid';
|
||||
import { InventoryList } from './EquipmentTab/InventoryList';
|
||||
import { EquipmentEffectsSummary } from './EquipmentTab/EquipmentEffectsSummary';
|
||||
|
||||
export function EquipmentTab() {
|
||||
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
const storeEquipItem = useCraftingStore((s) => s.equipItem);
|
||||
const storeUnequipItem = useCraftingStore((s) => s.unequipItem);
|
||||
const storeDeleteEquipment = useCraftingStore((s) => s.deleteEquipmentInstance);
|
||||
|
||||
const handleEquip = useCallback(
|
||||
(instanceId: string, slot: EquipmentSlot): boolean => {
|
||||
return storeEquipItem(instanceId, slot);
|
||||
},
|
||||
[storeEquipItem]
|
||||
);
|
||||
|
||||
const handleUnequip = useCallback(
|
||||
(slot: EquipmentSlot) => {
|
||||
storeUnequipItem(slot);
|
||||
},
|
||||
[storeUnequipItem]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(instanceId: string) => {
|
||||
storeDeleteEquipment(instanceId);
|
||||
},
|
||||
[storeDeleteEquipment]
|
||||
);
|
||||
|
||||
const inventoryItems = useMemo(
|
||||
() =>
|
||||
Object.entries(equipmentInstances).filter(
|
||||
([id]) => !Object.values(equippedInstances).includes(id)
|
||||
),
|
||||
[equipmentInstances, equippedInstances]
|
||||
);
|
||||
|
||||
return (
|
||||
<DebugName name="EquipmentTab">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)] mb-3">Equipped Gear</h2>
|
||||
<EquipmentSlotGrid
|
||||
equippedInstances={equippedInstances}
|
||||
equipmentInstances={equipmentInstances}
|
||||
onUnequip={handleUnequip}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EquipmentEffectsSummary
|
||||
equipmentInstances={equipmentInstances}
|
||||
equippedInstances={equippedInstances}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)] mb-3">
|
||||
Inventory ({inventoryItems.length})
|
||||
</h2>
|
||||
<InventoryList
|
||||
inventoryItems={inventoryItems}
|
||||
equippedInstances={equippedInstances}
|
||||
onEquip={handleEquip}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
EquipmentTab.displayName = 'EquipmentTab';
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { computeEquipmentEffects } from '@/lib/game/effects';
|
||||
import type { EquipmentInstance } from '@/lib/game/types';
|
||||
|
||||
interface EquipmentEffectsSummaryProps {
|
||||
equipmentInstances: Record<string, EquipmentInstance>;
|
||||
equippedInstances: Record<string, string | null>;
|
||||
}
|
||||
|
||||
const BONUS_LABELS: Record<string, string> = {
|
||||
maxMana: 'Max Mana',
|
||||
regen: 'Mana Regen',
|
||||
clickMana: 'Click Mana',
|
||||
baseDamage: 'Base Damage',
|
||||
elementCap: 'Element Cap',
|
||||
critChance: 'Crit Chance',
|
||||
attackSpeed: 'Attack Speed',
|
||||
meditationEfficiency: 'Meditation Efficiency',
|
||||
studySpeed: 'Study Speed',
|
||||
};
|
||||
|
||||
const MULT_LABELS: Record<string, string> = {
|
||||
maxMana: 'Max Mana',
|
||||
regen: 'Mana Regen',
|
||||
clickMana: 'Click Mana',
|
||||
baseDamage: 'Base Damage',
|
||||
attackSpeed: 'Attack Speed',
|
||||
elementCap: 'Element Cap',
|
||||
meditationEfficiency: 'Meditation Efficiency',
|
||||
studySpeed: 'Study Speed',
|
||||
};
|
||||
|
||||
export function EquipmentEffectsSummary({ equipmentInstances, equippedInstances }: EquipmentEffectsSummaryProps) {
|
||||
const { bonuses, multipliers, specials } = computeEquipmentEffects(equipmentInstances, equippedInstances);
|
||||
|
||||
const bonusEntries = Object.entries(bonuses).filter(([, v]) => v !== 0);
|
||||
const multEntries = Object.entries(multipliers).filter(([, v]) => v !== 1);
|
||||
const specialEntries = Array.from(specials);
|
||||
|
||||
if (bonusEntries.length === 0 && multEntries.length === 0 && specialEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-[var(--border-default)] bg-[var(--bg-sunken)] space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Equipment Effects</h3>
|
||||
|
||||
{bonusEntries.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-[var(--text-muted)]">Bonuses</div>
|
||||
{bonusEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-xs">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{BONUS_LABELS[key] || key}
|
||||
</span>
|
||||
<span className="text-[var(--color-success)]">
|
||||
{value > 0 ? '+' : ''}{typeof value === 'number' && !Number.isInteger(value) ? value.toFixed(2) : value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{multEntries.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-[var(--text-muted)]">Multipliers</div>
|
||||
{multEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-xs">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{MULT_LABELS[key] || key}
|
||||
</span>
|
||||
<span className="text-[var(--color-info)]">
|
||||
×{typeof value === 'number' ? value.toFixed(2) : value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{specialEntries.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-[var(--text-muted)]">Specials</div>
|
||||
{specialEntries.map((id) => (
|
||||
<div key={id} className="text-xs text-[var(--color-warning)]">
|
||||
★ {id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { Package } from 'lucide-react';
|
||||
import type { EquipmentInstance, EquipmentSlot } from '@/lib/game/types';
|
||||
import { EQUIPMENT_TYPES, SLOT_NAMES } from '@/lib/game/data/equipment';
|
||||
import { RARITY_CSS_VAR } from '@/components/game/LootInventory/types';
|
||||
import { CATEGORY_ICONS } from '@/components/game/LootInventory/icons';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
|
||||
interface EquipmentSlotGridProps {
|
||||
equippedInstances: Record<string, string | null>;
|
||||
equipmentInstances: Record<string, EquipmentInstance>;
|
||||
onUnequip: (slot: EquipmentSlot) => void;
|
||||
}
|
||||
|
||||
const SLOTS: EquipmentSlot[] = ['mainHand', 'offHand', 'head', 'body', 'hands', 'feet', 'accessory1', 'accessory2'];
|
||||
|
||||
export function EquipmentSlotGrid({ equippedInstances, equipmentInstances, onUnequip }: EquipmentSlotGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{SLOTS.map((slot) => {
|
||||
const instanceId = equippedInstances[slot];
|
||||
const instance = instanceId ? equipmentInstances[instanceId] : null;
|
||||
|
||||
if (instance) {
|
||||
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot}
|
||||
className="p-3 rounded border bg-[var(--bg-sunken)] space-y-2"
|
||||
style={{ borderColor: rarityColor }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[var(--text-muted)]">{SLOT_NAMES[slot]}</span>
|
||||
<Icon className="w-4 h-4" style={{ color: rarityColor }} />
|
||||
</div>
|
||||
<div className="text-sm font-semibold truncate" style={{ color: rarityColor }}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{type?.name || instance.typeId}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{instance.usedCapacity}/{instance.totalCapacity} cap
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{instance.enchantments.length} enchant{instance.enchantments.length !== 1 ? 's' : ''} • {instance.quality}% quality
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => onUnequip(slot)}
|
||||
>
|
||||
Unequip
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot}
|
||||
className="p-3 rounded border border-dashed border-[var(--border-default)] bg-[var(--bg-sunken)] space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[var(--text-muted)]">{SLOT_NAMES[slot]}</span>
|
||||
<Package className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-muted)] italic">Empty</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Package, Trash2 } from 'lucide-react';
|
||||
import type { EquipmentInstance, EquipmentSlot } from '@/lib/game/types';
|
||||
import { EQUIPMENT_TYPES, getValidSlotsForEquipmentType } from '@/lib/game/data/equipment';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from '@/components/game/LootInventory/types';
|
||||
import { CATEGORY_ICONS } from '@/components/game/LootInventory/icons';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface InventoryListProps {
|
||||
inventoryItems: [string, EquipmentInstance][];
|
||||
equippedInstances: Record<string, string | null>;
|
||||
onEquip: (instanceId: string, slot: EquipmentSlot) => boolean;
|
||||
onDelete: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function InventoryList({ inventoryItems, equippedInstances, onEquip, onDelete }: InventoryListProps) {
|
||||
const [selectedSlot, setSelectedSlot] = useState<Record<string, EquipmentSlot>>({});
|
||||
|
||||
if (inventoryItems.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-muted)] italic text-center py-4">
|
||||
No items in inventory. Craft or find equipment to fill your slots.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{inventoryItems.map(([instanceId, instance]) => {
|
||||
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[instance.rarity] || 'var(--rarity-common-glow)';
|
||||
const validSlots = type ? getValidSlotsForEquipmentType(type) : [];
|
||||
const chosenSlot = selectedSlot[instanceId];
|
||||
const availableSlots = validSlots.filter((s) => !equippedInstances[s]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={instanceId}
|
||||
className="p-3 rounded border bg-[var(--bg-sunken)] group flex items-center gap-3"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<Icon className="w-5 h-5 shrink-0" style={{ color: rarityColor }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate" style={{ color: rarityColor }}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{type?.name || instance.typeId} • {instance.usedCapacity}/{instance.totalCapacity} cap
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{instance.rarity} • {instance.enchantments.length} enchant{instance.enchantments.length !== 1 ? 's' : ''} • {instance.quality}% quality
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{availableSlots.length > 1 ? (
|
||||
<select
|
||||
className="text-xs bg-[var(--bg-base)] border border-[var(--border-default)] rounded px-2 py-1 text-[var(--text-primary)]"
|
||||
value={chosenSlot || ''}
|
||||
onChange={(e) => setSelectedSlot((prev) => ({ ...prev, [instanceId]: e.target.value as EquipmentSlot }))}
|
||||
>
|
||||
<option value="">Select slot</option>
|
||||
{availableSlots.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
const slot = availableSlots.length === 1 ? availableSlots[0] : chosenSlot;
|
||||
if (slot) {
|
||||
onEquip(instanceId, slot);
|
||||
setSelectedSlot((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[instanceId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={availableSlots.length === 0 || (availableSlots.length > 1 && !chosenSlot)}
|
||||
>
|
||||
Equip
|
||||
</ActionButton>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</ActionButton>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {instance.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. The item will be permanently destroyed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => onDelete(instanceId)}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ─── Test: GolemancyTab barrel export ─────────────────────────────────────────
|
||||
|
||||
describe('GolemancyTab module structure', () => {
|
||||
it('exports GolemancyTab from barrel index', async () => {
|
||||
const mod = await import('./GolemancyTab');
|
||||
expect(mod.GolemancyTab).toBeDefined();
|
||||
expect(typeof mod.GolemancyTab).toBe('function');
|
||||
});
|
||||
|
||||
it('GolemancyTab has correct displayName', async () => {
|
||||
const { GolemancyTab } = await import('./GolemancyTab');
|
||||
expect(GolemancyTab.displayName).toBe('GolemancyTab');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Barrel export includes GolemancyTab ────────────────────────────────
|
||||
|
||||
describe('Tab barrel export', () => {
|
||||
it('includes GolemancyTab in the tabs index', async () => {
|
||||
const mod = await import('@/components/game/tabs');
|
||||
expect(mod.GolemancyTab).toBeDefined();
|
||||
expect(typeof mod.GolemancyTab).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Golem data integrity ───────────────────────────────────────────────
|
||||
|
||||
describe('Golem data', () => {
|
||||
it('all golems have required fields', async () => {
|
||||
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
|
||||
for (const [id, def] of Object.entries(GOLEMS_DEF)) {
|
||||
expect(def.id).toBe(id);
|
||||
expect(def.name).toBeTruthy();
|
||||
expect(def.description).toBeTruthy();
|
||||
expect(def.baseManaType).toBeTruthy();
|
||||
expect(def.summonCost.length).toBeGreaterThan(0);
|
||||
expect(def.maintenanceCost.length).toBeGreaterThan(0);
|
||||
expect(def.damage).toBeGreaterThan(0);
|
||||
expect(def.attackSpeed).toBeGreaterThan(0);
|
||||
expect(def.hp).toBeGreaterThan(0);
|
||||
expect(def.armorPierce).toBeGreaterThanOrEqual(0);
|
||||
expect(def.tier).toBeGreaterThanOrEqual(1);
|
||||
expect(def.unlockCondition).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('has golems across multiple tiers', async () => {
|
||||
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
|
||||
const tiers = new Set(Object.values(GOLEMS_DEF).map(g => g.tier));
|
||||
expect(tiers.size).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('earthGolem is the only base tier golem', async () => {
|
||||
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
|
||||
const baseGolems = Object.values(GOLEMS_DEF).filter(g => g.tier === 1);
|
||||
expect(baseGolems.length).toBe(1);
|
||||
expect(baseGolems[0].id).toBe('earthGolem');
|
||||
});
|
||||
|
||||
it('voidstoneGolem is the highest tier', async () => {
|
||||
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
|
||||
const voidstone = GOLEMS_DEF.voidstoneGolem;
|
||||
expect(voidstone).toBeDefined();
|
||||
expect(voidstone.tier).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Golem utility functions ────────────────────────────────────────────
|
||||
|
||||
describe('Golem utility functions', () => {
|
||||
it('getGolemSlots returns 0 for fabricator level < 2', async () => {
|
||||
const { getGolemSlots } = await import('@/lib/game/data/golems');
|
||||
expect(getGolemSlots(0)).toBe(0);
|
||||
expect(getGolemSlots(1)).toBe(0);
|
||||
});
|
||||
|
||||
it('getGolemSlots scales with fabricator level', async () => {
|
||||
const { getGolemSlots } = await import('@/lib/game/data/golems');
|
||||
expect(getGolemSlots(2)).toBe(1);
|
||||
expect(getGolemSlots(4)).toBe(2);
|
||||
expect(getGolemSlots(10)).toBe(5);
|
||||
});
|
||||
|
||||
it('isGolemUnlocked returns false for unknown golem', async () => {
|
||||
const { isGolemUnlocked } = await import('@/lib/game/data/golems');
|
||||
expect(isGolemUnlocked('nonexistent', {}, [])).toBe(false);
|
||||
});
|
||||
|
||||
it('isGolemUnlocked checks attunement level', async () => {
|
||||
const { isGolemUnlocked } = await import('@/lib/game/data/golems');
|
||||
expect(isGolemUnlocked('earthGolem', { fabricator: { active: true, level: 1 } }, [])).toBe(false);
|
||||
expect(isGolemUnlocked('earthGolem', { fabricator: { active: true, level: 2 } }, [])).toBe(true);
|
||||
});
|
||||
|
||||
it('isGolemUnlocked checks mana unlocked', async () => {
|
||||
const { isGolemUnlocked } = await import('@/lib/game/data/golems');
|
||||
expect(isGolemUnlocked('steelGolem', {}, [])).toBe(false);
|
||||
expect(isGolemUnlocked('steelGolem', {}, ['metal'])).toBe(true);
|
||||
});
|
||||
|
||||
it('canAffordGolemSummon returns false for unknown golem', async () => {
|
||||
const { canAffordGolemSummon } = await import('@/lib/game/data/golems');
|
||||
expect(canAffordGolemSummon('nonexistent', 100, {})).toBe(false);
|
||||
});
|
||||
|
||||
it('canAffordGolemSummon checks raw mana cost', async () => {
|
||||
const { canAffordGolemSummon } = await import('@/lib/game/data/golems');
|
||||
// earthGolem costs 10 earth
|
||||
const elements = { earth: { current: 5, max: 100, unlocked: true } };
|
||||
expect(canAffordGolemSummon('earthGolem', 0, elements)).toBe(false);
|
||||
elements.earth.current = 10;
|
||||
expect(canAffordGolemSummon('earthGolem', 0, elements)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Combat store golemancy state ───────────────────────────────────────
|
||||
|
||||
describe('Combat store golemancy', () => {
|
||||
it('toggleGolem is a function', async () => {
|
||||
const { useCombatStore } = await import('@/lib/game/stores/combatStore');
|
||||
const state = useCombatStore.getState();
|
||||
expect(typeof state.toggleGolem).toBe('function');
|
||||
});
|
||||
|
||||
it('golemancy state has correct shape', async () => {
|
||||
const { useCombatStore } = await import('@/lib/game/stores/combatStore');
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.golemancy).toBeDefined();
|
||||
expect(Array.isArray(state.golemancy.enabledGolems)).toBe(true);
|
||||
expect(Array.isArray(state.golemancy.summonedGolems)).toBe(true);
|
||||
expect(typeof state.golemancy.lastSummonFloor).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: File size limit ────────────────────────────────────────────────────
|
||||
|
||||
describe('File size limits (400 lines max)', () => {
|
||||
it('GolemancyTab.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'GolemancyTab.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
||||
import { useAttunementStore } from '@/lib/game/stores/attunementStore';
|
||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||
import { GOLEMS_DEF, isGolemUnlocked, canAffordGolemSummon, getGolemSlots } from '@/lib/game/data/golems';
|
||||
import type { GolemDef } from '@/lib/game/data/golems';
|
||||
import { ELEMENTS } from '@/lib/game/constants/elements';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── Tier configuration ──────────────────────────────────────────────────────
|
||||
|
||||
interface TierConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
const TIERS: TierConfig[] = [
|
||||
{ key: 'base', label: 'Base', tier: 1 },
|
||||
{ key: 'elemental', label: 'Elemental', tier: 2 },
|
||||
{ key: 'hybrid', label: 'Hybrid', tier: 3 },
|
||||
];
|
||||
|
||||
function getTierLabel(tier: number): string {
|
||||
if (tier <= 1) return 'Base';
|
||||
if (tier <= 2) return 'Elemental';
|
||||
return 'Hybrid';
|
||||
}
|
||||
|
||||
function getTierColor(tier: number): string {
|
||||
if (tier <= 1) return 'bg-gray-600';
|
||||
if (tier <= 2) return 'bg-blue-600';
|
||||
if (tier <= 3) return 'bg-purple-600';
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatCost(cost: GolemDef['summonCost'][number]): string {
|
||||
if (cost.type === 'raw') return `${cost.amount} raw`;
|
||||
const elem = cost.element ? ELEMENTS[cost.element] : null;
|
||||
return `${cost.amount} ${elem?.sym ?? ''} ${cost.element ?? ''}`.trim();
|
||||
}
|
||||
|
||||
function formatUnlockCondition(golem: GolemDef): string {
|
||||
const cond = golem.unlockCondition;
|
||||
switch (cond.type) {
|
||||
case 'attunement_level':
|
||||
return `${cond.attunement} level ${cond.level}`;
|
||||
case 'mana_unlocked': {
|
||||
const elem = cond.manaType ? ELEMENTS[cond.manaType] : null;
|
||||
return `Unlock ${elem?.sym ?? ''} ${cond.manaType ?? ''}`.trim();
|
||||
}
|
||||
case 'dual_attunement':
|
||||
return `${cond.attunements?.join(' + ')} level ${cond.levels?.join('/')}`;
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Golem Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface GolemCardProps {
|
||||
golem: GolemDef;
|
||||
unlocked: boolean;
|
||||
enabled: boolean;
|
||||
summoned: boolean;
|
||||
canAfford: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
const GolemCard: React.FC<GolemCardProps> = React.memo(({
|
||||
golem,
|
||||
unlocked,
|
||||
enabled,
|
||||
summoned,
|
||||
canAfford,
|
||||
onToggle,
|
||||
}) => {
|
||||
const elemColor = ELEMENTS[golem.baseManaType]?.color ?? '#888';
|
||||
const elemSym = ELEMENTS[golem.baseManaType]?.sym ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-lg border p-4 space-y-3 transition-colors',
|
||||
unlocked
|
||||
? 'bg-gray-800/50 border-gray-600'
|
||||
: 'bg-gray-900/50 border-gray-800 opacity-60',
|
||||
summoned && 'ring-1 ring-green-500/50',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-100 truncate">{golem.name}</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{golem.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: elemColor }}
|
||||
title={golem.baseManaType}
|
||||
>
|
||||
{elemSym}
|
||||
</span>
|
||||
<Badge className={clsx('text-[10px] px-1.5 py-0', getTierColor(golem.tier))}>
|
||||
T{golem.tier}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<div className="text-gray-400">
|
||||
<span className="text-gray-500">Damage:</span> {golem.damage}
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
<span className="text-gray-500">Attack Speed:</span> {golem.attackSpeed}/h
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
<span className="text-gray-500">HP:</span> {golem.hp}
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
<span className="text-gray-500">Armor Pierce:</span> {Math.round(golem.armorPierce * 100)}%
|
||||
</div>
|
||||
{golem.isAoe && (
|
||||
<div className="col-span-2 text-gray-400">
|
||||
<span className="text-gray-500">AoE:</span> {golem.aoeTargets} targets
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Special Abilities */}
|
||||
{golem.specialAbilities && golem.specialAbilities.length > 0 && (
|
||||
<div className="text-xs space-y-0.5">
|
||||
<span className="text-gray-500">Special:</span>
|
||||
{golem.specialAbilities.map((ability, i) => (
|
||||
<div key={i} className="text-gray-400 pl-2">
|
||||
• {ability.name}: {ability.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Costs */}
|
||||
<div className="text-xs space-y-0.5">
|
||||
<div className="text-gray-400">
|
||||
<span className="text-gray-500">Summon:</span>{' '}
|
||||
{golem.summonCost.map((c, i) => (
|
||||
<span key={i}>{formatCost(c)}{i < golem.summonCost.length - 1 ? ' + ' : ''}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
<span className="text-gray-500">Upkeep:</span>{' '}
|
||||
{golem.maintenanceCost.map((c, i) => (
|
||||
<span key={i}>{formatCost(c)}{i < golem.maintenanceCost.length - 1 ? ' + ' : ''}</span>
|
||||
))}/tick
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unlock requirement */}
|
||||
{!unlocked && (
|
||||
<div className="text-xs text-red-400">
|
||||
🔒 Requires: {formatUnlockCondition(golem)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status + toggle */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="text-xs">
|
||||
{summoned ? (
|
||||
<span className="text-green-400">● Summoned</span>
|
||||
) : enabled ? (
|
||||
<span className="text-yellow-400">○ Queued</span>
|
||||
) : (
|
||||
<span className="text-gray-500">— Idle</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onToggle(golem.id)}
|
||||
disabled={!unlocked}
|
||||
className={clsx(
|
||||
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||
!unlocked
|
||||
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||
: enabled
|
||||
? 'bg-red-600/80 text-white hover:bg-red-500'
|
||||
: canAfford
|
||||
? 'bg-green-600/80 text-white hover:bg-green-500'
|
||||
: 'bg-blue-600/80 text-white hover:bg-blue-500',
|
||||
)}
|
||||
>
|
||||
{!unlocked ? 'Locked' : enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
GolemCard.displayName = 'GolemCard';
|
||||
|
||||
// ─── Main Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GolemancyTab: React.FC = () => {
|
||||
const [activeTier, setActiveTier] = useState<string>('base');
|
||||
|
||||
const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({
|
||||
golemancy: s.golemancy,
|
||||
toggleGolem: s.toggleGolem,
|
||||
})));
|
||||
const attunements = useAttunementStore(s => s.attunements);
|
||||
const { rawMana, elements } = useManaStore(useShallow(s => ({
|
||||
rawMana: s.rawMana,
|
||||
elements: s.elements,
|
||||
})));
|
||||
|
||||
// Build attunement lookup for isGolemUnlocked
|
||||
const attunementLookup = useMemo(() => {
|
||||
const lookup: Record<string, { active: boolean; level: number }> = {};
|
||||
for (const [id, att] of Object.entries(attunements)) {
|
||||
lookup[id] = { active: att.active, level: att.level };
|
||||
}
|
||||
return lookup;
|
||||
}, [attunements]);
|
||||
|
||||
const unlockedElements = useMemo(
|
||||
() => Object.entries(elements).filter(([, e]) => e.unlocked).map(([k]) => k),
|
||||
[elements],
|
||||
);
|
||||
|
||||
// Group golems by tier
|
||||
const golemsByTier = useMemo(() => {
|
||||
const groups: Record<string, GolemDef[]> = { base: [], elemental: [], hybrid: [] };
|
||||
for (const golem of Object.values(GOLEMS_DEF)) {
|
||||
const label = getTierLabel(golem.tier);
|
||||
const key = label.toLowerCase();
|
||||
if (groups[key]) {
|
||||
groups[key].push(golem);
|
||||
} else {
|
||||
// tier 4 golems go into hybrid
|
||||
groups.hybrid.push(golem);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback((id: string) => {
|
||||
toggleGolem(id);
|
||||
}, [toggleGolem]);
|
||||
|
||||
// Golem slot info
|
||||
const fabricatorLevel = attunements.fabricator?.level ?? 0;
|
||||
const golemSlots = getGolemSlots(fabricatorLevel);
|
||||
const enabledCount = golemancy.enabledGolems.length;
|
||||
|
||||
const activeTierGolems = golemsByTier[activeTier] ?? [];
|
||||
|
||||
return (
|
||||
<DebugName name="GolemancyTab">
|
||||
<div className="space-y-4">
|
||||
{/* Header info */}
|
||||
<div className="text-sm text-gray-400 space-y-1">
|
||||
<p>
|
||||
Configure your golem loadout. Enabled golems are automatically summoned
|
||||
when entering the spire if you can afford the cost.
|
||||
</p>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span>
|
||||
Slots: {enabledCount}/{golemSlots > 0 ? golemSlots : '—'}
|
||||
</span>
|
||||
<span>
|
||||
Summoned: {golemancy.summonedGolems.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier tabs */}
|
||||
<div className="flex gap-2">
|
||||
{TIERS.map((tier) => {
|
||||
const count = golemsByTier[tier.key]?.length ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={tier.key}
|
||||
onClick={() => setActiveTier(tier.key)}
|
||||
className={clsx(
|
||||
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||
activeTier === tier.key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:text-gray-200',
|
||||
)}
|
||||
>
|
||||
{tier.label} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Golem cards */}
|
||||
<ScrollArea className="h-[500px] rounded border border-gray-700 p-3">
|
||||
{activeTierGolems.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
No golems in this tier.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{activeTierGolems.map((golem) => {
|
||||
const unlocked = isGolemUnlocked(golem.id, attunementLookup, unlockedElements);
|
||||
const enabled = golemancy.enabledGolems.includes(golem.id);
|
||||
const summoned = golemancy.summonedGolems.some(g => g.golemId === golem.id);
|
||||
const canAfford = canAffordGolemSummon(golem.id, rawMana, elements);
|
||||
return (
|
||||
<GolemCard
|
||||
key={golem.id}
|
||||
golem={golem}
|
||||
unlocked={unlocked}
|
||||
enabled={enabled}
|
||||
summoned={summoned}
|
||||
canAfford={canAfford}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
};
|
||||
|
||||
GolemancyTab.displayName = 'GolemancyTab';
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ─── Test: GuardianPactsTab barrel export ──────────────────────────────────────
|
||||
|
||||
describe('GuardianPactsTab module structure', () => {
|
||||
it('exports GuardianPactsTab from barrel index', async () => {
|
||||
const mod = await import('./GuardianPactsTab');
|
||||
expect(mod.GuardianPactsTab).toBeDefined();
|
||||
expect(typeof mod.GuardianPactsTab).toBe('function');
|
||||
});
|
||||
|
||||
it('GuardianPactsTab has correct displayName', async () => {
|
||||
const { GuardianPactsTab } = await import('./GuardianPactsTab');
|
||||
expect(GuardianPactsTab.displayName).toBe('GuardianPactsTab');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Barrel export includes GuardianPactsTab ─────────────────────────────
|
||||
|
||||
describe('Tab barrel export', () => {
|
||||
it('includes GuardianPactsTab in the tabs index', async () => {
|
||||
const mod = await import('@/components/game/tabs');
|
||||
expect(mod.GuardianPactsTab).toBeDefined();
|
||||
expect(typeof mod.GuardianPactsTab).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Guardian data integrity ─────────────────────────────────────────────
|
||||
|
||||
describe('Guardian data', () => {
|
||||
it('all guardians have required fields', async () => {
|
||||
const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters');
|
||||
for (const floor of [10, 20, 30, 40, 50, 60, 80, 90, 100]) {
|
||||
const def = getGuardianForFloor(floor)!;
|
||||
expect(def.name).toBeTruthy();
|
||||
expect(def.element).toBeTruthy();
|
||||
expect(def.hp).toBeGreaterThan(0);
|
||||
expect(def.power).toBeGreaterThan(0);
|
||||
expect(def.boons.length).toBeGreaterThan(0);
|
||||
expect(def.pactCost).toBeGreaterThan(0);
|
||||
expect(def.pactTime).toBeGreaterThan(0);
|
||||
expect(def.uniquePerk).toBeTruthy();
|
||||
expect(def.signingCost).toBeTruthy();
|
||||
expect(def.signingCost.mana).toBeGreaterThan(0);
|
||||
expect(def.signingCost.time).toBeGreaterThan(0);
|
||||
expect(def.unlocksMana.length).toBeGreaterThan(0);
|
||||
expect(def.damageMultiplier).toBeGreaterThan(0);
|
||||
expect(def.insightMultiplier).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('guardians are defined at expected floors', async () => {
|
||||
const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters');
|
||||
const expectedFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
|
||||
for (const floor of expectedFloors) {
|
||||
expect(getGuardianForFloor(floor)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('guardian boons have valid types', async () => {
|
||||
const validBoonTypes = [
|
||||
'maxMana', 'manaRegen', 'castingSpeed', 'elementalDamage', 'rawDamage',
|
||||
'critChance', 'critDamage', 'spellEfficiency', 'manaGain', 'insightGain',
|
||||
'studySpeed', 'prestigeInsight',
|
||||
];
|
||||
const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters');
|
||||
for (const floor of [10, 20, 30, 40, 50, 60, 80, 90, 100]) {
|
||||
const def = getGuardianForFloor(floor)!;
|
||||
for (const boon of def.boons) {
|
||||
expect(validBoonTypes).toContain(boon.type);
|
||||
expect(boon.value).toBeGreaterThan(0);
|
||||
expect(boon.desc).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Prestige store pact state ───────────────────────────────────────────
|
||||
|
||||
describe('Prestige store pact state', () => {
|
||||
it('has correct initial pact state shape', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(Array.isArray(state.defeatedGuardians)).toBe(true);
|
||||
expect(Array.isArray(state.signedPacts)).toBe(true);
|
||||
expect(typeof state.pactSlots).toBe('number');
|
||||
expect(state.pactSlots).toBeGreaterThanOrEqual(1);
|
||||
expect(state.pactRitualFloor).toBeNull();
|
||||
expect(state.pactRitualProgress).toBe(0);
|
||||
});
|
||||
|
||||
it('startPactRitual is a function', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(typeof state.startPactRitual).toBe('function');
|
||||
});
|
||||
|
||||
it('cancelPactRitual is a function', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(typeof state.cancelPactRitual).toBe('function');
|
||||
});
|
||||
|
||||
it('completePactRitual is a function', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(typeof state.completePactRitual).toBe('function');
|
||||
});
|
||||
|
||||
it('defeatGuardian is a function', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(typeof state.defeatGuardian).toBe('function');
|
||||
});
|
||||
|
||||
it('removePact is a function', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(typeof state.removePact).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: File size limit ─────────────────────────────────────────────────────
|
||||
|
||||
describe('File size limits (400 lines max)', () => {
|
||||
it('GuardianPactsTab.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'GuardianPactsTab.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||
import { useUIStore } from '@/lib/game/stores/uiStore';
|
||||
import { getGuardianForFloor, getAllGuardianFloors } from '@/lib/game/data/guardian-encounters';
|
||||
import type { GuardianDef } from '@/lib/game/types';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
GuardianCard,
|
||||
PactHeaderSummary,
|
||||
TierFilter,
|
||||
} from './guardian-pacts-components';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type GuardianStatus = 'undefeated' | 'defeated' | 'signed';
|
||||
|
||||
function getGuardianStatus(floor: number, defeated: number[], signed: number[]): GuardianStatus {
|
||||
if (signed.includes(floor)) return 'signed';
|
||||
if (defeated.includes(floor)) return 'defeated';
|
||||
return 'undefeated';
|
||||
}
|
||||
|
||||
interface FloorTier {
|
||||
label: string;
|
||||
floors: number[];
|
||||
}
|
||||
|
||||
function groupFloorsByTier(floors: number[]): FloorTier[] {
|
||||
const tiers: FloorTier[] = [
|
||||
{ label: 'Early Spire (10–40)', floors: [] },
|
||||
{ label: 'Mid Spire (50–80)', floors: [] },
|
||||
{ label: 'Late Spire (90–100)', floors: [] },
|
||||
{ label: 'Compound (110)', floors: [] },
|
||||
{ label: 'Exotic (120–140)', floors: [] },
|
||||
{ label: 'Transcendent (150+)', floors: [] },
|
||||
];
|
||||
for (const f of floors) {
|
||||
if (f <= 40) tiers[0].floors.push(f);
|
||||
else if (f <= 80) tiers[1].floors.push(f);
|
||||
else if (f <= 100) tiers[2].floors.push(f);
|
||||
else if (f <= 110) tiers[3].floors.push(f);
|
||||
else if (f <= 140) tiers[4].floors.push(f);
|
||||
else tiers[5].floors.push(f);
|
||||
}
|
||||
return tiers.filter(t => t.floors.length > 0);
|
||||
}
|
||||
|
||||
// ─── Main Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GuardianPactsTab: React.FC = () => {
|
||||
const [activeTier, setActiveTier] = useState<string>('all');
|
||||
|
||||
const {
|
||||
defeatedGuardians,
|
||||
signedPacts,
|
||||
pactSlots,
|
||||
pactRitualFloor,
|
||||
pactRitualProgress,
|
||||
startPactRitual,
|
||||
} = usePrestigeStore(useShallow(s => ({
|
||||
defeatedGuardians: s.defeatedGuardians,
|
||||
signedPacts: s.signedPacts,
|
||||
pactSlots: s.pactSlots,
|
||||
pactRitualFloor: s.pactRitualFloor,
|
||||
pactRitualProgress: s.pactRitualProgress,
|
||||
startPactRitual: s.startPactRitual,
|
||||
})));
|
||||
|
||||
const rawMana = useManaStore(s => s.rawMana);
|
||||
const addLog = useUIStore(s => s.addLog);
|
||||
|
||||
const guardianFloors = useMemo(
|
||||
() => getAllGuardianFloors(),
|
||||
[],
|
||||
);
|
||||
|
||||
const guardianMap = useMemo(() => {
|
||||
const map: Record<number, GuardianDef> = {};
|
||||
for (const floor of guardianFloors) {
|
||||
const g = getGuardianForFloor(floor);
|
||||
if (g) map[floor] = g;
|
||||
}
|
||||
return map;
|
||||
}, [guardianFloors]);
|
||||
|
||||
const tiers = useMemo(() => groupFloorsByTier(guardianFloors), [guardianFloors]);
|
||||
|
||||
const filteredFloors = useMemo(() => {
|
||||
if (activeTier === 'all') return guardianFloors;
|
||||
const tier = tiers.find(t => t.label === activeTier);
|
||||
return tier ? tier.floors : guardianFloors;
|
||||
}, [activeTier, guardianFloors, tiers]);
|
||||
|
||||
const handleStartRitual = useCallback((floor: number) => {
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
if (!guardian) return;
|
||||
|
||||
const result = startPactRitual(floor, rawMana);
|
||||
if (result.success) {
|
||||
addLog(`📜 Began pact ritual with ${guardian.name}…`);
|
||||
} else {
|
||||
addLog(`⚠️ ${result.error}`);
|
||||
}
|
||||
}, [startPactRitual, rawMana, addLog]);
|
||||
|
||||
const cumulativeBoons = useMemo(() => {
|
||||
const boonMap: Record<string, number> = {};
|
||||
for (const floor of signedPacts) {
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
if (!guardian) continue;
|
||||
for (const boon of guardian.boons) {
|
||||
boonMap[boon.type] = (boonMap[boon.type] || 0) + boon.value;
|
||||
}
|
||||
}
|
||||
return boonMap;
|
||||
}, [signedPacts]);
|
||||
|
||||
return (
|
||||
<DebugName name="GuardianPactsTab">
|
||||
<div className="space-y-4">
|
||||
<PactHeaderSummary
|
||||
signedCount={signedPacts.length}
|
||||
pactSlots={pactSlots}
|
||||
defeatedCount={defeatedGuardians.length}
|
||||
cumulativeBoons={cumulativeBoons}
|
||||
/>
|
||||
|
||||
<TierFilter
|
||||
tiers={tiers}
|
||||
activeTier={activeTier}
|
||||
guardianFloors={guardianFloors}
|
||||
onSelectTier={setActiveTier}
|
||||
/>
|
||||
|
||||
<ScrollArea className="h-[500px] rounded border border-gray-700 p-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{filteredFloors.map((floor) => {
|
||||
const guardian = guardianMap[floor];
|
||||
if (!guardian) return null;
|
||||
return (
|
||||
<GuardianCard
|
||||
key={floor}
|
||||
floor={floor}
|
||||
guardian={guardian}
|
||||
status={getGuardianStatus(floor, defeatedGuardians, signedPacts)}
|
||||
canAfford={rawMana >= guardian.pactCost}
|
||||
hasSlot={signedPacts.length < pactSlots}
|
||||
isRitualActive={pactRitualFloor === floor}
|
||||
ritualProgress={pactRitualProgress}
|
||||
onStartRitual={handleStartRitual}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
};
|
||||
|
||||
GuardianPactsTab.displayName = 'GuardianPactsTab';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user