Compare commits
50 Commits
1a688394e4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 9b45010617 | |||
| f0601f7622 | |||
| a632b7c6af | |||
| 888aa5283d | |||
| e462bfcc13 | |||
| c8341f79f3 | |||
| fe0f2a079c |
@@ -48,3 +48,4 @@ prompt
|
||||
|
||||
server.log
|
||||
# Skills directory
|
||||
.desloppify/
|
||||
|
||||
@@ -43,26 +43,42 @@ 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`
|
||||
- **Active stores:** `src/lib/game/stores/{game,mana,combat,prestige,discipline,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
|
||||
- **Disciplines:** `data/disciplines/` + `stores/discipline-slice.ts` + `utils/discipline-math.ts`
|
||||
- **Effects:** All stat mods through `getUnifiedEffects()` — discipline bonuses enter via `computeDisciplineEffects()`
|
||||
|
||||
### Adding Effects
|
||||
1. `data/enchantment-effects.ts`
|
||||
2. `effects.ts` → `computeEquipmentEffects()`
|
||||
3. Access via `getUnifiedEffects(state)`
|
||||
|
||||
### Adding Skills
|
||||
1. `constants/skills-v2.ts`
|
||||
2. `computeStats()` mapping
|
||||
### Adding Disciplines
|
||||
1. Choose the correct data file under `data/disciplines/`:
|
||||
- `base.ts` — available to all attunements
|
||||
- `enchanter.ts` — requires Enchanter attunement
|
||||
- `invoker.ts` — requires Invoker attunement
|
||||
- `fabricator.ts` — requires Fabricator attunement
|
||||
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 +3)
|
||||
|
||||
### Adding Spells
|
||||
1. `constants/spells.ts`
|
||||
2. `data/enchantment-effects.ts`
|
||||
3. `constants/skills-v2.ts` research skill
|
||||
4. `EFFECT_RESEARCH_MAPPING`
|
||||
3. `EFFECT_RESEARCH_MAPPING`
|
||||
|
||||
## Banned
|
||||
|
||||
@@ -74,7 +90,7 @@ Lifesteal/healing, scroll crafting, ascension skills, LabTab, pause, mana types:
|
||||
|
||||
## 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
|
||||
**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
|
||||
@@ -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 -->
|
||||
+278
-608
File diff suppressed because it is too large
Load Diff
+6
-10
@@ -1,15 +1,11 @@
|
||||
# Circular Dependencies
|
||||
Generated: 2026-05-14T10:03:17.211Z
|
||||
Found: 8 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||
Generated: 2026-05-20T19:05:27.642Z
|
||||
Found: 4 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||
|
||||
1. Processed 174 files (2.4s) (27 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/gameActions.ts > stores/skillStore.ts
|
||||
8. 7) stores/combatStore.ts > stores/gameStore.ts > stores/gameLoopActions.ts
|
||||
1. Processed 126 files (1.3s) (3 warnings)
|
||||
2. 1) stores/gameStore.ts > stores/gameActions.ts
|
||||
3. 2) stores/gameStore.ts > stores/gameLoopActions.ts
|
||||
4. 3) stores/gameStore.ts > stores/tick-pipeline.ts
|
||||
|
||||
## How to fix
|
||||
1. Identify which import in the chain can be extracted to a shared types/utils file.
|
||||
|
||||
+187
-442
@@ -1,26 +1,10 @@
|
||||
{
|
||||
"_meta": {
|
||||
"generated": "2026-05-14T10:03:14.529Z",
|
||||
"generated": "2026-05-20T19:05:26.102Z",
|
||||
"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"
|
||||
],
|
||||
@@ -37,74 +21,14 @@
|
||||
"constants/guardians.ts",
|
||||
"constants/prestige.ts",
|
||||
"constants/rooms.ts",
|
||||
"constants/skills-v2-types.ts",
|
||||
"constants/skills-v2.ts",
|
||||
"constants/skills.ts",
|
||||
"constants/spells.ts"
|
||||
"constants/spells.ts",
|
||||
"types/game.ts"
|
||||
],
|
||||
"constants/prestige.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"constants/rooms.ts": [],
|
||||
"constants/skills-combat.ts": [
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-core.ts": [
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-crafting.ts": [
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-element-caps.ts": [
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-enchant.ts": [
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-golemancy.ts": [
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-hybrid.ts": [
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-invocation.ts": [
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-research.ts": [
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-v2-defs.ts": [
|
||||
"constants/skills-v2-registry.ts",
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-v2-registry.ts": [
|
||||
"constants/skills-combat.ts",
|
||||
"constants/skills-core.ts",
|
||||
"constants/skills-crafting.ts",
|
||||
"constants/skills-element-caps.ts",
|
||||
"constants/skills-enchant.ts",
|
||||
"constants/skills-golemancy.ts",
|
||||
"constants/skills-hybrid.ts",
|
||||
"constants/skills-invocation.ts",
|
||||
"constants/skills-research.ts",
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills-v2-types.ts": [],
|
||||
"constants/skills-v2.ts": [
|
||||
"constants/skills-combat.ts",
|
||||
"constants/skills-core.ts",
|
||||
"constants/skills-crafting.ts",
|
||||
"constants/skills-element-caps.ts",
|
||||
"constants/skills-enchant.ts",
|
||||
"constants/skills-golemancy.ts",
|
||||
"constants/skills-hybrid.ts",
|
||||
"constants/skills-invocation.ts",
|
||||
"constants/skills-research.ts",
|
||||
"constants/skills-v2-defs.ts",
|
||||
"constants/skills-v2-types.ts"
|
||||
],
|
||||
"constants/skills.ts": [
|
||||
"types.ts"
|
||||
"constants/rooms.ts": [
|
||||
"types/game.ts"
|
||||
],
|
||||
"constants/spells-modules/advanced-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
@@ -161,28 +85,32 @@
|
||||
],
|
||||
"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/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": [
|
||||
@@ -196,15 +124,15 @@
|
||||
],
|
||||
"crafting-actions/preparation-actions.ts": [
|
||||
"crafting-prep.ts",
|
||||
"types.ts"
|
||||
"stores/craftingStore.types.ts"
|
||||
],
|
||||
"crafting-apply.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",
|
||||
@@ -214,9 +142,9 @@
|
||||
"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",
|
||||
@@ -232,28 +160,8 @@
|
||||
"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"
|
||||
],
|
||||
@@ -264,7 +172,26 @@
|
||||
"types.ts"
|
||||
],
|
||||
"data/crafting-recipes.ts": [
|
||||
"types.ts"
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/disciplines/base.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/enchanter.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/fabricator.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/index.ts": [
|
||||
"data/disciplines/base.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/enchantments/index.ts"
|
||||
@@ -292,7 +219,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",
|
||||
@@ -307,7 +235,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",
|
||||
@@ -315,6 +245,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"
|
||||
],
|
||||
@@ -330,7 +263,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"
|
||||
@@ -347,6 +283,17 @@
|
||||
"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/shields.ts",
|
||||
"data/equipment/swords.ts"
|
||||
],
|
||||
"data/equipment/feet.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
@@ -361,6 +308,7 @@
|
||||
"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",
|
||||
@@ -375,9 +323,14 @@
|
||||
"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-recipes.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/golems/base-golems.ts": [
|
||||
@@ -386,304 +339,65 @@
|
||||
"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/golems-data.ts",
|
||||
"data/golems/types.ts"
|
||||
],
|
||||
"data/guardian-encounters.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"data/loot-drops.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"debug-context.tsx": [],
|
||||
"dynamic-compute.ts": [
|
||||
"special-effects.ts",
|
||||
"upgrade-effects.types.ts"
|
||||
],
|
||||
"effects.ts": [
|
||||
"data/enchantment-effects.ts",
|
||||
"special-effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts",
|
||||
"upgrade-effects.types.ts"
|
||||
"effects/discipline-effects.ts",
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.ts",
|
||||
"effects/upgrade-effects.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"formatting.ts": [
|
||||
"computed-stats.ts"
|
||||
"effects/discipline-effects.ts": [
|
||||
"data/disciplines/index.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"types/disciplines.ts",
|
||||
"utils/discipline-math.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",
|
||||
"special-effects.ts",
|
||||
"store.ts",
|
||||
"store/computed.ts",
|
||||
"upgrade-effects.ts"
|
||||
],
|
||||
"hooks/useSkillUpgradeSelection.ts": [],
|
||||
"navigation-slice.ts": [
|
||||
"computed-stats.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/elemental-attunement.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/enchanting-skills.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/focused-mind.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/guardian-skills.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/hybrid-skills.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/index.ts": [
|
||||
"skill-evolution-modules/elemental-attunement.ts",
|
||||
"skill-evolution-modules/enchanting-skills.ts",
|
||||
"skill-evolution-modules/focused-mind.ts",
|
||||
"skill-evolution-modules/guardian-skills.ts",
|
||||
"skill-evolution-modules/hybrid-skills.ts",
|
||||
"skill-evolution-modules/insight-harvest.ts",
|
||||
"skill-evolution-modules/invocation-skills.ts",
|
||||
"skill-evolution-modules/knowledge-retention.ts",
|
||||
"skill-evolution-modules/mana-utility-skills.ts",
|
||||
"skill-evolution-modules/mana-well-flow.ts",
|
||||
"skill-evolution-modules/quick-learner.ts",
|
||||
"skill-evolution-modules/types.ts",
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/insight-harvest.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/invocation-skills.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/knowledge-retention.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/learning-skills.ts": [
|
||||
"skill-evolution-modules/focused-mind.ts",
|
||||
"skill-evolution-modules/insight-harvest.ts",
|
||||
"skill-evolution-modules/knowledge-retention.ts",
|
||||
"skill-evolution-modules/quick-learner.ts"
|
||||
],
|
||||
"skill-evolution-modules/magic-skills.ts": [
|
||||
"skill-evolution-modules/elemental-attunement.ts",
|
||||
"skill-evolution-modules/mana-utility-skills.ts",
|
||||
"skill-evolution-modules/mana-well-flow.ts"
|
||||
],
|
||||
"skill-evolution-modules/mana-utility-skills.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/mana-well-flow.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/quick-learner.ts": [
|
||||
"skill-evolution-modules/utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/types.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution-modules/utils.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"skill-evolution.ts": [
|
||||
"skill-evolution-modules/index.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",
|
||||
"skill-evolution.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-tests/test-utils.ts": [
|
||||
"constants.ts",
|
||||
"types.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",
|
||||
"skill-evolution.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/skillSlice.ts": [
|
||||
"constants.ts",
|
||||
"skill-evolution.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts"
|
||||
],
|
||||
"store/timeSlice.ts": [
|
||||
"constants.ts",
|
||||
"store/computed.ts",
|
||||
"types.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",
|
||||
@@ -691,14 +405,17 @@
|
||||
],
|
||||
"stores/combat-actions.ts": [
|
||||
"constants.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/prestigeStore.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/gameStore.ts",
|
||||
"stores/combat-state.types.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"types.ts",
|
||||
"utils/activity-log.ts",
|
||||
@@ -708,76 +425,92 @@
|
||||
"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/craftingStore.types.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/skillStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts"
|
||||
"types/equipmentSlot.ts"
|
||||
],
|
||||
"stores/craftingStore.types.ts": [
|
||||
"types.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/discipline-effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/skillStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"upgrade-effects.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/gameHooks.ts": [
|
||||
"constants.ts",
|
||||
"effects.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/craftingStore.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/skillStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/gameLoopActions.ts": [
|
||||
"constants.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/skillStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/gameStore.ts": [
|
||||
"constants.ts",
|
||||
"data/attunements.ts",
|
||||
"special-effects.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/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/skillStore.ts",
|
||||
"stores/tick-pipeline.ts",
|
||||
"stores/uiStore.ts",
|
||||
"upgrade-effects.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/index.ts": [
|
||||
"constants.ts",
|
||||
"store-modules/computed-stats.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/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/skillStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
@@ -789,31 +522,35 @@
|
||||
"constants.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"stores/skillStore.ts": [
|
||||
"constants.ts",
|
||||
"skill-evolution.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/tick-pipeline.ts": [
|
||||
"stores/attunementStore.ts",
|
||||
"stores/combat-state.types.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"types.ts"
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts"
|
||||
],
|
||||
"stores/uiStore.ts": [],
|
||||
"study-slice.ts": [
|
||||
"constants.ts",
|
||||
"special-effects.ts",
|
||||
"types.ts",
|
||||
"upgrade-effects.ts"
|
||||
],
|
||||
"types.ts": [
|
||||
"data/equipment/types.ts",
|
||||
"types/attunements.ts",
|
||||
"types/elements.ts",
|
||||
"types/equipment.ts",
|
||||
"types/equipmentSlot.ts",
|
||||
"types/game.ts",
|
||||
"types/skills.ts",
|
||||
"types/spells.ts"
|
||||
],
|
||||
"types/attunements.ts": [],
|
||||
"types/disciplines.ts": [
|
||||
"types/elements.ts"
|
||||
],
|
||||
"types/elements.ts": [],
|
||||
"types/equipment.ts": [],
|
||||
"types/equipment.ts": [
|
||||
"types/equipmentSlot.ts"
|
||||
],
|
||||
"types/equipmentSlot.ts": [],
|
||||
"types/game.ts": [
|
||||
"types/attunements.ts",
|
||||
"types/elements.ts",
|
||||
@@ -824,29 +561,27 @@
|
||||
"types/attunements.ts",
|
||||
"types/elements.ts",
|
||||
"types/equipment.ts",
|
||||
"types/equipmentSlot.ts",
|
||||
"types/game.ts",
|
||||
"types/skills.ts",
|
||||
"types/spells.ts"
|
||||
],
|
||||
"types/skills.ts": [],
|
||||
"types/spells.ts": [],
|
||||
"upgrade-effects.ts": [
|
||||
"dynamic-compute.ts",
|
||||
"skill-evolution.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"
|
||||
"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",
|
||||
@@ -866,14 +601,24 @@
|
||||
"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": [
|
||||
"constants.ts"
|
||||
],
|
||||
"utils/room-utils.ts": [
|
||||
"constants.ts",
|
||||
"types.ts",
|
||||
"utils/enemy-utils.ts",
|
||||
"utils/floor-utils.ts"
|
||||
],
|
||||
"utils/spire-utils.ts": [
|
||||
"constants.ts",
|
||||
"data/guardian-encounters.ts",
|
||||
"types.ts",
|
||||
"utils/enemy-utils.ts",
|
||||
"utils/floor-utils.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
+93
-218
@@ -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,68 +85,69 @@ Mana-Loop/
|
||||
│ │ │ │ ├── GameStateDebug.tsx
|
||||
│ │ │ │ ├── GolemDebug.tsx
|
||||
│ │ │ │ ├── PactDebug.tsx
|
||||
│ │ │ │ ├── SkillDebug.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/
|
||||
│ │ │ │ ├── CraftingTab/
|
||||
│ │ │ │ │ ├── EnchanterSubTab.tsx
|
||||
│ │ │ │ │ └── FabricatorSubTab.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
|
||||
│ │ │ │ │ ├── ElementStatsSection.tsx
|
||||
│ │ │ │ │ ├── LoopStatsSection.tsx
|
||||
│ │ │ │ │ ├── ManaStatsSection.tsx
|
||||
│ │ │ │ │ ├── PactStatusSection.tsx
|
||||
│ │ │ │ │ └── StudyStatsSection.tsx
|
||||
│ │ │ │ ├── AchievementsTab.tsx
|
||||
│ │ │ │ ├── ActivityLog.tsx
|
||||
│ │ │ │ ├── AttunementsTab.test.ts
|
||||
│ │ │ │ ├── AttunementsTab.tsx
|
||||
│ │ │ │ ├── CategorySkillsList.tsx
|
||||
│ │ │ │ ├── CombatStatsPanel.tsx
|
||||
│ │ │ │ ├── CraftingTab.test.ts
|
||||
│ │ │ │ ├── CraftingTab.tsx
|
||||
│ │ │ │ ├── DebugTab.test.ts
|
||||
│ │ │ │ ├── DebugTab.tsx
|
||||
│ │ │ │ ├── EnchantmentsPanel.tsx
|
||||
│ │ │ │ ├── EquipmentControls.tsx
|
||||
│ │ │ │ ├── EquipmentInventory.tsx
|
||||
│ │ │ │ ├── EquipmentSlotGrid.tsx
|
||||
│ │ │ │ ├── DisciplinesTab.tsx
|
||||
│ │ │ │ ├── EquipmentTab.test.ts
|
||||
│ │ │ │ ├── EquipmentTab.tsx
|
||||
│ │ │ │ ├── FloorControls.tsx
|
||||
│ │ │ │ ├── GolemancyTab.test.ts
|
||||
│ │ │ │ ├── GolemancyTab.tsx
|
||||
│ │ │ │ ├── GuardianPanel.tsx
|
||||
│ │ │ │ ├── LootTab.tsx
|
||||
│ │ │ │ ├── MilestoneProgress.tsx
|
||||
│ │ │ │ ├── GuardianPactsTab.test.ts
|
||||
│ │ │ │ ├── GuardianPactsTab.tsx
|
||||
│ │ │ │ ├── PrestigeTab.test.ts
|
||||
│ │ │ │ ├── PrestigeTab.tsx
|
||||
│ │ │ │ ├── RoomDisplay.tsx
|
||||
│ │ │ │ ├── SkillCategoryHeader.tsx
|
||||
│ │ │ │ ├── SkillMultipliers.tsx
|
||||
│ │ │ │ ├── SkillRow.tsx
|
||||
│ │ │ │ ├── SpellsTab.tsx
|
||||
│ │ │ │ ├── SpireActiveSpells.tsx
|
||||
│ │ │ │ ├── SpireGolems.tsx
|
||||
│ │ │ │ ├── SpireHeader.tsx
|
||||
│ │ │ │ ├── SpireTab.tsx
|
||||
│ │ │ │ ├── SpireSummaryTab.test.ts
|
||||
│ │ │ │ ├── SpireSummaryTab.tsx
|
||||
│ │ │ │ ├── StatsTab.tsx
|
||||
│ │ │ │ ├── StudyProgress.tsx
|
||||
│ │ │ │ ├── UpgradeDialog.tsx
|
||||
│ │ │ │ ├── guardian-pacts-components.tsx
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── AchievementsDisplay.tsx
|
||||
│ │ │ ├── ActionButtons.tsx
|
||||
│ │ │ ├── ActivityLogPanel.tsx
|
||||
│ │ │ ├── AttunementStatus.tsx
|
||||
│ │ │ ├── CalendarDisplay.tsx
|
||||
│ │ │ ├── ConfirmDialog.tsx
|
||||
│ │ │ ├── CraftingProgress.tsx
|
||||
│ │ │ ├── GameContext.tsx
|
||||
│ │ │ ├── GameToast.tsx
|
||||
│ │ │ ├── ManaDisplay.tsx
|
||||
│ │ │ ├── SkillsTab.tsx
|
||||
│ │ │ ├── SpellsTab.tsx
|
||||
│ │ │ ├── StatsTab.tsx
|
||||
│ │ │ ├── StudyProgress.tsx
|
||||
│ │ │ ├── TimeDisplay.tsx
|
||||
│ │ │ ├── UpgradeDialog.tsx
|
||||
│ │ │ ├── index.ts
|
||||
@@ -191,7 +172,6 @@ Mana-Loop/
|
||||
│ │ │ ├── separator.tsx
|
||||
│ │ │ ├── sheet.tsx
|
||||
│ │ │ ├── skeleton.tsx
|
||||
│ │ │ ├── skill-row.tsx
|
||||
│ │ │ ├── stat-row.tsx
|
||||
│ │ │ ├── stepper.tsx
|
||||
│ │ │ ├── switch.tsx
|
||||
@@ -209,25 +189,23 @@ Mana-Loop/
|
||||
│ └── lib/
|
||||
│ ├── game/
|
||||
│ │ ├── __tests__/
|
||||
│ │ │ ├── skills-tests/
|
||||
│ │ │ │ ├── ascension-skills.test.ts
|
||||
│ │ │ │ ├── integration-and-evolution.test.ts
|
||||
│ │ │ │ ├── mana-skills.test.ts
|
||||
│ │ │ │ ├── prestige-upgrades.test.ts
|
||||
│ │ │ │ ├── skill-prerequisites.test.ts
|
||||
│ │ │ │ ├── specialized-skills.test.ts
|
||||
│ │ │ │ ├── study-skills.test.ts
|
||||
│ │ │ │ └── study-times.test.ts
|
||||
│ │ │ ├── store-method-tests/
|
||||
│ │ │ ├── achievements.test.ts
|
||||
│ │ │ ├── bug-fixes.test.ts
|
||||
│ │ │ ├── combat-utils.test.ts
|
||||
│ │ │ ├── computed-stats.test.ts
|
||||
│ │ │ ├── skill-system.test.ts
|
||||
│ │ │ └── skills.test.ts
|
||||
│ │ ├── attunements/
|
||||
│ │ │ ├── data.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ └── utils.ts
|
||||
│ │ │ ├── discipline-math.test.ts
|
||||
│ │ │ ├── enemy-generator.test.ts
|
||||
│ │ │ ├── floor-utils.test.ts
|
||||
│ │ │ ├── formatting.test.ts
|
||||
│ │ │ ├── mana-utils.test.ts
|
||||
│ │ │ ├── regression-fixes.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
|
||||
@@ -246,20 +224,6 @@ Mana-Loop/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── prestige.ts
|
||||
│ │ │ ├── rooms.ts
|
||||
│ │ │ ├── skills-combat.ts
|
||||
│ │ │ ├── skills-core.ts
|
||||
│ │ │ ├── skills-crafting.ts
|
||||
│ │ │ ├── skills-element-caps.ts
|
||||
│ │ │ ├── skills-enchant.ts
|
||||
│ │ │ ├── skills-golemancy.ts
|
||||
│ │ │ ├── skills-hybrid.ts
|
||||
│ │ │ ├── skills-invocation.ts
|
||||
│ │ │ ├── skills-research.ts
|
||||
│ │ │ ├── skills-v2-defs.ts
|
||||
│ │ │ ├── skills-v2-registry.ts
|
||||
│ │ │ ├── skills-v2-types.ts
|
||||
│ │ │ ├── skills-v2.ts
|
||||
│ │ │ ├── skills.ts
|
||||
│ │ │ └── spells.ts
|
||||
│ │ ├── crafting-actions/
|
||||
│ │ │ ├── application-actions.ts
|
||||
@@ -271,10 +235,17 @@ Mana-Loop/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── preparation-actions.ts
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── disciplines/
|
||||
│ │ │ │ ├── base.ts
|
||||
│ │ │ │ ├── enchanter.ts
|
||||
│ │ │ │ ├── fabricator.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
|
||||
@@ -293,6 +264,7 @@ Mana-Loop/
|
||||
│ │ │ │ ├── body.ts
|
||||
│ │ │ │ ├── casters.ts
|
||||
│ │ │ │ ├── catalysts.ts
|
||||
│ │ │ │ ├── equipment-types-data.ts
|
||||
│ │ │ │ ├── feet.ts
|
||||
│ │ │ │ ├── hands.ts
|
||||
│ │ │ │ ├── head.ts
|
||||
@@ -304,6 +276,7 @@ Mana-Loop/
|
||||
│ │ │ ├── golems/
|
||||
│ │ │ │ ├── base-golems.ts
|
||||
│ │ │ │ ├── elemental-golems.ts
|
||||
│ │ │ │ ├── golems-data.ts
|
||||
│ │ │ │ ├── hybrid-golems.ts
|
||||
│ │ │ │ ├── index.ts
|
||||
│ │ │ │ ├── types.ts
|
||||
@@ -313,142 +286,58 @@ Mana-Loop/
|
||||
│ │ │ ├── crafting-recipes.ts
|
||||
│ │ │ ├── enchantment-effects.ts
|
||||
│ │ │ ├── enchantment-types.ts
|
||||
│ │ │ ├── fabricator-recipes.ts
|
||||
│ │ │ ├── guardian-encounters.ts
|
||||
│ │ │ └── loot-drops.ts
|
||||
│ │ ├── effects/
|
||||
│ │ │ ├── discipline-effects.ts
|
||||
│ │ │ ├── dynamic-compute.ts
|
||||
│ │ │ ├── special-effects.ts
|
||||
│ │ │ ├── upgrade-effects.ts
|
||||
│ │ │ └── upgrade-effects.types.ts
|
||||
│ │ ├── hooks/
|
||||
│ │ │ ├── useGameDerived.ts
|
||||
│ │ │ └── useSkillUpgradeSelection.ts
|
||||
│ │ ├── skills-split-tests/
|
||||
│ │ │ ├── ascension-specialized-skills.test.ts
|
||||
│ │ │ ├── mana-skills.test.ts
|
||||
│ │ │ ├── prerequisites-studytimes-prestige-integration.test.ts
|
||||
│ │ │ └── study-skills.test.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
|
||||
│ │ │ ├── skillSlice.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
|
||||
│ │ ├── store-tests/
|
||||
│ │ │ ├── damage-calculation.test.ts
|
||||
│ │ │ ├── element-recipes.test.ts
|
||||
│ │ │ ├── floor.test.ts
|
||||
│ │ │ ├── formatting.test.ts
|
||||
│ │ │ ├── game-constants.test.ts
|
||||
│ │ │ ├── individual-skills.test.ts
|
||||
│ │ │ ├── insight-meditation-incursion.test.ts
|
||||
│ │ │ ├── integration.test.ts
|
||||
│ │ │ ├── mana-calculation.test.ts
|
||||
│ │ │ ├── skill-evolution.test.ts
|
||||
│ │ │ ├── skill-requirements.test.ts
|
||||
│ │ │ ├── spell-cost.test.ts
|
||||
│ │ │ ├── study-speed.test.ts
|
||||
│ │ │ └── test-utils.ts
|
||||
│ │ │ └── useGameDerived.ts
|
||||
│ │ ├── stores/
|
||||
│ │ │ ├── __tests__/
|
||||
│ │ │ │ ├── archive/
|
||||
│ │ │ │ │ ├── store-methods.test.ts
|
||||
│ │ │ │ │ └── stores.test.ts
|
||||
│ │ │ │ ├── combat-store-tests/
|
||||
│ │ │ │ ├── index-tests/
|
||||
│ │ │ │ │ ├── combat-calculations.test.ts
|
||||
│ │ │ │ │ ├── definitions.test.ts
|
||||
│ │ │ │ │ ├── mana-calculations.test.ts
|
||||
│ │ │ │ │ ├── meditation-insight-incursion.test.ts
|
||||
│ │ │ │ │ ├── spell-cost.test.ts
|
||||
│ │ │ │ │ ├── study-speed.test.ts
|
||||
│ │ │ │ │ └── utility-functions.test.ts
|
||||
│ │ │ │ ├── mana-store-tests/
|
||||
│ │ │ │ ├── prestige-store-tests/
|
||||
│ │ │ │ ├── store-method-tests/
|
||||
│ │ │ │ │ ├── combat-store.test.ts
|
||||
│ │ │ │ │ ├── mana-store.test.ts
|
||||
│ │ │ │ │ ├── prestige-store.test.ts
|
||||
│ │ │ │ │ ├── skill-store.test.ts
|
||||
│ │ │ │ │ └── ui-store.test.ts
|
||||
│ │ │ │ ├── stores-split-tests/
|
||||
│ │ │ │ ├── stores-tests/
|
||||
│ │ │ │ │ ├── damage-calculation.test.ts
|
||||
│ │ │ │ │ ├── floor.test.ts
|
||||
│ │ │ │ │ ├── formatting.test.ts
|
||||
│ │ │ │ │ ├── guardians.test.ts
|
||||
│ │ │ │ │ ├── incursion.test.ts
|
||||
│ │ │ │ │ ├── insight-calculation.test.ts
|
||||
│ │ │ │ │ ├── mana-calculation.test.ts
|
||||
│ │ │ │ │ ├── meditation.test.ts
|
||||
│ │ │ │ │ ├── prestige-upgrades.test.ts
|
||||
│ │ │ │ │ ├── skill-definitions.test.ts
|
||||
│ │ │ │ │ ├── spell-cost.test.ts
|
||||
│ │ │ │ │ ├── spell-definitions.test.ts
|
||||
│ │ │ │ │ └── study-speed.test.ts
|
||||
│ │ │ │ ├── ui-store-tests/
|
||||
│ │ │ │ ├── computed-stats.test.ts
|
||||
│ │ │ │ ├── equipment.test.ts
|
||||
│ │ │ │ ├── mana-conversion-fix.test.ts
|
||||
│ │ │ │ ├── mana.test.ts
|
||||
│ │ │ │ ├── regen.test.ts
|
||||
│ │ │ │ ├── skill.test.ts
|
||||
│ │ │ │ ├── spell-cost.test.ts
|
||||
│ │ │ │ ├── spire-exit-action.test.ts
|
||||
│ │ │ │ └── spire-tab-refresh.test.ts
|
||||
│ │ │ ├── attunementStore.ts
|
||||
│ │ │ ├── combat-actions.ts
|
||||
│ │ │ ├── combat-state.types.ts
|
||||
│ │ │ ├── combatStore.ts
|
||||
│ │ │ ├── craftingStore.ts
|
||||
│ │ │ ├── craftingStore.types.ts
|
||||
│ │ │ ├── discipline-slice.ts
|
||||
│ │ │ ├── gameActions.ts
|
||||
│ │ │ ├── gameHooks.ts
|
||||
│ │ │ ├── gameLoopActions.ts
|
||||
│ │ │ ├── gameStore.ts
|
||||
│ │ │ ├── index.test.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── manaStore.ts
|
||||
│ │ │ ├── prestigeStore.ts
|
||||
│ │ │ ├── tick-pipeline.ts
|
||||
│ │ │ └── uiStore.ts
|
||||
│ │ ├── stores-split-tests/
|
||||
│ │ │ ├── combat-store.test.ts
|
||||
│ │ │ ├── integration.test.ts
|
||||
│ │ │ ├── mana-store.test.ts
|
||||
│ │ │ ├── prestige-store.test.ts
|
||||
│ │ │ ├── skill-store.test.ts
|
||||
│ │ │ └── ui-store.test.ts
|
||||
│ │ ├── types/
|
||||
│ │ │ ├── attunements.ts
|
||||
│ │ │ ├── disciplines.ts
|
||||
│ │ │ ├── elements.ts
|
||||
│ │ │ ├── equipment.ts
|
||||
│ │ │ ├── equipmentSlot.ts
|
||||
│ │ │ ├── game.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── skills.ts
|
||||
│ │ │ └── spells.ts
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── 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
|
||||
@@ -456,30 +345,15 @@ Mana-Loop/
|
||||
│ │ ├── crafting-equipment.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
|
||||
│ │ ├── skills.test.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
|
||||
@@ -493,6 +367,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: 38 KiB |
@@ -0,0 +1,77 @@
|
||||
'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][]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && SPELLS_DEF) {
|
||||
setGrimoireSpells(
|
||||
Object.entries(SPELLS_DEF).filter((entry): entry is [string, SpellDef] => !!entry[1].grimoire)
|
||||
);
|
||||
}
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
if (!loaded) {
|
||||
return <div className="p-4 text-center text-gray-400">Loading grimoire...</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>
|
||||
);
|
||||
}
|
||||
|
||||
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,8 +8,9 @@ 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 { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
import { getMeditationBonus, getIncursionStrength } from '@/lib/game/stores';
|
||||
import { computeTotalMaxMana, computeTotalRegen, computeTotalClickMana } from '@/lib/game/effects';
|
||||
@@ -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,11 @@ 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 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);
|
||||
const incursionStrength = getIncursionStrength(useGameStore((s) => s.day), useGameStore((s) => s.hour));
|
||||
const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
|
||||
|
||||
@@ -84,7 +81,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 +95,6 @@ export function LeftPanel() {
|
||||
<CardContent className="pt-3">
|
||||
<ActionButtons
|
||||
currentAction={currentAction}
|
||||
currentStudyTarget={currentStudyTarget as any}
|
||||
designProgress={designProgress}
|
||||
designProgress2={designProgress2}
|
||||
preparationProgress={preparationProgress}
|
||||
@@ -127,4 +123,4 @@ export function LeftPanel() {
|
||||
</DebugName>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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',
|
||||
|
||||
+130
-266
@@ -1,14 +1,12 @@
|
||||
'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,
|
||||
@@ -19,200 +17,154 @@ import {
|
||||
getMeditationBonus,
|
||||
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 { DebugName } from '@/components/game/debug/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>;
|
||||
}
|
||||
|
||||
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((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>
|
||||
);
|
||||
function TabErrorFallback({ name }: { name: string }) {
|
||||
return <div className="p-4 text-red-400">{name} tab failed to load.</div>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Game Component
|
||||
// ============================================================================
|
||||
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
|
||||
|
||||
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({ skills: {} }, disciplineEffects);
|
||||
const meditationMultiplier = getMeditationBonus(meditateTicks, {}, upgradeEffects.meditationEfficiency);
|
||||
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 +172,32 @@ 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>
|
||||
<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 +207,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>
|
||||
|
||||
<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>
|
||||
<TabTriggers />
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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,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";
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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 { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useSkillStore } from '@/lib/game/stores';
|
||||
import { SKILL_CATEGORIES } from '@/lib/game/constants';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SkillUpgradeDialog } from './SkillsTab/SkillUpgradeDialog';
|
||||
import { SkillStudyProgress } from './SkillsTab/SkillStudyProgress';
|
||||
import { SkillCategory } from './SkillsTab/SkillCategory';
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
|
||||
export function SkillsTab() {
|
||||
const currentStudyTarget = useSkillStore((s) => s.currentStudyTarget);
|
||||
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
|
||||
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
|
||||
|
||||
const handleUpgradeClick = (skillId: string, milestone: 5 | 10) => {
|
||||
setUpgradeDialogSkill(skillId);
|
||||
setUpgradeDialogMilestone(milestone);
|
||||
};
|
||||
|
||||
const handleUpgradeClose = () => {
|
||||
setUpgradeDialogSkill(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<DebugName name="SkillsTab">
|
||||
<div className="space-y-4">
|
||||
{/* Upgrade Selection Dialog */}
|
||||
<SkillUpgradeDialog
|
||||
skillId={upgradeDialogSkill}
|
||||
milestone={upgradeDialogMilestone}
|
||||
onClose={handleUpgradeClose}
|
||||
/>
|
||||
|
||||
{/* Current Study Progress */}
|
||||
{currentStudyTarget && currentStudyTarget.type === 'skill' && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||
<CardContent className="pt-4">
|
||||
<SkillStudyProgress />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{SKILL_CATEGORIES.map((cat) => (
|
||||
<SkillCategory
|
||||
key={cat.id}
|
||||
categoryId={cat.id}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
SkillsTab.displayName = "SkillsTab";
|
||||
@@ -1,175 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { canAffordSpellCost, fmt } from '@/lib/game/stores';
|
||||
import { useCombatStore, useSkillStore, useManaStore } from '@/lib/game/stores';
|
||||
import { ELEMENTS, SPELLS_DEF } from '@/lib/game/constants';
|
||||
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
// Format spell cost for display
|
||||
function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||
if (cost.type === 'raw') {
|
||||
return `${cost.amount} raw`;
|
||||
}
|
||||
const elemDef = ELEMENTS[cost.element || ''];
|
||||
return `${cost.amount} ${elemDef?.sym || '?'}`;
|
||||
}
|
||||
|
||||
// Get cost color
|
||||
function getSpellCostColor(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||
if (cost.type === 'raw') {
|
||||
return '#60A5FA';
|
||||
}
|
||||
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
|
||||
}
|
||||
|
||||
// Format study time
|
||||
function formatStudyTime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
export function SpellsTab() {
|
||||
const spells = useCombatStore((s) => s.spells);
|
||||
const activeSpell = useCombatStore((s) => s.activeSpell);
|
||||
const setSpell = useCombatStore((s) => s.setSpell);
|
||||
const currentStudyTarget = useSkillStore((s) => s.currentStudyTarget);
|
||||
const setCurrentStudyTarget = useSkillStore((s) => s.setCurrentStudyTarget);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const { studySpeedMult, studyCostMult } = useStudyStats();
|
||||
|
||||
const spellTiers = [0, 1, 2, 3, 4];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{spellTiers.map(tier => {
|
||||
const spellsInTier = Object.entries(SPELLS_DEF).filter(([, def]) => def.tier === tier);
|
||||
if (spellsInTier.length === 0) return null;
|
||||
|
||||
const tierNames = ['Basic Spells (Raw Mana)', 'Tier 1 - Elemental', 'Tier 2 - Advanced', 'Tier 3 - Master', 'Tier 4 - Legendary'];
|
||||
const tierColors = ['text-gray-400', 'text-green-400', 'text-blue-400', 'text-purple-400', 'text-amber-400'];
|
||||
|
||||
return (
|
||||
<div key={tier}>
|
||||
<h3 className={`text-lg font-semibold mb-3 ${tierColors[tier]}`}>{tierNames[tier]}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{spellsInTier.map(([id, def]) => {
|
||||
const state = spells?.[id];
|
||||
const learned = state?.learned;
|
||||
const isStudying = currentStudyTarget?.id === id;
|
||||
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||
const baseStudyTime = def.studyTime || (def.tier * 4);
|
||||
const isActive = activeSpell === id;
|
||||
const canCast = learned && canAffordSpellCost(def.cost, rawMana, elements);
|
||||
|
||||
// Apply skill modifiers
|
||||
const studyTime = baseStudyTime / studySpeedMult;
|
||||
const unlockCost = Math.floor(def.unlock * studyCostMult);
|
||||
|
||||
// Can start studying?
|
||||
const canStudy = !learned && !isStudying && rawMana >= unlockCost;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={id}
|
||||
className={`bg-gray-900/80 border-gray-700 ${learned ? '' : 'opacity-75'} ${isStudying ? 'border-purple-500' : ''} ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm game-panel-title" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||
{def.name}
|
||||
</CardTitle>
|
||||
{def.tier > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
T{def.tier}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-xs text-gray-400">
|
||||
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
|
||||
<span className="mr-2">⚔️ {def.dmg} dmg</span>
|
||||
</div>
|
||||
|
||||
{/* Cost display */}
|
||||
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||
Cost: {formatSpellCost(def.cost)}
|
||||
</div>
|
||||
|
||||
{def.desc && (
|
||||
<div className="text-xs text-gray-500 italic">{def.desc}</div>
|
||||
)}
|
||||
|
||||
{def.effects && Array.isArray(def.effects) && def.effects.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{def.effects.map((eff, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">
|
||||
{eff.type === 'burn' && `🔥 Burn`}
|
||||
{eff.type === 'stun' && `⚡ Stun`}
|
||||
{eff.type === 'pierce' && `🎯 Pierce`}
|
||||
{eff.type === 'multicast' && `✨ Multicast`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{learned ? (
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-green-900/50 text-green-300">Learned</Badge>
|
||||
{isActive && <Badge className="bg-amber-900/50 text-amber-300">Active</Badge>}
|
||||
{!isActive && (
|
||||
<Button size="sm" variant="outline" onClick={() => setSpell(id)}>
|
||||
Set Active
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : isStudying ? (
|
||||
<div className="space-y-1">
|
||||
<Progress
|
||||
value={Math.min(100, ((state?.studyProgress || 0) / studyTime) * 100)}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
<div className="text-xs text-purple-400">
|
||||
Studying... {formatStudyTime(state?.studyProgress || 0)}/{formatStudyTime(studyTime)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className={studySpeedMult > 1 ? 'text-green-400' : ''}>
|
||||
Study: {formatStudyTime(studyTime)}{studySpeedMult > 1 && <span className="text-xs ml-1">({Math.round(studySpeedMult * 100)}% speed)</span>}
|
||||
</span>
|
||||
{' • '}
|
||||
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||||
Cost: {fmt(unlockCost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canStudy ? 'default' : 'outline'}
|
||||
disabled={!canStudy}
|
||||
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||
onClick={() => setCurrentStudyTarget({ type: 'spell', id, progress: 0, required: studyTime })}
|
||||
>
|
||||
Start Study ({fmt(unlockCost)} mana)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SpellsTab.displayName = "SpellsTab";
|
||||
@@ -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';
|
||||
@@ -32,35 +31,34 @@ export function UpgradeDialog({
|
||||
onOpenChange,
|
||||
}: UpgradeDialogProps) {
|
||||
if (!skillId) return null;
|
||||
|
||||
const skillDef = SKILLS_DEF[skillId];
|
||||
|
||||
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{available.map((upgrade) => {
|
||||
const isSelected = currentSelections.includes(upgrade.id);
|
||||
const canToggle = currentSelections.length < 2 || isSelected;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canToggle
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||
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={() => {
|
||||
@@ -93,15 +91,15 @@ export function UpgradeDialog({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onConfirm}
|
||||
disabled={currentSelections.length !== 2}
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 +23,6 @@ import {
|
||||
removeEffectFromDesign,
|
||||
} from './EnchantmentDesigner/utils';
|
||||
import { useCraftingStore } from '@/lib/game/stores';
|
||||
import { useSkillStore } from '@/lib/game/stores';
|
||||
|
||||
export function EnchantmentDesigner({
|
||||
selectedEquipmentType,
|
||||
@@ -44,15 +43,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 +54,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 +85,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 +109,8 @@ export function EnchantmentDesigner({
|
||||
setSelectedEffects={setSelectedEffects}
|
||||
availableEffects={availableEffects}
|
||||
incompatibleEffects={incompatibleEffects}
|
||||
enchantingLevel={enchantingLevel}
|
||||
efficiencyBonus={efficiencyBonus}
|
||||
enchantingLevel={0}
|
||||
efficiencyBonus={0}
|
||||
designProgress={designProgress}
|
||||
addEffect={addEffect}
|
||||
removeEffect={removeEffect}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,9 +11,10 @@ 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, EquipmentSlot } from '@/lib/game/types';
|
||||
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 { useGameStore, useCraftingStore, useManaStore } from '@/lib/game/stores';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
|
||||
export interface EnchantmentPreparerProps {
|
||||
@@ -30,7 +31,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);
|
||||
|
||||
|
||||
@@ -8,23 +8,221 @@ 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';
|
||||
|
||||
// ─── 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);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Crafting: {recipe?.name}
|
||||
</div>
|
||||
<Progress value={(progress.progress / progress.required) * 100} className="h-3" />
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blueprint Card ───────────────────────────────────────────────────────────
|
||||
|
||||
function BlueprintCard({ bpId, lootInventory, rawMana, isCrafting }: {
|
||||
bpId: string;
|
||||
lootInventory: LootInventory;
|
||||
rawMana: number;
|
||||
isCrafting: boolean;
|
||||
}) {
|
||||
const recipe = CRAFTING_RECIPES[bpId];
|
||||
if (!recipe) return null;
|
||||
|
||||
const { canCraft, missingMaterials } = canCraftRecipe(recipe, lootInventory.materials, rawMana);
|
||||
const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity];
|
||||
const startCraftingEquipment = useCraftingStore((s) => s.startCraftingEquipment);
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
|
||||
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">
|
||||
{recipe.equipmentTypeId ? 'Equipment' : 'Other'}
|
||||
</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">
|
||||
<div className="text-gray-500">Materials:</div>
|
||||
{Object.entries(recipe.materials).map(([matId, amount]) => {
|
||||
const available = lootInventory.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 Cost:</span>
|
||||
<span className={rawMana >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
||||
{fmt(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={() => startCraftingEquipment(bpId)}
|
||||
>
|
||||
{canCraft ? 'Craft Equipment' : 'Missing Resources'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blueprint List ───────────────────────────────────────────────────────────
|
||||
|
||||
function BlueprintList({ lootInventory, rawMana }: { lootInventory: LootInventory; rawMana: number }) {
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
|
||||
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'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Material Card ────────────────────────────────────────────────────────────
|
||||
|
||||
function MaterialCard({ matId, count }: { matId: string; count: number }) {
|
||||
const drop = LOOT_DROPS[matId];
|
||||
if (!drop) return null;
|
||||
|
||||
const rarityStyle = LOOT_RARITY_COLORS[drop.rarity];
|
||||
const deleteMaterial = useCraftingStore((s) => s.deleteMaterial);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-gray-800/50 group relative"
|
||||
style={{ borderColor: rarityStyle?.color }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">x{count}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||
onClick={() => deleteMaterial(matId, count)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Materials Inventory ─────────────────────────────────────────────────────
|
||||
|
||||
function MaterialsInventory({ materials }: { materials: Record<string, number> }) {
|
||||
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} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
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">
|
||||
@@ -34,166 +232,16 @@ export function EquipmentCrafter() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{equipmentCraftingProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Crafting: {CRAFTING_RECIPES[equipmentCraftingProgress.blueprintId]?.name}
|
||||
</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: {fmt(equipmentCraftingProgress.manaSpent)}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>Cancel</Button>
|
||||
</div>
|
||||
<CraftingProgress progress={equipmentCraftingProgress} />
|
||||
) : (
|
||||
<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 => {
|
||||
const recipe = CRAFTING_RECIPES[bpId];
|
||||
if (!recipe) return null;
|
||||
|
||||
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
|
||||
recipe,
|
||||
lootInventory.materials,
|
||||
rawMana
|
||||
);
|
||||
|
||||
const rarityStyle = RARITY_COLORS[recipe.rarity];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={bpId}
|
||||
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">
|
||||
{recipe.equipmentTypeId ? 'Equipment' : 'Other'}
|
||||
</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">
|
||||
<div className="text-gray-500">Materials:</div>
|
||||
{Object.entries(recipe.materials).map(([matId, amount]) => {
|
||||
const available = lootInventory.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 Cost:</span>
|
||||
<span className={rawMana >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
||||
{fmt(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 || currentAction === 'craft'}
|
||||
onClick={() => startCraftingEquipment(bpId)}
|
||||
>
|
||||
{canCraft ? 'Craft Equipment' : 'Missing Resources'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<BlueprintList lootInventory={lootInventory} rawMana={rawMana} />
|
||||
)}
|
||||
</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;
|
||||
const drop = LOOT_DROPS[matId];
|
||||
if (!drop) return null;
|
||||
|
||||
const rarityStyle = RARITY_COLORS[drop.rarity];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={matId}
|
||||
className="p-2 rounded border bg-gray-800/50 group relative"
|
||||
style={{ borderColor: rarityStyle?.color }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">x{count}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||
onClick={() => deleteMaterial(matId, count)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MaterialsInventory materials={lootInventory.materials} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EquipmentCrafter.displayName = "EquipmentCrafter";
|
||||
EquipmentCrafter.displayName = 'EquipmentCrafter';
|
||||
|
||||
@@ -6,18 +6,227 @@ 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 {
|
||||
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';
|
||||
|
||||
// ─── Warning Banner ──────────────────────────────────────────────────────────
|
||||
|
||||
function WarningBanner() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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({ elements, onUnlockBase, onUnlockUtility, onSkipToFloor, onResetFloorHP }: {
|
||||
elements: Record<string, { unlocked?: boolean }>;
|
||||
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">
|
||||
<Settings 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={onUnlockUtility}>
|
||||
Unlock Utility 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 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);
|
||||
@@ -26,12 +235,9 @@ export function GameStateDebug() {
|
||||
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) {
|
||||
@@ -49,225 +255,52 @@ export function GameStateDebug() {
|
||||
}
|
||||
};
|
||||
|
||||
const getMaxMana = () => {
|
||||
return computeMaxMana(
|
||||
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">
|
||||
{/* 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>
|
||||
|
||||
{/* Display Options */}
|
||||
<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>
|
||||
<WarningBanner />
|
||||
<DisplayOptions />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Game Reset */}
|
||||
<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={handleReset}
|
||||
>
|
||||
{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 */}
|
||||
<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} / {getMaxMana() || '?'}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddMana(10)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +10
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddMana(100)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +100
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddMana(1000)}>
|
||||
<Zap className="w-3 h-3 mr-1" /> +1K
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddMana(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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Fill Mana
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Time Control */}
|
||||
<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={() => 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>
|
||||
</div>
|
||||
<Separator className="bg-gray-700" />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={togglePause}
|
||||
>
|
||||
{paused ? '▶ Resume' : '⏸ Pause'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skills Debug - Quick Actions */}
|
||||
<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">
|
||||
<Settings className="w-4 h-4" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</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);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Unlock All Base Elements
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Unlock utility elements
|
||||
['transference'].forEach(e => {
|
||||
if (!elements[e]?.unlocked) {
|
||||
unlockElement(e, 500);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Unlock Utility Elements
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => debugSetFloor?.(100)}
|
||||
>
|
||||
Skip to Floor 100
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => resetFloorHP?.()}
|
||||
>
|
||||
Reset Floor HP
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<GameResetSection confirmReset={confirmReset} onReset={handleReset} />
|
||||
<ManaDebugSection rawMana={rawMana} onAddMana={handleAddMana} onFillMana={handleFillMana} />
|
||||
<TimeControlSection day={day} hour={hour} paused={paused} onSetDay={handleSetDay} onTogglePause={togglePause} />
|
||||
<QuickActionsSection
|
||||
elements={elements}
|
||||
onUnlockBase={handleUnlockBase}
|
||||
onUnlockUtility={handleUnlockUtility}
|
||||
onSkipToFloor={() => debugSetFloor?.(100)}
|
||||
onResetFloorHP={() => resetFloorHP?.()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GameStateDebug.displayName = "GameStateDebug";
|
||||
GameStateDebug.displayName = 'GameStateDebug';
|
||||
|
||||
@@ -6,48 +6,106 @@ import { Bug } from 'lucide-react';
|
||||
import { usePrestigeStore, useManaStore, useUIStore, useGameStore } from '@/lib/game/stores';
|
||||
import { GUARDIANS, ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
// ─── Guardian Pact Row ───────────────────────────────────────────────────────
|
||||
|
||||
function GuardianPactRow({ floor, isSigned, onForceSign, onRemove }: {
|
||||
floor: number;
|
||||
isSigned: boolean;
|
||||
onForceSign: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const guardian = GUARDIANS[floor];
|
||||
|
||||
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 = Object.keys(GUARDIANS || {}).map(Number).sort((a, b) => a - b);
|
||||
|
||||
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 addLog = useUIStore((s) => s.addLog);
|
||||
|
||||
const forcePact = (floor: number) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return;
|
||||
|
||||
// Check if already signed
|
||||
if (signedPacts.includes(floor)) {
|
||||
addLog(`⚠️ 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,13 +120,11 @@ export function PactDebug() {
|
||||
addLog(`📜 DEBUG: Pact with ${guardian.name} force-signed!`);
|
||||
};
|
||||
|
||||
// Remove a pact
|
||||
const removePactHandler = (floor: number) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
|
||||
|
||||
removePact(floor);
|
||||
|
||||
// Remove pact details
|
||||
|
||||
const newSignedPactDetails = { ...signedPactDetails };
|
||||
delete newSignedPactDetails[floor];
|
||||
debugSetPactDetails(newSignedPactDetails);
|
||||
@@ -76,7 +132,6 @@ export function PactDebug() {
|
||||
addLog(`📜 DEBUG: Removed pact with ${guardian?.name || 'Unknown'}!`);
|
||||
};
|
||||
|
||||
// Clear all pacts
|
||||
const clearAllPacts = () => {
|
||||
addLog(`📜 DEBUG: Cleared all pacts!`);
|
||||
debugSetSignedPacts([]);
|
||||
@@ -96,74 +151,23 @@ export function PactDebug() {
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
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);
|
||||
|
||||
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 */}
|
||||
<GuardianPactList
|
||||
signedPacts={signedPacts}
|
||||
onForceSign={forcePact}
|
||||
onRemove={removePactHandler}
|
||||
/>
|
||||
|
||||
{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} |
|
||||
Signed Pacts: {signedPacts.length} |
|
||||
Max Pacts: {1 + (prestigeUpgrades?.pactCapacity || 0)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,4 +176,4 @@ export function PactDebug() {
|
||||
);
|
||||
}
|
||||
|
||||
PactDebug.displayName = "PactDebug";
|
||||
PactDebug.displayName = 'PactDebug';
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { useSkillStore } from '@/lib/game/stores';
|
||||
import { useManaStore } from '@/lib/game/stores';
|
||||
|
||||
export function SkillDebug() {
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
|
||||
const incrementSkillLevel = (skillId: string) => {
|
||||
useSkillStore.getState().incrementSkillLevel(skillId);
|
||||
};
|
||||
|
||||
const setSkillLevel = (skillId: string, level: number) => {
|
||||
useSkillStore.getState().setSkillLevel(skillId, level);
|
||||
};
|
||||
|
||||
const unlockAllEffects = () => {
|
||||
const effectIds = [
|
||||
'spell_manaBolt', 'spell_manaStrike', 'spell_fireball', 'spell_emberShot',
|
||||
'spell_waterJet', 'spell_iceShard', 'spell_gust', 'spell_windSlash',
|
||||
'spell_stoneBullet', 'spell_rockSpike', 'spell_lightLance', 'spell_radiance',
|
||||
'spell_shadowBolt', 'spell_darkPulse', 'spell_drain',
|
||||
'spell_inferno', 'spell_flameWave', 'spell_tidalWave', 'spell_iceStorm',
|
||||
'spell_hurricane', 'spell_windBlade', 'spell_earthquake', 'spell_stoneBarrage',
|
||||
'spell_solarFlare', 'spell_divineSmite', 'spell_voidRift', 'spell_shadowStorm',
|
||||
'spell_pyroclasm', 'spell_tsunami', 'spell_meteorStrike',
|
||||
'spell_spark', 'spell_lightningBolt', 'spell_chainLightning',
|
||||
'spell_stormCall', 'spell_thunderStrike',
|
||||
'spell_metalShard', 'spell_ironFist', 'spell_steelTempest', 'spell_furnaceBlast',
|
||||
'spell_sandBlast', 'spell_sandstorm', 'spell_desertWind', 'spell_duneCollapse',
|
||||
'mana_cap_50', 'mana_cap_100', 'mana_regen_1', 'mana_regen_2', 'mana_regen_5',
|
||||
'click_mana_1', 'click_mana_3',
|
||||
'damage_5', 'damage_10', 'damage_pct_10', 'crit_5', 'attack_speed_10',
|
||||
'meditate_10', 'study_10', 'insight_5',
|
||||
'spell_echo_10', 'guardian_dmg_10', 'overpower_80',
|
||||
'weapon_mana_cap_20', 'weapon_mana_cap_50', 'weapon_mana_cap_100',
|
||||
'weapon_mana_regen_1', 'weapon_mana_regen_2', 'weapon_mana_regen_5',
|
||||
'sword_fire', 'sword_frost', 'sword_lightning', 'sword_void'
|
||||
];
|
||||
useManaStore.setState((prev: any) => {
|
||||
const currentEffects = prev.unlockedEffects || [];
|
||||
const newEffects = [...currentEffects];
|
||||
effectIds.forEach(id => {
|
||||
if (!newEffects.includes(id)) {
|
||||
newEffects.push(id);
|
||||
}
|
||||
});
|
||||
return { ...prev, unlockedEffects: newEffects };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Skill Research Debug
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Enchanting Skills */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-2">Enchanting Skills:</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Level up all enchanting skills by 1
|
||||
const enchantSkills = ['enchanting', 'efficientEnchant', 'enchantSpeed', 'essenceRefining'];
|
||||
enchantSkills.forEach(skillId => {
|
||||
const currentLevel = skills[skillId] || 0;
|
||||
if (currentLevel < 10) {
|
||||
incrementSkillLevel(skillId);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
+1 All Enchanting
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Max all enchanting skills
|
||||
const enchantSkills = ['enchanting', 'efficientEnchant', 'enchantSpeed', 'essenceRefining'];
|
||||
enchantSkills.forEach(skillId => {
|
||||
setSkillLevel(skillId, 10);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Max All Enchanting
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mana Skills */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-2">Mana Skills:</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const manaSkills = ['manaWell', 'manaFlow', 'manaOverflow', 'fireManaCap', 'waterManaCap', 'airManaCap', 'earthManaCap', 'lightManaCap', 'darkManaCap', 'deathManaCap', 'metalManaCap', 'sandManaCap', 'lightningManaCap', 'transferenceManaCap'];
|
||||
manaSkills.forEach(skillId => {
|
||||
const currentLevel = skills[skillId] || 0;
|
||||
if (currentLevel < 10) {
|
||||
incrementSkillLevel(skillId);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
+1 All Mana
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const manaSkills = ['manaWell', 'manaFlow', 'manaOverflow', 'fireManaCap', 'waterManaCap', 'airManaCap', 'earthManaCap', 'lightManaCap', 'darkManaCap', 'deathManaCap', 'metalManaCap', 'sandManaCap', 'lightningManaCap', 'transferenceManaCap'];
|
||||
manaSkills.forEach(skillId => {
|
||||
setSkillLevel(skillId, 10);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Max All Mana
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Study Skills */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-2">Study Skills:</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const studySkills = ['quickLearner', 'focusedMind', 'meditation', 'knowledgeRetention'];
|
||||
studySkills.forEach(skillId => {
|
||||
const currentLevel = skills[skillId] || 0;
|
||||
if (currentLevel < 10) {
|
||||
incrementSkillLevel(skillId);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
+1 All Study
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const studySkills = ['quickLearner', 'focusedMind', 'meditation', 'knowledgeRetention'];
|
||||
studySkills.forEach(skillId => {
|
||||
setSkillLevel(skillId, 10);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Max All Study
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crafting Skills */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-2">Crafting Skills:</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const craftSkills = ['effCrafting', 'fieldRepair', 'elemCrafting'];
|
||||
craftSkills.forEach(skillId => {
|
||||
const currentLevel = skills[skillId] || 0;
|
||||
if (currentLevel < 10) {
|
||||
incrementSkillLevel(skillId);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
+1 All Crafting
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const craftSkills = ['effCrafting', 'fieldRepair', 'elemCrafting'];
|
||||
craftSkills.forEach(skillId => {
|
||||
setSkillLevel(skillId, 10);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Max All Crafting
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Research Effects */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-2">Research Effects:</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const researchSkills = [
|
||||
'researchManaSpells', 'researchFireSpells', 'researchWaterSpells',
|
||||
'researchAirSpells', 'researchEarthSpells', 'researchLightSpells',
|
||||
'researchDarkSpells', 'researchLifeDeathSpells',
|
||||
'researchAdvancedFire', 'researchAdvancedWater', 'researchAdvancedAir',
|
||||
'researchAdvancedEarth', 'researchAdvancedLight', 'researchAdvancedDark',
|
||||
'researchMasterFire', 'researchMasterWater', 'researchMasterEarth',
|
||||
'researchDamageEffects', 'researchCombatEffects', 'researchManaEffects',
|
||||
'researchAdvancedManaEffects', 'researchUtilityEffects'
|
||||
];
|
||||
researchSkills.forEach(skillId => {
|
||||
setSkillLevel(skillId, 1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Unlock All Research
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
unlockAllEffects();
|
||||
}}
|
||||
>
|
||||
Unlock All Effects
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Max All */}
|
||||
<Separator className="bg-gray-700" />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => {
|
||||
// Max all skills to level 10
|
||||
Object.keys(skills).forEach(skillId => {
|
||||
setSkillLevel(skillId, 10);
|
||||
});
|
||||
// Unlock all effects
|
||||
unlockAllEffects();
|
||||
}}
|
||||
>
|
||||
🚀 Max Everything
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
SkillDebug.displayName = "SkillDebug";
|
||||
@@ -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';
|
||||
Executable → Regular
+241
-26
@@ -1,35 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
|
||||
import { useCombatStore, useManaStore, usePrestigeStore } from '@/lib/game/stores';
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
import { useState, useMemo, useEffect } 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';
|
||||
|
||||
export function AchievementsTab() {
|
||||
const achievements = useCombatStore((s) => s.achievements);
|
||||
const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
|
||||
const totalManaGathered = useManaStore((s) => s.totalManaGathered);
|
||||
const signedPacts = usePrestigeStore((s) => s.signedPacts);
|
||||
const totalSpellsCast = useCombatStore((s) => s.totalSpellsCast);
|
||||
const totalDamageDealt = useCombatStore((s) => s.totalDamageDealt);
|
||||
const totalCraftsCompleted = useCombatStore((s) => s.totalCraftsCompleted);
|
||||
// ─── 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 (
|
||||
<DebugName name="AchievementsTab">
|
||||
<div className="space-y-4">
|
||||
<AchievementsDisplay
|
||||
achievements={achievements}
|
||||
gameState={{
|
||||
maxFloorReached,
|
||||
totalManaGathered,
|
||||
signedPacts,
|
||||
totalSpellsCast,
|
||||
totalDamageDealt,
|
||||
totalCraftsCompleted,
|
||||
}}
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DebugName>
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
||||
AchievementsTab.displayName = "AchievementsTab";
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function AchievementsTab() {
|
||||
const achievements = useCombatStore((s) => s.achievements);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
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],
|
||||
}));
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
Loading achievements…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@@ -1,69 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { ActivityLogEntry } from '@/lib/game/types';
|
||||
|
||||
interface ActivityLogProps {
|
||||
activityLog?: ActivityLogEntry[];
|
||||
activityLog: ActivityLogEntry[];
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
export function ActivityLog({ activityLog, maxEntries = 50 }: ActivityLogProps) {
|
||||
const entries = activityLog || [];
|
||||
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 (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-48">
|
||||
<div className="space-y-1">
|
||||
{entries.slice(0, maxEntries).map((entry, i) => {
|
||||
const isLatest = i === 0;
|
||||
const color = getEventStyle(entry.eventType);
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`text-xs ${isLatest ? 'text-gray-200 font-semibold' : color}`}
|
||||
>
|
||||
{entry.message}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{entries.length === 0 && (
|
||||
<div className="text-xs text-gray-500 italic">No activity yet...</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function getEventStyle(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case 'enemy_defeated':
|
||||
case 'floor_cleared':
|
||||
return 'text-green-400';
|
||||
case 'damage_dealt':
|
||||
return 'text-red-400';
|
||||
case 'dodge':
|
||||
return 'text-yellow-400';
|
||||
case 'armor_proc':
|
||||
return 'text-blue-400';
|
||||
case 'special_effect':
|
||||
return 'text-purple-400';
|
||||
case 'floor_transition':
|
||||
return 'text-cyan-400';
|
||||
case 'spell_cast':
|
||||
return 'text-amber-400';
|
||||
case 'golem_attack':
|
||||
return 'text-orange-400';
|
||||
case 'puzzle_solved':
|
||||
return 'text-pink-400';
|
||||
default:
|
||||
return 'text-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Executable → Regular
+207
-256
@@ -1,270 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getTotalAttunementRegen, getAvailableSkillCategories, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL, getAttunementConversionRate } from '@/lib/game/data/attunements';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import type { AttunementState } from '@/lib/game/types';
|
||||
import { usePrestigeStore, useManaStore } from '@/lib/game/stores';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAttunementStore } from '@/lib/game/stores';
|
||||
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements';
|
||||
import type { AttunementDef, AttunementState } from '@/lib/game/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Lock, TrendingUp } from 'lucide-react';
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
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';
|
||||
|
||||
export function AttunementsTab() {
|
||||
const attunements = usePrestigeStore((s) => s.attunements) || {};
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
|
||||
// Get active attunements
|
||||
const activeAttunements = Object.entries(attunements)
|
||||
.filter(([, state]) => state.active)
|
||||
.map(([id]) => ATTUNEMENTS_DEF[id])
|
||||
.filter(Boolean);
|
||||
|
||||
// Calculate total regen from attunements
|
||||
const totalAttunementRegen = getTotalAttunementRegen(attunements);
|
||||
|
||||
// Get available skill categories
|
||||
const availableCategories = getAvailableSkillCategories(attunements);
|
||||
// ─── 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 (
|
||||
<DebugName name="AttunementsTab">
|
||||
<div className="space-y-4">
|
||||
{/* Overview Card */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">Your Attunements</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Attunements are magical bonds tied to specific body locations. Each attunement grants unique capabilities,
|
||||
mana regeneration, and access to specialized skills. Level them up to increase their power.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge className="bg-teal-900/50 text-teal-300">
|
||||
+{totalAttunementRegen.toFixed(1)} raw mana/hr
|
||||
</Badge>
|
||||
<Badge className="bg-purple-900/50 text-purple-300">
|
||||
{activeAttunements.length} active attunement{activeAttunements.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{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>
|
||||
|
||||
{/* Attunement Slots */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
|
||||
const state = attunements[id];
|
||||
const isActive = state?.active;
|
||||
const isUnlocked = state?.active || def.unlocked;
|
||||
const level = state?.level || 1;
|
||||
const xp = state?.experience || 0;
|
||||
const xpNeeded = getAttunementXPForLevel(level + 1);
|
||||
const xpProgress = xpNeeded > 0 ? (xp / xpNeeded) * 100 : 100;
|
||||
const isMaxLevel = level >= MAX_ATTUNEMENT_LEVEL;
|
||||
|
||||
// Get primary mana element info
|
||||
const primaryElem = def.primaryManaType ? ELEMENTS[def.primaryManaType] : null;
|
||||
|
||||
// Get current mana for this attunement's type
|
||||
const currentMana = def.primaryManaType ? elements[def.primaryManaType]?.current || 0 : 0;
|
||||
const maxMana = def.primaryManaType ? elements[def.primaryManaType]?.max || 50 : 50;
|
||||
|
||||
// Calculate level-scaled stats
|
||||
const levelMult = Math.pow(1.5, level - 1);
|
||||
const scaledRegen = def.rawManaRegen * levelMult;
|
||||
const scaledConversion = getAttunementConversionRate(id, level);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={id}
|
||||
className={`bg-gray-900/80 transition-all ${
|
||||
isActive
|
||||
? 'border-2 shadow-lg'
|
||||
: isUnlocked
|
||||
? 'border-gray-600'
|
||||
: 'border-gray-800 opacity-70'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: isActive ? def.color : undefined,
|
||||
boxShadow: isActive ? `0 0 20px ${def.color}30` : undefined
|
||||
}}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{def.icon}</span>
|
||||
<div>
|
||||
<CardTitle className="text-sm" style={{ color: isActive ? def.color : '#9CA3AF' }}>
|
||||
{def.name}
|
||||
</CardTitle>
|
||||
<div className="text-xs text-gray-500">
|
||||
{ATTUNEMENT_SLOT_NAMES[def.slot]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isUnlocked && (
|
||||
<Lock className="w-4 h-4 text-gray-600" />
|
||||
)}
|
||||
{isActive && (
|
||||
<Badge className="text-xs" style={{ backgroundColor: `${def.color}30`, color: def.color }}>
|
||||
Lv.{level}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-400">{def.desc}</p>
|
||||
|
||||
{/* Mana Type */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Primary Mana</span>
|
||||
{primaryElem ? (
|
||||
<span style={{ color: primaryElem.color }}>
|
||||
{primaryElem.sym} {primaryElem.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-purple-400">From Pacts</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mana bar (only for attunements with primary type) */}
|
||||
{primaryElem && isActive && (
|
||||
<div className="space-y-1">
|
||||
<Progress
|
||||
value={(currentMana / maxMana) * 100}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>{currentMana.toFixed(1)}</span>
|
||||
<span>/{maxMana}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats with level scaling */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="p-2 bg-gray-800/50 rounded">
|
||||
<div className="text-gray-500">Raw Regen</div>
|
||||
<div className="text-green-400 font-semibold">
|
||||
+{scaledRegen.toFixed(2)}/hr
|
||||
{level > 1 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 bg-gray-800/50 rounded">
|
||||
<div className="text-gray-500">Conversion</div>
|
||||
<div className="text-cyan-400 font-semibold">
|
||||
{scaledConversion > 0 ? `${scaledConversion.toFixed(2)}/hr` : '—'}
|
||||
{level > 1 && scaledConversion > 0 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* XP Progress Bar */}
|
||||
{isUnlocked && state && !isMaxLevel && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
XP Progress
|
||||
</span>
|
||||
<span className="text-amber-400">{xp} / {xpNeeded}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={xpProgress}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isMaxLevel ? 'Max Level' : `${xpNeeded - xp} XP to Level ${level + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Max Level Indicator */}
|
||||
{isMaxLevel && (
|
||||
<div className="text-xs text-amber-400 text-center font-semibold">
|
||||
✨ MAX LEVEL ✨
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Capabilities */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-500">Capabilities</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{def.capabilities.map(cap => (
|
||||
<Badge key={cap} variant="outline" className="text-xs">
|
||||
{cap === 'enchanting' && '✨ Enchanting'}
|
||||
{cap === 'disenchanting' && '🔄 Disenchant'} {/* TODO: Remove after bug 13 complete */}
|
||||
{cap === 'pacts' && '🤝 Pacts'}
|
||||
{cap === 'guardianPowers' && '💜 Guardian Powers'}
|
||||
{cap === 'elementalMastery' && '🌟 Elem. Mastery'}
|
||||
{cap === 'golemCrafting' && '🗿 Golems'}
|
||||
{cap === 'gearCrafting' && '⚒️ Gear'}
|
||||
{cap === 'earthShaping' && '⛰️ Earth Shaping'}
|
||||
{!['enchanting', 'pacts', 'guardianPowers',
|
||||
'elementalMastery', 'golemCrafting', 'gearCrafting', 'earthShaping'].includes(cap) && cap}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unlock condition for locked attunements */}
|
||||
{!isUnlocked && def.unlockCondition && (
|
||||
<div className="text-xs text-amber-400 italic">
|
||||
🔒 {def.unlockCondition}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Description */}
|
||||
<p className="text-xs text-gray-400 leading-relaxed">{def.desc}</p>
|
||||
|
||||
{/* Available Skills Summary */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">Available Skill Categories</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Your attunements grant access to specialized skill categories:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableCategories.map(cat => {
|
||||
const attunement = Object.values(ATTUNEMENTS_DEF || {}).find(a =>
|
||||
a.skillCategories.includes(cat) && attunements[a.id]?.active
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
key={cat}
|
||||
className={attunement ? '' : 'bg-gray-700/50 text-gray-400'}
|
||||
style={attunement ? {
|
||||
backgroundColor: `${attunement.color}30`,
|
||||
color: attunement.color
|
||||
} : undefined}
|
||||
>
|
||||
{cat === 'mana' && '💧 Mana'}
|
||||
{cat === 'study' && '📚 Study'}
|
||||
{cat === 'research' && '🔮 Research'} {/* TODO: Remove after Bug 12 - research moved to mana */}
|
||||
{cat === 'ascension' && '⭐ Ascension'}
|
||||
{cat === 'enchant' && '✨ Enchanting'}
|
||||
{cat === 'effectResearch' && '🔬 Effect Research'}
|
||||
{cat === 'invocation' && '💜 Invocation'}
|
||||
{cat === 'pact' && '🤝 Pact Mastery'}
|
||||
{cat === 'fabrication' && '⚒️ Fabrication'}
|
||||
{cat === 'golemancy' && '🗿 Golemancy'}
|
||||
{!['mana', 'study', 'research', 'ascension', 'enchant', 'effectResearch',
|
||||
'invocation', 'pact', 'fabrication', 'golemancy'].includes(cat) && cat}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{/* 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DebugName>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
AttunementsTab.displayName = "AttunementsTab";
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function AttunementsTab() {
|
||||
const attunements = useAttunementStore((s) => s.attunements);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const allDefs = Object.values(ATTUNEMENTS_DEF);
|
||||
const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length;
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
Loading attunements…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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';
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
// CategorySkillsList - Displays skills for a specific category
|
||||
// Migrated to use hooks directly (removed GameStore prop)
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
import { useSkillStore, useGameStore, usePrestigeStore } from '@/lib/game/stores';
|
||||
import { SkillRow } from './SkillRow';
|
||||
import type { GameStore } from '@/lib/game/stores'; // Keep type import for backward compatibility
|
||||
|
||||
interface CategorySkillsListProps {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
skills: Record<string, number>;
|
||||
skillUpgrades: Record<string, string[]>;
|
||||
skillTiers: Record<string, number>;
|
||||
prestigeUpgrades: Record<string, number>;
|
||||
studySpeedMult: number;
|
||||
upgradeEffects: any;
|
||||
currentStudyTarget: any;
|
||||
onStartStudying: (skillId: string) => void;
|
||||
onParallelStudy: (skillId: string) => void;
|
||||
onCancelStudy: () => void;
|
||||
onOpenUpgradeDialog: (skillId: string, milestone: 5 | 10) => void;
|
||||
onTierUp: (skillId: string) => void;
|
||||
pendingSelections: string[];
|
||||
setPendingSelections: (selections: string[]) => void;
|
||||
}
|
||||
|
||||
export function CategorySkillsList({
|
||||
categoryId,
|
||||
categoryName,
|
||||
skills,
|
||||
skillUpgrades,
|
||||
skillTiers,
|
||||
prestigeUpgrades,
|
||||
studySpeedMult,
|
||||
upgradeEffects,
|
||||
currentStudyTarget,
|
||||
onStartStudying,
|
||||
onParallelStudy,
|
||||
onCancelStudy,
|
||||
onOpenUpgradeDialog,
|
||||
onTierUp,
|
||||
pendingSelections,
|
||||
setPendingSelections,
|
||||
}: CategorySkillsListProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const categorySkills = Object.entries(SKILLS_DEF || {})
|
||||
.filter(([, def]) => def.category === categoryId)
|
||||
.sort((a, b) => (a[1].tier || 0) - (b[1].tier || 0));
|
||||
|
||||
const toggleCollapse = () => setCollapsed(!collapsed);
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="flex items-center gap-2 mb-2 cursor-pointer"
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
<span className="text-sm font-semibold text-gray-300">
|
||||
{categoryName} ({categorySkills.length})
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{collapsed ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-2">
|
||||
{categorySkills.map(([skillId, def]) => {
|
||||
const skillLevel = skills[skillId] || 0;
|
||||
const tier = skillTiers[skillId] || 0;
|
||||
const tierMult = getTierMultiplier(skillId)(tier);
|
||||
const isStudying = currentStudyTarget?.id === skillId;
|
||||
const isParallel = currentStudyTarget?.type === 'parallel' && currentStudyTarget?.id === skillId;
|
||||
|
||||
// Get upgrade choices for this skill
|
||||
const store = useGameStore.getState();
|
||||
const { available, selected } = store.getSkillUpgradeChoices(skillId, tier as 5 | 10);
|
||||
|
||||
return (
|
||||
<SkillRow
|
||||
key={skillId}
|
||||
skillId={skillId}
|
||||
skillDef={def}
|
||||
skillLevel={skillLevel}
|
||||
tier={tier}
|
||||
tierMult={tierMult}
|
||||
isStudying={isStudying}
|
||||
isParallel={isParallel}
|
||||
studySpeedMult={studySpeedMult}
|
||||
upgradeEffects={upgradeEffects}
|
||||
availableUpgrades={available}
|
||||
selectedUpgrades={selected}
|
||||
pendingSelections={pendingSelections}
|
||||
onToggleUpgrade={(upgradeId) => {
|
||||
if (pendingSelections.includes(upgradeId)) {
|
||||
setPendingSelections(pendingSelections.filter(id => id !== upgradeId));
|
||||
} else {
|
||||
setPendingSelections([...pendingSelections, upgradeId]);
|
||||
}
|
||||
}}
|
||||
onStartStudying={() => onStartStudying(skillId)}
|
||||
onParallelStudy={() => onParallelStudy(skillId)}
|
||||
onTierUp={() => onTierUp(skillId)}
|
||||
onOpenUpgradeDialog={(milestone) => onOpenUpgradeDialog(skillId, milestone)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { Zap, Shield, ShieldCheck, Wind, Heart, Mountain, BookOpen } from 'lucide-react';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems';
|
||||
import type { CombatStatsPanelProps } from '@/lib/game/types';
|
||||
import { useCombatStore } from '@/lib/game/stores';
|
||||
import { useSkillStore } from '@/lib/game/stores';
|
||||
import { usePrestigeStore } from '@/lib/game/stores';
|
||||
|
||||
export function CombatStatsPanel({
|
||||
activeEquipmentSpells,
|
||||
totalDPS,
|
||||
calcDamage,
|
||||
formatSpellCost,
|
||||
getSpellCostColor,
|
||||
SPELLS_DEF,
|
||||
upgradeEffects,
|
||||
canCastSpell,
|
||||
studySpeedMult,
|
||||
storeCurrentAction,
|
||||
}: CombatStatsPanelProps) {
|
||||
const golemancy = useCombatStore((s) => s.golemancy);
|
||||
const equipmentSpellStates = useCombatStore((s) => s.equipmentSpellStates);
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const signedPacts = usePrestigeStore((s) => s.signedPacts);
|
||||
const activeGolems = golemancy.summonedGolems;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Combat Stats</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Total DPS: <span className="text-amber-400 font-semibold">{storeCurrentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
|
||||
</div>
|
||||
|
||||
{activeEquipmentSpells.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-gray-500 font-semibold uppercase tracking-wider">Active Spells</div>
|
||||
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
if (!spellDef) return null;
|
||||
const spellState = equipmentSpellStates?.find(
|
||||
s => s.spellId === spellId && s.sourceEquipment === equipmentId
|
||||
);
|
||||
const progress = spellState?.castProgress || 0;
|
||||
const canCast = canCastSpell(spellId);
|
||||
|
||||
return (
|
||||
<div key={`${spellId}-${equipmentId}`} className="p-2 bg-gray-800/50 rounded border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
|
||||
{spellDef.name}
|
||||
{spellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200 text-xs">Basic</Badge>}
|
||||
{spellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100 text-xs">Legendary</Badge>}
|
||||
</span>
|
||||
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{canCast ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-1">
|
||||
⚔️ {fmt(calcDamage({ skills, signedPacts }, spellId))} dmg • {' '}
|
||||
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
|
||||
{formatSpellCost(spellDef.cost)}
|
||||
</span>
|
||||
{' '}• ⚡ {fmt(Math.floor(calcDamage({ skills, signedPacts }, spellId) * (spellDef.castSpeed || 1)))} dmg/hr
|
||||
</div>
|
||||
{storeCurrentAction === 'climb' && (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>Cast</span>
|
||||
<span>{(progress * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, progress * 100)}%`,
|
||||
background: `linear-gradient(90deg, ${spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color}99, ${spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeGolems.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 font-semibold uppercase tracking-wider">
|
||||
<Mountain className="w-3 h-3" />
|
||||
Active Golems
|
||||
</div>
|
||||
{activeGolems.map((summoned) => {
|
||||
const golemDef = GOLEMS_DEF[summoned.golemId];
|
||||
if (!golemDef) return null;
|
||||
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
|
||||
const damage = getGolemDamage(summoned.golemId, skills);
|
||||
const attackSpeed = getGolemAttackSpeed(summoned.golemId, skills);
|
||||
|
||||
return (
|
||||
<div key={summoned.golemId} className="p-2 bg-gray-800/50 rounded border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mountain className="w-3 h-3" style={{ color: elemColor }} />
|
||||
<span className="text-xs font-semibold" style={{ color: elemColor }}>
|
||||
{golemDef.name}
|
||||
</span>
|
||||
</div>
|
||||
{golemDef.isAoe && (
|
||||
<Badge variant="outline" className="text-xs py-0">AOE {golemDef.aoeTargets}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
⚔️ {damage} DMG • ⚡ {attackSpeed.toFixed(1)}/hr
|
||||
</div>
|
||||
{storeCurrentAction === 'climb' && summoned.attackProgress > 0 && (
|
||||
<div className="space-y-0.5 mt-1">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>Attack</span>
|
||||
<span>{Math.min(100, (summoned.attackProgress * 100)).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, summoned.attackProgress * 100)}%`,
|
||||
background: `linear-gradient(90deg, ${elemColor}99, ${elemColor})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-gray-700">
|
||||
<div className="text-xs text-gray-500 mb-1">Study Speed: {Math.round(studySpeedMult * 100)}%</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
|
||||
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
|
||||
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
|
||||
return value.toFixed(0);
|
||||
}
|
||||
|
||||
function fmtDec(value: number): string {
|
||||
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
|
||||
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
|
||||
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
|
||||
return value.toFixed(0);
|
||||
}
|
||||
@@ -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, FABRICATOR_RECIPES } = 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);
|
||||
});
|
||||
});
|
||||
Executable → Regular
+39
-254
@@ -1,267 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Scroll, Hammer, Sparkles, Anvil } from 'lucide-react';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import {
|
||||
EnchantmentDesigner,
|
||||
EnchantmentPreparer,
|
||||
EnchantmentApplier,
|
||||
EquipmentCrafter,
|
||||
} from '@/components/game/crafting';
|
||||
import { useCombatStore, useCraftingStore } from '@/lib/game/stores';
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
import type { DesignEffect } from '@/lib/game/types';
|
||||
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 showToast = useGameToast();
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
const designProgress = useCraftingStore((s) => s.designProgress);
|
||||
const preparationProgress = useCraftingStore((s) => s.preparationProgress);
|
||||
const applicationProgress = useCraftingStore((s) => s.applicationProgress);
|
||||
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
||||
const pauseApplication = useCraftingStore((s) => s.pauseApplication);
|
||||
const resumeApplication = useCraftingStore((s) => s.resumeApplication);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'fabricate' | 'enchant'>('fabricate');
|
||||
const [enchantStage, setEnchantStage] = useState<'design' | 'prepare' | 'apply'>('design');
|
||||
|
||||
// Enchant state
|
||||
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);
|
||||
|
||||
// Safe toFixed helper
|
||||
const safeToFixed = (value: number | undefined, decimals: number = 0): string => {
|
||||
if (value === undefined || isNaN(value)) return '0';
|
||||
return value.toFixed(decimals);
|
||||
};
|
||||
|
||||
// Safe percentage calculation
|
||||
const calcPercent = (progress: number, required: number): number => {
|
||||
if (!required || required === 0) return 0;
|
||||
return (progress / required) * 100;
|
||||
};
|
||||
|
||||
// Handle enchantment application with toast
|
||||
const handleEnchantmentApplied = () => {
|
||||
showToast('success', 'Enchantment Applied', 'The enchantment has been successfully applied!');
|
||||
};
|
||||
|
||||
// Handle enchantment capacity exceeded
|
||||
const handleCapacityExceeded = (itemName: string, used: number, total: number) => {
|
||||
showToast('error', 'Enchantment Capacity Exceeded', `${itemName} can only hold ${total} enchantments (${used}/${total} used). Remove some enchantments first.`);
|
||||
};
|
||||
const [activeSubTab, setActiveSubTab] = useState<CraftingAttunement>('fabricator');
|
||||
|
||||
return (
|
||||
<DebugName name="CraftingTab">
|
||||
<div className="space-y-4 max-w-full overflow-x-hidden">
|
||||
{/* Top Sub-Tabs: Fabricate / Enchant */}
|
||||
<GameCard variant="default" className="p-4">
|
||||
<div className="flex justify-center gap-2">
|
||||
<ActionButton
|
||||
variant={activeTab === 'fabricate' ? 'primary' : 'secondary'}
|
||||
onClick={() => setActiveTab('fabricate')}
|
||||
className={activeTab === 'fabricate' ? 'ring-2 ring-[var(--interactive-primary)]' : ''}
|
||||
>
|
||||
<Anvil size={14} className="mr-1" />
|
||||
Fabricate
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant={activeTab === 'enchant' ? 'primary' : 'secondary'}
|
||||
onClick={() => setActiveTab('enchant')}
|
||||
className={activeTab === 'enchant' ? 'ring-2 ring-[var(--interactive-primary)]' : ''}
|
||||
>
|
||||
<Sparkles size={14} className="mr-1" />
|
||||
Enchant
|
||||
</ActionButton>
|
||||
<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>
|
||||
</GameCard>
|
||||
|
||||
{/* Fabricate Content: EquipmentCrafter */}
|
||||
{activeTab === 'fabricate' && (
|
||||
<EquipmentCrafter />
|
||||
)}
|
||||
|
||||
{/* Enchant Content: Design → Prepare → Apply workflow */}
|
||||
{activeTab === 'enchant' && (
|
||||
<div className="space-y-4">
|
||||
{/* Enchant Sub-Navigation (no numbered stepper) */}
|
||||
<GameCard variant="default" className="p-4">
|
||||
<div className="flex justify-center gap-2 flex-wrap">
|
||||
<ActionButton
|
||||
variant={enchantStage === 'design' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setEnchantStage('design')}
|
||||
className={enchantStage === 'design' ? 'ring-2 ring-[var(--interactive-primary)]' : ''}
|
||||
>
|
||||
<Scroll size={14} className="mr-1" />
|
||||
Design
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant={enchantStage === 'prepare' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setEnchantStage('prepare')}
|
||||
className={enchantStage === 'prepare' ? 'ring-2 ring-[var(--interactive-primary)]' : ''}
|
||||
>
|
||||
<Hammer size={14} className="mr-1" />
|
||||
Prepare
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant={enchantStage === 'apply' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setEnchantStage('apply')}
|
||||
className={enchantStage === 'apply' ? 'ring-2 ring-[var(--interactive-primary)]' : ''}
|
||||
>
|
||||
<Sparkles size={14} className="mr-1" />
|
||||
Apply
|
||||
</ActionButton>
|
||||
</div>
|
||||
</GameCard>
|
||||
|
||||
{/* Enchant Stage Content */}
|
||||
{enchantStage === 'design' && (
|
||||
<EnchantmentDesigner
|
||||
selectedEquipmentType={selectedEquipmentType}
|
||||
setSelectedEquipmentType={setSelectedEquipmentType}
|
||||
selectedEffects={selectedEffects}
|
||||
setSelectedEffects={setSelectedEffects}
|
||||
designName={designName}
|
||||
setDesignName={setDesignName}
|
||||
selectedDesign={selectedDesign}
|
||||
setSelectedDesign={setSelectedDesign}
|
||||
/>
|
||||
)}
|
||||
{enchantStage === 'prepare' && (
|
||||
<EnchantmentPreparer
|
||||
selectedEquipmentInstance={selectedEquipmentInstance}
|
||||
setSelectedEquipmentInstance={setSelectedEquipmentInstance}
|
||||
/>
|
||||
)}
|
||||
{enchantStage === 'apply' && (
|
||||
<EnchantmentApplier
|
||||
selectedEquipmentInstance={selectedEquipmentInstance}
|
||||
setSelectedEquipmentInstance={setSelectedEquipmentInstance}
|
||||
selectedDesign={selectedDesign}
|
||||
setSelectedDesign={setSelectedDesign}
|
||||
onEnchantmentApplied={handleEnchantmentApplied}
|
||||
onCapacityExceeded={handleCapacityExceeded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Activity Indicator: Crafting */}
|
||||
{currentAction === 'craft' && equipmentCraftingProgress && (
|
||||
<GameCard variant="default" className="border-[var(--mana-water)]/60 bg-[var(--mana-water)]/10">
|
||||
<SectionHeader
|
||||
title="Crafting Equipment"
|
||||
action={
|
||||
<span className="text-sm text-[var(--text-muted)]">
|
||||
{safeToFixed(calcPercent(equipmentCraftingProgress.progress, equipmentCraftingProgress.required), 0)}%
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Progress
|
||||
value={calcPercent(equipmentCraftingProgress.progress, equipmentCraftingProgress.required)}
|
||||
className="h-3 bg-[var(--bg-sunken)]"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-[var(--text-secondary)]">
|
||||
<Anvil size={16} className="text-[var(--mana-water)]" />
|
||||
<span>Crafting equipment...</span>
|
||||
</div>
|
||||
</GameCard>
|
||||
)}
|
||||
|
||||
{/* Current Activity Indicator: Designing */}
|
||||
{currentAction === 'design' && designProgress && (
|
||||
<GameCard variant="default" className="border-[var(--mana-stellar)]/60 bg-[var(--mana-stellar)]/10">
|
||||
<SectionHeader
|
||||
title="Designing Enchantment"
|
||||
action={
|
||||
<ActionButton variant="ghost" size="sm" onClick={() => useCraftingStore.getState().cancelDesign()}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<Progress
|
||||
value={calcPercent(designProgress.progress, designProgress.required)}
|
||||
className="h-3 bg-[var(--bg-sunken)]"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-[var(--text-secondary)]">
|
||||
<Scroll size={16} className="text-[var(--mana-stellar)]" />
|
||||
<span>Designing: {designProgress.name}</span>
|
||||
</div>
|
||||
</GameCard>
|
||||
)}
|
||||
|
||||
{/* Current Activity Indicator: Preparing */}
|
||||
{currentAction === 'prepare' && preparationProgress && (
|
||||
<GameCard variant="default" className="border-[var(--color-warning)]/60 bg-[var(--color-warning)]/10">
|
||||
<SectionHeader
|
||||
title="Preparing Equipment"
|
||||
action={
|
||||
<ActionButton variant="ghost" size="sm" onClick={() => useCraftingStore.getState().cancelPreparation()}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<Progress
|
||||
value={calcPercent(preparationProgress.progress, preparationProgress.required)}
|
||||
className="h-3 bg-[var(--bg-sunken)]"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-[var(--text-secondary)]">
|
||||
<Hammer size={16} className="text-[var(--color-warning)]" />
|
||||
<span>Preparing equipment...</span>
|
||||
<span className="text-[var(--text-muted)] ml-auto">
|
||||
Mana paid: {fmt(preparationProgress.manaCostPaid)}
|
||||
</span>
|
||||
</div>
|
||||
</GameCard>
|
||||
)}
|
||||
|
||||
{/* Current Activity Indicator: Enchanting */}
|
||||
{currentAction === 'enchant' && applicationProgress && (
|
||||
<GameCard variant="default" className="border-[var(--mana-light)]/60 bg-[var(--mana-light)]/10">
|
||||
<SectionHeader
|
||||
title={applicationProgress.paused ? "Enchantment Paused" : "Applying Enchantment"}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
{applicationProgress.paused ? (
|
||||
<ActionButton size="sm" onClick={resumeApplication}>Resume</ActionButton>
|
||||
) : (
|
||||
<>
|
||||
<ActionButton variant="secondary" size="sm" onClick={pauseApplication}>Pause</ActionButton>
|
||||
<ActionButton variant="ghost" size="sm" onClick={() => {
|
||||
useCraftingStore.getState().cancelApplication();
|
||||
showToast('warning', 'Enchantment Cancelled', 'The enchantment application was cancelled.');
|
||||
}}>Cancel</ActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Progress
|
||||
value={calcPercent(applicationProgress.progress, applicationProgress.required)}
|
||||
className="h-3 bg-[var(--bg-sunken)]"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-[var(--text-secondary)]">
|
||||
<Sparkles size={16} className="text-[var(--mana-light)]" />
|
||||
<span>{applicationProgress.paused ? 'Enchantment paused' : 'Applying enchantment...'}</span>
|
||||
<span className="text-[var(--text-muted)] ml-auto">
|
||||
{safeToFixed(calcPercent(applicationProgress.progress, applicationProgress.required), 0)}%
|
||||
</span>
|
||||
</div>
|
||||
</GameCard>
|
||||
)}
|
||||
</div>
|
||||
{/* Sub-tab content */}
|
||||
{activeSubTab === 'fabricator' && <FabricatorSubTab />}
|
||||
{activeSubTab === 'enchanter' && <EnchanterSubTab />}
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,260 @@
|
||||
'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, Hammer, Package } from 'lucide-react';
|
||||
import {
|
||||
FABRICATOR_RECIPES,
|
||||
getRecipesByManaType,
|
||||
canCraftRecipe,
|
||||
} from '@/lib/game/data/fabricator-recipes';
|
||||
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 MANA_TYPE_LABELS: Record<string, string> = {
|
||||
earth: '⛰️ Earth',
|
||||
metal: '🔩 Metal',
|
||||
crystal: '💎 Crystal',
|
||||
sand: '🏜️ Sand',
|
||||
};
|
||||
|
||||
function RecipeCard({
|
||||
recipe,
|
||||
materials,
|
||||
manaAmount,
|
||||
onCraft,
|
||||
isCrafting,
|
||||
}: {
|
||||
recipe: FabricatorRecipe;
|
||||
materials: Record<string, number>;
|
||||
manaAmount: number;
|
||||
onCraft: (recipe: FabricatorRecipe) => void;
|
||||
isCrafting: boolean;
|
||||
}) {
|
||||
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
|
||||
recipe,
|
||||
materials,
|
||||
manaAmount,
|
||||
);
|
||||
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={manaAmount >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
||||
{manaAmount} / {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>
|
||||
);
|
||||
}
|
||||
|
||||
export function FabricatorSubTab() {
|
||||
const [selectedManaType, setSelectedManaType] = useState<string>('earth');
|
||||
|
||||
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
||||
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const startCraftingEquipment = useCraftingStore((s) => s.startCraftingEquipment);
|
||||
const cancelEquipmentCrafting = useCraftingStore((s) => s.cancelEquipmentCrafting);
|
||||
|
||||
const availableManaTypes = useMemo(() => {
|
||||
return [...new Set(FABRICATOR_RECIPES.map((r) => r.manaType))];
|
||||
}, []);
|
||||
|
||||
const filteredRecipes = useMemo(
|
||||
() => getRecipesByManaType(selectedManaType),
|
||||
[selectedManaType],
|
||||
);
|
||||
|
||||
const isCrafting = equipmentCraftingProgress !== null;
|
||||
|
||||
const handleCraft = (recipe: FabricatorRecipe) => {
|
||||
// Use the existing equipment crafting system with a fabricator-specific blueprint ID
|
||||
startCraftingEquipment(`fabricator-${recipe.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 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">
|
||||
<Hammer className="w-4 h-4" />
|
||||
{MANA_TYPE_LABELS[selectedManaType] ?? selectedManaType} 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">
|
||||
{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}
|
||||
manaAmount={rawMana}
|
||||
onCraft={handleCraft}
|
||||
isCrafting={isCrafting}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</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,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);
|
||||
});
|
||||
});
|
||||
Executable → Regular
+91
-20
@@ -1,30 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { GameStateDebug } from '@/components/game/debug/GameStateDebug';
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
import {
|
||||
SkillDebug,
|
||||
AttunementDebug,
|
||||
ElementDebug,
|
||||
GolemDebug,
|
||||
PactDebug
|
||||
} from '@/components/game/debug';
|
||||
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">
|
||||
<GameStateDebug />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<AttunementDebug />
|
||||
<ElementDebug />
|
||||
<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>
|
||||
|
||||
<SkillDebug />
|
||||
<GolemDebug />
|
||||
<PactDebug />
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,135 @@
|
||||
'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';
|
||||
|
||||
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 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
const isPaused = disc?.paused ?? true;
|
||||
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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,274 @@
|
||||
'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({ elements, onUnlockBase, onSkipToFloor, onResetFloorHP }: {
|
||||
elements: Record<string, { unlocked?: boolean }>;
|
||||
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
|
||||
elements={elements}
|
||||
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,168 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
// ─── Guardian Pact Row ───────────────────────────────────────────────────────
|
||||
|
||||
function GuardianPactRow({ floor, isSigned, onForceSign, onRemove }: {
|
||||
floor: number;
|
||||
isSigned: boolean;
|
||||
onForceSign: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const guardian = GUARDIANS[floor];
|
||||
|
||||
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 elements = useManaStore((s) => s.elements);
|
||||
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 unlockElement = useManaStore((s) => s.unlockElement);
|
||||
|
||||
const addLog = useUIStore((s) => s.addLog);
|
||||
|
||||
const guardianFloors = Object.keys(GUARDIANS || {}).map(Number).sort((a, b) => a - b);
|
||||
|
||||
const forcePact = (floor: number) => {
|
||||
const guardian = GUARDIANS[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 = GUARDIANS[floor];
|
||||
|
||||
removePact(floor);
|
||||
|
||||
const newSignedPactDetails = { ...signedPactDetails };
|
||||
delete newSignedPactDetails[floor];
|
||||
debugSetPactDetails(newSignedPactDetails);
|
||||
|
||||
addLog(`📜 DEBUG: Removed pact with ${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";
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
|
||||
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
|
||||
import { baseDisciplines } from '@/lib/game/data/disciplines/base';
|
||||
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 } from '@/lib/game/utils/discipline-math';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── Attunement Tabs ─────────────────────────────────────────────────────────
|
||||
|
||||
interface AttunementTab {
|
||||
key: string;
|
||||
label: string;
|
||||
items: DisciplineDefinition[];
|
||||
}
|
||||
|
||||
const ATTUNEMENT_TABS: AttunementTab[] = [
|
||||
{ key: 'base', label: 'Base', items: baseDisciplines },
|
||||
{ key: 'enchanter', label: 'Enchanter', items: enchanterDisciplines },
|
||||
{ key: 'fabricator', label: 'Fabricator', items: fabricatorDisciplines },
|
||||
{ key: 'invoker', label: 'Invoker', items: invokerDisciplines },
|
||||
];
|
||||
|
||||
// ─── Discipline Card Props (split from monolithic 15-field interface) ────────
|
||||
|
||||
export interface DisciplineCardDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
perkThresholds?: number[];
|
||||
perkValues?: number[];
|
||||
perkTypes?: string[];
|
||||
statBonus: string;
|
||||
baseValue: number;
|
||||
drainBase: number;
|
||||
difficultyFactor: number;
|
||||
scalingFactor: number;
|
||||
}
|
||||
|
||||
export interface DisciplineCardRuntime {
|
||||
xp: number;
|
||||
paused: boolean;
|
||||
concurrentLimit: number;
|
||||
}
|
||||
|
||||
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, perkThresholds, perkValues, perkTypes,
|
||||
statBonus, baseValue, drainBase, difficultyFactor, scalingFactor,
|
||||
} = definition;
|
||||
const { xp, paused: isPaused, concurrentLimit } = 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 unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => {
|
||||
const threshold = perkThresholds?.[idx];
|
||||
if (threshold === undefined) return acc;
|
||||
if (typ === 'once' || typ === 'infinite') {
|
||||
if (displayXp >= threshold) acc.push(`${typ}-${idx}`);
|
||||
} else if (typ === 'capped') {
|
||||
const interval = perkValues?.[idx] ?? 1;
|
||||
const tier = Math.max(0, Math.floor((displayXp - threshold) / interval) + 1);
|
||||
if (tier > 0) acc.push(`${typ}-${idx}`);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const toggleAction = () => {
|
||||
onToggle(id, isPaused);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={id} className="border rounded-lg p-4 shadow-sm space-y-3">
|
||||
<h3 className="text-lg font-medium">{name}</h3>
|
||||
<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={`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)} ✦{' '}
|
||||
<strong>XP:</strong> {displayXp}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-sm">
|
||||
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonus}
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<strong>Perks:</strong>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
|
||||
{unlockedPerks && unlockedPerks.length > 0 ? (
|
||||
unlockedPerks.map((p) => (
|
||||
<li key={p} className="text-green-500">{p.replace(/-([0-9]+)$/, ' $1')}</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-gray-400">—locked—</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={toggleAction}
|
||||
className={clsx(
|
||||
'rounded px-3 py-1 text-sm font-medium',
|
||||
isPaused
|
||||
? 'bg-yellow-600 text-white hover:bg-yellow-500'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-500',
|
||||
)}
|
||||
>
|
||||
{isPaused ? 'Activate' : 'Pause'}
|
||||
</button>
|
||||
</div>
|
||||
</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 [mounted, setMounted] = useState(false);
|
||||
const [activeAttunement, setActiveAttunement] = useState<string>('base');
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback((id: string, paused: boolean) => {
|
||||
if (paused) {
|
||||
activate(id);
|
||||
} else {
|
||||
deactivate(id);
|
||||
}
|
||||
}, [activate, deactivate]);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
Loading disciplines…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{ATTUNEMENT_TABS.map((tab) => {
|
||||
const isActiveTab = activeAttunement === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveAttunement(tab.key)}
|
||||
className={clsx('rounded px-3 py-1', {
|
||||
'bg-blue-600 text-white': isActiveTab,
|
||||
'text-gray-600': !isActiveTab,
|
||||
})}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Discipline cards — only render active tab */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{activeTab?.items.map((disc) => {
|
||||
const discState = disciplines[disc.id] ?? { xp: 0, paused: true };
|
||||
return (
|
||||
<DisciplineCard
|
||||
key={disc.id}
|
||||
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),
|
||||
statBonus: disc.statBonus.stat,
|
||||
baseValue: disc.statBonus.baseValue,
|
||||
drainBase: disc.drainBase,
|
||||
difficultyFactor: disc.difficultyFactor,
|
||||
scalingFactor: disc.scalingFactor,
|
||||
}}
|
||||
runtime={{
|
||||
xp: discState.xp,
|
||||
paused: discState.paused,
|
||||
concurrentLimit,
|
||||
}}
|
||||
callbacks={{
|
||||
onToggle: handleToggle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary info */}
|
||||
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-500">
|
||||
<div>Active Disciple{activeIds.length}{activeIds.length === 1 ? '' : 's'} / {concurrentLimit}</div>
|
||||
<div>Concurrent Limit: {concurrentLimit}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface EnchantmentsPanelProps {
|
||||
enchantments: Array<{ effectId: string; stacks: number }>;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function EnchantmentsPanel({
|
||||
enchantments,
|
||||
compact = false,
|
||||
}: EnchantmentsPanelProps) {
|
||||
if (enchantments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-1 ${compact ? 'mt-1' : ''}`}>
|
||||
{enchantments.map((ench, i) => {
|
||||
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
|
||||
return (
|
||||
<TooltipProvider key={i}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs cursor-help border-[var(--border-default)] text-[var(--text-secondary)]"
|
||||
>
|
||||
{effect?.name || ench.effectId}
|
||||
{ench.stacks > 1 && ` x${ench.stacks}`}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<p>{effect?.description || 'Unknown effect'}</p>
|
||||
<p className="text-[var(--text-muted)] text-xs">
|
||||
Category: {effect?.category || 'unknown'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { EquipmentSlot } from '@/lib/game/data/equipment';
|
||||
// GameStore import removed - not used
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface EquipmentControlsProps {
|
||||
onUnequip: (slot: EquipmentSlot) => void;
|
||||
onDelete: (instanceId: string, name: string) => void;
|
||||
selectedSlot: EquipmentSlot | null;
|
||||
isSlotBlocked: (slot: EquipmentSlot) => boolean;
|
||||
isEquipped: (instanceId: string) => boolean;
|
||||
getEquippableSlots: (typeId: string) => EquipmentSlot[];
|
||||
}
|
||||
|
||||
export function EquipmentControls({
|
||||
onUnequip,
|
||||
onDelete,
|
||||
selectedSlot,
|
||||
isSlotBlocked,
|
||||
isEquipped,
|
||||
getEquippableSlots,
|
||||
}: EquipmentControlsProps) {
|
||||
const SLOT_NAMES = {
|
||||
mainHand: 'Main Hand',
|
||||
offHand: 'Off Hand',
|
||||
head: 'Head',
|
||||
body: 'Body',
|
||||
hands: 'Hands',
|
||||
feet: 'Feet',
|
||||
accessory1: 'Accessory 1',
|
||||
accessory2: 'Accessory 2',
|
||||
} as const;
|
||||
|
||||
return {
|
||||
renderUnequipButton: (slot: EquipmentSlot, instanceName: string) => (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnequip(slot);
|
||||
}}
|
||||
aria-label={`Unequip ${instanceName}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionButton>
|
||||
),
|
||||
|
||||
renderDeleteButton: (instanceId: string, name: string) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger 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"
|
||||
onClick={() => onDelete(instanceId, name)}
|
||||
aria-label={`Delete ${name}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<p>Delete this item</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { EquipmentSlot } from '@/lib/game/data/equipment';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import type { EquipmentInstance } from '@/lib/game/types';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { EnchantmentsPanel } from './EnchantmentsPanel';
|
||||
|
||||
interface EquipmentInventoryProps {
|
||||
unequippedItems: EquipmentInstance[];
|
||||
onEquip: (instanceId: string, slot: EquipmentSlot) => void;
|
||||
onDelete: (instanceId: string, name: string) => void;
|
||||
getEquippableSlots: (typeId: string) => EquipmentSlot[];
|
||||
SLOT_NAMES: Record<EquipmentSlot, string>;
|
||||
SLOT_ICONS: Record<EquipmentSlot, React.ElementType>;
|
||||
}
|
||||
|
||||
export function EquipmentInventory({
|
||||
unequippedItems,
|
||||
onEquip,
|
||||
onDelete,
|
||||
getEquippableSlots,
|
||||
SLOT_NAMES,
|
||||
SLOT_ICONS,
|
||||
}: EquipmentInventoryProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
|
||||
{unequippedItems.map((instance) => {
|
||||
const equipmentType = EQUIPMENT_TYPES[instance.typeId];
|
||||
const validSlots = getEquippableSlots(instance.typeId);
|
||||
|
||||
return (
|
||||
<GameCard
|
||||
key={instance.instanceId}
|
||||
variant="default"
|
||||
className={`${getRarityBorderColor(instance.rarity)} ${getRarityBgColor(instance.rarity)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className={`font-semibold text-sm ${getRarityTextColor(instance.rarity)}`}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{equipmentType?.description}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs border-[var(--border-default)] text-[var(--text-secondary)]">
|
||||
{equipmentType?.category || 'unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[var(--text-muted)] space-y-1 mb-2">
|
||||
<div>
|
||||
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
|
||||
{instance.quality < 100 && (
|
||||
<span className="text-[var(--mana-light)] ml-1">
|
||||
(Quality: {instance.quality}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{instance.enchantments.length > 0 && (
|
||||
<EnchantmentsPanel enchantments={instance.enchantments} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{validSlots.length > 0 && (
|
||||
<EquipControls
|
||||
instance={instance}
|
||||
validSlots={validSlots}
|
||||
onEquip={onEquip}
|
||||
onDelete={onDelete}
|
||||
SLOT_NAMES={SLOT_NAMES}
|
||||
SLOT_ICONS={SLOT_ICONS}
|
||||
/>
|
||||
)}
|
||||
</GameCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRarityBorderColor(rarity: string) {
|
||||
const colors: Record<string, string> = {
|
||||
common: 'border-[var(--text-muted)]',
|
||||
uncommon: 'border-[var(--color-success)]',
|
||||
rare: 'border-[var(--mana-water)]',
|
||||
epic: 'border-[var(--mana-stellar)]',
|
||||
legendary: 'border-[var(--mana-light)]',
|
||||
mythic: 'border-[var(--mana-dark)]',
|
||||
};
|
||||
return colors[rarity] || 'border-[var(--border-default)]';
|
||||
}
|
||||
|
||||
function getRarityBgColor(rarity: string) {
|
||||
const colors: Record<string, string> = {
|
||||
common: 'bg-[var(--bg-sunken)]/30',
|
||||
uncommon: 'bg-[var(--color-success)]/10',
|
||||
rare: 'bg-[var(--mana-water)]/10',
|
||||
epic: 'bg-[var(--mana-stellar)]/10',
|
||||
legendary: 'bg-[var(--mana-light)]/10',
|
||||
mythic: 'bg-[var(--mana-dark)]/10',
|
||||
};
|
||||
return colors[rarity] || '';
|
||||
}
|
||||
|
||||
function getRarityTextColor(rarity: string) {
|
||||
const colors: Record<string, string> = {
|
||||
common: 'text-[var(--text-secondary)]',
|
||||
uncommon: 'text-[var(--color-success)]',
|
||||
rare: 'text-[var(--mana-water)]',
|
||||
epic: 'text-[var(--mana-stellar)]',
|
||||
legendary: 'text-[var(--mana-light)]',
|
||||
mythic: 'text-[var(--mana-dark)]',
|
||||
};
|
||||
return colors[rarity] || 'text-[var(--text-primary)]';
|
||||
}
|
||||
|
||||
interface EquipControlsProps {
|
||||
instance: EquipmentInstance;
|
||||
validSlots: EquipmentSlot[];
|
||||
onEquip: (instanceId: string, slot: EquipmentSlot) => void;
|
||||
onDelete: (instanceId: string, name: string) => void;
|
||||
SLOT_NAMES: Record<EquipmentSlot, string>;
|
||||
SLOT_ICONS: Record<EquipmentSlot, React.ElementType>;
|
||||
}
|
||||
|
||||
function EquipControls({
|
||||
instance,
|
||||
validSlots,
|
||||
onEquip,
|
||||
onDelete,
|
||||
SLOT_NAMES,
|
||||
SLOT_ICONS,
|
||||
}: EquipControlsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => onEquip(instance.instanceId, value as EquipmentSlot)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-[var(--bg-sunken)] border-[var(--border-default)]">
|
||||
<SelectValue placeholder="Equip to..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--bg-elevated)] border-[var(--border-default)]">
|
||||
{validSlots.map((slot) => (
|
||||
<SelectItem
|
||||
key={slot}
|
||||
value={slot}
|
||||
className="text-xs text-[var(--text-primary)] focus:bg-[var(--bg-sunken)]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = SLOT_ICONS[slot];
|
||||
return <Icon size={14} />;
|
||||
})()}
|
||||
{SLOT_NAMES[slot]}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<button
|
||||
className="h-8 w-8 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20 rounded flex items-center justify-center"
|
||||
onClick={() => onDelete(instance.instanceId, instance.name)}
|
||||
aria-label={`Delete ${instance.name}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { EquipmentSlot } from '@/lib/game/data/equipment';
|
||||
import { SLOT_NAMES, EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import type { EquipmentInstance } from '@/lib/game/types';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertCircle, Sword, Shield, HardHat, Shirt, Hand, Footprints, Gem } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RARITY_BORDER_COLORS, RARITY_BG_COLORS, RARITY_TEXT_COLORS } from './EquipmentTab';
|
||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||
import type { EquipmentType } from '@/lib/game/data/equipment';
|
||||
|
||||
const SLOT_ICONS: Record<EquipmentSlot, React.ElementType> = {
|
||||
mainHand: Sword,
|
||||
offHand: Shield,
|
||||
head: HardHat,
|
||||
body: Shirt,
|
||||
hands: Hand,
|
||||
feet: Footprints,
|
||||
accessory1: Gem,
|
||||
accessory2: Gem,
|
||||
};
|
||||
|
||||
interface EquipmentSlotGridProps {
|
||||
equippedInstances: Record<string, string | null>;
|
||||
equipmentInstances: Record<string, EquipmentInstance>;
|
||||
selectedSlot: EquipmentSlot | null;
|
||||
onSlotClick: (slot: EquipmentSlot) => void;
|
||||
onUnequip: (slot: EquipmentSlot) => void;
|
||||
isSlotBlocked: (slot: EquipmentSlot) => boolean;
|
||||
SLOT_GROUPS: Array<{ label: string; slots: EquipmentSlot[] }>;
|
||||
}
|
||||
|
||||
export function EquipmentSlotGrid({
|
||||
equippedInstances,
|
||||
equipmentInstances,
|
||||
selectedSlot,
|
||||
onSlotClick,
|
||||
onUnequip,
|
||||
isSlotBlocked,
|
||||
SLOT_GROUPS,
|
||||
}: EquipmentSlotGridProps) {
|
||||
const renderSlot = (slot: EquipmentSlot) => {
|
||||
const instanceId = equippedInstances[slot];
|
||||
const instance = instanceId ? equipmentInstances[instanceId] : null;
|
||||
const equipmentType = instance ? EQUIPMENT_TYPES?.[instance.typeId] : null;
|
||||
const blocked = isSlotBlocked(slot);
|
||||
const isEmpty = !instance;
|
||||
const SlotIcon = SLOT_ICONS[slot];
|
||||
|
||||
const slotContent = (
|
||||
<GameCard
|
||||
variant={blocked ? 'danger' : instance ? 'default' : 'sunken'}
|
||||
className={`relative transition-all duration-200
|
||||
${isEmpty && !blocked ? 'border-dashed' : ''}
|
||||
${blocked ? 'opacity-60 cursor-not-allowed' : 'hover:border-[var(--border-default)]'}
|
||||
`}
|
||||
role="button"
|
||||
aria-label={`${SLOT_NAMES[slot]} slot${blocked ? ' (blocked by 2-handed weapon)' : ''}${instance ? `: ${instance.name}` : ' (empty)'}`}
|
||||
tabIndex={blocked ? -1 : 0}
|
||||
onClick={() => !blocked && onSlotClick(slot)}
|
||||
onKeyDown={(e) => {
|
||||
if (!blocked && (e.key === 'Enter' || e.key === ' ')) {
|
||||
onSlotClick(slot);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlotIcon
|
||||
size={16}
|
||||
className={blocked ? 'text-[var(--text-disabled)]' : 'text-[var(--text-secondary)]'}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-semibold
|
||||
${blocked ? 'text-[var(--text-disabled)]' : 'text-[var(--text-primary)]'}
|
||||
`}
|
||||
>
|
||||
{SLOT_NAMES[slot]}
|
||||
</span>
|
||||
{blocked && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-[var(--mana-dark)] text-[var(--mana-dark)] ml-2"
|
||||
>
|
||||
<AlertCircle size={12} className="mr-1" />
|
||||
Occupied — 2H Weapon
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{instance && !blocked && (
|
||||
<button
|
||||
className="h-6 w-6 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnequip(slot);
|
||||
}}
|
||||
aria-label={`Unequip ${instance.name}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{instance ? (
|
||||
<EquipmentItemDisplay
|
||||
instance={instance}
|
||||
equipmentType={equipmentType}
|
||||
isTwoHanded={equipmentType?.twoHanded || false}
|
||||
isCompact={true}
|
||||
/>
|
||||
) : blocked ? (
|
||||
<div className="text-sm text-[var(--text-disabled)] italic">
|
||||
<AlertCircle size={14} className="inline mr-1" />
|
||||
Blocked by 2-handed weapon
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-muted)] italic text-center py-2">
|
||||
{SLOT_NAMES[slot]}
|
||||
</div>
|
||||
)}
|
||||
</GameCard>
|
||||
);
|
||||
|
||||
if (blocked) {
|
||||
return (
|
||||
<TooltipProvider key={slot}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{slotContent}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<p>The offhand slot is blocked because a 2-handed weapon is equipped in the main hand.</p>
|
||||
<p className="text-[var(--text-muted)] text-xs mt-1">Unequip the 2-handed weapon to use this slot.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={slot}>{slotContent}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{SLOT_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h4 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-2">
|
||||
{group.label}
|
||||
</h4>
|
||||
<div className={`grid gap-3
|
||||
grid-cols-2
|
||||
${group.slots.includes('mainHand' as EquipmentSlot) ? 'sm:grid-cols-2' : 'sm:grid-cols-2 lg:grid-cols-4'}
|
||||
`}>
|
||||
{group.slots.map((slot) => renderSlot(slot))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EquipmentItemDisplayProps {
|
||||
instance: EquipmentInstance;
|
||||
equipmentType: EquipmentType | undefined;
|
||||
isTwoHanded: boolean;
|
||||
isCompact?: boolean;
|
||||
}
|
||||
|
||||
function EquipmentItemDisplay({
|
||||
instance,
|
||||
equipmentType,
|
||||
isTwoHanded,
|
||||
isCompact = false,
|
||||
}: EquipmentItemDisplayProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity] || 'text-[var(--text-primary)]'}`}>
|
||||
{instance.name}
|
||||
{isTwoHanded && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-2 text-xs border-[var(--mana-light)] text-[var(--mana-light)]"
|
||||
>
|
||||
2-Handed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
Enchantments: {instance.enchantments.length}/{instance.totalCapacity}
|
||||
</div>
|
||||
{instance.enchantments.length > 0 && (
|
||||
<EnchantmentsDisplay enchantments={instance.enchantments} compact={isCompact} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnchantmentsDisplayProps {
|
||||
enchantments: Array<{ effectId: string; stacks: number }>;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function EnchantmentsDisplay({ enchantments, compact = false }: EnchantmentsDisplayProps) {
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-1 ${compact ? 'mt-1' : ''}`}>
|
||||
{enchantments.map((ench, i) => {
|
||||
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
|
||||
return (
|
||||
<TooltipProvider key={i}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs cursor-help border-[var(--border-default)] text-[var(--text-secondary)]"
|
||||
>
|
||||
{effect?.name || ench.effectId}
|
||||
{ench.stacks > 1 && ` x${ench.stacks}`}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<p>{effect?.description || 'Unknown effect'}</p>
|
||||
<p className="text-[var(--text-muted)] text-xs">
|
||||
Category: {effect?.category || 'unknown'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, vi } 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);
|
||||
});
|
||||
});
|
||||
Executable → Regular
+68
-360
@@ -1,386 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
EQUIPMENT_TYPES,
|
||||
EQUIPMENT_SLOTS,
|
||||
SLOT_NAMES,
|
||||
getEquipmentBySlot,
|
||||
type EquipmentSlot,
|
||||
type EquipmentType,
|
||||
} from '@/lib/game/data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { Button } from '@/components/ui/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 type { EquipmentInstance } from '@/lib/game/types';
|
||||
import { EquipmentSlotGrid } from './EquipmentSlotGrid';
|
||||
import { EquipmentInventory } from './EquipmentInventory';
|
||||
import { EnchantmentsPanel } from './EnchantmentsPanel';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
import { ConfirmDialog } from '@/components/game/ConfirmDialog';
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
import { equipItem, unequipItem, deleteEquipmentInstance } from '@/lib/game/crafting-actions';
|
||||
import { useCombatStore, useCraftingStore } from '@/lib/game/stores';
|
||||
import type { GameState } from '@/lib/game/types';
|
||||
|
||||
// Rarity color mappings using design system tokens
|
||||
export const RARITY_BORDER_COLORS: Record<string, string> = {
|
||||
common: 'border-[var(--text-muted)]',
|
||||
uncommon: 'border-[var(--color-success)]',
|
||||
rare: 'border-[var(--mana-water)]',
|
||||
epic: 'border-[var(--mana-stellar)]',
|
||||
legendary: 'border-[var(--mana-light)]',
|
||||
mythic: 'border-[var(--mana-dark)]',
|
||||
};
|
||||
export const RARITY_BG_COLORS: Record<string, string> = {
|
||||
common: 'bg-[var(--bg-sunken)]/30',
|
||||
uncommon: 'bg-[var(--color-success)]/10',
|
||||
rare: 'bg-[var(--mana-water)]/10',
|
||||
epic: 'bg-[var(--mana-stellar)]/10',
|
||||
legendary: 'bg-[var(--mana-light)]/10',
|
||||
mythic: 'bg-[var(--mana-dark)]/10',
|
||||
};
|
||||
export const RARITY_TEXT_COLORS: Record<string, string> = {
|
||||
common: 'text-[var(--text-secondary)]',
|
||||
uncommon: 'text-[var(--color-success)]',
|
||||
rare: 'text-[var(--mana-water)]',
|
||||
epic: 'text-[var(--mana-stellar)]',
|
||||
legendary: 'text-[var(--mana-light)]',
|
||||
mythic: 'text-[var(--mana-dark)]',
|
||||
};
|
||||
|
||||
const SLOT_ICONS: Record<EquipmentSlot, React.ElementType> = {
|
||||
mainHand: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14.5 4H5.5L2 10v10a2 2 0 002 2h16a2 2 0 002-2V10l-3.5-6z" />
|
||||
<line x1="6" y1="10" x2="18" y2="10" />
|
||||
</svg>
|
||||
),
|
||||
offHand: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 7H9a2 2 0 01-2-2V4a2 2 0 012-2h5l3 6-3 6h-5a2 2 0 01-2 2v4a2 2 0 002 2h7l3 6-3 6H4a2 2 0 01-2-2v-2" />
|
||||
</svg>
|
||||
),
|
||||
head: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<path d="M14 2v6h6M10 14l2 2 4-4" />
|
||||
</svg>
|
||||
),
|
||||
body: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20.38 8.5c.6-1.4 1.3-4.9-1.2-8.5-1.1-1.8-3.6-1.8-4.7 0-2.5 3.6-1.9 7.1-1.2 8.5.7 1.4 2.5 1.9 4 1.9 1.5 0 3.3-.5 4-1.9z" />
|
||||
<path d="M12 8v7M8 18h8" />
|
||||
</svg>
|
||||
),
|
||||
hands: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 11V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v5M6 11v6a2 2 0 002 2h5a2 2 0 002-2v-6" />
|
||||
<path d="M10 15V7a2 2 0 012-2h2a2 2 0 012 2v8" />
|
||||
</svg>
|
||||
),
|
||||
feet: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2v20M5 13a4 4 0 000 8h14a4 4 0 000-8" />
|
||||
<path d="M9 13V5a2 2 0 012-2h2a2 2 0 012 2v8" />
|
||||
</svg>
|
||||
),
|
||||
accessory1: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 4v8l2 4" />
|
||||
</svg>
|
||||
),
|
||||
accessory2: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 4v8l2 4" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// Slot grouping for visual layout
|
||||
type SlotGroup = {
|
||||
label: string;
|
||||
slots: EquipmentSlot[];
|
||||
};
|
||||
|
||||
const SLOT_GROUPS: SlotGroup[] = [
|
||||
{ label: 'Weapon & Shield', slots: ['mainHand', 'offHand'] },
|
||||
{ label: 'Armor', slots: ['head', 'body', 'hands', 'feet'] },
|
||||
{ label: 'Accessories', slots: ['accessory1', 'accessory2'] },
|
||||
];
|
||||
import { useState, useEffect, 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 showToast = useGameToast();
|
||||
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ instanceId: string; name: string } | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Use modular store directly - MUST be called before any conditional returns
|
||||
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);
|
||||
|
||||
// Get unequipped items - hooks must be called before conditional returns
|
||||
const equippedIds = useMemo(() =>
|
||||
new Set(Object.values(equippedInstances || {}).filter(Boolean)),
|
||||
[equippedInstances]
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleEquip = useCallback(
|
||||
(instanceId: string, slot: EquipmentSlot) => {
|
||||
storeEquipItem(instanceId, slot);
|
||||
},
|
||||
[storeEquipItem]
|
||||
);
|
||||
|
||||
const unequippedItems = useMemo(() =>
|
||||
Object.values(equipmentInstances || {}).filter(
|
||||
(inst) => !equippedIds.has(inst.instanceId)
|
||||
),
|
||||
[equipmentInstances, equippedIds]
|
||||
const handleUnequip = useCallback(
|
||||
(slot: EquipmentSlot) => {
|
||||
storeUnequipItem(slot);
|
||||
},
|
||||
[storeUnequipItem]
|
||||
);
|
||||
|
||||
// Guard against undefined during initialization - AFTER all hooks
|
||||
if (!equippedInstances || !equipmentInstances) {
|
||||
const handleDelete = useCallback(
|
||||
(instanceId: string) => {
|
||||
storeDeleteEquipment(instanceId);
|
||||
},
|
||||
[storeDeleteEquipment]
|
||||
);
|
||||
|
||||
const inventoryItems = useMemo(
|
||||
() =>
|
||||
Object.entries(equipmentInstances).filter(
|
||||
([id]) => !Object.values(equippedInstances).includes(id)
|
||||
),
|
||||
[equipmentInstances, equippedInstances]
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<DebugName name="EquipmentTab">
|
||||
<div className="p-4 text-center text-[var(--text-muted)]">
|
||||
Loading equipment data...
|
||||
<div className="flex items-center justify-center p-8 text-[var(--text-muted)]">
|
||||
Loading equipment…
|
||||
</div>
|
||||
|
||||
</DebugName>);
|
||||
);
|
||||
}
|
||||
|
||||
// Equip an item to a slot
|
||||
const handleEquip = (instanceId: string, slot: EquipmentSlot) => {
|
||||
const instance = equipmentInstances[instanceId];
|
||||
equipItem(instanceId, slot, useCraftingStore.getState as () => GameState, (fn) => useCraftingStore.setState(fn as any));
|
||||
setSelectedSlot(null);
|
||||
showToast('success', 'Item Equipped', `${instance?.name || 'Item'} equipped to ${SLOT_NAMES[slot]}`);
|
||||
};
|
||||
|
||||
// Unequip from a slot
|
||||
const handleUnequip = (slot: EquipmentSlot) => {
|
||||
const instanceId = equippedInstances[slot];
|
||||
const instance = instanceId ? equipmentInstances[instanceId] : null;
|
||||
unequipItem(slot, (fn) => useCraftingStore.setState(fn as any));
|
||||
showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed from ${SLOT_NAMES[slot]}`);
|
||||
};
|
||||
|
||||
// Check if a slot is blocked by a 2-handed weapon
|
||||
const isSlotBlocked = (slot: EquipmentSlot): boolean => {
|
||||
if (slot === 'offHand' && equippedInstances.mainHand) {
|
||||
const mainHandInstance = equipmentInstances[equippedInstances.mainHand];
|
||||
if (!mainHandInstance) return false;
|
||||
const mainHandType = EQUIPMENT_TYPES[mainHandInstance.typeId];
|
||||
return mainHandType?.twoHanded === true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get items that can be equipped in a slot
|
||||
const getEquippableItems = (slot: EquipmentSlot): EquipmentInstance[] => {
|
||||
const equipmentTypes = getEquipmentBySlot(slot);
|
||||
const typeIds = new Set(equipmentTypes.map((t) => t.id));
|
||||
return unequippedItems.filter((inst) => typeIds.has(inst.typeId));
|
||||
};
|
||||
|
||||
// Get all items that can go in a slot
|
||||
const getItemsForSlot = (slot: EquipmentSlot): EquipmentInstance[] => {
|
||||
if (isSlotBlocked(slot)) return [];
|
||||
|
||||
if (slot === 'accessory1' || slot === 'accessory2') {
|
||||
const accessoryTypeIds = Object.values(EQUIPMENT_TYPES)
|
||||
.filter((t) => t.category === 'accessory')
|
||||
.map((t) => t.id);
|
||||
return unequippedItems.filter((inst) => accessoryTypeIds.includes(inst.typeId));
|
||||
}
|
||||
|
||||
if (slot === 'offHand') {
|
||||
return getEquippableItems(slot).filter((inst) => {
|
||||
const type = EQUIPMENT_TYPES[inst.typeId];
|
||||
return !type?.twoHanded;
|
||||
});
|
||||
}
|
||||
|
||||
return getEquippableItems(slot);
|
||||
};
|
||||
|
||||
// Check if an instance is currently equipped
|
||||
const isEquipped = (instanceId: string): boolean =>
|
||||
Object.values(equippedInstances || {}).includes(instanceId);
|
||||
|
||||
// Get all slots an item type can be equipped to
|
||||
const getEquippableSlots = (typeId: string): EquipmentSlot[] => {
|
||||
const equipmentType = EQUIPMENT_TYPES[typeId];
|
||||
if (!equipmentType) return [];
|
||||
if (equipmentType.category === 'accessory') {
|
||||
return ['accessory1', 'accessory2'];
|
||||
}
|
||||
return [equipmentType.slot];
|
||||
};
|
||||
|
||||
// Handle item deletion
|
||||
const handleDelete = (instanceId: string, name: string) => {
|
||||
setDeleteConfirm({ instanceId, name });
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteConfirm) {
|
||||
deleteEquipmentInstance(deleteConfirm.instanceId, useCraftingStore.getState, (fn) => useCraftingStore.setState(fn));
|
||||
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Use already-fetched values for unified effects
|
||||
const unifiedEffects = getUnifiedEffects({ equipmentInstances, equippedInstances });
|
||||
|
||||
return (
|
||||
<DebugName name="EquipmentTab">
|
||||
<div className="space-y-4 max-w-full overflow-x-hidden">
|
||||
{/* Equipment Slots */}
|
||||
<GameCard variant="default">
|
||||
<SectionHeader
|
||||
title="Equipped Gear"
|
||||
action={
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{Object.values(equippedInstances || {}).filter(Boolean).length} / {EQUIPMENT_SLOTS.length} slots filled
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<EquipmentSlotGrid
|
||||
equippedInstances={equippedInstances}
|
||||
equipmentInstances={equipmentInstances}
|
||||
selectedSlot={selectedSlot}
|
||||
onSlotClick={setSelectedSlot}
|
||||
onUnequip={handleUnequip}
|
||||
isSlotBlocked={isSlotBlocked}
|
||||
SLOT_GROUPS={SLOT_GROUPS}
|
||||
SLOT_NAMES={SLOT_NAMES}
|
||||
SLOT_ICONS={SLOT_ICONS}
|
||||
/>
|
||||
</GameCard>
|
||||
|
||||
{/* Equipment Inventory */}
|
||||
<GameCard variant="default">
|
||||
<SectionHeader
|
||||
title={`Equipment Inventory (${unequippedItems.length} items)`}
|
||||
/>
|
||||
{unequippedItems.length === 0 ? (
|
||||
<div className="text-[var(--text-muted)] text-sm text-center py-4" role="status">
|
||||
No unequipped items. Craft new gear in the Crafting tab.
|
||||
</div>
|
||||
) : (
|
||||
<EquipmentInventory
|
||||
unequippedItems={unequippedItems}
|
||||
<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}
|
||||
getEquippableSlots={getEquippableSlots}
|
||||
SLOT_NAMES={SLOT_NAMES}
|
||||
SLOT_ICONS={SLOT_ICONS}
|
||||
/>
|
||||
)}
|
||||
</GameCard>
|
||||
|
||||
{/* Equipment Stats Summary */}
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="Equipment Stats Summary" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-[var(--mana-light)] font-[var(--font-mono)]">
|
||||
{Object.values(equipmentInstances || {}).length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Total Items</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-[var(--color-success)] font-[var(--font-mono)]">
|
||||
{equippedIds.size}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Equipped</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-[var(--mana-water)] font-[var(--font-mono)]">
|
||||
{unequippedItems.length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">In Inventory</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-[var(--mana-stellar)] font-[var(--font-mono)]">
|
||||
{Object.values(equipmentInstances || {}).reduce(
|
||||
(sum, inst) => sum + inst.enchantments.length,
|
||||
0
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Total Enchantments</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enchantment Power */}
|
||||
<GameCard className="mt-4">
|
||||
<div className="pb-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
✨ Enchantment Power
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
{(() => {
|
||||
const effects = unifiedEffects;
|
||||
if (!effects) return null;
|
||||
const enchantPower = effects.enchantmentPowerMultiplier || 1;
|
||||
return (
|
||||
<>
|
||||
<StatRow
|
||||
label="Enchantment Power:"
|
||||
value={`${enchantPower.toFixed(2)}×`}
|
||||
highlight={enchantPower > 1 ? 'success' : 'default'}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-2">
|
||||
Increases the power of all enchantments by {(enchantPower - 1) * 100}%. Multiplier applied to all enchantment effects.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</GameCard>
|
||||
|
||||
{/* Active Effects from Equipment */}
|
||||
<div className="mt-4">
|
||||
<div className="text-sm text-[var(--text-muted)] mb-2">Active Effects from Equipment:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(() => {
|
||||
const effects = unifiedEffects;
|
||||
if (!effects?.equipmentEffects) {
|
||||
return <span className="text-[var(--text-muted)] text-sm">No active effects</span>;
|
||||
}
|
||||
const effectEntries = Object.entries(effects.equipmentEffects).filter(([, v]) => v > 0);
|
||||
|
||||
if (effectEntries.length === 0) {
|
||||
return <span className="text-[var(--text-muted)] text-sm">No active effects</span>;
|
||||
}
|
||||
|
||||
return effectEntries.map(([stat, value]) => (
|
||||
<Badge key={stat} variant="outline" className="text-xs border-[var(--border-default)] text-[var(--text-secondary)]">
|
||||
{stat}: +{fmt(value)}
|
||||
</Badge>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</GameCard>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteConfirm}
|
||||
onOpenChange={() => setDeleteConfirm(null)}
|
||||
title="Discard Item?"
|
||||
description={`Discard ${deleteConfirm?.name}? This cannot be undone.`}
|
||||
variant="danger"
|
||||
confirmText="Discard"
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</DebugName>);
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ChevronUp, ChevronDown, Mountain, Skull } from 'lucide-react';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
interface FloorControlsProps {
|
||||
// Store values passed as individual props
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
equipmentSpellStates: any[];
|
||||
|
||||
// Other props
|
||||
climbDirection: 'up' | 'down' | null;
|
||||
isGuardianFloor: boolean;
|
||||
currentRoom: any;
|
||||
currentGuardian: any;
|
||||
isFloorCleared: boolean;
|
||||
floorElemDef: any;
|
||||
roomType: string;
|
||||
roomConfig: { label: string; icon: string; color: string };
|
||||
activeEquipmentSpells: any[];
|
||||
floorElem: string;
|
||||
totalDPS: number;
|
||||
calcDamage: (state: { skills: Record<string, number>; signedPacts: number[] }, spellId: string, floorElem?: string) => number;
|
||||
SPELLS_DEF: Record<string, any>;
|
||||
canCastSpell: (spellId: string) => boolean;
|
||||
storeCurrentAction: string;
|
||||
handleClimb: (direction: 'up' | 'down') => void;
|
||||
formatSpellCost: (cost: any) => string;
|
||||
getSpellCostColor: (cost: any) => string;
|
||||
// Skills and pacts needed for calcDamage
|
||||
skills: Record<string, number>;
|
||||
signedPacts: number[];
|
||||
}
|
||||
|
||||
export function FloorControls({
|
||||
currentFloor,
|
||||
floorHP,
|
||||
floorMaxHP,
|
||||
maxFloorReached,
|
||||
equipmentSpellStates,
|
||||
climbDirection,
|
||||
isGuardianFloor,
|
||||
currentRoom,
|
||||
currentGuardian,
|
||||
isFloorCleared,
|
||||
floorElemDef,
|
||||
roomType,
|
||||
roomConfig,
|
||||
activeEquipmentSpells,
|
||||
floorElem,
|
||||
totalDPS,
|
||||
calcDamage,
|
||||
SPELLS_DEF,
|
||||
canCastSpell,
|
||||
storeCurrentAction,
|
||||
handleClimb,
|
||||
formatSpellCost,
|
||||
getSpellCostColor,
|
||||
skills,
|
||||
signedPacts,
|
||||
}: FloorControlsProps) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Floor Navigation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleClimb('up')}
|
||||
disabled={storeCurrentAction === 'climb' || isFloorCleared || maxFloorReached >= 100}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4 mr-1" />
|
||||
Climb Up
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleClimb('down')}
|
||||
disabled={storeCurrentAction === 'climb' || currentFloor <= 1}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 mr-1" />
|
||||
Climb Down
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{storeCurrentAction === 'climb' && (
|
||||
<div className="p-3 bg-amber-900/30 rounded border border-amber-700/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Mountain className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm font-semibold text-amber-400">
|
||||
Climbing {climbDirection === 'up' ? 'Up' : 'Down'}
|
||||
</span>
|
||||
{isGuardianFloor && (
|
||||
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentGuardian && (
|
||||
<div className="text-xs mb-2 p-2 bg-gray-800/50 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skull className="w-3 h-3 text-red-400" />
|
||||
<span className="font-semibold" style={{ color: floorElemDef?.color }}>
|
||||
{currentGuardian.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!(roomType === 'swarm' || roomType === 'puzzle') && (
|
||||
<div className="space-y-1 mb-2">
|
||||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.max(0, (floorHP / floorMaxHP) * 100)}%`,
|
||||
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
|
||||
boxShadow: `0 0 8px ${floorElemDef?.glow}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{fmt(floorHP)} / {fmt(floorMaxHP)} HP</span>
|
||||
<span className="text-amber-400">
|
||||
DPS: {activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeEquipmentSpells.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
if (!spellDef) return null;
|
||||
const spellState = equipmentSpellStates?.find(
|
||||
s => s.spellId === spellId && s.sourceEquipment === equipmentId
|
||||
);
|
||||
const progress = spellState?.castProgress || 0;
|
||||
const canCast = canCastSpell(spellId);
|
||||
|
||||
return (
|
||||
<div key={`${spellId}-${equipmentId}`} className="p-2 bg-gray-800/50 rounded">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
|
||||
{spellDef.name}
|
||||
</span>
|
||||
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{canCast ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-1">
|
||||
⚔️ {fmt(calcDamage({ skills, signedPacts }, spellId, floorElem))} dmg • {' '}
|
||||
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
|
||||
{formatSpellCost(spellDef.cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>Cast</span>
|
||||
<span>{(progress * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, progress * 100)}%`,
|
||||
background: `linear-gradient(90deg, ${spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color}99, ${spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 italic">No active spells. Equip staves with spell effects.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storeCurrentAction !== 'climb' && (
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
Click Climb Up/Down to begin climbing
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
|
||||
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
|
||||
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
|
||||
return value.toFixed(0);
|
||||
}
|
||||
|
||||
function fmtDec(value: number): string {
|
||||
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
|
||||
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
|
||||
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
|
||||
return value.toFixed(0);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Executable → Regular
+332
-314
@@ -1,321 +1,339 @@
|
||||
'use client';
|
||||
|
||||
import { GameCard, StatRow, ElementBadge, ActionButton } from '@/components/ui';
|
||||
import React, { useState, useEffect, 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 { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Mountain, Zap, Clock, Swords, Sparkles, Lock, Check, X,
|
||||
Info, HelpCircle
|
||||
} from 'lucide-react';
|
||||
import { GOLEMS_DEF, getGolemSlots, isGolemUnlocked, getGolemDamage, getGolemAttackSpeed, getGolemFloorDuration } from '@/lib/game/data/golems';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { useManaStore, useSkillStore, useCombatStore, useAttunementStore } from '@/lib/game/stores';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function GolemancyTab() {
|
||||
const attunements = useAttunementStore((s) => s.attunements);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const golemancy = useCombatStore((s) => s.golemancy);
|
||||
const currentFloor = useCombatStore((s) => s.currentFloor);
|
||||
const currentRoom = useCombatStore((s) => s.currentRoom);
|
||||
const toggleGolem = useCombatStore((s) => s.toggleGolem);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
// ─── Tier configuration ──────────────────────────────────────────────────────
|
||||
|
||||
// Get Fabricator level and golem slots
|
||||
const fabricatorLevel = attunements.fabricator?.level || 0;
|
||||
const fabricatorActive = attunements.fabricator?.active || false;
|
||||
const maxSlots = getGolemSlots(fabricatorLevel);
|
||||
|
||||
// Get unlocked elements
|
||||
const unlockedElements = Object.entries(elements)
|
||||
.filter(([, e]) => e.unlocked)
|
||||
.map(([id]) => id);
|
||||
|
||||
// Get all unlocked golems
|
||||
const unlockedGolems = Object.values(GOLEMS_DEF || {}).filter(golem =>
|
||||
isGolemUnlocked(golem.id, attunements, unlockedElements)
|
||||
);
|
||||
|
||||
// Check if golemancy is available
|
||||
const hasGolemancy = fabricatorActive && fabricatorLevel >= 2;
|
||||
|
||||
// Check if currently in combat (not puzzle)
|
||||
const inCombat = currentRoom?.roomType !== 'puzzle';
|
||||
|
||||
// Get element info helper
|
||||
const getElementInfo = (elementId: string) => {
|
||||
return ELEMENTS[elementId];
|
||||
};
|
||||
|
||||
// Render a golem card
|
||||
const renderGolemCard = (golemId: string, isUnlocked: boolean) => {
|
||||
const golem = GOLEMS_DEF[golemId];
|
||||
if (!golem) return null;
|
||||
|
||||
const isEnabled = golemancy.enabledGolems.includes(golemId);
|
||||
const isSelected = golemancy.summonedGolems.some(g => g.golemId === golemId);
|
||||
|
||||
// Calculate effective stats
|
||||
const damage = getGolemDamage(golemId, skills);
|
||||
const attackSpeed = getGolemAttackSpeed(golemId, skills);
|
||||
const floorDuration = getGolemFloorDuration(skills);
|
||||
|
||||
// Get element color
|
||||
const primaryElement = getElementInfo(golem.baseManaType);
|
||||
const elementId = golem.baseManaType;
|
||||
|
||||
if (!isUnlocked) {
|
||||
// Locked golem card
|
||||
return (
|
||||
|
||||
<DebugName name="GolemancyTab">
|
||||
<GameCard key={golemId} variant="sunken" className="opacity-60">
|
||||
<div className="pb-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span className="text-[var(--text-muted)]">???</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{golem.unlockCondition.type === 'attunement_level' && (
|
||||
<div>Requires Fabricator Level {golem.unlockCondition.level}</div>
|
||||
)}
|
||||
{golem.unlockCondition.type === 'mana_unlocked' && (
|
||||
<div>Requires {ELEMENTS[golem.unlockCondition.manaType || '']?.name || golem.unlockCondition.manaType} Mana</div>
|
||||
)}
|
||||
{golem.unlockCondition.type === 'dual_attunement' && (
|
||||
<div>Requires Enchanter & Fabricator Level 5</div>
|
||||
)}
|
||||
</div>
|
||||
</GameCard>
|
||||
|
||||
</DebugName>);
|
||||
}
|
||||
|
||||
return (
|
||||
<GameCard
|
||||
key={golemId}
|
||||
variant={isEnabled ? "default" : "sunken"}
|
||||
className={`transition-all cursor-pointer border-2 ${
|
||||
isEnabled
|
||||
? 'border-[var(--color-success)] bg-[var(--bg-surface)]'
|
||||
: 'border-[var(--border-subtle)] hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
onClick={() => toggleGolem(golemId)}
|
||||
aria-label={`${isEnabled ? 'Disable' : 'Enable'} ${golem.name}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="pb-2">
|
||||
<h3 className="text-sm font-semibold flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mountain className="w-4 h-4" style={{ color: `var(--mana-${elementId})` }} />
|
||||
<span style={{ color: `var(--mana-${elementId})` }}>{golem.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{golem.isAoe && (
|
||||
<span className="text-xs px-1.5 py-0.5 border border-[var(--border-default)] rounded">
|
||||
AOE {golem.aoeTargets}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs px-1.5 py-0.5 border border-[var(--border-default)] rounded">
|
||||
{golem.tier}
|
||||
</span>
|
||||
{isEnabled ? (
|
||||
<Check className="w-4 h-4 text-[var(--color-success)]" />
|
||||
) : (
|
||||
<X className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
)}
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-[var(--text-secondary)]">{golem.description}</p>
|
||||
|
||||
<Separator className="bg-[var(--border-subtle)]" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<StatRow label="DMG:" value={damage.toString()} />
|
||||
<StatRow label="Speed:" value={`${attackSpeed.toFixed(1)}/hr`} />
|
||||
<StatRow label="Pierce:" value={`${Math.floor(golem.armorPierce * 100)}%`} />
|
||||
<StatRow label="Duration:" value={`${floorDuration} floor(s)`} />
|
||||
</div>
|
||||
|
||||
<Separator className="bg-[var(--border-subtle)]" />
|
||||
|
||||
{/* Summon Cost */}
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-1">Summon Cost:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{golem.summonCost.map((cost, idx) => {
|
||||
const elem = getElementInfo(cost.element || '');
|
||||
const available = cost.type === 'raw'
|
||||
? rawMana
|
||||
: elements[cost.element || '']?.current || 0;
|
||||
const canAfford = available >= cost.amount;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={`text-xs px-1.5 py-0.5 border rounded ${
|
||||
canAfford
|
||||
? 'border-[var(--color-success)] text-[var(--color-success)]'
|
||||
: 'border-[var(--color-danger)] text-[var(--color-danger)]'
|
||||
}`}
|
||||
>
|
||||
{cost.element && <ElementBadge elementId={cost.element} size="sm" />}
|
||||
{' '}{cost.amount}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Cost */}
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-1">Maintenance/hr:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{golem.maintenanceCost.map((cost, idx) => {
|
||||
return (
|
||||
<span key={idx} className="text-xs px-1.5 py-0.5 border border-[var(--border-default)] rounded">
|
||||
{cost.element && <ElementBadge elementId={cost.element} size="sm" />}
|
||||
{' '}{cost.amount}/hr
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
{isSelected && (
|
||||
<div className="mt-2 text-xs text-[var(--color-success)] flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Active on Floor {currentFloor}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GameCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<GameCard>
|
||||
<div className="pb-2">
|
||||
<h2 className="text-lg font-[var(--font-heading)] font-semibold flex items-center gap-2 text-[var(--text-primary)]">
|
||||
<Mountain className="w-5 h-5 text-[var(--mana-earth)]" />
|
||||
Golemancy
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{!hasGolemancy ? (
|
||||
<div className="text-center text-[var(--text-secondary)] py-4">
|
||||
<Lock className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Unlock the Fabricator attunement and reach Level 2 to summon golems.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StatRow
|
||||
label="Golem Slots:"
|
||||
value={`${golemancy.enabledGolems.length} / ${maxSlots}`}
|
||||
highlight={golemancy.enabledGolems.length > 0 ? 'success' : undefined}
|
||||
/>
|
||||
<StatRow
|
||||
label="Fabricator Level:"
|
||||
value={fabricatorLevel.toString()}
|
||||
highlight="warning"
|
||||
/>
|
||||
<StatRow
|
||||
label="Floor Duration:"
|
||||
value={`${getGolemFloorDuration(skills)} floor(s)`}
|
||||
/>
|
||||
<StatRow
|
||||
label="Status:"
|
||||
value={inCombat ? 'Combat Active' : 'Puzzle Room (No Golems)'}
|
||||
highlight={inCombat ? 'success' : 'warning'}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-[var(--text-muted)] mt-2">
|
||||
Golems are automatically summoned at the start of each combat floor.
|
||||
They cost mana to maintain and will be dismissed if you run out.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GameCard>
|
||||
|
||||
{/* Active Golems - Empty State */}
|
||||
{hasGolemancy && golemancy.summonedGolems.length === 0 && (
|
||||
<GameCard variant="sunken">
|
||||
<div className="text-center py-4 text-[var(--text-muted)]">
|
||||
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No golems summoned</p>
|
||||
<p className="text-xs mt-1">Enable golems below to summon them at the start of combat</p>
|
||||
</div>
|
||||
</GameCard>
|
||||
)}
|
||||
|
||||
{/* Active Golems */}
|
||||
{hasGolemancy && golemancy.summonedGolems.length > 0 && (
|
||||
<GameCard variant="default" className="border-[var(--color-success)]">
|
||||
<div className="pb-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2 text-[var(--color-success)]">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Active Golems ({golemancy.summonedGolems.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{golemancy.summonedGolems.map(sg => {
|
||||
const golem = GOLEMS_DEF[sg.golemId];
|
||||
if (!golem) return null;
|
||||
|
||||
return (
|
||||
<span key={sg.golemId} className="text-xs px-2 py-1 border border-[var(--border-default)] rounded">
|
||||
<Mountain className="w-3 h-3 inline mr-1" style={{ color: `var(--mana-${golem.baseManaType})` }} />
|
||||
{golem.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GameCard>
|
||||
)}
|
||||
|
||||
{/* Golem Selection */}
|
||||
{hasGolemancy && (
|
||||
<GameCard>
|
||||
<div className="pb-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Select Golems to Summon</h3>
|
||||
</div>
|
||||
<ScrollArea className="h-96">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pr-4">
|
||||
{/* Unlocked Golems */}
|
||||
{unlockedGolems.map(golem => renderGolemCard(golem.id, true))}
|
||||
|
||||
{/* Locked Golems */}
|
||||
{Object.values(GOLEMS_DEF || {})
|
||||
.filter(g => !isGolemUnlocked(g.id, attunements, unlockedElements))
|
||||
.map(golem => renderGolemCard(golem.id, false))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</GameCard>
|
||||
)}
|
||||
|
||||
{/* Golemancy Skills Info */}
|
||||
<GameCard>
|
||||
<div className="pb-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Golemancy Skills</h3>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<StatRow label="Golem Mastery:" value={`+${(skills.golemMastery || 0) * 10}% damage`} />
|
||||
<StatRow label="Golem Efficiency:" value={`+${(skills.golemEfficiency || 0) * 5}% attack speed`} />
|
||||
<StatRow label="Golem Longevity:" value={`+${skills.golemLongevity || 0} floor duration`} />
|
||||
<StatRow label="Golem Siphon:" value={`-${(skills.golemSiphon || 0) * 10}% maintenance`} />
|
||||
</div>
|
||||
</GameCard>
|
||||
</div>
|
||||
);
|
||||
interface TierConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
GolemancyTab.displayName = "GolemancyTab";
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
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">DMG:</span> {golem.damage}
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
<span className="text-gray-500">SPD:</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">AP:</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>
|
||||
|
||||
{/* 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 [mounted, setMounted] = useState(false);
|
||||
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,
|
||||
})));
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
Loading golemancy…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,132 @@
|
||||
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 { GUARDIANS } = await import('@/lib/game/constants/guardians');
|
||||
for (const [floor, def] of Object.entries(GUARDIANS)) {
|
||||
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 { GUARDIANS } = await import('@/lib/game/constants/guardians');
|
||||
const expectedFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
|
||||
for (const floor of expectedFloors) {
|
||||
expect(GUARDIANS[floor]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('guardian boons have valid types', async () => {
|
||||
const validBoonTypes = [
|
||||
'maxMana', 'manaRegen', 'castingSpeed', 'elementalDamage', 'rawDamage',
|
||||
'critChance', 'critDamage', 'spellEfficiency', 'manaGain', 'insightGain',
|
||||
'studySpeed', 'prestigeInsight',
|
||||
];
|
||||
const { GUARDIANS } = await import('@/lib/game/constants/guardians');
|
||||
for (const def of Object.values(GUARDIANS)) {
|
||||
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,163 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, 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 { GUARDIANS } from '@/lib/game/constants';
|
||||
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–60)', floors: [] },
|
||||
{ label: 'Late Spire (80–100)', floors: [] },
|
||||
];
|
||||
for (const f of floors) {
|
||||
if (f <= 40) tiers[0].floors.push(f);
|
||||
else if (f <= 60) tiers[1].floors.push(f);
|
||||
else tiers[2].floors.push(f);
|
||||
}
|
||||
return tiers.filter(t => t.floors.length > 0);
|
||||
}
|
||||
|
||||
// ─── Main Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GuardianPactsTab: React.FC = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const guardianFloors = useMemo(
|
||||
() => Object.keys(GUARDIANS).map(Number).sort((a, b) => a - b),
|
||||
[],
|
||||
);
|
||||
|
||||
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 = GUARDIANS[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 = GUARDIANS[floor];
|
||||
if (!guardian) continue;
|
||||
for (const boon of guardian.boons) {
|
||||
boonMap[boon.type] = (boonMap[boon.type] || 0) + boon.value;
|
||||
}
|
||||
}
|
||||
return boonMap;
|
||||
}, [signedPacts]);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
Loading guardian pacts…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = GUARDIANS[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';
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { GUARDIANS } from '@/lib/game/constants';
|
||||
|
||||
interface GuardianPanelProps {
|
||||
currentFloor: number;
|
||||
floorElemDef: any;
|
||||
}
|
||||
|
||||
export function GuardianPanel({ currentFloor, floorElemDef }: GuardianPanelProps) {
|
||||
const guardian = GUARDIANS[currentFloor];
|
||||
if (!guardian) return null;
|
||||
|
||||
return (
|
||||
<div className="p-3 bg-red-900/20 rounded border border-red-700/50 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">⚔️</span>
|
||||
<span className="text-sm font-semibold" style={{ color: floorElemDef?.color }}>
|
||||
{guardian.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-gray-300">
|
||||
<div>Power: <strong className="text-red-400">{fmt(guardian.power)}</strong></div>
|
||||
|
||||
{guardian.armor > 0 && (
|
||||
<div>Armor: <strong>{(guardian.armor * 100).toFixed(0)}%</strong></div>
|
||||
)}
|
||||
|
||||
{guardian.effects && guardian.effects.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{guardian.effects.map((eff: any, i: number) => (
|
||||
<Badge key={i} variant="outline" className="text-xs py-0">
|
||||
{eff.type === 'burn' && `🔥 Burn ${(eff.value * 100).toFixed(0)}%`}
|
||||
{eff.type === 'armor_pierce' && `🗡️ Pierce ${(eff.value * 100).toFixed(0)}%`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
|
||||
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
|
||||
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
|
||||
return value.toFixed(0);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCraftingStore, useManaStore } from '@/lib/game/stores';
|
||||
import { LootInventoryDisplay } from '@/components/game/LootInventory';
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
|
||||
export function LootTab() {
|
||||
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
const deleteMaterial = useCraftingStore((s) => s.deleteMaterial);
|
||||
const deleteEquipmentInstance = useCraftingStore((s) => s.deleteEquipmentInstance);
|
||||
|
||||
return (
|
||||
<DebugName name="LootTab">
|
||||
<div className="space-y-4">
|
||||
<LootInventoryDisplay
|
||||
inventory={lootInventory}
|
||||
elements={elements}
|
||||
equipmentInstances={equipmentInstances}
|
||||
onDeleteMaterial={deleteMaterial}
|
||||
onDeleteEquipment={deleteEquipmentInstance}
|
||||
/>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
LootTab.displayName = "LootTab";
|
||||
@@ -1,22 +0,0 @@
|
||||
// ─── Milestone Progress ───────────────────────────────────────────
|
||||
// Milestone upgrade progress indicator for skill rows
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface MilestoneProgressProps {
|
||||
milestoneInfo: {
|
||||
milestone: 5 | 10;
|
||||
hasUpgrades: boolean;
|
||||
selectedCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function MilestoneProgress({ milestoneInfo }: MilestoneProgressProps) {
|
||||
if (!milestoneInfo) return null;
|
||||
|
||||
return (
|
||||
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
|
||||
⭐ Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ─── Test: PrestigeTab barrel export ───────────────────────────────────────────
|
||||
|
||||
describe('PrestigeTab module structure', () => {
|
||||
it('exports PrestigeTab from barrel index', async () => {
|
||||
const mod = await import('./PrestigeTab');
|
||||
expect(mod.PrestigeTab).toBeDefined();
|
||||
expect(typeof mod.PrestigeTab).toBe('function');
|
||||
});
|
||||
|
||||
it('PrestigeTab has correct displayName', async () => {
|
||||
const { PrestigeTab } = await import('./PrestigeTab');
|
||||
expect(PrestigeTab.displayName).toBe('PrestigeTab');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Barrel export includes PrestigeTab ──────────────────────────────────
|
||||
|
||||
describe('Tab barrel export', () => {
|
||||
it('includes PrestigeTab in the tabs index', async () => {
|
||||
const mod = await import('@/components/game/tabs');
|
||||
expect(mod.PrestigeTab).toBeDefined();
|
||||
expect(typeof mod.PrestigeTab).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Prestige upgrade definitions ────────────────────────────────────────
|
||||
|
||||
describe('Prestige upgrade definitions', () => {
|
||||
it('has exactly 14 prestige upgrades', async () => {
|
||||
const { PRESTIGE_DEF } = await import('@/lib/game/constants/prestige');
|
||||
expect(Object.keys(PRESTIGE_DEF).length).toBe(14);
|
||||
});
|
||||
|
||||
it('all upgrades have required fields', async () => {
|
||||
const { PRESTIGE_DEF } = await import('@/lib/game/constants/prestige');
|
||||
for (const [id, def] of Object.entries(PRESTIGE_DEF)) {
|
||||
expect(def.name).toBeTruthy();
|
||||
expect(def.desc).toBeTruthy();
|
||||
expect(def.max).toBeGreaterThan(0);
|
||||
expect(def.cost).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('all 14 expected upgrade IDs are present', async () => {
|
||||
const { PRESTIGE_DEF } = await import('@/lib/game/constants/prestige');
|
||||
const expectedIds = [
|
||||
'manaWell', 'manaFlow', 'deepMemory', 'insightAmp', 'spireKey',
|
||||
'temporalEcho', 'steadyHand', 'ancientKnowledge', 'elementalAttune',
|
||||
'spellMemory', 'guardianPact', 'quickStart', 'elemStart',
|
||||
'unlockedManaTypeCapacity',
|
||||
];
|
||||
for (const id of expectedIds) {
|
||||
expect(PRESTIGE_DEF[id]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('upgrade costs are positive integers', async () => {
|
||||
const { PRESTIGE_DEF } = await import('@/lib/game/constants/prestige');
|
||||
for (const def of Object.values(PRESTIGE_DEF)) {
|
||||
expect(Number.isInteger(def.cost)).toBe(true);
|
||||
expect(def.cost).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('upgrade max levels are positive integers', async () => {
|
||||
const { PRESTIGE_DEF } = await import('@/lib/game/constants/prestige');
|
||||
for (const def of Object.values(PRESTIGE_DEF)) {
|
||||
expect(Number.isInteger(def.max)).toBe(true);
|
||||
expect(def.max).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Prestige store shape ────────────────────────────────────────────────
|
||||
|
||||
describe('Prestige store', () => {
|
||||
it('usePrestigeStore is importable', async () => {
|
||||
const mod = await import('@/lib/game/stores');
|
||||
expect(mod.usePrestigeStore).toBeDefined();
|
||||
expect(typeof mod.usePrestigeStore).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: File size limit ─────────────────────────────────────────────────────
|
||||
|
||||
describe('File size limits (400 lines max)', () => {
|
||||
it('PrestigeTab.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'PrestigeTab.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user