Compare commits
190 Commits
23d0a129c1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 49f8de01ca | |||
| 8a7ddaae27 | |||
| ee893e8973 | |||
| ce084a61a3 | |||
| 53b3a94725 | |||
| 742a992d59 | |||
| df316c2865 | |||
| a49b8a8bef | |||
| cba42e01ff | |||
| 56ac50f465 | |||
| 7d56fc368f | |||
| 1c7fc8c551 | |||
| 9882578627 | |||
| 1cda85929d | |||
| 0b6ee15e9b | |||
| dbc1b5e02c | |||
| 1cd612193d | |||
| 5643a4c145 | |||
| 2c4dc82aad | |||
| 639d396f80 | |||
| 50a9a62060 | |||
| ebcaab62bf | |||
| 213425e6c9 | |||
| e259484b53 | |||
| 3dcd967949 | |||
| 48a5ad1855 | |||
| c3a5f333da | |||
| a9918e83a6 | |||
| 594eec1ab4 | |||
| 4f932b6810 | |||
| ff3a268358 | |||
| 92238e4dd8 | |||
| afbdb71548 | |||
| 14ba02d987 | |||
| 084fea2a25 | |||
| ea3035ec5e | |||
| ca86b6268c | |||
| 2805f75f5e | |||
| 20c2ebd7b5 | |||
| 67bd5b4a86 | |||
| 43856acd1e | |||
| 28d1a672da | |||
| 00650c82fd | |||
| 9b45010617 | |||
| f0601f7622 | |||
| a632b7c6af | |||
| 888aa5283d | |||
| e462bfcc13 | |||
| c8341f79f3 | |||
| fe0f2a079c | |||
| 1a688394e4 | |||
| 5cbe672b8f | |||
| 3e5b634815 | |||
| ba231ac9dd | |||
| 07b311bd7a | |||
| bb268d4dea | |||
| 6ad48efff9 | |||
| e437269adb | |||
| b0eea7dadd | |||
| 70ec32bd4e | |||
| e8b8fc26c7 | |||
| 8665e903bd | |||
| 47b2a0bdc7 | |||
| f6bf049f91 | |||
| ae0bf3e38d | |||
| cad72fe88c | |||
| d1c90cd544 | |||
| d496dd241b | |||
| c7f024f2e3 | |||
| 4eeb258d30 | |||
| 2130d30133 | |||
| e4fb66df9f | |||
| c6d3e0d7bc | |||
| 71fbc7c964 | |||
| 0fadbfef4a | |||
| 58aa74486e | |||
| be918d1bab | |||
| 482320b519 | |||
| 32a86c3e62 | |||
| 7851d8c7cb | |||
| 54d5e576ab | |||
| 81ad79dd95 | |||
| a4004be229 | |||
| e5308ac239 | |||
| b7a91abc5d | |||
| 8b4a09a8c6 | |||
| 496d3dde4c | |||
| 17b3571a18 | |||
| a5ff32cb91 | |||
| e9485b93aa | |||
| 930d5b9e29 | |||
| fe2d1f6bc6 | |||
| b0cc848909 | |||
| ed69a8f2b4 | |||
| ed616738fd | |||
| f0532c1673 | |||
| 3db7e07302 | |||
| 221d3e4b41 | |||
| dc1aad3700 | |||
| 235bc09856 | |||
| 2c30d98096 | |||
| 587be05452 | |||
| d0738441f3 | |||
| 338ac19628 | |||
| bb8edaf57a | |||
| 837d963b63 | |||
| 0eabd604b0 | |||
| 98ab975fb9 | |||
| 5817206351 | |||
| df67abca50 | |||
| fef57d7a55 | |||
| ca07719456 | |||
| f1499046b5 | |||
| 129f7876c1 | |||
| 40d310b55a | |||
| d5cbc9faff | |||
| d2d28887b1 | |||
| c9ae2576f4 | |||
| dc38445225 | |||
| f0ab3ca3ce | |||
| 86683fe288 | |||
| 6c4ebd8b8e | |||
| 03815f27ee | |||
| 3691aa4acc | |||
| 454195cdfb | |||
| 88d6016557 | |||
| 1e5eae9b9d | |||
| c8a01acda3 | |||
| 351b6c2dca | |||
| 6f0b86d4d7 | |||
| b0a254b481 | |||
| 984459200b | |||
| 8aacc2c88e | |||
| 7056dc04d6 | |||
| 47c71e6f54 | |||
| 3c29c1c834 | |||
| 111e0211cb | |||
| 4f03544eaf | |||
| 712d587ad5 | |||
| a69ea7575e | |||
| 33c5a49577 | |||
| eeb1e3c784 | |||
| 749321d2cb | |||
| 3bcf20636c | |||
| ebb9d15e9e | |||
| 8261baab54 | |||
| beafa82789 | |||
| 35c69809a1 | |||
| 900c0e8fe9 | |||
| 2696f76069 | |||
| f31b98b378 | |||
| 06778f96b3 | |||
| 5f1f72d892 | |||
| 64b5e8578d | |||
| f37e76166d | |||
| f59be77e50 | |||
| 326dd43b34 | |||
| 313aa334a9 | |||
| b10d92b7ae | |||
| 563e41dbe3 | |||
| c2dd846f63 | |||
| c8baea4346 | |||
| 9bf6e911f4 | |||
| 50ce70efdd | |||
| 9f029d93e1 | |||
| 229cb16c5d | |||
| fc9e4c8327 | |||
| 419371885a | |||
| a6ce36b6e0 | |||
| f61ed00ac1 | |||
| 2355be66e9 | |||
| 5e0bee8820 | |||
| 7c05bea896 | |||
| 65b0f961e5 | |||
| e9bf5a99f8 | |||
| ad4b1a545e | |||
| 063b37dd45 | |||
| d9058bafec | |||
| f2d046c9e2 | |||
| 77f181b4a1 | |||
| 8d1d328c3f | |||
| 0f0b800e60 | |||
| c8cabf3e4b | |||
| edfc6f11c0 | |||
| 75a43c7209 | |||
| 132a4e6a72 | |||
| 6e3b867e7d | |||
| 7d1bfbe4dc | |||
| eea5ed1585 | |||
| d6b85d6367 |
@@ -48,4 +48,4 @@ prompt
|
||||
|
||||
server.log
|
||||
# Skills directory
|
||||
/.zscripts/
|
||||
.desloppify/
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
changed_files="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)"
|
||||
|
||||
if echo "$changed_files" | grep --quiet -E "package.json|package-lock.json"; then
|
||||
echo "📦 Dependencies changed. Syncing..."
|
||||
|
||||
# --no-progress stops the terminal spam
|
||||
# --loglevel error ensures we only see the bad stuff
|
||||
if npm install --no-progress --loglevel error; then
|
||||
echo "✅ Node modules are up to date."
|
||||
else
|
||||
echo "❌ npm install failed! Please check your connection or package.json."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "🔍 Running pre-commit checks..."
|
||||
|
||||
# Get staged files (added, copied, modified)
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
|
||||
|
||||
if [ -n "$STAGED_FILES" ]; then
|
||||
echo "📏 Checking file sizes..."
|
||||
node .husky/scripts/check-file-size.js $STAGED_FILES
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Generate project structure
|
||||
echo "🗺️ Updating project structure..."
|
||||
node .husky/scripts/generate-project-tree.js
|
||||
node .husky/scripts/generate-dependency-graph.js
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auto-add the generated project structure to the commit
|
||||
git add docs/project-structure.txt
|
||||
|
||||
echo "✅ All pre-commit checks passed!"
|
||||
@@ -0,0 +1,63 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const MAX_LINES = 400;
|
||||
|
||||
// List of file patterns to ignore (optional, can be customized)
|
||||
const IGNORE_PATTERNS = [
|
||||
/\.lock$/, // Lock files
|
||||
/\.min\.js$/, // Minified files
|
||||
/\.map$/, // Source maps
|
||||
/package-lock\.json$/,
|
||||
/bun\.lock$/,
|
||||
/tsconfig\.tsbuildinfo$/,
|
||||
/\.md$/, // Markdown documentation files
|
||||
/context\.md$/, // Context files for sub-agents
|
||||
/project-structure\.txt$/, // Generated project structure
|
||||
/dependency-graph\.json$/,
|
||||
];
|
||||
|
||||
function shouldIgnore(filePath) {
|
||||
return IGNORE_PATTERNS.some(pattern => pattern.test(filePath));
|
||||
}
|
||||
|
||||
const files = process.argv.slice(2);
|
||||
if (files.length === 0) {
|
||||
console.log('ℹ️ No files to check');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
files.forEach(file => {
|
||||
// Skip ignored patterns
|
||||
if (shouldIgnore(file)) {
|
||||
console.log(`⏭️ Skipping ${file} (ignored pattern)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file exists (it might have been deleted)
|
||||
if (!fs.existsSync(file)) {
|
||||
console.log(`⏭️ Skipping ${file} (file does not exist)`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const lines = content.split('\n').length;
|
||||
|
||||
if (lines > MAX_LINES) {
|
||||
console.error(`❌ ${file} is too large (${lines} lines, max ${MAX_LINES}). AI agents will struggle. Please refactor!`);
|
||||
hasError = true;
|
||||
} else {
|
||||
console.log(`✅ ${file} (${lines} lines) - OK`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`⚠️ Error reading ${file}: ${err.message}`);
|
||||
// Don't fail on read errors, just warn
|
||||
}
|
||||
});
|
||||
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate-dependency-graph.js
|
||||
*
|
||||
* Generates two files in docs/ on every commit:
|
||||
*
|
||||
* docs/dependency-graph.json — full import graph for src/lib/game/
|
||||
* docs/circular-deps.txt — list of circular dependency chains (empty = clean)
|
||||
*
|
||||
* Run manually: node .husky/scripts/generate-dependency-graph.js
|
||||
* Requires: bun add -d madge
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../');
|
||||
const DOCS_DIR = path.join(ROOT, 'docs');
|
||||
const GRAPH_OUT = path.join(DOCS_DIR, 'dependency-graph.json');
|
||||
const CIRCULAR_OUT = path.join(DOCS_DIR, 'circular-deps.txt');
|
||||
|
||||
// Check madge is available
|
||||
function madgeAvailable() {
|
||||
try {
|
||||
execSync('bunx madge --version', { stdio: 'ignore', cwd: ROOT });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function run(cmd) {
|
||||
return execSync(cmd, { cwd: ROOT, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
if (!madgeAvailable()) {
|
||||
console.error('madge not found. Install with: bun add -d madge');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(DOCS_DIR)) {
|
||||
fs.mkdirSync(DOCS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// ── 1. Full dependency graph for the game library ─────────────────────────
|
||||
try {
|
||||
const graphJson = run(
|
||||
'bunx madge --json --extensions ts,tsx --exclude "\\.test\\.|__tests__" src/lib/game'
|
||||
);
|
||||
// Parse and re-serialize with readable formatting
|
||||
const graph = JSON.parse(graphJson);
|
||||
|
||||
// Annotate with metadata for AI agents
|
||||
const output = {
|
||||
_meta: {
|
||||
generated: new Date().toISOString(),
|
||||
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,
|
||||
};
|
||||
|
||||
fs.writeFileSync(GRAPH_OUT, JSON.stringify(output, null, 2));
|
||||
const nodeCount = Object.keys(graph).length;
|
||||
console.log(`✅ Dependency graph: ${nodeCount} modules → docs/dependency-graph.json`);
|
||||
} catch (err) {
|
||||
console.error('Failed to generate dependency graph:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── 2. Circular dependency report ─────────────────────────────────────────
|
||||
try {
|
||||
let circularOutput = '';
|
||||
try {
|
||||
// madge exits with code 1 when circulars are found; capture stdout anyway
|
||||
circularOutput = run(
|
||||
'bunx madge --circular --extensions ts,tsx --exclude "\\.test\\.|__tests__" src/lib/game'
|
||||
);
|
||||
} catch (e) {
|
||||
// exitCode 1 = circulars found; stdout contains the list
|
||||
circularOutput = e.stdout || '';
|
||||
}
|
||||
|
||||
const lines = circularOutput.trim().split('\n').filter(Boolean);
|
||||
// madge circular output starts with "Found N circular dependencies!"
|
||||
const circularLines = lines.filter(
|
||||
(l) => !l.startsWith('Found') && !l.startsWith('✔') && l.trim()
|
||||
);
|
||||
|
||||
let content;
|
||||
if (circularLines.length === 0) {
|
||||
content = `# Circular Dependencies\nGenerated: ${new Date().toISOString()}\n\nNo circular dependencies found. ✅\n`;
|
||||
console.log('✅ No circular dependencies found');
|
||||
} else {
|
||||
content = [
|
||||
`# Circular Dependencies`,
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Found: ${circularLines.length} circular chain(s) — these MUST be fixed before modifying involved files.`,
|
||||
'',
|
||||
...circularLines.map((l, i) => `${i + 1}. ${l.trim()}`),
|
||||
'',
|
||||
'## How to fix',
|
||||
'1. Identify which import in the chain can be extracted to a shared types/utils file.',
|
||||
'2. Move the shared type or function there.',
|
||||
'3. Both files import from the new shared module instead of each other.',
|
||||
'4. Run: bunx madge --circular src/lib/game (should return clean)',
|
||||
].join('\n');
|
||||
console.warn(`⚠️ Found ${circularLines.length} circular dependency chain(s) — see docs/circular-deps.txt`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(CIRCULAR_OUT, content);
|
||||
} catch (err) {
|
||||
console.error('Failed to check circular dependencies:', err.message);
|
||||
// Non-fatal: write a note to the file and continue
|
||||
fs.writeFileSync(CIRCULAR_OUT, `# Circular Dependencies\nError running check: ${err.message}\n`);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('node:child_process');
|
||||
|
||||
// Directory to start from (project root)
|
||||
const ROOT_DIR = process.cwd();
|
||||
// Output file path
|
||||
const OUTPUT_FILE = path.join(ROOT_DIR, 'docs', 'project-structure.txt');
|
||||
|
||||
// Function to check if a path is ignored by git
|
||||
function isGitIgnored(filePath) {
|
||||
try {
|
||||
// git check-ignore -q returns 0 if ignored, 1 if not
|
||||
execSync(`git check-ignore -q "${filePath}"`, {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
return true; // Ignored
|
||||
} catch (e) {
|
||||
return false; // Not ignored
|
||||
}
|
||||
}
|
||||
|
||||
// Function to generate tree structure
|
||||
function generateTree(dir, prefix = '', isRoot = true) {
|
||||
let structure = '';
|
||||
|
||||
// Add root directory name if it's the root
|
||||
if (isRoot) {
|
||||
structure += `${path.basename(dir)}/\n`;
|
||||
}
|
||||
|
||||
let items;
|
||||
try {
|
||||
items = fs.readdirSync(dir);
|
||||
} catch (e) {
|
||||
console.error(`Error reading directory ${dir}: ${e.message}`);
|
||||
return structure;
|
||||
}
|
||||
|
||||
// Sort items: directories first, then files
|
||||
const dirs = [];
|
||||
const files = [];
|
||||
|
||||
items.forEach(item => {
|
||||
const itemPath = path.join(dir, item);
|
||||
|
||||
// Explicitly skip .git directory and husky internal directory
|
||||
if (item === '.git' && dir === ROOT_DIR) {
|
||||
return;
|
||||
}
|
||||
if (item === '_' && path.basename(dir) === '.husky') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if ignored by git
|
||||
if (isGitIgnored(itemPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(itemPath);
|
||||
if (stat.isDirectory()) {
|
||||
dirs.push(item);
|
||||
} else {
|
||||
files.push(item);
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip items we can't stat
|
||||
}
|
||||
});
|
||||
|
||||
// Sort directories and files alphabetically
|
||||
dirs.sort();
|
||||
files.sort();
|
||||
|
||||
const allItems = [...dirs, ...files];
|
||||
|
||||
allItems.forEach((item, index) => {
|
||||
const isLast = index === allItems.length - 1;
|
||||
const connector = isLast ? '└── ' : '├── ';
|
||||
const itemPath = path.join(dir, item);
|
||||
|
||||
structure += `${prefix}${connector}${item}${dirs.includes(item) ? '/' : ''}\n`;
|
||||
|
||||
// Recurse into directories
|
||||
if (dirs.includes(item)) {
|
||||
const newPrefix = prefix + (isLast ? ' ' : '│ ');
|
||||
structure += generateTree(itemPath, newPrefix, false);
|
||||
}
|
||||
});
|
||||
|
||||
return structure;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🗺️ Generating project structure...');
|
||||
|
||||
// Ensure docs directory exists
|
||||
const docsDir = path.join(ROOT_DIR, 'docs');
|
||||
if (!fs.existsSync(docsDir)) {
|
||||
fs.mkdirSync(docsDir, { recursive: true });
|
||||
console.log('📁 Created docs directory');
|
||||
}
|
||||
|
||||
// Generate tree
|
||||
const tree = generateTree(ROOT_DIR, '', true);
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(OUTPUT_FILE, tree);
|
||||
console.log(`✅ Project structure updated: ${OUTPUT_FILE}`);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`❌ Error generating project structure: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,431 +1,96 @@
|
||||
# Mana Loop - Project Architecture Guide
|
||||
# Mana Loop — Agent Guide
|
||||
|
||||
This document provides a comprehensive overview of the project architecture for AI agents working on this codebase.
|
||||
Browser incremental/idle game. Next.js 16 + Zustand, no backend.
|
||||
|
||||
---
|
||||
## 🔑 Git
|
||||
|
||||
## 🔑 Git Credentials (SAVE THESE)
|
||||
|
||||
**Repository:** `git@gitea.tailf367e3.ts.net:Anexim/Mana-Loop.git`
|
||||
|
||||
**HTTPS URL with credentials:**
|
||||
```
|
||||
https://zhipu:5LlnutmdsC2WirDwWgnZuRH7@gitea.tailf367e3.ts.net/Anexim/Mana-Loop.git
|
||||
https://n8n-gitea:tkF9HFgxL2k4cmT@gitea.tailf367e3.ts.net/Anexim/Mana-Loop.git
|
||||
```
|
||||
|
||||
**Credentials:**
|
||||
- **User:** zhipu
|
||||
- **Email:** zhipu@local.local
|
||||
- **Password:** 5LlnutmdsC2WirDwWgnZuRH7
|
||||
|
||||
**To configure git:**
|
||||
```bash
|
||||
git config --global user.name "zhipu"
|
||||
git config --global user.email "zhipu@local.local"
|
||||
git config --global user.name "n8n-gitea"
|
||||
git config --global user.email "n8n-gitea@anexim.local"
|
||||
```
|
||||
|
||||
---
|
||||
## Workflow
|
||||
|
||||
## ⚠️ MANDATORY GIT WORKFLOW - MUST BE FOLLOWED
|
||||
|
||||
**Before starting ANY work, you MUST:**
|
||||
|
||||
1. **Pull the latest changes:**
|
||||
```bash
|
||||
cd /home/z/my-project && git pull origin master
|
||||
```
|
||||
|
||||
2. **Do your task** - Make all necessary code changes
|
||||
|
||||
3. **Before finishing, commit and push:**
|
||||
```bash
|
||||
cd /home/z/my-project
|
||||
git add -A
|
||||
git commit -m "descriptive message about changes"
|
||||
git push origin master
|
||||
```
|
||||
|
||||
**This workflow is ENFORCED and NON-NEGOTIABLE.** Every agent session must:
|
||||
- Start with `git pull`
|
||||
- End with `git add`, `git commit`, `git push`
|
||||
|
||||
**Git Remote:** `git@gitea.tailf367e3.ts.net:Anexim/Mana-Loop.git`
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Mana Loop** is an incremental/idle game built with:
|
||||
- **Framework**: Next.js 16 with App Router
|
||||
- **Language**: TypeScript 5
|
||||
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
||||
- **State Management**: Zustand with persist middleware
|
||||
- **Database**: Prisma ORM with SQLite (for persistence features)
|
||||
|
||||
## Core Game Loop
|
||||
|
||||
1. **Mana Gathering**: Click or auto-generate mana over time
|
||||
2. **Studying**: Spend mana to learn skills and spells
|
||||
3. **Combat**: Climb the Spire, defeat guardians, sign pacts
|
||||
4. **Crafting**: Enchant equipment with spell effects
|
||||
5. **Prestige**: Reset progress for permanent bonuses (Insight)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── page.tsx # Main game UI (~1700 lines, single page application)
|
||||
│ ├── layout.tsx # Root layout with providers
|
||||
│ └── api/ # API routes (minimal use)
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui components (auto-generated)
|
||||
│ └── game/
|
||||
│ ├── index.ts # Barrel exports
|
||||
│ ├── ActionButtons.tsx # Main action buttons (Meditate, Climb, Study, etc.)
|
||||
│ ├── CalendarDisplay.tsx # Day calendar with incursion indicators
|
||||
│ ├── CraftingProgress.tsx # Design/preparation/application progress bars
|
||||
│ ├── StudyProgress.tsx # Current study progress with cancel button
|
||||
│ ├── ManaDisplay.tsx # Mana/gathering section with progress bar
|
||||
│ ├── TimeDisplay.tsx # Day/hour display with pause toggle
|
||||
│ └── tabs/ # Tab-specific components
|
||||
│ ├── index.ts # Tab component exports
|
||||
│ ├── CraftingTab.tsx # Enchantment crafting UI
|
||||
│ ├── LabTab.tsx # Skill upgrade and lab features
|
||||
│ ├── SpellsTab.tsx # Spell management and equipment spells
|
||||
│ └── SpireTab.tsx # Combat and spire climbing
|
||||
└── lib/
|
||||
├── game/
|
||||
│ ├── store.ts # Zustand store (~1650 lines, main state + tick logic)
|
||||
│ ├── computed-stats.ts # Computed stats functions (extracted utilities)
|
||||
│ ├── navigation-slice.ts # Floor navigation actions (setClimbDirection, changeFloor)
|
||||
│ ├── study-slice.ts # Study system actions (startStudying*, cancelStudy)
|
||||
│ ├── crafting-slice.ts # Equipment/enchantment logic
|
||||
│ ├── familiar-slice.ts # Familiar system actions
|
||||
│ ├── effects.ts # Unified effect computation
|
||||
│ ├── upgrade-effects.ts # Skill upgrade effect definitions
|
||||
│ ├── constants.ts # Game definitions (spells, skills, etc.)
|
||||
│ ├── skill-evolution.ts # Skill tier progression paths
|
||||
│ ├── types.ts # TypeScript interfaces
|
||||
│ ├── formatting.ts # Display formatters
|
||||
│ ├── utils.ts # Utility functions
|
||||
│ └── data/
|
||||
│ ├── equipment.ts # Equipment type definitions
|
||||
│ └── enchantment-effects.ts # Enchantment effect catalog
|
||||
└── utils.ts # General utilities (cn function)
|
||||
```bash
|
||||
cd /home/user/repos/Mana-Loop && git pull origin master
|
||||
# ... work ...
|
||||
git add -A && git commit -m "type: desc" && git push origin master
|
||||
```
|
||||
|
||||
## Key Systems
|
||||
## Session Start
|
||||
|
||||
### 1. State Management (`store.ts`)
|
||||
1. `docs/project-structure.txt`
|
||||
2. `docs/dependency-graph.json`
|
||||
3. `get_repo_summary` → resume in-progress or pick top todo
|
||||
4. `update_issue_status` → `ai:in-progress`
|
||||
5. Work, log with `add_comment`, then `update_issue_status` → `ai:done`
|
||||
|
||||
The game uses a Zustand store organized with **slice pattern** for better maintainability:
|
||||
## Labels
|
||||
|
||||
#### Store Slices
|
||||
- **Main Store** (`store.ts`): Core state, tick logic, and main actions
|
||||
- **Navigation Slice** (`navigation-slice.ts`): Floor navigation (setClimbDirection, changeFloor)
|
||||
- **Study Slice** (`study-slice.ts`): Study system (startStudyingSkill, startStudyingSpell, cancelStudy)
|
||||
- **Crafting Slice** (`crafting-slice.ts`): Equipment/enchantment (createEquipmentInstance, startDesigningEnchantment)
|
||||
- **Familiar Slice** (`familiar-slice.ts`): Familiar system (addFamiliar, removeFamiliar)
|
||||
`ai:todo` | `ai:in-progress` | `ai:review` | `ai:blocked` | `ai:done`
|
||||
|
||||
#### Computed Stats (`computed-stats.ts`)
|
||||
Extracted utility functions for stat calculations:
|
||||
- `computeMaxMana()`, `computeRegen()`, `computeEffectiveRegen()`
|
||||
- `calcDamage()`, `calcInsight()`, `getElementalBonus()`
|
||||
- `getFloorMaxHP()`, `getFloorElement()`, `getMeditationBonus()`
|
||||
- `canAffordSpellCost()`, `deductSpellCost()`
|
||||
## Terminal Tool
|
||||
|
||||
```typescript
|
||||
interface GameState {
|
||||
// Time
|
||||
day: number;
|
||||
hour: number;
|
||||
paused: boolean;
|
||||
|
||||
// Mana
|
||||
rawMana: number;
|
||||
elements: Record<string, ElementState>;
|
||||
|
||||
// Combat
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
activeSpell: string;
|
||||
castProgress: number;
|
||||
|
||||
// Progression
|
||||
skills: Record<string, number>;
|
||||
spells: Record<string, SpellState>;
|
||||
skillUpgrades: Record<string, string[]>;
|
||||
skillTiers: Record<string, number>;
|
||||
|
||||
// Equipment
|
||||
equipmentInstances: Record<string, EquipmentInstance>;
|
||||
equippedInstances: Record<string, string | null>;
|
||||
enchantmentDesigns: EnchantmentDesign[];
|
||||
|
||||
// Prestige
|
||||
insight: number;
|
||||
prestigeUpgrades: Record<string, number>;
|
||||
signedPacts: number[];
|
||||
}
|
||||
Always pair `run_command` → `get_process_status` in same turn. Use `wait: 120` for long tasks.
|
||||
|
||||
## Sub-Agents
|
||||
|
||||
Use for 3+ sequential independent calls. Zero context from parent — paste everything needed.
|
||||
|
||||
## 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,discipline,ui}Store.ts`
|
||||
- **Legacy (migrating):** `src/lib/game/store/` and `store-modules/`
|
||||
- **Crafting:** 3-step flow — Design → Prepare → Apply via `crafting-actions/`
|
||||
- **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 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)
|
||||
```
|
||||
|
||||
### 2. Effect System (`effects.ts`)
|
||||
|
||||
**CRITICAL**: All stat modifications flow through the unified effect system.
|
||||
|
||||
```typescript
|
||||
// Effects come from two sources:
|
||||
// 1. Skill Upgrades (milestone bonuses)
|
||||
// 2. Equipment Enchantments (crafted bonuses)
|
||||
|
||||
getUnifiedEffects(state) => UnifiedEffects {
|
||||
maxManaBonus, maxManaMultiplier,
|
||||
regenBonus, regenMultiplier,
|
||||
clickManaBonus, clickManaMultiplier,
|
||||
baseDamageBonus, baseDamageMultiplier,
|
||||
attackSpeedMultiplier,
|
||||
critChanceBonus, critDamageMultiplier,
|
||||
studySpeedMultiplier,
|
||||
specials: Set<string>, // Special effect IDs
|
||||
}
|
||||
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)
|
||||
|
||||
**When adding new stats**:
|
||||
1. Add to `ComputedEffects` interface in `upgrade-effects.ts`
|
||||
2. Add mapping in `computeEquipmentEffects()` in `effects.ts`
|
||||
3. Apply in the relevant game logic (tick, damage calc, etc.)
|
||||
### Adding Spells
|
||||
1. `constants/spells.ts`
|
||||
2. `data/enchantment-effects.ts`
|
||||
3. `EFFECT_RESEARCH_MAPPING`
|
||||
|
||||
### 3. Combat System
|
||||
## Banned
|
||||
|
||||
Combat uses a **cast speed** system:
|
||||
- Each spell has `castSpeed` (casts per hour)
|
||||
- Cast progress accumulates: `progress += castSpeed * attackSpeedMultiplier * HOURS_PER_TICK`
|
||||
- When `progress >= 1`, spell is cast (cost deducted, damage dealt)
|
||||
- DPS = `damagePerCast * castsPerSecond`
|
||||
Lifesteal/healing, scroll crafting, ascension skills, LabTab, pause, mana types: `life`, `blood`, `wood`, `mental`, `force`
|
||||
|
||||
Damage calculation order:
|
||||
1. Base spell damage
|
||||
2. Skill bonuses (combatTrain, arcaneFury, etc.)
|
||||
3. Upgrade effects (multipliers, bonuses)
|
||||
4. Special effects (Overpower, Berserker, etc.)
|
||||
5. Elemental modifiers (same element +25%, super effective +50%)
|
||||
## File Limit
|
||||
|
||||
### 4. Crafting/Enchantment System
|
||||
400 lines max (pre-commit hook enforces).
|
||||
|
||||
Three-stage process:
|
||||
1. **Design**: Select effects, takes time based on complexity
|
||||
2. **Prepare**: Pay mana to prepare equipment, takes time
|
||||
3. **Apply**: Apply design to equipment, costs mana per hour
|
||||
## Mana Types
|
||||
|
||||
Equipment has **capacity** that limits total enchantment power.
|
||||
|
||||
### 5. Skill Evolution System
|
||||
|
||||
Skills have 5 tiers of evolution:
|
||||
- At level 5: Choose 2 of 4 milestone upgrades
|
||||
- At level 10: Choose 2 more upgrades, then tier up
|
||||
- Each tier multiplies the skill's base effect by 10x
|
||||
|
||||
## Important Patterns
|
||||
|
||||
### Adding a New Effect
|
||||
|
||||
1. **Define in `enchantment-effects.ts`**:
|
||||
```typescript
|
||||
my_new_effect: {
|
||||
id: 'my_new_effect',
|
||||
name: 'Effect Name',
|
||||
description: '+10% something',
|
||||
category: 'combat',
|
||||
baseCapacityCost: 30,
|
||||
maxStacks: 3,
|
||||
allowedEquipmentCategories: ['caster', 'hands'],
|
||||
effect: { type: 'multiplier', stat: 'attackSpeed', value: 1.10 }
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add stat mapping in `effects.ts`** (if new stat):
|
||||
```typescript
|
||||
// In computeEquipmentEffects()
|
||||
if (effect.stat === 'myNewStat') {
|
||||
bonuses.myNewStat = (bonuses.myNewStat || 0) + effect.value;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Apply in game logic**:
|
||||
```typescript
|
||||
const effects = getUnifiedEffects(state);
|
||||
damage *= effects.myNewStatMultiplier;
|
||||
```
|
||||
|
||||
### Adding a New Skill
|
||||
|
||||
1. **Define in `constants.ts` SKILLS_DEF**
|
||||
2. **Add evolution path in `skill-evolution.ts`**
|
||||
3. **Add prerequisite checks in `store.ts`**
|
||||
4. **Update UI in `page.tsx`**
|
||||
|
||||
### Adding a New Spell
|
||||
|
||||
1. **Define in `constants.ts` SPELLS_DEF**
|
||||
2. **Add spell enchantment in `enchantment-effects.ts`**
|
||||
3. **Add research skill in `constants.ts`**
|
||||
4. **Map research to effect in `EFFECT_RESEARCH_MAPPING`**
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Forgetting to call `getUnifiedEffects()`**: Always use unified effects for stat calculations
|
||||
2. **Direct stat modification**: Never modify stats directly; use effect system
|
||||
3. **Missing tier multiplier**: Use `getTierMultiplier(skillId)` for tiered skills
|
||||
4. **Ignoring special effects**: Check `hasSpecial(effects, SPECIAL_EFFECTS.X)` for special abilities
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Run `bun run lint` after changes
|
||||
- Check dev server logs at `/home/z/my-project/dev.log`
|
||||
- Test with fresh game state (clear localStorage)
|
||||
|
||||
## Slice Pattern for Store Organization
|
||||
|
||||
The store uses a **slice pattern** to organize related actions into separate files. This improves maintainability and makes the codebase more modular.
|
||||
|
||||
### Creating a New Slice
|
||||
|
||||
1. **Create the slice file** (e.g., `my-feature-slice.ts`):
|
||||
```typescript
|
||||
// Define the actions interface
|
||||
export interface MyFeatureActions {
|
||||
doSomething: (param: string) => void;
|
||||
undoSomething: () => void;
|
||||
}
|
||||
|
||||
// Create the slice factory
|
||||
export function createMyFeatureSlice(
|
||||
set: StoreApi<GameStore>['setState'],
|
||||
get: StoreApi<GameStore>['getState']
|
||||
): MyFeatureActions {
|
||||
return {
|
||||
doSomething: (param: string) => {
|
||||
set((state) => {
|
||||
// Update state
|
||||
});
|
||||
},
|
||||
undoSomething: () => {
|
||||
set((state) => {
|
||||
// Update state
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add to main store** (`store.ts`):
|
||||
```typescript
|
||||
import { createMyFeatureSlice, MyFeatureActions } from './my-feature-slice';
|
||||
|
||||
// Extend GameStore interface
|
||||
interface GameStore extends GameState, MyFeatureActions, /* other slices */ {}
|
||||
|
||||
// Spread into store creation
|
||||
const useGameStore = create<GameStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...createMyFeatureSlice(set, get),
|
||||
// other slices and state
|
||||
}),
|
||||
// persist config
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Existing Slices
|
||||
|
||||
| Slice | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| Navigation | `navigation-slice.ts` | Floor navigation (setClimbDirection, changeFloor) |
|
||||
| Study | `study-slice.ts` | Study system (startStudyingSkill, startStudyingSpell, cancelStudy) |
|
||||
| Crafting | `crafting-slice.ts` | Equipment/enchantment (createEquipmentInstance, startDesigningEnchantment) |
|
||||
| Familiar | `familiar-slice.ts` | Familiar system (addFamiliar, removeFamiliar) |
|
||||
|
||||
## File Size Guidelines
|
||||
|
||||
### Current File Sizes (After Refactoring)
|
||||
| File | Lines | Notes |
|
||||
|------|-------|-------|
|
||||
| `store.ts` | ~1650 | Core state + tick logic (reduced from 2138, 23% reduction) |
|
||||
| `page.tsx` | ~1695 | Main UI (reduced from 2554, 34% reduction) |
|
||||
| `computed-stats.ts` | ~200 | Extracted utility functions |
|
||||
| `navigation-slice.ts` | ~50 | Navigation actions |
|
||||
| `study-slice.ts` | ~100 | Study system actions |
|
||||
|
||||
### Guidelines
|
||||
- Keep `page.tsx` under 2000 lines by extracting to components (ActionButtons, ManaDisplay, etc.)
|
||||
- Keep `store.ts` under 1800 lines by extracting to slices (navigation, study, crafting, familiar)
|
||||
- Extract computed stats and utility functions to `computed-stats.ts` when >50 lines
|
||||
- Use barrel exports (`index.ts`) for clean imports
|
||||
- Follow the slice pattern for store organization (see below)
|
||||
|
||||
---
|
||||
|
||||
## 🚫 BANNED CONTENT - NEVER ADD THESE
|
||||
|
||||
### Lifesteal and Healing are BANNED
|
||||
**DO NOT add lifesteal or healing mechanics to player abilities.**
|
||||
|
||||
This includes:
|
||||
- `lifesteal` spell effects
|
||||
- `heal` or `regeneration` abilities for the player
|
||||
- Any mechanic that restores player HP or mana based on damage dealt
|
||||
- Life-stealing weapons or enchantments
|
||||
|
||||
**Rationale**: The game's core design is that the player cannot take damage - only floors can. Healing/lifesteal mechanics are unnecessary and would create confusing gameplay.
|
||||
|
||||
### Banned Mana Types
|
||||
The following mana types have been **removed** and should **never be re-added**:
|
||||
- `life` - Healing/lifesteal themed (banned)
|
||||
- `blood` - Life + Water compound (banned due to lifesteal theme)
|
||||
- `wood` - Life + Earth compound (banned due to life connection)
|
||||
- `mental` - Mind/psionic themed (removed for design consistency)
|
||||
- `force` - Telekinetic themed (removed for design consistency)
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Mana Types Overview
|
||||
|
||||
### Base Mana Types (7)
|
||||
| Element | Symbol | Color | Theme |
|
||||
|---------|--------|-------|-------|
|
||||
| Fire | 🔥 | #FF6B35 | Destruction, burn damage |
|
||||
| Water | 💧 | #4ECDC4 | Flow, freeze effects |
|
||||
| Air | 🌬️ | #00D4FF | Speed, wind damage |
|
||||
| Earth | ⛰️ | #F4A261 | Stability, armor pierce |
|
||||
| Light | ☀️ | #FFD700 | Radiance, holy damage |
|
||||
| Dark | 🌑 | #9B59B6 | Shadows, void damage |
|
||||
| Death | 💀 | #778CA3 | Decay, rot damage |
|
||||
|
||||
### Utility Mana Types (1)
|
||||
| Element | Symbol | Color | Theme |
|
||||
|---------|--------|-------|-------|
|
||||
| Transference | 🔗 | #1ABC9C | Mana transfer, Enchanter attunement |
|
||||
|
||||
### Compound Mana Types (3)
|
||||
| Element | Recipe | Theme |
|
||||
|---------|--------|-------|
|
||||
| Metal | Fire + Earth | Armor piercing, forged weapons |
|
||||
| Sand | Earth + Water | AOE damage, desert winds |
|
||||
| Lightning | Fire + Air | Fast damage, armor pierce, chain effects |
|
||||
|
||||
### Exotic Mana Types (3)
|
||||
| Element | Recipe | Theme |
|
||||
|---------|--------|-------|
|
||||
| Crystal | Sand + Sand + Light | Prismatic, high damage |
|
||||
| Stellar | Fire + Fire + Light | Cosmic, ultimate fire/light |
|
||||
| Void | Dark + Dark + Death | Oblivion, ultimate dark/death |
|
||||
|
||||
### Mana Type Hierarchy
|
||||
```
|
||||
Base Elements (7) → Compound (3) → Exotic (3)
|
||||
↓
|
||||
Utility (1) ← Special attunement-based
|
||||
```
|
||||
**Base (7):** Fire 🔥 Water 💧 Air 🌬️ Earth ⛰️ Light ☀️ Dark 🌑 Death 💀
|
||||
**Utility (1):** Transference 🔗
|
||||
**Compound (3):** Fire+Earth=Metal, Earth+Water=Sand, Fire+Air=Lightning
|
||||
**Exotic (3):** Sand+Sand+Light=Crystal, Fire+Fire+Light=Stellar, Dark+Dark+Death=Void
|
||||
@@ -1,66 +1,20 @@
|
||||
# Mana Loop - Next.js Game Docker Image
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --no-cache libc6-compat openssl
|
||||
|
||||
# Install bun
|
||||
RUN npm install -g bun
|
||||
|
||||
# Copy package files first for better caching
|
||||
COPY package.json bun.lockb* ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json bun.lock* bun.lockb* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the application
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Set environment variables for build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
ENV DATABASE_URL="file:./dev.db"
|
||||
|
||||
# Generate Prisma client
|
||||
RUN bunx prisma generate --schema=./prisma/schema.prisma
|
||||
|
||||
# Build the application
|
||||
RUN bun run build
|
||||
|
||||
# Production image
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install openssl for Prisma
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV DATABASE_URL="file:./data/dev.db"
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy necessary files from builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
# Expose port
|
||||
RUN bun run build
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000 || exit 1
|
||||
|
||||
# Start the server (running as root)
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["bun", "run", "start"]
|
||||
@@ -1,141 +1,126 @@
|
||||
# Mana Loop
|
||||
|
||||
An incremental/idle game about climbing a magical spire, mastering skills, and uncovering the secrets of an ancient tower.
|
||||
<p align="center">
|
||||
<img src="public/logo.svg" alt="Mana Loop Logo" width="200" />
|
||||
<br />
|
||||
<em>An incremental/idle game about climbing a magical spire, mastering skills, and uncovering ancient secrets.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://gitea.tailf367e3.ts.net/Anexim/Mana-Loop">Repository</a> ·
|
||||
<a href="#getting-started">Getting Started</a> ·
|
||||
<a href="#game-systems">Game Systems</a> ·
|
||||
<a href="#contributing">Contributing</a> ·
|
||||
<a href="#deployment">Deployment</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.2.0-blue" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
|
||||
<img src="https://img.shields.io/badge/Next.js-16.1.1-black" alt="Next.js" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-5-blue" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/React-19-61DAFB" alt="React" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Game Systems](#game-systems)
|
||||
- [Deployment](#deployment)
|
||||
- [Contributing](#contributing)
|
||||
- [Banned Content](#banned-content)
|
||||
- [License](#license)
|
||||
- [Acknowledgments](#acknowledgments)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Mana Loop** is a browser-based incremental game where players gather mana, study skills and spells, climb the floors of a mysterious spire, and craft enchanted equipment. The game features a prestige system (Insight) that provides permanent progression bonuses across playthroughs.
|
||||
**Mana Loop** is a browser-based incremental/idle game where players gather mana, master skills, climb a mysterious 100-floor spire, craft enchanted equipment, and summon magical golems. The game features a unique time-loop prestige system (Insight) that provides permanent progression bonuses across playthroughs.
|
||||
|
||||
### The Game Loop
|
||||
### Core Game Loop
|
||||
|
||||
1. **Gather Mana** - Click to collect mana or let it regenerate automatically
|
||||
2. **Study Skills & Spells** - Spend mana to learn new abilities and unlock upgrades
|
||||
3. **Climb the Spire** - Battle through floors, defeat guardians, and sign pacts for power
|
||||
4. **Craft Equipment** - Enchant your gear with powerful effects
|
||||
5. **Prestige** - Reset for Insight, gaining permanent bonuses
|
||||
1. **Gather Mana** - Click to collect mana or let it regenerate automatically (14 total mana types)
|
||||
2. **Study Skills & Spells** - 20+ skills with 5-tier evolution system and milestone upgrades
|
||||
3. **Climb the Spire** - Battle through 100 procedurally-generated floors, defeat guardians, sign pacts
|
||||
4. **Craft & Enchant** - 3-stage equipment enchantment system with capacity limits
|
||||
5. **Summon Golems** - Magical constructs that fight alongside you (4 base + 6 hybrid types)
|
||||
6. **Prestige (Loop)** - Reset progress for Insight currency, gain permanent bonuses
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Mana Gathering & Management
|
||||
- Click-based mana collection with automatic regeneration
|
||||
- Elemental mana system with multiple elements
|
||||
- Mana conversion between raw and elemental forms
|
||||
- Meditation system for boosted regeneration
|
||||
- Compound mana types created from base elements
|
||||
- Exotic mana types for ultimate power
|
||||
### 🔮 Mana System
|
||||
- **14 Mana Types**: 7 base elements + 1 utility + 3 compound + 3 exotic
|
||||
- Elemental conversion, regeneration mechanics, and meditation bonuses
|
||||
- Mana types: Fire, Water, Air, Earth, Light, Dark, Death (base), Transference (utility), Metal, Sand, Lightning (compound), Crystal, Stellar, Void (exotic)
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Mana Types
|
||||
|
||||
Mana is the core resource of Mana Loop. There are four categories of mana types:
|
||||
|
||||
### Base Mana Types (7)
|
||||
| Element | Symbol | Color | Theme |
|
||||
|---------|--------|-------|-------|
|
||||
| Fire | 🔥 | #FF6B35 | Destruction, burn damage |
|
||||
| Water | 💧 | #4ECDC4 | Flow, freeze effects |
|
||||
| Air | 🌬️ | #00D4FF | Speed, wind damage |
|
||||
| Earth | ⛰️ | #F4A261 | Stability, armor pierce |
|
||||
| Light | ☀️ | #FFD700 | Radiance, holy damage |
|
||||
| Dark | 🌑 | #9B59B6 | Shadows, void damage |
|
||||
| Death | 💀 | #778CA3 | Decay, rot damage |
|
||||
|
||||
### Utility Mana Types (1)
|
||||
| Element | Symbol | Color | Theme |
|
||||
|---------|--------|-------|-------|
|
||||
| Transference | 🔗 | #1ABC9C | Mana transfer, Enchanter attunement |
|
||||
|
||||
### Compound Mana Types (3)
|
||||
Created by combining two base elements:
|
||||
| Element | Recipe | Theme |
|
||||
|---------|--------|-------|
|
||||
| Metal | Fire + Earth | Armor piercing, forged weapons |
|
||||
| Sand | Earth + Water | AOE damage, desert winds |
|
||||
| Lightning | Fire + Air | Fast damage, armor pierce, chain effects |
|
||||
|
||||
### Exotic Mana Types (3)
|
||||
Created from advanced recipes:
|
||||
| Element | Recipe | Theme |
|
||||
|---------|--------|-------|
|
||||
| Crystal | Sand + Sand + Light | Prismatic, high damage |
|
||||
| Stellar | Fire + Fire + Light | Cosmic, ultimate fire/light |
|
||||
| Void | Dark + Dark + Death | Oblivion, ultimate dark/death |
|
||||
|
||||
### Mana Type Hierarchy
|
||||
```
|
||||
Base Elements (7)
|
||||
↓
|
||||
Compound (3) Utility (1)
|
||||
↓
|
||||
Exotic (3)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Skill Progression with Tier Evolution
|
||||
### 📜 Skill & Spell System
|
||||
- 20+ skills across multiple categories (mana, study, enchanting, golemancy)
|
||||
- 5-tier evolution system for each skill
|
||||
- Milestone upgrades at levels 5 and 10 for each tier
|
||||
- Milestone upgrades at levels 5 and 10 per tier
|
||||
- Unique special effects unlocked through skill upgrades
|
||||
|
||||
### Equipment Crafting & Enchanting
|
||||
- 3-stage enchantment process (Design → Prepare → Apply)
|
||||
- Equipment capacity system limiting total enchantment power
|
||||
- Enchantment effects including stat bonuses, multipliers, and spell grants
|
||||
- Disenchanting to recover mana from unwanted enchantments (only in Prepare stage)
|
||||
- Cannot re-enchant already enchanted gear
|
||||
|
||||
### Golemancy System
|
||||
- Summon magical constructs to fight alongside you
|
||||
- Golem slots unlock every 2 Fabricator levels (Level 2=1, Level 10=5)
|
||||
- Base golems: Earth, Steel (metal), Crystal, Sand
|
||||
- Advanced hybrid golems at Enchanter 5 + Fabricator 5: Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone
|
||||
- Golems cost mana to summon and maintain
|
||||
- Golemancy skills improve damage, speed, duration, and maintenance costs
|
||||
|
||||
### Combat System
|
||||
- Cast speed-based spell casting
|
||||
### ⚔️ Combat & Spire
|
||||
- Cast-speed based combat system
|
||||
- Multi-spell support from equipped weapons
|
||||
- Golem allies deal automatic damage each tick
|
||||
- Elemental damage bonuses and effectiveness
|
||||
- Floor guardians with unique boons and pacts
|
||||
- 100-floor spire with elemental themes
|
||||
- Floor guardians with unique mechanics and pacts
|
||||
- Golem allies that deal automatic damage each tick
|
||||
|
||||
### Floor Navigation & Guardian Battles
|
||||
- Procedurally generated spire floors
|
||||
- Elemental floor themes affecting combat
|
||||
- Guardian bosses with unique mechanics
|
||||
- Pact system for permanent power boosts
|
||||
### 🛡️ Equipment & Enchanting
|
||||
- 3-stage enchantment process: Design → Prepare → Apply
|
||||
- Equipment capacity system limiting total enchantment power
|
||||
- Enchantment effects: stat bonuses, multipliers, spell grants
|
||||
- Disenchanting to recover mana (only in Prepare stage)
|
||||
- Weapon/armor slots with 2-handed weapon support
|
||||
|
||||
### Prestige System (Insight)
|
||||
- Reset progress for permanent bonuses
|
||||
### 🤖 Golemancy System
|
||||
- Summon magical constructs (Earth, Steel, Crystal, Sand + 6 hybrid types)
|
||||
- Golem slots unlock every 2 Fabricator levels (max 5 slots at Level 10)
|
||||
- Hybrid golems require Enchanter 5 + Fabricator 5
|
||||
- Golem maintenance costs and stat upgrades via skills
|
||||
|
||||
### 🔄 Prestige (Insight)
|
||||
- Reset progress for permanent Insight currency
|
||||
- Insight upgrades across multiple categories
|
||||
- Signed pacts persist through prestige
|
||||
- Signed pacts and attunements persist through prestige
|
||||
- Three attunement classes: Enchanter (Transference), Invoker (Spells), Fabricator (Golems/Equipment)
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Technology | Purpose |
|
||||
|------------|---------|
|
||||
| **Next.js 16** | Full-stack framework with App Router |
|
||||
| **TypeScript 5** | Type-safe development |
|
||||
| **Tailwind CSS 4** | Utility-first styling |
|
||||
| **shadcn/ui** | Reusable UI components |
|
||||
| **Zustand** | Client state management with persistence |
|
||||
| **Prisma ORM** | Database abstraction (SQLite) |
|
||||
| **Bun** | JavaScript runtime and package manager |
|
||||
| Technology | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| **Next.js** | ^16.1.1 | Full-stack framework (App Router) |
|
||||
| **React** | ^19.0.0 | UI library |
|
||||
| **TypeScript** | ^5 | Type-safe development |
|
||||
| **Tailwind CSS** | ^4 | Utility-first styling |
|
||||
| **shadcn/ui** | Radix-based | Reusable UI components |
|
||||
| **Zustand** | ^5.0.6 | Client state management (with persist) |
|
||||
| **Prisma ORM** | ^6.11.1 | Database abstraction (SQLite) |
|
||||
| **Bun** | Latest | JavaScript runtime & package manager |
|
||||
| **Vitest** | ^4.1.2 | Unit testing framework |
|
||||
| **ESLint** | ^9 | Code linting |
|
||||
| **@tanstack/react-query** | ^5.82.0 | Data fetching/caching |
|
||||
| **Framer Motion** | ^12.23.2 | Animation library |
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** 18+ or **Bun** runtime
|
||||
- **npm**, **yarn**, or **bun** package manager
|
||||
- **Bun** runtime (recommended) or Node.js 18+
|
||||
- **SQLite** (for local development, included with Prisma)
|
||||
- Git
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -144,9 +129,10 @@ Exotic (3)
|
||||
git clone git@gitea.tailf367e3.ts.net:Anexim/Mana-Loop.git
|
||||
cd Mana-Loop
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies (using Bun - recommended)
|
||||
bun install
|
||||
# or
|
||||
|
||||
# Or using npm
|
||||
npm install
|
||||
|
||||
# Set up the database
|
||||
@@ -158,7 +144,7 @@ npm run db:push
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Start the development server
|
||||
# Start the development server (runs on port 3000)
|
||||
bun run dev
|
||||
# or
|
||||
npm run dev
|
||||
@@ -166,134 +152,152 @@ npm run dev
|
||||
|
||||
The game will be available at `http://localhost:3000`.
|
||||
|
||||
### Other Commands
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Run linting
|
||||
bun run lint
|
||||
|
||||
# Build for production
|
||||
bun run build
|
||||
|
||||
# Start production server
|
||||
bun run start
|
||||
```
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `dev` | Start Next.js development server with logging |
|
||||
| `build` | Build for production (outputs to `.next/standalone`) |
|
||||
| `start` | Start production server (requires build first) |
|
||||
| `lint` | Run ESLint |
|
||||
| `test` | Run Vitest tests |
|
||||
| `test:coverage` | Run tests with coverage report |
|
||||
| `db:push` | Push Prisma schema to database |
|
||||
| `db:generate` | Generate Prisma client |
|
||||
| `db:migrate` | Run database migrations |
|
||||
| `db:reset` | Reset database |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── page.tsx # Main game UI (single-page application)
|
||||
│ ├── layout.tsx # Root layout with providers
|
||||
│ └── api/ # API routes
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ └── game/ # Game-specific components
|
||||
│ ├── tabs/ # Tab-based UI components
|
||||
│ │ ├── CraftingTab.tsx
|
||||
│ │ ├── GolemancyTab.tsx
|
||||
│ │ ├── LabTab.tsx
|
||||
│ │ ├── SpellsTab.tsx
|
||||
│ │ ├── SpireTab.tsx
|
||||
│ │ └── ...
|
||||
│ ├── ManaDisplay.tsx
|
||||
│ ├── TimeDisplay.tsx
|
||||
│ ├── ActionButtons.tsx
|
||||
│ └── ...
|
||||
└── lib/
|
||||
├── game/
|
||||
│ ├── store.ts # Zustand store (state + actions)
|
||||
│ ├── effects.ts # Unified effect computation
|
||||
│ ├── upgrade-effects.ts # Skill upgrade definitions
|
||||
│ ├── skill-evolution.ts # Tier progression paths
|
||||
│ ├── constants.ts # Game data definitions
|
||||
│ ├── computed-stats.ts # Stat calculation functions
|
||||
│ ├── crafting-slice.ts # Equipment/enchantment actions
|
||||
│ ├── navigation-slice.ts # Floor navigation actions
|
||||
│ ├── study-slice.ts # Study system actions
|
||||
│ ├── types.ts # TypeScript interfaces
|
||||
│ ├── formatting.ts # Display formatters
|
||||
│ ├── utils.ts # Utility functions
|
||||
│ └── data/
|
||||
│ ├── equipment.ts # Equipment definitions
|
||||
│ ├── enchantment-effects.ts # Enchantment catalog
|
||||
│ ├── golems.ts # Golem definitions
|
||||
│ ├── crafting-recipes.ts # Crafting recipes
|
||||
│ ├── achievements.ts # Achievement definitions
|
||||
│ └── loot-drops.ts # Loot drop tables
|
||||
└── utils.ts # General utilities
|
||||
Mana-Loop/
|
||||
├── src/ # Application source code
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── layout.tsx # Root layout (metadata, fonts, providers)
|
||||
│ │ ├── page.tsx # Main game UI (~583 lines)
|
||||
│ │ ├── globals.css # Global styles
|
||||
│ │ └── api/ # API routes (minimal)
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── ui/ # shadcn/ui components (20+ components)
|
||||
│ │ └── game/ # Game-specific components
|
||||
│ │ ├── tabs/ # Tab components (SpireTab, SkillsTab, etc.)
|
||||
│ │ ├── ManaDisplay.tsx, ActionButtons.tsx, TimeDisplay.tsx
|
||||
│ │ └── crafting/, debug/, shared/, stats/ subdirectories
|
||||
│ ├── hooks/ # Custom React hooks (use-mobile, use-toast)
|
||||
│ ├── lib/ # Utility libraries
|
||||
│ │ ├── game/ # Core game logic
|
||||
│ │ │ ├── store.ts # Main Zustand store (~2862 lines)
|
||||
│ │ │ ├── crafting-slice.ts, study-slice.ts, navigation-slice.ts
|
||||
│ │ │ ├── effects.ts, upgrade-effects.ts
|
||||
│ │ │ ├── skill-evolution.ts (~3400 lines)
|
||||
│ │ │ ├── constants/ # Game definitions (elements, spells, skills)
|
||||
│ │ │ ├── data/ # Game data (equipment, golems, recipes)
|
||||
│ │ │ └── __tests__/ # Test files for game logic
|
||||
│ │ └── db.ts, utils.ts
|
||||
│ └── test/ # Test setup
|
||||
├── prisma/ # Database schema and migrations
|
||||
│ └── schema.prisma # SQLite schema
|
||||
├── public/ # Static assets (logo.svg, robots.txt)
|
||||
├── docs/ # Project documentation
|
||||
│ ├── AGENTS.md # Comprehensive architecture guide
|
||||
│ ├── GAME_BRIEFING.md # Game design document
|
||||
│ └── task/ # Task tracking documentation
|
||||
├── .next/ # Next.js build output (generated)
|
||||
├── node_modules/ # Dependencies (generated)
|
||||
├── Configuration Files:
|
||||
│ ├── package.json # Project metadata and scripts
|
||||
│ ├── tsconfig.json # TypeScript configuration
|
||||
│ ├── next.config.ts # Next.js config (standalone output)
|
||||
│ ├── vitest.config.ts # Vitest test configuration
|
||||
│ ├── eslint.config.mjs # ESLint configuration
|
||||
│ ├── Dockerfile # Docker multi-stage build
|
||||
│ ├── docker-compose.yml # Docker Compose setup
|
||||
│ ├── Caddyfile # Reverse proxy configuration
|
||||
│ └── .gitea/workflows/ # Gitea Actions CI/CD pipeline
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
For detailed architecture documentation, see [AGENTS.md](./AGENTS.md).
|
||||
For detailed architecture patterns and coding guidelines, see [AGENTS.md](./docs/AGENTS.md).
|
||||
|
||||
---
|
||||
|
||||
## Game Systems Overview
|
||||
## Game Systems
|
||||
|
||||
### Mana System
|
||||
The core resource of the game. Mana is gathered manually or automatically and used for studying skills, casting spells, and crafting.
|
||||
The core resource of the game with 14 distinct types organized in a hierarchy:
|
||||
- **Base Elements (7)**: Fire, Water, Air, Earth, Light, Dark, Death
|
||||
- **Utility (1)**: Transference (Enchanter attunement)
|
||||
- **Compound (3)**: Metal (Fire+Earth), Sand (Earth+Water), Lightning (Fire+Air)
|
||||
- **Exotic (3)**: Crystal (Sand+Sand+Light), Stellar (Fire+Fire+Light), Void (Dark+Dark+Death)
|
||||
|
||||
**Key Files:**
|
||||
- `store.ts` - Mana state and actions
|
||||
- `computed-stats.ts` - Mana calculations
|
||||
**Key Files**: `src/lib/game/store.ts`, `src/lib/game/constants/elements.ts`
|
||||
|
||||
### Skill System
|
||||
Skills provide passive bonuses and unlock new abilities. Each skill can evolve through 5 tiers with milestone upgrades.
|
||||
|
||||
**Key Files:**
|
||||
- `constants.ts` - Skill definitions (`SKILLS_DEF`)
|
||||
- `skill-evolution.ts` - Evolution paths and upgrades
|
||||
- `upgrade-effects.ts` - Effect computation
|
||||
### Skill Evolution System
|
||||
Each skill progresses through 5 tiers with upgrades at levels 5 and 10 per tier:
|
||||
- **Tier 1**: Basic functionality
|
||||
- **Tier 2-5**: Unlock new mechanics and bonuses
|
||||
- **Evolution Paths**: Defined in `src/lib/game/skill-evolution.ts` (~3400 lines)
|
||||
|
||||
### Combat System
|
||||
Combat uses a cast-speed system where each spell has a unique cast rate. Damage is calculated with skill bonuses, elemental modifiers, and special effects.
|
||||
- Cast-speed based spell casting with DPS calculations
|
||||
- Elemental damage bonuses and effectiveness
|
||||
- Multi-spell support from equipped weapons
|
||||
- Golem allies deal automatic damage each tick
|
||||
|
||||
**Key Files:**
|
||||
- `store.ts` - Combat tick logic
|
||||
- `constants.ts` - Spell definitions (`SPELLS_DEF`)
|
||||
- `effects.ts` - Damage calculations
|
||||
**Key Files**: `src/lib/game/store.ts` (combat tick logic), `src/lib/game/constants/spells.ts`
|
||||
|
||||
### Enchanting System
|
||||
A 3-stage enchantment system for equipment. Design effects, prepare equipment, and apply enchantments within capacity limits.
|
||||
3-stage equipment enchantment process:
|
||||
1. **Design**: Choose effects for your equipment type
|
||||
2. **Prepare**: Prepare equipment (ONLY way to disenchant existing enchantments)
|
||||
3. **Apply**: Apply designed enchantments (cannot re-enchant already enchanted gear)
|
||||
|
||||
**Key Rules:**
|
||||
- Design: Choose effects for your equipment type
|
||||
- Prepare: Prepare equipment for enchanting (ONLY way to disenchant existing enchantments)
|
||||
- Apply: Apply designed enchantments (cannot re-enchant already enchanted gear)
|
||||
|
||||
**Key Files:**
|
||||
- `crafting-slice.ts` - Crafting actions
|
||||
- `data/equipment.ts` - Equipment types
|
||||
- `data/enchantment-effects.ts` - Available effects
|
||||
**Key Files**: `src/lib/game/crafting-slice.ts`, `src/lib/game/data/enchantment-effects.ts`
|
||||
|
||||
### Golemancy System
|
||||
Summon magical constructs to fight alongside you. Requires Fabricator attunement.
|
||||
- **Base Golems**: Earth (Fabricator 2), Steel (Metal), Crystal, Sand
|
||||
- **Hybrid Golems** (Enchanter 5 + Fabricator 5): Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone
|
||||
- **Golem Slots**: 1 slot at Fabricator Level 2, +1 every 2 levels (max 5 at Level 10)
|
||||
|
||||
**Golem Slots:**
|
||||
- Fabricator Level 2: 1 slot
|
||||
- Fabricator Level 4: 2 slots
|
||||
- Fabricator Level 6: 3 slots
|
||||
- Fabricator Level 8: 4 slots
|
||||
- Fabricator Level 10: 5 slots
|
||||
**Key Files**: `src/lib/game/data/golems.ts`, `src/lib/game/store.ts`
|
||||
|
||||
**Golem Types:**
|
||||
- Base: Earth (available at Fabricator 2)
|
||||
- Element Unlocks: Steel (metal), Crystal, Sand
|
||||
- Hybrids (Enchanter 5 + Fabricator 5): Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone
|
||||
### Prestige (Insight)
|
||||
Reset progress to gain Insight currency for permanent upgrades:
|
||||
- Signed pacts persist through prestige
|
||||
- Attunement choices affect gameplay (Enchanter/Invoker/Fabricator)
|
||||
- Insight upgrades provide bonuses across all loops
|
||||
|
||||
**Key Files:**
|
||||
- `data/golems.ts` - Golem definitions and unlock conditions
|
||||
- `store.ts` - Golemancy actions and state
|
||||
---
|
||||
|
||||
### Prestige System
|
||||
Reset progress for Insight, which provides permanent bonuses. Signed pacts persist through prestige.
|
||||
## Deployment
|
||||
|
||||
**Key Files:**
|
||||
- `store.ts` - Prestige logic
|
||||
- `constants.ts` - Insight upgrades
|
||||
### Docker Deployment
|
||||
The project includes Docker configuration for containerized deployment:
|
||||
|
||||
```bash
|
||||
# Build and run with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# Or build manually
|
||||
docker build -t mana-loop .
|
||||
docker run -p 3000:3000 mana-loop
|
||||
```
|
||||
|
||||
### CI/CD Pipeline
|
||||
- **Gitea Actions**: `.gitea/workflows/docker-build.yaml` automatically builds and pushes Docker images to `gitea.tailf367e3.ts.net/anexim/mana-loop:latest` on push to `master`/`main` branches
|
||||
- **Multi-platform**: Builds for linux/amd64 architecture
|
||||
- **Image Tags**: Branch name, commit SHA, "latest"
|
||||
|
||||
### Reverse Proxy
|
||||
A `Caddyfile` is included for reverse proxy setup (forwards port 81 to 3000).
|
||||
|
||||
### Production Build
|
||||
```bash
|
||||
bun run build
|
||||
NODE_ENV=production bun .next/standalone/server.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -302,23 +306,26 @@ Reset progress for Insight, which provides permanent bonuses. Signed pacts persi
|
||||
We welcome contributions! Please follow these guidelines:
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Pull the latest changes** before starting work
|
||||
2. **Create a feature branch** for your changes
|
||||
3. **Follow existing patterns** in the codebase
|
||||
4. **Run linting** before committing (`bun run lint`)
|
||||
5. **Test your changes** thoroughly
|
||||
1. **Pull latest changes** before starting work: `git pull origin master`
|
||||
2. **Create a feature branch** for your changes: `git checkout -b feature/your-feature`
|
||||
3. **Follow existing patterns** in the codebase (see AGENTS.md)
|
||||
4. **Run linting** before committing: `bun run lint`
|
||||
5. **Test your changes** thoroughly: `bun run test`
|
||||
6. **Commit and push** to your branch, then create a pull request
|
||||
|
||||
### Code Style
|
||||
|
||||
- TypeScript throughout with strict typing
|
||||
- Use existing shadcn/ui components over custom implementations
|
||||
- Follow the slice pattern for store actions
|
||||
- Keep components focused and extract to separate files when >50 lines
|
||||
- Follow the slice pattern for Zustand store actions
|
||||
- Keep components focused (extract to separate files when >50 lines)
|
||||
- Use path aliases: `@/*` maps to `./src/*`
|
||||
|
||||
### Adding New Features
|
||||
|
||||
For detailed patterns on adding new effects, skills, spells, or systems, see [AGENTS.md](./AGENTS.md).
|
||||
For detailed patterns on adding new effects, skills, spells, or systems, see the comprehensive [AGENTS.md](./docs/AGENTS.md) guide, which includes:
|
||||
- Architecture overview
|
||||
- Coding patterns
|
||||
- Git workflow (mandatory pull before work, commit & push after)
|
||||
- Credentials for automation (if applicable)
|
||||
|
||||
---
|
||||
|
||||
@@ -344,7 +351,7 @@ The following content has been removed from the game and should not be re-added:
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
This project is licensed under the MIT License - see the LICENSE section below for details.
|
||||
|
||||
```
|
||||
MIT License
|
||||
@@ -370,8 +377,20 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
**Note**: A `LICENSE` file is not currently present in the project root. It is recommended to create one with the above MIT License text.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with love using modern web technologies. Special thanks to the open-source community for the amazing tools that make this project possible.
|
||||
- Built with modern web technologies (Next.js, React, TypeScript, Tailwind CSS)
|
||||
- UI components from [shadcn/ui](https://ui.shadcn.com/)
|
||||
- State management with [Zustand](https://github.com/pmndrs/zustand)
|
||||
- Game icons from [Lucide React](https://lucide.dev/)
|
||||
- Special thanks to the open-source community for the amazing tools that make this project possible.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<em>Climb the spire. Master the mana. Uncover the loop.</em>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[test]
|
||||
dir = "./src/test"
|
||||
preload = ["./src/test/setup.ts"]
|
||||
@@ -1,238 +0,0 @@
|
||||
# Mana-Loop Codebase Audit Report
|
||||
|
||||
**Date:** 2025-01-24
|
||||
**Auditor:** Automated sub-agent analysis
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Mana-Loop codebase is a Next.js game application with a mixed architecture - partially refactored from a monolithic store (`store.ts`) to a modular store architecture (`stores/`). The codebase has significant technical debt in the form of dead code, unimplemented features, and files that have grown too large.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Files Over 300 Lines
|
||||
|
||||
### Critical (Needs Immediate Refactoring)
|
||||
|
||||
| File | Lines | Purpose | Issue |
|
||||
|------|-------|---------|-------|
|
||||
| `src/lib/game/store.ts` | 2464 | Legacy monolithic game store | Monolith doing virtually all game logic |
|
||||
| `src/lib/game/skill-evolution.ts` | 2312 | Skill talent trees | Single file with all skill trees; could split by category |
|
||||
| `src/lib/game/constants.ts` | 1436 | Game constants & definitions | Mixes constants with content data (spells, skills, etc.) |
|
||||
| `src/lib/game/types.ts` | 516 | TypeScript type definitions | Type definitions should be split by domain |
|
||||
| `src/components/game/tabs/CraftingTab.tsx` | 965 | Crafting UI | Single component handling all crafting phases |
|
||||
| `src/lib/game/crafting-slice.ts` | 847 | Old crafting store | Legacy file; newer version exists at `stores/craftingSlice.ts` |
|
||||
| `src/lib/game/data/enchantment-effects.ts` | 846 | Enchantment effect definitions | Large but focused; acceptable as data file |
|
||||
| `src/components/game/tabs/DebugTab.tsx` | 700 | Debug tools UI | Catch-all debug panel; could split by feature |
|
||||
| `src/lib/game/store/craftingSlice.ts` | 644 | New crafting store | Cleaner but still combines state with business logic |
|
||||
|
||||
### Moderate (Should Refactor)
|
||||
|
||||
| File | Lines | Purpose | Issue |
|
||||
|------|-------|---------|-------|
|
||||
| `src/lib/game/attunements.ts` | 567 | Attunement system (OLD) | **DEAD CODE** - new version at `data/attunements.ts` |
|
||||
| `src/components/game/tabs/GrimoireTab.tsx` | 567 | Pact system UI | Combines memory, pacts, boons |
|
||||
| `src/components/game/StatsTab.tsx` | 551 | Stats display (OLD) | Too many unrelated stat categories |
|
||||
| `src/components/game/tabs/StatsTab.tsx` | 545 | Stats display (NEW) | Same issues as above |
|
||||
| `src/lib/game/stores/gameStore.ts` | 509 | Game coordinator store | Still coordinates too many systems |
|
||||
| `src/lib/game/computed-stats.ts` | 492 | Computed statistics | Mixes utilities with stat calculations |
|
||||
| `src/lib/game/data/golems.ts` | 471 | Golem definitions | Focused, acceptable size |
|
||||
| `src/lib/game/data/equipment.ts` | 468 | Equipment definitions | Data file, acceptable size |
|
||||
| `src/app/page.tsx` | 465 | Main game page | Should be thin shell; currently imports everything |
|
||||
| `src/components/game/LootInventory.tsx` | 460 | Loot inventory UI | Handles multiple inventory types |
|
||||
| `src/components/game/SkillsTab.tsx` | 418 | Skills UI (OLD) | Combines display with upgrade dialog |
|
||||
| `src/components/game/GameContext.tsx` | 405 | Game context provider | Monolithic context combining all stores |
|
||||
| `src/lib/game/upgrade-effects.ts` | 402 | Upgrade effect computation | Focused, acceptable |
|
||||
| `src/components/game/tabs/EquipmentTab.tsx` | 393 | Equipment UI | Acceptable size |
|
||||
| `src/lib/game/utils.ts` | 372 | Game utilities | Grown to include significant game logic |
|
||||
| `src/components/game/tabs/SkillsTab.tsx` | 369 | Skills UI (NEW) | Same as old version |
|
||||
| `src/lib/game/store/skillSlice.ts` | 346 | Skill store slice (OLD) | Legacy; newer version exists |
|
||||
| `src/components/game/tabs/SpireTab.tsx` | 345 | Spire progression UI | Acceptable size |
|
||||
| `src/components/game/tabs/GolemancyTab.tsx` | 338 | Golem management UI | Acceptable size |
|
||||
| `src/lib/game/stores/skillStore.ts` | 332 | Skill store (NEW) | Acceptable size |
|
||||
| `src/lib/game/store/computed.ts` | 322 | Computed values (OLD) | Legacy computed values |
|
||||
| `src/components/game/SpireTab.tsx` | 320 | Spire UI (OLD) | Duplicate of tabs/ version |
|
||||
|
||||
### Test Files Over 300 Lines (Acceptable)
|
||||
|
||||
- `src/lib/game/store.test.ts` - 1042 lines
|
||||
- `src/lib/game/__tests__/skills.test.ts` - 588 lines
|
||||
- `src/lib/game/stores/__tests__/store-methods.test.ts` - 583 lines
|
||||
- `src/lib/game/stores/index.test.ts` - 571 lines
|
||||
- `src/lib/game/skills.test.ts` - 542 lines
|
||||
- `src/lib/game/stores.test.ts` - 494 lines
|
||||
- `src/lib/game/stores/__tests__/stores.test.ts` - 458 lines
|
||||
- `src/lib/game/__tests__/skill-system.test.ts` - 347 lines
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: TODO/FIXME and Unimplemented Features
|
||||
|
||||
### TODO/FIXME Comments
|
||||
|
||||
**No TODO or FIXME comments found in the codebase.**
|
||||
|
||||
### Unimplemented Special Effects
|
||||
|
||||
The following special effects are **registered but never applied** in game logic:
|
||||
|
||||
#### 1. `spellEcho10` (Enchantment Effect)
|
||||
- **Location:** `src/lib/game/data/enchantment-effects.ts:530`
|
||||
- **Definition:** 10% chance to cast a spell twice
|
||||
- **Issue:** `hasSpecial(effects, 'spellEcho10')` is never checked in combat logic
|
||||
- **Severity:** Medium - Equipment enchantment doesn't work
|
||||
|
||||
#### 2. `worldThread` and `worldWeb` (Skill Upgrades)
|
||||
- **Location:** `src/lib/game/skill-evolution.ts:1662,1670`
|
||||
- **Definitions:**
|
||||
- `worldThread`: "Enchantments also apply 5% as world effects"
|
||||
- `worldWeb`: "Enchantments also apply 10% as world effects"
|
||||
- **Issue:** These specialIds are never checked with `hasSpecial()`
|
||||
- **Severity:** High - Major feature gap ("world effects" mechanic unimplemented)
|
||||
|
||||
#### 3. Weapon Enchantment Specials
|
||||
All defined but never checked:
|
||||
|
||||
| Special ID | Location | Purpose |
|
||||
|------------|----------|---------|
|
||||
| `fireBlade` | `constants.ts:864`, `enchantment-effects.ts:781` | Burn enemies |
|
||||
| `frostBlade` | `constants.ts:877`, `enchantment-effects.ts:791` | Prevent enemy dodge |
|
||||
| `lightningBlade` | `constants.ts:890`, `enchantment-effects.ts:801` | Pierce 30% armor |
|
||||
| `voidBlade` | `constants.ts:903`, `enchantment-effects.ts:811` | +20% damage |
|
||||
|
||||
- **Severity:** High - Weapon enchantment system broken
|
||||
|
||||
#### 4. `comboMaster` (Special Effect)
|
||||
- **Location:** `src/lib/game/upgrade-effects.ts:97,396`
|
||||
- **Definition:** Every 5th attack deals 3x damage
|
||||
- **Issue:** Hit counter tracking status unclear; combat handler may not check this
|
||||
- **Severity:** Low - Implementation status unclear
|
||||
|
||||
### Effects Applied but Never Read
|
||||
|
||||
#### 1. `weaponManaMax` and `weaponManaRegen`
|
||||
- **Location:** `src/lib/game/data/enchantment-effects.ts:566-616`
|
||||
- **Issue:** Bonuses stored in `equipmentEffects.bonuses` but never read in `computeAllEffects()`
|
||||
- **Severity:** Medium - Weapon mana system incomplete
|
||||
|
||||
#### 2. `insightGainMultiplier` from Equipment
|
||||
- **Location:** `src/lib/game/effects.ts:127-129`
|
||||
- **Issue:** Stored via type assertion but never read in `calcInsight()`
|
||||
- **Severity:** Medium - Insight bonus from equipment doesn't work
|
||||
|
||||
#### 3. `guardianDamageMultiplier` from Equipment
|
||||
- **Location:** `src/lib/game/effects.ts:131-132`
|
||||
- **Issue:** Stored but unclear if ever read
|
||||
- **Severity:** Low - Needs investigation
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Dead Code Analysis
|
||||
|
||||
### Confirmed Dead Code (Safe to Remove)
|
||||
|
||||
| File | Reason | Evidence |
|
||||
|------|--------|----------|
|
||||
| `src/lib/game/attunements.ts` | Old attunement system | No imports; new version at `data/attunements.ts` |
|
||||
| `src/lib/game/navigation-slice.ts` | Old navigation system | No imports anywhere |
|
||||
| `src/lib/db.ts` | Prisma client | No imports; possibly planned backend feature |
|
||||
| `src/components/game/ComboMeter.tsx` | Unused component | No imports; references non-existent `ComboState` type |
|
||||
| `src/components/game/layout/GameFooter.tsx` | Unused component | No imports |
|
||||
| `src/components/game/shared/GameOverScreen.tsx` | Unused component | No imports |
|
||||
| `src/components/game/shared/MemorySlotPicker.tsx` | Unused component | Only used by GameOverScreen |
|
||||
|
||||
### Duplicate Code to Clean
|
||||
|
||||
| Location | Issue | Action |
|
||||
|----------|-------|--------|
|
||||
| `src/components/game/types.ts` | Duplicate formatting functions | Remove; use `src/lib/game/formatting.ts` instead |
|
||||
| `src/lib/game/store.ts` | Functions duplicated in `utils.ts` and `computed-stats.ts` | Consolidate during refactoring |
|
||||
|
||||
### Flagged for Review (String References/Dynamic Imports)
|
||||
|
||||
#### UI Components (Possibly Unused)
|
||||
Many shadcn/ui components in `src/components/ui/` have no direct imports:
|
||||
- `aspect-ratio.tsx`, `avatar.tsx`, `breadcrumb.tsx`, `calendar.tsx`, `carousel.tsx`, `chart.tsx`, `checkbox.tsx`, `collapsible.tsx`, `command.tsx`, `context-menu.tsx`, `drawer.tsx`, `dropdown-menu.tsx`, `form.tsx`, `hover-card.tsx`, `input-otp.tsx`, `label.tsx`, `menubar.tsx`, `navigation-menu.tsx`, `pagination.tsx`, `popover.tsx`, `radio-group.tsx`, `resizable.tsx`, `scroll-area.tsx` (USED), `select.tsx` (USED), `separator.tsx` (USED), `sheet.tsx`, `skeleton.tsx`, `slider.tsx`, `sonner.tsx`, `switch.tsx` (USED), `table.tsx`, `textarea.tsx`, `toggle.tsx`, `toggle-group.tsx`
|
||||
|
||||
**Note:** These might be used dynamically or via string references. Review before removing.
|
||||
|
||||
#### Test File Duplication
|
||||
- `src/lib/game/skills.test.ts` (tests old store)
|
||||
- `src/lib/game/store.test.ts` (tests old store)
|
||||
- `src/lib/game/__tests__/skills.test.ts` (newer)
|
||||
- `src/lib/game/stores/__tests__/store-methods.test.ts` (newer)
|
||||
- `src/lib/game/stores/__tests__/stores.test.ts` (newer)
|
||||
|
||||
**Recommendation:** Old test files may be redundant if new tests provide adequate coverage.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Store Migration In Progress
|
||||
The codebase shows an active migration from:
|
||||
- **Old:** Monolithic `src/lib/game/store.ts` (2464 lines)
|
||||
- **New:** Modular stores in `src/lib/game/stores/` (gameStore.ts, combatStore.ts, etc.)
|
||||
|
||||
Current status: Mixed usage - some components still import from old store.
|
||||
|
||||
### Data vs Logic Separation
|
||||
Good separation in `src/lib/game/data/` for:
|
||||
- `attunements.ts`
|
||||
- `crafting-recipes.ts`
|
||||
- `enchantment-effects.ts`
|
||||
- `equipment.ts`
|
||||
- `golems.ts`
|
||||
- `loot-drops.ts`
|
||||
- `achievements.ts`
|
||||
|
||||
---
|
||||
|
||||
## Recommended Action Plan
|
||||
|
||||
### Phase 2: Safe Deletions (High Priority)
|
||||
1. Delete `src/lib/game/attunements.ts`
|
||||
2. Delete `src/lib/game/navigation-slice.ts`
|
||||
3. Delete `src/lib/db.ts`
|
||||
4. Delete `src/components/game/ComboMeter.tsx`
|
||||
5. Delete `src/components/game/layout/GameFooter.tsx`
|
||||
6. Delete `src/components/game/shared/GameOverScreen.tsx`
|
||||
7. Delete `src/components/game/shared/MemorySlotPicker.tsx`
|
||||
8. Clean duplicate formatting functions from `src/components/game/types.ts`
|
||||
|
||||
### Phase 3: Refactor Large Files (Medium Priority)
|
||||
1. **store.ts (2464 lines):** Complete migration to modular stores
|
||||
2. **skill-evolution.ts (2312 lines):** Split by skill category
|
||||
3. **constants.ts (1436 lines):** Split into domain-specific data files
|
||||
4. **types.ts (516 lines):** Split by domain
|
||||
5. **CraftingTab.tsx (965 lines):** Split by crafting phase
|
||||
6. **page.tsx (465 lines):** Make thin shell
|
||||
7. **GameContext.tsx (405 lines):** Simplify or remove need for monolithic context
|
||||
|
||||
### Phase 4: Implement Missing Effects (High Priority)
|
||||
1. Implement weapon enchantment specials (`fireBlade`, `frostBlade`, `lightningBlade`, `voidBlade`)
|
||||
2. Implement `worldThread`/`worldWeb` ("world effects" mechanic)
|
||||
3. Wire up `spellEcho10` in combat logic
|
||||
4. Apply `weaponManaMax`/`weaponManaRegen` or remove
|
||||
5. Use `insightGainMultiplier` in `calcInsight()`
|
||||
6. Verify `comboMaster` implementation
|
||||
|
||||
### Phase 5: Cleanup (Low Priority)
|
||||
1. Review and remove unused UI components
|
||||
2. Consolidate test files
|
||||
3. Review relationship between `effects.ts` and `upgrade-effects.ts`
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After each phase:
|
||||
- [ ] Game builds without errors
|
||||
- [ ] Game runs correctly in browser
|
||||
- [ ] All tabs functional
|
||||
- [ ] No console errors
|
||||
- [ ] Tests pass (if any)
|
||||
- [ ] Commit and push changes
|
||||
|
||||
---
|
||||
|
||||
**Next Steps:** Begin Phase 2 - Safe Deletions
|
||||
@@ -0,0 +1,14 @@
|
||||
# Circular Dependencies
|
||||
Generated: 2026-05-20T19:05:27.642Z
|
||||
Found: 4 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||
|
||||
1. Processed 126 files (1.3s) (3 warnings)
|
||||
2. 1) stores/gameStore.ts > stores/gameActions.ts
|
||||
3. 2) stores/gameStore.ts > stores/gameLoopActions.ts
|
||||
4. 3) stores/gameStore.ts > stores/tick-pipeline.ts
|
||||
|
||||
## How to fix
|
||||
1. Identify which import in the chain can be extracted to a shared types/utils file.
|
||||
2. Move the shared type or function there.
|
||||
3. Both files import from the new shared module instead of each other.
|
||||
4. Run: bunx madge --circular src/lib/game (should return clean)
|
||||
@@ -0,0 +1,624 @@
|
||||
{
|
||||
"_meta": {
|
||||
"generated": "2026-05-20T19:05:26.102Z",
|
||||
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||
},
|
||||
"graph": {
|
||||
"constants.ts": [
|
||||
"constants/index.ts"
|
||||
],
|
||||
"constants/core.ts": [],
|
||||
"constants/elements.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"constants/guardians.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"constants/index.ts": [
|
||||
"constants/core.ts",
|
||||
"constants/elements.ts",
|
||||
"constants/guardians.ts",
|
||||
"constants/prestige.ts",
|
||||
"constants/rooms.ts",
|
||||
"constants/spells.ts",
|
||||
"types/game.ts"
|
||||
],
|
||||
"constants/prestige.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"constants/rooms.ts": [
|
||||
"types/game.ts"
|
||||
],
|
||||
"constants/spells-modules/advanced-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"constants/spells-modules/aoe-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"constants/spells-modules/basic-elemental-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"constants/spells-modules/compound-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"constants/spells-modules/enchantment-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"constants/spells-modules/legendary-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"constants/spells-modules/lightning-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"constants/spells-modules/master-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"constants/spells-modules/raw-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"constants/spells-modules/utility-spells.ts": [
|
||||
"constants/elements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"constants/spells.ts": [
|
||||
"constants/spells-modules/advanced-spells.ts",
|
||||
"constants/spells-modules/aoe-spells.ts",
|
||||
"constants/spells-modules/basic-elemental-spells.ts",
|
||||
"constants/spells-modules/compound-spells.ts",
|
||||
"constants/spells-modules/enchantment-spells.ts",
|
||||
"constants/spells-modules/legendary-spells.ts",
|
||||
"constants/spells-modules/lightning-spells.ts",
|
||||
"constants/spells-modules/master-spells.ts",
|
||||
"constants/spells-modules/raw-spells.ts",
|
||||
"constants/spells-modules/utility-spells.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-actions/application-actions.ts": [
|
||||
"crafting-apply.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-actions/computed-getters.ts": [
|
||||
"data/enchantment-effects.ts",
|
||||
"stores/craftingStore.types.ts"
|
||||
],
|
||||
"crafting-actions/crafting-equipment-actions.ts": [
|
||||
"crafting-equipment.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-actions/design-actions.ts": [
|
||||
"crafting-design.ts",
|
||||
"crafting-utils.ts",
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-actions/disenchant-actions.ts": [
|
||||
"stores/craftingStore.types.ts"
|
||||
],
|
||||
"crafting-actions/equipment-actions.ts": [
|
||||
"crafting-utils.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-actions/index.ts": [
|
||||
"crafting-actions/application-actions.ts",
|
||||
"crafting-actions/computed-getters.ts",
|
||||
"crafting-actions/crafting-equipment-actions.ts",
|
||||
"crafting-actions/design-actions.ts",
|
||||
"crafting-actions/disenchant-actions.ts",
|
||||
"crafting-actions/equipment-actions.ts",
|
||||
"crafting-actions/preparation-actions.ts"
|
||||
],
|
||||
"crafting-actions/preparation-actions.ts": [
|
||||
"crafting-prep.ts",
|
||||
"stores/craftingStore.types.ts"
|
||||
],
|
||||
"crafting-apply.ts": [
|
||||
"crafting-utils.ts",
|
||||
"data/attunements.ts",
|
||||
"data/enchantment-effects.ts",
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-attunements.ts": [
|
||||
"data/attunements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-design.ts": [
|
||||
"data/attunements.ts",
|
||||
"data/enchantment-effects.ts",
|
||||
"data/equipment/index.ts",
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.types.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-equipment.ts": [
|
||||
"crafting-utils.ts",
|
||||
"data/crafting-recipes.ts",
|
||||
"data/equipment/index.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-loot.ts": [
|
||||
"data/crafting-recipes.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-prep.ts": [
|
||||
"crafting-utils.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"crafting-utils.ts": [
|
||||
"data/crafting-recipes.ts",
|
||||
"data/equipment/index.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"data/achievements.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"data/attunements.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"data/crafting-recipes.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/disciplines/base.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/enchanter.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/fabricator.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/index.ts": [
|
||||
"data/disciplines/base.ts",
|
||||
"data/disciplines/enchanter.ts",
|
||||
"data/disciplines/fabricator.ts",
|
||||
"data/disciplines/invoker.ts",
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/disciplines/invoker.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"data/enchantment-effects.ts": [
|
||||
"data/enchantments/index.ts"
|
||||
],
|
||||
"data/enchantment-types.ts": [
|
||||
"data/equipment/index.ts"
|
||||
],
|
||||
"data/enchantments/combat-effects.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
"data/equipment/index.ts"
|
||||
],
|
||||
"data/enchantments/defense-effects.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
"data/equipment/index.ts"
|
||||
],
|
||||
"data/enchantments/elemental-effects.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
"data/equipment/index.ts"
|
||||
],
|
||||
"data/enchantments/index.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
"data/enchantments/combat-effects.ts",
|
||||
"data/enchantments/defense-effects.ts",
|
||||
"data/enchantments/elemental-effects.ts",
|
||||
"data/enchantments/mana-effects.ts",
|
||||
"data/enchantments/special-effects.ts",
|
||||
"data/enchantments/spell-effects/index.ts",
|
||||
"data/enchantments/utility-effects.ts",
|
||||
"data/equipment/index.ts"
|
||||
],
|
||||
"data/enchantments/mana-effects.ts": [
|
||||
"constants.ts",
|
||||
"data/enchantment-types.ts",
|
||||
"data/equipment/index.ts"
|
||||
],
|
||||
"data/enchantments/special-effects.ts": [
|
||||
"data/enchantment-types.ts",
|
||||
"data/equipment/index.ts"
|
||||
],
|
||||
"data/enchantments/spell-effects/basic-spells.ts": [
|
||||
"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",
|
||||
"data/enchantments/spell-effects/tier2-spells.ts",
|
||||
"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"
|
||||
],
|
||||
"data/enchantments/spell-effects/metal-spells.ts": [
|
||||
"data/enchantments/spell-effects/types.ts"
|
||||
],
|
||||
"data/enchantments/spell-effects/sand-spells.ts": [
|
||||
"data/enchantments/spell-effects/types.ts"
|
||||
],
|
||||
"data/enchantments/spell-effects/tier2-spells.ts": [
|
||||
"data/enchantments/spell-effects/types.ts"
|
||||
],
|
||||
"data/enchantments/spell-effects/tier3-spells.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"
|
||||
],
|
||||
"data/equipment/accessories.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/equipment/body.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/equipment/casters.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"data/equipment/hands.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/equipment/head.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/equipment/index.ts": [
|
||||
"data/equipment/accessories.ts",
|
||||
"data/equipment/body.ts",
|
||||
"data/equipment/casters.ts",
|
||||
"data/equipment/catalysts.ts",
|
||||
"data/equipment/equipment-types-data.ts",
|
||||
"data/equipment/feet.ts",
|
||||
"data/equipment/hands.ts",
|
||||
"data/equipment/head.ts",
|
||||
"data/equipment/shields.ts",
|
||||
"data/equipment/swords.ts",
|
||||
"data/equipment/types.ts",
|
||||
"data/equipment/utils.ts"
|
||||
],
|
||||
"data/equipment/shields.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/equipment/swords.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/equipment/types.ts": [
|
||||
"types/equipmentSlot.ts"
|
||||
],
|
||||
"data/equipment/utils.ts": [
|
||||
"data/equipment/equipment-types-data.ts",
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/fabricator-recipes.ts": [
|
||||
"data/equipment/types.ts"
|
||||
],
|
||||
"data/golems/base-golems.ts": [
|
||||
"data/golems/types.ts"
|
||||
],
|
||||
"data/golems/elemental-golems.ts": [
|
||||
"data/golems/types.ts"
|
||||
],
|
||||
"data/golems/golems-data.ts": [
|
||||
"data/golems/base-golems.ts",
|
||||
"data/golems/elemental-golems.ts",
|
||||
"data/golems/hybrid-golems.ts"
|
||||
],
|
||||
"data/golems/hybrid-golems.ts": [
|
||||
"data/golems/types.ts"
|
||||
],
|
||||
"data/golems/index.ts": [
|
||||
"data/golems/golems-data.ts",
|
||||
"data/golems/types.ts",
|
||||
"data/golems/utils.ts"
|
||||
],
|
||||
"data/golems/types.ts": [],
|
||||
"data/golems/utils.ts": [
|
||||
"data/golems/golems-data.ts",
|
||||
"data/golems/types.ts"
|
||||
],
|
||||
"data/guardian-encounters.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"data/loot-drops.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"effects.ts": [
|
||||
"data/enchantment-effects.ts",
|
||||
"effects/discipline-effects.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/disciplines.ts",
|
||||
"utils/discipline-math.ts"
|
||||
],
|
||||
"effects/dynamic-compute.ts": [
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.types.ts"
|
||||
],
|
||||
"effects/special-effects.ts": [
|
||||
"effects/upgrade-effects.types.ts"
|
||||
],
|
||||
"effects/upgrade-effects.ts": [
|
||||
"effects/upgrade-effects.types.ts"
|
||||
],
|
||||
"effects/upgrade-effects.types.ts": [],
|
||||
"hooks/useGameDerived.ts": [
|
||||
"constants.ts",
|
||||
"effects/special-effects.ts",
|
||||
"effects/upgrade-effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"utils/index.ts",
|
||||
"utils/pact-utils.ts"
|
||||
],
|
||||
"stores/attunementStore.ts": [
|
||||
"data/attunements.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"stores/combat-actions.ts": [
|
||||
"constants.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"stores/combat-state.types.ts",
|
||||
"types.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/combat-state.types.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"stores/combatStore.ts": [
|
||||
"stores/combat-actions.ts",
|
||||
"stores/combat-state.types.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"types.ts",
|
||||
"utils/activity-log.ts",
|
||||
"utils/index.ts",
|
||||
"utils/room-utils.ts"
|
||||
],
|
||||
"stores/craftingStore.ts": [
|
||||
"crafting-actions/application-actions.ts",
|
||||
"crafting-actions/preparation-actions.ts",
|
||||
"crafting-design.ts",
|
||||
"crafting-equipment.ts",
|
||||
"crafting-utils.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"types.ts",
|
||||
"types/equipmentSlot.ts"
|
||||
],
|
||||
"stores/craftingStore.types.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"stores/discipline-slice.ts": [
|
||||
"data/disciplines/base.ts",
|
||||
"data/disciplines/enchanter.ts",
|
||||
"data/disciplines/fabricator.ts",
|
||||
"data/disciplines/invoker.ts",
|
||||
"types/disciplines.ts",
|
||||
"utils/discipline-math.ts"
|
||||
],
|
||||
"stores/gameActions.ts": [
|
||||
"effects/discipline-effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/gameHooks.ts": [
|
||||
"constants.ts",
|
||||
"effects.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/craftingStore.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/gameLoopActions.ts": [
|
||||
"constants.ts",
|
||||
"effects/discipline-effects.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/gameStore.ts": [
|
||||
"constants.ts",
|
||||
"data/attunements.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",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/index.ts": [
|
||||
"constants.ts",
|
||||
"stores/attunementStore.ts",
|
||||
"stores/combat-state.types.ts",
|
||||
"stores/combatStore.ts",
|
||||
"stores/craftingStore.ts",
|
||||
"stores/craftingStore.types.ts",
|
||||
"stores/discipline-slice.ts",
|
||||
"stores/gameHooks.ts",
|
||||
"stores/gameStore.ts",
|
||||
"stores/manaStore.ts",
|
||||
"stores/prestigeStore.ts",
|
||||
"stores/uiStore.ts",
|
||||
"utils/index.ts"
|
||||
],
|
||||
"stores/manaStore.ts": [
|
||||
"constants.ts",
|
||||
"types.ts"
|
||||
],
|
||||
"stores/prestigeStore.ts": [
|
||||
"constants.ts",
|
||||
"types.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",
|
||||
"types/elements.ts",
|
||||
"types/equipment.ts",
|
||||
"types/equipmentSlot.ts",
|
||||
"types/game.ts",
|
||||
"types/spells.ts"
|
||||
],
|
||||
"types/attunements.ts": [],
|
||||
"types/disciplines.ts": [
|
||||
"types/elements.ts"
|
||||
],
|
||||
"types/elements.ts": [],
|
||||
"types/equipment.ts": [
|
||||
"types/equipmentSlot.ts"
|
||||
],
|
||||
"types/equipmentSlot.ts": [],
|
||||
"types/game.ts": [
|
||||
"types/attunements.ts",
|
||||
"types/elements.ts",
|
||||
"types/equipment.ts",
|
||||
"types/spells.ts"
|
||||
],
|
||||
"types/index.ts": [
|
||||
"types/attunements.ts",
|
||||
"types/elements.ts",
|
||||
"types/equipment.ts",
|
||||
"types/equipmentSlot.ts",
|
||||
"types/game.ts",
|
||||
"types/spells.ts"
|
||||
],
|
||||
"types/spells.ts": [],
|
||||
"utils/activity-log.ts": [
|
||||
"types.ts"
|
||||
],
|
||||
"utils/combat-utils.ts": [
|
||||
"constants.ts",
|
||||
"data/enchantment-effects.ts",
|
||||
"types.ts",
|
||||
"utils/mana-utils.ts"
|
||||
],
|
||||
"utils/discipline-math.ts": [
|
||||
"types/disciplines.ts"
|
||||
],
|
||||
"utils/enemy-generator.ts": [
|
||||
"types.ts",
|
||||
"utils/enemy-utils.ts",
|
||||
"utils/floor-utils.ts"
|
||||
],
|
||||
"utils/enemy-utils.ts": [
|
||||
"constants.ts",
|
||||
"types.ts",
|
||||
"utils/floor-utils.ts"
|
||||
],
|
||||
"utils/floor-utils.ts": [
|
||||
"constants.ts"
|
||||
],
|
||||
"utils/formatting.ts": [],
|
||||
"utils/index.ts": [
|
||||
"utils/combat-utils.ts",
|
||||
"utils/floor-utils.ts",
|
||||
"utils/formatting.ts",
|
||||
"utils/mana-utils.ts"
|
||||
],
|
||||
"utils/mana-utils.ts": [
|
||||
"constants.ts",
|
||||
"data/attunements.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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
# Phase 1 Audit Report - Mana-Loop Game
|
||||
|
||||
## Overview
|
||||
Audit completed on: 2024-04-24
|
||||
Scope: `/home/user/repos/Mana-Loop/src/` directory
|
||||
Initial build status: ✅ Passing (Next.js 16.2.4 build succeeds)
|
||||
|
||||
---
|
||||
|
||||
## 1. Files Over 300 Lines (Splitting Candidates)
|
||||
|
||||
| File Path | Line Count | Purpose | Split Candidate |
|
||||
|-----------|------------|---------|-----------------|
|
||||
| `src/lib/game/store.ts` | 2464 | Monolithic legacy game store | **YES (HIGH PRIORITY)** |
|
||||
| `src/lib/game/skill-evolution.ts` | 2312 | All skill talent trees | **YES (HIGH PRIORITY)** |
|
||||
| `src/lib/game/constants.ts` | 1436 | Mixed game constants | **YES (HIGH PRIORITY)** |
|
||||
| `src/lib/game/data/enchantment-effects.ts` | 846 | Enchantment effect definitions | **YES (MEDIUM PRIORITY)** |
|
||||
| `src/components/game/tabs/CraftingTab.tsx` | 965 | Crafting UI (4 stages) | **YES (MEDIUM PRIORITY)** |
|
||||
| `src/components/game/tabs/DebugTab.tsx` | 700 | Debug/development UI | **YES (LOW PRIORITY)** |
|
||||
| `src/lib/game/types.ts` | 516 | Central type definitions | **YES (MEDIUM PRIORITY)** |
|
||||
| `src/lib/game/computed-stats.ts` | 492 | Mixed utility/stat functions | **YES (MEDIUM PRIORITY)** |
|
||||
| `src/app/page.tsx` | 465 | Main game page component | **YES (LOW PRIORITY)** |
|
||||
| `src/components/game/GameContext.tsx` | 405 | Unified store context | **YES (LOW PRIORITY)** |
|
||||
| `src/lib/game/utils.ts` | 372 | Mixed utility functions | **YES (MEDIUM PRIORITY)** |
|
||||
|
||||
**Key Observation**: Project is mid-refactor from legacy `store.ts` to slice-based architecture (`lib/game/stores/`). Priority should be completing this migration.
|
||||
|
||||
---
|
||||
|
||||
## 2. Unused Exports (207 Total)
|
||||
|
||||
### Game Components (Never Imported)
|
||||
- `src/components/game/ComboMeter.tsx` - `ComboMeter`
|
||||
- `src/components/game/GrimoireTab.tsx` - `GrimoireTab`
|
||||
- `src/components/game/layout/GameFooter.tsx` - `GameFooter`
|
||||
- `src/components/game/layout/GameHeader.tsx` - `GameHeader`
|
||||
- `src/components/game/layout/GameSidebar.tsx` - `GameSidebar`
|
||||
- `src/components/game/shared/GameOverScreen.tsx` - `GameOverScreen`
|
||||
|
||||
### Tab Component Props (Unused Type Exports)
|
||||
- All `Tabs/*TabProps` types in `src/components/game/tabs/` (12 total)
|
||||
|
||||
### Library Files (Unused Exports)
|
||||
- `src/lib/game/attunements.ts` - 8 unused exports
|
||||
- `src/lib/game/constants.ts` - 15+ unused exports
|
||||
- `src/lib/game/computed-stats.ts` - 5 unused exports
|
||||
- `src/lib/game/effects.ts` - 5 unused exports
|
||||
- `src/lib/game/store.ts` - 7 unused exports
|
||||
- `src/lib/game/types.ts` - 20+ unused type exports
|
||||
- `src/lib/game/upgrade-effects.ts` - 6 unused exports
|
||||
- `src/lib/game/utils.ts` - 2 unused exports
|
||||
|
||||
### UI Components (shadcn/ui - Never Imported)
|
||||
28 unused shadcn/ui components in `src/components/ui/` (accordion, alert, calendar, chart, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Dead Imports (56 Total)
|
||||
|
||||
### Top-Level Components
|
||||
- `src/app/page.tsx`: 5 dead imports (`fmtDec`, `getDamageBreakdown`, `SKILL_EVOLUTION_PATHS`, etc.)
|
||||
- `src/components/game/SkillsTab.tsx`: 4 dead imports
|
||||
- `src/components/game/SpellsTab.tsx`: 4 dead imports
|
||||
- `src/components/game/StatsTab.tsx`: 5 dead imports
|
||||
|
||||
### Library Files
|
||||
- `src/lib/game/store.ts`: 1 dead import
|
||||
- `src/lib/game/store/combatSlice.ts`: 3 dead imports
|
||||
- `src/lib/game/store/computed.ts`: 4 dead imports
|
||||
- `src/lib/game/store/skillSlice.ts`: 3 dead imports
|
||||
|
||||
---
|
||||
|
||||
## 4. Unreferenced Files (57 Total)
|
||||
|
||||
### Game Components (Never Imported)
|
||||
- 7 game components including `ComboMeter.tsx`, `GameFooter.tsx`, etc.
|
||||
|
||||
### UI Components
|
||||
- 28 unused shadcn/ui components
|
||||
|
||||
### Library Files
|
||||
- Old store architecture: `src/lib/game/store/*.ts` (10 files)
|
||||
- Old stores: `src/lib/game/stores/*.ts` (8 files)
|
||||
- Test files: `src/lib/game/*test.ts` (4 files)
|
||||
- `src/lib/db.ts` (Prisma client, may be runtime-used)
|
||||
|
||||
---
|
||||
|
||||
## 5. TODO/FIXME Comments
|
||||
✅ **None found** in source code (only "Temp" substring matches from temporal/tempest references)
|
||||
|
||||
---
|
||||
|
||||
## 6. Unimplemented Stubs & Unused Effects
|
||||
|
||||
### Critical Issues
|
||||
1. **`EXECUTIONER` effect used but not defined** (HIGH SEVERITY)
|
||||
- Referenced in `store.ts:1085`, `combatSlice.ts:102`, `gameStore.ts:265`
|
||||
- Missing from `SPECIAL_EFFECTS` in `upgrade-effects.ts`
|
||||
- **Will cause runtime errors**
|
||||
|
||||
### Unused Effects
|
||||
2. **51/59 `SPECIAL_EFFECTS` constants unused** (Medium severity)
|
||||
- Only 8/59 effects are actually checked via `hasSpecial()`
|
||||
- Examples: `FLOW_SURGE`, `MANA_OVERFLOW`, `FIRST_STRIKE`, etc.
|
||||
|
||||
3. **5 unused enchantment `specialId` values**
|
||||
- `spellEcho10`, `fireBlade`, `frostBlade`, `lightningBlade`, `voidBlade`
|
||||
- Defined in `enchantment-effects.ts` but never checked in game logic
|
||||
|
||||
4. **~200+ `specialId` values in `skill-evolution.ts` never checked**
|
||||
- Most `specialId` values added to `specials` Set but no corresponding `hasSpecial()` check
|
||||
|
||||
### Empty Functions
|
||||
✅ **None found** - no empty function stubs detected
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary of Priority Actions
|
||||
|
||||
### Phase 2 (Safe Deletions) - Recommended Deletions
|
||||
1. Remove 28 unused shadcn/ui components from `src/components/ui/`
|
||||
2. Remove dead imports (56 total) across all files
|
||||
3. Remove old store architecture files if confirmed unused:
|
||||
- `src/lib/game/store/*.ts`
|
||||
- `src/lib/game/stores/*.ts`
|
||||
4. Remove unused game components if not needed:
|
||||
- `ComboMeter.tsx`, `GameFooter.tsx`, `GameHeader.tsx`, etc.
|
||||
|
||||
### Phase 3 (Refactor Large Files) - Recommended Splits
|
||||
1. **HIGH PRIORITY**: Split `src/lib/game/store.ts` (2464 lines) - complete migration to slice architecture
|
||||
2. Split `src/lib/game/skill-evolution.ts` (2312 lines) by skill category
|
||||
3. Split `src/lib/game/constants.ts` (1436 lines) into domain-specific files
|
||||
4. Split `src/components/game/tabs/CraftingTab.tsx` (965 lines) by crafting stage
|
||||
|
||||
### Phase 4 (Implement Missing Effects) - Critical Fixes
|
||||
1. **CRITICAL**: Add `EXECUTIONER: 'executioner'` to `SPECIAL_EFFECTS` in `upgrade-effects.ts`
|
||||
2. Either implement or remove 51 unused `SPECIAL_EFFECTS` constants
|
||||
3. Either implement or remove 5 unused enchantment `specialId` values
|
||||
4. Audit ~200 `specialId` values in `skill-evolution.ts`
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
- Initial build: ✅ Passing
|
||||
- No TODO/FIXME comments found
|
||||
- No empty function stubs found
|
||||
- Runtime error identified: Missing `EXECUTIONER` effect definition
|
||||
@@ -1,43 +0,0 @@
|
||||
# Phase 2: Safe Deletions Summary
|
||||
|
||||
## Completed Deletions (All Committed)
|
||||
|
||||
### 1. Unused shadcn/ui Components (29 files)
|
||||
- Deleted 29 unused UI components from `src/components/ui/`
|
||||
- Verified build passes after deletion
|
||||
- Commit: `Phase 2: Remove 29 unused shadcn/ui components`
|
||||
|
||||
### 2. Critical Bug Fix
|
||||
- Added missing `EXECUTIONER: 'executioner'` to `SPECIAL_EFFECTS` in `upgrade-effects.ts`
|
||||
- Fixed runtime error where `EXECUTIONER` was used but not defined
|
||||
- Commit: `Phase 4: Add missing EXECUTIONER special effect definition (fixes runtime error)`
|
||||
|
||||
### 3. Unreferenced Game Components (6 files)
|
||||
- `src/components/game/ComboMeter.tsx` - unreferenced
|
||||
- `src/components/game/layout/GameFooter.tsx` - unreferenced
|
||||
- `src/components/game/layout/GameHeader.tsx` - unreferenced
|
||||
- `src/components/game/layout/GameSidebar.tsx` - unreferenced
|
||||
- `src/components/game/shared/GameOverScreen.tsx` - unreferenced
|
||||
- Both `GrimoireTab.tsx` files (duplicate/unreferenced)
|
||||
- Commits: `Phase 2: Remove unreferenced ComboMeter and GameFooter components`, `Phase 2: Remove unreferenced GameHeader, GameSidebar, GameOverScreen components`, `Phase 2: Remove duplicate/unreferenced GrimoireTab components`
|
||||
|
||||
### 4. Dead Import Removals
|
||||
- Removed dead imports from `src/app/page.tsx` (fmtDec, getDamageBreakdown, SKILL_EVOLUTION_PATHS, getTierMultiplier, formatHour)
|
||||
|
||||
## Verified Build Status
|
||||
✅ Build passes after all deletions (verified multiple times with `npm run build`)
|
||||
|
||||
## Remaining Items (Flagged for Future Review)
|
||||
1. **~50 remaining dead imports**: Audit identified 56 dead imports, but manual verification shows many may be false positives. Sub-agent attempts failed. Since the build passes and these are non-critical, they are flagged for future cleanup.
|
||||
2. **Old store files**: Audit incorrectly listed `src/lib/game/store/*.ts` and `src/lib/game/stores/*.ts` as unreferenced, but grep shows they are actively imported. These should NOT be deleted.
|
||||
3. **51 unused SPECIAL_EFFECTS**: These are defined but not checked via `hasSpecial()`. Flagged for Phase 4 (Implement missing effects).
|
||||
|
||||
## Phase 2 Completion Criteria
|
||||
✅ Removed confirmed dead code (unused components, duplicates)
|
||||
✅ Fixed critical runtime bug (EXECUTIONER)
|
||||
✅ No changes to game balance values
|
||||
✅ No new dependencies introduced
|
||||
✅ Build verified passing after each deletion
|
||||
✅ Changes committed to git regularly
|
||||
|
||||
**Phase 2 is complete.** Ready to proceed to Phase 3 (Refactor large files).
|
||||
@@ -1,50 +0,0 @@
|
||||
# Phase 3: Refactor Large Files - Progress
|
||||
|
||||
## Completed Refactorings (All Committed & Pushed)
|
||||
|
||||
### 1. `types.ts` (516 lines) ✅
|
||||
- **Commit**: `eb81ccb Phase 3: Split types.ts into domain-specific files`
|
||||
- **Result**: Split into `types/elements.ts`, `types/attunements.ts`, `types/spells.ts`, `types/skills.ts`, `types/equipment.ts`, `types/game.ts`, `types/index.ts`
|
||||
- **Build**: ✅ Passes
|
||||
|
||||
### 2. `constants.ts` (1436 lines) ✅
|
||||
- **Commit**: `f8520e1 Phase 3: Split constants.ts into domain-specific files`
|
||||
- **Result**: Split into `constants/elements.ts`, `constants/guardians.ts`, `constants/spells.ts`, `constants/skills.ts`, `constants/prestige.ts`, `constants/rooms.ts`, `constants/core.ts`, `constants/index.ts`
|
||||
- **Build**: ✅ Passes
|
||||
|
||||
### 3. `enchantment-effects.ts` (846 lines) ✅
|
||||
- **Commit**: `c46981d Phase 3: Split enchantment-effects.ts into category files`
|
||||
- **Result**: Split into `data/enchantments/spell-effects.ts`, `mana-effects.ts`, `combat-effects.ts`, `elemental-effects.ts`, `defense-effects.ts`, `utility-effects.ts`, `special-effects.ts`, `enchantment-types.ts`, `index.ts`
|
||||
- **Build**: ✅ Passes
|
||||
|
||||
## Failed Refactorings
|
||||
|
||||
### 1. `store.ts` (2464 lines) ❌
|
||||
- **Issue**: Sub-agent made changes that broke build (`Cannot read properties of undefined (reading 'mainHand')`)
|
||||
- **Action**: Reverted changes with `git restore .`
|
||||
- **Status**: Flagged as "too large for current sub-agent setup"
|
||||
|
||||
### 2. `skill-evolution.ts` (2312 lines) ❌
|
||||
- **Issue**: Larger than `store.ts` which failed
|
||||
- **Status**: Flagged as "too large for current sub-agent setup"
|
||||
|
||||
## Next Files to Refactor
|
||||
|
||||
### High Priority (Smaller, Likely to Work)
|
||||
1. `src/components/game/tabs/CraftingTab.tsx` (965 lines) - Split by crafting stage
|
||||
2. `src/lib/game/computed-stats.ts` (492 lines) - Split by responsibility
|
||||
3. `src/lib/game/utils.ts` (372 lines) - Split by responsibility
|
||||
|
||||
### Medium Priority
|
||||
4. `src/components/game/tabs/DebugTab.tsx` (700 lines) - Split by functional area
|
||||
5. `src/lib/game/stores/gameStore.ts` (509 lines) - Clean up coordinator
|
||||
6. `src/app/page.tsx` (465 lines) - Lazy load tabs
|
||||
|
||||
## Build Status
|
||||
✅ Build passes after each successful refactoring
|
||||
✅ All commits pushed to remote
|
||||
|
||||
## Notes
|
||||
- Sub-agents work best with files under ~1500 lines
|
||||
- Focused prompts yield better results
|
||||
- Larger files (2000+ lines) tend to break builds or fail silently
|
||||
@@ -1,90 +0,0 @@
|
||||
# Phase 3: Refactor Large Files Plan
|
||||
|
||||
## Overview
|
||||
Split files over 300 lines into focused modules. Keep public APIs stable - don't rename exports unless clearly a mistake.
|
||||
|
||||
## High Priority Files (Audit Findings)
|
||||
|
||||
### 1. `src/lib/game/store.ts` (2464 lines) - MONOLITHIC LEGACY STORE
|
||||
**Status**: Mid-refactor - project has started moving to slice architecture (`/stores/` directory)
|
||||
**Action**: Complete migration to slice-based architecture
|
||||
- New architecture exists: `gameStore.ts`, `manaStore.ts`, `skillStore.ts`, `combatStore.ts`, `prestigeStore.ts`
|
||||
- Migrate all remaining imports from `store.ts` to new stores
|
||||
- Deprecate and eventually remove `store.ts`
|
||||
- **Complexity**: 3+ steps → Requires sub-agent delegation
|
||||
|
||||
### 2. `src/lib/game/skill-evolution.ts` (2312 lines) - ALL SKILL TREES
|
||||
**Action**: Split by skill category
|
||||
- Separate files for: mana skills, combat skills, study skills, element skills
|
||||
- Extract helper functions (`createPerk`, `createUpgrade`, etc.) to `skill-utils.ts`
|
||||
- Keep `SKILL_EVOLUTION_PATHS` as the main export
|
||||
- **Complexity**: 3+ steps → Requires sub-agent delegation
|
||||
|
||||
### 3. `src/lib/game/constants.ts` (1436 lines) - MIXED CONSTANTS
|
||||
**Action**: Split into domain-specific files
|
||||
- `elements.ts` (element definitions)
|
||||
- `guardians.ts` (guardian definitions)
|
||||
- `spells.ts` (spell definitions)
|
||||
- `skills.ts` (skill definitions)
|
||||
- `prestige.ts` (prestige definitions)
|
||||
- `room-config.ts` (room types, swarm config, etc.)
|
||||
- Keep `constants.ts` as barrel file exporting from all split files
|
||||
- **Complexity**: 3+ steps → Requires sub-agent delegation
|
||||
|
||||
### 4. `src/lib/game/data/enchantment-effects.ts` (846 lines) - ENCHANTMENT DATA
|
||||
**Action**: Split by category or move data to JSON
|
||||
- Separate files: `spell-enchants.ts`, `mana-enchants.ts`, `combat-enchants.ts`, etc.
|
||||
- Keep `ENCHANTMENT_EFFECTS` as main export
|
||||
- **Complexity**: 2-3 steps → Consider sub-agent
|
||||
|
||||
### 5. `src/components/game/tabs/CraftingTab.tsx` (965 lines) - CRAFTING UI
|
||||
**Action**: Split by crafting stage
|
||||
- `EnchantmentDesigner.tsx` (design stage)
|
||||
- `EnchantmentPreparer.tsx` (prepare stage)
|
||||
- `EnchantmentApplier.tsx` (apply stage)
|
||||
- `EquipmentCrafter.tsx` (craft stage)
|
||||
- Keep `CraftingTab.tsx` as coordinator
|
||||
- **Complexity**: 2-3 steps → Consider sub-agent
|
||||
|
||||
## Medium Priority Files
|
||||
|
||||
### 6. `src/lib/game/types.ts` (516 lines) - TYPE DEFINITIONS
|
||||
**Action**: Split into domain-specific type files
|
||||
- `types/elements.ts`, `types/attunements.ts`, `types/spells.ts`, etc.
|
||||
- Keep `types/index.ts` as barrel file
|
||||
|
||||
### 7. `src/lib/game/computed-stats.ts` (492 lines) - MIXED UTILITIES
|
||||
**Action**: Split by responsibility
|
||||
- `formatting.ts` (fmt, fmtDec)
|
||||
- `floor-utils.ts` (floor HP, floor element)
|
||||
- `mana-utils.ts` (computeMaxMana, computeRegen, etc.)
|
||||
- `combat-utils.ts` (calcDamage, calcInsight, etc.)
|
||||
|
||||
### 8. `src/lib/game/utils.ts` (372 lines) - MIXED UTILITIES
|
||||
**Action**: Same as computed-stats.ts - split by responsibility
|
||||
|
||||
## Low Priority Files (Acceptable Size/Structure)
|
||||
- `src/lib/game/stores/gameStore.ts` (509 lines) - Good coordinator
|
||||
- `src/lib/game/store/craftingSlice.ts` (644 lines) - Well-structured slice
|
||||
- `src/components/game/tabs/DebugTab.tsx` (700 lines) - Debug UI, can split if desired
|
||||
- `src/app/page.tsx` (465 lines) - Main page, could lazy load tabs
|
||||
|
||||
## Guidelines
|
||||
1. Keep public APIs stable - don't rename exports unless mistake
|
||||
2. Don't change game balance values
|
||||
3. Don't refactor working code just for style
|
||||
4. Don't introduce new dependencies
|
||||
5. When in doubt, flag it and move on
|
||||
6. Verify build after each file split
|
||||
7. Commit regularly
|
||||
|
||||
## Execution Order
|
||||
1. Start with `store.ts` (highest priority, completes architecture migration)
|
||||
2. Then `skill-evolution.ts` (second largest)
|
||||
3. Then `constants.ts` (mixed constants)
|
||||
4. Other files as time permits
|
||||
|
||||
## Verification
|
||||
- Run `npm run build` after each refactoring step
|
||||
- Run `npm run test` if tests exist
|
||||
- Commit changes regularly with descriptive messages
|
||||
@@ -0,0 +1,373 @@
|
||||
Mana-Loop/
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ └── docker-build.yaml
|
||||
├── .husky/
|
||||
│ ├── scripts/
|
||||
│ │ ├── check-file-size.js
|
||||
│ │ ├── generate-dependency-graph.js
|
||||
│ │ └── generate-project-tree.js
|
||||
│ ├── post-merge
|
||||
│ └── pre-commit
|
||||
├── docs/
|
||||
│ ├── GAME_BRIEFING.md
|
||||
│ ├── circular-deps.txt
|
||||
│ ├── dependency-graph.json
|
||||
│ └── project-structure.txt
|
||||
├── e2e/
|
||||
├── playwright-report/
|
||||
│ ├── data/
|
||||
│ │ ├── 1513ea5b9ea5985996f67ca36f2bc4d34add51f1.webm
|
||||
│ │ ├── 23eb0c541b68af33d962c3ac20ba74eb9ba477b3.md
|
||||
│ │ ├── 25af666b2659e25b596f1eb58ca5629f38f0fa74.png
|
||||
│ │ ├── 294ed85dfd5fbd79486f5274129a1d8b83cfa676.png
|
||||
│ │ ├── 37c584c77b029af648d58a063f9724538662c6d0.webm
|
||||
│ │ ├── 4d1229974e5326e2351c32921095bff6e989005e.png
|
||||
│ │ ├── 4f22caa1a2b454f813b4c68c510a2ef0b340a248.md
|
||||
│ │ ├── 6408809a17a0a92b06e5cc75fcee95e9778138c4.md
|
||||
│ │ ├── 66a1f85e1e6a655dfb90f10bd1a60887cffa87da.md
|
||||
│ │ ├── 6b97a6c84cfda4c717249f240d0a80e1b195498a.png
|
||||
│ │ ├── 6c1c7d873c0c5262ffca286974649ec3bf1eb3f4.md
|
||||
│ │ ├── 72280c2048aa77a6b58afc7bba8f9db3dfd1c68b.webm
|
||||
│ │ ├── 8035d8abad1bfb2166374e25b55f52324fef1275.png
|
||||
│ │ ├── 8396039272c615989307eaf4113a77b0d77cfbdd.webm
|
||||
│ │ ├── a69b7491fd34ee0580bc0153a90dc146b509aac3.md
|
||||
│ │ ├── bb3c9d51cafcb654c796b093c72c5b702f52faed.webm
|
||||
│ │ ├── bee318a3f485bd3e98088a4735e02181585e431b.png
|
||||
│ │ ├── c0f44af041cac0f5d5efaec8a9a9e5d165c8d26a.png
|
||||
│ │ ├── cf49b56fde3bacf27d842ef4bfeed4887d97f01e.webm
|
||||
│ │ ├── dbea283cbcf6aaed195161609c68ab7de0c6adfa.png
|
||||
│ │ ├── dc2d9fe97c08dd61f42a27ead0829c2d74322ccc.webm
|
||||
│ │ ├── e3d1abb209771785e7247c38fd372d8fd61b7ea4.md
|
||||
│ │ ├── e59720b989841926cc856d6a00be0a6f8365cf49.webm
|
||||
│ │ └── f5ba77f8b20c452bd2c31718b44897276882a465.md
|
||||
│ └── index.html
|
||||
├── public/
|
||||
│ ├── fonts/
|
||||
│ │ ├── GeistMonoVF.woff
|
||||
│ │ └── GeistVF.woff
|
||||
│ ├── logo.svg
|
||||
│ └── robots.txt
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── GameOverScreen.tsx
|
||||
│ │ │ ├── GrimoireTab.tsx
|
||||
│ │ │ └── LeftPanel.tsx
|
||||
│ │ ├── globals.css
|
||||
│ │ ├── layout.tsx
|
||||
│ │ └── page.tsx
|
||||
│ ├── components/
|
||||
│ │ ├── game/
|
||||
│ │ │ ├── LootInventory/
|
||||
│ │ │ │ ├── BlueprintsSection.tsx
|
||||
│ │ │ │ ├── EquipmentItem.tsx
|
||||
│ │ │ │ ├── EssenceItem.tsx
|
||||
│ │ │ │ ├── MaterialItem.tsx
|
||||
│ │ │ │ ├── icons.ts
|
||||
│ │ │ │ └── types.ts
|
||||
│ │ │ ├── crafting/
|
||||
│ │ │ │ ├── EnchantmentDesigner/
|
||||
│ │ │ │ │ ├── DesignForm.tsx
|
||||
│ │ │ │ │ ├── EffectSelector.tsx
|
||||
│ │ │ │ │ ├── EquipmentTypeSelector.tsx
|
||||
│ │ │ │ │ ├── SavedDesigns.tsx
|
||||
│ │ │ │ │ ├── types.ts
|
||||
│ │ │ │ │ └── utils.ts
|
||||
│ │ │ │ ├── EnchantmentApplier.tsx
|
||||
│ │ │ │ ├── EnchantmentDesigner.tsx
|
||||
│ │ │ │ ├── EnchantmentPreparer.tsx
|
||||
│ │ │ │ ├── EquipmentCrafter.tsx
|
||||
│ │ │ │ └── index.tsx
|
||||
│ │ │ ├── debug/
|
||||
│ │ │ │ ├── AttunementDebug.tsx
|
||||
│ │ │ │ ├── ElementDebug.tsx
|
||||
│ │ │ │ ├── GameStateDebug.tsx
|
||||
│ │ │ │ ├── GolemDebug.tsx
|
||||
│ │ │ │ ├── PactDebug.tsx
|
||||
│ │ │ │ ├── debug-context.tsx
|
||||
│ │ │ │ └── index.tsx
|
||||
│ │ │ ├── shared/
|
||||
│ │ │ ├── tabs/
|
||||
│ │ │ │ ├── CraftingTab/
|
||||
│ │ │ │ │ ├── EnchanterSubTab.tsx
|
||||
│ │ │ │ │ └── FabricatorSubTab.tsx
|
||||
│ │ │ │ ├── DebugTab/
|
||||
│ │ │ │ │ ├── AchievementDebugSection.tsx
|
||||
│ │ │ │ │ ├── AttunementDebugSection.tsx
|
||||
│ │ │ │ │ ├── DisciplineDebugSection.tsx
|
||||
│ │ │ │ │ ├── ElementDebugSection.tsx
|
||||
│ │ │ │ │ ├── GameStateDebugSection.tsx
|
||||
│ │ │ │ │ ├── GolemDebugSection.tsx
|
||||
│ │ │ │ │ ├── PactDebugSection.tsx
|
||||
│ │ │ │ │ └── SpireDebugSection.tsx
|
||||
│ │ │ │ ├── EquipmentTab/
|
||||
│ │ │ │ │ ├── EquipmentEffectsSummary.tsx
|
||||
│ │ │ │ │ ├── EquipmentSlotGrid.tsx
|
||||
│ │ │ │ │ └── InventoryList.tsx
|
||||
│ │ │ │ ├── SpireCombatPage/
|
||||
│ │ │ │ │ ├── RoomDisplay.tsx
|
||||
│ │ │ │ │ ├── SpireActivityLog.tsx
|
||||
│ │ │ │ │ ├── SpireCombatControls.tsx
|
||||
│ │ │ │ │ ├── SpireCombatPage.tsx
|
||||
│ │ │ │ │ ├── SpireHeader.tsx
|
||||
│ │ │ │ │ ├── SpireManaDisplay.tsx
|
||||
│ │ │ │ │ └── index.ts
|
||||
│ │ │ │ ├── StatsTab/
|
||||
│ │ │ │ │ ├── CombatStatsSection.tsx
|
||||
│ │ │ │ │ ├── ElementStatsSection.tsx
|
||||
│ │ │ │ │ ├── LoopStatsSection.tsx
|
||||
│ │ │ │ │ ├── ManaStatsSection.tsx
|
||||
│ │ │ │ │ ├── PactStatusSection.tsx
|
||||
│ │ │ │ │ └── StudyStatsSection.tsx
|
||||
│ │ │ │ ├── AchievementsTab.tsx
|
||||
│ │ │ │ ├── ActivityLog.tsx
|
||||
│ │ │ │ ├── AttunementsTab.test.ts
|
||||
│ │ │ │ ├── AttunementsTab.tsx
|
||||
│ │ │ │ ├── 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
|
||||
│ │ │ ├── GameToast.tsx
|
||||
│ │ │ ├── ManaDisplay.tsx
|
||||
│ │ │ ├── TimeDisplay.tsx
|
||||
│ │ │ ├── UpgradeDialog.tsx
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── types.ts
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── action-button.tsx
|
||||
│ │ │ ├── alert-dialog.tsx
|
||||
│ │ │ ├── badge.tsx
|
||||
│ │ │ ├── button.tsx
|
||||
│ │ │ ├── card.tsx
|
||||
│ │ │ ├── dialog.tsx
|
||||
│ │ │ ├── element-badge.tsx
|
||||
│ │ │ ├── game-card.tsx
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── input.tsx
|
||||
│ │ │ ├── label.tsx
|
||||
│ │ │ ├── mana-bar.tsx
|
||||
│ │ │ ├── progress.tsx
|
||||
│ │ │ ├── scroll-area.tsx
|
||||
│ │ │ ├── section-header.tsx
|
||||
│ │ │ ├── select.tsx
|
||||
│ │ │ ├── separator.tsx
|
||||
│ │ │ ├── sheet.tsx
|
||||
│ │ │ ├── skeleton.tsx
|
||||
│ │ │ ├── stat-row.tsx
|
||||
│ │ │ ├── stepper.tsx
|
||||
│ │ │ ├── switch.tsx
|
||||
│ │ │ ├── tabs.tsx
|
||||
│ │ │ ├── toast.tsx
|
||||
│ │ │ ├── toaster.tsx
|
||||
│ │ │ ├── toggle.tsx
|
||||
│ │ │ ├── tooltip-info.tsx
|
||||
│ │ │ ├── tooltip.tsx
|
||||
│ │ │ └── value-display.tsx
|
||||
│ │ └── ErrorBoundary.tsx
|
||||
│ ├── hooks/
|
||||
│ │ ├── use-mobile.ts
|
||||
│ │ └── use-toast.ts
|
||||
│ └── lib/
|
||||
│ ├── game/
|
||||
│ │ ├── __tests__/
|
||||
│ │ │ ├── store-method-tests/
|
||||
│ │ │ ├── achievements.test.ts
|
||||
│ │ │ ├── bug-fixes.test.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
|
||||
│ │ │ │ ├── aoe-spells.ts
|
||||
│ │ │ │ ├── basic-elemental-spells.ts
|
||||
│ │ │ │ ├── compound-spells.ts
|
||||
│ │ │ │ ├── enchantment-spells.ts
|
||||
│ │ │ │ ├── legendary-spells.ts
|
||||
│ │ │ │ ├── lightning-spells.ts
|
||||
│ │ │ │ ├── master-spells.ts
|
||||
│ │ │ │ ├── raw-spells.ts
|
||||
│ │ │ │ └── utility-spells.ts
|
||||
│ │ │ ├── core.ts
|
||||
│ │ │ ├── elements.ts
|
||||
│ │ │ ├── guardians.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── prestige.ts
|
||||
│ │ │ ├── rooms.ts
|
||||
│ │ │ └── spells.ts
|
||||
│ │ ├── crafting-actions/
|
||||
│ │ │ ├── application-actions.ts
|
||||
│ │ │ ├── computed-getters.ts
|
||||
│ │ │ ├── crafting-equipment-actions.ts
|
||||
│ │ │ ├── design-actions.ts
|
||||
│ │ │ ├── disenchant-actions.ts
|
||||
│ │ │ ├── equipment-actions.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── preparation-actions.ts
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── disciplines/
|
||||
│ │ │ │ ├── base.ts
|
||||
│ │ │ │ ├── enchanter.ts
|
||||
│ │ │ │ ├── fabricator.ts
|
||||
│ │ │ │ ├── index.ts
|
||||
│ │ │ │ └── invoker.ts
|
||||
│ │ │ ├── enchantments/
|
||||
│ │ │ │ ├── spell-effects/
|
||||
│ │ │ │ │ ├── basic-spells.ts
|
||||
│ │ │ │ │ ├── index.ts
|
||||
│ │ │ │ │ ├── legendary-spells.ts
|
||||
│ │ │ │ │ ├── lightning-spells.ts
|
||||
│ │ │ │ │ ├── metal-spells.ts
|
||||
│ │ │ │ │ ├── sand-spells.ts
|
||||
│ │ │ │ │ ├── tier2-spells.ts
|
||||
│ │ │ │ │ ├── tier3-spells.ts
|
||||
│ │ │ │ │ └── types.ts
|
||||
│ │ │ │ ├── combat-effects.ts
|
||||
│ │ │ │ ├── defense-effects.ts
|
||||
│ │ │ │ ├── elemental-effects.ts
|
||||
│ │ │ │ ├── index.ts
|
||||
│ │ │ │ ├── mana-effects.ts
|
||||
│ │ │ │ ├── special-effects.ts
|
||||
│ │ │ │ └── utility-effects.ts
|
||||
│ │ │ ├── equipment/
|
||||
│ │ │ │ ├── accessories.ts
|
||||
│ │ │ │ ├── body.ts
|
||||
│ │ │ │ ├── casters.ts
|
||||
│ │ │ │ ├── catalysts.ts
|
||||
│ │ │ │ ├── equipment-types-data.ts
|
||||
│ │ │ │ ├── feet.ts
|
||||
│ │ │ │ ├── hands.ts
|
||||
│ │ │ │ ├── head.ts
|
||||
│ │ │ │ ├── index.ts
|
||||
│ │ │ │ ├── shields.ts
|
||||
│ │ │ │ ├── swords.ts
|
||||
│ │ │ │ ├── types.ts
|
||||
│ │ │ │ └── utils.ts
|
||||
│ │ │ ├── golems/
|
||||
│ │ │ │ ├── base-golems.ts
|
||||
│ │ │ │ ├── elemental-golems.ts
|
||||
│ │ │ │ ├── golems-data.ts
|
||||
│ │ │ │ ├── hybrid-golems.ts
|
||||
│ │ │ │ ├── index.ts
|
||||
│ │ │ │ ├── types.ts
|
||||
│ │ │ │ └── utils.ts
|
||||
│ │ │ ├── achievements.ts
|
||||
│ │ │ ├── attunements.ts
|
||||
│ │ │ ├── crafting-recipes.ts
|
||||
│ │ │ ├── enchantment-effects.ts
|
||||
│ │ │ ├── enchantment-types.ts
|
||||
│ │ │ ├── fabricator-recipes.ts
|
||||
│ │ │ ├── guardian-encounters.ts
|
||||
│ │ │ └── loot-drops.ts
|
||||
│ │ ├── effects/
|
||||
│ │ │ ├── discipline-effects.ts
|
||||
│ │ │ ├── dynamic-compute.ts
|
||||
│ │ │ ├── special-effects.ts
|
||||
│ │ │ ├── upgrade-effects.ts
|
||||
│ │ │ └── upgrade-effects.types.ts
|
||||
│ │ ├── hooks/
|
||||
│ │ │ └── useGameDerived.ts
|
||||
│ │ ├── 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.ts
|
||||
│ │ │ ├── manaStore.ts
|
||||
│ │ │ ├── prestigeStore.ts
|
||||
│ │ │ ├── tick-pipeline.ts
|
||||
│ │ │ └── uiStore.ts
|
||||
│ │ ├── types/
|
||||
│ │ │ ├── attunements.ts
|
||||
│ │ │ ├── disciplines.ts
|
||||
│ │ │ ├── elements.ts
|
||||
│ │ │ ├── equipment.ts
|
||||
│ │ │ ├── equipmentSlot.ts
|
||||
│ │ │ ├── game.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── spells.ts
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── activity-log.ts
|
||||
│ │ │ ├── combat-utils.ts
|
||||
│ │ │ ├── discipline-math.ts
|
||||
│ │ │ ├── enemy-generator.ts
|
||||
│ │ │ ├── enemy-utils.ts
|
||||
│ │ │ ├── floor-utils.ts
|
||||
│ │ │ ├── formatting.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── mana-utils.ts
|
||||
│ │ │ ├── pact-utils.ts
|
||||
│ │ │ ├── result.ts
|
||||
│ │ │ ├── room-utils.ts
|
||||
│ │ │ ├── safe-persist.ts
|
||||
│ │ │ └── spire-utils.ts
|
||||
│ │ ├── constants.ts
|
||||
│ │ ├── crafting-apply.ts
|
||||
│ │ ├── crafting-attunements.ts
|
||||
│ │ ├── crafting-design.ts
|
||||
│ │ ├── crafting-equipment.ts
|
||||
│ │ ├── crafting-loot.ts
|
||||
│ │ ├── crafting-prep.ts
|
||||
│ │ ├── crafting-utils.ts
|
||||
│ │ ├── effects.ts
|
||||
│ │ └── types.ts
|
||||
│ └── utils.ts
|
||||
├── test-results/
|
||||
│ └── .last-run.json
|
||||
├── .dockerignore
|
||||
├── .gitignore
|
||||
├── AGENTS.md
|
||||
├── Caddyfile
|
||||
├── Dockerfile
|
||||
├── README.md
|
||||
├── bun.lock
|
||||
├── bunfig.toml
|
||||
├── components.json
|
||||
├── docker-compose.yml
|
||||
├── eslint.config.mjs
|
||||
├── next.config.ts
|
||||
├── package-lock.json
|
||||
├── package.json
|
||||
├── playwright.config.ts
|
||||
├── postcss.config.mjs
|
||||
├── scorecard.png
|
||||
├── tailwind.config.ts
|
||||
├── tsconfig.json
|
||||
└── vitest.config.ts
|
||||
@@ -1,651 +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)
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| Ascension | ⭐ | Loop-persisting benefits |
|
||||
|
||||
### 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
|
||||
|
||||
### Ascension Skills (Any Attunement)
|
||||
|
||||
| Skill | Max | Effect | Base Cost | Study Time | Attunement Req |
|
||||
|-------|-----|--------|-----------|------------|----------------|
|
||||
| Insight Harvest | 5 | +10% insight/level | 1000 | 20h | Any level 5+ |
|
||||
| Guardian Bane | 3 | +20% dmg vs guardians/level | 1500 | 30h | Invoker 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 | 5 | +10% effect power/level | 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.
|
||||
|
||||
### Pact-Weaving (Invoker + Enchanter)
|
||||
|
||||
**Requirement:** Invoker 3 + Enchanter 3
|
||||
**Max Level:** 5 (with Elite Perk at Level 5)
|
||||
|
||||
**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)
|
||||
|
||||
**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)
|
||||
|
||||
**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.
|
||||
|
||||
#### Tier 1 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 |
|
||||
|
||||
#### Tier 2 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 |
|
||||
|
||||
#### Tier 3 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
|
||||
|
||||
### Mana Well Upgrade Tree
|
||||
|
||||
#### Tier 1 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)
|
||||
|
||||
#### Tier 2 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
|
||||
|
||||
#### Tier 1 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
|
||||
|
||||
#### Tier 1 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
|
||||
|
||||
#### Tier 1 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
|
||||
|
||||
#### Tier 1 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
|
||||
|
||||
#### Tier 1 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
|
||||
|
||||
#### Tier 1 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 5)
|
||||
- **Level 5:** Pure Essence (+25% power for tier 1 enchants)
|
||||
- **Tier 2 Level 5:** Perfect Essence (+50% all enchant power)
|
||||
|
||||
#### 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)
|
||||
|
||||
#### Insight Harvest (Max 5)
|
||||
- **Level 5:** Insight Bounty (+25% insight from guardians)
|
||||
- **Tier 2 Level 5:** Greater Harvest (+50% insight from all sources)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## 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**
|
||||
@@ -1 +0,0 @@
|
||||
Here are all the generated files.
|
||||
@@ -1,196 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
username: string;
|
||||
content: string;
|
||||
timestamp: Date | string;
|
||||
type: 'user' | 'system';
|
||||
}
|
||||
|
||||
export default function SocketDemo() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputMessage, setInputMessage] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [isUsernameSet, setIsUsernameSet] = useState(false);
|
||||
const [socket, setSocket] = useState<any>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Connect to websocket server
|
||||
// Never use PORT in the URL, alyways use XTransformPort
|
||||
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
|
||||
const socketInstance = io('/?XTransformPort=3003', {
|
||||
transports: ['websocket', 'polling'],
|
||||
forceNew: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
setSocket(socketInstance);
|
||||
|
||||
socketInstance.on('connect', () => {
|
||||
setIsConnected(true);
|
||||
});
|
||||
|
||||
socketInstance.on('disconnect', () => {
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
socketInstance.on('message', (msg: Message) => {
|
||||
setMessages(prev => [...prev, msg]);
|
||||
});
|
||||
|
||||
socketInstance.on('user-joined', (data: { user: User; message: Message }) => {
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
setUsers(prev => {
|
||||
if (!prev.find(u => u.id === data.user.id)) {
|
||||
return [...prev, data.user];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
});
|
||||
|
||||
socketInstance.on('user-left', (data: { user: User; message: Message }) => {
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
setUsers(prev => prev.filter(u => u.id !== data.user.id));
|
||||
});
|
||||
|
||||
socketInstance.on('users-list', (data: { users: User[] }) => {
|
||||
setUsers(data.users);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socketInstance.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleJoin = () => {
|
||||
if (socket && username.trim() && isConnected) {
|
||||
socket.emit('join', { username: username.trim() });
|
||||
setIsUsernameSet(true);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
if (socket && inputMessage.trim() && username.trim()) {
|
||||
socket.emit('message', {
|
||||
content: inputMessage.trim(),
|
||||
username: username.trim()
|
||||
});
|
||||
setInputMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
WebSocket Demo
|
||||
<span className={`text-sm px-2 py-1 rounded ${isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isUsernameSet ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleJoin();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your username..."
|
||||
disabled={!isConnected}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleJoin}
|
||||
disabled={!isConnected || !username.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
Join Chat
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="h-80 w-full border rounded-md p-4">
|
||||
<div className="space-y-2">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-gray-500 text-center">No messages yet</p>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div key={msg.id} className="border-b pb-2 last:border-b-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${msg.type === 'system'
|
||||
? 'text-blue-600 italic'
|
||||
: 'text-gray-700'
|
||||
}`}>
|
||||
{msg.username}
|
||||
</p>
|
||||
<p className={`${msg.type === 'system'
|
||||
? 'text-blue-500 italic'
|
||||
: 'text-gray-900'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type a message..."
|
||||
disabled={!isConnected}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={!isConnected || !inputMessage.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { createServer } from 'http'
|
||||
import { Server } from 'socket.io'
|
||||
|
||||
const httpServer = createServer()
|
||||
const io = new Server(httpServer, {
|
||||
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
|
||||
path: '/',
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
},
|
||||
pingTimeout: 60000,
|
||||
pingInterval: 25000,
|
||||
})
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
username: string
|
||||
content: string
|
||||
timestamp: Date
|
||||
type: 'user' | 'system'
|
||||
}
|
||||
|
||||
const users = new Map<string, User>()
|
||||
|
||||
const generateMessageId = () => Math.random().toString(36).substr(2, 9)
|
||||
|
||||
const createSystemMessage = (content: string): Message => ({
|
||||
id: generateMessageId(),
|
||||
username: 'System',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
type: 'system'
|
||||
})
|
||||
|
||||
const createUserMessage = (username: string, content: string): Message => ({
|
||||
id: generateMessageId(),
|
||||
username,
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
type: 'user'
|
||||
})
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`User connected: ${socket.id}`)
|
||||
|
||||
// Add test event handler
|
||||
socket.on('test', (data) => {
|
||||
console.log('Received test message:', data)
|
||||
socket.emit('test-response', {
|
||||
message: 'Server received test message',
|
||||
data: data,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('join', (data: { username: string }) => {
|
||||
const { username } = data
|
||||
|
||||
// Create user object
|
||||
const user: User = {
|
||||
id: socket.id,
|
||||
username
|
||||
}
|
||||
|
||||
// Add to user list
|
||||
users.set(socket.id, user)
|
||||
|
||||
// Send join message to all users
|
||||
const joinMessage = createSystemMessage(`${username} joined the chat room`)
|
||||
io.emit('user-joined', { user, message: joinMessage })
|
||||
|
||||
// Send current user list to new user
|
||||
const usersList = Array.from(users.values())
|
||||
socket.emit('users-list', { users: usersList })
|
||||
|
||||
console.log(`${username} joined the chat room, current online users: ${users.size}`)
|
||||
})
|
||||
|
||||
socket.on('message', (data: { content: string; username: string }) => {
|
||||
const { content, username } = data
|
||||
const user = users.get(socket.id)
|
||||
|
||||
if (user && user.username === username) {
|
||||
const message = createUserMessage(username, content)
|
||||
io.emit('message', message)
|
||||
console.log(`${username}: ${content}`)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
const user = users.get(socket.id)
|
||||
|
||||
if (user) {
|
||||
// Remove from user list
|
||||
users.delete(socket.id)
|
||||
|
||||
// Send leave message to all users
|
||||
const leaveMessage = createSystemMessage(`${user.username} left the chat room`)
|
||||
io.emit('user-left', { user: { id: socket.id, username: user.username }, message: leaveMessage })
|
||||
|
||||
console.log(`${user.username} left the chat room, current online users: ${users.size}`)
|
||||
} else {
|
||||
console.log(`User disconnected: ${socket.id}`)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error(`Socket error (${socket.id}):`, error)
|
||||
})
|
||||
})
|
||||
|
||||
const PORT = 3003
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`WebSocket server running on port ${PORT}`)
|
||||
})
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Received SIGTERM signal, shutting down server...')
|
||||
httpServer.close(() => {
|
||||
console.log('WebSocket server closed')
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Received SIGINT signal, shutting down server...')
|
||||
httpServer.close(() => {
|
||||
console.log('WebSocket server closed')
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
@@ -3,98 +3,95 @@
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000 2>&1 | tee dev.log",
|
||||
"dev": "next dev -p 3000 --hostname 0.0.0.0 2>&1 | tee dev.log",
|
||||
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
|
||||
"start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"db:push": "prisma db push",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:reset": "prisma migrate reset"
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@mdxeditor/editor": "^3.39.1",
|
||||
"@prisma/client": "^6.11.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-auth": "^4.24.11",
|
||||
"next-intl": "^4.3.4",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"prisma": "^6.11.1",
|
||||
"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",
|
||||
"z-ai-web-dev-sdk": "^0.0.17",
|
||||
"zod": "^4.0.2",
|
||||
"zustand": "^5.0.6"
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"jsdom": "^29.0.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.2"
|
||||
"@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.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: combat.spec.ts >> Combat System >> shows floor information in spire mode
|
||||
- Location: e2e/combat.spec.ts:65:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(locator).toBeVisible() failed
|
||||
|
||||
Locator: locator('text="Floor"').first()
|
||||
Expected: visible
|
||||
Timeout: 5000ms
|
||||
Error: element(s) not found
|
||||
|
||||
Call log:
|
||||
- Expect "toBeVisible" with timeout 5000ms
|
||||
- waiting for locator('text="Floor"').first()
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- heading "MANA LOOP" [level=1] [ref=e5]
|
||||
- generic [ref=e7]:
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9]: Day 1
|
||||
- generic [ref=e10]: 02:04
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]: "0"
|
||||
- generic [ref=e13]: Insight
|
||||
- main [ref=e14]:
|
||||
- generic [ref=e15]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20]: "15"
|
||||
- generic [ref=e21]: / 100
|
||||
- generic [ref=e22]:
|
||||
- text: +3.0 mana/hr
|
||||
- generic [ref=e23]: (1.5x med)
|
||||
- progressbar [ref=e24]
|
||||
- button "Gather +1 Mana" [ref=e26]:
|
||||
- img
|
||||
- text: Gather +1 Mana
|
||||
- generic [ref=e27]:
|
||||
- button "Elemental Mana (1)" [ref=e28]:
|
||||
- generic [ref=e29]: Elemental Mana (1)
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]:
|
||||
- generic [ref=e34]:
|
||||
- generic [ref=e35]: 🔗
|
||||
- generic [ref=e36]: Transference
|
||||
- generic [ref=e39]: 0/10
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]: "1"
|
||||
- generic [ref=e42]: "2"
|
||||
- generic [ref=e43]: "3"
|
||||
- generic [ref=e44]: "4"
|
||||
- generic [ref=e45]: "5"
|
||||
- generic [ref=e46]: "6"
|
||||
- generic [ref=e47]: "7"
|
||||
- generic [ref=e48]: "8"
|
||||
- generic [ref=e49]: "9"
|
||||
- generic [ref=e50]: "10"
|
||||
- generic [ref=e51]: "11"
|
||||
- generic [ref=e52]: "12"
|
||||
- generic [ref=e53]: "13"
|
||||
- generic [ref=e54]: "14"
|
||||
- generic [ref=e55]: "15"
|
||||
- generic [ref=e56]: "16"
|
||||
- generic [ref=e57]: "17"
|
||||
- generic [ref=e58]: "18"
|
||||
- generic [ref=e59]: "19"
|
||||
- generic [ref=e60]: "20"
|
||||
- generic [ref=e61]: "21"
|
||||
- generic [ref=e62]: "22"
|
||||
- generic [ref=e63]: "23"
|
||||
- generic [ref=e64]: "24"
|
||||
- generic [ref=e65]: "25"
|
||||
- generic [ref=e66]: "26"
|
||||
- generic [ref=e67]: "27"
|
||||
- generic [ref=e68]: "28"
|
||||
- generic [ref=e69]: "29"
|
||||
- generic [ref=e70]: "30"
|
||||
- generic [ref=e72]:
|
||||
- tablist [ref=e73]:
|
||||
- tab "⚔️ Spire" [selected] [ref=e74]
|
||||
- tab "✨ Attune" [ref=e75]
|
||||
- tab "🗿 Golems" [ref=e76]
|
||||
- tab "📚 Skills" [ref=e77]
|
||||
- tab "🔮 Spells" [ref=e78]
|
||||
- tab "🛡️ Gear" [ref=e79]
|
||||
- tab "🔧 Craft" [ref=e80]
|
||||
- tab "💎 Loot" [ref=e81]
|
||||
- tab "🏆 Achieve" [ref=e82]
|
||||
- tab "📊 Stats" [ref=e83]
|
||||
- tab "🐛 Debug" [ref=e84]
|
||||
- tab "📖 Grimoire" [ref=e85]
|
||||
- tabpanel "⚔️ Spire" [ref=e86]:
|
||||
- generic [ref=e87]:
|
||||
- generic [ref=e89]:
|
||||
- button "Exit Spire Mode" [ref=e90]:
|
||||
- img
|
||||
- text: Exit Spire Mode
|
||||
- generic [ref=e91]: Climb down to floor 1 to return to the main game
|
||||
- generic [ref=e92]:
|
||||
- heading "Current Floor 🐝 Swarm" [level=3] [ref=e94]:
|
||||
- generic [ref=e95]: Current Floor
|
||||
- generic [ref=e96]: 🐝 Swarm
|
||||
- generic [ref=e97]:
|
||||
- generic [ref=e98]:
|
||||
- generic [ref=e99]: "1"
|
||||
- generic [ref=e100]: / 100
|
||||
- generic [ref=e101]: 🔥 Fire
|
||||
- generic [ref=e102]:
|
||||
- text: "Best: Floor"
|
||||
- strong [ref=e103]: "1"
|
||||
- text: "• Pacts:"
|
||||
- strong [ref=e104]: "0"
|
||||
- generic [ref=e106]:
|
||||
- generic [ref=e108]: Active Spells (1)
|
||||
- generic [ref=e110]:
|
||||
- generic [ref=e111]:
|
||||
- generic [ref=e112]: Mana BoltBasic
|
||||
- generic [ref=e113]: ✓
|
||||
- generic [ref=e114]: ⚔️ 5 dmg • 3 raw • ⚡ 15 dmg/hr
|
||||
- generic [ref=e115]:
|
||||
- generic [ref=e116]: Swarm Enemies (6)
|
||||
- generic [ref=e118]:
|
||||
- generic [ref=e119]:
|
||||
- img [ref=e120]
|
||||
- generic [ref=e125]: Emberling
|
||||
- generic [ref=e126]: 🔥 60/60 HP
|
||||
- generic [ref=e130]:
|
||||
- generic [ref=e131]:
|
||||
- img [ref=e132]
|
||||
- generic [ref=e137]: Fire Imp
|
||||
- generic [ref=e138]: 🔥 60/60 HP
|
||||
- generic [ref=e142]:
|
||||
- generic [ref=e143]:
|
||||
- img [ref=e144]
|
||||
- generic [ref=e149]: Scorchling
|
||||
- generic [ref=e150]: 🔥 60/60 HP
|
||||
- generic [ref=e154]:
|
||||
- generic [ref=e155]:
|
||||
- img [ref=e156]
|
||||
- generic [ref=e161]: Flame Sprite
|
||||
- generic [ref=e162]: 🔥 60/60 HP
|
||||
- generic [ref=e166]:
|
||||
- generic [ref=e167]:
|
||||
- img [ref=e168]
|
||||
- generic [ref=e173]: Emberling
|
||||
- generic [ref=e174]: 🔥 60/60 HP
|
||||
- generic [ref=e178]:
|
||||
- generic [ref=e179]:
|
||||
- img [ref=e180]
|
||||
- generic [ref=e185]: Inferno Whelp
|
||||
- generic [ref=e186]: 🔥 60/60 HP
|
||||
- generic [ref=e189]:
|
||||
- generic [ref=e191]: Floor Navigation
|
||||
- generic [ref=e192]:
|
||||
- generic [ref=e193]:
|
||||
- button "Climb Up" [ref=e194]:
|
||||
- img
|
||||
- text: Climb Up
|
||||
- button "Climb Down" [disabled]:
|
||||
- img
|
||||
- text: Climb Down
|
||||
- generic [ref=e195]: Click Climb Up/Down to begin climbing
|
||||
- generic [ref=e196]:
|
||||
- generic [ref=e198]: Combat Stats
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]: "Total DPS: —"
|
||||
- generic [ref=e201]:
|
||||
- generic [ref=e202]: Active Spells
|
||||
- generic [ref=e203]:
|
||||
- generic [ref=e204]:
|
||||
- generic [ref=e205]:
|
||||
- text: Mana Bolt
|
||||
- generic [ref=e206]: Basic
|
||||
- generic [ref=e207]: ✓
|
||||
- generic [ref=e208]: ⚔️ 5 dmg • 3 raw • ⚡ 15 dmg/hr
|
||||
- generic [ref=e210]: "Study Speed: 100%"
|
||||
- generic [ref=e211]:
|
||||
- generic [ref=e213]: Activity Log
|
||||
- generic [ref=e219]: No activity yet...
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- button "Open Next.js Dev Tools" [ref=e225] [cursor=pointer]:
|
||||
- img [ref=e226]
|
||||
- alert [ref=e229]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | /**
|
||||
4 | * E2E tests for combat system:
|
||||
5 | * - Entering spire mode (climbing)
|
||||
6 | * - Casting spells and seeing progress
|
||||
7 | * - Enemy HP reduction
|
||||
8 | * - Floor advancement
|
||||
9 | */
|
||||
10 |
|
||||
11 | test.describe('Combat System', () => {
|
||||
12 | test.beforeEach(async ({ page }) => {
|
||||
13 | await page.goto('/');
|
||||
14 | // Clear game state to ensure a fresh start
|
||||
15 | await page.evaluate(() => {
|
||||
16 | Object.keys(localStorage)
|
||||
17 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
18 | .forEach((k) => localStorage.removeItem(k));
|
||||
19 | });
|
||||
20 | await page.reload();
|
||||
21 | await page.waitForLoadState('networkidle');
|
||||
22 | });
|
||||
23 |
|
||||
24 | test('can see the Spire tab and "Climb the Spire" button', async ({ page }) => {
|
||||
25 | // The Spire tab uses an icon + text, so match by the tab role
|
||||
26 | const spireTab = page.getByRole('tab', { name: /⚔️ Spire/ });
|
||||
27 | await expect(spireTab).toBeVisible();
|
||||
28 |
|
||||
29 | // Main page should show "Climb the Spire" button
|
||||
30 | const climbBtn = page.getByRole('button', { name: 'Climb the Spire' });
|
||||
31 | await expect(climbBtn).toBeVisible();
|
||||
32 | });
|
||||
33 |
|
||||
34 | test('can enter Spire mode by clicking Climb button', async ({ page }) => {
|
||||
35 | // Click "Climb the Spire" button on the main page (via left panel)
|
||||
36 | await page.getByRole('button', { name: 'Climb the Spire' }).click();
|
||||
37 |
|
||||
38 | // Should now see Spire mode UI elements
|
||||
39 | // The "Enter Spire Mode" button appears when on the Spire tab
|
||||
40 | const enterBtn = page.getByRole('button', { name: 'Enter Spire Mode' });
|
||||
41 | await expect(enterBtn).toBeVisible({ timeout: 5000 });
|
||||
42 | });
|
||||
43 |
|
||||
44 | test('can navigate to Spire tab', async ({ page }) => {
|
||||
45 | // Click the Spire tab specifically (using role=tab to disambiguate)
|
||||
46 | await page.getByRole('tab', { name: /⚔️ Spire/ }).click();
|
||||
47 |
|
||||
48 | // Should see Spire-specific UI
|
||||
49 | const enterSpireBtn = page.getByRole('button', { name: 'Enter Spire Mode' });
|
||||
50 | await expect(enterSpireBtn).toBeVisible({ timeout: 5000 });
|
||||
51 | });
|
||||
52 |
|
||||
53 | test('can enter spire mode from the Spire tab', async ({ page }) => {
|
||||
54 | await page.getByRole('tab', { name: /⚔️ Spire/ }).click();
|
||||
55 |
|
||||
56 | const enterBtn = page.getByRole('button', { name: 'Enter Spire Mode' });
|
||||
57 | await expect(enterBtn).toBeEnabled();
|
||||
58 | await enterBtn.click();
|
||||
59 |
|
||||
60 | // After entering, should see exit button
|
||||
61 | const exitBtn = page.getByRole('button', { name: 'Exit Spire Mode' });
|
||||
62 | await expect(exitBtn).toBeVisible({ timeout: 5000 });
|
||||
63 | });
|
||||
64 |
|
||||
65 | test('shows floor information in spire mode', async ({ page }) => {
|
||||
66 | await page.getByRole('tab', { name: /⚔️ Spire/ }).click();
|
||||
67 | await page.getByRole('button', { name: 'Enter Spire Mode' }).click();
|
||||
68 |
|
||||
69 | // Should display floor number - look for "Floor" label or the floor counter
|
||||
70 | const floorDisplay = page.locator('text="Floor"').first();
|
||||
> 71 | await expect(floorDisplay).toBeVisible({ timeout: 5000 });
|
||||
| ^ Error: expect(locator).toBeVisible() failed
|
||||
72 | });
|
||||
73 | });
|
||||
```
|
||||
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 258 KiB |
@@ -0,0 +1,348 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: equipment.spec.ts >> Equipment Management >> can unequip an item from a slot
|
||||
- Location: e2e/equipment.spec.ts:113:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(locator).toBeVisible() failed
|
||||
|
||||
Locator: locator('text=Hands').locator('..').locator('button').first()
|
||||
Expected: visible
|
||||
Timeout: 5000ms
|
||||
Error: element(s) not found
|
||||
|
||||
Call log:
|
||||
- Expect "toBeVisible" with timeout 5000ms
|
||||
- waiting for locator('text=Hands').locator('..').locator('button').first()
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- heading "MANA LOOP" [level=1] [ref=e5]
|
||||
- generic [ref=e7]:
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9]: Day 1
|
||||
- generic [ref=e10]: 01:55
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]: "0"
|
||||
- generic [ref=e13]: Insight
|
||||
- main [ref=e14]:
|
||||
- generic [ref=e15]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20]: "14"
|
||||
- generic [ref=e21]: / 100
|
||||
- generic [ref=e22]:
|
||||
- text: +2.8 mana/hr
|
||||
- generic [ref=e23]: (1.4x med)
|
||||
- progressbar [ref=e24]
|
||||
- button "Gather +1 Mana" [ref=e26]:
|
||||
- img
|
||||
- text: Gather +1 Mana
|
||||
- generic [ref=e27]:
|
||||
- button "Elemental Mana (1)" [ref=e28]:
|
||||
- generic [ref=e29]: Elemental Mana (1)
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]:
|
||||
- generic [ref=e34]:
|
||||
- generic [ref=e35]: 🔗
|
||||
- generic [ref=e36]: Transference
|
||||
- generic [ref=e39]: 0/10
|
||||
- button "Climb the Spire" [ref=e40]:
|
||||
- img
|
||||
- text: Climb the Spire
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- img [ref=e44]
|
||||
- generic [ref=e46]: Current Activity
|
||||
- generic [ref=e47]: Meditating
|
||||
- generic [ref=e48]:
|
||||
- generic [ref=e49]: "1"
|
||||
- generic [ref=e50]: "2"
|
||||
- generic [ref=e51]: "3"
|
||||
- generic [ref=e52]: "4"
|
||||
- generic [ref=e53]: "5"
|
||||
- generic [ref=e54]: "6"
|
||||
- generic [ref=e55]: "7"
|
||||
- generic [ref=e56]: "8"
|
||||
- generic [ref=e57]: "9"
|
||||
- generic [ref=e58]: "10"
|
||||
- generic [ref=e59]: "11"
|
||||
- generic [ref=e60]: "12"
|
||||
- generic [ref=e61]: "13"
|
||||
- generic [ref=e62]: "14"
|
||||
- generic [ref=e63]: "15"
|
||||
- generic [ref=e64]: "16"
|
||||
- generic [ref=e65]: "17"
|
||||
- generic [ref=e66]: "18"
|
||||
- generic [ref=e67]: "19"
|
||||
- generic [ref=e68]: "20"
|
||||
- generic [ref=e69]: "21"
|
||||
- generic [ref=e70]: "22"
|
||||
- generic [ref=e71]: "23"
|
||||
- generic [ref=e72]: "24"
|
||||
- generic [ref=e73]: "25"
|
||||
- generic [ref=e74]: "26"
|
||||
- generic [ref=e75]: "27"
|
||||
- generic [ref=e76]: "28"
|
||||
- generic [ref=e77]: "29"
|
||||
- generic [ref=e78]: "30"
|
||||
- generic [ref=e80]:
|
||||
- tablist [ref=e81]:
|
||||
- tab "⚔️ Spire" [ref=e82]
|
||||
- tab "✨ Attune" [ref=e83]
|
||||
- tab "🗿 Golems" [ref=e84]
|
||||
- tab "📚 Skills" [ref=e85]
|
||||
- tab "🔮 Spells" [ref=e86]
|
||||
- tab "🛡️ Gear" [active] [selected] [ref=e87]
|
||||
- tab "🔧 Craft" [ref=e88]
|
||||
- tab "💎 Loot" [ref=e89]
|
||||
- tab "🏆 Achieve" [ref=e90]
|
||||
- tab "📊 Stats" [ref=e91]
|
||||
- tab "🐛 Debug" [ref=e92]
|
||||
- tab "📖 Grimoire" [ref=e93]
|
||||
- tabpanel "🛡️ Gear" [ref=e94]:
|
||||
- generic [ref=e95]:
|
||||
- generic [ref=e96]:
|
||||
- generic [ref=e97]:
|
||||
- heading "Equipped Gear" [level=3] [ref=e98]
|
||||
- generic [ref=e100]: 4 / 8 slots filled
|
||||
- generic [ref=e101]:
|
||||
- generic [ref=e102]:
|
||||
- heading "Weapon & Shield" [level=4] [ref=e103]
|
||||
- generic [ref=e104]:
|
||||
- 'button "Main Hand slot: Basic Staff" [ref=e106]':
|
||||
- generic [ref=e107]:
|
||||
- generic [ref=e108]:
|
||||
- img [ref=e109]
|
||||
- generic [ref=e114]: Main Hand
|
||||
- button "Unequip Basic Staff" [ref=e115]:
|
||||
- img [ref=e116]
|
||||
- generic [ref=e119]:
|
||||
- generic [ref=e120]:
|
||||
- text: Basic Staff
|
||||
- generic [ref=e121]: 2-Handed
|
||||
- generic [ref=e122]: "Enchantments: 1/50"
|
||||
- generic [ref=e124]: Mana Bolt
|
||||
- button "Off Hand slot (blocked by 2-handed weapon) (empty)" [ref=e125]:
|
||||
- generic [ref=e127]:
|
||||
- img [ref=e128]
|
||||
- generic [ref=e130]: Off Hand
|
||||
- generic [ref=e131]:
|
||||
- img
|
||||
- text: Occupied — 2H Weapon
|
||||
- generic [ref=e132]:
|
||||
- img [ref=e133]
|
||||
- text: Blocked by 2-handed weapon
|
||||
- generic [ref=e135]:
|
||||
- heading "Armor" [level=4] [ref=e136]
|
||||
- generic [ref=e137]:
|
||||
- button "Head slot (empty)" [ref=e139]:
|
||||
- generic [ref=e141]:
|
||||
- img [ref=e142]
|
||||
- generic [ref=e147]: Head
|
||||
- generic [ref=e148]: Head
|
||||
- 'button "Body slot: Civilian Shirt" [ref=e150]':
|
||||
- generic [ref=e151]:
|
||||
- generic [ref=e152]:
|
||||
- img [ref=e153]
|
||||
- generic [ref=e155]: Body
|
||||
- button "Unequip Civilian Shirt" [ref=e156]:
|
||||
- img [ref=e157]
|
||||
- generic [ref=e160]:
|
||||
- generic [ref=e161]: Civilian Shirt
|
||||
- generic [ref=e162]: "Enchantments: 0/30"
|
||||
- 'button "Hands slot: Civilian Gloves" [ref=e164]':
|
||||
- generic [ref=e165]:
|
||||
- generic [ref=e166]:
|
||||
- img [ref=e167]
|
||||
- generic [ref=e172]: Hands
|
||||
- button "Unequip Civilian Gloves" [ref=e173]:
|
||||
- img [ref=e174]
|
||||
- generic [ref=e177]:
|
||||
- generic [ref=e178]: Civilian Gloves
|
||||
- generic [ref=e179]: "Enchantments: 0/20"
|
||||
- 'button "Feet slot: Civilian Shoes" [ref=e181]':
|
||||
- generic [ref=e182]:
|
||||
- generic [ref=e183]:
|
||||
- img [ref=e184]
|
||||
- generic [ref=e187]: Feet
|
||||
- button "Unequip Civilian Shoes" [ref=e188]:
|
||||
- img [ref=e189]
|
||||
- generic [ref=e192]:
|
||||
- generic [ref=e193]: Civilian Shoes
|
||||
- generic [ref=e194]: "Enchantments: 0/15"
|
||||
- generic [ref=e195]:
|
||||
- heading "Accessories" [level=4] [ref=e196]
|
||||
- generic [ref=e197]:
|
||||
- button "Accessory 1 slot (empty)" [ref=e199]:
|
||||
- generic [ref=e201]:
|
||||
- img [ref=e202]
|
||||
- generic [ref=e205]: Accessory 1
|
||||
- generic [ref=e206]: Accessory 1
|
||||
- button "Accessory 2 slot (empty)" [ref=e208]:
|
||||
- generic [ref=e210]:
|
||||
- img [ref=e211]
|
||||
- generic [ref=e214]: Accessory 2
|
||||
- generic [ref=e215]: Accessory 2
|
||||
- generic [ref=e216]:
|
||||
- heading "Equipment Inventory (0 items)" [level=3] [ref=e218]
|
||||
- status [ref=e219]: No unequipped items. Craft new gear in the Crafting tab.
|
||||
- generic [ref=e220]:
|
||||
- heading "Equipment Stats Summary" [level=3] [ref=e222]
|
||||
- generic [ref=e223]:
|
||||
- generic [ref=e224]:
|
||||
- generic [ref=e225]: "4"
|
||||
- generic [ref=e226]: Total Items
|
||||
- generic [ref=e227]:
|
||||
- generic [ref=e228]: "4"
|
||||
- generic [ref=e229]: Equipped
|
||||
- generic [ref=e230]:
|
||||
- generic [ref=e231]: "0"
|
||||
- generic [ref=e232]: In Inventory
|
||||
- generic [ref=e233]:
|
||||
- generic [ref=e234]: "1"
|
||||
- generic [ref=e235]: Total Enchantments
|
||||
- generic [ref=e236]:
|
||||
- heading "✨ Enchantment Power" [level=3] [ref=e238]
|
||||
- generic [ref=e239]:
|
||||
- generic [ref=e240]:
|
||||
- generic [ref=e241]: "Enchantment Power:"
|
||||
- generic [ref=e242]: 1.00×
|
||||
- paragraph [ref=e243]: Increases the power of all enchantments by 0%. Multiplier applied to all enchantment effects.
|
||||
- generic [ref=e244]:
|
||||
- generic [ref=e245]: "Active Effects from Equipment:"
|
||||
- generic [ref=e247]: No active effects
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- button "Open Next.js Dev Tools" [ref=e253] [cursor=pointer]:
|
||||
- img [ref=e254]
|
||||
- alert [ref=e257]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
28 |
|
||||
29 | // Verify equipment UI elements
|
||||
30 | const equippedGearHeading = page.locator('text="Equipped Gear"');
|
||||
31 | await expect(equippedGearHeading).toBeVisible({ timeout: 5000 });
|
||||
32 | });
|
||||
33 |
|
||||
34 | test('shows equipment slots with labels', async ({ page }) => {
|
||||
35 | await page.goto('/');
|
||||
36 | await page.evaluate(() => {
|
||||
37 | Object.keys(localStorage)
|
||||
38 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
39 | .forEach((k) => localStorage.removeItem(k));
|
||||
40 | });
|
||||
41 | await page.reload();
|
||||
42 | await page.waitForLoadState('networkidle');
|
||||
43 |
|
||||
44 | await page.getByRole('tab', { name: /🛡️ Gear/ }).click();
|
||||
45 |
|
||||
46 | // Check for expected slot labels - use role=heading or more specific selectors
|
||||
47 | // Main Hand slot
|
||||
48 | const mainHandSection = page.locator('text=Main Hand');
|
||||
49 | await expect(mainHandSection.first()).toBeVisible();
|
||||
50 |
|
||||
51 | // Off Hand
|
||||
52 | const offHandSection = page.locator('text=Off Hand');
|
||||
53 | await expect(offHandSection.first()).toBeVisible();
|
||||
54 |
|
||||
55 | // Head
|
||||
56 | const headSection = page.locator('text=Head');
|
||||
57 | await expect(headSection.first()).toBeVisible();
|
||||
58 |
|
||||
59 | // Body
|
||||
60 | const bodySection = page.locator('text=Body');
|
||||
61 | await expect(bodySection.first()).toBeVisible();
|
||||
62 |
|
||||
63 | // Hands
|
||||
64 | const handsSection = page.locator('text=Hands');
|
||||
65 | await expect(handsSection.first()).toBeVisible();
|
||||
66 |
|
||||
67 | // Feet
|
||||
68 | const feetSection = page.locator('text=Feet');
|
||||
69 | await expect(feetSection.first()).toBeVisible();
|
||||
70 |
|
||||
71 | // Accessory 1 and 2
|
||||
72 | const acc1Section = page.locator('text=Accessory 1');
|
||||
73 | await expect(acc1Section.first()).toBeVisible();
|
||||
74 | const acc2Section = page.locator('text=Accessory 2');
|
||||
75 | await expect(acc2Section.first()).toBeVisible();
|
||||
76 | });
|
||||
77 |
|
||||
78 | test('shows starting equipment already equipped', async ({ page }) => {
|
||||
79 | await page.goto('/');
|
||||
80 | await page.evaluate(() => {
|
||||
81 | Object.keys(localStorage)
|
||||
82 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
83 | .forEach((k) => localStorage.removeItem(k));
|
||||
84 | });
|
||||
85 | await page.reload();
|
||||
86 | await page.waitForLoadState('networkidle');
|
||||
87 |
|
||||
88 | await page.getByRole('tab', { name: /🛡️ Gear/ }).click();
|
||||
89 |
|
||||
90 | // The player starts with a Basic Staff in main hand (as an equipped item)
|
||||
91 | const mainHandSlot = page.locator('text=Main Hand >> .. >> text=Basic Staff');
|
||||
92 | await expect(mainHandSlot).toBeVisible({ timeout: 5000 });
|
||||
93 | });
|
||||
94 |
|
||||
95 | test('2-handed weapon blocks offhand slot', async ({ page }) => {
|
||||
96 | await page.goto('/');
|
||||
97 | await page.evaluate(() => {
|
||||
98 | Object.keys(localStorage)
|
||||
99 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
100 | .forEach((k) => localStorage.removeItem(k));
|
||||
101 | });
|
||||
102 | await page.reload();
|
||||
103 | await page.waitForLoadState('networkidle');
|
||||
104 |
|
||||
105 | await page.getByRole('tab', { name: /🛡️ Gear/ }).click();
|
||||
106 |
|
||||
107 | // The starting basic staff is 2-handed
|
||||
108 | // The offhand slot should show as blocked with "Occupied — 2H Weapon"
|
||||
109 | const offHandBlocked = page.locator('text=Occupied').first();
|
||||
110 | await expect(offHandBlocked).toBeVisible({ timeout: 5000 });
|
||||
111 | });
|
||||
112 |
|
||||
113 | test('can unequip an item from a slot', async ({ page }) => {
|
||||
114 | await page.goto('/');
|
||||
115 | await page.evaluate(() => {
|
||||
116 | Object.keys(localStorage)
|
||||
117 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
118 | .forEach((k) => localStorage.removeItem(k));
|
||||
119 | });
|
||||
120 | await page.reload();
|
||||
121 | await page.waitForLoadState('networkidle');
|
||||
122 |
|
||||
123 | await page.getByRole('tab', { name: /🛡️ Gear/ }).click();
|
||||
124 |
|
||||
125 | // Find an equiped slot with an unequip button (the X button)
|
||||
126 | // The hands slot has civilian gloves equipped
|
||||
127 | const handsSlot = page.locator('text=Hands >> .. >> button').first();
|
||||
> 128 | await expect(handsSlot).toBeVisible({ timeout: 5000 });
|
||||
| ^ Error: expect(locator).toBeVisible() failed
|
||||
129 | // Note: exact behavior of unequip depends on implementation state
|
||||
130 | });
|
||||
131 | });
|
||||
```
|
||||
@@ -0,0 +1,285 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: enchanting.spec.ts >> Enchanting Flow >> can navigate to Crafting tab
|
||||
- Location: e2e/enchanting.spec.ts:28:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(locator).toBeVisible() failed
|
||||
|
||||
Locator: getByRole('button')
|
||||
Expected: visible
|
||||
Error: strict mode violation: getByRole('button') resolved to 6 elements:
|
||||
1) <button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-primary-foreground shad…>…</button> aka getByRole('button', { name: 'Gather +1 Mana' })
|
||||
2) <button class="flex items-center justify-between w-full text-xs text-gray-400 hover:text-gray-300 mb-2">…</button> aka getByRole('button', { name: 'Elemental Mana (1)' })
|
||||
3) <button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-primary-foreground shadow-xs hover…>…</button> aka getByRole('button', { name: 'Climb the Spire' })
|
||||
4) <button data-slot="action-button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] bg-[var(--interactive-primary)] text-white …>…</button> aka getByRole('button', { name: 'Fabricate' })
|
||||
5) <button data-slot="action-button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] bg-[var(--interactive-secondary)] text-[var…>…</button> aka getByRole('button', { name: 'Enchant' })
|
||||
6) <button id="next-logo" aria-haspopup="menu" data-next-mark="true" aria-expanded="false" aria-label="Open Next.js Dev Tools" data-nextjs-dev-tools-button="true" aria-controls="nextjs-dev-tools-menu">…</button> aka getByRole('button', { name: 'Open Next.js Dev Tools' })
|
||||
|
||||
Call log:
|
||||
- Expect "toBeVisible" with timeout 5000ms
|
||||
- waiting for getByRole('button')
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- heading "MANA LOOP" [level=1] [ref=e5]
|
||||
- generic [ref=e7]:
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9]: Day 1
|
||||
- generic [ref=e10]: 00:55
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]: "0"
|
||||
- generic [ref=e13]: Insight
|
||||
- main [ref=e14]:
|
||||
- generic [ref=e15]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20]: "11"
|
||||
- generic [ref=e21]: / 100
|
||||
- generic [ref=e22]:
|
||||
- text: +2.4 mana/hr
|
||||
- generic [ref=e23]: (1.2x med)
|
||||
- progressbar [ref=e24]
|
||||
- button "Gather +1 Mana" [ref=e26]:
|
||||
- img
|
||||
- text: Gather +1 Mana
|
||||
- generic [ref=e27]:
|
||||
- button "Elemental Mana (1)" [ref=e28]:
|
||||
- generic [ref=e29]: Elemental Mana (1)
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]:
|
||||
- generic [ref=e34]:
|
||||
- generic [ref=e35]: 🔗
|
||||
- generic [ref=e36]: Transference
|
||||
- generic [ref=e39]: 0/10
|
||||
- button "Climb the Spire" [ref=e40]:
|
||||
- img
|
||||
- text: Climb the Spire
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- img [ref=e44]
|
||||
- generic [ref=e46]: Current Activity
|
||||
- generic [ref=e47]: Meditating
|
||||
- generic [ref=e48]:
|
||||
- generic [ref=e49]: "1"
|
||||
- generic [ref=e50]: "2"
|
||||
- generic [ref=e51]: "3"
|
||||
- generic [ref=e52]: "4"
|
||||
- generic [ref=e53]: "5"
|
||||
- generic [ref=e54]: "6"
|
||||
- generic [ref=e55]: "7"
|
||||
- generic [ref=e56]: "8"
|
||||
- generic [ref=e57]: "9"
|
||||
- generic [ref=e58]: "10"
|
||||
- generic [ref=e59]: "11"
|
||||
- generic [ref=e60]: "12"
|
||||
- generic [ref=e61]: "13"
|
||||
- generic [ref=e62]: "14"
|
||||
- generic [ref=e63]: "15"
|
||||
- generic [ref=e64]: "16"
|
||||
- generic [ref=e65]: "17"
|
||||
- generic [ref=e66]: "18"
|
||||
- generic [ref=e67]: "19"
|
||||
- generic [ref=e68]: "20"
|
||||
- generic [ref=e69]: "21"
|
||||
- generic [ref=e70]: "22"
|
||||
- generic [ref=e71]: "23"
|
||||
- generic [ref=e72]: "24"
|
||||
- generic [ref=e73]: "25"
|
||||
- generic [ref=e74]: "26"
|
||||
- generic [ref=e75]: "27"
|
||||
- generic [ref=e76]: "28"
|
||||
- generic [ref=e77]: "29"
|
||||
- generic [ref=e78]: "30"
|
||||
- generic [ref=e80]:
|
||||
- tablist [ref=e81]:
|
||||
- tab "⚔️ Spire" [ref=e82]
|
||||
- tab "✨ Attune" [ref=e83]
|
||||
- tab "🗿 Golems" [ref=e84]
|
||||
- tab "📚 Skills" [ref=e85]
|
||||
- tab "🔮 Spells" [ref=e86]
|
||||
- tab "🛡️ Gear" [ref=e87]
|
||||
- tab "🔧 Craft" [active] [selected] [ref=e88]
|
||||
- tab "💎 Loot" [ref=e89]
|
||||
- tab "🏆 Achieve" [ref=e90]
|
||||
- tab "📊 Stats" [ref=e91]
|
||||
- tab "🐛 Debug" [ref=e92]
|
||||
- tab "📖 Grimoire" [ref=e93]
|
||||
- tabpanel "🔧 Craft" [ref=e94]:
|
||||
- generic [ref=e95]:
|
||||
- generic [ref=e97]:
|
||||
- button "Fabricate" [ref=e98]:
|
||||
- img
|
||||
- text: Fabricate
|
||||
- button "Enchant" [ref=e99]:
|
||||
- img
|
||||
- text: Enchant
|
||||
- generic [ref=e100]:
|
||||
- generic [ref=e101]:
|
||||
- generic [ref=e103]:
|
||||
- img [ref=e104]
|
||||
- text: Available Blueprints
|
||||
- generic [ref=e113]:
|
||||
- img [ref=e114]
|
||||
- paragraph [ref=e118]: No blueprints discovered yet.
|
||||
- paragraph [ref=e119]: Defeat guardians to find blueprints!
|
||||
- generic [ref=e120]:
|
||||
- generic [ref=e122]:
|
||||
- img [ref=e123]
|
||||
- text: Materials (0)
|
||||
- generic [ref=e131]:
|
||||
- img [ref=e132]
|
||||
- paragraph [ref=e134]: No materials collected yet.
|
||||
- paragraph [ref=e135]: Defeat floors to gather materials!
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- button "Open Next.js Dev Tools" [ref=e141] [cursor=pointer]:
|
||||
- img [ref=e142]
|
||||
- alert [ref=e145]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | /**
|
||||
4 | * E2E tests for the 3-step enchantment flow:
|
||||
5 | * Design → Prepare → Apply
|
||||
6 | *
|
||||
7 | * These tests validate the core crafting loop works end-to-end.
|
||||
8 | */
|
||||
9 |
|
||||
10 | test.describe('Enchanting Flow', () => {
|
||||
11 | /**
|
||||
12 | * Before each test, ensure we start with a clean state.
|
||||
13 | * The game persists state in localStorage, so we clear it.
|
||||
14 | */
|
||||
15 | test.beforeEach(async ({ page }) => {
|
||||
16 | await page.goto('/');
|
||||
17 | // Clear game state to ensure a fresh start
|
||||
18 | await page.evaluate(() => {
|
||||
19 | Object.keys(localStorage)
|
||||
20 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
21 | .forEach((k) => localStorage.removeItem(k));
|
||||
22 | });
|
||||
23 | await page.reload();
|
||||
24 | // Wait for the game to initialize
|
||||
25 | await page.waitForLoadState('networkidle');
|
||||
26 | });
|
||||
27 |
|
||||
28 | test('can navigate to Crafting tab', async ({ page }) => {
|
||||
29 | // The tab bar contains a "Craft" tab
|
||||
30 | const craftTab = page.getByRole('tab', { name: /🔧 Craft/ });
|
||||
31 | await expect(craftTab).toBeVisible();
|
||||
32 | await craftTab.click();
|
||||
33 |
|
||||
34 | // Verify we're on the crafting tab by checking for sub-tabs
|
||||
35 | const fabricateBtn = page.getByRole('button', { hasText: 'Fabricate' });
|
||||
36 | const enchantBtn = page.getByRole('button', { hasText: 'Enchant' });
|
||||
> 37 | await expect(fabricateBtn).toBeVisible();
|
||||
| ^ Error: expect(locator).toBeVisible() failed
|
||||
38 | await expect(enchantBtn).toBeVisible();
|
||||
39 | });
|
||||
40 |
|
||||
41 | test('can switch to Enchant sub-tab and see design UI', async ({ page }) => {
|
||||
42 | await page.goto('/');
|
||||
43 | await page.evaluate(() => {
|
||||
44 | Object.keys(localStorage)
|
||||
45 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
46 | .forEach((k) => localStorage.removeItem(k));
|
||||
47 | });
|
||||
48 | await page.reload();
|
||||
49 | await page.waitForLoadState('networkidle');
|
||||
50 |
|
||||
51 | // Navigate to Crafting tab
|
||||
52 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
53 |
|
||||
54 | // Click Enchant sub-tab
|
||||
55 | const enchantBtn = page.getByRole('button', { hasText: 'Enchant' });
|
||||
56 | await enchantBtn.click();
|
||||
57 |
|
||||
58 | // Should see the design stage UI
|
||||
59 | const designBtn = page.getByRole('button', { hasText: 'Design' });
|
||||
60 | const prepareBtn = page.getByRole('button', { hasText: 'Prepare' });
|
||||
61 | const applyBtn = page.getByRole('button', { hasText: 'Apply' });
|
||||
62 | await expect(designBtn).toBeVisible();
|
||||
63 | await expect(prepareBtn).toBeVisible();
|
||||
64 | await expect(applyBtn).toBeVisible();
|
||||
65 | });
|
||||
66 |
|
||||
67 | test('can select equipment type and effect in Design stage', async ({ page }) => {
|
||||
68 | await page.goto('/');
|
||||
69 | await page.evaluate(() => {
|
||||
70 | Object.keys(localStorage)
|
||||
71 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
72 | .forEach((k) => localStorage.removeItem(k));
|
||||
73 | });
|
||||
74 | await page.reload();
|
||||
75 | await page.waitForLoadState('networkidle');
|
||||
76 |
|
||||
77 | // Navigate to Crafting > Enchant > Design
|
||||
78 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
79 | await page.getByRole('button', { hasText: 'Enchant' }).click();
|
||||
80 |
|
||||
81 | // The design section should show effect selectors once an equipment type is chosen
|
||||
82 | // Look for any element matching equipment type buttons and effect-related content
|
||||
83 | const equipmentButtons = page.locator('button:has-text("Basic Staff"), button:has-text("Apprentice Wand"), button:has-text("Oak Staff"), button:has-text("Crystal Wand")');
|
||||
84 | const count = await equipmentButtons.count();
|
||||
85 | expect(count).toBeGreaterThan(0);
|
||||
86 | });
|
||||
87 |
|
||||
88 | test('can navigate through all 3 enchant stages', async ({ page }) => {
|
||||
89 | await page.goto('/');
|
||||
90 | await page.evaluate(() => {
|
||||
91 | Object.keys(localStorage)
|
||||
92 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
93 | .forEach((k) => localStorage.removeItem(k));
|
||||
94 | });
|
||||
95 | await page.reload();
|
||||
96 | await page.waitForLoadState('networkidle');
|
||||
97 |
|
||||
98 | // Navigate to Crafting > Enchant
|
||||
99 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
100 | await page.getByRole('button', { hasText: 'Enchant' }).click();
|
||||
101 |
|
||||
102 | // Verify Design stage is active
|
||||
103 | const designBtn = page.getByRole('button', { hasText: 'Design' });
|
||||
104 | await expect(designBtn).toBeVisible();
|
||||
105 |
|
||||
106 | // Switch to Prepare stage
|
||||
107 | const prepareBtn = page.getByRole('button', { hasText: 'Prepare' });
|
||||
108 | await prepareBtn.click();
|
||||
109 |
|
||||
110 | // Should see preparation UI
|
||||
111 | const prepareHeading = page.locator('text=Select Equipment to Prepare');
|
||||
112 | await expect(prepareHeading).toBeVisible({ timeout: 5000 });
|
||||
113 |
|
||||
114 | // Switch to Apply stage
|
||||
115 | const applyBtn = page.getByRole('button', { hasText: 'Apply' });
|
||||
116 | await applyBtn.click();
|
||||
117 |
|
||||
118 | // Should see application UI
|
||||
119 | const applyHeading = page.locator('text=Select Equipment & Design');
|
||||
120 | await expect(applyHeading).toBeVisible({ timeout: 5000 });
|
||||
121 | });
|
||||
122 | });
|
||||
```
|
||||
@@ -0,0 +1,260 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: combat.spec.ts >> Combat System >> can enter Spire mode by clicking Climb button
|
||||
- Location: e2e/combat.spec.ts:34:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(locator).toBeVisible() failed
|
||||
|
||||
Locator: getByRole('button', { name: 'Enter Spire Mode' })
|
||||
Expected: visible
|
||||
Timeout: 5000ms
|
||||
Error: element(s) not found
|
||||
|
||||
Call log:
|
||||
- Expect "toBeVisible" with timeout 5000ms
|
||||
- waiting for getByRole('button', { name: 'Enter Spire Mode' })
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- heading "MANA LOOP" [level=1] [ref=e5]
|
||||
- generic [ref=e7]:
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9]: Day 1
|
||||
- generic [ref=e10]: 01:43
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]: "0"
|
||||
- generic [ref=e13]: Insight
|
||||
- main [ref=e14]:
|
||||
- generic [ref=e15]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20]: "14"
|
||||
- generic [ref=e21]: / 100
|
||||
- generic [ref=e22]:
|
||||
- text: +2.9 mana/hr
|
||||
- generic [ref=e23]: (1.4x med)
|
||||
- progressbar [ref=e24]
|
||||
- button "Gather +1 Mana" [ref=e26]:
|
||||
- img
|
||||
- text: Gather +1 Mana
|
||||
- generic [ref=e27]:
|
||||
- button "Elemental Mana (1)" [ref=e28]:
|
||||
- generic [ref=e29]: Elemental Mana (1)
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]:
|
||||
- generic [ref=e34]:
|
||||
- generic [ref=e35]: 🔗
|
||||
- generic [ref=e36]: Transference
|
||||
- generic [ref=e39]: 0/10
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]: "1"
|
||||
- generic [ref=e42]: "2"
|
||||
- generic [ref=e43]: "3"
|
||||
- generic [ref=e44]: "4"
|
||||
- generic [ref=e45]: "5"
|
||||
- generic [ref=e46]: "6"
|
||||
- generic [ref=e47]: "7"
|
||||
- generic [ref=e48]: "8"
|
||||
- generic [ref=e49]: "9"
|
||||
- generic [ref=e50]: "10"
|
||||
- generic [ref=e51]: "11"
|
||||
- generic [ref=e52]: "12"
|
||||
- generic [ref=e53]: "13"
|
||||
- generic [ref=e54]: "14"
|
||||
- generic [ref=e55]: "15"
|
||||
- generic [ref=e56]: "16"
|
||||
- generic [ref=e57]: "17"
|
||||
- generic [ref=e58]: "18"
|
||||
- generic [ref=e59]: "19"
|
||||
- generic [ref=e60]: "20"
|
||||
- generic [ref=e61]: "21"
|
||||
- generic [ref=e62]: "22"
|
||||
- generic [ref=e63]: "23"
|
||||
- generic [ref=e64]: "24"
|
||||
- generic [ref=e65]: "25"
|
||||
- generic [ref=e66]: "26"
|
||||
- generic [ref=e67]: "27"
|
||||
- generic [ref=e68]: "28"
|
||||
- generic [ref=e69]: "29"
|
||||
- generic [ref=e70]: "30"
|
||||
- generic [ref=e72]:
|
||||
- tablist [ref=e73]:
|
||||
- tab "⚔️ Spire" [selected] [ref=e74]
|
||||
- tab "✨ Attune" [ref=e75]
|
||||
- tab "🗿 Golems" [ref=e76]
|
||||
- tab "📚 Skills" [ref=e77]
|
||||
- tab "🔮 Spells" [ref=e78]
|
||||
- tab "🛡️ Gear" [ref=e79]
|
||||
- tab "🔧 Craft" [ref=e80]
|
||||
- tab "💎 Loot" [ref=e81]
|
||||
- tab "🏆 Achieve" [ref=e82]
|
||||
- tab "📊 Stats" [ref=e83]
|
||||
- tab "🐛 Debug" [ref=e84]
|
||||
- tab "📖 Grimoire" [ref=e85]
|
||||
- tabpanel "⚔️ Spire" [ref=e86]:
|
||||
- generic [ref=e87]:
|
||||
- generic [ref=e89]:
|
||||
- button "Exit Spire Mode" [ref=e90]:
|
||||
- img
|
||||
- text: Exit Spire Mode
|
||||
- generic [ref=e91]: Climb down to floor 1 to return to the main game
|
||||
- generic [ref=e92]:
|
||||
- heading "Current Floor ⚔️ Combat" [level=3] [ref=e94]:
|
||||
- generic [ref=e95]: Current Floor
|
||||
- generic [ref=e96]: ⚔️ Combat
|
||||
- generic [ref=e97]:
|
||||
- generic [ref=e98]:
|
||||
- generic [ref=e99]: "1"
|
||||
- generic [ref=e100]: / 100
|
||||
- generic [ref=e101]: 🔥 Fire
|
||||
- generic [ref=e102]:
|
||||
- text: "Best: Floor"
|
||||
- strong [ref=e103]: "1"
|
||||
- text: "• Pacts:"
|
||||
- strong [ref=e104]: "0"
|
||||
- generic [ref=e106]:
|
||||
- generic [ref=e108]: Active Spells (1)
|
||||
- generic [ref=e110]:
|
||||
- generic [ref=e111]:
|
||||
- generic [ref=e112]: Mana BoltBasic
|
||||
- generic [ref=e113]: ✓
|
||||
- generic [ref=e114]: ⚔️ 5 dmg • 3 raw • ⚡ 15 dmg/hr
|
||||
- generic [ref=e115]:
|
||||
- generic [ref=e116]:
|
||||
- generic [ref=e117]:
|
||||
- img [ref=e118]
|
||||
- generic [ref=e123]: Inferno Whelp
|
||||
- generic [ref=e124]: 🔥 Fire
|
||||
- generic [ref=e129]: 151 / 151 HP
|
||||
- generic [ref=e130]:
|
||||
- generic [ref=e132]: Floor Navigation
|
||||
- generic [ref=e133]:
|
||||
- generic [ref=e134]:
|
||||
- button "Climb Up" [ref=e135]:
|
||||
- img
|
||||
- text: Climb Up
|
||||
- button "Climb Down" [disabled]:
|
||||
- img
|
||||
- text: Climb Down
|
||||
- generic [ref=e136]: Click Climb Up/Down to begin climbing
|
||||
- generic [ref=e137]:
|
||||
- generic [ref=e139]: Combat Stats
|
||||
- generic [ref=e140]:
|
||||
- generic [ref=e141]: "Total DPS: —"
|
||||
- generic [ref=e142]:
|
||||
- generic [ref=e143]: Active Spells
|
||||
- generic [ref=e144]:
|
||||
- generic [ref=e145]:
|
||||
- generic [ref=e146]:
|
||||
- text: Mana Bolt
|
||||
- generic [ref=e147]: Basic
|
||||
- generic [ref=e148]: ✓
|
||||
- generic [ref=e149]: ⚔️ 5 dmg • 3 raw • ⚡ 15 dmg/hr
|
||||
- generic [ref=e151]: "Study Speed: 100%"
|
||||
- generic [ref=e152]:
|
||||
- generic [ref=e154]: Activity Log
|
||||
- generic [ref=e160]: No activity yet...
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- button "Open Next.js Dev Tools" [ref=e166] [cursor=pointer]:
|
||||
- img [ref=e167]
|
||||
- alert [ref=e170]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | /**
|
||||
4 | * E2E tests for combat system:
|
||||
5 | * - Entering spire mode (climbing)
|
||||
6 | * - Casting spells and seeing progress
|
||||
7 | * - Enemy HP reduction
|
||||
8 | * - Floor advancement
|
||||
9 | */
|
||||
10 |
|
||||
11 | test.describe('Combat System', () => {
|
||||
12 | test.beforeEach(async ({ page }) => {
|
||||
13 | await page.goto('/');
|
||||
14 | // Clear game state to ensure a fresh start
|
||||
15 | await page.evaluate(() => {
|
||||
16 | Object.keys(localStorage)
|
||||
17 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
18 | .forEach((k) => localStorage.removeItem(k));
|
||||
19 | });
|
||||
20 | await page.reload();
|
||||
21 | await page.waitForLoadState('networkidle');
|
||||
22 | });
|
||||
23 |
|
||||
24 | test('can see the Spire tab and "Climb the Spire" button', async ({ page }) => {
|
||||
25 | // The Spire tab uses an icon + text, so match by the tab role
|
||||
26 | const spireTab = page.getByRole('tab', { name: /⚔️ Spire/ });
|
||||
27 | await expect(spireTab).toBeVisible();
|
||||
28 |
|
||||
29 | // Main page should show "Climb the Spire" button
|
||||
30 | const climbBtn = page.getByRole('button', { name: 'Climb the Spire' });
|
||||
31 | await expect(climbBtn).toBeVisible();
|
||||
32 | });
|
||||
33 |
|
||||
34 | test('can enter Spire mode by clicking Climb button', async ({ page }) => {
|
||||
35 | // Click "Climb the Spire" button on the main page (via left panel)
|
||||
36 | await page.getByRole('button', { name: 'Climb the Spire' }).click();
|
||||
37 |
|
||||
38 | // Should now see Spire mode UI elements
|
||||
39 | // The "Enter Spire Mode" button appears when on the Spire tab
|
||||
40 | const enterBtn = page.getByRole('button', { name: 'Enter Spire Mode' });
|
||||
> 41 | await expect(enterBtn).toBeVisible({ timeout: 5000 });
|
||||
| ^ Error: expect(locator).toBeVisible() failed
|
||||
42 | });
|
||||
43 |
|
||||
44 | test('can navigate to Spire tab', async ({ page }) => {
|
||||
45 | // Click the Spire tab specifically (using role=tab to disambiguate)
|
||||
46 | await page.getByRole('tab', { name: /⚔️ Spire/ }).click();
|
||||
47 |
|
||||
48 | // Should see Spire-specific UI
|
||||
49 | const enterSpireBtn = page.getByRole('button', { name: 'Enter Spire Mode' });
|
||||
50 | await expect(enterSpireBtn).toBeVisible({ timeout: 5000 });
|
||||
51 | });
|
||||
52 |
|
||||
53 | test('can enter spire mode from the Spire tab', async ({ page }) => {
|
||||
54 | await page.getByRole('tab', { name: /⚔️ Spire/ }).click();
|
||||
55 |
|
||||
56 | const enterBtn = page.getByRole('button', { name: 'Enter Spire Mode' });
|
||||
57 | await expect(enterBtn).toBeEnabled();
|
||||
58 | await enterBtn.click();
|
||||
59 |
|
||||
60 | // After entering, should see exit button
|
||||
61 | const exitBtn = page.getByRole('button', { name: 'Exit Spire Mode' });
|
||||
62 | await expect(exitBtn).toBeVisible({ timeout: 5000 });
|
||||
63 | });
|
||||
64 |
|
||||
65 | test('shows floor information in spire mode', async ({ page }) => {
|
||||
66 | await page.getByRole('tab', { name: /⚔️ Spire/ }).click();
|
||||
67 | await page.getByRole('button', { name: 'Enter Spire Mode' }).click();
|
||||
68 |
|
||||
69 | // Should display floor number - look for "Floor" label or the floor counter
|
||||
70 | const floorDisplay = page.locator('text="Floor"').first();
|
||||
71 | await expect(floorDisplay).toBeVisible({ timeout: 5000 });
|
||||
72 | });
|
||||
73 | });
|
||||
```
|
||||
|
After Width: | Height: | Size: 187 KiB |
@@ -0,0 +1,280 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: enchanting.spec.ts >> Enchanting Flow >> can switch to Enchant sub-tab and see design UI
|
||||
- Location: e2e/enchanting.spec.ts:41:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: locator.click: Error: strict mode violation: getByRole('button') resolved to 6 elements:
|
||||
1) <button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-primary-foreground shad…>…</button> aka getByRole('button', { name: 'Gather +1 Mana' })
|
||||
2) <button class="flex items-center justify-between w-full text-xs text-gray-400 hover:text-gray-300 mb-2">…</button> aka getByRole('button', { name: 'Elemental Mana (1)' })
|
||||
3) <button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-primary-foreground shadow-xs hover…>…</button> aka getByRole('button', { name: 'Climb the Spire' })
|
||||
4) <button data-slot="action-button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] bg-[var(--interactive-primary)] text-white …>…</button> aka getByRole('button', { name: 'Fabricate' })
|
||||
5) <button data-slot="action-button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] bg-[var(--interactive-secondary)] text-[var…>…</button> aka getByRole('button', { name: 'Enchant' })
|
||||
6) <button id="next-logo" aria-haspopup="menu" data-next-mark="true" aria-expanded="false" aria-label="Open Next.js Dev Tools" data-nextjs-dev-tools-button="true" aria-controls="nextjs-dev-tools-menu">…</button> aka getByRole('button', { name: 'Open Next.js Dev Tools' })
|
||||
|
||||
Call log:
|
||||
- waiting for getByRole('button')
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- heading "MANA LOOP" [level=1] [ref=e5]
|
||||
- generic [ref=e7]:
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9]: Day 1
|
||||
- generic [ref=e10]: 01:04
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]: "0"
|
||||
- generic [ref=e13]: Insight
|
||||
- main [ref=e14]:
|
||||
- generic [ref=e15]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20]: "12"
|
||||
- generic [ref=e21]: / 100
|
||||
- generic [ref=e22]:
|
||||
- text: +2.3 mana/hr
|
||||
- generic [ref=e23]: (1.1x med)
|
||||
- progressbar [ref=e24]
|
||||
- button "Gather +1 Mana" [ref=e26]:
|
||||
- img
|
||||
- text: Gather +1 Mana
|
||||
- generic [ref=e27]:
|
||||
- button "Elemental Mana (1)" [ref=e28]:
|
||||
- generic [ref=e29]: Elemental Mana (1)
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]:
|
||||
- generic [ref=e34]:
|
||||
- generic [ref=e35]: 🔗
|
||||
- generic [ref=e36]: Transference
|
||||
- generic [ref=e39]: 0/10
|
||||
- button "Climb the Spire" [ref=e40]:
|
||||
- img
|
||||
- text: Climb the Spire
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- img [ref=e44]
|
||||
- generic [ref=e46]: Current Activity
|
||||
- generic [ref=e47]: Meditating
|
||||
- generic [ref=e48]:
|
||||
- generic [ref=e49]: "1"
|
||||
- generic [ref=e50]: "2"
|
||||
- generic [ref=e51]: "3"
|
||||
- generic [ref=e52]: "4"
|
||||
- generic [ref=e53]: "5"
|
||||
- generic [ref=e54]: "6"
|
||||
- generic [ref=e55]: "7"
|
||||
- generic [ref=e56]: "8"
|
||||
- generic [ref=e57]: "9"
|
||||
- generic [ref=e58]: "10"
|
||||
- generic [ref=e59]: "11"
|
||||
- generic [ref=e60]: "12"
|
||||
- generic [ref=e61]: "13"
|
||||
- generic [ref=e62]: "14"
|
||||
- generic [ref=e63]: "15"
|
||||
- generic [ref=e64]: "16"
|
||||
- generic [ref=e65]: "17"
|
||||
- generic [ref=e66]: "18"
|
||||
- generic [ref=e67]: "19"
|
||||
- generic [ref=e68]: "20"
|
||||
- generic [ref=e69]: "21"
|
||||
- generic [ref=e70]: "22"
|
||||
- generic [ref=e71]: "23"
|
||||
- generic [ref=e72]: "24"
|
||||
- generic [ref=e73]: "25"
|
||||
- generic [ref=e74]: "26"
|
||||
- generic [ref=e75]: "27"
|
||||
- generic [ref=e76]: "28"
|
||||
- generic [ref=e77]: "29"
|
||||
- generic [ref=e78]: "30"
|
||||
- generic [ref=e80]:
|
||||
- tablist [ref=e81]:
|
||||
- tab "⚔️ Spire" [ref=e82]
|
||||
- tab "✨ Attune" [ref=e83]
|
||||
- tab "🗿 Golems" [ref=e84]
|
||||
- tab "📚 Skills" [ref=e85]
|
||||
- tab "🔮 Spells" [ref=e86]
|
||||
- tab "🛡️ Gear" [ref=e87]
|
||||
- tab "🔧 Craft" [active] [selected] [ref=e88]
|
||||
- tab "💎 Loot" [ref=e89]
|
||||
- tab "🏆 Achieve" [ref=e90]
|
||||
- tab "📊 Stats" [ref=e91]
|
||||
- tab "🐛 Debug" [ref=e92]
|
||||
- tab "📖 Grimoire" [ref=e93]
|
||||
- tabpanel "🔧 Craft" [ref=e94]:
|
||||
- generic [ref=e95]:
|
||||
- generic [ref=e97]:
|
||||
- button "Fabricate" [ref=e98]:
|
||||
- img
|
||||
- text: Fabricate
|
||||
- button "Enchant" [ref=e99]:
|
||||
- img
|
||||
- text: Enchant
|
||||
- generic [ref=e100]:
|
||||
- generic [ref=e101]:
|
||||
- generic [ref=e103]:
|
||||
- img [ref=e104]
|
||||
- text: Available Blueprints
|
||||
- generic [ref=e113]:
|
||||
- img [ref=e114]
|
||||
- paragraph [ref=e118]: No blueprints discovered yet.
|
||||
- paragraph [ref=e119]: Defeat guardians to find blueprints!
|
||||
- generic [ref=e120]:
|
||||
- generic [ref=e122]:
|
||||
- img [ref=e123]
|
||||
- text: Materials (0)
|
||||
- generic [ref=e131]:
|
||||
- img [ref=e132]
|
||||
- paragraph [ref=e134]: No materials collected yet.
|
||||
- paragraph [ref=e135]: Defeat floors to gather materials!
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- button "Open Next.js Dev Tools" [ref=e141] [cursor=pointer]:
|
||||
- img [ref=e142]
|
||||
- alert [ref=e145]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | /**
|
||||
4 | * E2E tests for the 3-step enchantment flow:
|
||||
5 | * Design → Prepare → Apply
|
||||
6 | *
|
||||
7 | * These tests validate the core crafting loop works end-to-end.
|
||||
8 | */
|
||||
9 |
|
||||
10 | test.describe('Enchanting Flow', () => {
|
||||
11 | /**
|
||||
12 | * Before each test, ensure we start with a clean state.
|
||||
13 | * The game persists state in localStorage, so we clear it.
|
||||
14 | */
|
||||
15 | test.beforeEach(async ({ page }) => {
|
||||
16 | await page.goto('/');
|
||||
17 | // Clear game state to ensure a fresh start
|
||||
18 | await page.evaluate(() => {
|
||||
19 | Object.keys(localStorage)
|
||||
20 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
21 | .forEach((k) => localStorage.removeItem(k));
|
||||
22 | });
|
||||
23 | await page.reload();
|
||||
24 | // Wait for the game to initialize
|
||||
25 | await page.waitForLoadState('networkidle');
|
||||
26 | });
|
||||
27 |
|
||||
28 | test('can navigate to Crafting tab', async ({ page }) => {
|
||||
29 | // The tab bar contains a "Craft" tab
|
||||
30 | const craftTab = page.getByRole('tab', { name: /🔧 Craft/ });
|
||||
31 | await expect(craftTab).toBeVisible();
|
||||
32 | await craftTab.click();
|
||||
33 |
|
||||
34 | // Verify we're on the crafting tab by checking for sub-tabs
|
||||
35 | const fabricateBtn = page.getByRole('button', { hasText: 'Fabricate' });
|
||||
36 | const enchantBtn = page.getByRole('button', { hasText: 'Enchant' });
|
||||
37 | await expect(fabricateBtn).toBeVisible();
|
||||
38 | await expect(enchantBtn).toBeVisible();
|
||||
39 | });
|
||||
40 |
|
||||
41 | test('can switch to Enchant sub-tab and see design UI', async ({ page }) => {
|
||||
42 | await page.goto('/');
|
||||
43 | await page.evaluate(() => {
|
||||
44 | Object.keys(localStorage)
|
||||
45 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
46 | .forEach((k) => localStorage.removeItem(k));
|
||||
47 | });
|
||||
48 | await page.reload();
|
||||
49 | await page.waitForLoadState('networkidle');
|
||||
50 |
|
||||
51 | // Navigate to Crafting tab
|
||||
52 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
53 |
|
||||
54 | // Click Enchant sub-tab
|
||||
55 | const enchantBtn = page.getByRole('button', { hasText: 'Enchant' });
|
||||
> 56 | await enchantBtn.click();
|
||||
| ^ Error: locator.click: Error: strict mode violation: getByRole('button') resolved to 6 elements:
|
||||
57 |
|
||||
58 | // Should see the design stage UI
|
||||
59 | const designBtn = page.getByRole('button', { hasText: 'Design' });
|
||||
60 | const prepareBtn = page.getByRole('button', { hasText: 'Prepare' });
|
||||
61 | const applyBtn = page.getByRole('button', { hasText: 'Apply' });
|
||||
62 | await expect(designBtn).toBeVisible();
|
||||
63 | await expect(prepareBtn).toBeVisible();
|
||||
64 | await expect(applyBtn).toBeVisible();
|
||||
65 | });
|
||||
66 |
|
||||
67 | test('can select equipment type and effect in Design stage', async ({ page }) => {
|
||||
68 | await page.goto('/');
|
||||
69 | await page.evaluate(() => {
|
||||
70 | Object.keys(localStorage)
|
||||
71 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
72 | .forEach((k) => localStorage.removeItem(k));
|
||||
73 | });
|
||||
74 | await page.reload();
|
||||
75 | await page.waitForLoadState('networkidle');
|
||||
76 |
|
||||
77 | // Navigate to Crafting > Enchant > Design
|
||||
78 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
79 | await page.getByRole('button', { hasText: 'Enchant' }).click();
|
||||
80 |
|
||||
81 | // The design section should show effect selectors once an equipment type is chosen
|
||||
82 | // Look for any element matching equipment type buttons and effect-related content
|
||||
83 | const equipmentButtons = page.locator('button:has-text("Basic Staff"), button:has-text("Apprentice Wand"), button:has-text("Oak Staff"), button:has-text("Crystal Wand")');
|
||||
84 | const count = await equipmentButtons.count();
|
||||
85 | expect(count).toBeGreaterThan(0);
|
||||
86 | });
|
||||
87 |
|
||||
88 | test('can navigate through all 3 enchant stages', async ({ page }) => {
|
||||
89 | await page.goto('/');
|
||||
90 | await page.evaluate(() => {
|
||||
91 | Object.keys(localStorage)
|
||||
92 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
93 | .forEach((k) => localStorage.removeItem(k));
|
||||
94 | });
|
||||
95 | await page.reload();
|
||||
96 | await page.waitForLoadState('networkidle');
|
||||
97 |
|
||||
98 | // Navigate to Crafting > Enchant
|
||||
99 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
100 | await page.getByRole('button', { hasText: 'Enchant' }).click();
|
||||
101 |
|
||||
102 | // Verify Design stage is active
|
||||
103 | const designBtn = page.getByRole('button', { hasText: 'Design' });
|
||||
104 | await expect(designBtn).toBeVisible();
|
||||
105 |
|
||||
106 | // Switch to Prepare stage
|
||||
107 | const prepareBtn = page.getByRole('button', { hasText: 'Prepare' });
|
||||
108 | await prepareBtn.click();
|
||||
109 |
|
||||
110 | // Should see preparation UI
|
||||
111 | const prepareHeading = page.locator('text=Select Equipment to Prepare');
|
||||
112 | await expect(prepareHeading).toBeVisible({ timeout: 5000 });
|
||||
113 |
|
||||
114 | // Switch to Apply stage
|
||||
115 | const applyBtn = page.getByRole('button', { hasText: 'Apply' });
|
||||
116 | await applyBtn.click();
|
||||
117 |
|
||||
118 | // Should see application UI
|
||||
119 | const applyHeading = page.locator('text=Select Equipment & Design');
|
||||
120 | await expect(applyHeading).toBeVisible({ timeout: 5000 });
|
||||
121 | });
|
||||
122 | });
|
||||
```
|
||||
|
After Width: | Height: | Size: 243 KiB |
@@ -0,0 +1,375 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: equipment.spec.ts >> Equipment Management >> shows starting equipment already equipped
|
||||
- Location: e2e/equipment.spec.ts:78:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(locator).toBeVisible() failed
|
||||
|
||||
Locator: locator('text=Main Hand').locator('..').locator('text=Basic Staff')
|
||||
Expected: visible
|
||||
Timeout: 5000ms
|
||||
Error: element(s) not found
|
||||
|
||||
Call log:
|
||||
- Expect "toBeVisible" with timeout 5000ms
|
||||
- waiting for locator('text=Main Hand').locator('..').locator('text=Basic Staff')
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- heading "MANA LOOP" [level=1] [ref=e5]
|
||||
- generic [ref=e7]:
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9]: Day 1
|
||||
- generic [ref=e10]: 01:52
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]: "0"
|
||||
- generic [ref=e13]: Insight
|
||||
- main [ref=e14]:
|
||||
- generic [ref=e15]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20]: "14"
|
||||
- generic [ref=e21]: / 100
|
||||
- generic [ref=e22]:
|
||||
- text: +2.7 mana/hr
|
||||
- generic [ref=e23]: (1.4x med)
|
||||
- progressbar [ref=e24]
|
||||
- button "Gather +1 Mana" [ref=e26]:
|
||||
- img
|
||||
- text: Gather +1 Mana
|
||||
- generic [ref=e27]:
|
||||
- button "Elemental Mana (1)" [ref=e28]:
|
||||
- generic [ref=e29]: Elemental Mana (1)
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]:
|
||||
- generic [ref=e34]:
|
||||
- generic [ref=e35]: 🔗
|
||||
- generic [ref=e36]: Transference
|
||||
- generic [ref=e39]: 0/10
|
||||
- button "Climb the Spire" [ref=e40]:
|
||||
- img
|
||||
- text: Climb the Spire
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- img [ref=e44]
|
||||
- generic [ref=e46]: Current Activity
|
||||
- generic [ref=e47]: Meditating
|
||||
- generic [ref=e48]:
|
||||
- generic [ref=e49]: "1"
|
||||
- generic [ref=e50]: "2"
|
||||
- generic [ref=e51]: "3"
|
||||
- generic [ref=e52]: "4"
|
||||
- generic [ref=e53]: "5"
|
||||
- generic [ref=e54]: "6"
|
||||
- generic [ref=e55]: "7"
|
||||
- generic [ref=e56]: "8"
|
||||
- generic [ref=e57]: "9"
|
||||
- generic [ref=e58]: "10"
|
||||
- generic [ref=e59]: "11"
|
||||
- generic [ref=e60]: "12"
|
||||
- generic [ref=e61]: "13"
|
||||
- generic [ref=e62]: "14"
|
||||
- generic [ref=e63]: "15"
|
||||
- generic [ref=e64]: "16"
|
||||
- generic [ref=e65]: "17"
|
||||
- generic [ref=e66]: "18"
|
||||
- generic [ref=e67]: "19"
|
||||
- generic [ref=e68]: "20"
|
||||
- generic [ref=e69]: "21"
|
||||
- generic [ref=e70]: "22"
|
||||
- generic [ref=e71]: "23"
|
||||
- generic [ref=e72]: "24"
|
||||
- generic [ref=e73]: "25"
|
||||
- generic [ref=e74]: "26"
|
||||
- generic [ref=e75]: "27"
|
||||
- generic [ref=e76]: "28"
|
||||
- generic [ref=e77]: "29"
|
||||
- generic [ref=e78]: "30"
|
||||
- generic [ref=e80]:
|
||||
- tablist [ref=e81]:
|
||||
- tab "⚔️ Spire" [ref=e82]
|
||||
- tab "✨ Attune" [ref=e83]
|
||||
- tab "🗿 Golems" [ref=e84]
|
||||
- tab "📚 Skills" [ref=e85]
|
||||
- tab "🔮 Spells" [ref=e86]
|
||||
- tab "🛡️ Gear" [active] [selected] [ref=e87]
|
||||
- tab "🔧 Craft" [ref=e88]
|
||||
- tab "💎 Loot" [ref=e89]
|
||||
- tab "🏆 Achieve" [ref=e90]
|
||||
- tab "📊 Stats" [ref=e91]
|
||||
- tab "🐛 Debug" [ref=e92]
|
||||
- tab "📖 Grimoire" [ref=e93]
|
||||
- tabpanel "🛡️ Gear" [ref=e94]:
|
||||
- generic [ref=e95]:
|
||||
- generic [ref=e96]:
|
||||
- generic [ref=e97]:
|
||||
- heading "Equipped Gear" [level=3] [ref=e98]
|
||||
- generic [ref=e100]: 4 / 8 slots filled
|
||||
- generic [ref=e101]:
|
||||
- generic [ref=e102]:
|
||||
- heading "Weapon & Shield" [level=4] [ref=e103]
|
||||
- generic [ref=e104]:
|
||||
- 'button "Main Hand slot: Basic Staff" [ref=e106]':
|
||||
- generic [ref=e107]:
|
||||
- generic [ref=e108]:
|
||||
- img [ref=e109]
|
||||
- generic [ref=e114]: Main Hand
|
||||
- button "Unequip Basic Staff" [ref=e115]:
|
||||
- img [ref=e116]
|
||||
- generic [ref=e119]:
|
||||
- generic [ref=e120]:
|
||||
- text: Basic Staff
|
||||
- generic [ref=e121]: 2-Handed
|
||||
- generic [ref=e122]: "Enchantments: 1/50"
|
||||
- generic [ref=e124]: Mana Bolt
|
||||
- button "Off Hand slot (blocked by 2-handed weapon) (empty)" [ref=e125]:
|
||||
- generic [ref=e127]:
|
||||
- img [ref=e128]
|
||||
- generic [ref=e130]: Off Hand
|
||||
- generic [ref=e131]:
|
||||
- img
|
||||
- text: Occupied — 2H Weapon
|
||||
- generic [ref=e132]:
|
||||
- img [ref=e133]
|
||||
- text: Blocked by 2-handed weapon
|
||||
- generic [ref=e135]:
|
||||
- heading "Armor" [level=4] [ref=e136]
|
||||
- generic [ref=e137]:
|
||||
- button "Head slot (empty)" [ref=e139]:
|
||||
- generic [ref=e141]:
|
||||
- img [ref=e142]
|
||||
- generic [ref=e147]: Head
|
||||
- generic [ref=e148]: Head
|
||||
- 'button "Body slot: Civilian Shirt" [ref=e150]':
|
||||
- generic [ref=e151]:
|
||||
- generic [ref=e152]:
|
||||
- img [ref=e153]
|
||||
- generic [ref=e155]: Body
|
||||
- button "Unequip Civilian Shirt" [ref=e156]:
|
||||
- img [ref=e157]
|
||||
- generic [ref=e160]:
|
||||
- generic [ref=e161]: Civilian Shirt
|
||||
- generic [ref=e162]: "Enchantments: 0/30"
|
||||
- 'button "Hands slot: Civilian Gloves" [ref=e164]':
|
||||
- generic [ref=e165]:
|
||||
- generic [ref=e166]:
|
||||
- img [ref=e167]
|
||||
- generic [ref=e172]: Hands
|
||||
- button "Unequip Civilian Gloves" [ref=e173]:
|
||||
- img [ref=e174]
|
||||
- generic [ref=e177]:
|
||||
- generic [ref=e178]: Civilian Gloves
|
||||
- generic [ref=e179]: "Enchantments: 0/20"
|
||||
- 'button "Feet slot: Civilian Shoes" [ref=e181]':
|
||||
- generic [ref=e182]:
|
||||
- generic [ref=e183]:
|
||||
- img [ref=e184]
|
||||
- generic [ref=e187]: Feet
|
||||
- button "Unequip Civilian Shoes" [ref=e188]:
|
||||
- img [ref=e189]
|
||||
- generic [ref=e192]:
|
||||
- generic [ref=e193]: Civilian Shoes
|
||||
- generic [ref=e194]: "Enchantments: 0/15"
|
||||
- generic [ref=e195]:
|
||||
- heading "Accessories" [level=4] [ref=e196]
|
||||
- generic [ref=e197]:
|
||||
- button "Accessory 1 slot (empty)" [ref=e199]:
|
||||
- generic [ref=e201]:
|
||||
- img [ref=e202]
|
||||
- generic [ref=e205]: Accessory 1
|
||||
- generic [ref=e206]: Accessory 1
|
||||
- button "Accessory 2 slot (empty)" [ref=e208]:
|
||||
- generic [ref=e210]:
|
||||
- img [ref=e211]
|
||||
- generic [ref=e214]: Accessory 2
|
||||
- generic [ref=e215]: Accessory 2
|
||||
- generic [ref=e216]:
|
||||
- heading "Equipment Inventory (0 items)" [level=3] [ref=e218]
|
||||
- status [ref=e219]: No unequipped items. Craft new gear in the Crafting tab.
|
||||
- generic [ref=e220]:
|
||||
- heading "Equipment Stats Summary" [level=3] [ref=e222]
|
||||
- generic [ref=e223]:
|
||||
- generic [ref=e224]:
|
||||
- generic [ref=e225]: "4"
|
||||
- generic [ref=e226]: Total Items
|
||||
- generic [ref=e227]:
|
||||
- generic [ref=e228]: "4"
|
||||
- generic [ref=e229]: Equipped
|
||||
- generic [ref=e230]:
|
||||
- generic [ref=e231]: "0"
|
||||
- generic [ref=e232]: In Inventory
|
||||
- generic [ref=e233]:
|
||||
- generic [ref=e234]: "1"
|
||||
- generic [ref=e235]: Total Enchantments
|
||||
- generic [ref=e236]:
|
||||
- heading "✨ Enchantment Power" [level=3] [ref=e238]
|
||||
- generic [ref=e239]:
|
||||
- generic [ref=e240]:
|
||||
- generic [ref=e241]: "Enchantment Power:"
|
||||
- generic [ref=e242]: 1.00×
|
||||
- paragraph [ref=e243]: Increases the power of all enchantments by 0%. Multiplier applied to all enchantment effects.
|
||||
- generic [ref=e244]:
|
||||
- generic [ref=e245]: "Active Effects from Equipment:"
|
||||
- generic [ref=e247]: No active effects
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- button "Open Next.js Dev Tools" [ref=e253] [cursor=pointer]:
|
||||
- img [ref=e254]
|
||||
- alert [ref=e257]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | /**
|
||||
4 | * E2E tests for equipment management:
|
||||
5 | * - Equipping items to slots
|
||||
6 | * - 2-handed weapon blocking offhand slot
|
||||
7 | * - Unequipping items back to inventory
|
||||
8 | */
|
||||
9 |
|
||||
10 | test.describe('Equipment Management', () => {
|
||||
11 | test.beforeEach(async ({ page }) => {
|
||||
12 | await page.goto('/');
|
||||
13 | // Clear game state for a fresh start
|
||||
14 | await page.evaluate(() => {
|
||||
15 | Object.keys(localStorage)
|
||||
16 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
17 | .forEach((k) => localStorage.removeItem(k));
|
||||
18 | });
|
||||
19 | await page.reload();
|
||||
20 | await page.waitForLoadState('networkidle');
|
||||
21 | });
|
||||
22 |
|
||||
23 | test('can navigate to Equipment tab', async ({ page }) => {
|
||||
24 | // Use the tab with the shield icon to disambiguate
|
||||
25 | const gearTab = page.getByRole('tab', { name: /🛡️ Gear/ });
|
||||
26 | await expect(gearTab).toBeVisible();
|
||||
27 | await gearTab.click();
|
||||
28 |
|
||||
29 | // Verify equipment UI elements
|
||||
30 | const equippedGearHeading = page.locator('text="Equipped Gear"');
|
||||
31 | await expect(equippedGearHeading).toBeVisible({ timeout: 5000 });
|
||||
32 | });
|
||||
33 |
|
||||
34 | test('shows equipment slots with labels', async ({ page }) => {
|
||||
35 | await page.goto('/');
|
||||
36 | await page.evaluate(() => {
|
||||
37 | Object.keys(localStorage)
|
||||
38 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
39 | .forEach((k) => localStorage.removeItem(k));
|
||||
40 | });
|
||||
41 | await page.reload();
|
||||
42 | await page.waitForLoadState('networkidle');
|
||||
43 |
|
||||
44 | await page.getByRole('tab', { name: /🛡️ Gear/ }).click();
|
||||
45 |
|
||||
46 | // Check for expected slot labels - use role=heading or more specific selectors
|
||||
47 | // Main Hand slot
|
||||
48 | const mainHandSection = page.locator('text=Main Hand');
|
||||
49 | await expect(mainHandSection.first()).toBeVisible();
|
||||
50 |
|
||||
51 | // Off Hand
|
||||
52 | const offHandSection = page.locator('text=Off Hand');
|
||||
53 | await expect(offHandSection.first()).toBeVisible();
|
||||
54 |
|
||||
55 | // Head
|
||||
56 | const headSection = page.locator('text=Head');
|
||||
57 | await expect(headSection.first()).toBeVisible();
|
||||
58 |
|
||||
59 | // Body
|
||||
60 | const bodySection = page.locator('text=Body');
|
||||
61 | await expect(bodySection.first()).toBeVisible();
|
||||
62 |
|
||||
63 | // Hands
|
||||
64 | const handsSection = page.locator('text=Hands');
|
||||
65 | await expect(handsSection.first()).toBeVisible();
|
||||
66 |
|
||||
67 | // Feet
|
||||
68 | const feetSection = page.locator('text=Feet');
|
||||
69 | await expect(feetSection.first()).toBeVisible();
|
||||
70 |
|
||||
71 | // Accessory 1 and 2
|
||||
72 | const acc1Section = page.locator('text=Accessory 1');
|
||||
73 | await expect(acc1Section.first()).toBeVisible();
|
||||
74 | const acc2Section = page.locator('text=Accessory 2');
|
||||
75 | await expect(acc2Section.first()).toBeVisible();
|
||||
76 | });
|
||||
77 |
|
||||
78 | test('shows starting equipment already equipped', async ({ page }) => {
|
||||
79 | await page.goto('/');
|
||||
80 | await page.evaluate(() => {
|
||||
81 | Object.keys(localStorage)
|
||||
82 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
83 | .forEach((k) => localStorage.removeItem(k));
|
||||
84 | });
|
||||
85 | await page.reload();
|
||||
86 | await page.waitForLoadState('networkidle');
|
||||
87 |
|
||||
88 | await page.getByRole('tab', { name: /🛡️ Gear/ }).click();
|
||||
89 |
|
||||
90 | // The player starts with a Basic Staff in main hand (as an equipped item)
|
||||
91 | const mainHandSlot = page.locator('text=Main Hand >> .. >> text=Basic Staff');
|
||||
> 92 | await expect(mainHandSlot).toBeVisible({ timeout: 5000 });
|
||||
| ^ Error: expect(locator).toBeVisible() failed
|
||||
93 | });
|
||||
94 |
|
||||
95 | test('2-handed weapon blocks offhand slot', async ({ page }) => {
|
||||
96 | await page.goto('/');
|
||||
97 | await page.evaluate(() => {
|
||||
98 | Object.keys(localStorage)
|
||||
99 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
100 | .forEach((k) => localStorage.removeItem(k));
|
||||
101 | });
|
||||
102 | await page.reload();
|
||||
103 | await page.waitForLoadState('networkidle');
|
||||
104 |
|
||||
105 | await page.getByRole('tab', { name: /🛡️ Gear/ }).click();
|
||||
106 |
|
||||
107 | // The starting basic staff is 2-handed
|
||||
108 | // The offhand slot should show as blocked with "Occupied — 2H Weapon"
|
||||
109 | const offHandBlocked = page.locator('text=Occupied').first();
|
||||
110 | await expect(offHandBlocked).toBeVisible({ timeout: 5000 });
|
||||
111 | });
|
||||
112 |
|
||||
113 | test('can unequip an item from a slot', async ({ page }) => {
|
||||
114 | await page.goto('/');
|
||||
115 | await page.evaluate(() => {
|
||||
116 | Object.keys(localStorage)
|
||||
117 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
118 | .forEach((k) => localStorage.removeItem(k));
|
||||
119 | });
|
||||
120 | await page.reload();
|
||||
121 | await page.waitForLoadState('networkidle');
|
||||
122 |
|
||||
123 | await page.getByRole('tab', { name: /🛡️ Gear/ }).click();
|
||||
124 |
|
||||
125 | // Find an equiped slot with an unequip button (the X button)
|
||||
126 | // The hands slot has civilian gloves equipped
|
||||
127 | const handsSlot = page.locator('text=Hands >> .. >> button').first();
|
||||
128 | await expect(handsSlot).toBeVisible({ timeout: 5000 });
|
||||
129 | // Note: exact behavior of unequip depends on implementation state
|
||||
130 | });
|
||||
131 | });
|
||||
```
|
||||
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 243 KiB |
@@ -0,0 +1,280 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: enchanting.spec.ts >> Enchanting Flow >> can select equipment type and effect in Design stage
|
||||
- Location: e2e/enchanting.spec.ts:67:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: locator.click: Error: strict mode violation: getByRole('button') resolved to 6 elements:
|
||||
1) <button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-primary-foreground shad…>…</button> aka getByRole('button', { name: 'Gather +1 Mana' })
|
||||
2) <button class="flex items-center justify-between w-full text-xs text-gray-400 hover:text-gray-300 mb-2">…</button> aka getByRole('button', { name: 'Elemental Mana (1)' })
|
||||
3) <button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-primary-foreground shadow-xs hover…>…</button> aka getByRole('button', { name: 'Climb the Spire' })
|
||||
4) <button data-slot="action-button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] bg-[var(--interactive-primary)] text-white …>…</button> aka getByRole('button', { name: 'Fabricate' })
|
||||
5) <button data-slot="action-button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] bg-[var(--interactive-secondary)] text-[var…>…</button> aka getByRole('button', { name: 'Enchant' })
|
||||
6) <button id="next-logo" aria-haspopup="menu" data-next-mark="true" aria-expanded="false" aria-label="Open Next.js Dev Tools" data-nextjs-dev-tools-button="true" aria-controls="nextjs-dev-tools-menu">…</button> aka getByRole('button', { name: 'Open Next.js Dev Tools' })
|
||||
|
||||
Call log:
|
||||
- waiting for getByRole('button')
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- heading "MANA LOOP" [level=1] [ref=e5]
|
||||
- generic [ref=e7]:
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9]: Day 1
|
||||
- generic [ref=e10]: 01:02
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]: "0"
|
||||
- generic [ref=e13]: Insight
|
||||
- main [ref=e14]:
|
||||
- generic [ref=e15]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20]: "12"
|
||||
- generic [ref=e21]: / 100
|
||||
- generic [ref=e22]:
|
||||
- text: +2.3 mana/hr
|
||||
- generic [ref=e23]: (1.1x med)
|
||||
- progressbar [ref=e24]
|
||||
- button "Gather +1 Mana" [ref=e26]:
|
||||
- img
|
||||
- text: Gather +1 Mana
|
||||
- generic [ref=e27]:
|
||||
- button "Elemental Mana (1)" [ref=e28]:
|
||||
- generic [ref=e29]: Elemental Mana (1)
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]:
|
||||
- generic [ref=e34]:
|
||||
- generic [ref=e35]: 🔗
|
||||
- generic [ref=e36]: Transference
|
||||
- generic [ref=e39]: 0/10
|
||||
- button "Climb the Spire" [ref=e40]:
|
||||
- img
|
||||
- text: Climb the Spire
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- img [ref=e44]
|
||||
- generic [ref=e46]: Current Activity
|
||||
- generic [ref=e47]: Meditating
|
||||
- generic [ref=e48]:
|
||||
- generic [ref=e49]: "1"
|
||||
- generic [ref=e50]: "2"
|
||||
- generic [ref=e51]: "3"
|
||||
- generic [ref=e52]: "4"
|
||||
- generic [ref=e53]: "5"
|
||||
- generic [ref=e54]: "6"
|
||||
- generic [ref=e55]: "7"
|
||||
- generic [ref=e56]: "8"
|
||||
- generic [ref=e57]: "9"
|
||||
- generic [ref=e58]: "10"
|
||||
- generic [ref=e59]: "11"
|
||||
- generic [ref=e60]: "12"
|
||||
- generic [ref=e61]: "13"
|
||||
- generic [ref=e62]: "14"
|
||||
- generic [ref=e63]: "15"
|
||||
- generic [ref=e64]: "16"
|
||||
- generic [ref=e65]: "17"
|
||||
- generic [ref=e66]: "18"
|
||||
- generic [ref=e67]: "19"
|
||||
- generic [ref=e68]: "20"
|
||||
- generic [ref=e69]: "21"
|
||||
- generic [ref=e70]: "22"
|
||||
- generic [ref=e71]: "23"
|
||||
- generic [ref=e72]: "24"
|
||||
- generic [ref=e73]: "25"
|
||||
- generic [ref=e74]: "26"
|
||||
- generic [ref=e75]: "27"
|
||||
- generic [ref=e76]: "28"
|
||||
- generic [ref=e77]: "29"
|
||||
- generic [ref=e78]: "30"
|
||||
- generic [ref=e80]:
|
||||
- tablist [ref=e81]:
|
||||
- tab "⚔️ Spire" [ref=e82]
|
||||
- tab "✨ Attune" [ref=e83]
|
||||
- tab "🗿 Golems" [ref=e84]
|
||||
- tab "📚 Skills" [ref=e85]
|
||||
- tab "🔮 Spells" [ref=e86]
|
||||
- tab "🛡️ Gear" [ref=e87]
|
||||
- tab "🔧 Craft" [active] [selected] [ref=e88]
|
||||
- tab "💎 Loot" [ref=e89]
|
||||
- tab "🏆 Achieve" [ref=e90]
|
||||
- tab "📊 Stats" [ref=e91]
|
||||
- tab "🐛 Debug" [ref=e92]
|
||||
- tab "📖 Grimoire" [ref=e93]
|
||||
- tabpanel "🔧 Craft" [ref=e94]:
|
||||
- generic [ref=e95]:
|
||||
- generic [ref=e97]:
|
||||
- button "Fabricate" [ref=e98]:
|
||||
- img
|
||||
- text: Fabricate
|
||||
- button "Enchant" [ref=e99]:
|
||||
- img
|
||||
- text: Enchant
|
||||
- generic [ref=e100]:
|
||||
- generic [ref=e101]:
|
||||
- generic [ref=e103]:
|
||||
- img [ref=e104]
|
||||
- text: Available Blueprints
|
||||
- generic [ref=e113]:
|
||||
- img [ref=e114]
|
||||
- paragraph [ref=e118]: No blueprints discovered yet.
|
||||
- paragraph [ref=e119]: Defeat guardians to find blueprints!
|
||||
- generic [ref=e120]:
|
||||
- generic [ref=e122]:
|
||||
- img [ref=e123]
|
||||
- text: Materials (0)
|
||||
- generic [ref=e131]:
|
||||
- img [ref=e132]
|
||||
- paragraph [ref=e134]: No materials collected yet.
|
||||
- paragraph [ref=e135]: Defeat floors to gather materials!
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- button "Open Next.js Dev Tools" [ref=e141] [cursor=pointer]:
|
||||
- img [ref=e142]
|
||||
- alert [ref=e145]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | /**
|
||||
4 | * E2E tests for the 3-step enchantment flow:
|
||||
5 | * Design → Prepare → Apply
|
||||
6 | *
|
||||
7 | * These tests validate the core crafting loop works end-to-end.
|
||||
8 | */
|
||||
9 |
|
||||
10 | test.describe('Enchanting Flow', () => {
|
||||
11 | /**
|
||||
12 | * Before each test, ensure we start with a clean state.
|
||||
13 | * The game persists state in localStorage, so we clear it.
|
||||
14 | */
|
||||
15 | test.beforeEach(async ({ page }) => {
|
||||
16 | await page.goto('/');
|
||||
17 | // Clear game state to ensure a fresh start
|
||||
18 | await page.evaluate(() => {
|
||||
19 | Object.keys(localStorage)
|
||||
20 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
21 | .forEach((k) => localStorage.removeItem(k));
|
||||
22 | });
|
||||
23 | await page.reload();
|
||||
24 | // Wait for the game to initialize
|
||||
25 | await page.waitForLoadState('networkidle');
|
||||
26 | });
|
||||
27 |
|
||||
28 | test('can navigate to Crafting tab', async ({ page }) => {
|
||||
29 | // The tab bar contains a "Craft" tab
|
||||
30 | const craftTab = page.getByRole('tab', { name: /🔧 Craft/ });
|
||||
31 | await expect(craftTab).toBeVisible();
|
||||
32 | await craftTab.click();
|
||||
33 |
|
||||
34 | // Verify we're on the crafting tab by checking for sub-tabs
|
||||
35 | const fabricateBtn = page.getByRole('button', { hasText: 'Fabricate' });
|
||||
36 | const enchantBtn = page.getByRole('button', { hasText: 'Enchant' });
|
||||
37 | await expect(fabricateBtn).toBeVisible();
|
||||
38 | await expect(enchantBtn).toBeVisible();
|
||||
39 | });
|
||||
40 |
|
||||
41 | test('can switch to Enchant sub-tab and see design UI', async ({ page }) => {
|
||||
42 | await page.goto('/');
|
||||
43 | await page.evaluate(() => {
|
||||
44 | Object.keys(localStorage)
|
||||
45 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
46 | .forEach((k) => localStorage.removeItem(k));
|
||||
47 | });
|
||||
48 | await page.reload();
|
||||
49 | await page.waitForLoadState('networkidle');
|
||||
50 |
|
||||
51 | // Navigate to Crafting tab
|
||||
52 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
53 |
|
||||
54 | // Click Enchant sub-tab
|
||||
55 | const enchantBtn = page.getByRole('button', { hasText: 'Enchant' });
|
||||
56 | await enchantBtn.click();
|
||||
57 |
|
||||
58 | // Should see the design stage UI
|
||||
59 | const designBtn = page.getByRole('button', { hasText: 'Design' });
|
||||
60 | const prepareBtn = page.getByRole('button', { hasText: 'Prepare' });
|
||||
61 | const applyBtn = page.getByRole('button', { hasText: 'Apply' });
|
||||
62 | await expect(designBtn).toBeVisible();
|
||||
63 | await expect(prepareBtn).toBeVisible();
|
||||
64 | await expect(applyBtn).toBeVisible();
|
||||
65 | });
|
||||
66 |
|
||||
67 | test('can select equipment type and effect in Design stage', async ({ page }) => {
|
||||
68 | await page.goto('/');
|
||||
69 | await page.evaluate(() => {
|
||||
70 | Object.keys(localStorage)
|
||||
71 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
72 | .forEach((k) => localStorage.removeItem(k));
|
||||
73 | });
|
||||
74 | await page.reload();
|
||||
75 | await page.waitForLoadState('networkidle');
|
||||
76 |
|
||||
77 | // Navigate to Crafting > Enchant > Design
|
||||
78 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
> 79 | await page.getByRole('button', { hasText: 'Enchant' }).click();
|
||||
| ^ Error: locator.click: Error: strict mode violation: getByRole('button') resolved to 6 elements:
|
||||
80 |
|
||||
81 | // The design section should show effect selectors once an equipment type is chosen
|
||||
82 | // Look for any element matching equipment type buttons and effect-related content
|
||||
83 | const equipmentButtons = page.locator('button:has-text("Basic Staff"), button:has-text("Apprentice Wand"), button:has-text("Oak Staff"), button:has-text("Crystal Wand")');
|
||||
84 | const count = await equipmentButtons.count();
|
||||
85 | expect(count).toBeGreaterThan(0);
|
||||
86 | });
|
||||
87 |
|
||||
88 | test('can navigate through all 3 enchant stages', async ({ page }) => {
|
||||
89 | await page.goto('/');
|
||||
90 | await page.evaluate(() => {
|
||||
91 | Object.keys(localStorage)
|
||||
92 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
93 | .forEach((k) => localStorage.removeItem(k));
|
||||
94 | });
|
||||
95 | await page.reload();
|
||||
96 | await page.waitForLoadState('networkidle');
|
||||
97 |
|
||||
98 | // Navigate to Crafting > Enchant
|
||||
99 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
100 | await page.getByRole('button', { hasText: 'Enchant' }).click();
|
||||
101 |
|
||||
102 | // Verify Design stage is active
|
||||
103 | const designBtn = page.getByRole('button', { hasText: 'Design' });
|
||||
104 | await expect(designBtn).toBeVisible();
|
||||
105 |
|
||||
106 | // Switch to Prepare stage
|
||||
107 | const prepareBtn = page.getByRole('button', { hasText: 'Prepare' });
|
||||
108 | await prepareBtn.click();
|
||||
109 |
|
||||
110 | // Should see preparation UI
|
||||
111 | const prepareHeading = page.locator('text=Select Equipment to Prepare');
|
||||
112 | await expect(prepareHeading).toBeVisible({ timeout: 5000 });
|
||||
113 |
|
||||
114 | // Switch to Apply stage
|
||||
115 | const applyBtn = page.getByRole('button', { hasText: 'Apply' });
|
||||
116 | await applyBtn.click();
|
||||
117 |
|
||||
118 | // Should see application UI
|
||||
119 | const applyHeading = page.locator('text=Select Equipment & Design');
|
||||
120 | await expect(applyHeading).toBeVisible({ timeout: 5000 });
|
||||
121 | });
|
||||
122 | });
|
||||
```
|
||||
@@ -0,0 +1,280 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: enchanting.spec.ts >> Enchanting Flow >> can navigate through all 3 enchant stages
|
||||
- Location: e2e/enchanting.spec.ts:88:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: locator.click: Error: strict mode violation: getByRole('button') resolved to 6 elements:
|
||||
1) <button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-primary-foreground shad…>…</button> aka getByRole('button', { name: 'Gather +1 Mana' })
|
||||
2) <button class="flex items-center justify-between w-full text-xs text-gray-400 hover:text-gray-300 mb-2">…</button> aka getByRole('button', { name: 'Elemental Mana (1)' })
|
||||
3) <button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-primary-foreground shadow-xs hover…>…</button> aka getByRole('button', { name: 'Climb the Spire' })
|
||||
4) <button data-slot="action-button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] bg-[var(--interactive-primary)] text-white …>…</button> aka getByRole('button', { name: 'Fabricate' })
|
||||
5) <button data-slot="action-button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] bg-[var(--interactive-secondary)] text-[var…>…</button> aka getByRole('button', { name: 'Enchant' })
|
||||
6) <button id="next-logo" aria-haspopup="menu" data-next-mark="true" aria-expanded="false" aria-label="Open Next.js Dev Tools" data-nextjs-dev-tools-button="true" aria-controls="nextjs-dev-tools-menu">…</button> aka getByRole('button', { name: 'Open Next.js Dev Tools' })
|
||||
|
||||
Call log:
|
||||
- waiting for getByRole('button')
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- heading "MANA LOOP" [level=1] [ref=e5]
|
||||
- generic [ref=e7]:
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9]: Day 1
|
||||
- generic [ref=e10]: 00:55
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]: "0"
|
||||
- generic [ref=e13]: Insight
|
||||
- main [ref=e14]:
|
||||
- generic [ref=e15]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20]: "11"
|
||||
- generic [ref=e21]: / 100
|
||||
- generic [ref=e22]:
|
||||
- text: +2.2 mana/hr
|
||||
- generic [ref=e23]: (1.1x med)
|
||||
- progressbar [ref=e24]
|
||||
- button "Gather +1 Mana" [ref=e26]:
|
||||
- img
|
||||
- text: Gather +1 Mana
|
||||
- generic [ref=e27]:
|
||||
- button "Elemental Mana (1)" [ref=e28]:
|
||||
- generic [ref=e29]: Elemental Mana (1)
|
||||
- img [ref=e30]
|
||||
- generic [ref=e33]:
|
||||
- generic [ref=e34]:
|
||||
- generic [ref=e35]: 🔗
|
||||
- generic [ref=e36]: Transference
|
||||
- generic [ref=e39]: 0/10
|
||||
- button "Climb the Spire" [ref=e40]:
|
||||
- img
|
||||
- text: Climb the Spire
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- img [ref=e44]
|
||||
- generic [ref=e46]: Current Activity
|
||||
- generic [ref=e47]: Meditating
|
||||
- generic [ref=e48]:
|
||||
- generic [ref=e49]: "1"
|
||||
- generic [ref=e50]: "2"
|
||||
- generic [ref=e51]: "3"
|
||||
- generic [ref=e52]: "4"
|
||||
- generic [ref=e53]: "5"
|
||||
- generic [ref=e54]: "6"
|
||||
- generic [ref=e55]: "7"
|
||||
- generic [ref=e56]: "8"
|
||||
- generic [ref=e57]: "9"
|
||||
- generic [ref=e58]: "10"
|
||||
- generic [ref=e59]: "11"
|
||||
- generic [ref=e60]: "12"
|
||||
- generic [ref=e61]: "13"
|
||||
- generic [ref=e62]: "14"
|
||||
- generic [ref=e63]: "15"
|
||||
- generic [ref=e64]: "16"
|
||||
- generic [ref=e65]: "17"
|
||||
- generic [ref=e66]: "18"
|
||||
- generic [ref=e67]: "19"
|
||||
- generic [ref=e68]: "20"
|
||||
- generic [ref=e69]: "21"
|
||||
- generic [ref=e70]: "22"
|
||||
- generic [ref=e71]: "23"
|
||||
- generic [ref=e72]: "24"
|
||||
- generic [ref=e73]: "25"
|
||||
- generic [ref=e74]: "26"
|
||||
- generic [ref=e75]: "27"
|
||||
- generic [ref=e76]: "28"
|
||||
- generic [ref=e77]: "29"
|
||||
- generic [ref=e78]: "30"
|
||||
- generic [ref=e80]:
|
||||
- tablist [ref=e81]:
|
||||
- tab "⚔️ Spire" [ref=e82]
|
||||
- tab "✨ Attune" [ref=e83]
|
||||
- tab "🗿 Golems" [ref=e84]
|
||||
- tab "📚 Skills" [ref=e85]
|
||||
- tab "🔮 Spells" [ref=e86]
|
||||
- tab "🛡️ Gear" [ref=e87]
|
||||
- tab "🔧 Craft" [active] [selected] [ref=e88]
|
||||
- tab "💎 Loot" [ref=e89]
|
||||
- tab "🏆 Achieve" [ref=e90]
|
||||
- tab "📊 Stats" [ref=e91]
|
||||
- tab "🐛 Debug" [ref=e92]
|
||||
- tab "📖 Grimoire" [ref=e93]
|
||||
- tabpanel "🔧 Craft" [ref=e94]:
|
||||
- generic [ref=e95]:
|
||||
- generic [ref=e97]:
|
||||
- button "Fabricate" [ref=e98]:
|
||||
- img
|
||||
- text: Fabricate
|
||||
- button "Enchant" [ref=e99]:
|
||||
- img
|
||||
- text: Enchant
|
||||
- generic [ref=e100]:
|
||||
- generic [ref=e101]:
|
||||
- generic [ref=e103]:
|
||||
- img [ref=e104]
|
||||
- text: Available Blueprints
|
||||
- generic [ref=e113]:
|
||||
- img [ref=e114]
|
||||
- paragraph [ref=e118]: No blueprints discovered yet.
|
||||
- paragraph [ref=e119]: Defeat guardians to find blueprints!
|
||||
- generic [ref=e120]:
|
||||
- generic [ref=e122]:
|
||||
- img [ref=e123]
|
||||
- text: Materials (0)
|
||||
- generic [ref=e131]:
|
||||
- img [ref=e132]
|
||||
- paragraph [ref=e134]: No materials collected yet.
|
||||
- paragraph [ref=e135]: Defeat floors to gather materials!
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- region "Notifications (F8)":
|
||||
- list
|
||||
- button "Open Next.js Dev Tools" [ref=e141] [cursor=pointer]:
|
||||
- img [ref=e142]
|
||||
- alert [ref=e145]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | /**
|
||||
4 | * E2E tests for the 3-step enchantment flow:
|
||||
5 | * Design → Prepare → Apply
|
||||
6 | *
|
||||
7 | * These tests validate the core crafting loop works end-to-end.
|
||||
8 | */
|
||||
9 |
|
||||
10 | test.describe('Enchanting Flow', () => {
|
||||
11 | /**
|
||||
12 | * Before each test, ensure we start with a clean state.
|
||||
13 | * The game persists state in localStorage, so we clear it.
|
||||
14 | */
|
||||
15 | test.beforeEach(async ({ page }) => {
|
||||
16 | await page.goto('/');
|
||||
17 | // Clear game state to ensure a fresh start
|
||||
18 | await page.evaluate(() => {
|
||||
19 | Object.keys(localStorage)
|
||||
20 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
21 | .forEach((k) => localStorage.removeItem(k));
|
||||
22 | });
|
||||
23 | await page.reload();
|
||||
24 | // Wait for the game to initialize
|
||||
25 | await page.waitForLoadState('networkidle');
|
||||
26 | });
|
||||
27 |
|
||||
28 | test('can navigate to Crafting tab', async ({ page }) => {
|
||||
29 | // The tab bar contains a "Craft" tab
|
||||
30 | const craftTab = page.getByRole('tab', { name: /🔧 Craft/ });
|
||||
31 | await expect(craftTab).toBeVisible();
|
||||
32 | await craftTab.click();
|
||||
33 |
|
||||
34 | // Verify we're on the crafting tab by checking for sub-tabs
|
||||
35 | const fabricateBtn = page.getByRole('button', { hasText: 'Fabricate' });
|
||||
36 | const enchantBtn = page.getByRole('button', { hasText: 'Enchant' });
|
||||
37 | await expect(fabricateBtn).toBeVisible();
|
||||
38 | await expect(enchantBtn).toBeVisible();
|
||||
39 | });
|
||||
40 |
|
||||
41 | test('can switch to Enchant sub-tab and see design UI', async ({ page }) => {
|
||||
42 | await page.goto('/');
|
||||
43 | await page.evaluate(() => {
|
||||
44 | Object.keys(localStorage)
|
||||
45 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
46 | .forEach((k) => localStorage.removeItem(k));
|
||||
47 | });
|
||||
48 | await page.reload();
|
||||
49 | await page.waitForLoadState('networkidle');
|
||||
50 |
|
||||
51 | // Navigate to Crafting tab
|
||||
52 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
53 |
|
||||
54 | // Click Enchant sub-tab
|
||||
55 | const enchantBtn = page.getByRole('button', { hasText: 'Enchant' });
|
||||
56 | await enchantBtn.click();
|
||||
57 |
|
||||
58 | // Should see the design stage UI
|
||||
59 | const designBtn = page.getByRole('button', { hasText: 'Design' });
|
||||
60 | const prepareBtn = page.getByRole('button', { hasText: 'Prepare' });
|
||||
61 | const applyBtn = page.getByRole('button', { hasText: 'Apply' });
|
||||
62 | await expect(designBtn).toBeVisible();
|
||||
63 | await expect(prepareBtn).toBeVisible();
|
||||
64 | await expect(applyBtn).toBeVisible();
|
||||
65 | });
|
||||
66 |
|
||||
67 | test('can select equipment type and effect in Design stage', async ({ page }) => {
|
||||
68 | await page.goto('/');
|
||||
69 | await page.evaluate(() => {
|
||||
70 | Object.keys(localStorage)
|
||||
71 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
72 | .forEach((k) => localStorage.removeItem(k));
|
||||
73 | });
|
||||
74 | await page.reload();
|
||||
75 | await page.waitForLoadState('networkidle');
|
||||
76 |
|
||||
77 | // Navigate to Crafting > Enchant > Design
|
||||
78 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
79 | await page.getByRole('button', { hasText: 'Enchant' }).click();
|
||||
80 |
|
||||
81 | // The design section should show effect selectors once an equipment type is chosen
|
||||
82 | // Look for any element matching equipment type buttons and effect-related content
|
||||
83 | const equipmentButtons = page.locator('button:has-text("Basic Staff"), button:has-text("Apprentice Wand"), button:has-text("Oak Staff"), button:has-text("Crystal Wand")');
|
||||
84 | const count = await equipmentButtons.count();
|
||||
85 | expect(count).toBeGreaterThan(0);
|
||||
86 | });
|
||||
87 |
|
||||
88 | test('can navigate through all 3 enchant stages', async ({ page }) => {
|
||||
89 | await page.goto('/');
|
||||
90 | await page.evaluate(() => {
|
||||
91 | Object.keys(localStorage)
|
||||
92 | .filter((k) => k.startsWith('mana-loop-'))
|
||||
93 | .forEach((k) => localStorage.removeItem(k));
|
||||
94 | });
|
||||
95 | await page.reload();
|
||||
96 | await page.waitForLoadState('networkidle');
|
||||
97 |
|
||||
98 | // Navigate to Crafting > Enchant
|
||||
99 | await page.getByRole('tab', { name: /🔧 Craft/ }).click();
|
||||
> 100 | await page.getByRole('button', { hasText: 'Enchant' }).click();
|
||||
| ^ Error: locator.click: Error: strict mode violation: getByRole('button') resolved to 6 elements:
|
||||
101 |
|
||||
102 | // Verify Design stage is active
|
||||
103 | const designBtn = page.getByRole('button', { hasText: 'Design' });
|
||||
104 | await expect(designBtn).toBeVisible();
|
||||
105 |
|
||||
106 | // Switch to Prepare stage
|
||||
107 | const prepareBtn = page.getByRole('button', { hasText: 'Prepare' });
|
||||
108 | await prepareBtn.click();
|
||||
109 |
|
||||
110 | // Should see preparation UI
|
||||
111 | const prepareHeading = page.locator('text=Select Equipment to Prepare');
|
||||
112 | await expect(prepareHeading).toBeVisible({ timeout: 5000 });
|
||||
113 |
|
||||
114 | // Switch to Apply stage
|
||||
115 | const applyBtn = page.getByRole('button', { hasText: 'Apply' });
|
||||
116 | await applyBtn.click();
|
||||
117 |
|
||||
118 | // Should see application UI
|
||||
119 | const applyHeading = page.locator('text=Select Equipment & Design');
|
||||
120 | await expect(applyHeading).toBeVisible({ timeout: 5000 });
|
||||
121 | });
|
||||
122 | });
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Post {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
content String?
|
||||
published Boolean @default(false)
|
||||
authorId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
After Width: | Height: | Size: 38 KiB |
@@ -1,5 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ message: "Hello, world!" });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { useGameStore } from '@/lib/game/stores';
|
||||
|
||||
interface GameOverScreenProps {
|
||||
day: number;
|
||||
hour: number;
|
||||
insightGained: number;
|
||||
totalInsight: number;
|
||||
}
|
||||
|
||||
export function GameOverScreen({ day, hour, insightGained, totalInsight }: GameOverScreenProps) {
|
||||
const startNewLoop = () => {
|
||||
useGameStore.getState().startNewLoop();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 game-overlay flex items-center justify-center z-50">
|
||||
<Card className="bg-gray-900 border-gray-600 max-w-md w-full mx-4 shadow-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl text-center game-title text-amber-400">
|
||||
LOOP ENDS
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-center text-gray-400">
|
||||
The time loop resets... but you remember.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-amber-400 game-mono">{fmt(insightGained)}</div>
|
||||
<div className="text-xs text-gray-400">Insight Gained</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-blue-400 game-mono">{day}</div>
|
||||
<div className="text-xs text-gray-400">Day Reached</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-purple-400 game-mono">{hour}</div>
|
||||
<div className="text-xs text-gray-400">Hour</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-green-400 game-mono">{fmt(totalInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Total Insight</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
||||
size="lg"
|
||||
onClick={startNewLoop}
|
||||
>
|
||||
Begin New Loop
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Mountain } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
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 '@/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';
|
||||
|
||||
export function LeftPanel() {
|
||||
const [isGathering, setIsGathering] = useState(false);
|
||||
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const meditateTicks = useManaStore((s) => s.meditateTicks);
|
||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
||||
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
const gatherMana = useGameStore((s) => s.gatherMana);
|
||||
const spireMode = useCombatStore((s) => s.spireMode);
|
||||
const enterSpireMode = useCombatStore((s) => s.enterSpireMode);
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
const designProgress = useCraftingStore((s) => s.designProgress);
|
||||
const designProgress2 = useCraftingStore((s) => s.designProgress2);
|
||||
const preparationProgress = useCraftingStore((s) => s.preparationProgress);
|
||||
const applicationProgress = useCraftingStore((s) => s.applicationProgress);
|
||||
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
||||
|
||||
const handleGatherStart = () => { setIsGathering(true); gatherMana(); };
|
||||
const handleGatherEnd = () => { setIsGathering(false); };
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGathering) return;
|
||||
let lastGatherTime = 0;
|
||||
const minGatherInterval = 100;
|
||||
let animationFrameId: number;
|
||||
const gatherLoop = (timestamp: number) => {
|
||||
if (timestamp - lastGatherTime >= minGatherInterval) {
|
||||
gatherMana();
|
||||
lastGatherTime = timestamp;
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||
};
|
||||
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||
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, {}, upgradeEffects.meditationEfficiency);
|
||||
const incursionStrength = getIncursionStrength(useGameStore((s) => s.day), useGameStore((s) => s.hour));
|
||||
const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
|
||||
|
||||
return (
|
||||
<div className="md:w-80 space-y-3 flex-shrink-0 p-1">
|
||||
{/* 1. Mana Display */}
|
||||
<DebugName name="ManaDisplay">
|
||||
<ManaDisplay
|
||||
rawMana={rawMana}
|
||||
maxMana={maxMana}
|
||||
effectiveRegen={effectiveRegen}
|
||||
meditationMultiplier={meditationMultiplier}
|
||||
clickMana={clickMana}
|
||||
isGathering={isGathering}
|
||||
onGatherStart={handleGatherStart}
|
||||
onGatherEnd={handleGatherEnd}
|
||||
elements={elements}
|
||||
/>
|
||||
</DebugName>
|
||||
|
||||
{/* 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-600 text-white" size="lg" onClick={enterSpireMode}>
|
||||
<Mountain className="w-5 h-5 mr-2" />
|
||||
Climb the Spire
|
||||
</Button>
|
||||
</DebugName>
|
||||
)}
|
||||
|
||||
{/* 3. Current Action */}
|
||||
{!spireMode && (
|
||||
<DebugName name="ActionButtons">
|
||||
<Card className="bg-[var(--bg-surface)] border-[var(--border-subtle)]">
|
||||
<CardContent className="pt-3">
|
||||
<ActionButtons
|
||||
currentAction={currentAction}
|
||||
designProgress={designProgress}
|
||||
designProgress2={designProgress2}
|
||||
preparationProgress={preparationProgress}
|
||||
applicationProgress={applicationProgress}
|
||||
equipmentCraftingProgress={equipmentCraftingProgress}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DebugName>
|
||||
)}
|
||||
|
||||
{/* 4. Attunement Status */}
|
||||
{!spireMode && (
|
||||
<DebugName name="AttunementStatus">
|
||||
<Card className="bg-[var(--bg-surface)] border-[var(--border-subtle)]">
|
||||
<CardContent className="pt-3">
|
||||
<AttunementStatus />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DebugName>
|
||||
)}
|
||||
|
||||
{/* 5. Activity Log */}
|
||||
<DebugName name="ActivityLogPanel">
|
||||
<ActivityLogPanel />
|
||||
</DebugName>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +1,163 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-destructive: var(--destructive);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: #060811;
|
||||
--foreground: #c8d8f8;
|
||||
--card: #0C1020;
|
||||
--card-foreground: #c8d8f8;
|
||||
--popover: #111628;
|
||||
--popover-foreground: #c8d8f8;
|
||||
--primary: #3B6FE8;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #1e2a45;
|
||||
--secondary-foreground: #c8d8f8;
|
||||
--muted: #181f35;
|
||||
--muted-foreground: #7a92c0;
|
||||
--accent: #2a3a60;
|
||||
--accent-foreground: #c8d8f8;
|
||||
--destructive: #C0392B;
|
||||
--border: #1e2a45;
|
||||
--input: #1e2a45;
|
||||
--ring: #3B6FE8;
|
||||
--chart-1: #FF6B35;
|
||||
--chart-2: #4ECDC4;
|
||||
--chart-3: #9B59B6;
|
||||
--chart-4: #2ECC71;
|
||||
--chart-5: #FFD700;
|
||||
--sidebar: #0C1020;
|
||||
--sidebar-foreground: #c8d8f8;
|
||||
--sidebar-primary: #D4A843;
|
||||
--sidebar-primary-foreground: #0C1020;
|
||||
--sidebar-accent: #1e2a45;
|
||||
--sidebar-accent-foreground: #c8d8f8;
|
||||
--sidebar-border: #1e2a45;
|
||||
--sidebar-ring: #D4A843;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Game-specific colors */
|
||||
--game-bg: #060811;
|
||||
--game-bg1: #0C1020;
|
||||
--game-bg2: #111628;
|
||||
--game-bg3: #181f35;
|
||||
--game-border: #1e2a45;
|
||||
--game-border2: #2a3a60;
|
||||
--game-text: #c8d8f8;
|
||||
--game-text2: #7a92c0;
|
||||
--game-text3: #4a5f8a;
|
||||
--game-gold: #D4A843;
|
||||
/* === Background Colors (Depth Levels) === */
|
||||
--bg-base: #060811;
|
||||
--bg-surface: #0C1020;
|
||||
--bg-elevated: #111628;
|
||||
--bg-sunken: #181f35;
|
||||
|
||||
/* === Border Colors === */
|
||||
--border-subtle: #1e2a45;
|
||||
--border-default: #2a3a60;
|
||||
--border-focus: #5B8FFF;
|
||||
|
||||
/* === Text Colors === */
|
||||
--text-primary: #c8d8f8;
|
||||
--text-secondary: #7a92c0;
|
||||
--text-muted: #4a5f8a;
|
||||
--text-disabled: #2a3a60;
|
||||
|
||||
/* === Mana Element Colors === */
|
||||
--mana-fire: #E8734A;
|
||||
--mana-water: #3BAFDA;
|
||||
--mana-air: #C8D8F8;
|
||||
--mana-earth: #B8860B;
|
||||
--mana-light: #D4A843;
|
||||
--mana-dark: #4B0082;
|
||||
--mana-death: #8B7D8B;
|
||||
--mana-transfer: #00CED1;
|
||||
--mana-metal: #708090;
|
||||
--mana-sand: #C2B280;
|
||||
--mana-lightning: #FFD700;
|
||||
--mana-crystal: #B0E0E6;
|
||||
--mana-stellar: #FF8C00;
|
||||
--mana-void: #1A0A2E;
|
||||
|
||||
/* === Semantic UI Colors === */
|
||||
--color-success: #27AE60;
|
||||
--color-warning: #F39C12;
|
||||
--color-danger: #C0392B;
|
||||
--color-info: #3B6FE8;
|
||||
|
||||
/* === Rarity Colors === */
|
||||
--rarity-common: #9CA3AF;
|
||||
--rarity-common-glow: rgba(156, 163, 175, 0.25);
|
||||
--rarity-uncommon: #22C55E;
|
||||
--rarity-uncommon-glow: rgba(34, 197, 94, 0.25);
|
||||
--rarity-rare: #3B82F6;
|
||||
--rarity-rare-glow: rgba(59, 130, 246, 0.25);
|
||||
--rarity-epic: #A855F7;
|
||||
--rarity-epic-glow: rgba(168, 85, 247, 0.25);
|
||||
--rarity-legendary: #F59E0B;
|
||||
--rarity-legendary-glow: rgba(245, 158, 11, 0.375);
|
||||
--rarity-mythic: #E8734A;
|
||||
--rarity-mythic-glow: rgba(232, 115, 74, 0.25);
|
||||
|
||||
/* === Interactive Colors === */
|
||||
--interactive-primary: #3B6FE8;
|
||||
--interactive-primary-hover: #5B8FFF;
|
||||
--interactive-secondary: #2a3a60;
|
||||
--interactive-secondary-hover: #3a4a70;
|
||||
--interactive-danger: #C0392B;
|
||||
--interactive-danger-hover: #E74C3C;
|
||||
--interactive-disabled: #1e2a45;
|
||||
|
||||
/* === Typography === */
|
||||
--font-display: 'Cinzel', serif;
|
||||
--font-body: 'Source Serif 4', 'Crimson Text', Georgia, serif;
|
||||
--font-ui: 'JetBrains Mono', monospace;
|
||||
|
||||
/* === Shadow System === */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow-gold: 0 0 15px rgba(212, 168, 67, 0.4);
|
||||
--shadow-glow-purple: 0 0 15px rgba(124, 92, 191, 0.4);
|
||||
--shadow-glow-accent: 0 0 15px rgba(60, 111, 232, 0.4);
|
||||
|
||||
/* === Mana Loop Design Tokens (Strategy Spec) === */
|
||||
--bg-void: #0d0d0f;
|
||||
--bg-panel: #141418;
|
||||
--bg-raised: #242430;
|
||||
--mana-raw: #8b7fd4;
|
||||
--mana-transference: #1abc9c;
|
||||
--border-accent: rgba(255, 255, 255, 0.22);
|
||||
|
||||
/* === Legacy Shadcn Variables (mapped to new system) === */
|
||||
--background: var(--bg-base);
|
||||
--foreground: var(--text-primary);
|
||||
--card: var(--bg-surface);
|
||||
--card-foreground: var(--text-primary);
|
||||
--popover: var(--bg-elevated);
|
||||
--popover-foreground: var(--text-primary);
|
||||
--primary: var(--interactive-primary);
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: var(--bg-sunken);
|
||||
--secondary-foreground: var(--text-primary);
|
||||
--muted: var(--bg-sunken);
|
||||
--muted-foreground: var(--text-secondary);
|
||||
--accent: var(--interactive-secondary);
|
||||
--accent-foreground: var(--text-primary);
|
||||
--destructive: var(--color-danger);
|
||||
--border: var(--border-subtle);
|
||||
--input: var(--border-subtle);
|
||||
--ring: var(--border-focus);
|
||||
--chart-1: var(--mana-fire);
|
||||
--chart-2: var(--mana-water);
|
||||
--chart-3: var(--mana-light);
|
||||
--chart-4: var(--color-success);
|
||||
--chart-5: var(--mana-lightning);
|
||||
--sidebar: var(--bg-surface);
|
||||
--sidebar-foreground: var(--text-primary);
|
||||
--sidebar-primary: var(--mana-light);
|
||||
--sidebar-primary-foreground: #0C1020;
|
||||
--sidebar-accent: var(--interactive-secondary);
|
||||
--sidebar-accent-foreground: var(--text-primary);
|
||||
--sidebar-border: var(--border-subtle);
|
||||
--sidebar-ring: var(--mana-light);
|
||||
|
||||
/* Legacy game colors (kept for compatibility) */
|
||||
--game-bg: var(--bg-base);
|
||||
--game-bg1: var(--bg-surface);
|
||||
--game-bg2: var(--bg-elevated);
|
||||
--game-bg3: var(--bg-sunken);
|
||||
--game-border: var(--border-subtle);
|
||||
--game-border2: var(--border-default);
|
||||
--game-text: var(--text-primary);
|
||||
--game-text2: var(--text-secondary);
|
||||
--game-text3: var(--text-muted);
|
||||
--game-gold: var(--mana-light);
|
||||
--game-gold2: #A87830;
|
||||
--game-purple: #7C5CBF;
|
||||
--game-purpleL: #A07EE0;
|
||||
--game-accent: #3B6FE8;
|
||||
--game-accentL: #5B8FFF;
|
||||
--game-danger: #C0392B;
|
||||
--game-success: #27AE60;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #060811;
|
||||
--foreground: #c8d8f8;
|
||||
--card: #0C1020;
|
||||
--card-foreground: #c8d8f8;
|
||||
--popover: #111628;
|
||||
--popover-foreground: #c8d8f8;
|
||||
--primary: #5B8FFF;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #1e2a45;
|
||||
--secondary-foreground: #c8d8f8;
|
||||
--muted: #181f35;
|
||||
--muted-foreground: #7a92c0;
|
||||
--accent: #2a3a60;
|
||||
--accent-foreground: #c8d8f8;
|
||||
--destructive: #C0392B;
|
||||
--border: #1e2a45;
|
||||
--input: #1e2a45;
|
||||
--ring: #5B8FFF;
|
||||
--chart-1: #FF6B35;
|
||||
--chart-2: #4ECDC4;
|
||||
--chart-3: #9B59B6;
|
||||
--chart-4: #2ECC71;
|
||||
--chart-5: #FFD700;
|
||||
--sidebar: #0C1020;
|
||||
--sidebar-foreground: #c8d8f8;
|
||||
--sidebar-primary: #D4A843;
|
||||
--sidebar-primary-foreground: #0C1020;
|
||||
--sidebar-accent: #1e2a45;
|
||||
--sidebar-accent-foreground: #c8d8f8;
|
||||
--sidebar-border: #1e2a45;
|
||||
--sidebar-ring: #D4A843;
|
||||
--game-accent: var(--interactive-primary);
|
||||
--game-accentL: var(--interactive-primary-hover);
|
||||
--game-danger: var(--color-danger);
|
||||
--game-success: var(--color-success);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -139,13 +166,13 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Crimson Text', Georgia, serif;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
}
|
||||
|
||||
/* Game-specific styles */
|
||||
.game-root {
|
||||
font-family: 'Crimson Text', Georgia, serif;
|
||||
font-family: var(--font-body);
|
||||
background: var(--game-bg);
|
||||
color: var(--game-text);
|
||||
min-height: 100vh;
|
||||
@@ -159,7 +186,7 @@
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-family: var(--font-display);
|
||||
background: linear-gradient(135deg, var(--game-gold) 0%, var(--game-purpleL) 50%, var(--game-accentL) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
@@ -167,13 +194,13 @@
|
||||
}
|
||||
|
||||
.game-panel-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.game-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
@@ -218,6 +245,25 @@
|
||||
box-shadow: 0 0 15px rgba(60, 111, 232, 0.4);
|
||||
}
|
||||
|
||||
/* Gather button glow animation */
|
||||
@keyframes gather-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(59, 111, 232, 0.3), 0 0 10px rgba(59, 111, 232, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 15px rgba(59, 111, 232, 0.5), 0 0 25px rgba(59, 111, 232, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gather-glow {
|
||||
animation: gather-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Active scale effect for buttons - using CSS only */
|
||||
.active\:scale-95:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Button hover effects */
|
||||
.btn-game {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { DebugProvider } from "@/lib/game/debug-context";
|
||||
import { GameToaster } from "@/components/game/GameToast";
|
||||
import { DebugProvider } from "@/components/game/debug/debug-context";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
const geistSans = localFont({
|
||||
src: '../../public/fonts/GeistVF.woff',
|
||||
variable: '--font-geist-sans',
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
const geistMono = localFont({
|
||||
src: '../../public/fonts/GeistMonoVF.woff',
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -33,8 +34,9 @@ export default function RootLayout({
|
||||
>
|
||||
<DebugProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
<GameToaster />
|
||||
</DebugProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,465 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useGameStore, useGameLoop, fmt, getFloorElement, computeMaxMana, computeRegen, computeClickMana, getMeditationBonus, getIncursionStrength, canAffordSpellCost } from '@/lib/game/store';
|
||||
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
|
||||
import { useEffect, useState, lazy, Suspense } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, PRESTIGE_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||
import {
|
||||
useGameStore,
|
||||
useUIStore,
|
||||
useManaStore,
|
||||
useCombatStore,
|
||||
usePrestigeStore,
|
||||
useCraftingStore,
|
||||
fmt,
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
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 { hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
|
||||
import { TimeDisplay } from '@/components/game';
|
||||
|
||||
import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
|
||||
|
||||
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 { RotateCcw } from 'lucide-react';
|
||||
import { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab, EquipmentTab, AttunementsTab, DebugTab, LootTab, AchievementsTab, GolemancyTab } from '@/components/game/tabs';
|
||||
import { ActionButtons, CalendarDisplay, ManaDisplay, TimeDisplay } from '@/components/game';
|
||||
// Loot and Achievements moved to separate tabs
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
|
||||
import { GameOverScreen } from './components/GameOverScreen';
|
||||
import { LeftPanel } from './components/LeftPanel';
|
||||
import { GrimoireTab } from './components/GrimoireTab';
|
||||
|
||||
// Lazy load tab components
|
||||
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 TabFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||
|
||||
function TabErrorFallback({ name }: { name: string }) {
|
||||
return <div className="p-4 text-red-400">{name} tab failed to load.</div>;
|
||||
}
|
||||
|
||||
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
|
||||
const upgradeEffects = getUnifiedEffects({
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
equippedInstances,
|
||||
equipmentInstances,
|
||||
});
|
||||
|
||||
const disciplineEffects = computeDisciplineEffects();
|
||||
|
||||
const maxMana = computeMaxMana({
|
||||
skills: {},
|
||||
prestigeUpgrades,
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
}, upgradeEffects, disciplineEffects);
|
||||
|
||||
const baseRegen = computeRegen({
|
||||
skills: {},
|
||||
prestigeUpgrades,
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
attunements: {},
|
||||
}, upgradeEffects, disciplineEffects);
|
||||
|
||||
const clickMana = computeClickMana({ skills: {} }, disciplineEffects);
|
||||
const meditationMultiplier = getMeditationBonus(meditateTicks, {}, upgradeEffects.meditationEfficiency);
|
||||
const incursionStrength = getIncursionStrength(day, hour);
|
||||
|
||||
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;
|
||||
|
||||
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('spire');
|
||||
const [isGathering, setIsGathering] = useState(false);
|
||||
|
||||
// Game store
|
||||
const store = useGameStore();
|
||||
const gameLoop = useGameLoop();
|
||||
|
||||
// Computed effects from upgrades and equipment
|
||||
const upgradeEffects = getUnifiedEffects(store);
|
||||
|
||||
// Derived stats
|
||||
const maxMana = computeMaxMana(store, upgradeEffects);
|
||||
const baseRegen = computeRegen(store, upgradeEffects);
|
||||
const clickMana = computeClickMana(store);
|
||||
const floorElem = getFloorElement(store.currentFloor);
|
||||
const floorElemDef = ELEMENTS[floorElem];
|
||||
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
|
||||
const currentGuardian = GUARDIANS[store.currentFloor];
|
||||
const meditationMultiplier = getMeditationBonus(store.meditateTicks, store.skills, upgradeEffects.meditationEfficiency);
|
||||
const incursionStrength = getIncursionStrength(store.day, store.hour);
|
||||
const studySpeedMult = getStudySpeedMultiplier(store.skills);
|
||||
const studyCostMult = getStudyCostMultiplier(store.skills);
|
||||
|
||||
// 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;
|
||||
|
||||
// Effective regen
|
||||
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
|
||||
|
||||
// Get all active spells from equipment
|
||||
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
|
||||
|
||||
// Compute total DPS
|
||||
const totalDPS = getTotalDPS(store, upgradeEffects, floorElem);
|
||||
|
||||
// Auto-gather while holding
|
||||
useEffect(() => {
|
||||
if (!isGathering) return;
|
||||
|
||||
let lastGatherTime = 0;
|
||||
const minGatherInterval = 100;
|
||||
let animationFrameId: number;
|
||||
|
||||
const gatherLoop = (timestamp: number) => {
|
||||
if (timestamp - lastGatherTime >= minGatherInterval) {
|
||||
store.gatherMana();
|
||||
lastGatherTime = timestamp;
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [isGathering, store]);
|
||||
|
||||
// Handle gather button events
|
||||
const handleGatherStart = () => {
|
||||
setIsGathering(true);
|
||||
store.gatherMana();
|
||||
};
|
||||
|
||||
const handleGatherEnd = () => {
|
||||
setIsGathering(false);
|
||||
};
|
||||
|
||||
// Start game loop
|
||||
useEffect(() => {
|
||||
const cleanup = gameLoop.start();
|
||||
return cleanup;
|
||||
}, [gameLoop]);
|
||||
const [activeTab, setActiveTab] = useState('spells');
|
||||
|
||||
// Check if spell can be cast
|
||||
const canCastSpell = (spellId: string): boolean => {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return false;
|
||||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||||
};
|
||||
useGameLoop();
|
||||
|
||||
// Game Over Screen
|
||||
if (store.gameOver) {
|
||||
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]);
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
|
||||
useEffect(() => {
|
||||
if (spireMode) {
|
||||
setActiveTab('spells'); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
}
|
||||
}, [spireMode]);
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 game-overlay flex items-center justify-center z-50">
|
||||
<Card className="bg-gray-900 border-gray-600 max-w-md w-full mx-4 shadow-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className={`text-3xl text-center game-title ${store.victory ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{store.victory ? 'VICTORY!' : 'LOOP ENDS'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-center text-gray-400">
|
||||
{store.victory
|
||||
? 'The Awakened One falls! Your power echoes through eternity.'
|
||||
: 'The time loop resets... but you remember.'}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-amber-400 game-mono">{fmt(store.loopInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Insight Gained</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-blue-400 game-mono">{store.maxFloorReached}</div>
|
||||
<div className="text-xs text-gray-400">Best Floor</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-purple-400 game-mono">{store.signedPacts.length}</div>
|
||||
<div className="text-xs text-gray-400">Pacts Signed</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-green-400 game-mono">{store.loopCount + 1}</div>
|
||||
<div className="text-xs text-gray-400">Total Loops</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
||||
size="lg"
|
||||
onClick={() => store.startNewLoop()}
|
||||
>
|
||||
Begin New Loop
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div className="p-4 text-center text-gray-400">Loading spire...</div>}>
|
||||
<SpireCombatPage />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<TimeDisplay
|
||||
day={store.day}
|
||||
hour={store.hour}
|
||||
isPaused={store.isPaused}
|
||||
togglePause={store.togglePause}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<TooltipProvider>
|
||||
<div className="game-root min-h-screen flex flex-col">
|
||||
<header className="sticky top-0 z-50 bg-gradient-to-b from-gray-900 to-gray-900/80 border-b border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold game-title tracking-wider">MANA LOOP</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<TimeDisplay day={day} hour={hour} insight={insight} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col md:flex-row gap-4 p-4">
|
||||
{/* Left Panel - Mana & Actions */}
|
||||
<div className="md:w-80 space-y-4 flex-shrink-0">
|
||||
{/* Mana Display */}
|
||||
<DebugName name="ManaDisplay">
|
||||
<ManaDisplay
|
||||
rawMana={store.rawMana}
|
||||
maxMana={maxMana}
|
||||
effectiveRegen={effectiveRegen}
|
||||
meditationMultiplier={meditationMultiplier}
|
||||
clickMana={clickMana}
|
||||
isGathering={isGathering}
|
||||
onGatherStart={handleGatherStart}
|
||||
onGatherEnd={handleGatherEnd}
|
||||
elements={store.elements}
|
||||
/>
|
||||
</DebugName>
|
||||
<main className="flex-1 flex flex-col md:flex-row gap-4 p-4">
|
||||
<LeftPanel />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<DebugName name="ActionButtons">
|
||||
<ActionButtons
|
||||
currentAction={store.currentAction}
|
||||
designProgress={store.designProgress}
|
||||
preparationProgress={store.preparationProgress}
|
||||
applicationProgress={store.applicationProgress}
|
||||
setAction={store.setAction}
|
||||
/>
|
||||
</DebugName>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabTriggers />
|
||||
|
||||
{/* Calendar */}
|
||||
<DebugName name="CalendarDisplay">
|
||||
<CalendarDisplay
|
||||
day={store.day}
|
||||
hour={store.hour}
|
||||
incursionStrength={incursionStrength}
|
||||
/>
|
||||
</DebugName>
|
||||
|
||||
{/* Loot and Achievements moved to tabs */}
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Tabs */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="flex flex-wrap gap-1 w-full mb-4 h-auto">
|
||||
<TabsTrigger value="spire" className="text-xs px-2 py-1">⚔️ Spire</TabsTrigger>
|
||||
<TabsTrigger value="attunements" className="text-xs px-2 py-1">✨ Attune</TabsTrigger>
|
||||
<TabsTrigger value="golemancy" className="text-xs px-2 py-1">🗿 Golems</TabsTrigger>
|
||||
<TabsTrigger value="skills" className="text-xs px-2 py-1">📚 Skills</TabsTrigger>
|
||||
<TabsTrigger value="spells" className="text-xs px-2 py-1">🔮 Spells</TabsTrigger>
|
||||
<TabsTrigger value="equipment" className="text-xs px-2 py-1">🛡️ Gear</TabsTrigger>
|
||||
<TabsTrigger value="crafting" className="text-xs px-2 py-1">🔧 Craft</TabsTrigger>
|
||||
<TabsTrigger value="loot" className="text-xs px-2 py-1">💎 Loot</TabsTrigger>
|
||||
<TabsTrigger value="achievements" className="text-xs px-2 py-1">🏆 Achieve</TabsTrigger>
|
||||
<TabsTrigger value="lab" className="text-xs px-2 py-1">🔬 Lab</TabsTrigger>
|
||||
<TabsTrigger value="stats" className="text-xs px-2 py-1">📊 Stats</TabsTrigger>
|
||||
<TabsTrigger value="debug" className="text-xs px-2 py-1">🔧 Debug</TabsTrigger>
|
||||
<TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="spire">
|
||||
<DebugName name="SpireTab">
|
||||
<SpireTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="attunements">
|
||||
<DebugName name="AttunementsTab">
|
||||
<AttunementsTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="golemancy">
|
||||
<DebugName name="GolemancyTab">
|
||||
<GolemancyTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="skills">
|
||||
<DebugName name="SkillsTab">
|
||||
<SkillsTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="spells">
|
||||
<DebugName name="SpellsTab">
|
||||
<SpellsTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="equipment">
|
||||
<DebugName name="EquipmentTab">
|
||||
<EquipmentTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="crafting">
|
||||
<DebugName name="CraftingTab">
|
||||
<CraftingTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="loot">
|
||||
<DebugName name="LootTab">
|
||||
<LootTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="achievements">
|
||||
<DebugName name="AchievementsTab">
|
||||
<AchievementsTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="lab">
|
||||
<DebugName name="LabTab">
|
||||
<LabTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stats">
|
||||
<DebugName name="StatsTab">
|
||||
<StatsTab
|
||||
store={store}
|
||||
upgradeEffects={upgradeEffects}
|
||||
maxMana={maxMana}
|
||||
baseRegen={baseRegen}
|
||||
clickMana={clickMana}
|
||||
meditationMultiplier={meditationMultiplier}
|
||||
effectiveRegen={effectiveRegen}
|
||||
incursionStrength={incursionStrength}
|
||||
manaCascadeBonus={manaCascadeBonus}
|
||||
studySpeedMult={studySpeedMult}
|
||||
studyCostMult={studyCostMult}
|
||||
/>
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="grimoire">
|
||||
<DebugName name="GrimoireTab">
|
||||
{renderGrimoireTab()}
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="debug">
|
||||
<DebugName name="DebugTab">
|
||||
<DebugTab store={store} />
|
||||
</DebugName>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<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>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
// Grimoire Tab (Prestige)
|
||||
function renderGrimoireTab() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Current Status */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||
<div className="text-xs text-gray-400">Current Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Total Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
|
||||
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Signed Pacts */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Signed Pacts</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{store.signedPacts.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{store.signedPacts.map((floor) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return null;
|
||||
return (
|
||||
<div
|
||||
key={floor}
|
||||
className="flex items-center justify-between p-2 rounded border"
|
||||
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}15` }}
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||
{guardian.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Floor {floor}</div>
|
||||
</div>
|
||||
<Badge className="bg-amber-900/50 text-amber-300">
|
||||
{guardian.pact}x multiplier
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Prestige Upgrades */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
|
||||
const level = store.prestigeUpgrades[id] || 0;
|
||||
const maxed = level >= def.max;
|
||||
const canBuy = !maxed && store.insight >= def.cost;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-3 rounded border border-gray-700 bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{level}/{def.max}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canBuy ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
disabled={!canBuy}
|
||||
onClick={() => store.doPrestige(id)}
|
||||
>
|
||||
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reset Game Button */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Reset All Progress</div>
|
||||
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
|
||||
store.resetGame();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Import TooltipProvider
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { Component, ReactNode } from 'react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
<div className="p-4 bg-red-900/20 border border-red-600/50 rounded">
|
||||
<h3 className="text-red-400 font-bold mb-2">Something went wrong:</h3>
|
||||
<pre className="text-xs text-red-300">{this.state.error?.message}</pre>
|
||||
<pre className="text-xs text-gray-500 mt-2">{this.state.error?.stack}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Trophy, Lock, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { AchievementState } from '@/lib/game/types';
|
||||
import { ACHIEVEMENTS, ACHIEVEMENT_CATEGORY_COLORS, getAchievementsByCategory, isAchievementRevealed } from '@/lib/game/data/achievements';
|
||||
import { GameState } from '@/lib/game/types';
|
||||
|
||||
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 (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
Achievements
|
||||
<Badge className="ml-auto bg-amber-900/50 text-amber-300">
|
||||
{unlockedCount} / {totalCount}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(categories).map(([category, categoryAchievements]) => (
|
||||
<div key={category} className="space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between text-xs"
|
||||
onClick={() => setExpandedCategory(expandedCategory === category ? null : category)}
|
||||
>
|
||||
<span style={{ color: ACHIEVEMENT_CATEGORY_COLORS[category] }}>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{categoryAchievements.filter(a => achievements.unlocked.includes(a.id)).length} / {categoryAchievements.length}
|
||||
</span>
|
||||
{expandedCategory === category ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{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-gray-800/30 border border-gray-700">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span className="text-sm">???</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`p-2 rounded border ${
|
||||
isUnlocked
|
||||
? 'bg-amber-900/20 border-amber-600/50'
|
||||
: 'bg-gray-800/30 border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{isUnlocked ? (
|
||||
<CheckCircle className="w-4 h-4 text-amber-400" />
|
||||
) : (
|
||||
<Trophy className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
<span className={`text-sm font-semibold ${isUnlocked ? 'text-amber-300' : 'text-gray-300'}`}>
|
||||
{achievement.name}
|
||||
</span>
|
||||
</div>
|
||||
{achievement.reward.title && isUnlocked && (
|
||||
<Badge className="text-xs bg-purple-900/50 text-purple-300">
|
||||
Title
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
{achievement.desc}
|
||||
</div>
|
||||
|
||||
{!isUnlocked && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={progressPercent} className="h-1 bg-gray-700" />
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>{progress.toLocaleString()} / {achievement.requirement.value.toLocaleString()}</span>
|
||||
<span>{progressPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUnlocked && achievement.reward && (
|
||||
<div className="text-xs text-amber-400/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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sparkles, Swords, BookOpen, Target, FlaskConical } from 'lucide-react';
|
||||
import { Sparkles, Swords, BookOpen, Target, FlaskConical, Cog, Hammer } from 'lucide-react';
|
||||
import type { GameAction } from '@/lib/game/types';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
currentAction: GameAction;
|
||||
currentStudyTarget: { type: 'skill' | 'spell'; id: string; progress: number; required: number } | null;
|
||||
designProgress: { progress: number; required: number } | null;
|
||||
designProgress2: { progress: number; required: number } | null;
|
||||
preparationProgress: { progress: number; required: number } | null;
|
||||
applicationProgress: { progress: number; required: number } | null;
|
||||
setAction: (action: GameAction) => void;
|
||||
equipmentCraftingProgress: { progress: number; required: number } | null;
|
||||
}
|
||||
|
||||
// Map action IDs to labels and icons
|
||||
const ACTION_CONFIG: Record<string, { label: string; icon: typeof Sparkles; color: string }> = {
|
||||
meditate: { label: 'Meditating', icon: Sparkles, color: 'text-blue-400' },
|
||||
climb: { label: 'Climbing', icon: Swords, color: 'text-green-400' },
|
||||
study: { label: 'Studying', icon: BookOpen, color: 'text-yellow-400' },
|
||||
design: { label: 'Designing Enchantment', icon: Target, color: 'text-purple-400' },
|
||||
prepare: { label: 'Preparing Equipment', icon: FlaskConical, color: 'text-purple-400' },
|
||||
enchant: { label: 'Enchanting', icon: Sparkles, color: 'text-purple-400' },
|
||||
craft: { label: 'Crafting Equipment', icon: Hammer, color: 'text-orange-400' },
|
||||
convert: { label: 'Converting Mana', icon: Cog, color: 'text-cyan-400' },
|
||||
};
|
||||
|
||||
function ProgressBar({ progress, required, label }: { progress: number; required: number; label?: string }) {
|
||||
const percentage = Math.min(100, (progress / required) * 100);
|
||||
return (
|
||||
<div className="mt-1">
|
||||
{label && <div className="text-xs text-gray-400 mb-0.5">{label}</div>}
|
||||
<div className="w-full bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionButtons({
|
||||
currentAction,
|
||||
currentStudyTarget,
|
||||
designProgress,
|
||||
designProgress2,
|
||||
preparationProgress,
|
||||
applicationProgress,
|
||||
setAction,
|
||||
equipmentCraftingProgress,
|
||||
}: ActionButtonsProps) {
|
||||
const actions: { id: GameAction; label: string; icon: typeof Swords }[] = [
|
||||
{ id: 'meditate', label: 'Meditate', icon: Sparkles },
|
||||
{ id: 'climb', label: 'Climb', icon: Swords },
|
||||
{ id: 'study', label: 'Study', icon: BookOpen },
|
||||
];
|
||||
const config = ACTION_CONFIG[currentAction] || { label: currentAction, icon: Sparkles, color: 'text-gray-400' };
|
||||
const Icon = config.icon;
|
||||
|
||||
const hasDesignProgress = designProgress !== null;
|
||||
const hasPrepProgress = preparationProgress !== null;
|
||||
const hasAppProgress = applicationProgress !== null;
|
||||
// Calculate additional info for specific actions
|
||||
const getActionDetails = () => {
|
||||
switch (currentAction) {
|
||||
case 'study':
|
||||
if (currentStudyTarget) {
|
||||
const progress = currentStudyTarget.progress;
|
||||
const required = currentStudyTarget.required;
|
||||
const percentage = Math.min(100, (progress / required) * 100);
|
||||
return (
|
||||
<ProgressBar
|
||||
progress={progress}
|
||||
required={required}
|
||||
label={`${currentStudyTarget.type === 'skill' ? 'Skill' : 'Spell'}: ${percentage.toFixed(0)}%`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'design':
|
||||
if (designProgress) {
|
||||
return (
|
||||
<ProgressBar
|
||||
progress={designProgress.progress}
|
||||
required={designProgress.required}
|
||||
label="Design progress"
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'prepare':
|
||||
if (preparationProgress) {
|
||||
return (
|
||||
<ProgressBar
|
||||
progress={preparationProgress.progress}
|
||||
required={preparationProgress.required}
|
||||
label="Preparation progress"
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'enchant':
|
||||
if (applicationProgress) {
|
||||
return (
|
||||
<ProgressBar
|
||||
progress={applicationProgress.progress}
|
||||
required={applicationProgress.required}
|
||||
label="Enchantment progress"
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'craft':
|
||||
if (equipmentCraftingProgress) {
|
||||
return (
|
||||
<ProgressBar
|
||||
progress={equipmentCraftingProgress.progress}
|
||||
required={equipmentCraftingProgress.required}
|
||||
label="Crafting progress"
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{actions.map(({ id, label, icon: Icon }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant={currentAction === id ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`h-9 ${currentAction === id ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||
onClick={() => setAction(id)}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-1" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Crafting actions row - shown when there's active crafting progress */}
|
||||
{(hasDesignProgress || hasPrepProgress || hasAppProgress) && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={currentAction === 'design' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!hasDesignProgress}
|
||||
className={`h-9 ${currentAction === 'design' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||
onClick={() => hasDesignProgress && setAction('design')}
|
||||
>
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
Design
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentAction === 'prepare' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!hasPrepProgress}
|
||||
className={`h-9 ${currentAction === 'prepare' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||
onClick={() => hasPrepProgress && setAction('prepare')}
|
||||
>
|
||||
<FlaskConical className="w-4 h-4 mr-1" />
|
||||
Prepare
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentAction === 'enchant' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!hasAppProgress}
|
||||
className={`h-9 ${currentAction === 'enchant' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||
onClick={() => hasAppProgress && setAction('enchant')}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-1" />
|
||||
Enchant
|
||||
</Button>
|
||||
<div className="bg-gray-800/50 rounded-lg p-3 border border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`w-4 h-4 ${config.color}`} />
|
||||
<span className="text-sm font-medium text-gray-200">Current Activity</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`text-lg font-semibold mt-1 ${config.color}`}>
|
||||
{config.label}
|
||||
</div>
|
||||
{getActionDetails()}
|
||||
|
||||
{/* Show second design slot if active */}
|
||||
{designProgress2 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="w-3 h-3 text-purple-400" />
|
||||
<span className="text-xs text-gray-400">Second Design Slot</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
progress={designProgress2.progress}
|
||||
required={designProgress2.required}
|
||||
label="Design progress"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ActionButtons.displayName = "ActionButtons";
|
||||
ProgressBar.displayName = "ProgressBar";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useCombatStore } from '@/lib/game/stores';
|
||||
import { ActivityLog } from './tabs/ActivityLog';
|
||||
|
||||
/**
|
||||
* Activity log panel for the left sidebar.
|
||||
* Wraps the existing ActivityLog tab component with store integration,
|
||||
* showing only the most recent 20 entries.
|
||||
*/
|
||||
export function ActivityLogPanel() {
|
||||
const activityLog = useCombatStore((s) => s.activityLog);
|
||||
|
||||
return (
|
||||
<ActivityLog activityLog={activityLog} maxEntries={20} />
|
||||
);
|
||||
}
|
||||
|
||||
ActivityLogPanel.displayName = 'ActivityLogPanel';
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useAttunementStore } from '@/lib/game/stores';
|
||||
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
const SLOT_LABELS: Record<string, string> = {
|
||||
rightHand: 'R. Hand',
|
||||
leftHand: 'L. Hand',
|
||||
head: 'Head',
|
||||
back: 'Back',
|
||||
chest: 'Chest',
|
||||
leftLeg: 'L. Leg',
|
||||
rightLeg: 'R. Leg',
|
||||
};
|
||||
|
||||
export function AttunementStatus() {
|
||||
const attunements = useAttunementStore((s) => s.attunements);
|
||||
|
||||
const activeAttunements = Object.entries(attunements)
|
||||
.filter(([, state]) => state.active)
|
||||
.sort(([, a], [, b]) => {
|
||||
const orderA = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === a.id);
|
||||
const orderB = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === b.id);
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const xpForNext = (level: number) => {
|
||||
if (level <= 1) return 0;
|
||||
if (level === 2) return 1000;
|
||||
return Math.floor(1000 * Math.pow(2, level - 2) * (level >= 3 ? 1.25 : 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] font-bold">Attunements</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)]">{activeAttunements.length} active</span>
|
||||
</div>
|
||||
<Separator className="bg-[var(--border-subtle)]" />
|
||||
<div className="space-y-1.5">
|
||||
{activeAttunements.length === 0 ? (
|
||||
<div className="text-[10px] text-[var(--text-muted)] italic">No attunements active</div>
|
||||
) : (
|
||||
activeAttunements.map(([id, state]) => {
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def) return null;
|
||||
const nextXp = xpForNext(state.level);
|
||||
const xpProgress = nextXp > 0 ? (state.experience / nextXp) * 100 : 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider key={id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 p-1.5 rounded bg-[var(--bg-sunken)]/50 border border-[var(--border-subtle)]">
|
||||
<span className="text-sm">{def.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-[var(--text-primary)] truncate">
|
||||
{def.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-secondary)] font-mono">
|
||||
Lv.{state.level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)]">
|
||||
<span className="capitalize">{SLOT_LABELS[def.slot] || def.slot}</span>
|
||||
{nextXp > 0 && (
|
||||
<span className="ml-1.5 font-mono">
|
||||
{Math.floor(state.experience).toLocaleString()}/{nextXp.toLocaleString()} XP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{nextXp > 0 && (
|
||||
<div className="w-full h-0.5 bg-[var(--border-subtle)] rounded-full mt-0.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, xpProgress)}%`,
|
||||
backgroundColor: def.color,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p className="text-xs max-w-[220px]">{def.desc}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AttunementStatus.displayName = 'AttunementStatus';
|
||||
@@ -1,50 +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>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +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/store';
|
||||
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;
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
import { useSkillStore } from '@/lib/game/stores/skillStore';
|
||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||
import { useUIStore } from '@/lib/game/stores/uiStore';
|
||||
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
||||
import { useGameStore, useGameLoop } from '@/lib/game/stores/gameStore';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
|
||||
import { getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
getMeditationBonus,
|
||||
canAffordSpellCost,
|
||||
calcDamage,
|
||||
getFloorElement,
|
||||
getBoonBonuses,
|
||||
getIncursionStrength,
|
||||
} from '@/lib/game/utils';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
HOURS_PER_TICK,
|
||||
TICK_MS,
|
||||
} from '@/lib/game/constants';
|
||||
import type { ElementDef, GuardianDef, SpellDef, GameAction } from '@/lib/game/types';
|
||||
|
||||
// Define a unified store type that combines all stores
|
||||
interface UnifiedStore {
|
||||
// From gameStore (coordinator)
|
||||
day: number;
|
||||
hour: number;
|
||||
incursionStrength: number;
|
||||
containmentWards: number;
|
||||
initialized: boolean;
|
||||
tick: () => void;
|
||||
resetGame: () => void;
|
||||
gatherMana: () => void;
|
||||
startNewLoop: () => void;
|
||||
|
||||
// From manaStore
|
||||
rawMana: number;
|
||||
meditateTicks: number;
|
||||
totalManaGathered: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
setRawMana: (amount: number) => void;
|
||||
addRawMana: (amount: number, max: number) => void;
|
||||
spendRawMana: (amount: number) => boolean;
|
||||
convertMana: (element: string, amount: number) => boolean;
|
||||
unlockElement: (element: string, cost: number) => boolean;
|
||||
craftComposite: (target: string, recipe: string[]) => boolean;
|
||||
|
||||
// From skillStore
|
||||
skills: Record<string, number>;
|
||||
skillProgress: Record<string, number>;
|
||||
skillUpgrades: Record<string, string[]>;
|
||||
skillTiers: Record<string, number>;
|
||||
paidStudySkills: Record<string, number>;
|
||||
currentStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
|
||||
parallelStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
|
||||
setSkillLevel: (skillId: string, level: number) => void;
|
||||
startStudyingSkill: (skillId: string, rawMana: number) => { started: boolean; cost: number };
|
||||
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => { started: boolean; cost: number };
|
||||
cancelStudy: (retentionBonus: number) => void;
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
||||
tierUpSkill: (skillId: string) => void;
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: Array<{ id: string; name: string; desc: string; milestone: 5 | 10; effect: { type: string; stat?: string; value?: number; specialId?: string } }>; selected: string[] };
|
||||
|
||||
// From prestigeStore
|
||||
loopCount: number;
|
||||
insight: number;
|
||||
totalInsight: number;
|
||||
loopInsight: number;
|
||||
prestigeUpgrades: Record<string, number>;
|
||||
memorySlots: number;
|
||||
pactSlots: number;
|
||||
memories: Array<{ skillId: string; level: number; tier: number; upgrades: string[] }>;
|
||||
defeatedGuardians: number[];
|
||||
signedPacts: number[];
|
||||
pactRitualFloor: number | null;
|
||||
pactRitualProgress: number;
|
||||
doPrestige: (id: string) => void;
|
||||
addMemory: (memory: { skillId: string; level: number; tier: number; upgrades: string[] }) => void;
|
||||
removeMemory: (skillId: string) => void;
|
||||
clearMemories: () => void;
|
||||
startPactRitual: (floor: number, rawMana: number) => boolean;
|
||||
cancelPactRitual: () => void;
|
||||
removePact: (floor: number) => void;
|
||||
defeatGuardian: (floor: number) => void;
|
||||
|
||||
// From combatStore
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
activeSpell: string;
|
||||
currentAction: GameAction;
|
||||
castProgress: number;
|
||||
spells: Record<string, { learned: boolean; level: number; studyProgress?: number }>;
|
||||
setAction: (action: GameAction) => void;
|
||||
setSpell: (spellId: string) => void;
|
||||
learnSpell: (spellId: string) => void;
|
||||
advanceFloor: () => void;
|
||||
|
||||
// From uiStore
|
||||
log: string[];
|
||||
paused: boolean;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
addLog: (message: string) => void;
|
||||
togglePause: () => void;
|
||||
setPaused: (paused: boolean) => void;
|
||||
setGameOver: (gameOver: boolean, victory?: boolean) => void;
|
||||
}
|
||||
|
||||
interface GameContextValue {
|
||||
// Unified store for backward compatibility
|
||||
store: UnifiedStore;
|
||||
|
||||
// Individual stores for direct access if needed
|
||||
skillStore: ReturnType<typeof useSkillStore.getState>;
|
||||
manaStore: ReturnType<typeof useManaStore.getState>;
|
||||
prestigeStore: ReturnType<typeof usePrestigeStore.getState>;
|
||||
uiStore: ReturnType<typeof useUIStore.getState>;
|
||||
combatStore: ReturnType<typeof useCombatStore.getState>;
|
||||
|
||||
// Computed effects from upgrades
|
||||
upgradeEffects: ReturnType<typeof computeEffects>;
|
||||
|
||||
// Derived stats
|
||||
maxMana: number;
|
||||
baseRegen: number;
|
||||
clickMana: number;
|
||||
floorElem: string;
|
||||
floorElemDef: ElementDef | undefined;
|
||||
isGuardianFloor: boolean;
|
||||
currentGuardian: GuardianDef | undefined;
|
||||
activeSpellDef: SpellDef | undefined;
|
||||
meditationMultiplier: number;
|
||||
incursionStrength: number;
|
||||
studySpeedMult: number;
|
||||
studyCostMult: number;
|
||||
|
||||
// Effective regen calculations
|
||||
effectiveRegenWithSpecials: number;
|
||||
manaCascadeBonus: number;
|
||||
effectiveRegen: number;
|
||||
|
||||
// DPS calculation
|
||||
dps: number;
|
||||
|
||||
// Boons
|
||||
activeBoons: ReturnType<typeof getBoonBonuses>;
|
||||
|
||||
// Helpers
|
||||
canCastSpell: (spellId: string) => boolean;
|
||||
hasSpecial: (effects: ReturnType<typeof computeEffects>, specialId: string) => boolean;
|
||||
SPECIAL_EFFECTS: typeof SPECIAL_EFFECTS;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
|
||||
export function GameProvider({ children }: { children: ReactNode }) {
|
||||
// Get all individual stores
|
||||
const gameStore = useGameStore();
|
||||
const skillState = useSkillStore();
|
||||
const manaState = useManaStore();
|
||||
const prestigeState = usePrestigeStore();
|
||||
const uiState = useUIStore();
|
||||
const combatState = useCombatStore();
|
||||
|
||||
// Create unified store object for backward compatibility
|
||||
const unifiedStore = useMemo<UnifiedStore>(() => ({
|
||||
// From gameStore
|
||||
day: gameStore.day,
|
||||
hour: gameStore.hour,
|
||||
incursionStrength: gameStore.incursionStrength,
|
||||
containmentWards: gameStore.containmentWards,
|
||||
initialized: gameStore.initialized,
|
||||
tick: gameStore.tick,
|
||||
resetGame: gameStore.resetGame,
|
||||
gatherMana: gameStore.gatherMana,
|
||||
startNewLoop: gameStore.startNewLoop,
|
||||
|
||||
// From manaStore
|
||||
rawMana: manaState.rawMana,
|
||||
meditateTicks: manaState.meditateTicks,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
elements: manaState.elements,
|
||||
setRawMana: manaState.setRawMana,
|
||||
addRawMana: manaState.addRawMana,
|
||||
spendRawMana: manaState.spendRawMana,
|
||||
convertMana: manaState.convertMana,
|
||||
unlockElement: manaState.unlockElement,
|
||||
craftComposite: manaState.craftComposite,
|
||||
|
||||
// From skillStore
|
||||
skills: skillState.skills,
|
||||
skillProgress: skillState.skillProgress,
|
||||
skillUpgrades: skillState.skillUpgrades,
|
||||
skillTiers: skillState.skillTiers,
|
||||
paidStudySkills: skillState.paidStudySkills,
|
||||
currentStudyTarget: skillState.currentStudyTarget,
|
||||
parallelStudyTarget: skillState.parallelStudyTarget,
|
||||
setSkillLevel: skillState.setSkillLevel,
|
||||
startStudyingSkill: skillState.startStudyingSkill,
|
||||
startStudyingSpell: skillState.startStudyingSpell,
|
||||
cancelStudy: skillState.cancelStudy,
|
||||
selectSkillUpgrade: skillState.selectSkillUpgrade,
|
||||
deselectSkillUpgrade: skillState.deselectSkillUpgrade,
|
||||
commitSkillUpgrades: skillState.commitSkillUpgrades,
|
||||
tierUpSkill: skillState.tierUpSkill,
|
||||
getSkillUpgradeChoices: skillState.getSkillUpgradeChoices,
|
||||
|
||||
// From prestigeStore
|
||||
loopCount: prestigeState.loopCount,
|
||||
insight: prestigeState.insight,
|
||||
totalInsight: prestigeState.totalInsight,
|
||||
loopInsight: prestigeState.loopInsight,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
memorySlots: prestigeState.memorySlots,
|
||||
pactSlots: prestigeState.pactSlots,
|
||||
memories: prestigeState.memories,
|
||||
defeatedGuardians: prestigeState.defeatedGuardians,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
pactRitualFloor: prestigeState.pactRitualFloor,
|
||||
pactRitualProgress: prestigeState.pactRitualProgress,
|
||||
doPrestige: prestigeState.doPrestige,
|
||||
addMemory: prestigeState.addMemory,
|
||||
removeMemory: prestigeState.removeMemory,
|
||||
clearMemories: prestigeState.clearMemories,
|
||||
startPactRitual: prestigeState.startPactRitual,
|
||||
cancelPactRitual: prestigeState.cancelPactRitual,
|
||||
removePact: prestigeState.removePact,
|
||||
defeatGuardian: prestigeState.defeatGuardian,
|
||||
|
||||
// From combatStore
|
||||
currentFloor: combatState.currentFloor,
|
||||
floorHP: combatState.floorHP,
|
||||
floorMaxHP: combatState.floorMaxHP,
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
activeSpell: combatState.activeSpell,
|
||||
currentAction: combatState.currentAction,
|
||||
castProgress: combatState.castProgress,
|
||||
spells: combatState.spells,
|
||||
setAction: combatState.setAction,
|
||||
setSpell: combatState.setSpell,
|
||||
learnSpell: combatState.learnSpell,
|
||||
advanceFloor: combatState.advanceFloor,
|
||||
|
||||
// From uiStore
|
||||
log: uiState.logs,
|
||||
paused: uiState.paused,
|
||||
gameOver: uiState.gameOver,
|
||||
victory: uiState.victory,
|
||||
addLog: uiState.addLog,
|
||||
togglePause: uiState.togglePause,
|
||||
setPaused: uiState.setPaused,
|
||||
setGameOver: uiState.setGameOver,
|
||||
}), [gameStore, skillState, manaState, prestigeState, uiState, combatState]);
|
||||
|
||||
// Computed effects from upgrades
|
||||
const upgradeEffects = useMemo(
|
||||
() => computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {}),
|
||||
[skillState.skillUpgrades, skillState.skillTiers]
|
||||
);
|
||||
|
||||
// Create a minimal state object for compute functions
|
||||
const stateForCompute = useMemo(() => ({
|
||||
skills: skillState.skills,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
skillUpgrades: skillState.skillUpgrades,
|
||||
skillTiers: skillState.skillTiers,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
rawMana: manaState.rawMana,
|
||||
meditateTicks: manaState.meditateTicks,
|
||||
incursionStrength: gameStore.incursionStrength,
|
||||
}), [skillState, prestigeState, manaState, gameStore.incursionStrength]);
|
||||
|
||||
// Derived stats
|
||||
const maxMana = useMemo(
|
||||
() => computeMaxMana(stateForCompute, upgradeEffects),
|
||||
[stateForCompute, upgradeEffects]
|
||||
);
|
||||
|
||||
const baseRegen = useMemo(
|
||||
() => computeRegen(stateForCompute, upgradeEffects),
|
||||
[stateForCompute, upgradeEffects]
|
||||
);
|
||||
|
||||
const clickMana = useMemo(() => computeClickMana(stateForCompute), [stateForCompute]);
|
||||
|
||||
// Floor element from combat store
|
||||
const floorElem = useMemo(() => getFloorElement(combatState.currentFloor), [combatState.currentFloor]);
|
||||
const floorElemDef = ELEMENTS[floorElem];
|
||||
const isGuardianFloor = !!GUARDIANS[combatState.currentFloor];
|
||||
const currentGuardian = GUARDIANS[combatState.currentFloor];
|
||||
const activeSpellDef = SPELLS_DEF[combatState.activeSpell];
|
||||
|
||||
const meditationMultiplier = useMemo(
|
||||
() => getMeditationBonus(manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency),
|
||||
[manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency]
|
||||
);
|
||||
|
||||
const incursionStrength = useMemo(
|
||||
() => getIncursionStrength(gameStore.day, gameStore.hour),
|
||||
[gameStore.day, gameStore.hour]
|
||||
);
|
||||
|
||||
const studySpeedMult = useMemo(
|
||||
() => getStudySpeedMultiplier(skillState.skills),
|
||||
[skillState.skills]
|
||||
);
|
||||
|
||||
const studyCostMult = useMemo(
|
||||
() => getStudyCostMultiplier(skillState.skills),
|
||||
[skillState.skills]
|
||||
);
|
||||
|
||||
// Effective regen calculations
|
||||
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
|
||||
|
||||
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
|
||||
? Math.floor(maxMana / 100) * 0.1
|
||||
: 0;
|
||||
|
||||
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
|
||||
|
||||
// Active boons
|
||||
const activeBoons = useMemo(
|
||||
() => getBoonBonuses(prestigeState.signedPacts),
|
||||
[prestigeState.signedPacts]
|
||||
);
|
||||
|
||||
// DPS calculation - based on active spell, attack speed, and damage
|
||||
const dps = useMemo(() => {
|
||||
if (!activeSpellDef) return 0;
|
||||
const baseDmg = calcDamage(
|
||||
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
|
||||
combatState.activeSpell,
|
||||
floorElem
|
||||
);
|
||||
const dmgWithEffects = baseDmg * upgradeEffects.baseDamageMultiplier + upgradeEffects.baseDamageBonus;
|
||||
const attackSpeed = (1 + (skillState.skills.quickCast || 0) * 0.05) * upgradeEffects.attackSpeedMultiplier;
|
||||
const castSpeed = activeSpellDef.castSpeed || 1;
|
||||
return dmgWithEffects * attackSpeed * castSpeed;
|
||||
}, [activeSpellDef, skillState.skills, prestigeState.signedPacts, floorElem, upgradeEffects, combatState.activeSpell]);
|
||||
|
||||
// Helper functions
|
||||
const canCastSpell = (spellId: string): boolean => {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return false;
|
||||
return canAffordSpellCost(spell.cost, manaState.rawMana, manaState.elements);
|
||||
};
|
||||
|
||||
const value: GameContextValue = {
|
||||
store: unifiedStore,
|
||||
skillStore: skillState,
|
||||
manaStore: manaState,
|
||||
prestigeStore: prestigeState,
|
||||
uiStore: uiState,
|
||||
combatStore: combatState,
|
||||
upgradeEffects,
|
||||
maxMana,
|
||||
baseRegen,
|
||||
clickMana,
|
||||
floorElem,
|
||||
floorElemDef,
|
||||
isGuardianFloor,
|
||||
currentGuardian,
|
||||
activeSpellDef,
|
||||
meditationMultiplier,
|
||||
incursionStrength,
|
||||
studySpeedMult,
|
||||
studyCostMult,
|
||||
effectiveRegenWithSpecials,
|
||||
manaCascadeBonus,
|
||||
effectiveRegen,
|
||||
dps,
|
||||
activeBoons,
|
||||
canCastSpell,
|
||||
hasSpecial,
|
||||
SPECIAL_EFFECTS,
|
||||
};
|
||||
|
||||
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGameContext() {
|
||||
const context = useContext(GameContext);
|
||||
if (!context) {
|
||||
throw new Error('useGameContext must be used within a GameProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Re-export useGameLoop for convenience
|
||||
export { useGameLoop };
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from '@/components/ui/toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Toast type definitions
|
||||
type ToastType = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
interface ToastIconProps {
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
// Icon mapping for toast types
|
||||
function ToastIcon({ type }: ToastIconProps) {
|
||||
const iconClass = 'h-4 w-4 shrink-0';
|
||||
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle className={cn(iconClass, 'text-[var(--color-success)]')} />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className={cn(iconClass, 'text-[var(--color-warning)]')} />;
|
||||
case 'error':
|
||||
return <AlertCircle className={cn(iconClass, 'text-[var(--color-danger)]')} />;
|
||||
case 'info':
|
||||
return <Info className={cn(iconClass, 'text-[var(--color-info)]')} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Color mapping for toast types using design system tokens
|
||||
const TOAST_TYPE_STYLES: Record<ToastType, string> = {
|
||||
success: 'border-[var(--color-success)]/50 bg-[var(--color-success)]/10',
|
||||
warning: 'border-[var(--color-warning)]/50 bg-[var(--color-warning)]/10',
|
||||
error: 'border-[var(--color-danger)]/50 bg-[var(--color-danger)]/10',
|
||||
info: 'border-[var(--color-info)]/50 bg-[var(--color-info)]/10',
|
||||
};
|
||||
|
||||
const TOAST_TYPE_TEXT: Record<ToastType, string> = {
|
||||
success: 'text-[var(--color-success)]',
|
||||
warning: 'text-[var(--color-warning)]',
|
||||
error: 'text-[var(--color-danger)]',
|
||||
info: 'text-[var(--color-info)]',
|
||||
};
|
||||
|
||||
export function GameToaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map((toast) => {
|
||||
// Determine toast type from className or default to info
|
||||
const toastType: ToastType =
|
||||
toast.variant === 'destructive' ? 'error' :
|
||||
(toast as { toastType?: ToastType }).toastType || 'info';
|
||||
|
||||
return (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
className={cn(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-3 overflow-hidden rounded-md border p-4 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
TOAST_TYPE_STYLES[toastType]
|
||||
)}
|
||||
{...toast}
|
||||
>
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<ToastIcon type={toastType} />
|
||||
<div className="grid gap-1 flex-1">
|
||||
{toast.title && (
|
||||
<ToastTitle className={cn('text-sm font-semibold', TOAST_TYPE_TEXT[toastType])}>
|
||||
{toast.title}
|
||||
</ToastTitle>
|
||||
)}
|
||||
{toast.description && (
|
||||
<ToastDescription className="text-xs text-[var(--text-secondary)]">
|
||||
{toast.description}
|
||||
</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ToastClose className="absolute right-1 top-1 rounded-md p-1 text-[var(--text-muted)] opacity-0 transition-opacity hover:text-[var(--text-primary)] focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-70">
|
||||
<X className="h-3 w-3" />
|
||||
</ToastClose>
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
{/*
|
||||
Viewport positioning:
|
||||
- Desktop: bottom-right
|
||||
- Mobile: bottom-center, full-width
|
||||
*/}
|
||||
<ToastViewport
|
||||
className={cn(
|
||||
'fixed z-[100] flex max-h-screen w-full flex-col-reverse p-4',
|
||||
// Desktop: bottom-right, fixed width
|
||||
'sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col sm:max-w-[420px]',
|
||||
// Mobile: bottom-center, full-width
|
||||
'max-sm:bottom-0 max-sm:left-0 max-sm:flex-col max-sm:items-center'
|
||||
)}
|
||||
/>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom hook to show typed toasts
|
||||
export function useGameToast() {
|
||||
const { toast } = useToast();
|
||||
|
||||
return (type: ToastType, title: ReactNode, description?: ReactNode) => {
|
||||
const toastTypeClass = `toast-type-${type}`;
|
||||
|
||||
return toast({
|
||||
title,
|
||||
description,
|
||||
className: toastTypeClass,
|
||||
// Store the type for styling
|
||||
...{ toastType: type },
|
||||
} as {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
toastType?: ToastType;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { type ToastType };
|
||||
@@ -1,171 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useGameStore } from '@/lib/game/store';
|
||||
import { ELEMENTS, MANA_PER_ELEMENT } from '@/lib/game/constants';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function LabTab() {
|
||||
const store = useGameStore();
|
||||
const [convertTarget, setConvertTarget] = useState('fire');
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Elemental Mana Display */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Elemental Mana</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([, state]) => state.unlocked && state.current >= 1)
|
||||
.map(([id, state]) => {
|
||||
const def = ELEMENTS[id];
|
||||
const isSelected = convertTarget === id;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`p-2 rounded border cursor-pointer transition-all ${isSelected ? 'border-blue-500 bg-blue-900/20' : 'border-gray-700 bg-gray-800/50 hover:border-gray-600'}`}
|
||||
style={{ borderColor: isSelected ? def?.color : undefined }}
|
||||
onClick={() => setConvertTarget(id)}
|
||||
>
|
||||
<div className="text-lg text-center">{def?.sym}</div>
|
||||
<div className="text-xs font-semibold text-center" style={{ color: def?.color }}>{def?.name}</div>
|
||||
<div className="text-xs text-gray-400 game-mono text-center">{state.current}/{state.max}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Element Conversion */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Element Conversion</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Convert raw mana to elemental mana (100:1 ratio)
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => store.convertMana(convertTarget, 1)}
|
||||
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT}
|
||||
>
|
||||
+1 ({MANA_PER_ELEMENT})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => store.convertMana(convertTarget, 10)}
|
||||
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 10}
|
||||
>
|
||||
+10 ({MANA_PER_ELEMENT * 10})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => store.convertMana(convertTarget, 100)}
|
||||
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 100}
|
||||
>
|
||||
+100 ({MANA_PER_ELEMENT * 100})
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Unlock Elements */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Unlock Elements</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Unlock new elemental affinities (500 mana each)
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([id, state]) => !state.unlocked && ELEMENTS[id]?.cat !== 'exotic')
|
||||
.map(([id]) => {
|
||||
const def = ELEMENTS[id];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border border-gray-700 bg-gray-800/50"
|
||||
>
|
||||
<div className="text-lg opacity-50">{def?.sym}</div>
|
||||
<div className="text-xs font-semibold text-gray-500">{def?.name}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1 w-full"
|
||||
disabled={store.rawMana < 500}
|
||||
onClick={() => store.unlockElement(id)}
|
||||
>
|
||||
Unlock
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Composite Crafting */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Composite & Exotic Crafting</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Object.entries(ELEMENTS)
|
||||
.filter(([, def]) => def.recipe)
|
||||
.map(([id, def]) => {
|
||||
const state = store.elements[id];
|
||||
const recipe = def.recipe!;
|
||||
const canCraft = recipe.every(
|
||||
(r) => (store.elements[r]?.current || 0) >= recipe.filter((x) => x === r).length
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`p-3 rounded border ${canCraft ? 'border-gray-600 bg-gray-800/50' : 'border-gray-700 bg-gray-800/30 opacity-50'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{def.sym}</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: def.color }}>
|
||||
{def.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{def.cat}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
{recipe.map((r) => ELEMENTS[r]?.sym).join(' + ')}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canCraft ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
disabled={!canCraft}
|
||||
onClick={() => store.craftComposite(id)}
|
||||
>
|
||||
Craft
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Gem, Sparkles, Scroll, Droplet, Trash2, Search,
|
||||
Package, Sword, Shield, Shirt, Crown, ArrowUpDown,
|
||||
Wrench, AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface LootInventoryProps {
|
||||
inventory: LootInventoryType;
|
||||
elements?: Record<string, ElementState>;
|
||||
equipmentInstances?: Record<string, EquipmentInstance>;
|
||||
onDeleteMaterial?: (materialId: string, amount: number) => void;
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
type SortMode = 'name' | 'rarity' | 'count';
|
||||
type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
|
||||
|
||||
const RARITY_ORDER = {
|
||||
common: 0,
|
||||
uncommon: 1,
|
||||
rare: 2,
|
||||
epic: 3,
|
||||
legendary: 4,
|
||||
mythic: 5,
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, typeof Sword> = {
|
||||
caster: Sword,
|
||||
shield: Shield,
|
||||
catalyst: Sparkles,
|
||||
head: Crown,
|
||||
body: Shirt,
|
||||
hands: Wrench,
|
||||
feet: Package,
|
||||
accessory: Gem,
|
||||
};
|
||||
|
||||
export function LootInventoryDisplay({
|
||||
inventory,
|
||||
elements,
|
||||
equipmentInstances = {},
|
||||
onDeleteMaterial,
|
||||
onDeleteEquipment,
|
||||
}: LootInventoryProps) {
|
||||
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.values(elements).reduce((a, e) => 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 (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;
|
||||
|
||||
if (!hasItems) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Gem className="w-4 h-4" />
|
||||
Inventory
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-gray-500 text-sm text-center py-4">
|
||||
No items collected yet. Defeat floors and guardians to find loot!
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
onDeleteMaterial(deleteConfirm.id, inventory.materials[deleteConfirm.id] || 0);
|
||||
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
|
||||
onDeleteEquipment(deleteConfirm.id);
|
||||
}
|
||||
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Gem className="w-4 h-4" />
|
||||
Inventory
|
||||
<Badge className="ml-auto bg-gray-800 text-gray-300 text-xs">
|
||||
{totalItems} items
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-500" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-7 pl-7 bg-gray-800/50 border-gray-700 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 bg-gray-800/50"
|
||||
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
|
||||
>
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant={filterMode === mode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${filterMode === mode ? 'bg-amber-600 hover:bg-amber-700' : 'bg-gray-800/50'}`}
|
||||
onClick={() => setFilterMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-3">
|
||||
{/* Materials */}
|
||||
{(filterMode === 'all' || filterMode === 'materials') && filteredMaterials.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Materials
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{filteredMaterials.map(([id, count]) => {
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return null;
|
||||
const rarityStyle = RARITY_COLORS[drop.rarity];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border bg-gray-800/50 group relative"
|
||||
style={{
|
||||
borderColor: rarityStyle?.color || '#9CA3AF',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
x{count}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 capitalize">
|
||||
{drop.rarity}
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteMaterial && (
|
||||
<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={() => handleDeleteMaterial(id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Essence */}
|
||||
{(filterMode === 'all' || filterMode === 'essence') && filteredEssence.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||
<Droplet className="w-3 h-3" />
|
||||
Elemental Essence
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{filteredEssence.map(([id, state]) => {
|
||||
const elem = ELEMENTS[id];
|
||||
if (!elem) return null;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border bg-gray-800/50"
|
||||
style={{
|
||||
borderColor: elem.color,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span style={{ color: elem.color }}>{elem.sym}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: elem.color }}>
|
||||
{elem.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{state.current} / {state.max}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blueprints */}
|
||||
{(filterMode === 'all' || filterMode === 'blueprints') && inventory.blueprints.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||
<Scroll className="w-3 h-3" />
|
||||
Blueprints (permanent)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{inventory.blueprints.map((id) => {
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return null;
|
||||
const rarityStyle = RARITY_COLORS[drop.rarity];
|
||||
return (
|
||||
<Badge
|
||||
key={id}
|
||||
className="text-xs"
|
||||
style={{
|
||||
backgroundColor: `${rarityStyle?.color}20`,
|
||||
color: rarityStyle?.color,
|
||||
borderColor: rarityStyle?.color,
|
||||
}}
|
||||
>
|
||||
{drop.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1 italic">
|
||||
Blueprints are permanent unlocks - use them to craft equipment
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{(filterMode === 'all' || filterMode === 'equipment') && filteredEquipment.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||
<Package className="w-3 h-3" />
|
||||
Equipment
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredEquipment.map(([id, instance]) => {
|
||||
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||
const rarityStyle = RARITY_COLORS[instance.rarity];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border bg-gray-800/50 group"
|
||||
style={{
|
||||
borderColor: rarityStyle?.color || '#9CA3AF',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="w-4 h-4 mt-0.5" style={{ color: rarityStyle?.color }} />
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{type?.name} • {instance.usedCapacity}/{instance.totalCapacity} cap
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 capitalize">
|
||||
{instance.rarity} • {instance.enchantments.length} enchants
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteEquipment && (
|
||||
<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={() => handleDeleteEquipment(id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<AlertDialogContent className="bg-gray-900 border-gray-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-amber-400 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Delete Item
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-300">
|
||||
Are you sure you want to delete <strong>{deleteConfirm?.name}</strong>?
|
||||
{deleteConfirm?.type === 'material' && (
|
||||
<span className="block mt-2 text-red-400">
|
||||
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
|
||||
</span>
|
||||
)}
|
||||
{deleteConfirm?.type === 'equipment' && (
|
||||
<span className="block mt-2 text-red-400">
|
||||
This equipment and all its enchantments will be permanently lost!
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="bg-gray-800 border-gray-700">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Scroll } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
|
||||
interface BlueprintsSectionProps {
|
||||
blueprints: string[];
|
||||
}
|
||||
|
||||
export function BlueprintsSection({ blueprints }: BlueprintsSectionProps) {
|
||||
if (blueprints.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Scroll className="w-3 h-3" />
|
||||
Blueprints (permanent)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{blueprints.map((id) => {
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return null;
|
||||
const rarityColor = RARITY_CSS_VAR[drop.rarity] || 'var(--rarity-common)';
|
||||
return (
|
||||
<Badge
|
||||
key={id}
|
||||
className="text-xs"
|
||||
style={{
|
||||
backgroundColor: `${RARITY_GLOW_CSS_VAR[drop.rarity] || 'var(--rarity-common-glow)'}`,
|
||||
color: rarityColor,
|
||||
borderColor: rarityColor,
|
||||
}}
|
||||
>
|
||||
{drop.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1 italic">
|
||||
Blueprints are permanent unlocks - use them to craft equipment
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { Package, Trash2 } from 'lucide-react';
|
||||
import type { EquipmentInstance } from '@/lib/game/types';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { CATEGORY_ICONS } from './icons';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
|
||||
interface EquipmentItemProps {
|
||||
instanceId: string;
|
||||
instance: EquipmentInstance;
|
||||
onDelete?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function EquipmentItem({ instanceId, instance, onDelete }: EquipmentItemProps) {
|
||||
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)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)] group"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="w-4 h-4 mt-0.5" style={{ color: rarityColor }} />
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityColor }}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{type?.name} • {instance.usedCapacity}/{instance.totalCapacity} cap
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{instance.rarity} • {instance.enchantments.length} enchants
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={() => onDelete(instanceId)}
|
||||
aria-label={`Delete ${instance.name}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EquipmentSectionProps {
|
||||
equipment: [string, EquipmentInstance][];
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function EquipmentSection({ equipment, onDeleteEquipment }: EquipmentSectionProps) {
|
||||
if (equipment.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Package className="w-3 h-3" />
|
||||
Equipment
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{equipment.map(([id, instance]) => (
|
||||
<EquipmentItem
|
||||
key={id}
|
||||
instanceId={id}
|
||||
instance={instance}
|
||||
onDelete={onDeleteEquipment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Droplet } from 'lucide-react';
|
||||
import { ElementBadge } from '@/components/ui/element-badge';
|
||||
import type { ElementState } from '@/lib/game/types';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
interface EssenceItemProps {
|
||||
elementId: string;
|
||||
state: ElementState;
|
||||
}
|
||||
|
||||
export function EssenceItem({ elementId, state }: EssenceItemProps) {
|
||||
const elem = ELEMENTS[elementId];
|
||||
if (!elem) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)]"
|
||||
style={{
|
||||
borderColor: `var(--mana-${elementId})`,
|
||||
backgroundColor: `var(--mana-${elementId})20`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<ElementBadge element={elementId} showIcon={true} size="sm" />
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{state.current} / {state.max}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EssenceSectionProps {
|
||||
essence: [string, ElementState][];
|
||||
}
|
||||
|
||||
export function EssenceSection({ essence }: EssenceSectionProps) {
|
||||
if (essence.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Droplet className="w-3 h-3" />
|
||||
Elemental Essence
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{essence.map(([id, state]) => (
|
||||
<EssenceItem key={id} elementId={id} state={state} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import type { LootInventory } from '@/lib/game/types';
|
||||
// For backward compatibility
|
||||
type LootInventoryType = LootInventory;
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { Sparkles, Trash2 } from 'lucide-react';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
|
||||
interface MaterialItemProps {
|
||||
materialId: string;
|
||||
count: number;
|
||||
onDelete?: (materialId: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialItem({ materialId, count, onDelete }: MaterialItemProps) {
|
||||
const drop = LOOT_DROPS[materialId];
|
||||
if (!drop) return null;
|
||||
|
||||
const rarityColor = RARITY_CSS_VAR[drop.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[drop.rarity] || 'var(--rarity-common-glow)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)] group relative"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityColor }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
x{count}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{drop.rarity}
|
||||
</div>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={() => onDelete(materialId)}
|
||||
aria-label={`Delete ${drop.name}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MaterialsSectionProps {
|
||||
materials: [string, number][];
|
||||
onDeleteMaterial?: (materialId: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialsSection({ materials, onDeleteMaterial }: MaterialsSectionProps) {
|
||||
if (materials.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Materials
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{materials.map(([id, count]) => (
|
||||
<MaterialItem
|
||||
key={id}
|
||||
materialId={id}
|
||||
count={count}
|
||||
onDelete={onDeleteMaterial}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Gem, Sparkles, Scroll, Droplet, Trash2, Search,
|
||||
Package, Sword, Shield, Shirt, Crown, ArrowUpDown,
|
||||
Wrench, AlertTriangle } from 'lucide-react';
|
||||
import type { EquipmentCategory } from '@/lib/game/data/equipment';
|
||||
|
||||
export const CATEGORY_ICONS: Record<string, typeof Sword> = {
|
||||
caster: Sword,
|
||||
shield: Shield,
|
||||
catalyst: Sparkles,
|
||||
head: Crown,
|
||||
body: Shirt,
|
||||
hands: Wrench,
|
||||
feet: Package,
|
||||
accessory: Gem,
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
|
||||
export type SortMode = 'name' | 'rarity' | 'count';
|
||||
export type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
|
||||
|
||||
export const RARITY_ORDER = {
|
||||
common: 0,
|
||||
uncommon: 1,
|
||||
rare: 2,
|
||||
epic: 3,
|
||||
legendary: 4,
|
||||
mythic: 5,
|
||||
};
|
||||
|
||||
// Map rarity to CSS variable for colors
|
||||
export const RARITY_CSS_VAR: Record<string, string> = {
|
||||
common: 'var(--rarity-common)',
|
||||
uncommon: 'var(--rarity-uncommon)',
|
||||
rare: 'var(--rarity-rare)',
|
||||
epic: 'var(--rarity-epic)',
|
||||
legendary: 'var(--rarity-legendary)',
|
||||
mythic: 'var(--rarity-mythic)',
|
||||
};
|
||||
|
||||
// Map rarity to CSS variable for glow/background
|
||||
export const RARITY_GLOW_CSS_VAR: Record<string, string> = {
|
||||
common: 'var(--rarity-common-glow)',
|
||||
uncommon: 'var(--rarity-uncommon-glow)',
|
||||
rare: 'var(--rarity-rare-glow)',
|
||||
epic: 'var(--rarity-epic-glow)',
|
||||
legendary: 'var(--rarity-legendary-glow)',
|
||||
mythic: 'var(--rarity-mythic-glow)',
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Zap, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { fmt, fmtDec } from '@/lib/game/store';
|
||||
import { fmt, fmtDec } from '@/lib/game/stores';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -39,26 +39,37 @@ export function ManaDisplay({
|
||||
.sort((a, b) => b[1].current - a[1].current);
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<Card className="bg-[var(--bg-panel)] border-[var(--border-subtle)]">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
{/* Raw Mana - Main Display */}
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(rawMana)}</span>
|
||||
<span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
|
||||
<span className="text-3xl font-bold game-mono" style={{ color: 'var(--mana-raw)' }}>{fmt(rawMana)}</span>
|
||||
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>/ {fmt(maxMana)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
+{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && <span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>}
|
||||
<div className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
+{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && <span style={{ color: 'var(--mana-light)' }}>({fmtDec(meditationMultiplier, 1)}x med)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={(rawMana / maxMana) * 100}
|
||||
className="h-2 bg-gray-800"
|
||||
className="h-2 bg-[var(--bg-sunken)]"
|
||||
style={{ '--progress-bg': 'var(--mana-raw)' } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${isGathering ? 'animate-pulse' : ''}`}
|
||||
className={`w-full transition-all text-[var(--font-display)] tracking-wider
|
||||
${isGathering
|
||||
? 'animate-gather-glow'
|
||||
: 'hover:scale-[1.02]'}
|
||||
`}
|
||||
style={{
|
||||
background: 'var(--mana-raw)',
|
||||
border: '1px solid var(--border-accent)',
|
||||
color: '#0C1020',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
onMouseDown={onGatherStart}
|
||||
onMouseUp={onGatherEnd}
|
||||
onMouseLeave={onGatherEnd}
|
||||
@@ -67,22 +78,23 @@ export function ManaDisplay({
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Gather +{clickMana} Mana
|
||||
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
|
||||
{isGathering && <span className="ml-2 text-xs" style={{ opacity: 0.8 }}>(Holding...)</span>}
|
||||
</Button>
|
||||
|
||||
{/* Elemental Mana Pools */}
|
||||
{unlockedElements.length > 0 && (
|
||||
<div className="border-t border-gray-700 pt-3 mt-3">
|
||||
<div className="border-t border-[var(--border-subtle)] pt-3 mt-3">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center justify-between w-full text-xs text-gray-400 hover:text-gray-300 mb-2"
|
||||
className="flex items-center justify-between w-full text-xs transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<span>Elemental Mana ({unlockedElements.length})</span>
|
||||
<span style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.5px' }}>ELEMENTAL MANA ({unlockedElements.length})</span>
|
||||
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{unlockedElements.map(([id, state]) => {
|
||||
const elem = ELEMENTS[id];
|
||||
if (!elem) return null;
|
||||
@@ -90,7 +102,11 @@ export function ManaDisplay({
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded bg-gray-800/50 border border-gray-700"
|
||||
className="p-2 transition-all border rounded-sm"
|
||||
style={{
|
||||
background: 'var(--bg-sunken)/30',
|
||||
borderColor: `${elem.color}30`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span style={{ color: elem.color }}>{elem.sym}</span>
|
||||
@@ -98,16 +114,16 @@ export function ManaDisplay({
|
||||
{elem.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-void)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
className="h-full transition-all rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
|
||||
backgroundColor: elem.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
<div className="text-xs game-mono" style={{ color: 'var(--text-muted)' }}>
|
||||
{fmt(state.current)}/{fmt(state.max)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,3 +137,5 @@ export function ManaDisplay({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
ManaDisplay.displayName = "ManaDisplay";
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
|
||||
import { SKILLS_DEF, SKILL_CATEGORIES } from '@/lib/game/constants';
|
||||
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { computeEffects } from '@/lib/game/upgrade-effects';
|
||||
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { BookOpen, X } from 'lucide-react';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
// Format study time
|
||||
function formatStudyTime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
export function SkillsTab() {
|
||||
const store = useGameStore();
|
||||
const { studySpeedMult, studyCostMult, hasParallelStudy } = useStudyStats();
|
||||
|
||||
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
|
||||
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
|
||||
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
|
||||
|
||||
const upgradeEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||||
|
||||
// Check if skill has milestone available
|
||||
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||
if (!path) return null;
|
||||
|
||||
if (level >= 5) {
|
||||
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, store.skillTiers);
|
||||
const selected5 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
|
||||
if (upgrades5.length > 0 && selected5.length < 2) {
|
||||
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
|
||||
}
|
||||
}
|
||||
|
||||
if (level >= 10) {
|
||||
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, store.skillTiers);
|
||||
const selected10 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
|
||||
if (upgrades10.length > 0 && selected10.length < 2) {
|
||||
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Render upgrade selection dialog
|
||||
const renderUpgradeDialog = () => {
|
||||
if (!upgradeDialogSkill) return null;
|
||||
|
||||
const skillDef = SKILLS_DEF[upgradeDialogSkill];
|
||||
const level = store.skills[upgradeDialogSkill] || 0;
|
||||
const { available, selected: alreadySelected } = store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
|
||||
|
||||
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
|
||||
|
||||
const toggleUpgrade = (upgradeId: string) => {
|
||||
if (currentSelections.includes(upgradeId)) {
|
||||
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
|
||||
} else if (currentSelections.length < 2) {
|
||||
setPendingUpgradeSelections([...currentSelections, upgradeId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
if (currentSelections.length === 2 && upgradeDialogSkill) {
|
||||
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
|
||||
}
|
||||
setPendingUpgradeSelections([]);
|
||||
setUpgradeDialogSkill(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setPendingUpgradeSelections([]);
|
||||
setUpgradeDialogSkill(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!upgradeDialogSkill} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingUpgradeSelections([]);
|
||||
setUpgradeDialogSkill(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-amber-400">
|
||||
Choose Upgrade - {skillDef?.name || upgradeDialogSkill}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Level {upgradeDialogMilestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{available.map((upgrade) => {
|
||||
const isSelected = currentSelections.includes(upgrade.id);
|
||||
const canToggle = currentSelections.length < 2 || isSelected;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canToggle
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (canToggle) {
|
||||
toggleUpgrade(upgrade.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
|
||||
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleDone}
|
||||
disabled={currentSelections.length !== 2}
|
||||
>
|
||||
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Render study progress
|
||||
const renderStudyProgress = () => {
|
||||
if (!store.currentStudyTarget) return null;
|
||||
|
||||
const target = store.currentStudyTarget;
|
||||
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||
const def = SKILLS_DEF[target.id] || SKILLS_DEF[target.id.split('_t')[0]];
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm font-semibold text-purple-300">
|
||||
{def?.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={() => store.cancelStudy()}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||||
<span>{studySpeedMult.toFixed(1)}x speed</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Upgrade Selection Dialog */}
|
||||
{renderUpgradeDialog()}
|
||||
|
||||
{/* Current Study Progress */}
|
||||
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||
<CardContent className="pt-4">
|
||||
{renderStudyProgress()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{SKILL_CATEGORIES.map((cat) => {
|
||||
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
|
||||
if (skillsInCat.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||
{cat.icon} {cat.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{skillsInCat.map(([id, def]) => {
|
||||
const currentTier = store.skillTiers?.[id] || 1;
|
||||
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
|
||||
const tierMultiplier = getTierMultiplier(tieredSkillId);
|
||||
|
||||
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
|
||||
const maxed = level >= def.max;
|
||||
|
||||
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
|
||||
const savedProgress = store.skillProgress[tieredSkillId] || store.skillProgress[id] || 0;
|
||||
|
||||
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
|
||||
const skillDisplayName = tierDef?.name || def.name;
|
||||
|
||||
// Check prerequisites
|
||||
let prereqMet = true;
|
||||
if (def.req) {
|
||||
for (const [r, rl] of Object.entries(def.req)) {
|
||||
if ((store.skills[r] || 0) < rl) {
|
||||
prereqMet = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply skill modifiers
|
||||
const studyEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||||
const effectiveSpeedMult = studySpeedMult * studyEffects.studySpeedMultiplier;
|
||||
|
||||
const tierStudyTime = def.studyTime * currentTier;
|
||||
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
|
||||
|
||||
const baseCost = def.base * (level + 1) * currentTier;
|
||||
const cost = Math.floor(baseCost * studyCostMult);
|
||||
|
||||
const canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
|
||||
|
||||
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level);
|
||||
const nextTierSkill = getNextTierSkill(tieredSkillId);
|
||||
const canTierUp = maxed && nextTierSkill;
|
||||
|
||||
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
|
||||
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
|
||||
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
|
||||
isStudying ? 'border-purple-500 bg-purple-900/20' :
|
||||
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
|
||||
'border-gray-700 bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm">{skillDisplayName}</span>
|
||||
{currentTier > 1 && (
|
||||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
|
||||
)}
|
||||
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
|
||||
{selectedUpgrades.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{selectedL5.length > 0 && (
|
||||
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
|
||||
)}
|
||||
{selectedL10.length > 0 && (
|
||||
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
|
||||
{!prereqMet && def.req && (
|
||||
<div className="text-xs text-red-400 mt-1">
|
||||
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
|
||||
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
|
||||
</span>
|
||||
{' • '}
|
||||
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||||
Cost: {fmt(cost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{milestoneInfo && (
|
||||
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
|
||||
⭐ Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
|
||||
{/* Level dots */}
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{Array.from({ length: def.max }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full border ${
|
||||
i < level ? 'bg-purple-500 border-purple-400' :
|
||||
i === 4 || i === 9 ? 'border-amber-500' :
|
||||
'border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isStudying ? (
|
||||
<div className="text-xs text-purple-400">
|
||||
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
|
||||
</div>
|
||||
) : milestoneInfo ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
onClick={() => {
|
||||
setUpgradeDialogSkill(tieredSkillId);
|
||||
setUpgradeDialogMilestone(milestoneInfo.milestone);
|
||||
}}
|
||||
>
|
||||
Choose Upgrades
|
||||
</Button>
|
||||
) : canTierUp ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => store.tierUpSkill(tieredSkillId)}
|
||||
>
|
||||
⬆️ Tier Up
|
||||
</Button>
|
||||
) : maxed ? (
|
||||
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canStudy ? 'default' : 'outline'}
|
||||
disabled={!canStudy}
|
||||
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||
onClick={() => store.startStudyingSkill(tieredSkillId)}
|
||||
>
|
||||
Study ({fmt(cost)})
|
||||
</Button>
|
||||
{/* Parallel Study button */}
|
||||
{hasParallelStudy &&
|
||||
store.currentStudyTarget &&
|
||||
!store.parallelStudyTarget &&
|
||||
store.currentStudyTarget.id !== tieredSkillId &&
|
||||
canStudy && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
|
||||
onClick={() => store.startParallelStudySkill(tieredSkillId)}
|
||||
>
|
||||
⚡
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Study in parallel (50% speed)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, canAffordSpellCost } from '@/lib/game/store';
|
||||
import { ELEMENTS, SPELLS_DEF } from '@/lib/game/constants';
|
||||
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
// Format spell cost for display
|
||||
function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||
if (cost.type === 'raw') {
|
||||
return `${cost.amount} raw`;
|
||||
}
|
||||
const elemDef = ELEMENTS[cost.element || ''];
|
||||
return `${cost.amount} ${elemDef?.sym || '?'}`;
|
||||
}
|
||||
|
||||
// Get cost color
|
||||
function getSpellCostColor(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||
if (cost.type === 'raw') {
|
||||
return '#60A5FA';
|
||||
}
|
||||
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
|
||||
}
|
||||
|
||||
// Format study time
|
||||
function formatStudyTime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
export function SpellsTab() {
|
||||
const store = useGameStore();
|
||||
const { studySpeedMult, studyCostMult } = useStudyStats();
|
||||
|
||||
const spellTiers = [0, 1, 2, 3, 4];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{spellTiers.map(tier => {
|
||||
const spellsInTier = Object.entries(SPELLS_DEF).filter(([, def]) => def.tier === tier);
|
||||
if (spellsInTier.length === 0) return null;
|
||||
|
||||
const tierNames = ['Basic Spells (Raw Mana)', 'Tier 1 - Elemental', 'Tier 2 - Advanced', 'Tier 3 - Master', 'Tier 4 - Legendary'];
|
||||
const tierColors = ['text-gray-400', 'text-green-400', 'text-blue-400', 'text-purple-400', 'text-amber-400'];
|
||||
|
||||
return (
|
||||
<div key={tier}>
|
||||
<h3 className={`text-lg font-semibold mb-3 ${tierColors[tier]}`}>{tierNames[tier]}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{spellsInTier.map(([id, def]) => {
|
||||
const state = store.spells[id];
|
||||
const learned = state?.learned;
|
||||
const isStudying = store.currentStudyTarget?.id === id;
|
||||
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||
const baseStudyTime = def.studyTime || (def.tier * 4);
|
||||
const isActive = store.activeSpell === id;
|
||||
const canCast = learned && canAffordSpellCost(def.cost, store.rawMana, store.elements);
|
||||
|
||||
// Apply skill modifiers
|
||||
const studyTime = baseStudyTime / studySpeedMult;
|
||||
const unlockCost = Math.floor(def.unlock * studyCostMult);
|
||||
|
||||
// Can start studying?
|
||||
const canStudy = !learned && !isStudying && store.rawMana >= unlockCost;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={id}
|
||||
className={`bg-gray-900/80 border-gray-700 ${learned ? '' : 'opacity-75'} ${isStudying ? 'border-purple-500' : ''} ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm game-panel-title" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||
{def.name}
|
||||
</CardTitle>
|
||||
{def.tier > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
T{def.tier}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-xs text-gray-400">
|
||||
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
|
||||
<span className="mr-2">⚔️ {def.dmg} dmg</span>
|
||||
</div>
|
||||
|
||||
{/* Cost display */}
|
||||
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||
Cost: {formatSpellCost(def.cost)}
|
||||
</div>
|
||||
|
||||
{def.desc && (
|
||||
<div className="text-xs text-gray-500 italic">{def.desc}</div>
|
||||
)}
|
||||
|
||||
{def.effects && def.effects.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{def.effects.map((eff, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">
|
||||
{eff.type === 'burn' && `🔥 Burn`}
|
||||
{eff.type === 'stun' && `⚡ Stun`}
|
||||
{eff.type === 'pierce' && `🎯 Pierce`}
|
||||
{eff.type === 'multicast' && `✨ Multicast`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{learned ? (
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-green-900/50 text-green-300">Learned</Badge>
|
||||
{isActive && <Badge className="bg-amber-900/50 text-amber-300">Active</Badge>}
|
||||
{!isActive && (
|
||||
<Button size="sm" variant="outline" onClick={() => store.setSpell(id)}>
|
||||
Set Active
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : isStudying ? (
|
||||
<div className="space-y-1">
|
||||
<Progress
|
||||
value={Math.min(100, ((state?.studyProgress || 0) / studyTime) * 100)}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
<div className="text-xs text-purple-400">
|
||||
Studying... {formatStudyTime(state?.studyProgress || 0)}/{formatStudyTime(studyTime)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className={studySpeedMult > 1 ? 'text-green-400' : ''}>
|
||||
Study: {formatStudyTime(studyTime)}{studySpeedMult > 1 && <span className="text-xs ml-1">({Math.round(studySpeedMult * 100)}% speed)</span>}
|
||||
</span>
|
||||
{' • '}
|
||||
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||||
Cost: {fmt(unlockCost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canStudy ? 'default' : 'outline'}
|
||||
disabled={!canStudy}
|
||||
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||
onClick={() => store.startStudyingSpell(id)}
|
||||
>
|
||||
Start Study ({fmt(unlockCost)} mana)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF } from '@/lib/game/constants';
|
||||
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { X, BookOpen } from 'lucide-react';
|
||||
|
||||
export function SpireTab() {
|
||||
const store = useGameStore();
|
||||
const { effectiveRegen, meditationMultiplier, incursionStrength } = useManaStats();
|
||||
const {
|
||||
floorElem, floorElemDef, isGuardianFloor, currentGuardian,
|
||||
activeSpellDef, dps, damageBreakdown
|
||||
} = useCombatStats();
|
||||
const { effectiveStudySpeedMult } = useStudyStats();
|
||||
|
||||
// Check if spell can be cast
|
||||
const canCastSpell = (spellId: string): boolean => {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return false;
|
||||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||||
};
|
||||
|
||||
// Render study progress
|
||||
const renderStudyProgress = () => {
|
||||
if (!store.currentStudyTarget) return null;
|
||||
|
||||
const target = store.currentStudyTarget;
|
||||
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||
const isSkill = target.type === 'skill';
|
||||
const def = isSkill ? SPELLS_DEF[target.id] : SPELLS_DEF[target.id];
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm font-semibold text-purple-300">
|
||||
{def?.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={() => store.cancelStudy()}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||||
<span>{effectiveStudySpeedMult.toFixed(1)}x speed</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Current Floor Card */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
|
||||
{store.currentFloor}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm">/ 100</span>
|
||||
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
|
||||
{floorElemDef?.sym} {floorElemDef?.name}
|
||||
</span>
|
||||
{isGuardianFloor && (
|
||||
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGuardianFloor && currentGuardian && (
|
||||
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
|
||||
⚔️ {currentGuardian.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HP Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
|
||||
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
|
||||
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400 game-mono">
|
||||
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
|
||||
<span>DPS: {store.currentAction === 'climb' && canCastSpell(store.activeSpell) ? fmtDec(dps) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong> •
|
||||
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Spell Card */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Spell</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{activeSpellDef ? (
|
||||
<>
|
||||
<div className="text-lg font-semibold game-panel-title" style={{ color: activeSpellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[activeSpellDef.elem]?.color }}>
|
||||
{activeSpellDef.name}
|
||||
{activeSpellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200">Basic</Badge>}
|
||||
{activeSpellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100">Legendary</Badge>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 game-mono">
|
||||
⚔️ {fmt(calcDamage(store, store.activeSpell))} dmg •
|
||||
<span style={{ color: getSpellCostColor(activeSpellDef.cost) }}>
|
||||
{' '}{formatSpellCost(activeSpellDef.cost)}
|
||||
</span>
|
||||
{' '}• ⚡ {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
|
||||
</div>
|
||||
|
||||
{/* Cast progress bar when climbing */}
|
||||
{store.currentAction === 'climb' && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Cast Progress</span>
|
||||
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={Math.min(100, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSpellDef.desc && (
|
||||
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</div>
|
||||
)}
|
||||
{activeSpellDef.effects && activeSpellDef.effects.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{activeSpellDef.effects.map((eff, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">
|
||||
{eff.type === 'burn' && `🔥 Burn ${eff.value}/hr`}
|
||||
{eff.type === 'stun' && `⚡ Stun ${eff.value}s`}
|
||||
{eff.type === 'pierce' && `🗡️ Pierce ${Math.round(eff.value * 100)}%`}
|
||||
{eff.type === 'multicast' && `✨ ${Math.round(eff.value * 100)}% Multicast`}
|
||||
{eff.type === 'buff' && `💪 Buff`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-500">No spell selected</div>
|
||||
)}
|
||||
|
||||
{/* Can cast indicator */}
|
||||
{activeSpellDef && (
|
||||
<div className={`text-xs ${canCastSpell(store.activeSpell) ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{incursionStrength > 0 && (
|
||||
<div className="p-2 bg-red-900/20 border border-red-800/50 rounded">
|
||||
<div className="text-xs text-red-400 game-panel-title mb-1">LABYRINTH INCURSION</div>
|
||||
<div className="text-sm text-gray-300">
|
||||
-{Math.round(incursionStrength * 100)}% mana regen
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Study (if any) */}
|
||||
{store.currentStudyTarget && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
{renderStudyProgress()}
|
||||
|
||||
{/* Parallel Study Progress */}
|
||||
{store.parallelStudyTarget && (
|
||||
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-sm font-semibold text-cyan-300">
|
||||
Parallel: {store.parallelStudyTarget.type === 'skill' ? store.parallelStudyTarget.id : store.parallelStudyTarget.id}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={() => store.cancelParallelStudy()}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
|
||||
<span>50% speed (Parallel Study)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pact Signing Progress */}
|
||||
{store.pactSigningProgress && (
|
||||
<Card className="bg-gray-900/80 border-amber-600/50 lg:col-span-2">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="p-3 rounded border border-amber-500/30 bg-amber-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">📜</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-amber-300">
|
||||
Signing Pact: {GUARDIANS[store.pactSigningProgress.floor]?.name}
|
||||
</div>
|
||||
<div className="text-xs text-amber-400">
|
||||
Floor {store.pactSigningProgress.floor}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(100, (store.pactSigningProgress.progress / store.pactSigningProgress.required) * 100)}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-amber-400 mt-1">
|
||||
<span>{formatStudyTime(store.pactSigningProgress.progress)} / {formatStudyTime(store.pactSigningProgress.required)}</span>
|
||||
<span>Cost: {fmt(store.pactSigningProgress.manaCost)} mana</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Spells Available */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Known Spells</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{Object.entries(store.spells)
|
||||
.filter(([, state]) => state.learned)
|
||||
.map(([id, state]) => {
|
||||
const def = SPELLS_DEF[id];
|
||||
if (!def) return null;
|
||||
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||
const isActive = store.activeSpell === id;
|
||||
const canCast = canCastSpell(id);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={id}
|
||||
variant="outline"
|
||||
className={`h-auto py-2 px-3 flex flex-col items-start ${isActive ? 'border-amber-500 bg-amber-900/20' : canCast ? 'border-gray-600 bg-gray-800/50 hover:bg-gray-700/50' : 'border-gray-700 bg-gray-800/30 opacity-60'}`}
|
||||
onClick={() => store.setSpell(id)}
|
||||
>
|
||||
<div className="text-sm font-semibold" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||
{def.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
{fmt(calcDamage(store, id))} dmg
|
||||
</div>
|
||||
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||
{formatSpellCost(def.cost)}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity Log */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-32">
|
||||
<div className="space-y-1">
|
||||
{store.log.slice(0, 20).map((entry, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
|
||||
>
|
||||
{entry}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Droplet, Swords, BookOpen, FlaskConical, RotateCcw, Trophy, Star } from 'lucide-react';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
export function StatsTab() {
|
||||
const store = useGameStore();
|
||||
const {
|
||||
upgradeEffects, maxMana, baseRegen, clickMana,
|
||||
meditationMultiplier, incursionStrength, manaCascadeBonus, effectiveRegen,
|
||||
hasSteadyStream, hasManaTorrent, hasDesperateWells
|
||||
} = useManaStats();
|
||||
const { activeSpellDef, pactMultiplier, pactInsightMultiplier } = useCombatStats();
|
||||
const { studySpeedMult, studyCostMult } = useStudyStats();
|
||||
|
||||
// Compute element max
|
||||
const elemMax = (() => {
|
||||
const ea = store.skillTiers?.elemAttune || 1;
|
||||
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return 10 + level * 50 * tierMult + (store.prestigeUpgrades.elementalAttune || 0) * 25;
|
||||
})();
|
||||
|
||||
// Get all selected skill upgrades
|
||||
const getAllSelectedUpgrades = () => {
|
||||
const upgrades: { skillId: string; upgrade: SkillUpgradeChoice }[] = [];
|
||||
for (const [skillId, selectedIds] of Object.entries(store.skillUpgrades)) {
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||
if (!path) continue;
|
||||
for (const tier of path.tiers) {
|
||||
if (tier.skillId === skillId) {
|
||||
for (const upgradeId of selectedIds) {
|
||||
const upgrade = tier.upgrades.find(u => u.id === upgradeId);
|
||||
if (upgrade) {
|
||||
upgrades.push({ skillId, upgrade });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return upgrades;
|
||||
};
|
||||
|
||||
const selectedUpgrades = getAllSelectedUpgrades();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mana Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-blue-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Droplet className="w-4 h-4" />
|
||||
Mana Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Base Max Mana:</span>
|
||||
<span className="text-gray-200">100</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Well Bonus:</span>
|
||||
<span className="text-blue-300">
|
||||
{(() => {
|
||||
const mw = store.skillTiers?.manaWell || 1;
|
||||
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
|
||||
const level = store.skills[tieredSkillId] || store.skills.manaWell || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${fmt(level * 100 * tierMult)} (${level} lvl × 100 × ${tierMult}x tier)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Mana Well:</span>
|
||||
<span className="text-blue-300">+{fmt((store.prestigeUpgrades.manaWell || 0) * 500)}</span>
|
||||
</div>
|
||||
{upgradeEffects.maxManaBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Mana Bonus:</span>
|
||||
<span className="text-amber-300">+{fmt(upgradeEffects.maxManaBonus)}</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.maxManaMultiplier > 1 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Mana Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(upgradeEffects.maxManaMultiplier, 2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Total Max Mana:</span>
|
||||
<span className="text-blue-400">{fmt(maxMana)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Base Regen:</span>
|
||||
<span className="text-gray-200">2/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Flow Bonus:</span>
|
||||
<span className="text-blue-300">
|
||||
{(() => {
|
||||
const mf = store.skillTiers?.manaFlow || 1;
|
||||
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
|
||||
const level = store.skills[tieredSkillId] || store.skills.manaFlow || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${fmtDec(level * 1 * tierMult)}/hr (${level} lvl × 1 × ${tierMult}x tier)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Spring Bonus:</span>
|
||||
<span className="text-blue-300">+{(store.skills.manaSpring || 0) * 2}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Mana Flow:</span>
|
||||
<span className="text-blue-300">+{fmtDec((store.prestigeUpgrades.manaFlow || 0) * 0.5)}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Temporal Echo:</span>
|
||||
<span className="text-blue-300">×{fmtDec(1 + (store.prestigeUpgrades.temporalEcho || 0) * 0.1, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Base Regen:</span>
|
||||
<span className="text-blue-400">{fmtDec(baseRegen, 2)}/hr</span>
|
||||
</div>
|
||||
{upgradeEffects.regenBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Regen Bonus:</span>
|
||||
<span className="text-amber-300">+{fmtDec(upgradeEffects.regenBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.permanentRegenBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Permanent Regen Bonus:</span>
|
||||
<span className="text-amber-300">+{fmtDec(upgradeEffects.permanentRegenBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.regenMultiplier > 1 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Regen Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(upgradeEffects.regenMultiplier, 2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
{/* Skill Upgrade Effects Summary */}
|
||||
{upgradeEffects.activeUpgrades.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<span className="text-xs text-amber-400 game-panel-title">Active Skill Upgrades</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3">
|
||||
{upgradeEffects.activeUpgrades.map((upgrade, idx) => (
|
||||
<div key={idx} className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">{upgrade.name}</span>
|
||||
<span className="text-gray-400">{upgrade.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Click Mana Value:</span>
|
||||
<span className="text-purple-300">+{clickMana}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Tap Bonus:</span>
|
||||
<span className="text-purple-300">+{store.skills.manaTap || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Surge Bonus:</span>
|
||||
<span className="text-purple-300">+{(store.skills.manaSurge || 0) * 3}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Overflow:</span>
|
||||
<span className="text-purple-300">×{fmtDec(1 + (store.skills.manaOverflow || 0) * 0.25, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Meditation Multiplier:</span>
|
||||
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
|
||||
{fmtDec(meditationMultiplier, 2)}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Effective Regen:</span>
|
||||
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
|
||||
</div>
|
||||
{incursionStrength > 0 && !hasSteadyStream && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-red-400">Incursion Penalty:</span>
|
||||
<span className="text-red-400">-{Math.round(incursionStrength * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{hasSteadyStream && incursionStrength > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-400">Steady Stream:</span>
|
||||
<span className="text-green-400">Immune to incursion</span>
|
||||
</div>
|
||||
)}
|
||||
{manaCascadeBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Cascade Bonus:</span>
|
||||
<span className="text-cyan-400">+{fmtDec(manaCascadeBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{hasManaTorrent && store.rawMana > maxMana * 0.75 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Torrent:</span>
|
||||
<span className="text-cyan-400">+50% regen (high mana)</span>
|
||||
</div>
|
||||
)}
|
||||
{hasDesperateWells && store.rawMana < maxMana * 0.25 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Desperate Wells:</span>
|
||||
<span className="text-cyan-400">+50% regen (low mana)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Combat Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-red-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Swords className="w-4 h-4" />
|
||||
Combat Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Active Spell Base Damage:</span>
|
||||
<span className="text-gray-200">{activeSpellDef?.dmg || 5}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Combat Training Bonus:</span>
|
||||
<span className="text-red-300">+{(store.skills.combatTrain || 0) * 5}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Arcane Fury Multiplier:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.arcaneFury || 0) * 0.1, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elemental Mastery:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.elementalMastery || 0) * 0.15, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Guardian Bane:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Critical Hit Chance:</span>
|
||||
<span className="text-amber-300">{((store.skills.precision || 0) * 5)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Critical Multiplier:</span>
|
||||
<span className="text-amber-300">1.5x</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Spell Echo Chance:</span>
|
||||
<span className="text-amber-300">{((store.skills.spellEcho || 0) * 10)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Pact Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Total Damage:</span>
|
||||
<span className="text-red-400">{fmt(calcDamage(store, store.activeSpell))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pact Status */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
Pact Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Pact Slots:</span>
|
||||
<span className="text-amber-300">{store.signedPacts.length} / {1 + (store.prestigeUpgrades.pactCapacity || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Damage Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Insight Multiplier:</span>
|
||||
<span className="text-purple-300">×{fmtDec(pactInsightMultiplier, 2)}</span>
|
||||
</div>
|
||||
{store.signedPacts.length > 1 && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Interference Mitigation:</span>
|
||||
<span className="text-green-300">{Math.min(store.pactInterferenceMitigation || 0, 5) * 10}%</span>
|
||||
</div>
|
||||
{(store.pactInterferenceMitigation || 0) >= 5 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Synergy Bonus:</span>
|
||||
<span className="text-cyan-300">+{((store.pactInterferenceMitigation || 0) - 5) * 10}%</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400 mb-2">Unlocked Mana Types:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([, state]) => state.unlocked)
|
||||
.map(([id]) => {
|
||||
const elem = ELEMENTS[id];
|
||||
return (
|
||||
<Badge key={id} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
|
||||
{elem?.sym} {elem?.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Study Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Study Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Study Speed:</span>
|
||||
<span className="text-purple-300">×{fmtDec(studySpeedMult, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Quick Learner Bonus:</span>
|
||||
<span className="text-purple-300">+{((store.skills.quickLearner || 0) * 10)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Study Cost:</span>
|
||||
<span className="text-purple-300">{Math.round(studyCostMult * 100)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Focused Mind Bonus:</span>
|
||||
<span className="text-purple-300">-{((store.skills.focusedMind || 0) * 5)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Progress Retention:</span>
|
||||
<span className="text-purple-300">{Math.round((1 + (store.skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Element Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-green-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<FlaskConical className="w-4 h-4" />
|
||||
Element Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Element Capacity:</span>
|
||||
<span className="text-green-300">{elemMax}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elem. Attunement Bonus:</span>
|
||||
<span className="text-green-300">
|
||||
{(() => {
|
||||
const ea = store.skillTiers?.elemAttune || 1;
|
||||
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${level * 50 * tierMult}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Attunement:</span>
|
||||
<span className="text-green-300">+{(store.prestigeUpgrades.elementalAttune || 0) * 25}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Unlocked Elements:</span>
|
||||
<span className="text-green-300">{Object.values(store.elements).filter(e => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elem. Crafting Bonus:</span>
|
||||
<span className="text-green-300">×{fmtDec(1 + (store.skills.elemCrafting || 0) * 0.25, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
<div className="text-xs text-gray-400 mb-2">Elemental Mana Pools:</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([, state]) => state.unlocked)
|
||||
.map(([id, state]) => {
|
||||
const def = ELEMENTS[id];
|
||||
return (
|
||||
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 text-center">
|
||||
<div className="text-lg">{def?.sym}</div>
|
||||
<div className="text-xs text-gray-400">{state.current}/{state.max}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Upgrades */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Star className="w-4 h-4" />
|
||||
Active Skill Upgrades ({selectedUpgrades.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedUpgrades.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{selectedUpgrades.map(({ skillId, upgrade }) => (
|
||||
<div key={upgrade.id} className="p-2 rounded border border-amber-600/30 bg-amber-900/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-amber-300 text-sm font-semibold">{upgrade.name}</span>
|
||||
<Badge variant="outline" className="text-xs text-gray-400">
|
||||
{SKILLS_DEF[skillId]?.name || skillId}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect active'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Loop Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Loop Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||
<div className="text-xs text-gray-400">Current Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Total Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-green-400 game-mono">{store.maxFloorReached}</div>
|
||||
<div className="text-xs text-gray-400">Max Floor</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.spells).filter(s => s.learned).length}</div>
|
||||
<div className="text-xs text-gray-400">Spells Learned</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.skills).reduce((a, b) => a + b, 0)}</div>
|
||||
<div className="text-xs text-gray-400">Total Skill Levels</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{fmt(store.totalManaGathered)}</div>
|
||||
<div className="text-xs text-gray-400">Total Mana Gathered</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{store.memorySlots}</div>
|
||||
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { Play, Pause } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { fmt } from '@/lib/game/store';
|
||||
import { formatHour } from '@/lib/game/formatting';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { formatHour } from '@/lib/game/utils/formatting';
|
||||
|
||||
interface TimeDisplayProps {
|
||||
day: number;
|
||||
hour: number;
|
||||
insight: number;
|
||||
paused: boolean;
|
||||
onTogglePause: () => void;
|
||||
}
|
||||
|
||||
export function TimeDisplay({
|
||||
day,
|
||||
hour,
|
||||
insight,
|
||||
paused,
|
||||
onTogglePause,
|
||||
}: TimeDisplayProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -37,15 +31,8 @@ export function TimeDisplay({
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Insight</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onTogglePause}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
{paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TimeDisplay.displayName = "TimeDisplay";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -32,35 +31,34 @@ export function UpgradeDialog({
|
||||
onOpenChange,
|
||||
}: UpgradeDialogProps) {
|
||||
if (!skillId) return null;
|
||||
|
||||
const skillDef = SKILLS_DEF[skillId];
|
||||
|
||||
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-amber-400">
|
||||
Choose Upgrade - {skillDef?.name || skillId}
|
||||
Choose Upgrade - {skillId}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Level {milestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{available.map((upgrade) => {
|
||||
const isSelected = currentSelections.includes(upgrade.id);
|
||||
const canToggle = currentSelections.length < 2 || isSelected;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canToggle
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canToggle
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={() => {
|
||||
@@ -93,15 +91,15 @@ export function UpgradeDialog({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onConfirm}
|
||||
disabled={currentSelections.length !== 2}
|
||||
@@ -113,3 +111,5 @@ export function UpgradeDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeDialog.displayName = "UpgradeDialog";
|
||||
|
||||
@@ -1,211 +1,280 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useState } from 'react';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { StatRow } from '@/components/ui/stat-row';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||
import type { EquipmentInstance, EnchantmentDesign, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||
import { fmt, type GameStore } from '@/lib/game/store';
|
||||
|
||||
// Slot display names
|
||||
const SLOT_NAMES: Record<EquipmentSlot, string> = {
|
||||
mainHand: 'Main Hand',
|
||||
offHand: 'Off Hand',
|
||||
head: 'Head',
|
||||
body: 'Body',
|
||||
hands: 'Hands',
|
||||
feet: 'Feet',
|
||||
accessory1: 'Accessory 1',
|
||||
accessory2: 'Accessory 2',
|
||||
};
|
||||
import type { EquipmentSlot } from '@/lib/game/data/equipment';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { CheckCircle, Sparkles } from 'lucide-react';
|
||||
import { useGameStore, useCraftingStore, useManaStore } from '@/lib/game/stores';
|
||||
|
||||
export interface EnchantmentApplierProps {
|
||||
store: GameStore;
|
||||
selectedEquipmentInstance: string | null;
|
||||
setSelectedEquipmentInstance: (id: string | null) => void;
|
||||
selectedDesign: string | null;
|
||||
setSelectedDesign: (id: string | null) => void;
|
||||
onEnchantmentApplied?: () => void;
|
||||
onCapacityExceeded?: (itemName: string, used: number, total: number) => void;
|
||||
}
|
||||
|
||||
export function EnchantmentApplier({
|
||||
store,
|
||||
selectedEquipmentInstance,
|
||||
setSelectedEquipmentInstance,
|
||||
selectedDesign,
|
||||
setSelectedDesign,
|
||||
onEnchantmentApplied,
|
||||
onCapacityExceeded,
|
||||
}: EnchantmentApplierProps) {
|
||||
const equippedInstances = store.equippedInstances;
|
||||
const equipmentInstances = store.equipmentInstances;
|
||||
const enchantmentDesigns = store.enchantmentDesigns;
|
||||
const applicationProgress = store.applicationProgress;
|
||||
const rawMana = store.rawMana;
|
||||
const startApplying = store.startApplying;
|
||||
const pauseApplication = store.pauseApplication;
|
||||
const resumeApplication = store.resumeApplication;
|
||||
const cancelApplication = store.cancelApplication;
|
||||
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
const enchantmentDesigns = useCraftingStore((s) => s.enchantmentDesigns);
|
||||
const applicationProgress = useCraftingStore((s) => s.applicationProgress);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const startApplying = useCraftingStore((s) => s.startApplying);
|
||||
const pauseApplication = useCraftingStore((s) => s.pauseApplication);
|
||||
const resumeApplication = useCraftingStore((s) => s.resumeApplication);
|
||||
const cancelApplication = useCraftingStore((s) => s.cancelApplication);
|
||||
|
||||
// Get equipped items as array
|
||||
// Get equipped items as array - ONLY show items tagged 'Ready for Enchantment' (requirement cr5)
|
||||
const equippedItems = Object.entries(equippedInstances)
|
||||
.filter(([, instanceId]) => instanceId && equipmentInstances[instanceId])
|
||||
.map(([slot, instanceId]) => ({
|
||||
slot: slot as EquipmentSlot,
|
||||
instance: equipmentInstances[instanceId!],
|
||||
}));
|
||||
}))
|
||||
.filter(({ instance }) => instance.tags?.includes('Ready for Enchantment'));
|
||||
|
||||
// Handle apply button click
|
||||
const handleApply = () => {
|
||||
if (!selectedEquipmentInstance || !selectedDesign) return;
|
||||
|
||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||
const design = enchantmentDesigns.find(d => d.id === selectedDesign);
|
||||
|
||||
if (!instance || !design) return;
|
||||
|
||||
// Check capacity
|
||||
const availableCap = instance.totalCapacity - instance.usedCapacity;
|
||||
if (availableCap < design.totalCapacityUsed) {
|
||||
onCapacityExceeded?.(instance.name, instance.usedCapacity, instance.totalCapacity);
|
||||
return;
|
||||
}
|
||||
|
||||
startApplying(selectedEquipmentInstance, selectedDesign);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Equipment & Design Selection */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">Select Equipment & Design</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{applicationProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Applying to: {equipmentInstances[applicationProgress.equipmentInstanceId]?.name}
|
||||
</div>
|
||||
<Progress value={(applicationProgress.progress / applicationProgress.required) * 100} className="h-3" />
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{applicationProgress.progress.toFixed(1)}h / {applicationProgress.required.toFixed(1)}h</span>
|
||||
<span>Mana spent: {fmt(applicationProgress.manaSpent)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{applicationProgress.paused ? (
|
||||
<Button size="sm" onClick={resumeApplication}>Resume</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={pauseApplication}>Pause</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={cancelApplication}>Cancel</Button>
|
||||
</div>
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="Select Equipment & Design" />
|
||||
{applicationProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Applying to: {equipmentInstances[applicationProgress.equipmentInstanceId]?.name}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-2">Equipment (without enchantments):</div>
|
||||
<ScrollArea className="h-32">
|
||||
<div className="space-y-1">
|
||||
{equippedItems
|
||||
.filter(({ instance }) => instance.enchantments.length === 0)
|
||||
.map(({ slot, instance }) => (
|
||||
<div
|
||||
key={instance.instanceId}
|
||||
className={`p-2 rounded border cursor-pointer text-sm ${
|
||||
selectedEquipmentInstance === instance.instanceId
|
||||
? 'border-amber-500 bg-amber-900/20'
|
||||
: 'border-gray-700 bg-gray-800/50'
|
||||
}`}
|
||||
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
|
||||
>
|
||||
{instance.name} ({instance.usedCapacity}/{instance.totalCapacity} cap)
|
||||
</div>
|
||||
))}
|
||||
{equippedItems.filter(({ instance }) => instance.enchantments.length === 0).length === 0 && (
|
||||
<div className="text-center text-gray-500 text-xs py-2">
|
||||
No unenchanted equipment available. Disenchant in Prepare stage first.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-2">Design:</div>
|
||||
<ScrollArea className="h-32">
|
||||
<div className="space-y-1">
|
||||
{enchantmentDesigns.map(design => (
|
||||
<div
|
||||
key={design.id}
|
||||
className={`p-2 rounded border cursor-pointer text-sm ${
|
||||
selectedDesign === design.id
|
||||
? 'border-purple-500 bg-purple-900/20'
|
||||
: 'border-gray-700 bg-gray-800/50'
|
||||
}`}
|
||||
onClick={() => setSelectedDesign(design.id)}
|
||||
>
|
||||
{design.name} ({design.totalCapacityUsed} cap)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="h-3 bg-[var(--bg-sunken)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--mana-light)] transition-all duration-300"
|
||||
style={{ width: `${(applicationProgress.progress / applicationProgress.required) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Application Details */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">Apply Enchantment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedEquipmentInstance || !selectedDesign ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Select equipment and a design
|
||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
||||
<span>{applicationProgress.progress.toFixed(1)}h / {applicationProgress.required.toFixed(1)}h</span>
|
||||
<span>Mana spent: {fmt(applicationProgress.manaSpent)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{applicationProgress.paused ? (
|
||||
<ActionButton size="sm" onClick={resumeApplication}>Resume</ActionButton>
|
||||
) : (
|
||||
<>
|
||||
<ActionButton variant="secondary" size="sm" onClick={pauseApplication}>Pause</ActionButton>
|
||||
<ActionButton variant="ghost" size="sm" onClick={() => {
|
||||
cancelApplication();
|
||||
onEnchantmentApplied?.(); // This will trigger the cancel toast via parent
|
||||
}}>Cancel</ActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-[var(--text-muted)] mb-2">
|
||||
Equipment (Ready for Enchantment):
|
||||
</div>
|
||||
<ScrollArea className="h-32">
|
||||
<div className="space-y-1">
|
||||
{equippedItems.map(({ slot, instance }) => (
|
||||
<div
|
||||
key={instance.instanceId}
|
||||
className={`p-2 rounded border cursor-pointer text-sm transition-all
|
||||
${selectedEquipmentInstance === instance.instanceId
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}
|
||||
`}
|
||||
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${instance.name} (Ready for Enchantment)`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-primary)]">{instance.name}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
({instance.usedCapacity}/{instance.totalCapacity} cap)
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-success)] mt-1">
|
||||
<CheckCircle size={10} className="inline mr-1" />
|
||||
Ready
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{equippedItems.length === 0 && (
|
||||
<div className="text-center text-[var(--text-muted)] text-xs py-2">
|
||||
No equipment ready for enchantment.
|
||||
<br />
|
||||
Prepare equipment first in the Prepare stage.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : applicationProgress ? (
|
||||
<div className="text-gray-400">Application in progress...</div>
|
||||
) : (
|
||||
(() => {
|
||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||
const design = enchantmentDesigns.find(d => d.id === selectedDesign);
|
||||
if (!design) return null;
|
||||
|
||||
const availableCap = instance.totalCapacity - instance.usedCapacity;
|
||||
const canFit = availableCap >= design.totalCapacityUsed;
|
||||
const applicationTime = 2 + design.effects.reduce((t, e) => t + e.stacks, 0);
|
||||
const manaPerHour = 20 + design.effects.reduce((t, e) => t + e.stacks * 5, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-semibold">{design.name}</div>
|
||||
<div className="text-sm text-gray-400">→ {instance.name}</div>
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Required Capacity:</span>
|
||||
<span className={canFit ? 'text-green-400' : 'text-red-400'}>
|
||||
{design.totalCapacityUsed} / {availableCap} available
|
||||
<div>
|
||||
<div className="text-sm text-[var(--text-muted)] mb-2">Design:</div>
|
||||
<ScrollArea className="h-32">
|
||||
<div className="space-y-1">
|
||||
{enchantmentDesigns.map(design => (
|
||||
<div
|
||||
key={design.id}
|
||||
className={`p-2 rounded border cursor-pointer text-sm transition-all
|
||||
${selectedDesign === design.id
|
||||
? 'border-[var(--mana-stellar)] bg-[var(--mana-stellar)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}
|
||||
`}
|
||||
onClick={() => setSelectedDesign(design.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select design: ${design.name}`}
|
||||
>
|
||||
<span className="text-[var(--text-primary)]">{design.name}</span>
|
||||
<span className="text-xs text-[var(--text-muted)] ml-2">
|
||||
({design.totalCapacityUsed} cap)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Application Time:</span>
|
||||
<span>{applicationTime}h</span>
|
||||
))}
|
||||
{enchantmentDesigns.length === 0 && (
|
||||
<div className="text-center text-[var(--text-muted)] text-xs py-2">
|
||||
No designs available. Create one in the Design stage.
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Mana per Hour:</span>
|
||||
<span>{manaPerHour}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GameCard>
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
Effects:
|
||||
<ul className="list-disc list-inside">
|
||||
{design.effects.map(eff => (
|
||||
<li key={eff.effectId}>
|
||||
{ENCHANTMENT_EFFECTS[eff.effectId]?.name} x{eff.stacks}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Application Details */}
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="Apply Enchantment" />
|
||||
{!selectedEquipmentInstance || !selectedDesign ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
Select equipment and a design
|
||||
</div>
|
||||
) : applicationProgress ? (
|
||||
<div className="text-[var(--text-secondary)]">Application in progress...</div>
|
||||
) : (
|
||||
(() => {
|
||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||
if (!instance) return null;
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!canFit}
|
||||
onClick={() => startApplying(selectedEquipmentInstance, selectedDesign)}
|
||||
>
|
||||
Apply Enchantment
|
||||
</Button>
|
||||
// Check if equipment is ready for enchantment
|
||||
const isReady = instance.tags?.includes('Ready for Enchantment');
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="text-center text-[var(--color-danger)] py-8">
|
||||
This equipment is not prepared for enchantment. Please prepare it in the Prepare stage first.
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
|
||||
const design = enchantmentDesigns.find(d => d.id === selectedDesign);
|
||||
if (!design) return null;
|
||||
|
||||
const availableCap = instance.totalCapacity - instance.usedCapacity;
|
||||
const canFit = availableCap >= design.totalCapacityUsed;
|
||||
const applicationTime = 2 + design.effects.reduce((t, e) => t + e.stacks, 0);
|
||||
const manaPerHour = 20 + design.effects.reduce((t, e) => t + e.stacks * 5, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-semibold text-[var(--text-primary)]">{design.name}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">→ {instance.name}</div>
|
||||
<div className="text-xs text-[var(--color-success)]">
|
||||
<CheckCircle size={12} className="inline mr-1" />
|
||||
Ready for Enchantment
|
||||
</div>
|
||||
<Separator className="bg-[var(--border-subtle)]" />
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<StatRow
|
||||
label="Required Capacity:"
|
||||
value={
|
||||
<span className={canFit ? 'text-[var(--color-success)]' : 'text-[var(--color-danger)]'}>
|
||||
{design.totalCapacityUsed} / {availableCap} available
|
||||
</span>
|
||||
}
|
||||
highlight={canFit ? 'success' : 'danger'}
|
||||
/>
|
||||
<StatRow
|
||||
label="Application Time:"
|
||||
value={`${applicationTime}h`}
|
||||
highlight="default"
|
||||
/>
|
||||
<StatRow
|
||||
label="Mana per Hour:"
|
||||
value={manaPerHour}
|
||||
highlight="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-[var(--text-muted)]">
|
||||
Effects:
|
||||
<ul className="list-disc list-inside mt-1">
|
||||
{design.effects.map(eff => (
|
||||
<li key={eff.effectId} className="text-[var(--text-secondary)]">
|
||||
{ENCHANTMENT_EFFECTS[eff.effectId]?.name} x{eff.stacks}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
disabled={!canFit}
|
||||
onClick={handleApply}
|
||||
>
|
||||
<Sparkles size={16} className="mr-2" />
|
||||
Apply Enchantment
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</GameCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EnchantmentApplier.displayName = 'EnchantmentApplier';
|
||||
|
||||
@@ -1,45 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useState } 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 { useState, useMemo } from 'react';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Wand2, Scroll, Trash2, Plus, Minus } from 'lucide-react';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||
import { fmt, type GameStore } from '@/lib/game/store';
|
||||
|
||||
// Slot display names
|
||||
const SLOT_NAMES: Record<EquipmentSlot, string> = {
|
||||
mainHand: 'Main Hand',
|
||||
offHand: 'Off Hand',
|
||||
head: 'Head',
|
||||
body: 'Body',
|
||||
hands: 'Hands',
|
||||
feet: 'Feet',
|
||||
accessory1: 'Accessory 1',
|
||||
accessory2: 'Accessory 2',
|
||||
};
|
||||
|
||||
export interface EnchantmentDesignerProps {
|
||||
store: GameStore;
|
||||
selectedEquipmentType: string | null;
|
||||
setSelectedEquipmentType: (type: string | null) => void;
|
||||
selectedEffects: DesignEffect[];
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void;
|
||||
designName: string;
|
||||
setDesignName: (name: string) => void;
|
||||
selectedDesign: string | null;
|
||||
setSelectedDesign: (id: string | null) => void;
|
||||
}
|
||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||
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';
|
||||
import { SavedDesigns } from './EnchantmentDesigner/SavedDesigns';
|
||||
import { DesignForm } from './EnchantmentDesigner/DesignForm';
|
||||
import {
|
||||
getAvailableEffects,
|
||||
getIncompatibleEffects,
|
||||
getOwnedEquipmentTypes,
|
||||
getIncompatibilityReason,
|
||||
calculateDesignCapacityCost,
|
||||
getEquipmentCapacity,
|
||||
calculateDesignTime,
|
||||
addEffectToDesign,
|
||||
removeEffectFromDesign,
|
||||
} from './EnchantmentDesigner/utils';
|
||||
import { useCraftingStore } from '@/lib/game/stores';
|
||||
|
||||
export function EnchantmentDesigner({
|
||||
store,
|
||||
selectedEquipmentType,
|
||||
setSelectedEquipmentType,
|
||||
selectedEffects,
|
||||
@@ -49,67 +34,32 @@ export function EnchantmentDesigner({
|
||||
selectedDesign,
|
||||
setSelectedDesign,
|
||||
}: EnchantmentDesignerProps) {
|
||||
const enchantmentDesigns = store.enchantmentDesigns;
|
||||
const designProgress = store.designProgress;
|
||||
const startDesigningEnchantment = store.startDesigningEnchantment;
|
||||
const cancelDesign = store.cancelDesign;
|
||||
const deleteDesign = store.deleteDesign;
|
||||
const unlockedEffects = store.unlockedEffects;
|
||||
const skills = store.skills;
|
||||
|
||||
const enchantingLevel = skills.enchanting || 0;
|
||||
const efficiencyBonus = (skills.efficientEnchant || 0) * 0.05;
|
||||
// Crafting store selectors
|
||||
const enchantmentDesigns = useCraftingStore((s) => s.enchantmentDesigns);
|
||||
const designProgress = useCraftingStore((s) => s.designProgress);
|
||||
const startDesigningEnchantment = useCraftingStore((s) => s.startDesigningEnchantment);
|
||||
const cancelDesign = useCraftingStore((s) => s.cancelDesign);
|
||||
const deleteDesign = useCraftingStore((s) => s.deleteDesign);
|
||||
const unlockedEffects = useCraftingStore((s) => s.unlockedEffects);
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
|
||||
// Calculate total capacity cost for current design
|
||||
const designCapacityCost = selectedEffects.reduce(
|
||||
(total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus),
|
||||
0
|
||||
);
|
||||
const designCapacityCost = calculateDesignCapacityCost(selectedEffects, 0);
|
||||
|
||||
// Get capacity limit for selected equipment type
|
||||
const selectedEquipmentCapacity = selectedEquipmentType ? EQUIPMENT_TYPES[selectedEquipmentType]?.baseCapacity || 0 : 0;
|
||||
const isOverCapacity = selectedEquipmentType ? designCapacityCost > selectedEquipmentCapacity : false;
|
||||
const selectedEquipmentCapacity = getEquipmentCapacity(selectedEquipmentType);
|
||||
|
||||
// Calculate design time
|
||||
const designTime = selectedEffects.reduce((total, eff) => total + 0.5 * eff.stacks, 1);
|
||||
const designTime = calculateDesignTime(selectedEffects);
|
||||
|
||||
// Add effect to design
|
||||
const addEffect = (effectId: string) => {
|
||||
const existing = selectedEffects.find(e => e.effectId === effectId);
|
||||
const effectDef = ENCHANTMENT_EFFECTS[effectId];
|
||||
if (!effectDef) return;
|
||||
|
||||
if (existing) {
|
||||
if (existing.stacks < effectDef.maxStacks) {
|
||||
setSelectedEffects(selectedEffects.map(e =>
|
||||
e.effectId === effectId
|
||||
? { ...e, stacks: e.stacks + 1 }
|
||||
: e
|
||||
));
|
||||
}
|
||||
} else {
|
||||
setSelectedEffects([...selectedEffects, {
|
||||
effectId,
|
||||
stacks: 1,
|
||||
capacityCost: calculateEffectCapacityCost(effectId, 1, efficiencyBonus),
|
||||
}]);
|
||||
}
|
||||
addEffectToDesign(effectId, selectedEffects, 0, setSelectedEffects);
|
||||
};
|
||||
|
||||
// Remove effect from design
|
||||
const removeEffect = (effectId: string) => {
|
||||
const existing = selectedEffects.find(e => e.effectId === effectId);
|
||||
if (!existing) return;
|
||||
|
||||
if (existing.stacks > 1) {
|
||||
setSelectedEffects(selectedEffects.map(e =>
|
||||
e.effectId === effectId
|
||||
? { ...e, stacks: e.stacks - 1 }
|
||||
: e
|
||||
));
|
||||
} else {
|
||||
setSelectedEffects(selectedEffects.filter(e => e.effectId !== effectId));
|
||||
}
|
||||
removeEffectFromDesign(effectId, selectedEffects, setSelectedEffects);
|
||||
};
|
||||
|
||||
// Create design
|
||||
@@ -126,231 +76,75 @@ export function EnchantmentDesigner({
|
||||
};
|
||||
|
||||
// Get available effects for selected equipment type (only unlocked ones)
|
||||
const getAvailableEffects = () => {
|
||||
if (!selectedEquipmentType) return [];
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return [];
|
||||
const availableEffects = getAvailableEffects(selectedEquipmentType, unlockedEffects);
|
||||
|
||||
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
||||
effect =>
|
||||
effect.allowedEquipmentCategories.includes(type.category) &&
|
||||
unlockedEffects.includes(effect.id)
|
||||
);
|
||||
// Get incompatible effects (unlocked but not for this equipment type)
|
||||
const incompatibleEffects = getIncompatibleEffects(selectedEquipmentType, unlockedEffects);
|
||||
|
||||
// Get equipment types that the player actually owns (has instances of)
|
||||
const ownedEquipmentTypes = getOwnedEquipmentTypes(equipmentInstances);
|
||||
|
||||
// Get the reason why an effect is incompatible
|
||||
const getIncompatibilityReasonWrapper = (effect: { id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] }) => {
|
||||
return getIncompatibilityReason(effect, selectedEquipmentType);
|
||||
};
|
||||
|
||||
// Render design stage
|
||||
// Render stage
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Equipment Type Selection */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">1. Select Equipment Type</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{designProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Designing for: {EQUIPMENT_TYPES[designProgress.equipmentType]?.name}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-amber-300">{designProgress.name}</div>
|
||||
<Progress value={(designProgress.progress / designProgress.required) * 100} className="h-3" />
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
|
||||
<Button size="sm" variant="outline" onClick={cancelDesign}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.values(EQUIPMENT_TYPES).map(type => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`p-2 rounded border cursor-pointer transition-all ${
|
||||
selectedEquipmentType === type.id
|
||||
? 'border-amber-500 bg-amber-900/20'
|
||||
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => setSelectedEquipmentType(type.id)}
|
||||
>
|
||||
<div className="text-sm font-semibold">{type.name}</div>
|
||||
<div className="text-xs text-gray-400">Cap: {type.baseCapacity}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EquipmentTypeSelector
|
||||
ownedEquipmentTypes={ownedEquipmentTypes}
|
||||
selectedEquipmentType={selectedEquipmentType}
|
||||
setSelectedEquipmentType={setSelectedEquipmentType}
|
||||
designProgress={designProgress}
|
||||
cancelDesign={cancelDesign}
|
||||
/>
|
||||
|
||||
{/* Effect Selection */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">2. Select Effects</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{enchantingLevel < 1 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
<Wand2 className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Learn Enchanting skill to design enchantments</p>
|
||||
</div>
|
||||
) : designProgress ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400">Design in progress...</div>
|
||||
{designProgress.effects.map(eff => {
|
||||
const def = ENCHANTMENT_EFFECTS[eff.effectId];
|
||||
return (
|
||||
<div key={eff.effectId} className="flex justify-between text-sm">
|
||||
<span>{def?.name} x{eff.stacks}</span>
|
||||
<span className="text-gray-400">{eff.capacityCost} cap</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : !selectedEquipmentType ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Select an equipment type first
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="h-48 mb-4">
|
||||
<div className="space-y-2">
|
||||
{getAvailableEffects().map(effect => {
|
||||
const selected = selectedEffects.find(e => e.effectId === effect.id);
|
||||
const cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
|
||||
<GameCard variant="default">
|
||||
<EffectSelector
|
||||
selectedEquipmentType={selectedEquipmentType}
|
||||
selectedEffects={selectedEffects}
|
||||
setSelectedEffects={setSelectedEffects}
|
||||
availableEffects={availableEffects}
|
||||
incompatibleEffects={incompatibleEffects}
|
||||
enchantingLevel={0}
|
||||
efficiencyBonus={0}
|
||||
designProgress={designProgress}
|
||||
addEffect={addEffect}
|
||||
removeEffect={removeEffect}
|
||||
getIncompatibilityReason={getIncompatibilityReasonWrapper}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={effect.id}
|
||||
className={`p-2 rounded border transition-all ${
|
||||
selected
|
||||
? 'border-purple-500 bg-purple-900/20'
|
||||
: 'border-gray-700 bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{effect.name}</div>
|
||||
<div className="text-xs text-gray-400">{effect.description}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Cost: {effect.baseCapacityCost} cap | Max: {effect.maxStacks}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{selected && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeEffect(effect.id)}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => addEffect(effect.id)}
|
||||
disabled={!selected && selectedEffects.length >= 5}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{selected.stacks}/{effect.maxStacks}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Selected effects summary */}
|
||||
<Separator className="bg-gray-700 my-2" />
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Design name..."
|
||||
value={designName}
|
||||
onChange={(e) => setDesignName(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Total Capacity:</span>
|
||||
<span className={isOverCapacity ? 'text-red-400' : 'text-green-400'}>
|
||||
{designCapacityCost.toFixed(0)} / {selectedEquipmentCapacity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-400">
|
||||
<span>Design Time:</span>
|
||||
<span>{designTime.toFixed(1)}h</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!designName || selectedEffects.length === 0 || isOverCapacity}
|
||||
onClick={handleCreateDesign}
|
||||
>
|
||||
{isOverCapacity ? 'Over Capacity!' : `Start Design (${designTime.toFixed(1)}h)`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Selected effects summary - only show when not in design progress and equipment type is selected */}
|
||||
{!designProgress && selectedEquipmentType && (
|
||||
<>
|
||||
<Separator className="bg-[var(--border-subtle)] my-2" />
|
||||
<DesignForm
|
||||
designName={designName}
|
||||
setDesignName={setDesignName}
|
||||
selectedEffects={selectedEffects}
|
||||
designCapacityCost={designCapacityCost}
|
||||
selectedEquipmentCapacity={selectedEquipmentCapacity}
|
||||
isOverCapacity={designCapacityCost > selectedEquipmentCapacity}
|
||||
designTime={designTime}
|
||||
selectedEquipmentType={selectedEquipmentType}
|
||||
handleCreateDesign={handleCreateDesign}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</GameCard>
|
||||
|
||||
{/* Saved Designs */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">Saved Designs ({enchantmentDesigns.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{enchantmentDesigns.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
No saved designs yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{enchantmentDesigns.map(design => (
|
||||
<div
|
||||
key={design.id}
|
||||
className={`p-3 rounded border ${
|
||||
selectedDesign === design.id
|
||||
? 'border-amber-500 bg-amber-900/20'
|
||||
: 'border-gray-700 bg-gray-800/50'
|
||||
}`}
|
||||
onClick={() => setSelectedDesign(design.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-semibold">{design.name}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{EQUIPMENT_TYPES[design.equipmentType]?.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteDesign(design.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
{design.effects.length} effects | {design.totalCapacityUsed} cap
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SavedDesigns
|
||||
enchantmentDesigns={enchantmentDesigns}
|
||||
selectedDesign={selectedDesign}
|
||||
setSelectedDesign={setSelectedDesign}
|
||||
deleteDesign={deleteDesign}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EnchantmentDesigner.displayName = 'EnchantmentDesigner';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { StatRow } from '@/components/ui/stat-row';
|
||||
import type { DesignFormProps } from './types';
|
||||
|
||||
export function DesignForm({
|
||||
designName,
|
||||
setDesignName,
|
||||
selectedEffects,
|
||||
designCapacityCost,
|
||||
selectedEquipmentCapacity,
|
||||
isOverCapacity,
|
||||
designTime,
|
||||
selectedEquipmentType,
|
||||
handleCreateDesign,
|
||||
}: DesignFormProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Design name..."
|
||||
value={designName}
|
||||
onChange={(e) => setDesignName(e.target.value)}
|
||||
className="w-full bg-[var(--bg-sunken)] border border-[var(--border-default)] rounded px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] focus:outline-none focus:border-[var(--border-focus)]"
|
||||
aria-label="Design name"
|
||||
/>
|
||||
<StatRow
|
||||
label="Total Capacity:"
|
||||
value={
|
||||
<span className={isOverCapacity ? 'text-[var(--color-danger)]' : 'text-[var(--color-success)]'}>
|
||||
{designCapacityCost.toFixed(0)} / {selectedEquipmentCapacity}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label="Design Time:"
|
||||
value={`${designTime.toFixed(1)}h`}
|
||||
highlight="default"
|
||||
/>
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
disabled={!designName || selectedEffects.length === 0 || isOverCapacity}
|
||||
onClick={handleCreateDesign}
|
||||
>
|
||||
{isOverCapacity ? 'Over Capacity!' : `Start Design (${designTime.toFixed(1)}h)`}
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DesignForm.displayName = 'DesignForm';
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, Wand2, Plus, Minus } from 'lucide-react';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
|
||||
import type { EffectSelectorProps } from './types';
|
||||
|
||||
export function EffectSelector({
|
||||
selectedEquipmentType,
|
||||
selectedEffects,
|
||||
setSelectedEffects,
|
||||
availableEffects,
|
||||
incompatibleEffects,
|
||||
enchantingLevel,
|
||||
efficiencyBonus,
|
||||
designProgress,
|
||||
addEffect,
|
||||
removeEffect,
|
||||
getIncompatibilityReason,
|
||||
}: EffectSelectorProps) {
|
||||
return (
|
||||
<>
|
||||
{enchantingLevel < 1 ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
<Wand2 className="w-12 h-12 mx-auto mb-2 opacity-50 text-[var(--text-disabled)]" />
|
||||
<p>Learn Enchanting skill to design enchantments</p>
|
||||
</div>
|
||||
) : designProgress ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-[var(--text-secondary)]">Design in progress...</div>
|
||||
{designProgress.effects.map(eff => {
|
||||
const def = ENCHANTMENT_EFFECTS[eff.effectId];
|
||||
return (
|
||||
<div key={eff.effectId} className="flex justify-between text-sm text-[var(--text-primary)]">
|
||||
<span>{def?.name} x{eff.stacks}</span>
|
||||
<span className="text-[var(--text-muted)]">{eff.capacityCost} cap</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : !selectedEquipmentType ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
Select an equipment type first
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="h-48 mb-4">
|
||||
<div className="space-y-2">
|
||||
{/* Compatible Effects */}
|
||||
{availableEffects.map(effect => {
|
||||
const selected = selectedEffects.find(e => e.effectId === effect.id);
|
||||
const cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={effect.id}
|
||||
className={`p-2 rounded border transition-all
|
||||
${selected
|
||||
? 'border-[var(--mana-stellar)] bg-[var(--mana-stellar)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">{effect.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{effect.description}</div>
|
||||
<div className="text-xs text-[var(--text-disabled)] mt-1">
|
||||
Cost: {effect.baseCapacityCost} cap | Max: {effect.maxStacks}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{selected && (
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeEffect(effect.id)}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => addEffect(effect.id)}
|
||||
disabled={!selected && selectedEffects.length >= 5}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<Badge variant="outline" className="mt-1 text-xs border-[var(--mana-stellar)] text-[var(--mana-stellar)]">
|
||||
{selected.stacks}/{effect.maxStacks}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Incompatible Effects - Requirement: greyed-out "Unavailable" section with tooltips */}
|
||||
{incompatibleEffects.length > 0 && (
|
||||
<>
|
||||
<Separator className="bg-[var(--border-subtle)] my-2" />
|
||||
<div className="text-xs font-semibold text-[var(--text-disabled)] uppercase tracking-wider mb-2">
|
||||
Unavailable
|
||||
</div>
|
||||
{incompatibleEffects.map(effect => {
|
||||
const reason = getIncompatibilityReason(effect);
|
||||
|
||||
return (
|
||||
<TooltipProvider key={effect.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="p-2 rounded border border-[var(--border-subtle)] bg-[var(--bg-sunken)]/30 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-[var(--text-disabled)]">{effect.name}</div>
|
||||
<div className="text-xs text-[var(--text-disabled)]">{effect.description}</div>
|
||||
</div>
|
||||
<AlertCircle size={14} className="text-[var(--text-disabled)]" />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<p className="font-semibold">Incompatible Effect</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">{reason}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
EffectSelector.displayName = 'EffectSelector';
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { EquipmentTypeSelectorProps } from './types';
|
||||
|
||||
export function EquipmentTypeSelector({
|
||||
ownedEquipmentTypes,
|
||||
selectedEquipmentType,
|
||||
setSelectedEquipmentType,
|
||||
designProgress,
|
||||
cancelDesign,
|
||||
}: EquipmentTypeSelectorProps) {
|
||||
return (
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="1. Select Equipment Type" />
|
||||
{designProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Designing for: {designProgress.equipmentType}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-[var(--mana-light)]">{designProgress.name}</div>
|
||||
<Progress
|
||||
value={(designProgress.progress / designProgress.required) * 100}
|
||||
className="h-3 bg-[var(--bg-sunken)]"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
||||
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
|
||||
<ActionButton size="sm" variant="outline" onClick={cancelDesign}>Cancel</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ownedEquipmentTypes.map(type => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`p-2 rounded border cursor-pointer transition-all
|
||||
${selectedEquipmentType === type.id
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
onClick={() => setSelectedEquipmentType(type.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${type.name}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">{type.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Cap: {type.baseCapacity}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{ownedEquipmentTypes.length === 0 && (
|
||||
<div className="text-center text-[var(--text-muted)] py-4 text-sm">
|
||||
No equipment blueprints owned. Craft or find equipment blueprints first.
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
EquipmentTypeSelector.displayName = 'EquipmentTypeSelector';
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import type { SavedDesignsProps } from './types';
|
||||
|
||||
export function SavedDesigns({
|
||||
enchantmentDesigns,
|
||||
selectedDesign,
|
||||
setSelectedDesign,
|
||||
deleteDesign,
|
||||
}: SavedDesignsProps) {
|
||||
return (
|
||||
<GameCard variant="default" className="lg:col-span-2">
|
||||
<SectionHeader title={`Saved Designs (${enchantmentDesigns.length})`} />
|
||||
{enchantmentDesigns.length === 0 ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-4">
|
||||
No saved designs yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{enchantmentDesigns.map(design => (
|
||||
<div
|
||||
key={design.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all
|
||||
${selectedDesign === design.id
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
onClick={() => setSelectedDesign(design.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select design: ${design.name}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text-primary)]">{design.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{EQUIPMENT_TYPES[design.equipmentType]?.name}
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-[var(--text-muted)] hover:text-[var(--color-danger)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteDesign(design.id);
|
||||
}}
|
||||
aria-label={`Delete design: ${design.name}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-[var(--text-muted)]">
|
||||
{design.effects.length} effects | {design.totalCapacityUsed} cap
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
SavedDesigns.displayName = 'SavedDesigns';
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, EquipmentCraftingProgress, EquipmentCategory } from '@/lib/game/types';
|
||||
|
||||
export interface EnchantmentDesignerProps {
|
||||
selectedEquipmentType: string | null;
|
||||
setSelectedEquipmentType: (type: string | null) => void;
|
||||
selectedEffects: DesignEffect[];
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void;
|
||||
designName: string;
|
||||
setDesignName: (name: string) => void;
|
||||
selectedDesign: string | null;
|
||||
setSelectedDesign: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export interface EquipmentTypeSelectorProps {
|
||||
ownedEquipmentTypes: Array<{ id: string; name: string; baseCapacity: number }>;
|
||||
selectedEquipmentType: string | null;
|
||||
setSelectedEquipmentType: (type: string | null) => void;
|
||||
designProgress: EquipmentCraftingProgress | null;
|
||||
cancelDesign: () => void;
|
||||
}
|
||||
|
||||
export interface EffectSelectorProps {
|
||||
selectedEquipmentType: string | null;
|
||||
selectedEffects: DesignEffect[];
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void;
|
||||
availableEffects: Array<{ id: string; name: string; description: string; baseCapacityCost: number; maxStacks: number }>;
|
||||
incompatibleEffects: Array<{ id: string; name: string; description: string }>;
|
||||
enchantingLevel: number;
|
||||
efficiencyBonus: number;
|
||||
designProgress: EquipmentCraftingProgress | null;
|
||||
addEffect: (effectId: string) => void;
|
||||
removeEffect: (effectId: string) => void;
|
||||
getIncompatibilityReason: (effect: { id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] }) => string;
|
||||
}
|
||||
|
||||
export interface SavedDesignsProps {
|
||||
enchantmentDesigns: EnchantmentDesign[];
|
||||
selectedDesign: string | null;
|
||||
setSelectedDesign: (id: string | null) => void;
|
||||
deleteDesign: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface DesignFormProps {
|
||||
designName: string;
|
||||
setDesignName: (name: string) => void;
|
||||
selectedEffects: DesignEffect[];
|
||||
designCapacityCost: number;
|
||||
selectedEquipmentCapacity: number;
|
||||
isOverCapacity: boolean;
|
||||
designTime: number;
|
||||
selectedEquipmentType: string | null;
|
||||
handleCreateDesign: () => void;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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)
|
||||
* Requirement (task3 bug #7): Show incompatible enchantments in greyed-out "Unavailable" section
|
||||
*/
|
||||
export function getAvailableEffects(
|
||||
selectedEquipmentType: string | null,
|
||||
unlockedEffects: string[]
|
||||
) {
|
||||
if (!selectedEquipmentType) return [];
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return [];
|
||||
|
||||
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
||||
effect =>
|
||||
effect.allowedEquipmentCategories.includes(type.category) &&
|
||||
unlockedEffects.includes(effect.id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incompatible effects (unlocked but not for this equipment type)
|
||||
*/
|
||||
export function getIncompatibleEffects(
|
||||
selectedEquipmentType: string | null,
|
||||
unlockedEffects: string[]
|
||||
) {
|
||||
if (!selectedEquipmentType) return [];
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return [];
|
||||
|
||||
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
||||
effect =>
|
||||
!effect.allowedEquipmentCategories.includes(type.category) &&
|
||||
unlockedEffects.includes(effect.id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get equipment types that the player actually owns (has instances of)
|
||||
* This ensures enchantment compatibility is based on owned items, not just blueprints
|
||||
*/
|
||||
export function getOwnedEquipmentTypes(equipmentInstances: Record<string, EquipmentInstance>) {
|
||||
// Get all unique equipment type IDs from owned instances
|
||||
const ownedEquipmentTypeIds = new Set<string>();
|
||||
|
||||
// Check all equipment instances the player owns
|
||||
for (const instance of Object.values(equipmentInstances || {})) {
|
||||
ownedEquipmentTypeIds.add(instance.typeId);
|
||||
}
|
||||
|
||||
// Filter EQUIPMENT_TYPES to only include types the player owns
|
||||
return Object.values(EQUIPMENT_TYPES).filter(type => ownedEquipmentTypeIds.has(type.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reason why an effect is incompatible
|
||||
*/
|
||||
export function getIncompatibilityReason(
|
||||
effect: { id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] },
|
||||
selectedEquipmentType: string | null
|
||||
): string {
|
||||
if (!selectedEquipmentType) return 'No equipment selected';
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return 'Unknown equipment type';
|
||||
|
||||
// Check what categories this effect is allowed for
|
||||
const allowedCategories = effect.allowedEquipmentCategories;
|
||||
const equipmentCategory = type.category;
|
||||
|
||||
if (allowedCategories.includes(equipmentCategory)) {
|
||||
return 'Compatible';
|
||||
}
|
||||
|
||||
// Provide specific reasons
|
||||
if (allowedCategories.includes('weapon' as EquipmentCategory) && equipmentCategory !== 'sword' && equipmentCategory !== 'caster' && equipmentCategory !== 'catalyst') {
|
||||
return `Requires a weapon (${allowedCategories.filter(c => ['sword', 'caster', 'catalyst'].includes(c)).join(', ')})`;
|
||||
}
|
||||
|
||||
return `Requires ${allowedCategories.join(' or ')} equipment`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total capacity cost for current design
|
||||
* Delegates to canonical calculateDesignCapacityCost from crafting-design
|
||||
*/
|
||||
export function calculateDesignCapacityCost(
|
||||
selectedEffects: DesignEffect[],
|
||||
efficiencyBonus: number
|
||||
): number {
|
||||
return calcCapacityCost(selectedEffects, efficiencyBonus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capacity limit for selected equipment type
|
||||
*/
|
||||
export function getEquipmentCapacity(selectedEquipmentType: string | null): number {
|
||||
return selectedEquipmentType ? EQUIPMENT_TYPES[selectedEquipmentType]?.baseCapacity || 0 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate design time
|
||||
* Delegates to canonical calculateDesignTime from crafting-design
|
||||
*/
|
||||
export function calculateDesignTime(selectedEffects: DesignEffect[]): number {
|
||||
return calcDesignTime(selectedEffects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add effect to design
|
||||
*/
|
||||
export function addEffectToDesign(
|
||||
effectId: string,
|
||||
selectedEffects: DesignEffect[],
|
||||
efficiencyBonus: number,
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void
|
||||
) {
|
||||
const existing = selectedEffects.find(e => e.effectId === effectId);
|
||||
const effectDef = ENCHANTMENT_EFFECTS[effectId];
|
||||
if (!effectDef) return;
|
||||
|
||||
if (existing) {
|
||||
if (existing.stacks < effectDef.maxStacks) {
|
||||
setSelectedEffects(selectedEffects.map(e =>
|
||||
e.effectId === effectId
|
||||
? { ...e, stacks: e.stacks + 1 }
|
||||
: e
|
||||
));
|
||||
}
|
||||
} else {
|
||||
setSelectedEffects([...selectedEffects, {
|
||||
effectId,
|
||||
stacks: 1,
|
||||
capacityCost: calculateEffectCapacityCost(effectId, 1, efficiencyBonus),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove effect from design
|
||||
*/
|
||||
export function removeEffectFromDesign(
|
||||
effectId: string,
|
||||
selectedEffects: DesignEffect[],
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void
|
||||
) {
|
||||
const existing = selectedEffects.find(e => e.effectId === effectId);
|
||||
if (!existing) return;
|
||||
|
||||
if (existing.stacks > 1) {
|
||||
setSelectedEffects(selectedEffects.map(e =>
|
||||
e.effectId === effectId
|
||||
? { ...e, stacks: e.stacks - 1 }
|
||||
: e
|
||||
));
|
||||
} else {
|
||||
setSelectedEffects(selectedEffects.filter(e => e.effectId !== effectId));
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useState } from 'react';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { StatRow } from '@/components/ui/stat-row';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Trash2, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import type { EquipmentInstance, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||
import { fmt, type GameStore } from '@/lib/game/store';
|
||||
|
||||
// Slot display names
|
||||
const SLOT_NAMES: Record<EquipmentSlot, string> = {
|
||||
mainHand: 'Main Hand',
|
||||
offHand: 'Off Hand',
|
||||
head: 'Head',
|
||||
body: 'Body',
|
||||
hands: 'Hands',
|
||||
feet: 'Feet',
|
||||
accessory1: 'Accessory 1',
|
||||
accessory2: 'Accessory 2',
|
||||
};
|
||||
import type { EquipmentSlot } from '@/lib/game/types';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { useGameStore, useCraftingStore, useManaStore } from '@/lib/game/stores';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
|
||||
export interface EnchantmentPreparerProps {
|
||||
store: GameStore;
|
||||
selectedEquipmentInstance: string | null;
|
||||
setSelectedEquipmentInstance: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export function EnchantmentPreparer({
|
||||
store,
|
||||
selectedEquipmentInstance,
|
||||
setSelectedEquipmentInstance,
|
||||
}: EnchantmentPreparerProps) {
|
||||
const equippedInstances = store.equippedInstances;
|
||||
const equipmentInstances = store.equipmentInstances;
|
||||
const preparationProgress = store.preparationProgress;
|
||||
const rawMana = store.rawMana;
|
||||
const skills = store.skills;
|
||||
const startPreparing = store.startPreparing;
|
||||
const cancelPreparation = store.cancelPreparation;
|
||||
const disenchantEquipment = store.disenchantEquipment;
|
||||
const showToast = useGameToast();
|
||||
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||
const preparationProgress = useCraftingStore((s) => s.preparationProgress);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const startPreparing = useCraftingStore((s) => s.startPreparing);
|
||||
const cancelPreparation = useCraftingStore((s) => s.cancelPreparation);
|
||||
|
||||
// Get equipped items as array
|
||||
const equippedItems = Object.entries(equippedInstances)
|
||||
@@ -50,155 +42,263 @@ export function EnchantmentPreparer({
|
||||
instance: equipmentInstances[instanceId!],
|
||||
}));
|
||||
|
||||
// Confirm dialog state
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
const handleStartPreparation = () => {
|
||||
if (!selectedEquipmentInstance) return;
|
||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||
if (!instance) return;
|
||||
|
||||
// If item has existing enchantments, show confirm dialog (bug #8)
|
||||
if (instance.enchantments.length > 0) {
|
||||
setShowConfirmDialog(true);
|
||||
} else {
|
||||
startPreparingWithToast(selectedEquipmentInstance);
|
||||
}
|
||||
};
|
||||
|
||||
const startPreparingWithToast = (instanceId: string) => {
|
||||
const instance = equipmentInstances[instanceId];
|
||||
startPreparing(instanceId);
|
||||
if (instance) {
|
||||
showToast('info', 'Preparation Started', `Preparing ${instance.name} for enchantment...`);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPreparation = () => {
|
||||
if (selectedEquipmentInstance) {
|
||||
startPreparingWithToast(selectedEquipmentInstance);
|
||||
setShowConfirmDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Equipment Selection */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">Select Equipment to Prepare or Disenchant</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{preparationProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Preparing: {equipmentInstances[preparationProgress.equipmentInstanceId]?.name}
|
||||
</div>
|
||||
<Progress value={(preparationProgress.progress / preparationProgress.required) * 100} className="h-3" />
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
|
||||
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={cancelPreparation}>Cancel</Button>
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="Select Equipment to Prepare" />
|
||||
{preparationProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Preparing: {equipmentInstances[preparationProgress.equipmentInstanceId]?.name}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2">
|
||||
{equippedItems.map(({ slot, instance }) => {
|
||||
const hasEnchantments = instance.enchantments.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={instance.instanceId}
|
||||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||
selectedEquipmentInstance === instance.instanceId
|
||||
? 'border-amber-500 bg-amber-900/20'
|
||||
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
|
||||
} ${hasEnchantments ? 'border-l-4 border-l-red-600' : ''}`}
|
||||
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">{instance.name}</div>
|
||||
<div className="text-xs text-gray-400">{SLOT_NAMES[slot]}</div>
|
||||
{hasEnchantments && (
|
||||
<div className="text-xs text-red-400 mt-1">
|
||||
⚠️ {instance.enchantments.length} enchantments - Disenchant to apply new
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div className="text-green-400">{instance.usedCapacity}/{instance.totalCapacity} cap</div>
|
||||
<div className="text-xs text-gray-400">{instance.enchantments.length} enchants</div>
|
||||
</div>
|
||||
<div className="h-3 bg-[var(--bg-sunken)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-warning)] transition-all duration-300"
|
||||
style={{ width: `${(preparationProgress.progress / preparationProgress.required) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
||||
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
|
||||
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
|
||||
</div>
|
||||
<ActionButton size="sm" variant="outline" onClick={() => {
|
||||
cancelPreparation();
|
||||
showToast('warning', 'Preparation Cancelled', 'Equipment preparation was cancelled.');
|
||||
}}>Cancel</ActionButton>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2">
|
||||
{equippedItems.map(({ slot, instance }) => {
|
||||
const hasEnchantments = instance.enchantments.length > 0;
|
||||
const isReady = instance.tags?.includes('Ready for Enchantment');
|
||||
return (
|
||||
<div
|
||||
key={instance.instanceId}
|
||||
className={`p-3 rounded border cursor-pointer transition-all
|
||||
${selectedEquipmentInstance === instance.instanceId
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}
|
||||
${hasEnchantments ? 'border-l-4 border-l-[var(--color-danger)]' : ''}
|
||||
${isReady ? 'border-l-4 border-l-[var(--color-success)]' : ''}
|
||||
`}
|
||||
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${instance.name}${hasEnchantments ? ' (has enchantments)' : ''}${isReady ? ' (ready for enchantment)' : ''}`}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text-primary)]">{instance.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{slot}</div>
|
||||
{hasEnchantments && (
|
||||
<div className="text-xs text-[var(--color-danger)] mt-1">
|
||||
<AlertTriangle size={12} className="inline mr-1" />
|
||||
{instance.enchantments.length} enchantments - Preparation will remove them
|
||||
</div>
|
||||
)}
|
||||
{isReady && (
|
||||
<div className="text-xs text-[var(--color-success)] mt-1">
|
||||
<CheckCircle size={12} className="inline mr-1" />
|
||||
Ready for Enchantment
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div className="text-[var(--color-success)]">{instance.usedCapacity}/{instance.totalCapacity} cap</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{instance.enchantments.length} enchants</div>
|
||||
{/* Requirement: Visual badge for 'Ready for Enchantment' */}
|
||||
{isReady && (
|
||||
<Badge className="mt-1 bg-[var(--color-success)]/20 text-[var(--color-success)] border-[var(--color-success)]/40">
|
||||
<CheckCircle size={10} className="mr-1" />
|
||||
Ready
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{equippedItems.length === 0 && (
|
||||
<div className="text-center text-gray-400 py-4">No equipped items</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{equippedItems.length === 0 && (
|
||||
<div className="text-center text-[var(--text-muted)] py-4">No equipped items</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</GameCard>
|
||||
|
||||
{/* Preparation Details */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">Preparation Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedEquipmentInstance ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Select equipment to prepare or disenchant
|
||||
</div>
|
||||
) : preparationProgress ? (
|
||||
<div className="text-gray-400">Preparation in progress...</div>
|
||||
) : (
|
||||
(() => {
|
||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||
const hasEnchantments = instance.enchantments.length > 0;
|
||||
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
|
||||
const manaCost = instance.totalCapacity * 10;
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="Preparation Details" />
|
||||
{!selectedEquipmentInstance ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
Select equipment to prepare
|
||||
</div>
|
||||
) : preparationProgress ? (
|
||||
<div className="text-[var(--text-secondary)]">Preparation in progress...</div>
|
||||
) : (
|
||||
(() => {
|
||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||
if (!instance) return null;
|
||||
const hasEnchantments = instance.enchantments.length > 0;
|
||||
const isReady = instance.tags?.includes('Ready for Enchantment');
|
||||
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
|
||||
const manaCost = instance.totalCapacity * 10;
|
||||
|
||||
// Calculate disenchant recovery
|
||||
const disenchantLevel = skills.disenchanting || 0;
|
||||
const recoveryRate = 0.1 + disenchantLevel * 0.2;
|
||||
const totalRecoverable = instance.enchantments.reduce(
|
||||
(sum, e) => sum + Math.floor(e.actualCost * recoveryRate),
|
||||
0
|
||||
);
|
||||
// Calculate disenchant recovery
|
||||
const recoveryRate = 0.1; // Base recovery rate
|
||||
const totalRecoverable = instance.enchantments.reduce(
|
||||
(sum, e) => sum + Math.floor(e.actualCost * recoveryRate),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-semibold">{instance.name}</div>
|
||||
<Separator className="bg-gray-700" />
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-semibold text-[var(--text-primary)]">{instance.name}</div>
|
||||
<Separator className="bg-[var(--border-subtle)]" />
|
||||
|
||||
{/* Disenchant option for enchanted gear */}
|
||||
{hasEnchantments && (
|
||||
<div className="p-3 rounded border border-red-600/50 bg-red-900/20 space-y-3">
|
||||
<div className="text-sm font-semibold text-red-400">⚠️ Equipment has enchantments</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
You must disenchant before applying new enchantments.
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Recoverable Mana:</span>
|
||||
<span className="text-green-400">{fmt(totalRecoverable)}</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full bg-red-600 hover:bg-red-700"
|
||||
onClick={() => disenchantEquipment(instance.instanceId)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Disenchant & Recover {fmt(totalRecoverable)} Mana
|
||||
</Button>
|
||||
{/* Show warning if item has enchantments - Requirement: button reads "Prepare — removes existing enchantments" */}
|
||||
{hasEnchantments && !isReady && (
|
||||
<div className="p-3 rounded border border-[var(--color-danger)]/50 bg-[var(--color-danger)]/10">
|
||||
<div className="text-sm font-semibold text-[var(--color-danger)]">
|
||||
<AlertTriangle size={14} className="inline mr-1" />
|
||||
Equipment has enchantments
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">
|
||||
Preparation will remove all existing enchantments and recover some mana.
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="text-[var(--text-muted)]">Recoverable Mana:</span>
|
||||
<span className="text-[var(--color-success)]">{fmt(totalRecoverable)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prepare option for non-enchanted gear */}
|
||||
{!hasEnchantments && (
|
||||
<>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Capacity:</span>
|
||||
<span>{instance.usedCapacity}/{instance.totalCapacity}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Prep Time:</span>
|
||||
<span>{prepTime}h</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Mana Cost:</span>
|
||||
<span className={rawMana < manaCost ? 'text-red-400' : 'text-green-400'}>
|
||||
{fmt(manaCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={rawMana < manaCost}
|
||||
onClick={() => startPreparing(selectedEquipmentInstance)}
|
||||
>
|
||||
Start Preparation ({prepTime}h, {fmt(manaCost)} mana)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Show ready status */}
|
||||
{isReady && (
|
||||
<div className="p-3 rounded border border-[var(--color-success)]/50 bg-[var(--color-success)]/10">
|
||||
<div className="text-sm font-semibold text-[var(--color-success)]">
|
||||
<CheckCircle size={14} className="inline mr-1" />
|
||||
Ready for Enchantment
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">
|
||||
This item has been prepared and is ready for enchantment application.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<StatRow
|
||||
label="Capacity:"
|
||||
value={`${instance.usedCapacity}/${instance.totalCapacity}`}
|
||||
highlight="default"
|
||||
/>
|
||||
<StatRow
|
||||
label="Prep Time:"
|
||||
value={`${prepTime}h`}
|
||||
highlight="default"
|
||||
/>
|
||||
<StatRow
|
||||
label="Mana Cost:"
|
||||
value={
|
||||
<span className={rawMana < manaCost ? 'text-[var(--color-danger)]' : 'text-[var(--color-success)]'}>
|
||||
{fmt(manaCost)}
|
||||
</span>
|
||||
}
|
||||
highlight={rawMana < manaCost ? 'danger' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Requirement (bug #8): Confirm dialog before proceeding if item has enchantments */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
disabled={rawMana < manaCost || isReady}
|
||||
onClick={handleStartPreparation}
|
||||
>
|
||||
{hasEnchantments ? (
|
||||
<>
|
||||
<Trash2 size={16} className="mr-2" />
|
||||
Prepare — removes existing enchantments ({prepTime}h, {fmt(manaCost)} mana)
|
||||
</>
|
||||
) : (
|
||||
<>Start Preparation ({prepTime}h, {fmt(manaCost)} mana)</>
|
||||
)}
|
||||
</ActionButton>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-[var(--color-danger)]">
|
||||
<AlertTriangle className="inline mr-2" size={18} />
|
||||
Confirm Preparation
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[var(--text-secondary)]">
|
||||
This equipment has {instance.enchantments.length} existing enchantment(s). Preparation will
|
||||
<strong className="text-[var(--color-danger)]"> permanently remove</strong> all existing enchantments
|
||||
and recover approximately <strong className="text-[var(--color-success)]">{fmt(totalRecoverable)} mana</strong>.
|
||||
<div className="mt-2 p-2 bg-[var(--bg-sunken)]/50 rounded text-xs">
|
||||
Equipment: {instance.name}<br />
|
||||
Enchantments to remove: {instance.enchantments.length}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-[var(--color-danger)] hover:bg-[var(--interactive-danger-hover)] text-white"
|
||||
onClick={confirmPreparation}
|
||||
>
|
||||
Yes, Remove Enchantments & Prepare
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</GameCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EnchantmentPreparer.displayName = 'EnchantmentPreparer';
|
||||
|
||||