Compare commits

...

42 Commits

Author SHA1 Message Date
n8n-gitea 8a7ddaae27 refactor: split bloated state types into State + Actions interfaces (issue #102)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
- CombatState: split into CombatState (data) + CombatActions + CombatStore
- PrestigeState: split into PrestigeState (data) + PrestigeActions + PrestigeStore
- ManaState: split into ManaState (data) + ManaActions + ManaStore
- GameState: deprecated, removed from barrel exports
- crafting-actions: updated to use CraftingState instead of GameState
- combat-utils/mana-utils: replaced Pick<GameState,...> with focused interfaces
- DisciplineCardProps: split into Definition + Runtime + Callbacks
- stores/index.ts: now exports both State and Actions types
2026-05-20 21:05:22 +02:00
n8n-gitea ee893e8973 refactor: tick pipeline pattern — read all → compute all → write all (issue #103)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
- New tick-pipeline.ts: TickContext/TickWrites types + buildTickContext/applyTickWrites orchestrator
- gameStore.ts tick(): refactored to 3-phase pipeline (read snapshot → compute updates → batch writes)
- combat-actions.ts: accept signedPacts as parameter instead of usePrestigeStore.getState() in combat loop
- combatStore.ts/combat-state.types.ts: updated processCombatTick signature for signedPacts passthrough
- craftingStore.ts: removed tempState = { ...get(), rawMana } as any anti-pattern
- preparation-actions.ts: accept rawMana as explicit parameter instead of GameState bag
2026-05-20 19:48:40 +02:00
n8n-gitea ce084a61a3 refactor: extract sub-components from monster functions (issue #99)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
- GuardianPactsTab: extracted GuardianCard, PactHeaderSummary, TierFilter + 5 helper components into guardian-pacts-components.tsx
- SpireSummaryTab: extracted TopStatsRow, NextGuardianCard, GuardianRoster, GuardianRosterItem, FloorLegend
- PrestigeTab: extracted InsightSummary, MemoriesCard, PactsCard, ResetLoopSection
- GameStateDebug: extracted WarningBanner, DisplayOptions, GameResetSection, ManaDebugSection, TimeControlSection, QuickActionsSection
- EquipmentCrafter: extracted CraftingProgress, BlueprintCard, BlueprintList, MaterialCard, MaterialsInventory
- PactDebug: extracted GuardianPactRow, GuardianPactList
- GameStateDebugSection: extracted DisplayOptions, GameResetSection, ManaDebugSection, TimeControlSection, QuickActionsSection
- PactDebugSection: extracted GuardianPactRow
- SpireCombatPage: extracted useSpireStats hook
- page.tsx: extracted GrimoireTab to separate file, useGameDerivedStats hook, TabTriggers, LazyTab wrapper

All files now under 400 lines. Build passes. All 639 tests pass.
2026-05-20 18:38:24 +02:00
n8n-gitea 53b3a94725 refactor: consolidate duplicate functions (calculateDesignTime, calculateDesignCapacityCost, generateSwarmEnemies)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
2026-05-20 17:46:43 +02:00
n8n-gitea 742a992d59 refactor: eliminate as any type casts across 18 source files
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m34s
- Fix computeDisciplineEffects() to not require GameState parameter
- Fix getUnifiedEffects() to accept proper partial state type
- Replace upgradeEffects as any with proper UnifiedEffects type
- Replace explicit : any annotations with proper types (ComputedEffects, DesignProgress, SpellDef, etc.)
- Fix activity-log.ts eventType casting
- Fix crafting-design.ts computedEffects and designProgress types
- Fix page.tsx grimoire spell rendering with proper SpellDef property names
- Fix StatsTab ManaStatsSection with proper ManaStatsEffects interface
- Remove unused imports (useDisciplineStore from page.tsx, LeftPanel.tsx)

Remaining: 1 as any in craftingStore.ts (pre-existing CraftingStore/GameState architectural mismatch)
2026-05-20 17:22:52 +02:00
n8n-gitea df316c2865 test: add store action and cross-store tick integration tests; fix pact ritual double-counting bug
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m16s
- Add test-setup.ts: shared test environment setup helper for tick integration tests
- Add combat-store.test.ts (24 tests): setCurrentFloor, advanceFloor, setFloorHP, setMaxFloorReached, setAction, setSpell, setCastProgress, learnSpell, setSpellState, debugSetFloor, resetFloorHP, resetCombat, climbDownFloor, exitSpireMode
- Add mana-store.test.ts (36 tests): setRawMana, addRawMana, spendRawMana, gatherMana, convertMana, unlockElement, addElementMana, spendElementMana, craftComposite, processConvertAction, resetMana, meditation ticks, setElementMax
- Add tick-integration.test.ts (19 tests): time progression, mana regeneration, incursion penalty, meditation, loop end, paused/game over states
- Add tick-integration-pact.test.ts (9 tests): victory condition, pact ritual progress, multiple ticks accumulation
- Add tick-debug.test.ts (3 debug tests): regen trace, pact ritual trace, persist leak check
- Fix bug in gameStore.ts: updatePactRitualProgress was called with absolute newProgress instead of incremental HOURS_PER_TICK, causing exponential progress accumulation
- All 422 tests pass (18 test files), all files under 400-line limit
2026-05-20 15:20:42 +02:00
n8n-gitea a49b8a8bef test: add unit tests for core game logic utilities
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
Add 5 new test files covering pure utility functions:
- discipline-math.test.ts (42 tests): stat bonus, mana drain, perk tiers,
  discipline activation/progression, unlocked perks, discipline stats
- formatting.test.ts (35 tests): fmt, fmtDec, formatSpellCost,
  getSpellCostColor, formatStudyTime, formatHour
- floor-utils.test.ts (13 tests): getFloorMaxHP, getFloorElement
- combat-utils.test.ts (37 tests): getElementalBonus, getBoonBonuses,
  getIncursionStrength, canAffordSpellCost, deductSpellCost
- mana-utils.test.ts (36 tests): computeMaxMana, computeRegen,
  computeClickMana, getMeditationBonus, computeEffectiveRegenForDisplay

Total: 163 new tests, all passing. No existing tests broken.
2026-05-20 13:01:15 +02:00
n8n-gitea cba42e01ff refactor: remove legacy store.ts and crafting-slice.ts, complete modular store migration
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
- Delete store.ts (355 LOC monolithic store, zero imports)
- Delete crafting-slice.ts (379 LOC legacy crafting module)
- Inline createStartingEquipment() into craftingStore.ts
- Remove legacy equipment/inventory fields from GameState
- Remove EquipmentDef from game.ts imports (unused)
- Fix duplicate EquipmentSpellState export in types.ts
- Fix bluePrintId typo in craftingStore.ts
- Update stores/index.ts to import CraftingState/CraftingActions from craftingStore.types
- Update EquipmentTab.test.ts to test store state instead of deleted module
- Clean up stale comments referencing crafting-slice.ts
- Reduce TS errors from 83 to 72 by removing conflicting legacy types
2026-05-20 12:36:00 +02:00
n8n-gitea 56ac50f465 refactor: break circular deps in equipment and golems data modules
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
2026-05-20 12:00:46 +02:00
n8n-gitea 7d56fc368f feat: Recreate Spire Combat Page — full spire climbing experience
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
- Add guardian-encounters.ts: Extended guardian definitions for all mana types (compound, exotic, combo) with dynamic name generation
- Add spire-utils.ts: Spire-specific utilities (room generation, enemy stat scaling, insight calculation)
- Add enemy-generator.ts: Enemy generation with combinable modifiers (mage, shield, armored, swarm, agile)
- Add SpireCombatPage/ directory with modular sub-components:
  - SpireHeader.tsx: Floor info, climb controls, exit button, HP/room progress bars
  - RoomDisplay.tsx: Current room info with enemies, barriers, armor, dodge stats
  - SpireCombatControls.tsx: Spell selection panel, golem status panel
  - SpireActivityLog.tsx: Combat activity log
  - SpireManaDisplay.tsx: Compact mana display with elemental pools
- Modify page.tsx: Conditionally render SpireCombatPage when spireMode is true
- Add comprehensive tests (49 tests) for spire utilities, guardian encounters, and enemy generation
2026-05-20 09:28:05 +02:00
n8n-gitea 1c7fc8c551 feat: recreate Crafting Tab with Fabricator and Enchanter sub-tabs
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 2m18s
- Add fabricator-recipes.ts with 12 recipes across earth/metal/crystal/sand mana types
- Add FabricatorSubTab with mana-type filtering, recipe cards, materials inventory
- Add EnchanterSubTab integrating existing 3-phase flow (Design → Prepare → Apply)
- Add CraftingTab main component with clsx-based sub-tab system (matches DisciplinesTab pattern)
- Wire into tabs barrel export and page.tsx with lazy loading + DebugName wrapper
- Add 17 tests covering exports, displayNames, recipe data integrity, helpers, file sizes
- All files under 400 lines
2026-05-20 02:32:37 +02:00
n8n-gitea 9882578627 feat: add Spire Summary Tab showing guardian progress, floor map, and climb button
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
2026-05-19 22:59:54 +02:00
n8n-gitea 1cda85929d feat: recreate Guardian Pacts tab for Invoker attunement
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m16s
- Add GuardianPactsTab.tsx with guardian cards organized by floor tier
- Display HP, armor, power stats, boons, unique perk, pact cost per guardian
- Show status: Undefeated / Defeated (pact available) / Pact Signed
- Allow starting pact rituals with defeated guardians
- Show pact ritual progress bar
- Display active pacts and cumulative boon effects
- Show remaining pact slots
- Add tier filter (All / Early / Mid / Late Spire)
- Add to tabs barrel export and page.tsx with lazy loading
- Add DebugName wrapper
- Write 13 tests covering module structure, data integrity, store shape, file size
2026-05-19 22:37:53 +02:00
n8n-gitea 0b6ee15e9b feat: recreate Golemancy tab with golem loadout configuration
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m17s
2026-05-19 22:25:59 +02:00
n8n-gitea dbc1b5e02c feat: recreate Equipment Tab with equip/unequip gear management
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
2026-05-19 22:04:27 +02:00
n8n-gitea 1cd612193d feat: recreate Prestige tab with insight upgrades, memories, pacts, and loop reset
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
2026-05-19 20:19:31 +02:00
n8n-gitea 5643a4c145 feat: recreate Attunements tab with detailed attunement cards
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
2026-05-19 18:29:29 +02:00
n8n-gitea 2c4dc82aad feat: recreate Debug Tab with modular debugging functions
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m17s
- Add DebugTab.tsx as main container with collapsible sections
- Add 8 debug section components in DebugTab/ subdirectory:
  - GameStateDebugSection: reset, mana, time, pause controls
  - DisciplineDebugSection: activate/deactivate, add XP
  - AttunementDebugSection: unlock, add XP
  - ElementDebugSection: unlock all, add elemental mana
  - GolemDebugSection: enable/disable golems
  - PactDebugSection: force sign/clear pacts
  - SpireDebugSection: jump floors, toggle spire mode
  - AchievementDebugSection: unlock/reset achievements
- Add DebugTab to barrel export (tabs/index.ts)
- Add lazy-loaded Debug tab to page.tsx
- Add DebugTab.test.ts with 45 tests
- All files under 400 lines
- Uses existing debug context (DebugProvider, DebugName)
- Destructive actions require confirmation (double-click pattern)
2026-05-19 15:55:20 +02:00
n8n-gitea 639d396f80 feat: recreate Achievements tab with category sections, progress tracking, and hidden achievement logic
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
2026-05-19 14:44:27 +02:00
n8n-gitea 50a9a62060 fix: resolve priority 4 issues — discipline mutation, skill→discipline migration, uiStore persistence, game loop interval, toast listener leak, page re-renders
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
- Issue 70: Fix discipline-slice.ts nested element mutation (use immutable spread)
- Issue 71: Add persist middleware to uiStore for paused/gameOver/victory
- Issue 72: Wire discipline effects into calcDamage (spell-casting, void-manipulation)
- Issue 73: Fix useGameLoop interval recreation (use getState() + empty deps)
- Issue 74: Fix use-toast.ts listener leak (change [state] dep to [])
- Issue 75: Reduce page.tsx re-renders with useShallow for multi-field subscriptions
- Issue 76: Fix createGatherMana hardcoded click mana (use computeClickMana with discipline effects)
- Issue 77: Pass discipline effects to computeMaxMana/computeRegen/calcInsight in tick()
- Export DisciplineBonuses type and useDisciplineStore from barrel exports
- Update tests to match new function signatures
2026-05-19 13:53:33 +02:00
n8n-gitea ebcaab62bf fix: clone nested element objects in discipline-slice processTick to avoid bypassing Zustand reactivity
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
2026-05-19 12:51:41 +02:00
n8n-gitea 213425e6c9 fix(tests): remove broken test index files and fix computed-stats import
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m24s
2026-05-19 12:34:58 +02:00
n8n-gitea e259484b53 fix(game): pass real effects to hasSpecial in tick() — Executioner/Berserker now work
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
2026-05-19 12:06:46 +02:00
n8n-gitea 3dcd967949 refactor: consolidate all tab components into src/components/game/tabs/
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s
2026-05-19 11:44:25 +02:00
n8n-gitea 48a5ad1855 fix: 6 priority-3 bug fixes with regression tests
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m24s
- Issue 83: Mana Tide pulse factor now ranges 0.5x-1.5x (was 0.5x-1.0x)
- Issue 82: SteadyStream no longer returns early like EternalFlow; only skips incursion penalty
- Issue 81: Prestige store partialize now includes defeatedGuardians, signedPacts, signedPactDetails, pactRitualFloor, pactRitualProgress, loopInsight, pactSlots
- Issue 80: Combat store partialize now includes floorHP, floorMaxHP, castProgress, spireMode, clearedFloors, golemancy, equipmentSpellStates, activityLog, achievements
- Issue 78: cancelDesign now always cancels designProgress first, then designProgress2
- Issue 79: startDesigningEnchantment now uses designProgress2 when designProgress is occupied

Added 13 regression tests in src/lib/game/__tests__/regression-fixes.test.ts
Refactored craftingStore types to craftingStore.types.ts to stay under 400-line limit
2026-05-19 11:19:10 +02:00
n8n-gitea c3a5f333da fix: resolve 22 remaining issues - type exports, dead code, state mutations, orphaned components
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
2026-05-18 21:03:43 +02:00
n8n-gitea a9918e83a6 fix: add missing enchantment effects for rotTouch, soulRend, master, and legendary spells
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
- Add spell_rotTouch to BASIC_SPELL_EFFECTS (death element Tier 1)
- Add spell_soulRend to TIER2_SPELL_EFFECTS (death element Tier 2)
- Add spell_cosmicStorm, spell_heavenLight, spell_oblivion, spell_deathMark to TIER3_SPELL_EFFECTS (master spells)
- Create legendary-spells.ts with spell_stellarNova, spell_voidCollapse, spell_crystalShatter (legendary spells)
- Update spell-effects/index.ts to include LEGENDARY_SPELL_EFFECTS in SPELL_EFFECTS

Closes #41
2026-05-18 20:30:46 +02:00
n8n-gitea 594eec1ab4 fix: resolve all Priority 4 and Priority 3 issues (18 issues total)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
Priority 4 fixes:
- #50: getUnlockedAttunements filter now only returns active attunements
- #48: doPrestige return type changed from void to boolean
- #47: ConfirmDialog now catches and displays async errors from onConfirm
- #46: GameStateDebug Fill Mana now uses direct setState instead of loop
- #55: DisciplinesTab statBonus/baseValue props verified correct
- #56: DisciplinesTab tab filtering verified working

Priority 3 fixes:
- #45: drain spell description changed from 'life force' to 'vital energy'
- #44: removed banned 'ascension' skill category
- #43: renamed lifeEssenceDrop to vitalityEssenceDrop
- #42: pactMaster achievement requirement changed from 12 to 9
- #40: golems/utils.ts and equipment/utils.ts now import from index
- #39: removed duplicate RoomType from constants/rooms.ts
- #38: consolidated EquipmentSlot type in types/equipmentSlot.ts
- #37: removed duplicate EnchantmentEffectDef from spell-effects/types.ts
- #36: renamed RARITY_COLORS in loot-drops.ts to LOOT_RARITY_COLORS
2026-05-18 20:09:54 +02:00
n8n-gitea 4f932b6810 fix: remove dead GameContext system and orphaned MemorySlotPicker
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s
GameContext (Provider, hooks, context-create, types) was never wired into
the app — no layout or page wrapped children with GameProvider. The only
consumer, MemorySlotPicker, was itself orphaned (never imported/rendered).
The app uses direct Zustand hooks throughout. Removes 6 dead files.

Fixes #65
2026-05-18 19:38:22 +02:00
n8n-gitea ff3a268358 fix: resolve all Priority 5 CRASH/BLOCKER issues (#51-#57)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
- #51: Fix broken import path @/types/disciplines → @/lib/game/types/disciplines
- #52: Fix canProceedDiscipline() called with wrong arguments in discipline-slice.ts
- #53: Add local useState for activeAttunement tab filtering in DisciplinesTab
- #54: Make canProceedDiscipline() defensive when gameState is undefined
- #57: Remove stale CraftingTab export from game/index.ts
- Refactored DisciplineCard to use Zustand selector subscriptions properly
- Added DisciplinePerk type import to discipline-math.ts
2026-05-18 17:51:06 +02:00
n8n-gitea 92238e4dd8 updated dependency tracking files
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
2026-05-18 15:14:00 +02:00
n8n-gitea afbdb71548 fix: resolve Docker build errors - JSX ternary, missing barrel export, missing ActivityLog component
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m22s
2026-05-18 15:07:34 +02:00
n8n-gitea 14ba02d987 fix: remove debugSetTime and useGameStore import from combatStore to break remaining circular deps
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 55s
2026-05-18 14:51:38 +02:00
n8n-gitea 084fea2a25 fix: resolve 7 circular dependency chains in src/lib/game
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 57s
- equipment/utils.ts: import directly from individual equipment modules instead of index.ts
- golems/utils.ts: import directly from individual golem modules instead of index.ts
- combatStore.ts: extract CombatState to combat-state.types.ts, remove debugSetTime (was only user of gameStore import)
- combat-actions.ts: import CombatState from combat-state.types.ts instead of combatStore
- stores/index.ts: re-export CombatState from combat-state.types.ts
- GameStateDebug.tsx: replace debugSetTime calls with direct useGameStore.setState()

Verification: bunx madge --circular src/lib/game → No circular dependency found!
2026-05-18 14:46:57 +02:00
n8n-gitea ea3035ec5e updated dependency tracking files
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 57s
2026-05-18 14:22:47 +02:00
n8n-gitea ca86b6268c refactor: resolve structural inconsistencies and dead code
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 55s
- Fix broken barrel exports in components/game/index.ts
- Remove skill system from stores (gameStore, gameActions, gameLoopActions, gameHooks, craftingStore, combat)
- Remove skill system from components (page.tsx, LeftPanel, StatsTab, SpellsTab, EnchantmentDesigner, EnchantmentPreparer, GameContext/Provider)
- Delete dead code: stats/ directory, attunements/ directory, layout/ Header+TabBar, shared/ StudyProgress+UpgradeDialog duplicates, effects.ts.fix, study-slice.ts, navigation-slice.ts
- Delete legacy store/ and store-modules/ directories, redirect remaining callers
- Merge root formatting.ts into utils/formatting.ts
- Move effects files (dynamic-compute, upgrade-effects, special-effects, upgrade-effects.types) into effects/ directory
- Move debug-context.tsx into components/game/debug/
- Create tabs/index.ts barrel for tab components
- Fix page.tsx lazy imports to use tabs barrel
- Fix all broken import paths across codebase
- Remove SKILLS_DEF and skill-evolution references
- Trim store.ts to under 400 lines by removing dead skill actions
2026-05-18 14:21:59 +02:00
n8n-gitea 2805f75f5e cleanup: delete computed-stats.ts shim and store/index.ts
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 57s
- Delete src/lib/game/computed-stats.ts (root-level re-export shim)
- Delete src/lib/game/store/index.ts (nothing imports from it)
- Update __tests__/computed-stats.test.ts to import from ../utils instead
- Clean up craftingStore.ts imports (remove unused useGameStore, CraftingApply)

Typecheck and lint pass (pre-existing DisciplinesTab.tsx errors unchanged)
2026-05-18 12:08:38 +02:00
n8n-gitea 20c2ebd7b5 Updated docs
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 56s
2026-05-18 11:26:24 +02:00
n8n-gitea 67bd5b4a86 updated dependencies
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 57s
2026-05-18 10:33:15 +02:00
n8n-gitea 43856acd1e fix(build): sync bun lockfile for CI
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s
2026-05-18 10:24:31 +02:00
n8n-gitea 28d1a672da updated dependency tracking files
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 31s
2026-05-18 09:58:56 +02:00
n8n-gitea 00650c82fd updated dependency tracking files 2026-05-18 09:58:21 +02:00
243 changed files with 14781 additions and 17582 deletions
+1
View File
@@ -48,3 +48,4 @@ prompt
server.log
# Skills directory
.desloppify/
+24 -8
View File
@@ -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
-43
View File
@@ -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 -->
+247 -678
View File
File diff suppressed because it is too large Load Diff
+267 -597
View File
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -1,14 +1,11 @@
# Circular Dependencies
Generated: 2026-05-17T17:39:56.862Z
Found: 7 circular chain(s) — these MUST be fixed before modifying involved files.
Generated: 2026-05-20T17:48:45.265Z
Found: 4 circular chain(s) — these MUST be fixed before modifying involved files.
1. Processed 151 files (1.4s) (37 warnings)
2. 1) data/equipment/index.ts > data/equipment/utils.ts
3. 2) data/golems/index.ts > data/golems/utils.ts
4. 3) stores/combat-actions.ts > stores/combatStore.ts
5. 4) stores/combatStore.ts > stores/gameStore.ts
6. 5) stores/combatStore.ts > stores/gameStore.ts > stores/gameActions.ts
7. 6) stores/combatStore.ts > stores/gameStore.ts > stores/gameLoopActions.ts
1. Processed 126 files (1.4s) (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.
+147 -276
View File
@@ -1,26 +1,10 @@
{
"_meta": {
"generated": "2026-05-17T17:39:55.251Z",
"generated": "2026-05-20T17:48:43.703Z",
"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,12 +21,15 @@
"constants/guardians.ts",
"constants/prestige.ts",
"constants/rooms.ts",
"constants/spells.ts"
"constants/spells.ts",
"types/game.ts"
],
"constants/prestige.ts": [
"types.ts"
],
"constants/rooms.ts": [],
"constants/rooms.ts": [
"types/game.ts"
],
"constants/spells-modules/advanced-spells.ts": [
"constants/elements.ts",
"types.ts"
@@ -111,9 +98,9 @@
"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",
"types.ts"
],
"crafting-actions/disenchant-actions.ts": [
"types.ts"
@@ -133,15 +120,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",
@@ -151,9 +138,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",
@@ -169,28 +156,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"
],
@@ -203,21 +170,20 @@
"data/crafting-recipes.ts": [
"data/equipment/types.ts"
],
"data/disciplines/base-disciplines.ts": [],
"data/disciplines/base.ts": [
"types/disciplines.ts"
],
"data/disciplines/enchanter-disciplines.ts": [],
"data/disciplines/enchanter.ts": [
"types/disciplines.ts"
],
"data/disciplines/fabricator-disciplines.ts": [
"types/disciplines.ts"
],
"data/disciplines/fabricator.ts": [
"types/disciplines.ts"
],
"data/disciplines/invoker-disciplines.ts": [
"data/disciplines/index.ts": [
"data/disciplines/base.ts",
"data/disciplines/enchanter.ts",
"data/disciplines/fabricator.ts",
"data/disciplines/invoker.ts",
"types/disciplines.ts"
],
"data/disciplines/invoker.ts": [
@@ -249,7 +215,8 @@
"data/enchantments/mana-effects.ts",
"data/enchantments/special-effects.ts",
"data/enchantments/spell-effects/index.ts",
"data/enchantments/utility-effects.ts"
"data/enchantments/utility-effects.ts",
"data/equipment/index.ts"
],
"data/enchantments/mana-effects.ts": [
"constants.ts",
@@ -264,7 +231,9 @@
"data/enchantments/spell-effects/types.ts"
],
"data/enchantments/spell-effects/index.ts": [
"data/enchantment-types.ts",
"data/enchantments/spell-effects/basic-spells.ts",
"data/enchantments/spell-effects/legendary-spells.ts",
"data/enchantments/spell-effects/lightning-spells.ts",
"data/enchantments/spell-effects/metal-spells.ts",
"data/enchantments/spell-effects/sand-spells.ts",
@@ -272,6 +241,9 @@
"data/enchantments/spell-effects/tier3-spells.ts",
"data/enchantments/spell-effects/types.ts"
],
"data/enchantments/spell-effects/legendary-spells.ts": [
"data/enchantments/spell-effects/types.ts"
],
"data/enchantments/spell-effects/lightning-spells.ts": [
"data/enchantments/spell-effects/types.ts"
],
@@ -287,7 +259,10 @@
"data/enchantments/spell-effects/tier3-spells.ts": [
"data/enchantments/spell-effects/types.ts"
],
"data/enchantments/spell-effects/types.ts": [],
"data/enchantments/spell-effects/types.ts": [
"data/enchantment-types.ts",
"data/equipment/index.ts"
],
"data/enchantments/utility-effects.ts": [
"data/enchantment-types.ts",
"data/equipment/index.ts"
@@ -304,6 +279,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"
],
@@ -318,6 +304,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",
@@ -332,9 +319,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": [
@@ -343,217 +335,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",
"effects/discipline-effects.ts",
"special-effects.ts",
"types.ts",
"upgrade-effects.ts",
"upgrade-effects.types.ts"
"effects/special-effects.ts",
"effects/upgrade-effects.ts",
"effects/upgrade-effects.types.ts",
"types.ts"
],
"effects/discipline-effects.ts": [
"data/disciplines/index.ts",
"stores/discipline-slice.ts",
"types.ts",
"types/disciplines.ts",
"utils/discipline-math.ts"
],
"formatting.ts": [
"computed-stats.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"
],
"navigation-slice.ts": [
"computed-stats.ts",
"types.ts"
],
"special-effects.ts": [
"upgrade-effects.types.ts"
],
"store-modules/activity-log.ts": [
"types.ts"
],
"store-modules/computed-stats.ts": [
"constants.ts",
"data/attunements.ts",
"effects.ts",
"special-effects.ts",
"types.ts",
"upgrade-effects.types.ts"
],
"store-modules/enemy-utils.ts": [
"constants.ts",
"types.ts",
"utils/floor-utils.ts"
],
"store-modules/initial-state.ts": [
"constants.ts",
"crafting-slice.ts",
"store-modules/computed-stats.ts",
"store-modules/room-utils.ts",
"types.ts",
"upgrade-effects.ts",
"utils/floor-utils.ts"
],
"store-modules/room-utils.ts": [
"constants.ts",
"store-modules/enemy-utils.ts",
"types.ts",
"utils/floor-utils.ts"
],
"store-modules/store-actions.ts": [
"constants.ts",
"crafting-slice.ts",
"data/attunements.ts",
"data/enchantment-effects.ts",
"data/equipment/index.ts",
"data/golems/index.ts",
"effects.ts",
"special-effects.ts",
"store-modules/activity-log.ts",
"store-modules/computed-stats.ts",
"store-modules/enemy-utils.ts",
"store-modules/initial-state.ts",
"store-modules/room-utils.ts",
"types.ts",
"upgrade-effects.ts",
"upgrade-effects.types.ts",
"utils/combat-utils.ts"
],
"store-modules/tick-logic.ts": [
"constants.ts",
"crafting-slice.ts",
"data/attunements.ts",
"data/golems/index.ts",
"effects.ts",
"special-effects.ts",
"store-modules/activity-log.ts",
"store-modules/computed-stats.ts",
"store-modules/room-utils.ts",
"types.ts",
"utils/combat-utils.ts",
"utils/floor-utils.ts"
],
"store.ts": [
"store-modules/activity-log.ts",
"store-modules/computed-stats.ts",
"store-modules/initial-state.ts",
"store-modules/room-utils.ts",
"types.ts",
"utils/floor-utils.ts",
"utils/formatting.ts"
],
"store/combatSlice.ts": [
"constants.ts",
"special-effects.ts",
"store/computed.ts",
"types.ts",
"upgrade-effects.ts"
],
"store/computed.ts": [
"constants.ts",
"effects.ts",
"types.ts",
"upgrade-effects.ts",
"upgrade-effects.types.ts"
],
"store/crafting-modules/initial-state.ts": [
"data/equipment/index.ts",
"store/crafting-modules/types.ts"
],
"store/crafting-modules/selectors.ts": [
"data/equipment/index.ts",
"store/crafting-modules/types.ts",
"store/crafting-modules/utils.ts",
"types.ts"
],
"store/crafting-modules/slice-logic.ts": [
"data/equipment/index.ts",
"store/crafting-modules/initial-state.ts",
"store/crafting-modules/selectors.ts",
"store/crafting-modules/tick-processors.ts",
"store/crafting-modules/types.ts",
"store/crafting-modules/utils.ts",
"types.ts"
],
"store/crafting-modules/starting-equipment.ts": [
"store/crafting-modules/utils.ts",
"types.ts"
],
"store/crafting-modules/tick-processors.ts": [
"data/enchantment-effects.ts",
"store/crafting-modules/types.ts",
"store/crafting-modules/utils.ts",
"types.ts"
],
"store/crafting-modules/types.ts": [
"data/equipment/index.ts",
"types.ts"
],
"store/crafting-modules/utils.ts": [
"data/enchantment-effects.ts",
"data/equipment/index.ts",
"types.ts"
],
"store/craftingSlice.ts": [
"store/crafting-modules/initial-state.ts",
"store/crafting-modules/slice-logic.ts",
"store/crafting-modules/starting-equipment.ts",
"store/crafting-modules/types.ts",
"store/crafting-modules/utils.ts"
],
"store/index.ts": [
"store.ts",
"store/computed.ts"
],
"store/manaSlice.ts": [
"constants.ts",
"special-effects.ts",
"store.ts",
"store/computed.ts",
"types.ts",
"upgrade-effects.ts"
],
"store/pactSlice.ts": [
"constants.ts",
"store/computed.ts",
"types.ts"
],
"store/prestigeSlice.ts": [
"constants.ts",
"store/computed.ts",
"types.ts"
],
"store/timeSlice.ts": [
"constants.ts",
"store/computed.ts",
"types.ts"
"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",
@@ -561,14 +401,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",
@@ -578,18 +421,18 @@
"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/uiStore.ts",
"types.ts",
"upgrade-effects.ts"
"types/equipmentSlot.ts"
],
"stores/craftingStore.types.ts": [
"types.ts"
],
"stores/discipline-slice.ts": [
"data/disciplines/base.ts",
@@ -600,18 +443,22 @@
"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/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",
@@ -620,7 +467,10 @@
],
"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/uiStore.ts",
@@ -629,23 +479,30 @@
"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/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",
@@ -661,13 +518,17 @@
"constants.ts",
"types.ts"
],
"stores/uiStore.ts": [],
"study-slice.ts": [
"constants.ts",
"special-effects.ts",
"types.ts",
"upgrade-effects.ts"
"stores/tick-pipeline.ts": [
"stores/attunementStore.ts",
"stores/combat-state.types.ts",
"stores/craftingStore.types.ts",
"stores/discipline-slice.ts",
"stores/gameStore.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
"stores/uiStore.ts"
],
"stores/uiStore.ts": [],
"types.ts": [
"data/equipment/types.ts",
"types/attunements.ts",
@@ -681,7 +542,9 @@
"types/elements.ts"
],
"types/elements.ts": [],
"types/equipment.ts": [],
"types/equipment.ts": [
"types/equipmentSlot.ts"
],
"types/equipmentSlot.ts": [],
"types/game.ts": [
"types/attunements.ts",
@@ -692,31 +555,29 @@
"types/index.ts": [
"types/attunements.ts",
"types/elements.ts",
"types/equipment.ts",
"types/equipmentSlot.ts",
"types/game.ts",
"types/spells.ts"
],
"types/spells.ts": [],
"upgrade-effects.ts": [
"dynamic-compute.ts",
"special-effects.ts",
"types.ts",
"upgrade-effects.types.ts"
],
"upgrade-effects.types.ts": [
"types.ts"
],
"utils/activity-log.ts": [
"types.ts"
],
"utils/combat-utils.ts": [
"constants.ts",
"data/enchantment-effects.ts",
"types.ts"
"types.ts",
"utils/mana-utils.ts"
],
"utils/discipline-math.ts": [
"types/disciplines.ts"
],
"utils/enemy-generator.ts": [
"types.ts",
"utils/enemy-utils.ts",
"utils/floor-utils.ts"
],
"utils/enemy-utils.ts": [
"constants.ts",
"types.ts",
@@ -735,14 +596,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"
]
}
}
+92 -99
View File
@@ -10,17 +10,11 @@ Mana-Loop/
│ ├── post-merge
│ └── pre-commit
├── docs/
│ ├── strategy/
│ │ └── overall-remediation-plan.md
│ ├── GAME_BRIEFING.md
│ ├── circular-deps.txt
│ ├── dependency-graph.json
── project-structure.txt
│ └── skills.md
── project-structure.txt
├── e2e/
│ ├── combat.spec.ts
│ ├── enchanting.spec.ts
│ └── equipment.spec.ts
├── playwright-report/
│ ├── data/
│ │ ├── 1513ea5b9ea5985996f67ca36f2bc4d34add51f1.webm
@@ -58,34 +52,20 @@ Mana-Loop/
│ ├── app/
│ │ ├── components/
│ │ │ ├── GameOverScreen.tsx
│ │ │ ├── GrimoireTab.tsx
│ │ │ └── LeftPanel.tsx
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components/
│ │ ├── game/
│ │ │ ├── GameContext/
│ │ │ │ ├── Provider.tsx
│ │ │ │ ├── context-create.ts
│ │ │ │ ├── hooks.ts
│ │ │ │ └── types.ts
│ │ │ ├── LootInventory/
│ │ │ │ ├── BlueprintsSection.tsx
│ │ │ │ ├── EquipmentItem.tsx
│ │ │ │ ├── EssenceItem.tsx
│ │ │ │ ├── LootInventoryDisplay.tsx
│ │ │ │ ├── MaterialItem.tsx
│ │ │ │ ├── icons.ts
│ │ │ │ ├── index.tsx
│ │ │ │ └── types.ts
│ │ │ ├── StatsTab/
│ │ │ │ ├── ActiveUpgradesSection.tsx
│ │ │ │ ├── CombatStatsSection.tsx
│ │ │ │ ├── ElementStatsSection.tsx
│ │ │ │ ├── LoopStatsSection.tsx
│ │ │ │ ├── ManaStatsSection.tsx
│ │ │ │ ├── PactStatusSection.tsx
│ │ │ │ └── StudyStatsSection.tsx
│ │ │ ├── crafting/
│ │ │ │ ├── EnchantmentDesigner/
│ │ │ │ │ ├── DesignForm.tsx
@@ -105,36 +85,69 @@ Mana-Loop/
│ │ │ │ ├── GameStateDebug.tsx
│ │ │ │ ├── GolemDebug.tsx
│ │ │ │ ├── PactDebug.tsx
│ │ │ │ ├── debug-context.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── layout/
│ │ │ │ ├── Header.tsx
│ │ │ │ └── TabBar.tsx
│ │ │ ├── shared/
│ │ │ │ ├── MemorySlotPicker.tsx
│ │ │ │ ├── StudyProgress.tsx
│ │ │ │ └── UpgradeDialog.tsx
│ │ │ ├── stats/
│ │ │ │ ├── CombatStatsSection.tsx
│ │ │ │ ├── ManaStatsSection.tsx
│ │ │ │ ├── ManaTypeBreakdown.tsx
│ │ │ │ ├── StudyStatsSection.tsx
│ │ │ │ ├── UpgradeEffectsSection.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── tabs/
│ │ │ │ ── DisciplinesTab.tsx
│ │ │ ├── AchievementsDisplay.tsx
│ │ │ │ ── CraftingTab/
│ │ │ │ │ ├── EnchanterSubTab.tsx
│ │ │ │ │ └── FabricatorSubTab.tsx
│ │ │ │ ├── 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
│ │ │ │ ├── CraftingTab.test.ts
│ │ │ │ ├── CraftingTab.tsx
│ │ │ │ ├── DebugTab.test.ts
│ │ │ │ ├── DebugTab.tsx
│ │ │ │ ├── DisciplinesTab.tsx
│ │ │ │ ├── EquipmentTab.test.ts
│ │ │ │ ├── EquipmentTab.tsx
│ │ │ │ ├── GolemancyTab.test.ts
│ │ │ │ ├── GolemancyTab.tsx
│ │ │ │ ├── GuardianPactsTab.test.ts
│ │ │ │ ├── GuardianPactsTab.tsx
│ │ │ │ ├── PrestigeTab.test.ts
│ │ │ │ ├── PrestigeTab.tsx
│ │ │ │ ├── SpellsTab.tsx
│ │ │ │ ├── SpireSummaryTab.test.ts
│ │ │ │ ├── SpireSummaryTab.tsx
│ │ │ │ ├── StatsTab.tsx
│ │ │ │ ├── guardian-pacts-components.tsx
│ │ │ │ └── index.ts
│ │ │ ├── ActionButtons.tsx
│ │ │ ├── ActivityLogPanel.tsx
│ │ │ ├── AttunementStatus.tsx
│ │ │ ├── CalendarDisplay.tsx
│ │ │ ├── ConfirmDialog.tsx
│ │ │ ├── CraftingProgress.tsx
│ │ │ ├── GameContext.tsx
│ │ │ ├── GameToast.tsx
│ │ │ ├── ManaDisplay.tsx
│ │ │ ├── SpellsTab.tsx
│ │ │ ├── StatsTab.tsx
│ │ │ ├── StudyProgress.tsx
│ │ │ ├── TimeDisplay.tsx
│ │ │ ├── UpgradeDialog.tsx
│ │ │ ├── index.ts
@@ -177,13 +190,22 @@ Mana-Loop/
│ ├── game/
│ │ ├── __tests__/
│ │ │ ├── store-method-tests/
│ │ │ ├── achievements.test.ts
│ │ │ ├── bug-fixes.test.ts
│ │ │ ── computed-stats.test.ts
│ │ ├── attunements/
│ │ │ ├── data.ts
│ │ │ ├── index.ts
│ │ │ ├── types.ts
│ │ │ ── utils.ts
│ │ │ ── combat-utils.test.ts
│ │ │ ├── computed-stats.test.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
@@ -214,18 +236,16 @@ Mana-Loop/
│ │ │ └── preparation-actions.ts
│ │ ├── data/
│ │ │ ├── disciplines/
│ │ │ │ ├── base-disciplines.ts
│ │ │ │ ├── base.ts
│ │ │ │ ├── enchanter-disciplines.ts
│ │ │ │ ├── enchanter.ts
│ │ │ │ ├── fabricator-disciplines.ts
│ │ │ │ ├── fabricator.ts
│ │ │ │ ├── invoker-disciplines.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── invoker.ts
│ │ │ ├── enchantments/
│ │ │ │ ├── spell-effects/
│ │ │ │ │ ├── basic-spells.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── legendary-spells.ts
│ │ │ │ │ ├── lightning-spells.ts
│ │ │ │ │ ├── metal-spells.ts
│ │ │ │ │ ├── sand-spells.ts
@@ -244,6 +264,7 @@ Mana-Loop/
│ │ │ │ ├── body.ts
│ │ │ │ ├── casters.ts
│ │ │ │ ├── catalysts.ts
│ │ │ │ ├── equipment-types-data.ts
│ │ │ │ ├── feet.ts
│ │ │ │ ├── hands.ts
│ │ │ │ ├── head.ts
@@ -255,6 +276,7 @@ Mana-Loop/
│ │ │ ├── golems/
│ │ │ │ ├── base-golems.ts
│ │ │ │ ├── elemental-golems.ts
│ │ │ │ ├── golems-data.ts
│ │ │ │ ├── hybrid-golems.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── types.ts
@@ -264,51 +286,33 @@ Mana-Loop/
│ │ │ ├── crafting-recipes.ts
│ │ │ ├── enchantment-effects.ts
│ │ │ ├── enchantment-types.ts
│ │ │ ├── fabricator-recipes.ts
│ │ │ ├── guardian-encounters.ts
│ │ │ └── loot-drops.ts
│ │ ├── effects/
│ │ │ ── discipline-effects.ts
│ │ │ ── discipline-effects.ts
│ │ │ ├── dynamic-compute.ts
│ │ │ ├── special-effects.ts
│ │ │ ├── upgrade-effects.ts
│ │ │ └── upgrade-effects.types.ts
│ │ ├── hooks/
│ │ │ └── useGameDerived.ts
│ │ ├── store/
│ │ │ ├── crafting-modules/
│ │ │ │ ├── initial-state.ts
│ │ │ │ ├── selectors.ts
│ │ │ │ ├── slice-logic.ts
│ │ │ │ ├── starting-equipment.ts
│ │ │ │ ├── tick-processors.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils.ts
│ │ │ ├── combatSlice.ts
│ │ │ ├── computed.ts
│ │ │ ├── craftingSlice.ts
│ │ │ ├── index.ts
│ │ │ ├── manaSlice.ts
│ │ │ ├── pactSlice.ts
│ │ │ ├── prestigeSlice.ts
│ │ │ └── timeSlice.ts
│ │ ├── store-modules/
│ │ │ ├── {room-utils,enemy-utils,initial-state,activity-log,store-actions}/
│ │ │ ├── activity-log.ts
│ │ │ ├── computed-stats.ts
│ │ │ ├── enemy-utils.ts
│ │ │ ├── initial-state.ts
│ │ │ ├── room-utils.ts
│ │ │ ├── store-actions.ts
│ │ │ └── tick-logic.ts
│ │ ├── stores/
│ │ │ ├── attunementStore.ts
│ │ │ ├── combat-actions.ts
│ │ │ ├── combat-state.types.ts
│ │ │ ├── combatStore.ts
│ │ │ ├── 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
│ │ ├── types/
│ │ │ ├── attunements.ts
@@ -323,13 +327,15 @@ Mana-Loop/
│ │ │ ├── activity-log.ts
│ │ │ ├── combat-utils.ts
│ │ │ ├── discipline-math.ts
│ │ │ ├── enemy-generator.ts
│ │ │ ├── enemy-utils.ts
│ │ │ ├── floor-utils.ts
│ │ │ ├── formatting.ts
│ │ │ ├── index.ts
│ │ │ ├── mana-utils.ts
│ │ │ ── room-utils.ts
│ │ ├── computed-stats.ts
│ │ │ ── pact-utils.ts
│ │ │ ├── room-utils.ts
│ │ │ └── spire-utils.ts
│ │ ├── constants.ts
│ │ ├── crafting-apply.ts
│ │ ├── crafting-attunements.ts
@@ -337,29 +343,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
│ │ ├── special-effects.ts
│ │ ├── store.test.ts
│ │ ├── store.ts
│ │ ├── stores.test.ts
│ │ ├── study-slice.ts
│ │ ├── types.ts
│ │ ├── upgrade-effects.ts
│ │ └── upgrade-effects.types.ts
│ │ ── types.ts
│ └── utils.ts
├── test-results/
│ └── .last-run.json
├── .dockerignore
├── .gitignore
├── AGENTS.md
├── CLAUDE.md
├── Caddyfile
├── Dockerfile
├── README.md
@@ -373,6 +365,7 @@ Mana-Loop/
├── package.json
├── playwright.config.ts
├── postcss.config.mjs
├── scorecard.png
├── tailwind.config.ts
├── tsconfig.json
└── vitest.config.ts
-726
View File
@@ -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*
-650
View File
@@ -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: **810 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.
-80
View File
@@ -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);
});
});
-106
View File
@@ -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 });
});
});
-100
View File
@@ -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 });
});
});
+18 -3201
View File
File diff suppressed because it is too large Load Diff
+58 -57
View File
@@ -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"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+77
View File
@@ -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>
);
}
+9 -13
View File
@@ -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}
+1 -1
View File
@@ -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
View File
@@ -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>
<TabTriggers />
<TabsTrigger value="stats" className="text-xs px-2 py-1">📊 Stats</TabsTrigger>
<TabsTrigger value="debug" className="text-xs px-2 py-1">🐛 Debug</TabsTrigger>
<TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
</TabsList>
<TabsContent value="spire">
<ErrorBoundary fallback={<div className="p-4 text-red-400">spire tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<SpireTab simpleMode={spireMode} />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="attunements">
<ErrorBoundary fallback={<div className="p-4 text-red-400">attunements tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<AttunementsTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="golemancy">
<ErrorBoundary fallback={<div className="p-4 text-red-400">golemancy tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<GolemancyTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="skills">
<ErrorBoundary fallback={<div className="p-4 text-red-400">skills tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<SkillsTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="spells">
<ErrorBoundary fallback={<div className="p-4 text-red-400">spells tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<SpellsTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="equipment">
<ErrorBoundary fallback={<div className="p-4 text-red-400">equipment tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<EquipmentTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="crafting">
<ErrorBoundary fallback={<div className="p-4 text-red-400">crafting tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<CraftingTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="loot">
<ErrorBoundary fallback={<div className="p-4 text-red-400">loot tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<LootTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="achievements">
<ErrorBoundary fallback={<div className="p-4 text-red-400">achievements tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<AchievementsTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="stats">
<ErrorBoundary fallback={<div className="p-4 text-red-400">stats tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<StatsTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="debug">
<ErrorBoundary fallback={<div className="p-4 text-red-400">debug tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<DebugTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
<TabsContent value="grimoire">
<GrimoireTab />
</TabsContent>
<TabsContent value="spells"><LazyTab name="spells"><SpellsTab /></LazyTab></TabsContent>
<TabsContent value="stats"><LazyTab name="stats"><StatsTab /></LazyTab></TabsContent>
<TabsContent value="disciplines"><LazyTab name="disciplines"><DisciplinesTab /></LazyTab></TabsContent>
<TabsContent value="grimoire"><GrimoireTab /></TabsContent>
<TabsContent value="debug"><LazyTab name="debug"><DebugTab /></LazyTab></TabsContent>
<TabsContent value="attunements"><LazyTab name="attunements"><AttunementsTab /></LazyTab></TabsContent>
<TabsContent value="achievements"><LazyTab name="achievements"><AchievementsTab /></LazyTab></TabsContent>
<TabsContent value="prestige"><LazyTab name="prestige"><PrestigeTab /></LazyTab></TabsContent>
<TabsContent value="equipment"><LazyTab name="equipment"><EquipmentTab /></LazyTab></TabsContent>
<TabsContent value="golemancy"><LazyTab name="golemancy"><GolemancyTab /></LazyTab></TabsContent>
<TabsContent value="pacts"><LazyTab name="pacts"><GuardianPactsTab /></LazyTab></TabsContent>
<TabsContent value="spire"><LazyTab name="spire"><SpireSummaryTab /></LazyTab></TabsContent>
<TabsContent value="crafting"><LazyTab name="crafting"><CraftingTab /></LazyTab></TabsContent>
</Tabs>
</div>
</main>
-205
View File
@@ -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";
-53
View File
@@ -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";
-184
View File
@@ -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;
-163
View File
@@ -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";
-10
View File
@@ -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);
-13
View File
@@ -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;
}
-160
View File
@@ -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";
-318
View File
@@ -1,318 +0,0 @@
'use client';
import { useState } from 'react';
import { GameCard } from '@/components/ui/game-card';
import { Badge } from '@/components/ui/badge';
import { ActionButton } from '@/components/ui/action-button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import {
Gem, Search, ArrowUpDown, AlertTriangle
} from 'lucide-react';
import { ElementBadge } from '@/components/ui/element-badge';
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
import { ELEMENTS } from '@/lib/game/constants';
import { useGameToast } from '@/components/game/GameToast';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { type SortMode, type FilterMode, RARITY_ORDER, RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
import { MaterialsSection } from './MaterialItem';
import { EssenceSection } from './EssenceItem';
import { BlueprintsSection } from './BlueprintsSection';
import { EquipmentSection } from './EquipmentItem';
interface LootInventoryProps {
inventory: LootInventoryType;
elements?: Record<string, ElementState>;
equipmentInstances?: Record<string, EquipmentInstance>;
onDeleteMaterial?: (materialId: string, amount: number) => void;
onDeleteEquipment?: (instanceId: string) => void;
}
export function LootInventoryDisplay({
inventory,
elements,
equipmentInstances = {},
onDeleteMaterial,
onDeleteEquipment,
}: LootInventoryProps) {
const showToast = useGameToast();
const [searchTerm, setSearchTerm] = useState('');
const [sortMode, setSortMode] = useState<SortMode>('rarity');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'material' | 'equipment'; id: string; name: string } | null>(null);
// Count items
const materialCount = Object.values(inventory.materials || {}).reduce((a: number, b: number) => a + b, 0);
// Calculate essence count
let essenceCount = 0;
if (elements) {
essenceCount = Object.entries(elements).reduce((acc: number, [id, state]) => {
if (id === 'transference') return acc;
return acc + (state.current || 0);
}, 0);
}
const blueprintCount = inventory.blueprints.length;
const equipmentCount = Object.keys(equipmentInstances).length;
const totalItems = materialCount + blueprintCount + equipmentCount;
// Filter and sort materials
const filteredMaterials = Object.entries(inventory.materials)
.filter(([id, count]) => {
if (count <= 0) return false;
const drop = LOOT_DROPS[id];
if (!drop) return false;
if (searchTerm && !drop.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aCount], [bId, bCount]) => {
const aDrop = LOOT_DROPS[aId];
const bDrop = LOOT_DROPS[bId];
if (!aDrop || !bDrop) return 0;
switch (sortMode) {
case 'name':
return aDrop.name.localeCompare(bDrop.name);
case 'rarity':
return RARITY_ORDER[bDrop.rarity] - RARITY_ORDER[aDrop.rarity];
case 'count':
return bCount - aCount;
default:
return 0;
}
});
// Filter and sort essence
const filteredEssence = elements
? Object.entries(elements)
.filter(([id, state]) => {
if (!state.unlocked || state.current <= 0) return false;
if (id === 'transference') return false;
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aState], [bId, bState]) => {
switch (sortMode) {
case 'name':
return (ELEMENTS[aId]?.name || aId).localeCompare(ELEMENTS[bId]?.name || bId);
case 'count':
return bState.current - aState.current;
default:
return 0;
}
})
: [];
// Filter and sort equipment
const filteredEquipment = Object.entries(equipmentInstances)
.filter(([id, instance]) => {
if (searchTerm && !instance.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aInst], [bId, bInst]) => {
switch (sortMode) {
case 'name':
return aInst.name.localeCompare(bInst.name);
case 'rarity':
return RARITY_ORDER[bInst.rarity] - RARITY_ORDER[aInst.rarity];
default:
return 0;
}
});
const hasItems = totalItems > 0 || essenceCount > 0;
const handleDeleteMaterial = (materialId: string) => {
const drop = LOOT_DROPS[materialId];
if (drop) {
setDeleteConfirm({ type: 'material', id: materialId, name: drop.name });
}
};
const handleDeleteEquipment = (instanceId: string) => {
const instance = equipmentInstances[instanceId];
if (instance) {
setDeleteConfirm({ type: 'equipment', id: instanceId, name: instance.name });
}
};
const confirmDelete = () => {
if (!deleteConfirm) return;
if (deleteConfirm.type === 'material' && onDeleteMaterial) {
const amount = inventory.materials[deleteConfirm.id] || 0;
onDeleteMaterial(deleteConfirm.id, amount);
showToast('success', 'Material Deleted', `${deleteConfirm.name} removed from inventory`);
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
onDeleteEquipment(deleteConfirm.id);
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
}
setDeleteConfirm(null);
};
if (!hasItems) {
return (
<GameCard variant="default" className="w-full">
<div className="flex items-center gap-2 mb-2">
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
Inventory
</h3>
</div>
<div className="text-[var(--text-muted)] text-sm text-center py-4">
No items collected yet. Defeat floors and guardians to find loot!
</div>
</GameCard>
);
}
return (
<>
<GameCard variant="default" className="w-full">
<div className="flex items-center gap-2 mb-3">
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
Inventory
</h3>
<Badge
className="ml-auto bg-[var(--bg-sunken)] text-[var(--text-secondary)] text-xs border-[var(--border-subtle)]"
aria-label={`${totalItems} items in inventory`}
>
{totalItems} items
</Badge>
</div>
{/* Search and Filter Controls */}
<div className="flex gap-2 mb-3">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--text-muted)]" />
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-7 pl-7 bg-[var(--bg-sunken)] border-[var(--border-subtle)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-disabled)]"
aria-label="Search inventory"
/>
</div>
<ActionButton
variant="secondary"
size="sm"
className="h-7 px-2"
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
aria-label={`Sort by ${sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity'}`}
>
<ArrowUpDown className="w-3 h-3" />
</ActionButton>
</div>
{/* Filter Tabs */}
<div className="flex gap-1 flex-wrap mb-3">
{[
{ mode: 'all' as FilterMode, label: 'All' },
{ mode: 'materials' as FilterMode, label: `Materials (${materialCount})` },
{ mode: 'essence' as FilterMode, label: `Essence (${essenceCount})` },
{ mode: 'blueprints' as FilterMode, label: `Blueprints (${blueprintCount})` },
{ mode: 'equipment' as FilterMode, label: `Equipment (${equipmentCount})` },
].map(({ mode, label }) => (
<ActionButton
key={mode}
variant={filterMode === mode ? 'primary' : 'secondary'}
size="sm"
className={`h-6 px-2 text-xs ${filterMode === mode ? '' : 'bg-[var(--bg-sunken)]'}`}
onClick={() => setFilterMode(mode)}
aria-pressed={filterMode === mode}
aria-label={`Filter by ${label}`}
>
{label}
</ActionButton>
))}
</div>
<Separator className="bg-[var(--border-subtle)] mb-3" />
<ScrollArea className="h-64 w-full">
<div className="space-y-3 pr-2">
{/* Materials */}
{(filterMode === 'all' || filterMode === 'materials') && (
<MaterialsSection
materials={filteredMaterials}
onDeleteMaterial={handleDeleteMaterial}
/>
)}
{/* Essence */}
{(filterMode === 'all' || filterMode === 'essence') && (
<EssenceSection essence={filteredEssence} />
)}
{/* Blueprints */}
{(filterMode === 'all' || filterMode === 'blueprints') && (
<BlueprintsSection blueprints={inventory.blueprints} />
)}
{/* Equipment */}
{(filterMode === 'all' || filterMode === 'equipment') && (
<EquipmentSection
equipment={filteredEquipment}
onDeleteEquipment={handleDeleteEquipment}
/>
)}
</div>
</ScrollArea>
</GameCard>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
<AlertDialogContent className="bg-[var(--bg-surface)] border-[var(--border-default)]">
<AlertDialogHeader>
<AlertDialogTitle className="text-[var(--mana-light)] flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Delete Item
</AlertDialogTitle>
<AlertDialogDescription className="text-[var(--text-secondary)]">
Are you sure you want to delete <strong className="text-[var(--text-primary)]">{deleteConfirm?.name}</strong>?
{deleteConfirm?.type === 'material' && (
<span className="block mt-2 text-[var(--color-danger)]">
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
</span>
)}
{deleteConfirm?.type === 'equipment' && (
<span className="block mt-2 text-[var(--color-danger)]">
This equipment and all its enchantments will be permanently lost!
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]">
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-[var(--interactive-danger)] hover:bg-[var(--interactive-danger-hover)] text-white"
onClick={confirmDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
LootInventoryDisplay.displayName = "LootInventoryDisplay";
+1 -1
View File
@@ -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';
-91
View File
@@ -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>
);
}
-59
View File
@@ -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 -1
View File
@@ -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 -3
View File
@@ -1,6 +1,5 @@
'use client';
import { SKILLS_DEF } from '@/lib/game/constants';
import type { SkillUpgradeChoice } from '@/lib/game/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -33,7 +32,6 @@ export function UpgradeDialog({
}: UpgradeDialogProps) {
if (!skillId) return null;
const skillDef = SKILLS_DEF[skillId];
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
return (
@@ -41,7 +39,7 @@ export function UpgradeDialog({
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
<DialogHeader>
<DialogTitle className="text-amber-400">
Choose Upgrade - {skillDef?.name || skillId}
Choose Upgrade - {skillId}
</DialogTitle>
<DialogDescription className="text-gray-400">
Level {milestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
@@ -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);
}
/**
@@ -14,7 +14,7 @@ import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
import type { EquipmentInstance, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
import type { EquipmentSlot } from '@/lib/game/types';
import { fmt } from '@/lib/game/stores';
import { useGameStore, useCraftingStore, useManaStore, useSkillStore } from '@/lib/game/stores';
import { useGameStore, useCraftingStore, useManaStore } from '@/lib/game/stores';
import { useGameToast } from '@/components/game/GameToast';
export interface EnchantmentPreparerProps {
@@ -31,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);
+209 -161
View File
@@ -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';
+248 -215
View File
@@ -9,15 +9,224 @@ import { Label } from '@/components/ui/label';
import {
RotateCcw, AlertTriangle, Zap, Clock, Settings, Eye,
} from 'lucide-react';
import { useDebug } from '@/lib/game/debug-context';
import { useDebug } from '@/components/game/debug/debug-context';
import { useGameStore, useManaStore, useUIStore, useCombatStore } from '@/lib/game/stores';
import { computeMaxMana } from '@/lib/game/stores';
// ─── 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';
+76 -72
View File
@@ -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 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([]);
@@ -97,71 +152,20 @@ export function PactDebug() {
Force sign pacts with guardians (bypasses mana costs and signing time)
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{guardianFloors.map((floor) => {
const guardian = GUARDIANS[floor];
const isSigned = signedPacts.includes(floor);
<GuardianPactList
signedPacts={signedPacts}
onForceSign={forcePact}
onRemove={removePactHandler}
/>
return (
<div
key={floor}
className={`p-2 rounded border flex items-center justify-between ${
isSigned ? 'border-green-600/50 bg-green-900/20' : 'border-gray-700'
}`}
style={{ borderColor: isSigned ? undefined : guardian.color, borderWidth: '1px' }}
>
<div>
<div className="text-sm font-semibold" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">
Floor {floor} | {guardian.pact}x multiplier
</div>
<div className="text-xs text-gray-500">
Element: {ELEMENTS[guardian.element]?.name || guardian.element}
</div>
</div>
<div className="flex gap-1">
{isSigned ? (
<Button
size="sm"
variant="destructive"
onClick={() => removePactHandler(floor)}
className="text-xs"
>
Remove
</Button>
) : (
<Button
size="sm"
variant="default"
onClick={() => forcePact(floor)}
className="text-xs bg-amber-600 hover:bg-amber-700"
>
Force Sign
</Button>
)}
</div>
</div>
);
})}
</div>
{/* Clear All Button */}
{signedPacts.length > 0 && (
<div className="pt-2 border-t border-gray-700">
<Button
size="sm"
variant="destructive"
onClick={clearAllPacts}
className="w-full text-xs"
>
<Button size="sm" variant="destructive" onClick={clearAllPacts} className="w-full text-xs">
Clear All Pacts ({signedPacts.length})
</Button>
</div>
)}
{/* Status */}
<div className="text-xs text-gray-400 pt-2 border-t border-gray-700">
Signed Pacts: {signedPacts.length} |
Max Pacts: {1 + (prestigeUpgrades?.pactCapacity || 0)}
@@ -172,4 +176,4 @@ export function PactDebug() {
);
}
PactDebug.displayName = "PactDebug";
PactDebug.displayName = 'PactDebug';
-1
View File
@@ -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 -7
View File
@@ -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';
-45
View File
@@ -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";
-142
View File
@@ -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";
-11
View File
@@ -1,11 +0,0 @@
export { ManaStatsSection } from './ManaStatsSection';
export type { ManaStatsSectionProps } from './ManaStatsSection';
export { CombatStatsSection } from './CombatStatsSection';
export type { CombatStatsSectionProps } from './CombatStatsSection';
export { StudyStatsSection } from './StudyStatsSection';
export type { StudyStatsSectionProps } from './StudyStatsSection';
export { UpgradeEffectsSection } from './UpgradeEffectsSection';
export type { UpgradeEffectsSectionProps } from './UpgradeEffectsSection';
@@ -0,0 +1,250 @@
'use client';
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';
// ─── Helpers ─────────────────────────────────────────────────────────────────
const CATEGORY_LABELS: Record<string, string> = {
combat: '⚔️ Combat',
progression: '📈 Progression',
crafting: '🔨 Crafting',
magic: '✨ Magic',
special: '🌟 Special',
};
function getProgressForAchievement(
achievement: AchievementDef,
progress: Record<string, number>,
): number {
return progress[achievement.id] ?? 0;
}
function getProgressPercent(achievement: AchievementDef, current: number): number {
if (achievement.requirement.value <= 0) return 100;
return Math.min(100, Math.round((current / achievement.requirement.value) * 100));
}
function formatReward(reward: AchievementDef['reward']): string {
const parts: string[] = [];
if (reward.insight) parts.push(`${reward.insight} insight`);
if (reward.manaBonus) parts.push(`+${reward.manaBonus} mana`);
if (reward.damageBonus) parts.push(`+${(reward.damageBonus * 100).toFixed(0)}% dmg`);
if (reward.regenBonus) parts.push(`+${reward.regenBonus} regen`);
if (reward.title) parts.push(`title: "${reward.title}"`);
if (reward.unlockEffect) parts.push(`unlock: ${reward.unlockEffect}`);
return parts.join(', ');
}
// ─── Category Section ────────────────────────────────────────────────────────
interface CategorySectionProps {
category: string;
achievements: AchievementDef[];
unlocked: string[];
progress: Record<string, number>;
collapsed: boolean;
onToggleCollapse: () => void;
}
function CategorySection({
category,
achievements,
unlocked,
progress,
collapsed,
onToggleCollapse,
}: CategorySectionProps) {
const color = ACHIEVEMENT_CATEGORY_COLORS[category] ?? '#9CA3AF';
const label = CATEGORY_LABELS[category] ?? category;
const unlockedCount = achievements.filter((a) => unlocked.includes(a.id)).length;
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader
title={`${label} (${unlockedCount}/${achievements.length})`}
action={
<button
onClick={onToggleCollapse}
className="text-xs text-gray-400 hover:text-gray-200 transition-colors"
>
{collapsed ? 'Expand ▼' : 'Collapse ▲'}
</button>
}
/>
{!collapsed && (
<CardContent className="space-y-3">
{achievements.map((achievement) => {
const isUnlocked = unlocked.includes(achievement.id);
const currentProgress = getProgressForAchievement(achievement, progress);
const percent = getProgressPercent(achievement, currentProgress);
const revealed = isAchievementRevealed(achievement, currentProgress);
if (!revealed) {
return (
<div
key={achievement.id}
className="p-3 rounded border border-gray-700/50 bg-gray-800/30"
>
<div className="flex items-center gap-2">
<span className="text-gray-500">???</span>
<span className="text-xs text-gray-600 italic">
Hidden achievement keep progressing to reveal
</span>
</div>
<div className="mt-2">
<Progress value={percent} className="h-1.5" />
</div>
</div>
);
}
return (
<div
key={achievement.id}
className={`p-3 rounded border ${
isUnlocked
? 'border-green-700/50 bg-green-900/20'
: 'border-gray-700/50 bg-gray-800/30'
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm" style={{ color }}>
{achievement.name}
</span>
{isUnlocked && (
<Badge className="bg-green-900/50 text-green-300 text-xs">
Unlocked
</Badge>
)}
</div>
<span className="text-xs text-gray-500">
{fmt(currentProgress)} / {fmt(achievement.requirement.value)}
</span>
</div>
<p className="text-xs text-gray-400 mb-2">{achievement.desc}</p>
<div className="flex items-center gap-2 mb-2">
<Progress value={percent} className="h-1.5 flex-1" />
<span className="text-xs text-gray-500 w-10 text-right">
{percent}%
</span>
</div>
<div className="text-xs text-gray-500">
<span className="text-gray-400">Reward:</span>{' '}
{formatReward(achievement.reward)}
</div>
</div>
);
})}
</CardContent>
)}
</Card>
);
}
// ─── Main Component ──────────────────────────────────────────────────────────
export function AchievementsTab() {
const achievements = useCombatStore((s) => s.achievements);
const [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';
+34
View File
@@ -0,0 +1,34 @@
import type { ActivityLogEntry } from '@/lib/game/types';
interface ActivityLogProps {
activityLog: ActivityLogEntry[];
maxEntries?: number;
}
export function ActivityLog({ activityLog, maxEntries = 20 }: ActivityLogProps) {
const entries = activityLog.slice(0, maxEntries);
if (entries.length === 0) {
return (
<div className="text-sm text-gray-500 italic p-2">
No activity yet.
</div>
);
}
return (
<div className="space-y-1 max-h-64 overflow-y-auto">
{entries.map((entry) => (
<div
key={entry.id}
className="text-xs text-gray-300 border-b border-gray-700 pb-1 last:border-0"
>
<span className="text-gray-500 mr-1">
[{entry.eventType}]
</span>
{entry.message}
</div>
))}
</div>
);
}
@@ -0,0 +1,145 @@
import { describe, it, expect, vi } from 'vitest';
// ─── Test: AttunementsTab barrel export ───────────────────────────────────────
describe('AttunementsTab module structure', () => {
it('exports AttunementsTab from barrel index', async () => {
const mod = await import('./AttunementsTab');
expect(mod.AttunementsTab).toBeDefined();
expect(typeof mod.AttunementsTab).toBe('function');
});
it('AttunementsTab has correct displayName', async () => {
const { AttunementsTab } = await import('./AttunementsTab');
expect(AttunementsTab.displayName).toBe('AttunementsTab');
});
});
// ─── Test: Barrel export includes AttunementsTab ──────────────────────────────
describe('Tab barrel export', () => {
it('includes AttunementsTab in the tabs index', async () => {
const mod = await import('@/components/game/tabs');
expect(mod.AttunementsTab).toBeDefined();
expect(typeof mod.AttunementsTab).toBe('function');
});
});
// ─── Test: Attunement data integrity ──────────────────────────────────────────
describe('Attunement data', () => {
it('all attunements have required fields', async () => {
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
for (const [id, def] of Object.entries(ATTUNEMENTS_DEF)) {
expect(def.id).toBe(id);
expect(def.name).toBeTruthy();
expect(def.desc).toBeTruthy();
expect(def.slot).toBeTruthy();
expect(def.icon).toBeTruthy();
expect(def.color).toBeTruthy();
expect(def.rawManaRegen).toBeGreaterThanOrEqual(0);
expect(def.conversionRate).toBeGreaterThanOrEqual(0);
expect(def.capabilities.length).toBeGreaterThan(0);
expect(def.skillCategories.length).toBeGreaterThan(0);
}
});
it('enchanter is unlocked by default', async () => {
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
expect(ATTUNEMENTS_DEF.enchanter.unlocked).toBe(true);
});
it('invoker and fabricator are locked by default', async () => {
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
expect(ATTUNEMENTS_DEF.invoker.unlocked).toBe(false);
expect(ATTUNEMENTS_DEF.fabricator.unlocked).toBe(false);
});
it('each attunement has a unique slot', async () => {
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
const slots = Object.values(ATTUNEMENTS_DEF).map((d) => d.slot);
const uniqueSlots = new Set(slots);
expect(uniqueSlots.size).toBe(slots.length);
});
});
// ─── Test: XP curve ───────────────────────────────────────────────────────────
describe('Attunement XP curve', () => {
it('level 1 requires 0 XP', async () => {
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
expect(getAttunementXPForLevel(1)).toBe(0);
});
it('level 2 requires 1000 XP', async () => {
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
expect(getAttunementXPForLevel(2)).toBe(1000);
});
it('XP requirements increase with level', async () => {
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
const xp2 = getAttunementXPForLevel(2);
const xp3 = getAttunementXPForLevel(3);
const xp4 = getAttunementXPForLevel(4);
expect(xp3).toBeGreaterThan(xp2);
expect(xp4).toBeGreaterThan(xp3);
});
it('MAX_ATTUNEMENT_LEVEL is 10', async () => {
const { MAX_ATTUNEMENT_LEVEL } = await import('@/lib/game/data/attunements');
expect(MAX_ATTUNEMENT_LEVEL).toBe(10);
});
});
// ─── Test: Attunement store interactions ──────────────────────────────────────
describe('Attunement store interactions', () => {
it('addAttunementXP is callable', async () => {
const mockAddXP = await vi.fn();
mockAddXP('enchanter', 100);
expect(mockAddXP).toHaveBeenCalledWith('enchanter', 100);
});
it('debugUnlockAttunement is callable', async () => {
const mockUnlock = await vi.fn();
mockUnlock('invoker');
expect(mockUnlock).toHaveBeenCalledWith('invoker');
});
it('setAttunements is callable', async () => {
const mockSet = await vi.fn();
mockSet({ enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 } });
expect(mockSet).toHaveBeenCalled();
});
it('resetAttunements is callable', async () => {
const mockReset = await vi.fn();
mockReset();
expect(mockReset).toHaveBeenCalledTimes(1);
});
});
// ─── Test: Slot name mapping ──────────────────────────────────────────────────
describe('Attunement slot names', () => {
it('all slots used by attunements have display names', async () => {
const { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES } = await import('@/lib/game/data/attunements');
for (const def of Object.values(ATTUNEMENTS_DEF)) {
expect(ATTUNEMENT_SLOT_NAMES[def.slot]).toBeDefined();
expect(ATTUNEMENT_SLOT_NAMES[def.slot].length).toBeGreaterThan(0);
}
});
});
// ─── Test: File size limit ────────────────────────────────────────────────────
describe('File size limits (400 lines max)', () => {
it('AttunementsTab.tsx is under 400 lines', async () => {
const fs = await import('fs');
const path = await import('path');
const filePath = path.join(__dirname, 'AttunementsTab.tsx');
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n').length;
expect(lines).toBeLessThan(400);
});
});
+221
View File
@@ -0,0 +1,221 @@
'use client';
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 { ScrollArea } from '@/components/ui/scroll-area';
import { SectionHeader } from '@/components/ui/section-header';
import { DebugName } from '@/components/game/debug/debug-context';
import { fmt } from '@/lib/game/stores';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getXpForNextLevel(level: number): number {
if (level >= MAX_ATTUNEMENT_LEVEL) return 0;
return getAttunementXPForLevel(level + 1);
}
function getXpProgress(state: AttunementState): number {
const nextXp = getXpForNextLevel(state.level);
if (nextXp <= 0) return 100;
return Math.min(100, Math.round((state.experience / nextXp) * 100));
}
function isAttunementUnlocked(id: string, attunements: Record<string, AttunementState>): boolean {
return id in attunements;
}
// ─── Attunement Card ─────────────────────────────────────────────────────────
interface AttunementCardProps {
def: AttunementDef;
state?: AttunementState;
}
function AttunementCard({ def, state }: AttunementCardProps) {
const unlocked = !!state;
const xpProgress = state ? getXpProgress(state) : 0;
const nextXp = state ? getXpForNextLevel(state.level) : 0;
return (
<Card className={`bg-gray-900/60 ${unlocked ? 'border-gray-700' : 'border-gray-800 opacity-60'}`}>
<CardContent className="p-4 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{def.icon}</span>
<div>
<h3 className="font-medium text-gray-100">{def.name}</h3>
<p className="text-xs text-gray-500">
{ATTUNEMENT_SLOT_NAMES[def.slot] ?? def.slot}
</p>
</div>
</div>
{unlocked ? (
<Badge className="bg-teal-900/50 text-teal-300 text-xs">
Lv.{state.level}
</Badge>
) : (
<Badge variant="outline" className="border-gray-700 text-gray-500 text-xs">
Locked
</Badge>
)}
</div>
{/* Description */}
<p className="text-xs text-gray-400 leading-relaxed">{def.desc}</p>
{/* XP Progress (unlocked only) */}
{unlocked && state && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500">XP Progress</span>
<span className="text-gray-400 font-mono">
{fmt(state.experience)} / {fmt(nextXp)}
</span>
</div>
<Progress value={xpProgress} className="h-2" />
{state.level >= MAX_ATTUNEMENT_LEVEL && (
<p className="text-xs text-amber-400 italic">Maximum level reached</p>
)}
</div>
)}
{/* Unlock condition (locked only) */}
{!unlocked && def.unlockCondition && (
<div className="text-xs text-gray-500 italic border-t border-gray-800 pt-2">
🔒 {def.unlockCondition}
</div>
)}
{/* Details grid */}
<div className="grid grid-cols-2 gap-2 text-xs border-t border-gray-800 pt-3">
<div>
<span className="text-gray-500">Mana Type</span>
<p className="text-gray-300 capitalize">
{def.primaryManaType ?? 'None (pact-based)'}
</p>
</div>
<div>
<span className="text-gray-500">Raw Regen</span>
<p className="text-gray-300">+{def.rawManaRegen}/hr</p>
</div>
{def.conversionRate > 0 && (
<div>
<span className="text-gray-500">Conversion</span>
<p className="text-gray-300">{def.conversionRate}/hr</p>
</div>
)}
<div>
<span className="text-gray-500">Status</span>
<p className={state?.active ? 'text-green-400' : 'text-gray-500'}>
{state?.active ? 'Active' : unlocked ? 'Inactive' : 'Locked'}
</p>
</div>
</div>
{/* Capabilities */}
<div className="border-t border-gray-800 pt-3">
<span className="text-xs text-gray-500 block mb-1.5">Capabilities</span>
<div className="flex flex-wrap gap-1">
{def.capabilities.map((cap) => (
<Badge
key={cap}
variant="outline"
className="border-gray-700 text-gray-400 text-[10px]"
>
{cap}
</Badge>
))}
</div>
</div>
{/* Skill Categories */}
<div>
<span className="text-xs text-gray-500 block mb-1.5">Skill Categories</span>
<div className="flex flex-wrap gap-1">
{def.skillCategories.map((cat) => (
<Badge
key={cat}
variant="outline"
className="border-gray-700 text-gray-400 text-[10px]"
>
{cat}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
);
}
// ─── Main Component ──────────────────────────────────────────────────────────
export function AttunementsTab() {
const attunements = useAttunementStore((s) => s.attunements);
const [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';
@@ -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);
});
});
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import clsx from 'clsx';
import { Hammer, Sparkles } from 'lucide-react';
import { DebugName } from '@/components/game/debug/debug-context';
import { FabricatorSubTab } from './CraftingTab/FabricatorSubTab';
import { EnchanterSubTab } from './CraftingTab/EnchanterSubTab';
type CraftingAttunement = 'fabricator' | 'enchanter';
interface CraftingSubTab {
key: CraftingAttunement;
label: string;
icon: typeof Hammer;
}
const CRAFTING_SUB_TABS: CraftingSubTab[] = [
{ key: 'fabricator', label: 'Fabricator', icon: Hammer },
{ key: 'enchanter', label: 'Enchanter', icon: Sparkles },
];
export function CraftingTab() {
const [activeSubTab, setActiveSubTab] = useState<CraftingAttunement>('fabricator');
return (
<DebugName name="CraftingTab">
<div className="space-y-4">
{/* Sub-tab bar — same clsx pattern as DisciplinesTab */}
<div className="flex gap-2">
{CRAFTING_SUB_TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveSubTab(key)}
className={clsx('rounded px-3 py-1 text-sm font-medium flex items-center gap-1.5', {
'bg-amber-600 text-white': activeSubTab === key,
'text-gray-400 hover:text-gray-200': activeSubTab !== key,
})}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
{/* Sub-tab content */}
{activeSubTab === 'fabricator' && <FabricatorSubTab />}
{activeSubTab === 'enchanter' && <EnchanterSubTab />}
</div>
</DebugName>
);
}
CraftingTab.displayName = 'CraftingTab';
@@ -0,0 +1,100 @@
'use client';
import { useState } from 'react';
import clsx from 'clsx';
import { PenLine, FlaskConical, Sparkles } from 'lucide-react';
import {
EnchantmentDesigner,
EnchantmentPreparer,
EnchantmentApplier,
} from '@/components/game/crafting';
import { useCraftingStore } from '@/lib/game/stores';
import type { DesignEffect } from '@/lib/game/types';
type EnchanterPhase = 'design' | 'prepare' | 'apply';
const PHASES: { key: EnchanterPhase; label: string; icon: typeof PenLine }[] = [
{ key: 'design', label: 'Design', icon: PenLine },
{ key: 'prepare', label: 'Prepare', icon: FlaskConical },
{ key: 'apply', label: 'Apply', icon: Sparkles },
];
export function EnchanterSubTab() {
const [activePhase, setActivePhase] = useState<EnchanterPhase>('design');
// Shared state for the enchantment flow
const [selectedEquipmentType, setSelectedEquipmentType] = useState<string | null>(null);
const [selectedEffects, setSelectedEffects] = useState<DesignEffect[]>([]);
const [designName, setDesignName] = useState('');
const [selectedDesign, setSelectedDesign] = useState<string | null>(null);
const [selectedEquipmentInstance, setSelectedEquipmentInstance] = useState<string | null>(null);
const resetEnchantmentSelection = useCraftingStore((s) => s.resetEnchantmentSelection);
const handlePhaseChange = (phase: EnchanterPhase) => {
setActivePhase(phase);
};
const handleEnchantmentApplied = () => {
// Reset selection after successful application
resetEnchantmentSelection();
setSelectedEquipmentInstance(null);
setSelectedDesign(null);
// Go back to design phase
setActivePhase('design');
};
return (
<div className="space-y-4">
{/* Phase selector */}
<div className="flex gap-2">
{PHASES.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => handlePhaseChange(key)}
className={clsx('rounded px-3 py-1 text-sm font-medium flex items-center gap-1.5', {
'bg-purple-600 text-white': activePhase === key,
'text-gray-400 hover:text-gray-200': activePhase !== key,
})}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
{/* Phase content */}
{activePhase === 'design' && (
<EnchantmentDesigner
selectedEquipmentType={selectedEquipmentType}
setSelectedEquipmentType={setSelectedEquipmentType}
selectedEffects={selectedEffects}
setSelectedEffects={setSelectedEffects}
designName={designName}
setDesignName={setDesignName}
selectedDesign={selectedDesign}
setSelectedDesign={setSelectedDesign}
/>
)}
{activePhase === 'prepare' && (
<EnchantmentPreparer
selectedEquipmentInstance={selectedEquipmentInstance}
setSelectedEquipmentInstance={setSelectedEquipmentInstance}
/>
)}
{activePhase === 'apply' && (
<EnchantmentApplier
selectedEquipmentInstance={selectedEquipmentInstance}
setSelectedEquipmentInstance={setSelectedEquipmentInstance}
selectedDesign={selectedDesign}
setSelectedDesign={setSelectedDesign}
onEnchantmentApplied={handleEnchantmentApplied}
/>
)}
</div>
);
}
EnchanterSubTab.displayName = 'EnchanterSubTab';
@@ -0,0 +1,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';
+337
View File
@@ -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);
});
});
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react';
import { DebugName } from '@/components/game/debug/debug-context';
import { GameStateDebugSection } from './DebugTab/GameStateDebugSection';
import { DisciplineDebugSection } from './DebugTab/DisciplineDebugSection';
import { AttunementDebugSection } from './DebugTab/AttunementDebugSection';
import { ElementDebugSection } from './DebugTab/ElementDebugSection';
import { GolemDebugSection } from './DebugTab/GolemDebugSection';
import { PactDebugSection } from './DebugTab/PactDebugSection';
import { SpireDebugSection } from './DebugTab/SpireDebugSection';
import { AchievementDebugSection } from './DebugTab/AchievementDebugSection';
interface DebugSectionProps {
title: string;
color: string;
children: React.ReactNode;
defaultOpen?: boolean;
}
function DebugSection({ title, color, children, defaultOpen = false }: DebugSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<Card className="bg-gray-900/60 border-gray-700/50">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full px-4 py-3 flex items-center gap-2 text-left hover:bg-gray-800/30 transition-colors"
>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
<span className="font-semibold text-sm" style={{ color }}>{title}</span>
</button>
{isOpen && (
<CardContent className="pt-0 pb-4">
{children}
</CardContent>
)}
</Card>
);
}
export function DebugTab() {
return (
<DebugName name="DebugTab">
<div className="space-y-4">
{/* Warning Banner */}
<Card className="bg-amber-900/20 border-amber-600/50">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-amber-400">
<AlertTriangle className="w-5 h-5" />
<span className="font-semibold">Debug Mode</span>
</div>
<p className="text-sm text-amber-300/70 mt-1">
These tools are for development and testing. Using them may break game balance or save data.
</p>
</CardContent>
</Card>
<div className="space-y-3">
<DebugSection title="Game State" color="#60A5FA" defaultOpen={true}>
<GameStateDebugSection />
</DebugSection>
<DebugSection title="Spire" color="#2DD4BF">
<SpireDebugSection />
</DebugSection>
<DebugSection title="Disciplines" color="#818CF8">
<DisciplineDebugSection />
</DebugSection>
<DebugSection title="Attunements" color="#C084FC">
<AttunementDebugSection />
</DebugSection>
<DebugSection title="Elements" color="#4ADE80">
<ElementDebugSection />
</DebugSection>
<DebugSection title="Golems" color="#FB923C">
<GolemDebugSection />
</DebugSection>
<DebugSection title="Pacts" color="#F87171">
<PactDebugSection />
</DebugSection>
<DebugSection title="Achievements" color="#FACC15">
<AchievementDebugSection />
</DebugSection>
</div>
</div>
</DebugName>
);
}
DebugTab.displayName = "DebugTab";
@@ -0,0 +1,83 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Trophy, CheckCircle, RotateCcw } from 'lucide-react';
import { useCombatStore } from '@/lib/game/stores';
import { ACHIEVEMENTS } from '@/lib/game/data/achievements';
export function AchievementDebugSection() {
const achievements = useCombatStore((s) => s.achievements);
const unlockedCount = achievements?.unlocked?.length || 0;
const totalCount = Object.keys(ACHIEVEMENTS).length;
const handleUnlockAll = () => {
useCombatStore.setState({
achievements: {
unlocked: Object.keys(ACHIEVEMENTS),
progress: Object.fromEntries(
Object.keys(ACHIEVEMENTS).map((id) => [id, ACHIEVEMENTS[id].requirement.value])
),
},
});
};
const handleResetAll = () => {
useCombatStore.setState({
achievements: {
unlocked: [],
progress: {},
},
});
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-yellow-400 text-sm flex items-center gap-2">
<Trophy className="w-4 h-4" />
Achievement Debug
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400">
Unlocked: {unlockedCount} / {totalCount}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={handleUnlockAll}>
<CheckCircle className="w-3 h-3 mr-1" /> Unlock All
</Button>
<Button size="sm" variant="destructive" onClick={handleResetAll}>
<RotateCcw className="w-3 h-3 mr-1" /> Reset All
</Button>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{Object.entries(ACHIEVEMENTS).map(([id, def]) => {
const isUnlocked = achievements?.unlocked?.includes(id);
return (
<div
key={id}
className={`flex items-center justify-between p-2 rounded text-xs ${
isUnlocked ? 'bg-green-900/20 border border-green-600/50' : 'bg-gray-800/50'
}`}
>
<div>
<span className="font-medium">{def.name}</span>
<span className="text-gray-500 ml-2">({def.category})</span>
</div>
{isUnlocked && (
<CheckCircle className="w-3 h-3 text-green-400" />
)}
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
AchievementDebugSection.displayName = "AchievementDebugSection";
@@ -0,0 +1,88 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Sparkles, Unlock } from 'lucide-react';
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
import { useAttunementStore } from '@/lib/game/stores';
import { useManaStore } from '@/lib/game/stores';
export function AttunementDebugSection() {
const attunements = useAttunementStore((s) => s.attunements);
const debugUnlockAttunement = useAttunementStore((s) => s.debugUnlockAttunement);
const addAttunementXP = useAttunementStore((s) => s.addAttunementXP);
const handleUnlockAttunement = (id: string) => {
if (debugUnlockAttunement) {
debugUnlockAttunement(id);
if (id === 'enchanter') {
useManaStore.getState().unlockElement('transference', 0);
}
}
};
const handleAddAttunementXP = (id: string, amount: number) => {
if (addAttunementXP) {
addAttunementXP(id, amount);
}
};
const handleUnlockAll = () => {
Object.keys(ATTUNEMENTS_DEF).forEach((id) => {
handleUnlockAttunement(id);
});
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
<Sparkles className="w-4 h-4" />
Attunements
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button size="sm" variant="outline" onClick={handleUnlockAll}>
<Unlock className="w-3 h-3 mr-1" /> Unlock All
</Button>
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
const isActive = attunements?.[id]?.active;
const level = attunements?.[id]?.level || 1;
const xp = attunements?.[id]?.experience || 0;
return (
<div key={id} className="flex items-center justify-between p-2 bg-gray-800/50 rounded">
<div className="flex items-center gap-2">
<span>{def.icon}</span>
<div>
<div className="text-sm font-medium">{def.name}</div>
{isActive && (
<div className="text-xs text-gray-400">Lv.{level} {xp} XP</div>
)}
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleUnlockAttunement(id)}
>
<Unlock className="w-3 h-3 mr-1" /> Unlock
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAddAttunementXP(id, 100)}
>
+100 XP
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
);
}
AttunementDebugSection.displayName = "AttunementDebugSection";
@@ -0,0 +1,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";
+193 -204
View File
@@ -1,178 +1,170 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import type { DisciplineDefinition } from '@/types/disciplines';
import baseDisciplines from '../data/disciplines/base';
import enchanterDisciplines from '../data/disciplines/enchanter';
import fabricatorDisciplines from '../data/disciplines/fabricator';
import invokerDisciplines from '../data/disciplines/invoker';
import 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 { useRef } from 'react';
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 store = useDisciplineStore();
const { disciplines, activeIds, concurrentLimit } = store;
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 allDisciplines: DisciplineDefinition[] = [
...baseDisciplines,
...enchanterDisciplines,
...fabricatorDisciplines,
...invokerDisciplines,
];
// Group disciplines by attunement for tab rendering
const attunementTabs: {
label: string;
items: DisciplineDefinition[];
}[] = [
{ label: 'Base', items: baseDisciplines },
{ label: 'Enchanter', items: enchanterDisciplines },
{ label: 'Fabricator', items: fabricatorDisciplines },
{ label: 'Invoker', items: invokerDisciplines },
];
// Helper to render a single discipline card
const DisciplineCard: React.FC<{
id: string;
name: string;
description: string;
xp: number;
perkThresholds?: number[];
perkValues?: number[];
perkTypes?: string[];
statBonus: string;
baseValue: number;
drainBase: number;
difficultyFactor: number;
scalingFactor: number;
}> = ({
id,
name,
description,
xp,
perkThresholds,
perkValues,
perkTypes,
statBonus,
baseValue,
drainBase,
difficultyFactor,
scalingFactor,
}) => {
if (!mounted) return null;
const state = useDisciplineStore().getState();
const currentDisc = state.disciplines[id] ?? { xp: 0, paused: true };
const isActive = activeIds.includes(id);
const canActivate = concurrentLimit > activeIds.filter(a => state.disciplines[a]?.paused !== true).length;
// Calculate displayed stats
const displayXp = currentDisc.xp;
const progressPercent = Math.min(displayXp / Math.max(1, (concurrentLimit * 100) ?? 1), 100);
const isPaused = currentDisc.paused;
const hasPendingPerk = perkThresholds?.some((t, i) => displayXp >= t && perkTypes?.[i] !== 'capped');
const activeStatBonus = calculateStatBonus(
parseInt(baseValue) || 0,
displayXp,
scalingFactor
);
// Simple visual for drain per tick
const estimatedDrain = calculateManaDrain(
drainBase,
displayXp,
difficultyFactor
);
// Determine unlocked perks
const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => {
if (typ === 'once' || typ === 'infinite') {
if (displayXp >= perkThresholds?.[idx] ?? 0) {
acc.push(`${typ}-${idx}`);
}
} else if (typ === 'capped') {
const tier = Math.max(0, Math.floor((displayXp - perkThresholds?.[idx] ?? 0) / perkValues?.[idx] ?? 1) + 1);
if (tier > 0) acc.push(`${typ}-${idx}`);
}
return acc;
}, []);
// Helper to decide button action
const toggleAction = () => {
if (isPaused) {
// Resume activate
const storeDispatch = useDisciplineStore().getState().activate as any;
storeDispatch(id);
} else {
// Pause deactivate
const storeDispatch = useDisciplineStore().getState().deactivate as any;
storeDispatch(id);
}
};
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={`bg-blue-500 transition-all duration-300 ${
activeStatBonus > 0 ? 'bg-green-500' : 'bg-red-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
<div className="text-sm text-gray-400">
<strong>Drain:</strong> {estimatedDrain.toFixed(1)}{' '}
<strong>XP:</strong> {displayXp}
</div>
{/* Bonus display */}
<div className="mt-2 text-sm">
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonus}
</div>
{/* Perks */}
<div className="mt-2">
<strong>Perks:</strong>
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
{unlockedPerks?.map((p) => (
<li key={p} className="text-green-500">{p.replace(/-([0-9]+)$/, ' $1')}</li>
)) : (
<li className="text-gray-400">locked</li>
)}
</ul>
</div>
{/* Action button */}
<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>
);
};
const handleToggle = useCallback((id: string, paused: boolean) => {
if (paused) {
activate(id);
} else {
deactivate(id);
}
}, [activate, deactivate]);
if (!mounted) {
return (
@@ -182,25 +174,21 @@ export const DisciplinesTab: React.FC = () => {
);
}
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
return (
<div className="mt-6">
{/* Tab bar */}
<div className="flex gap-2 mb-4">
{attunementTabs.map((tab) => {
const isActiveTab = store
.getState()
.activeAttunement === tab.label.toLowerCase();
{ATTUNEMENT_TABS.map((tab) => {
const isActiveTab = activeAttunement === tab.key;
return (
<button
key={tab.label}
onClick={() =>
// Here you could dispatch an action to switch tabs if needed
// For simplicity, we just render the tabs
console.log(`Switch to ${tab.label}`);
}
key={tab.key}
onClick={() => setActiveAttunement(tab.key)}
className={clsx('rounded px-3 py-1', {
'bg-blue-600 text-white': tab.label === 'Base', // highlight first for demo
'text-gray-600': tab.label !== 'Base',
'bg-blue-600 text-white': isActiveTab,
'text-gray-600': !isActiveTab,
})}
>
{tab.label}
@@ -209,36 +197,37 @@ export const DisciplinesTab: React.FC = () => {
})}
</div>
{/* Discipline cards */}
{/* Discipline cards — only render active tab */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{attunementTabs
.map((tab) =>
tab.items.map((disc) => {
const state = useDisciplineStore().getState();
const discState = state.disciplines[disc.id] ?? { xp: 0, paused: true };
const isActive = activeIds.includes(disc.id);
const isVisible = attunementTabs.find((t) => t.label === tab.label)?.items === tab.items;
return isVisible && (
<DisciplineCard
key={disc.id}
id={disc.id}
name={disc.name}
description={disc.description}
xp={discState.xp}
perkThresholds={disc.perks?.map((p) => p.threshold)}
perkValues={disc.perks?.map((p) => p.value)}
perkTypes={disc.perks?.map((p) => p.type)}
statBonus={disc.statBonus}
baseValue={disc.statBonus.baseValue?.toString() ?? '0'}
drainBase={disc.drainBase}
difficultyFactor={disc.difficultyFactor}
scalingFactor={disc.scalingFactor}
/>
);
})
)
.flat()
}
{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 */}
@@ -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);
});
});
+94
View File
@@ -0,0 +1,94 @@
'use client';
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 [mounted, setMounted] = useState(false);
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);
useEffect(() => {
setMounted(true);
}, []);
const handleEquip = useCallback(
(instanceId: string, slot: EquipmentSlot) => {
storeEquipItem(instanceId, slot);
},
[storeEquipItem]
);
const handleUnequip = useCallback(
(slot: EquipmentSlot) => {
storeUnequipItem(slot);
},
[storeUnequipItem]
);
const handleDelete = useCallback(
(instanceId: string) => {
storeDeleteEquipment(instanceId);
},
[storeDeleteEquipment]
);
const inventoryItems = useMemo(
() =>
Object.entries(equipmentInstances).filter(
([id]) => !Object.values(equippedInstances).includes(id)
),
[equipmentInstances, equippedInstances]
);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-[var(--text-muted)]">
Loading equipment
</div>
);
}
return (
<DebugName name="EquipmentTab">
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)] mb-3">Equipped Gear</h2>
<EquipmentSlotGrid
equippedInstances={equippedInstances}
equipmentInstances={equipmentInstances}
onUnequip={handleUnequip}
/>
</div>
<EquipmentEffectsSummary
equipmentInstances={equipmentInstances}
equippedInstances={equippedInstances}
/>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)] mb-3">
Inventory ({inventoryItems.length})
</h2>
<InventoryList
inventoryItems={inventoryItems}
equippedInstances={equippedInstances}
onEquip={handleEquip}
onDelete={handleDelete}
/>
</div>
</div>
</DebugName>
);
}
EquipmentTab.displayName = 'EquipmentTab';
@@ -0,0 +1,93 @@
'use client';
import { computeEquipmentEffects } from '@/lib/game/effects';
import type { EquipmentInstance } from '@/lib/game/types';
interface EquipmentEffectsSummaryProps {
equipmentInstances: Record<string, EquipmentInstance>;
equippedInstances: Record<string, string | null>;
}
const BONUS_LABELS: Record<string, string> = {
maxMana: 'Max Mana',
regen: 'Mana Regen',
clickMana: 'Click Mana',
baseDamage: 'Base Damage',
elementCap: 'Element Cap',
critChance: 'Crit Chance',
attackSpeed: 'Attack Speed',
meditationEfficiency: 'Meditation Efficiency',
studySpeed: 'Study Speed',
};
const MULT_LABELS: Record<string, string> = {
maxMana: 'Max Mana',
regen: 'Mana Regen',
clickMana: 'Click Mana',
baseDamage: 'Base Damage',
attackSpeed: 'Attack Speed',
elementCap: 'Element Cap',
meditationEfficiency: 'Meditation Efficiency',
studySpeed: 'Study Speed',
};
export function EquipmentEffectsSummary({ equipmentInstances, equippedInstances }: EquipmentEffectsSummaryProps) {
const { bonuses, multipliers, specials } = computeEquipmentEffects(equipmentInstances, equippedInstances);
const bonusEntries = Object.entries(bonuses).filter(([, v]) => v !== 0);
const multEntries = Object.entries(multipliers).filter(([, v]) => v !== 1);
const specialEntries = Array.from(specials);
if (bonusEntries.length === 0 && multEntries.length === 0 && specialEntries.length === 0) {
return null;
}
return (
<div className="p-3 rounded border border-[var(--border-default)] bg-[var(--bg-sunken)] space-y-2">
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Equipment Effects</h3>
{bonusEntries.length > 0 && (
<div className="space-y-1">
<div className="text-xs text-[var(--text-muted)]">Bonuses</div>
{bonusEntries.map(([key, value]) => (
<div key={key} className="flex justify-between text-xs">
<span className="text-[var(--text-secondary)]">
{BONUS_LABELS[key] || key}
</span>
<span className="text-[var(--color-success)]">
{value > 0 ? '+' : ''}{typeof value === 'number' && !Number.isInteger(value) ? value.toFixed(2) : value}
</span>
</div>
))}
</div>
)}
{multEntries.length > 0 && (
<div className="space-y-1">
<div className="text-xs text-[var(--text-muted)]">Multipliers</div>
{multEntries.map(([key, value]) => (
<div key={key} className="flex justify-between text-xs">
<span className="text-[var(--text-secondary)]">
{MULT_LABELS[key] || key}
</span>
<span className="text-[var(--color-info)]">
×{typeof value === 'number' ? value.toFixed(2) : value}
</span>
</div>
))}
</div>
)}
{specialEntries.length > 0 && (
<div className="space-y-1">
<div className="text-xs text-[var(--text-muted)]">Specials</div>
{specialEntries.map((id) => (
<div key={id} className="text-xs text-[var(--color-warning)]">
{id}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,79 @@
'use client';
import { Package } from 'lucide-react';
import type { EquipmentInstance, EquipmentSlot } from '@/lib/game/types';
import { EQUIPMENT_TYPES, SLOT_NAMES } from '@/lib/game/data/equipment';
import { RARITY_CSS_VAR } from '@/components/game/LootInventory/types';
import { CATEGORY_ICONS } from '@/components/game/LootInventory/icons';
import { ActionButton } from '@/components/ui/action-button';
interface EquipmentSlotGridProps {
equippedInstances: Record<string, string | null>;
equipmentInstances: Record<string, EquipmentInstance>;
onUnequip: (slot: EquipmentSlot) => void;
}
const SLOTS: EquipmentSlot[] = ['mainHand', 'offHand', 'head', 'body', 'hands', 'feet', 'accessory1', 'accessory2'];
export function EquipmentSlotGrid({ equippedInstances, equipmentInstances, onUnequip }: EquipmentSlotGridProps) {
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{SLOTS.map((slot) => {
const instanceId = equippedInstances[slot];
const instance = instanceId ? equipmentInstances[instanceId] : null;
if (instance) {
const type = EQUIPMENT_TYPES[instance.typeId];
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
return (
<div
key={slot}
className="p-3 rounded border bg-[var(--bg-sunken)] space-y-2"
style={{ borderColor: rarityColor }}
>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-muted)]">{SLOT_NAMES[slot]}</span>
<Icon className="w-4 h-4" style={{ color: rarityColor }} />
</div>
<div className="text-sm font-semibold truncate" style={{ color: rarityColor }}>
{instance.name}
</div>
<div className="text-xs text-[var(--text-secondary)]">
{type?.name || instance.typeId}
</div>
<div className="text-xs text-[var(--text-muted)]">
{instance.usedCapacity}/{instance.totalCapacity} cap
</div>
<div className="text-xs text-[var(--text-muted)]">
{instance.enchantments.length} enchant{instance.enchantments.length !== 1 ? 's' : ''} {instance.quality}% quality
</div>
<ActionButton
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={() => onUnequip(slot)}
>
Unequip
</ActionButton>
</div>
);
}
return (
<div
key={slot}
className="p-3 rounded border border-dashed border-[var(--border-default)] bg-[var(--bg-sunken)] space-y-2"
>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-muted)]">{SLOT_NAMES[slot]}</span>
<Package className="w-4 h-4 text-[var(--text-muted)]" />
</div>
<div className="text-sm text-[var(--text-muted)] italic">Empty</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { Package, Trash2 } from 'lucide-react';
import type { EquipmentInstance, EquipmentSlot } from '@/lib/game/types';
import { EQUIPMENT_TYPES, getValidSlotsForEquipmentType } from '@/lib/game/data/equipment';
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from '@/components/game/LootInventory/types';
import { CATEGORY_ICONS } from '@/components/game/LootInventory/icons';
import { ActionButton } from '@/components/ui/action-button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
interface InventoryListProps {
inventoryItems: [string, EquipmentInstance][];
equippedInstances: Record<string, string | null>;
onEquip: (instanceId: string, slot: EquipmentSlot) => boolean;
onDelete: (instanceId: string) => void;
}
export function InventoryList({ inventoryItems, equippedInstances, onEquip, onDelete }: InventoryListProps) {
const [selectedSlot, setSelectedSlot] = useState<Record<string, EquipmentSlot>>({});
if (inventoryItems.length === 0) {
return (
<div className="text-sm text-[var(--text-muted)] italic text-center py-4">
No items in inventory. Craft or find equipment to fill your slots.
</div>
);
}
return (
<div className="space-y-2">
{inventoryItems.map(([instanceId, instance]) => {
const type = EQUIPMENT_TYPES[instance.typeId];
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
const rarityGlow = RARITY_GLOW_CSS_VAR[instance.rarity] || 'var(--rarity-common-glow)';
const validSlots = type ? getValidSlotsForEquipmentType(type) : [];
const chosenSlot = selectedSlot[instanceId];
const availableSlots = validSlots.filter((s) => !equippedInstances[s]);
return (
<div
key={instanceId}
className="p-3 rounded border bg-[var(--bg-sunken)] group flex items-center gap-3"
style={{
borderColor: rarityColor,
backgroundColor: rarityGlow,
}}
>
<Icon className="w-5 h-5 shrink-0" style={{ color: rarityColor }} />
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold truncate" style={{ color: rarityColor }}>
{instance.name}
</div>
<div className="text-xs text-[var(--text-secondary)]">
{type?.name || instance.typeId} {instance.usedCapacity}/{instance.totalCapacity} cap
</div>
<div className="text-xs text-[var(--text-muted)] capitalize">
{instance.rarity} {instance.enchantments.length} enchant{instance.enchantments.length !== 1 ? 's' : ''} {instance.quality}% quality
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{availableSlots.length > 1 ? (
<select
className="text-xs bg-[var(--bg-base)] border border-[var(--border-default)] rounded px-2 py-1 text-[var(--text-primary)]"
value={chosenSlot || ''}
onChange={(e) => setSelectedSlot((prev) => ({ ...prev, [instanceId]: e.target.value as EquipmentSlot }))}
>
<option value="">Select slot</option>
{availableSlots.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
) : null}
<ActionButton
variant="secondary"
size="sm"
className="text-xs"
onClick={() => {
const slot = availableSlots.length === 1 ? availableSlots[0] : chosenSlot;
if (slot) {
onEquip(instanceId, slot);
setSelectedSlot((prev) => {
const next = { ...prev };
delete next[instanceId];
return next;
});
}
}}
disabled={availableSlots.length === 0 || (availableSlots.length > 1 && !chosenSlot)}
>
Equip
</ActionButton>
<AlertDialog>
<AlertDialogTrigger asChild>
<ActionButton
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
>
<Trash2 className="w-3.5 h-3.5" />
</ActionButton>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {instance.name}?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The item will be permanently destroyed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(instanceId)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest';
// ─── Test: GolemancyTab barrel export ─────────────────────────────────────────
describe('GolemancyTab module structure', () => {
it('exports GolemancyTab from barrel index', async () => {
const mod = await import('./GolemancyTab');
expect(mod.GolemancyTab).toBeDefined();
expect(typeof mod.GolemancyTab).toBe('function');
});
it('GolemancyTab has correct displayName', async () => {
const { GolemancyTab } = await import('./GolemancyTab');
expect(GolemancyTab.displayName).toBe('GolemancyTab');
});
});
// ─── Test: Barrel export includes GolemancyTab ────────────────────────────────
describe('Tab barrel export', () => {
it('includes GolemancyTab in the tabs index', async () => {
const mod = await import('@/components/game/tabs');
expect(mod.GolemancyTab).toBeDefined();
expect(typeof mod.GolemancyTab).toBe('function');
});
});
// ─── Test: Golem data integrity ───────────────────────────────────────────────
describe('Golem data', () => {
it('all golems have required fields', async () => {
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
for (const [id, def] of Object.entries(GOLEMS_DEF)) {
expect(def.id).toBe(id);
expect(def.name).toBeTruthy();
expect(def.description).toBeTruthy();
expect(def.baseManaType).toBeTruthy();
expect(def.summonCost.length).toBeGreaterThan(0);
expect(def.maintenanceCost.length).toBeGreaterThan(0);
expect(def.damage).toBeGreaterThan(0);
expect(def.attackSpeed).toBeGreaterThan(0);
expect(def.hp).toBeGreaterThan(0);
expect(def.armorPierce).toBeGreaterThanOrEqual(0);
expect(def.tier).toBeGreaterThanOrEqual(1);
expect(def.unlockCondition).toBeTruthy();
}
});
it('has golems across multiple tiers', async () => {
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
const tiers = new Set(Object.values(GOLEMS_DEF).map(g => g.tier));
expect(tiers.size).toBeGreaterThanOrEqual(3);
});
it('earthGolem is the only base tier golem', async () => {
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
const baseGolems = Object.values(GOLEMS_DEF).filter(g => g.tier === 1);
expect(baseGolems.length).toBe(1);
expect(baseGolems[0].id).toBe('earthGolem');
});
it('voidstoneGolem is the highest tier', async () => {
const { GOLEMS_DEF } = await import('@/lib/game/data/golems');
const voidstone = GOLEMS_DEF.voidstoneGolem;
expect(voidstone).toBeDefined();
expect(voidstone.tier).toBe(4);
});
});
// ─── Test: Golem utility functions ────────────────────────────────────────────
describe('Golem utility functions', () => {
it('getGolemSlots returns 0 for fabricator level < 2', async () => {
const { getGolemSlots } = await import('@/lib/game/data/golems');
expect(getGolemSlots(0)).toBe(0);
expect(getGolemSlots(1)).toBe(0);
});
it('getGolemSlots scales with fabricator level', async () => {
const { getGolemSlots } = await import('@/lib/game/data/golems');
expect(getGolemSlots(2)).toBe(1);
expect(getGolemSlots(4)).toBe(2);
expect(getGolemSlots(10)).toBe(5);
});
it('isGolemUnlocked returns false for unknown golem', async () => {
const { isGolemUnlocked } = await import('@/lib/game/data/golems');
expect(isGolemUnlocked('nonexistent', {}, [])).toBe(false);
});
it('isGolemUnlocked checks attunement level', async () => {
const { isGolemUnlocked } = await import('@/lib/game/data/golems');
expect(isGolemUnlocked('earthGolem', { fabricator: { active: true, level: 1 } }, [])).toBe(false);
expect(isGolemUnlocked('earthGolem', { fabricator: { active: true, level: 2 } }, [])).toBe(true);
});
it('isGolemUnlocked checks mana unlocked', async () => {
const { isGolemUnlocked } = await import('@/lib/game/data/golems');
expect(isGolemUnlocked('steelGolem', {}, [])).toBe(false);
expect(isGolemUnlocked('steelGolem', {}, ['metal'])).toBe(true);
});
it('canAffordGolemSummon returns false for unknown golem', async () => {
const { canAffordGolemSummon } = await import('@/lib/game/data/golems');
expect(canAffordGolemSummon('nonexistent', 100, {})).toBe(false);
});
it('canAffordGolemSummon checks raw mana cost', async () => {
const { canAffordGolemSummon } = await import('@/lib/game/data/golems');
// earthGolem costs 10 earth
const elements = { earth: { current: 5, max: 100, unlocked: true } };
expect(canAffordGolemSummon('earthGolem', 0, elements)).toBe(false);
elements.earth.current = 10;
expect(canAffordGolemSummon('earthGolem', 0, elements)).toBe(true);
});
});
// ─── Test: Combat store golemancy state ───────────────────────────────────────
describe('Combat store golemancy', () => {
it('toggleGolem is a function', async () => {
const { useCombatStore } = await import('@/lib/game/stores/combatStore');
const state = useCombatStore.getState();
expect(typeof state.toggleGolem).toBe('function');
});
it('golemancy state has correct shape', async () => {
const { useCombatStore } = await import('@/lib/game/stores/combatStore');
const state = useCombatStore.getState();
expect(state.golemancy).toBeDefined();
expect(Array.isArray(state.golemancy.enabledGolems)).toBe(true);
expect(Array.isArray(state.golemancy.summonedGolems)).toBe(true);
expect(typeof state.golemancy.lastSummonFloor).toBe('number');
});
});
// ─── Test: File size limit ────────────────────────────────────────────────────
describe('File size limits (400 lines max)', () => {
it('GolemancyTab.tsx is under 400 lines', async () => {
const fs = await import('fs');
const path = await import('path');
const filePath = path.join(__dirname, 'GolemancyTab.tsx');
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n').length;
expect(lines).toBeLessThan(400);
});
});
+339
View File
@@ -0,0 +1,339 @@
'use client';
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 clsx from 'clsx';
// ─── Tier configuration ──────────────────────────────────────────────────────
interface TierConfig {
key: string;
label: string;
tier: number;
}
const TIERS: TierConfig[] = [
{ key: 'base', label: 'Base', tier: 1 },
{ key: 'elemental', label: 'Elemental', tier: 2 },
{ key: 'hybrid', label: 'Hybrid', tier: 3 },
];
function getTierLabel(tier: number): string {
if (tier <= 1) return 'Base';
if (tier <= 2) return 'Elemental';
return 'Hybrid';
}
function getTierColor(tier: number): string {
if (tier <= 1) return 'bg-gray-600';
if (tier <= 2) return 'bg-blue-600';
if (tier <= 3) return 'bg-purple-600';
return 'bg-amber-500';
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatCost(cost: GolemDef['summonCost'][number]): string {
if (cost.type === 'raw') return `${cost.amount} raw`;
const elem = cost.element ? ELEMENTS[cost.element] : null;
return `${cost.amount} ${elem?.sym ?? ''} ${cost.element ?? ''}`.trim();
}
function formatUnlockCondition(golem: GolemDef): string {
const cond = golem.unlockCondition;
switch (cond.type) {
case 'attunement_level':
return `${cond.attunement} level ${cond.level}`;
case 'mana_unlocked': {
const elem = cond.manaType ? ELEMENTS[cond.manaType] : null;
return `Unlock ${elem?.sym ?? ''} ${cond.manaType ?? ''}`.trim();
}
case 'dual_attunement':
return `${cond.attunements?.join(' + ')} level ${cond.levels?.join('/')}`;
default:
return 'Unknown';
}
}
// ─── Golem Card ──────────────────────────────────────────────────────────────
interface GolemCardProps {
golem: GolemDef;
unlocked: boolean;
enabled: boolean;
summoned: boolean;
canAfford: boolean;
onToggle: (id: string) => void;
}
const GolemCard: React.FC<GolemCardProps> = React.memo(({
golem,
unlocked,
enabled,
summoned,
canAfford,
onToggle,
}) => {
const elemColor = ELEMENTS[golem.baseManaType]?.color ?? '#888';
const elemSym = ELEMENTS[golem.baseManaType]?.sym ?? '';
return (
<div
className={clsx(
'rounded-lg border p-4 space-y-3 transition-colors',
unlocked
? 'bg-gray-800/50 border-gray-600'
: 'bg-gray-900/50 border-gray-800 opacity-60',
summoned && 'ring-1 ring-green-500/50',
)}
>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-semibold text-gray-100 truncate">{golem.name}</h3>
<p className="text-xs text-gray-400 mt-0.5">{golem.description}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span
className="text-xs"
style={{ color: elemColor }}
title={golem.baseManaType}
>
{elemSym}
</span>
<Badge className={clsx('text-[10px] px-1.5 py-0', getTierColor(golem.tier))}>
T{golem.tier}
</Badge>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="text-gray-400">
<span className="text-gray-500">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 (1040)', floors: [] },
{ label: 'Mid Spire (5060)', floors: [] },
{ label: 'Late Spire (80100)', 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 success = startPactRitual(floor, rawMana);
if (success) {
addLog(`📜 Began pact ritual with ${guardian.name}`);
} else {
addLog(`⚠️ Cannot start pact ritual with ${guardian.name}.`);
}
}, [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';
@@ -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);
});
});
+312
View File
@@ -0,0 +1,312 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePrestigeStore, useGameStore } from '@/lib/game/stores';
import { PRESTIGE_DEF } from '@/lib/game/constants/prestige';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
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';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
// ─── Stat Cell ────────────────────────────────────────────────────────────────
function PrestigeStatCell({ value, label, color }: { value: string | number; label: string; color: string }) {
return (
<div className="text-center">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
</div>
);
}
// ─── Insight Summary ──────────────────────────────────────────────────────────
function InsightSummary({ insight, totalInsight, loopCount, loopInsight }: {
insight: number;
totalInsight: number;
loopCount: number;
loopInsight: number;
}) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<CardContent className="py-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<PrestigeStatCell value={fmt(insight)} label="Insight Available" color="text-amber-400" />
<PrestigeStatCell value={fmt(totalInsight)} label="Total Insight Earned" color="text-gray-200" />
<PrestigeStatCell value={loopCount} label="Loops Completed" color="text-gray-200" />
<PrestigeStatCell value={fmt(loopInsight)} label="This Loop's Insight" color="text-purple-400" />
</div>
</CardContent>
</Card>
);
}
// ─── Memories Card ────────────────────────────────────────────────────────────
function MemoriesCard({ memories, memorySlots }: { memories: { skillId: string; level: number; tier: number }[]; memorySlots: number }) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🧠 Memories" />
<CardContent className="pt-0">
<p className="text-xs text-gray-400 mb-2">
Skills carried between loops. Slots: {memories.length}/{memorySlots}
</p>
{memories.length === 0 ? (
<p className="text-xs text-gray-500 italic">No memories stored yet.</p>
) : (
<div className="space-y-1">
{memories.map((m) => (
<div key={m.skillId} className="text-xs text-gray-300 flex justify-between">
<span>{m.skillId}</span>
<span className="text-gray-500">Lv.{m.level} T{m.tier}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// ─── Pacts Card ───────────────────────────────────────────────────────────────
function PactsCard({ signedPacts, pactSlots, defeatedGuardians }: {
signedPacts: number[];
pactSlots: number;
defeatedGuardians: number[];
}) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="📜 Pacts" />
<CardContent className="pt-0">
<p className="text-xs text-gray-400 mb-2">
Guardian pacts signed. Slots: {signedPacts.length}/{pactSlots}
</p>
{defeatedGuardians.length > 0 && (
<p className="text-xs text-gray-500 mb-1">
Defeated guardians: {defeatedGuardians.map((f) => `F${f}`).join(', ')}
</p>
)}
{signedPacts.length === 0 ? (
<p className="text-xs text-gray-500 italic">No pacts signed yet.</p>
) : (
<div className="space-y-1">
{signedPacts.map((f) => (
<div key={f} className="text-xs text-green-400">
Floor {f} Pact signed
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// ─── Upgrade Card ─────────────────────────────────────────────────────────────
interface UpgradeCardProps {
id: string;
name: string;
desc: string;
max: number;
cost: number;
currentLevel: number;
insight: number;
onPurchase: (id: string) => void;
}
function UpgradeCard({ id, name, desc, max, cost, currentLevel, insight, onPurchase }: UpgradeCardProps) {
const isMaxed = currentLevel >= max;
const canAfford = insight >= cost;
const disabled = isMaxed || !canAfford;
return (
<Card className={`bg-gray-900/60 ${isMaxed ? 'border-amber-700/50' : 'border-gray-700'}`}>
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm text-gray-100">{name}</span>
<Badge
variant="outline"
className={isMaxed ? 'border-amber-600 text-amber-400' : 'border-gray-600 text-gray-400'}
>
{currentLevel}/{max}
</Badge>
</div>
<p className="text-xs text-gray-400">{desc}</p>
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-gray-500">
Cost: <span className={canAfford ? 'text-amber-400' : 'text-red-400'}>{fmt(cost)}</span> insight
</span>
<Button
size="sm"
disabled={disabled}
onClick={() => onPurchase(id)}
className="h-7 px-3 text-xs"
>
{isMaxed ? 'Maxed' : 'Buy'}
</Button>
</div>
</CardContent>
</Card>
);
}
// ─── Reset Loop Section ──────────────────────────────────────────────────────
function ResetLoopSection({ loopInsight, onReset }: { loopInsight: number; onReset: () => void }) {
return (
<Card className="bg-gray-900/60 border-red-900/50">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-red-400">Reset Loop</h3>
<p className="text-xs text-gray-400 mt-1">
End the current loop and gain {fmt(loopInsight)} insight. Your prestige upgrades, memories, and pacts are preserved.
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" className="flex-shrink-0 ml-4">
Reset Loop
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the Loop?</AlertDialogTitle>
<AlertDialogDescription>
This will end your current loop and award you <strong className="text-amber-400">{fmt(loopInsight)} insight</strong>.
Your prestige upgrades, memories, and pacts will be preserved.
<br /><br />
Day, hour, mana, floor progress, and combat state will be reset.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onReset}>
Confirm Reset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function PrestigeTab() {
const [mounted, setMounted] = useState(false);
const {
insight,
totalInsight,
loopInsight,
loopCount,
prestigeUpgrades,
memorySlots,
memories,
pactSlots,
signedPacts,
defeatedGuardians,
doPrestige,
} = usePrestigeStore(useShallow((s) => ({
insight: s.insight,
totalInsight: s.totalInsight,
loopInsight: s.loopInsight,
loopCount: s.loopCount,
prestigeUpgrades: s.prestigeUpgrades,
memorySlots: s.memorySlots,
memories: s.memories,
pactSlots: s.pactSlots,
signedPacts: s.signedPacts,
defeatedGuardians: s.defeatedGuardians,
doPrestige: s.doPrestige,
})));
const startNewLoop = useGameStore((s) => s.startNewLoop);
useEffect(() => {
setMounted(true);
}, []);
const handlePurchase = useCallback((id: string) => {
doPrestige(id);
}, [doPrestige]);
const handleResetLoop = useCallback(() => {
startNewLoop();
}, [startNewLoop]);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading prestige
</div>
);
}
const upgradeEntries = Object.entries(PRESTIGE_DEF);
return (
<DebugName name="PrestigeTab">
<div className="space-y-4">
<InsightSummary
insight={insight}
totalInsight={totalInsight}
loopCount={loopCount}
loopInsight={loopInsight}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<MemoriesCard memories={memories} memorySlots={memorySlots} />
<PactsCard signedPacts={signedPacts} pactSlots={pactSlots} defeatedGuardians={defeatedGuardians} />
</div>
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="⬆️ Prestige Upgrades" />
<CardContent className="pt-0">
<ScrollArea className="h-[400px] pr-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{upgradeEntries.map(([id, def]) => (
<UpgradeCard
key={id}
id={id}
name={def.name}
desc={def.desc}
max={def.max}
cost={def.cost}
currentLevel={prestigeUpgrades[id] || 0}
insight={insight}
onPurchase={handlePurchase}
/>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<ResetLoopSection loopInsight={loopInsight} onReset={handleResetLoop} />
</div>
</DebugName>
);
}
PrestigeTab.displayName = 'PrestigeTab';
@@ -1,13 +1,11 @@
'use client';
import { canAffordSpellCost, fmt } from '@/lib/game/stores';
import { useCombatStore, useSkillStore, useManaStore } from '@/lib/game/stores';
import { useCombatStore, 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 {
@@ -26,21 +24,12 @@ function getSpellCostColor(cost: { type: 'raw' | 'element'; element?: string; am
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];
@@ -60,23 +49,14 @@ export function SpellsTab() {
{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' : ''}`}
className={`bg-gray-900/80 border-gray-700 ${learned ? '' : 'opacity-75'} ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
@@ -128,36 +108,9 @@ export function SpellsTab() {
</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 className="text-xs text-gray-500">
Not yet learned
</div>
)}
</CardContent>
@@ -0,0 +1,190 @@
'use client';
import type { FloorState, EnemyState, RoomType } from '@/lib/game/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { getSpireRoomTypeDisplay } from '@/lib/game/utils/spire-utils';
import { getModifierDisplay, getModifierDescription } from '@/lib/game/utils/enemy-generator';
import { ELEMENTS } from '@/lib/game/constants';
import { fmt } from '@/lib/game/stores';
interface RoomDisplayProps {
floorState: FloorState;
floor: number;
}
function EnemyRow({ enemy, floor }: { enemy: EnemyState; floor: number }) {
const elemDef = ELEMENTS[enemy.element];
const hpPercent = enemy.maxHP > 0 ? (enemy.hp / enemy.maxHP) * 100 : 0;
const barrierVal = enemy.barrier ?? 0;
const hasBarrier = barrierVal > 0;
const barrierPercent = hasBarrier ? barrierVal * 100 : 0;
return (
<div className="space-y-1 p-2 bg-gray-800/50 rounded border border-gray-700/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{elemDef && (
<span style={{ color: elemDef.color }}>{elemDef.sym}</span>
)}
<span className="text-sm font-medium text-gray-200">{enemy.name}</span>
</div>
<span className="text-xs text-gray-500">
{fmt(enemy.hp)} / {fmt(enemy.maxHP)}
</span>
</div>
{/* HP bar */}
<div className="relative">
<Progress
value={hpPercent}
className="h-2 bg-gray-700"
style={{ '--progress-bg': elemDef?.color || '#EF4444' } as React.CSSProperties}
/>
{hasBarrier && (
<div
className="absolute top-0 left-0 h-2 rounded-full bg-blue-400/40"
style={{ width: `${barrierPercent}%` }}
/>
)}
</div>
{/* Enemy stats */}
<div className="flex items-center gap-2 flex-wrap">
{enemy.armor > 0 && (
<Badge variant="outline" className="text-[10px] border-amber-600 text-amber-400">
{Math.round(enemy.armor * 100)}% armor
</Badge>
)}
{enemy.dodgeChance > 0 && (
<Badge variant="outline" className="text-[10px] border-green-600 text-green-400">
💨 {Math.round(enemy.dodgeChance * 100)}% dodge
</Badge>
)}
{hasBarrier && (
<Badge variant="outline" className="text-[10px] border-blue-600 text-blue-400">
🛡 Barrier
</Badge>
)}
</div>
</div>
);
}
export function RoomDisplay({ floorState, floor }: RoomDisplayProps) {
const roomDisplay = getSpireRoomTypeDisplay(floorState.roomType as RoomType);
// Handle special room types (cast to string for extended types)
const rt = floorState.roomType as string;
if (rt === 'recovery') {
const progress = floorState.puzzleProgress || 0;
const required = floorState.puzzleRequired || 1;
return (
<Card className="bg-gray-900/80 border-green-800/40">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#10B981' }}>
💚 Recovery Room
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400 mb-2">
Rest and recover. Spend 1 hour to gain 5x mana regen & conversion rates.
</p>
<Progress
value={(progress / required) * 100}
className="h-2 bg-gray-800"
style={{ '--progress-bg': '#10B981' } as React.CSSProperties}
/>
</CardContent>
</Card>
);
}
if (rt === 'library') {
return (
<Card className="bg-gray-900/80 border-indigo-800/40">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#6366F1' }}>
📚 Ancient Library
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400">
Study a random discipline at 10x XP speed (no mana cost). Spend 1 hour to gain knowledge.
</p>
</CardContent>
</Card>
);
}
if (rt === 'treasure') {
return (
<Card className="bg-gray-900/80 border-amber-800/40">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#F59E0B' }}>
💎 Treasure Room
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400">
A hidden cache of resources awaits. Claim your reward!
</p>
</CardContent>
</Card>
);
}
if (floorState.roomType === 'puzzle') {
const puzzleId = floorState.puzzleId || 'unknown';
const progress = floorState.puzzleProgress || 0;
const required = floorState.puzzleRequired || 1;
return (
<Card className="bg-gray-900/80 border-purple-800/40">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#8B5CF6' }}>
🧩 Puzzle Room {puzzleId.replace(/_/g, ' ')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400 mb-2">
Solve the puzzle. Higher attunement levels speed up progress.
</p>
<Progress
value={(progress / required) * 100}
className="h-2 bg-gray-800"
style={{ '--progress-bg': '#8B5CF6' } as React.CSSProperties}
/>
<div className="text-xs text-gray-500 mt-1">
Progress: {Math.round(progress * 100)} / {Math.round(required * 100)}
</div>
</CardContent>
</Card>
);
}
// Combat rooms (combat, swarm, speed, guardian)
const enemies = floorState.enemies || [];
const isGuardian = floorState.roomType === 'guardian';
return (
<Card className={`bg-gray-900/80 ${isGuardian ? 'border-red-800/40' : 'border-gray-700'}`}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: roomDisplay.color }}>
{roomDisplay.icon} {roomDisplay.label}
{isGuardian && <Badge className="bg-red-900/50 text-red-300 text-xs">BOSS</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{enemies.length === 0 ? (
<div className="text-xs text-gray-500 italic">Room cleared!</div>
) : (
enemies.map((enemy) => (
<EnemyRow key={enemy.id} enemy={enemy} floor={floor} />
))
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,43 @@
'use client';
import type { ActivityLogEntry } from '@/lib/game/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
interface SpireActivityLogProps {
activityLog: ActivityLogEntry[];
maxEntries?: number;
}
export function SpireActivityLog({ activityLog, maxEntries = 30 }: SpireActivityLogProps) {
const entries = activityLog.slice(0, maxEntries);
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-gray-400">📜 Activity Log</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-48">
{entries.length === 0 ? (
<div className="text-xs text-gray-500 italic">No activity yet.</div>
) : (
<div className="space-y-1">
{entries.map((entry) => (
<div
key={entry.id}
className="text-xs text-gray-300 border-b border-gray-800 pb-1 last:border-0"
>
<span className="text-gray-600 mr-1">
[{entry.eventType}]
</span>
{entry.message}
</div>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
);
}
@@ -0,0 +1,133 @@
'use client';
import { useCombatStore, useManaStore, canAffordSpellCost, fmt } from '@/lib/game/stores';
import { SPELLS_DEF, ELEMENTS } from '@/lib/game/constants';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { GOLEMS_DEF } from '@/lib/game/data/golems';
interface SpireCombatControlsProps {
castProgress: number;
}
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 || '?'}`;
}
export function SpireCombatControls({ castProgress }: SpireCombatControlsProps) {
const spells = useCombatStore((s) => s.spells);
const activeSpell = useCombatStore((s) => s.activeSpell);
const setSpell = useCombatStore((s) => s.setSpell);
const golemancy = useCombatStore((s) => s.golemancy);
const rawMana = useManaStore((s) => s.rawMana);
const elements = useManaStore((s) => s.elements);
const learnedSpells = Object.entries(spells)
.filter(([, state]) => state?.learned)
.map(([id]) => id);
const summonedGolems = golemancy.summonedGolems || [];
return (
<div className="space-y-4">
{/* Active Spell Panel */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-amber-400">🔮 Active Spells</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{/* Cast progress */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-400">Cast Progress</span>
<span className="text-gray-500">{Math.round(castProgress * 100)}%</span>
</div>
<Progress
value={castProgress * 100}
className="h-2 bg-gray-800"
style={{ '--progress-bg': '#F59E0B' } as React.CSSProperties}
/>
</div>
{/* Spell selection */}
<div className="grid grid-cols-1 gap-1.5 max-h-48 overflow-y-auto">
{learnedSpells.map((spellId) => {
const def = SPELLS_DEF[spellId];
if (!def) return null;
const isActive = activeSpell === spellId;
const canCast = canAffordSpellCost(def.cost, rawMana, elements);
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
return (
<button
key={spellId}
onClick={() => setSpell(spellId)}
className={`flex items-center justify-between p-2 rounded text-xs transition-colors ${
isActive
? 'bg-amber-900/40 border border-amber-600'
: canCast
? 'bg-gray-800/50 border border-gray-700 hover:border-gray-500'
: 'bg-gray-800/30 border border-gray-800 opacity-50'
}`}
>
<div className="flex items-center gap-2">
{elemDef && <span>{elemDef.sym}</span>}
<span className={isActive ? 'text-amber-300 font-medium' : 'text-gray-300'}>
{def.name}
</span>
</div>
<span className="text-gray-500">
{formatSpellCost(def.cost)}
</span>
</button>
);
})}
</div>
{learnedSpells.length === 0 && (
<div className="text-xs text-gray-500 italic">No spells learned yet.</div>
)}
</CardContent>
</Card>
{/* Golem Status Panel */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-blue-400">🗿 Golems</CardTitle>
</CardHeader>
<CardContent>
{summonedGolems.length === 0 ? (
<div className="text-xs text-gray-500 italic">No golems summoned.</div>
) : (
<div className="space-y-2">
{summonedGolems.map((sg) => {
const golemDef = GOLEMS_DEF[sg.golemId];
if (!golemDef) return null;
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
return (
<div
key={sg.golemId}
className="flex items-center justify-between p-2 bg-gray-800/50 rounded border border-gray-700/50"
>
<div className="flex items-center gap-2">
<span style={{ color: elemColor }}></span>
<span className="text-xs text-gray-200">{golemDef.name}</span>
</div>
<div className="text-xs text-gray-500">
{golemDef.damage} dmg · {golemDef.attackSpeed}/h
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,223 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, useManaStore, usePrestigeStore, fmt, computeMaxMana, computeRegen } from '@/lib/game/stores';
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
import { getUnifiedEffects } from '@/lib/game/effects';
import { useCraftingStore } from '@/lib/game/stores/craftingStore';
import { GUARDIANS } from '@/lib/game/constants';
import { getExtendedGuardian, isGuardianFloor } from '@/lib/game/data/guardian-encounters';
import { getRoomsForFloor, generateSpireFloorState } from '@/lib/game/utils/spire-utils';
import { SpireHeader } from './SpireHeader';
import { RoomDisplay } from './RoomDisplay';
import { SpireCombatControls } from './SpireCombatControls';
import { SpireActivityLog } from './SpireActivityLog';
import { SpireManaDisplay } from './SpireManaDisplay';
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
function useSpireStats(prestigeUpgrades: Record<string, number>, equippedInstances: unknown[], equipmentInstances: unknown[]) {
const disciplineEffects = computeDisciplineEffects();
const upgradeEffects = getUnifiedEffects({
skillUpgrades: {},
skillTiers: {},
equippedInstances,
equipmentInstances,
});
const maxMana = computeMaxMana({
skills: {},
prestigeUpgrades,
skillUpgrades: {},
skillTiers: {},
}, upgradeEffects, disciplineEffects);
const baseRegen = computeRegen({
skills: {},
prestigeUpgrades,
skillUpgrades: {},
skillTiers: {},
attunements: {},
}, upgradeEffects, disciplineEffects);
return { maxMana, baseRegen };
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function SpireCombatPage() {
const [mounted, setMounted] = useState(false);
const [roomsCleared, setRoomsCleared] = useState(0);
const {
currentFloor,
floorHP,
floorMaxHP,
castProgress,
clearedFloors,
isDescending,
currentRoom,
activityLog,
setCurrentRoom,
setFloorHP,
setClearedFloor,
climbDownFloor,
exitSpireMode,
startClimbUp,
startClimbDown,
addActivityLog,
setAction,
} = useCombatStore(useShallow((s) => ({
currentFloor: s.currentFloor,
floorHP: s.floorHP,
floorMaxHP: s.floorMaxHP,
castProgress: s.castProgress,
clearedFloors: s.clearedFloors,
isDescending: s.isDescending,
currentRoom: s.currentRoom,
activityLog: s.activityLog,
setCurrentRoom: s.setCurrentRoom,
setFloorHP: s.setFloorHP,
setClearedFloor: s.setClearedFloor,
climbDownFloor: s.climbDownFloor,
exitSpireMode: s.exitSpireMode,
startClimbUp: s.startClimbUp,
startClimbDown: s.startClimbDown,
addActivityLog: s.addActivityLog,
setAction: s.setAction,
})));
const { rawMana, elements } = useManaStore(useShallow((s) => ({
rawMana: s.rawMana,
elements: s.elements,
})));
const { prestigeUpgrades, insight } = usePrestigeStore(useShallow((s) => ({
prestigeUpgrades: s.prestigeUpgrades,
insight: s.insight,
})));
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
const { maxMana, baseRegen } = useSpireStats(prestigeUpgrades, equippedInstances, equipmentInstances);
const totalRooms = useMemo(() => getRoomsForFloor(currentFloor), [currentFloor]);
useEffect(() => {
setMounted(true);
setRoomsCleared(0);
const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms);
setCurrentRoom(newRoom);
setAction('climb');
}, [currentFloor, totalRooms, setCurrentRoom, setAction]);
const handleRoomCleared = () => {
const nextRoomIndex = roomsCleared + 1;
if (nextRoomIndex >= totalRooms) {
const wasGuardian = isGuardianFloor(currentFloor);
setClearedFloor(currentFloor, true);
if (wasGuardian) {
const guardian = GUARDIANS[currentFloor] || getExtendedGuardian(currentFloor);
if (guardian) {
addActivityLog('enemy_defeated', `⚔️ ${guardian.name} defeated!`, {
enemyName: guardian.name,
floor: currentFloor,
});
}
}
addActivityLog('floor_cleared', `🏰 Floor ${currentFloor} cleared!`, {
floor: currentFloor,
});
const newFloor = currentFloor + 1;
const newTotalRooms = getRoomsForFloor(newFloor);
const newRoom = generateSpireFloorState(newFloor, 0, newTotalRooms);
setCurrentRoom(newRoom);
setFloorHP(floorMaxHP);
setClearedFloor(currentFloor, true);
setRoomsCleared(0);
} else {
const newRoom = generateSpireFloorState(currentFloor, nextRoomIndex, totalRooms);
setCurrentRoom(newRoom);
setRoomsCleared(nextRoomIndex);
}
};
const handleClimbUp = () => {
startClimbUp();
addActivityLog('floor_transition', `⬆️ Climbing to floor ${currentFloor + 1}...`);
};
const handleClimbDown = () => {
if (currentFloor <= 1) return;
startClimbDown();
climbDownFloor();
setRoomsCleared(0);
addActivityLog('floor_transition', `⬇️ Descending to floor ${currentFloor - 1}...`);
};
const handleExitSpire = () => {
exitSpireMode();
addActivityLog('floor_transition', '🚪 Exited the Spire.');
};
if (!mounted) {
return (
<div className="flex items-center justify-center min-h-screen text-gray-500">
Loading spire...
</div>
);
}
return (
<div className="min-h-screen bg-gray-950 flex flex-col">
<header className="sticky top-0 z-50 bg-gray-900/95 border-b border-gray-800 px-4 py-2">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold text-amber-400 tracking-wider">🏔 SPIRE</h1>
<div className="text-xs text-gray-500">
Floor {currentFloor} · Insight: {fmt(insight)}
</div>
</div>
</header>
<main className="flex-1 p-4 space-y-4 max-w-7xl mx-auto w-full">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<SpireHeader
currentFloor={currentFloor}
floorHP={floorHP}
floorMaxHP={floorMaxHP}
roomsCleared={roomsCleared}
totalRooms={totalRooms}
onClimbUp={handleClimbUp}
onClimbDown={handleClimbDown}
onExitSpire={handleExitSpire}
isDescending={isDescending}
/>
</div>
<div>
<SpireManaDisplay maxMana={maxMana} effectiveRegen={baseRegen} />
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<RoomDisplay floorState={currentRoom} floor={currentFloor} />
</div>
<div>
<SpireCombatControls castProgress={castProgress} />
</div>
</div>
<SpireActivityLog activityLog={activityLog} maxEntries={20} />
</main>
</div>
);
}
@@ -0,0 +1,128 @@
'use client';
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Mountain, ArrowUp, ArrowDown, LogOut } from 'lucide-react';
import { GUARDIANS } from '@/lib/game/constants';
import { isGuardianFloor, getExtendedGuardian } from '@/lib/game/data/guardian-encounters';
interface SpireHeaderProps {
currentFloor: number;
floorHP: number;
floorMaxHP: number;
roomsCleared: number;
totalRooms: number;
onClimbUp: () => void;
onClimbDown: () => void;
onExitSpire: () => void;
isDescending: boolean;
}
export function SpireHeader({
currentFloor,
floorHP,
floorMaxHP,
roomsCleared,
totalRooms,
onClimbUp,
onClimbDown,
onExitSpire,
isDescending,
}: SpireHeaderProps) {
const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
const { insight } = usePrestigeStore((s) => ({ insight: s.insight }));
const guardian = GUARDIANS[currentFloor] || getExtendedGuardian(currentFloor);
const isGuardian = isGuardianFloor(currentFloor);
const hpPercent = floorMaxHP > 0 ? (floorHP / floorMaxHP) * 100 : 100;
const roomProgress = totalRooms > 0 ? ((roomsCleared) / totalRooms) * 100 : 0;
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="py-3 space-y-3">
{/* Top row: Floor info + controls */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Mountain className="w-6 h-6 text-amber-400" />
<div>
<div className="text-xl font-bold text-amber-400">
Floor {currentFloor}
</div>
<div className="text-xs text-gray-400">
Max: {maxFloorReached} · Insight: {fmt(insight)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={onClimbUp}
disabled={isDescending}
className="border-gray-600 hover:border-amber-500"
>
<ArrowUp className="w-4 h-4 mr-1" />
Climb Up
</Button>
<Button
size="sm"
variant="outline"
onClick={onClimbDown}
disabled={currentFloor <= 1 || isDescending}
className="border-gray-600 hover:border-amber-500"
>
<ArrowDown className="w-4 h-4 mr-1" />
Climb Down
</Button>
{currentFloor === 1 && (
<Button
size="sm"
variant="outline"
onClick={onExitSpire}
className="border-green-600 text-green-400 hover:bg-green-900/30"
>
<LogOut className="w-4 h-4 mr-1" />
Exit Spire
</Button>
)}
</div>
</div>
{/* Floor HP bar */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className={isGuardian ? 'text-red-400 font-semibold' : 'text-gray-400'}>
{isGuardian && guardian ? `🛡️ ${guardian.name}` : 'Floor HP'}
</span>
<span className="text-gray-500">
{fmt(floorHP)} / {fmt(floorMaxHP)}
</span>
</div>
<Progress
value={hpPercent}
className="h-3 bg-gray-800"
style={{
'--progress-bg': isGuardian ? '#EF4444' : '#F59E0B',
} as React.CSSProperties}
/>
</div>
{/* Room progress */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-400">Rooms Cleared</span>
<span className="text-gray-500">{roomsCleared} / {totalRooms}</span>
</div>
<Progress
value={roomProgress}
className="h-2 bg-gray-800"
style={{ '--progress-bg': '#6366F1' } as React.CSSProperties}
/>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,72 @@
'use client';
import { useManaStore, fmt, fmtDec } from '@/lib/game/stores';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { ELEMENTS } from '@/lib/game/constants';
interface SpireManaDisplayProps {
maxMana: number;
effectiveRegen: number;
}
export function SpireManaDisplay({ maxMana, effectiveRegen }: SpireManaDisplayProps) {
const rawMana = useManaStore((s) => s.rawMana);
const elements = useManaStore((s) => s.elements);
const unlockedElements = Object.entries(elements)
.filter(([, state]) => state.unlocked && state.current > 0)
.sort((a, b) => b[1].current - a[1].current)
.slice(0, 6); // Show max 6 in compact view
const manaPercent = maxMana > 0 ? (rawMana / maxMana) * 100 : 0;
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="py-3 space-y-2">
{/* Raw Mana */}
<div>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold game-mono" style={{ color: '#60A5FA' }}>
{fmt(rawMana)}
</span>
<span className="text-xs text-gray-500">/ {fmt(maxMana)}</span>
</div>
<div className="text-[10px] text-gray-500">
+{fmtDec(effectiveRegen)}/hr
</div>
</div>
<Progress
value={manaPercent}
className="h-1.5 bg-gray-800"
style={{ '--progress-bg': '#60A5FA' } as React.CSSProperties}
/>
{/* Elemental pools (compact) */}
{unlockedElements.length > 0 && (
<div className="grid grid-cols-3 gap-1">
{unlockedElements.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
const pct = state.max > 0 ? (state.current / state.max) * 100 : 0;
return (
<div key={id} className="text-center">
<div className="text-[10px]" style={{ color: elem.color }}>
{elem.sym} {fmt(state.current)}
</div>
<div className="h-1 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${pct}%`, backgroundColor: elem.color }}
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,8 @@
// ─── SpireCombatPage Barrel ────────────────────────────────────────────────────
export { SpireCombatPage } from './SpireCombatPage';
export { SpireHeader } from './SpireHeader';
export { RoomDisplay } from './RoomDisplay';
export { SpireCombatControls } from './SpireCombatControls';
export { SpireActivityLog } from './SpireActivityLog';
export { SpireManaDisplay } from './SpireManaDisplay';
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
// ─── Test: SpireSummaryTab barrel export ───────────────────────────────────────
describe('SpireSummaryTab module structure', () => {
it('exports SpireSummaryTab from its module', async () => {
const mod = await import('./SpireSummaryTab');
expect(mod.SpireSummaryTab).toBeDefined();
expect(typeof mod.SpireSummaryTab).toBe('function');
});
it('SpireSummaryTab has correct displayName', async () => {
const { SpireSummaryTab } = await import('./SpireSummaryTab');
expect(SpireSummaryTab.displayName).toBe('SpireSummaryTab');
});
});
// ─── Test: Barrel export includes SpireSummaryTab ──────────────────────────────
describe('Tab barrel export', () => {
it('includes SpireSummaryTab in the tabs index', async () => {
const mod = await import('@/components/game/tabs');
expect(mod.SpireSummaryTab).toBeDefined();
expect(typeof mod.SpireSummaryTab).toBe('function');
});
});
// ─── Test: Guardian data ───────────────────────────────────────────────────────
describe('Guardian constants', () => {
it('has 9 guardians defined', async () => {
const { GUARDIANS } = await import('@/lib/game/constants');
expect(Object.keys(GUARDIANS).length).toBe(9);
});
it('guardians are at expected floors', async () => {
const { GUARDIANS } = await import('@/lib/game/constants');
const expectedFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
for (const floor of expectedFloors) {
expect(GUARDIANS[floor]).toBeDefined();
}
});
it('all guardians have required fields', async () => {
const { GUARDIANS } = await import('@/lib/game/constants');
for (const [, def] of Object.entries(GUARDIANS)) {
expect(def.name).toBeTruthy();
expect(def.element).toBeTruthy();
expect(def.hp).toBeGreaterThan(0);
expect(def.color).toBeTruthy();
expect(def.boons).toBeInstanceOf(Array);
expect(def.boons.length).toBeGreaterThan(0);
}
});
it('all guardians have unique elements', async () => {
const { GUARDIANS } = await import('@/lib/game/constants');
const elements = Object.values(GUARDIANS).map((g) => g.element);
const uniqueElements = new Set(elements);
expect(uniqueElements.size).toBe(elements.length);
});
});
// ─── Test: Combat store spire fields ───────────────────────────────────────────
describe('Combat store spire state', () => {
it('useCombatStore is importable', async () => {
const mod = await import('@/lib/game/stores');
expect(mod.useCombatStore).toBeDefined();
expect(typeof mod.useCombatStore).toBe('function');
});
});
// ─── Test: Floor element cycle ─────────────────────────────────────────────────
describe('Floor element cycle', () => {
it('FLOOR_ELEM_CYCLE has 7 elements', async () => {
const { FLOOR_ELEM_CYCLE } = await import('@/lib/game/constants');
expect(FLOOR_ELEM_CYCLE.length).toBe(7);
});
it('element opposites define expected pairs', async () => {
const { ELEMENT_OPPOSITES } = await import('@/lib/game/constants');
// Core pairs are symmetric
expect(ELEMENT_OPPOSITES['fire']).toBe('water');
expect(ELEMENT_OPPOSITES['water']).toBe('fire');
expect(ELEMENT_OPPOSITES['air']).toBe('earth');
expect(ELEMENT_OPPOSITES['earth']).toBe('air');
expect(ELEMENT_OPPOSITES['light']).toBe('dark');
expect(ELEMENT_OPPOSITES['dark']).toBe('light');
// Lightning has an asymmetric opposite (grounding)
expect(ELEMENT_OPPOSITES['lightning']).toBe('earth');
});
});
// ─── Test: File size limit ─────────────────────────────────────────────────────
describe('File size limits (400 lines max)', () => {
it('SpireSummaryTab.tsx is under 400 lines', async () => {
const fs = await import('fs');
const path = await import('path');
const filePath = path.join(__dirname, 'SpireSummaryTab.tsx');
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n').length;
expect(lines).toBeLessThan(400);
});
});
@@ -0,0 +1,395 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
import { GUARDIANS, ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { SectionHeader } from '@/components/ui/section-header';
import { DebugName } from '@/components/game/debug/debug-context';
import { Mountain } from 'lucide-react';
// ─── Guardian Data ────────────────────────────────────────────────────────────
const GUARDIAN_FLOORS = Object.keys(GUARDIANS)
.map(Number)
.sort((a, b) => a - b);
// ─── Helper: Get Counter Element ─────────────────────────────────────────────
function getCounterElement(element: string): string | null {
return ELEMENT_OPPOSITES[element] || null;
}
function getElementColor(element: string): string {
const colors: Record<string, string> = {
fire: '#FF6B35',
water: '#4ECDC4',
air: '#00D4FF',
earth: '#F4A261',
light: '#FFD700',
dark: '#9B59B6',
death: '#778CA3',
void: '#4A235A',
stellar: '#F0E68C',
};
return colors[element] || '#9CA3AF';
}
// ─── Sub-component: Floor Progress Bar ────────────────────────────────────────
function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clearedFloors: Record<number, boolean> }) {
const totalFloors = Math.min(maxFloor, 100);
const clearedSet = new Set(Object.entries(clearedFloors).filter(([, v]) => v).map(([k]) => Number(k)));
const rows: number[][] = [];
for (let i = 0; i < totalFloors; i += 10) {
rows.push(Array.from({ length: 10 }, (_, j) => i + j + 1).filter((f) => f <= totalFloors));
}
return (
<div className="space-y-1">
{rows.reverse().map((row) => (
<div key={row[0]} className="flex gap-1">
{row.map((floor) => {
const isCleared = clearedSet.has(floor);
const isGuardian = !!GUARDIANS[floor];
const isCurrent = floor === maxFloor;
let bgClass = 'bg-gray-800';
if (isCleared) bgClass = 'bg-emerald-600/60';
else if (isCurrent) bgClass = 'bg-amber-600/60';
const borderClass = isGuardian
? 'border-amber-500'
: isCurrent
? 'border-amber-400'
: 'border-gray-700';
return (
<div
key={floor}
className={`w-7 h-7 flex items-center justify-center text-[9px] rounded border ${bgClass} ${borderClass} ${
isGuardian ? 'font-bold' : ''
}`}
title={
GUARDIANS[floor]
? `Floor ${floor}${GUARDIANS[floor].name} (${GUARDIANS[floor].element})`
: `Floor ${floor}${isCleared ? ' (cleared)' : ''}`
}
>
{floor}
</div>
);
})}
</div>
))}
<FloorLegend />
</div>
);
}
function FloorLegend() {
return (
<div className="flex items-center gap-3 mt-2 text-[10px] text-gray-500">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-emerald-600/60 border border-gray-700" />
<span>Cleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-gray-700" />
<span>Uncleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-amber-500" />
<span>Guardian</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-amber-600/60 border border-amber-400" />
<span>Current</span>
</div>
</div>
);
}
// ─── Top Stats Row ───────────────────────────────────────────────────────────
function TopStatsRow({ maxFloorReached, totalFloorsCleared, defeatedCount, insight }: {
maxFloorReached: number;
totalFloorsCleared: number;
defeatedCount: number;
insight: number;
}) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<CardContent className="py-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCell value={maxFloorReached} label="Max Floor Reached" color="text-amber-400" />
<StatCell value={totalFloorsCleared} label="Floors Cleared" color="text-gray-200" />
<StatCell value={defeatedCount} label="Guardians Defeated" color="text-emerald-400" />
<StatCell value={fmt(insight)} label="Insight Earned" color="text-purple-400" />
</div>
</CardContent>
</Card>
);
}
function StatCell({ value, label, color }: { value: number | string; label: string; color: string }) {
return (
<div className="text-center">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
</div>
);
}
// ─── Next Guardian Card ──────────────────────────────────────────────────────
function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: number; nextGuardianData: (typeof GUARDIANS)[number] }) {
const counterElement = getCounterElement(nextGuardianData.element);
const nextFloorElement = FLOOR_ELEM_CYCLE[(nextGuardian - 1) % FLOOR_ELEM_CYCLE.length];
return (
<Card className="bg-gray-900/60 border-amber-800/40">
<SectionHeader
title={`🛡️ Next Guardian — Floor ${nextGuardian}`}
className="text-amber-400"
/>
<CardContent className="pt-0 space-y-3">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-lg font-bold"
style={{
backgroundColor: `${nextGuardianData.color}20`,
border: `2px solid ${nextGuardianData.color}`,
color: nextGuardianData.color,
}}
>
{nextGuardian}
</div>
<div>
<div className="font-semibold text-gray-100">{nextGuardianData.name}</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: getElementColor(nextGuardianData.element), color: getElementColor(nextGuardianData.element) }}
>
{nextGuardianData.element}
</Badge>
<span className="text-xs text-gray-500">HP: {fmt(nextGuardianData.hp)}</span>
{nextGuardianData.armor && (
<span className="text-xs text-gray-500">
Armor: {Math.round(nextGuardianData.armor * 100)}%
</span>
)}
</div>
</div>
</div>
<PreparationTips
counterElement={counterElement}
nextFloorElement={nextFloorElement}
hasHighArmor={!!(nextGuardianData.armor && nextGuardianData.armor > 0.15)}
/>
</CardContent>
</Card>
);
}
function PreparationTips({ counterElement, nextFloorElement, hasHighArmor }: { counterElement: string | null; nextFloorElement: string | null; hasHighArmor: boolean }) {
return (
<div className="bg-gray-800/50 rounded-lg p-3 space-y-2">
<div className="text-xs font-medium text-gray-300">Recommended Preparation:</div>
<div className="text-xs text-gray-400 space-y-1">
{counterElement && (
<div className="flex items-center gap-2">
<span className="text-emerald-400"></span>
<span>
Use <span style={{ color: getElementColor(counterElement) }} className="font-medium">{counterElement}</span> spells for super effective damage (+50%)
</span>
</div>
)}
{nextFloorElement && (
<div className="flex items-center gap-2">
<span className="text-blue-400">🔄</span>
<span>
Floor element: <span style={{ color: getElementColor(nextFloorElement) }} className="font-medium">{nextFloorElement}</span>
</span>
</div>
)}
{hasHighArmor && (
<div className="flex items-center gap-2">
<span className="text-red-400">🛡</span>
<span>High armor consider armor-piercing or raw damage spells</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-amber-400">💡</span>
<span>Ensure mana pools are full before attempting</span>
</div>
</div>
</div>
);
}
// ─── Guardian Roster ─────────────────────────────────────────────────────────
function GuardianRoster({ clearedFloors }: { clearedFloors: Record<number, boolean> }) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🏛️ Guardian Roster" />
<CardContent className="pt-0">
<div className="space-y-2">
{GUARDIAN_FLOORS.map((floor) => (
<GuardianRosterItem key={floor} floor={floor} guardian={GUARDIANS[floor]} isDefeated={!!clearedFloors[floor]} />
))}
</div>
</CardContent>
</Card>
);
}
function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; guardian: (typeof GUARDIANS)[number]; isDefeated: boolean }) {
return (
<div
className={`flex items-center justify-between p-2 rounded border ${
isDefeated
? 'bg-emerald-900/20 border-emerald-800/40'
: 'bg-gray-800/40 border-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: isDefeated ? `${guardian.color}30` : '#374151',
color: isDefeated ? guardian.color : '#6B7280',
}}
>
{floor}
</div>
<div>
<div className={`text-sm font-medium ${isDefeated ? 'text-gray-100' : 'text-gray-400'}`}>
{guardian.name}
</div>
<div className="flex items-center gap-1.5">
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${guardian.color}15`,
color: guardian.color,
}}
>
{guardian.element}
</span>
<span className="text-[10px] text-gray-500">HP: {fmt(guardian.hp)}</span>
</div>
</div>
</div>
<div>
{isDefeated ? (
<Badge variant="outline" className="border-emerald-600 text-emerald-400 text-xs">
Defeated
</Badge>
) : (
<Badge variant="outline" className="border-gray-600 text-gray-500 text-xs">
Undefeated
</Badge>
)}
</div>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function SpireSummaryTab() {
const [mounted, setMounted] = useState(false);
const {
currentFloor,
maxFloorReached,
clearedFloors,
enterSpireMode,
} = useCombatStore(useShallow((s) => ({
currentFloor: s.currentFloor,
maxFloorReached: s.maxFloorReached,
clearedFloors: s.clearedFloors,
enterSpireMode: s.enterSpireMode,
})));
const { insight } = usePrestigeStore(useShallow((s) => ({
insight: s.insight,
})));
useEffect(() => {
setMounted(true);
}, []);
const defeatedGuardians = useMemo(() => {
return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]);
}, [clearedFloors]);
const nextGuardian = useMemo(() => {
return GUARDIAN_FLOORS.find((floor) => !clearedFloors[floor]) || null;
}, [clearedFloors]);
const nextGuardianData = nextGuardian ? GUARDIANS[nextGuardian] : null;
const totalFloorsCleared = useMemo(() => {
return Object.values(clearedFloors).filter(Boolean).length;
}, [clearedFloors]);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading spire data
</div>
);
}
return (
<DebugName name="SpireSummaryTab">
<div className="space-y-4">
<TopStatsRow
maxFloorReached={maxFloorReached}
totalFloorsCleared={totalFloorsCleared}
defeatedCount={defeatedGuardians.length}
insight={insight}
/>
<DebugName name="ClimbSpireButton">
<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>
</DebugName>
{nextGuardianData && nextGuardian && (
<NextGuardianCard nextGuardian={nextGuardian} nextGuardianData={nextGuardianData} />
)}
<GuardianRoster clearedFloors={clearedFloors} />
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🗺️ Floor Progress" />
<CardContent className="pt-0">
<ScrollArea className="h-[300px] pr-2">
<FloorProgressBar maxFloor={maxFloorReached} clearedFloors={clearedFloors} />
</ScrollArea>
</CardContent>
</Card>
</div>
</DebugName>
);
}
SpireSummaryTab.displayName = 'SpireSummaryTab';
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { usePrestigeStore, fmt, fmtDec } from '@/lib/game/stores';
import { ELEMENTS } from '@/lib/game/constants';
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 { LoopStatsSection } from './StatsTab/LoopStatsSection';
export function StatsTab() {
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
const manaStats = useManaStats();
const combatStats = useCombatStats();
const studyStats = useStudyStats();
// Compute element max (base + prestige only)
const elemMax = 10 + (prestigeUpgrades.elementalAttune || 0) * 25;
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,
incursionStrength: manaStats.incursionStrength,
rawMana: manaStats.maxMana,
hasSteadyStream: manaStats.hasSteadyStream,
hasManaTorrent: manaStats.hasManaTorrent,
hasDesperateWells: manaStats.hasDesperateWells,
manaCascadeBonus: manaStats.manaCascadeBonus,
manaWaterfallBonus: manaStats.manaWaterfallBonus,
hasFlowSurge: manaStats.hasFlowSurge,
hasManaOverflow: manaStats.hasManaOverflow,
hasEternalFlow: manaStats.hasEternalFlow,
}}
elemMax={elemMax}
/>
<CombatStatsSection
activeSpellDef={combatStats.activeSpellDef}
pactMultiplier={combatStats.pactMultiplier}
/>
<PactStatusSection
pactMultiplier={combatStats.pactMultiplier}
pactInsightMultiplier={combatStats.pactInsightMultiplier}
/>
<StudyStatsSection
studySpeedMult={studyStats.studySpeedMult}
studyCostMult={studyStats.studyCostMult}
/>
<ElementStatsSection
elemMax={elemMax}
/>
<LoopStatsSection />
</div>
);
}
StatsTab.displayName = "StatsTab";
@@ -0,0 +1,48 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Swords } from 'lucide-react';
import { fmt, fmtDec } from '@/lib/game/stores';
import type { SpellDef } from '@/lib/game/types';
interface CombatStatsSectionProps {
activeSpellDef: SpellDef | null;
pactMultiplier: number;
}
export function CombatStatsSection({ activeSpellDef, pactMultiplier }: CombatStatsSectionProps) {
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)' }}>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 className="space-y-2">
<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>
</div>
</CardContent>
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More