Compare commits

..

2 Commits

Author SHA1 Message Date
Z User a0595e6077 Fix mana conversion visibility and UI improvements
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m9s
- Increase attunement mana conversion rates (0.2 -> 2 for Enchanter)
- Hide mana types with current < 1 in ManaDisplay and LabTab
- Only show owned equipment types when designing enchantments
2026-03-28 06:15:14 +00:00
Z User 9566f44652 Initial commit 2026-03-28 06:07:39 +00:00
438 changed files with 35389 additions and 52062 deletions
Executable
View File
Executable
+128
View File
@@ -0,0 +1,128 @@
{
"Meta": {
"Strict": true,
"Retries": 10,
"MaxDeletes": 10,
"SkipDirNlink": 20,
"CaseInsensi": false,
"ReadOnly": false,
"NoBGJob": true,
"OpenCache": 0,
"OpenCacheLimit": 10000,
"Heartbeat": 12000000000,
"MountPoint": "/tmp/storage/containers/rundjuicefs-1bea976b-453e-42c1-b51b-ec164a4af3ae-my-project",
"Subdir": "/0954660f-fdaf-430e-9c08-43d856f4b183/chat-97147419-5634-40fa-8c67-d722ea396734/my-project",
"AtimeMode": "noatime",
"DirStatFlushPeriod": 1000000000,
"SkipDirMtime": 100000000,
"Sid": 4581116,
"SortDir": false,
"FastStatfs": false,
"TTLCleanupInterval": 1800000000000
},
"Format": {
"Name": "pcs-ue6ju0nuiu0hz7tjc-0e3odv6t4dackr8s3",
"UUID": "ad4b5b55-9406-4e74-b5e1-5422c94dd1fa",
"Storage": "oss",
"Bucket": "https://pcs-ue6ju0nuiu0hz7tjc-0e3odv6t4dackr8s3.oss-cn-hongkong-internal.aliyuncs.com",
"AccessKey": "STS.NZZnKQECyeGHHhqZ2WozyMzan",
"SecretKey": "removed",
"SessionToken": "removed",
"BlockSize": 4096,
"Compression": "none",
"HashPrefix": true,
"EncryptAlgo": "aes256gcm-rsa",
"TrashDays": 0,
"MetaVersion": 1,
"MinClientVersion": "1.1.0-A",
"DirStats": true,
"EnableACL": false,
"Consul": "21.0.14.104:8500",
"CustomLabels": "cluster:pfs-j6cm9t56111f4x38;uid:1936221977589032",
"PushGateway": "http://cn-hongkong-intranet.arms.aliyuncs.com/prometheus/322760eec05a83d258d354fca51498ab/1047553595254976/tiwz7q7d94/cn-hongkong/api/v2"
},
"Chunk": {
"CacheDir": "/var/jfsCache/ad4b5b55-9406-4e74-b5e1-5422c94dd1fa",
"CacheMode": 384,
"CacheSize": 107374182400,
"CacheItems": 0,
"CacheChecksum": "extend",
"CacheEviction": "2-random",
"CacheScanInterval": 3600000000000,
"CacheExpire": 0,
"OSCache": true,
"FreeSpace": 0.1,
"AutoCreate": true,
"Compress": "none",
"MaxUpload": 20,
"MaxStageWrite": 1000,
"MaxRetries": 10,
"UploadLimit": 0,
"DownloadLimit": 0,
"Writeback": false,
"UploadDelay": 0,
"UploadHours": "",
"HashPrefix": true,
"BlockSize": 4194304,
"GetTimeout": 60000000000,
"PutTimeout": 60000000000,
"CacheFullBlock": true,
"CacheLargeWrite": false,
"BufferSize": 314572800,
"Readahead": 33554432,
"Prefetch": 1
},
"Security": {
"EnableCap": false,
"EnableSELinux": false
},
"Port": {},
"Version": "1.3.0+2025-11-13.7d12dfcb",
"AttrTimeout": 1000000000,
"DirEntryTimeout": 1000000000,
"NegEntryTimeout": 0,
"EntryTimeout": 1000000000,
"ReaddirCache": false,
"BackupMeta": 3600000000000,
"BackupSkipTrash": false,
"PrefixInternal": false,
"HideInternal": false,
"AllSquash": {
"Uid": 1001,
"Gid": 1001
},
"NonDefaultPermission": true,
"UMask": 0,
"Pid": 224,
"PPid": 214,
"CommPath": "/tmp/fuse_fd_comm.214",
"StatePath": "/tmp/state214.json",
"FuseOpts": {
"AllowOther": true,
"Options": [
"nonempty",
"default_permissions"
],
"MaxBackground": 200,
"MaxWrite": 0,
"MaxReadAhead": 1048576,
"IgnoreSecurityLabels": false,
"RememberInodes": false,
"FsName": "JuiceFS:pcs-ue6ju0nuiu0hz7tjc-0e3odv6t4dackr8s3",
"Name": "juicefs",
"SingleThreaded": false,
"DisableXAttrs": true,
"Debug": false,
"EnableLocks": true,
"EnableSymlinkCaching": true,
"ExplicitDataCacheControl": false,
"DirectMount": true,
"DirectMountFlags": 0,
"EnableAcl": false,
"EnableWriteback": false,
"DontUmask": true,
"OtherCaps": 0,
"NoAllocForRead": false,
"Timeout": 900000000000
}
}
+1 -1
View File
@@ -48,4 +48,4 @@ prompt
server.log server.log
# Skills directory # Skills directory
.desloppify/ /skills/
-16
View File
@@ -1,16 +0,0 @@
#!/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
-27
View File
@@ -1,27 +0,0 @@
#!/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!"
-63
View File
@@ -1,63 +0,0 @@
/* 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);
}
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-require-imports */
/**
* 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 format:
// "Found N circular dependencies!" (summary)
// "1) fileA > fileB > fileC" (chain lines start with number + ')')
// "Processed N files ..." (info line to ignore)
// "✔ No circular dependency found!" (clean result)
const circularLines = lines.filter(
(l) => /^\d+\)/.test(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`);
}
-117
View File
@@ -1,117 +0,0 @@
/* 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);
}
Executable
View File
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
# 将 stderr 重定向到 stdout,避免 execute_command 因为 stderr 输出而报错
exec 2>&1
set -e
# 获取脚本所在目录(.zscripts 目录,即 workspace-agent/.zscripts
# 使用 $0 获取脚本路径(兼容 sh 和 bash)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Next.js 项目路径
NEXTJS_PROJECT_DIR="/home/z/my-project"
# 检查 Next.js 项目目录是否存在
if [ ! -d "$NEXTJS_PROJECT_DIR" ]; then
echo "❌ 错误: Next.js 项目目录不存在: $NEXTJS_PROJECT_DIR"
exit 1
fi
echo "🚀 开始构建 Next.js 应用和 mini-services..."
echo "📁 Next.js 项目路径: $NEXTJS_PROJECT_DIR"
# 切换到 Next.js 项目目录
cd "$NEXTJS_PROJECT_DIR" || exit 1
# 设置环境变量
export NEXT_TELEMETRY_DISABLED=1
BUILD_DIR="/tmp/build_fullstack_$BUILD_ID"
echo "📁 清理并创建构建目录: $BUILD_DIR"
mkdir -p "$BUILD_DIR"
# 安装依赖
echo "📦 安装依赖..."
bun install
# 构建 Next.js 应用
echo "🔨 构建 Next.js 应用..."
bun run build
# 构建 mini-services
# 检查 Next.js 项目目录下是否有 mini-services 目录
if [ -d "$NEXTJS_PROJECT_DIR/mini-services" ]; then
echo "🔨 构建 mini-services..."
# 使用 workspace-agent 目录下的 mini-services 脚本
sh "$SCRIPT_DIR/mini-services-install.sh"
sh "$SCRIPT_DIR/mini-services-build.sh"
# 复制 mini-services-start.sh 到 mini-services-dist 目录
echo " - 复制 mini-services-start.sh 到 $BUILD_DIR"
cp "$SCRIPT_DIR/mini-services-start.sh" "$BUILD_DIR/mini-services-start.sh"
chmod +x "$BUILD_DIR/mini-services-start.sh"
else
echo "️ mini-services 目录不存在,跳过"
fi
# 将所有构建产物复制到临时构建目录
echo "📦 收集构建产物到 $BUILD_DIR..."
# 复制 Next.js standalone 构建输出
if [ -d ".next/standalone" ]; then
echo " - 复制 .next/standalone"
cp -r .next/standalone "$BUILD_DIR/next-service-dist/"
fi
# 复制 Next.js 静态文件
if [ -d ".next/static" ]; then
echo " - 复制 .next/static"
mkdir -p "$BUILD_DIR/next-service-dist/.next"
cp -r .next/static "$BUILD_DIR/next-service-dist/.next/"
fi
# 复制 public 目录
if [ -d "public" ]; then
echo " - 复制 public"
cp -r public "$BUILD_DIR/next-service-dist/"
fi
# 最后再迁移数据库到 BUILD_DIR/db
if [ "$(ls -A ./db 2>/dev/null)" ]; then
echo "🗄️ 检测到数据库文件,运行数据库迁移..."
DATABASE_URL=file:$BUILD_DIR/db/custom.db bun run db:push
echo "✅ 数据库迁移完成"
ls -lah $BUILD_DIR/db
else
echo "ℹ️ db 目录为空,跳过数据库迁移"
fi
# 复制 Caddyfile(如果存在)
if [ -f "Caddyfile" ]; then
echo " - 复制 Caddyfile"
cp Caddyfile "$BUILD_DIR/"
else
echo "️ Caddyfile 不存在,跳过"
fi
# 复制 start.sh 脚本
echo " - 复制 start.sh 到 $BUILD_DIR"
cp "$SCRIPT_DIR/start.sh" "$BUILD_DIR/start.sh"
chmod +x "$BUILD_DIR/start.sh"
# 打包到 $BUILD_DIR.tar.gz
PACKAGE_FILE="${BUILD_DIR}.tar.gz"
echo ""
echo "📦 打包构建产物到 $PACKAGE_FILE..."
cd "$BUILD_DIR" || exit 1
tar -czf "$PACKAGE_FILE" .
cd - > /dev/null || exit 1
# # 清理临时目录
# rm -rf "$BUILD_DIR"
echo ""
echo "✅ 构建完成!所有产物已打包到 $PACKAGE_FILE"
echo "📊 打包文件大小:"
ls -lh "$PACKAGE_FILE"
+1247
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
553
+154
View File
@@ -0,0 +1,154 @@
#!/bin/bash
set -euo pipefail
# 获取脚本所在目录(.zscripts)
# 使用 $0 获取脚本路径(与 build.sh 保持一致)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
log_step_start() {
local step_name="$1"
echo "=========================================="
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting: $step_name"
echo "=========================================="
export STEP_START_TIME
STEP_START_TIME=$(date +%s)
}
log_step_end() {
local step_name="${1:-Unknown step}"
local end_time
end_time=$(date +%s)
local duration=$((end_time - STEP_START_TIME))
echo "=========================================="
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Completed: $step_name"
echo "[LOG] Step: $step_name | Duration: ${duration}s"
echo "=========================================="
echo ""
}
start_mini_services() {
local mini_services_dir="$PROJECT_DIR/mini-services"
local started_count=0
log_step_start "Starting mini-services"
if [ ! -d "$mini_services_dir" ]; then
echo "Mini-services directory not found, skipping..."
log_step_end "Starting mini-services"
return 0
fi
echo "Found mini-services directory, scanning for sub-services..."
for service_dir in "$mini_services_dir"/*; do
if [ ! -d "$service_dir" ]; then
continue
fi
local service_name
service_name=$(basename "$service_dir")
echo "Checking service: $service_name"
if [ ! -f "$service_dir/package.json" ]; then
echo "[$service_name] No package.json found, skipping..."
continue
fi
if ! grep -q '"dev"' "$service_dir/package.json"; then
echo "[$service_name] No dev script found, skipping..."
continue
fi
echo "Starting $service_name in background..."
(
cd "$service_dir"
echo "[$service_name] Installing dependencies..."
bun install
echo "[$service_name] Running bun run dev..."
exec bun run dev
) >"$PROJECT_DIR/.zscripts/mini-service-${service_name}.log" 2>&1 &
local service_pid=$!
echo "[$service_name] Started in background (PID: $service_pid)"
echo "[$service_name] Log: $PROJECT_DIR/.zscripts/mini-service-${service_name}.log"
disown "$service_pid" 2>/dev/null || true
started_count=$((started_count + 1))
done
echo "Mini-services startup completed. Started $started_count service(s)."
log_step_end "Starting mini-services"
}
wait_for_service() {
local host="$1"
local port="$2"
local service_name="$3"
local max_attempts="${4:-60}"
local attempt=1
echo "Waiting for $service_name to be ready on $host:$port..."
while [ "$attempt" -le "$max_attempts" ]; do
if curl -s --connect-timeout 2 --max-time 5 "http://$host:$port" >/dev/null 2>&1; then
echo "$service_name is ready!"
return 0
fi
echo "Attempt $attempt/$max_attempts: $service_name not ready yet, waiting..."
sleep 1
attempt=$((attempt + 1))
done
echo "ERROR: $service_name failed to start within $max_attempts seconds"
return 1
}
cleanup() {
if [ -n "${DEV_PID:-}" ] && kill -0 "$DEV_PID" >/dev/null 2>&1; then
echo "Stopping Next.js dev server (PID: $DEV_PID)..."
kill "$DEV_PID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT INT TERM
cd "$PROJECT_DIR"
if ! command -v bun >/dev/null 2>&1; then
echo "ERROR: bun is not installed or not in PATH"
exit 1
fi
log_step_start "bun install"
echo "[BUN] Installing dependencies..."
bun install
log_step_end "bun install"
log_step_start "bun run db:push"
echo "[BUN] Setting up database..."
bun run db:push
log_step_end "bun run db:push"
log_step_start "Starting Next.js dev server"
echo "[BUN] Starting development server..."
bun run dev &
DEV_PID=$!
log_step_end "Starting Next.js dev server"
log_step_start "Waiting for Next.js dev server"
wait_for_service "localhost" "3000" "Next.js dev server"
log_step_end "Waiting for Next.js dev server"
log_step_start "Health check"
echo "[BUN] Performing health check..."
curl -fsS localhost:3000 >/dev/null
echo "[BUN] Health check passed"
log_step_end "Health check"
start_mini_services
echo "Next.js dev server is running in background (PID: $DEV_PID)."
echo "Use 'kill $DEV_PID' to stop it."
disown "$DEV_PID" 2>/dev/null || true
unset DEV_PID
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# 配置项
ROOT_DIR="/home/z/my-project/mini-services"
DIST_DIR="/tmp/build_fullstack_$BUILD_ID/mini-services-dist"
main() {
echo "🚀 开始批量构建..."
# 检查 rootdir 是否存在
if [ ! -d "$ROOT_DIR" ]; then
echo "️ 目录 $ROOT_DIR 不存在,跳过构建"
return
fi
# 创建输出目录(如果不存在)
mkdir -p "$DIST_DIR"
# 统计变量
success_count=0
fail_count=0
# 遍历 mini-services 目录下的所有文件夹
for dir in "$ROOT_DIR"/*; do
# 检查是否是目录且包含 package.json
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
project_name=$(basename "$dir")
# 智能查找入口文件 (按优先级查找)
entry_path=""
for entry in "src/index.ts" "index.ts" "src/index.js" "index.js"; do
if [ -f "$dir/$entry" ]; then
entry_path="$dir/$entry"
break
fi
done
if [ -z "$entry_path" ]; then
echo "⚠️ 跳过 $project_name: 未找到入口文件 (index.ts/js)"
continue
fi
echo ""
echo "📦 正在构建: $project_name..."
# 使用 bun build CLI 构建
output_file="$DIST_DIR/mini-service-$project_name.js"
if bun build "$entry_path" \
--outfile "$output_file" \
--target bun \
--minify; then
echo "$project_name 构建成功 -> $output_file"
success_count=$((success_count + 1))
else
echo "$project_name 构建失败"
fail_count=$((fail_count + 1))
fi
fi
done
if [ -f ./.zscripts/mini-services-start.sh ]; then
cp ./.zscripts/mini-services-start.sh "$DIST_DIR/mini-services-start.sh"
chmod +x "$DIST_DIR/mini-services-start.sh"
fi
echo ""
echo "🎉 所有任务完成!"
if [ $success_count -gt 0 ] || [ $fail_count -gt 0 ]; then
echo "✅ 成功: $success_count"
if [ $fail_count -gt 0 ]; then
echo "❌ 失败: $fail_count"
fi
fi
}
main
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# 配置项
ROOT_DIR="/home/z/my-project/mini-services"
main() {
echo "🚀 开始批量安装依赖..."
# 检查 rootdir 是否存在
if [ ! -d "$ROOT_DIR" ]; then
echo "️ 目录 $ROOT_DIR 不存在,跳过安装"
return
fi
# 统计变量
success_count=0
fail_count=0
failed_projects=""
# 遍历 mini-services 目录下的所有文件夹
for dir in "$ROOT_DIR"/*; do
# 检查是否是目录且包含 package.json
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
project_name=$(basename "$dir")
echo ""
echo "📦 正在安装依赖: $project_name..."
# 进入项目目录并执行 bun install
if (cd "$dir" && bun install); then
echo "$project_name 依赖安装成功"
success_count=$((success_count + 1))
else
echo "$project_name 依赖安装失败"
fail_count=$((fail_count + 1))
if [ -z "$failed_projects" ]; then
failed_projects="$project_name"
else
failed_projects="$failed_projects $project_name"
fi
fi
fi
done
# 汇总结果
echo ""
echo "=================================================="
if [ $success_count -gt 0 ] || [ $fail_count -gt 0 ]; then
echo "🎉 安装完成!"
echo "✅ 成功: $success_count"
if [ $fail_count -gt 0 ]; then
echo "❌ 失败: $fail_count"
echo ""
echo "失败的项目:"
for project in $failed_projects; do
echo " - $project"
done
fi
else
echo "️ 未找到任何包含 package.json 的项目"
fi
echo "=================================================="
}
main
+123
View File
@@ -0,0 +1,123 @@
#!/bin/sh
# 配置项
DIST_DIR="./mini-services-dist"
# 存储所有子进程的 PID
pids=""
# 清理函数:优雅关闭所有服务
cleanup() {
echo ""
echo "🛑 正在关闭所有服务..."
# 发送 SIGTERM 信号给所有子进程
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
service_name=$(ps -p "$pid" -o comm= 2>/dev/null || echo "unknown")
echo " 关闭进程 $pid ($service_name)..."
kill -TERM "$pid" 2>/dev/null
fi
done
# 等待所有进程退出(最多等待 5 秒)
sleep 1
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
# 如果还在运行,等待最多 4 秒
timeout=4
while [ $timeout -gt 0 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1
timeout=$((timeout - 1))
done
# 如果仍然在运行,强制关闭
if kill -0 "$pid" 2>/dev/null; then
echo " 强制关闭进程 $pid..."
kill -KILL "$pid" 2>/dev/null
fi
fi
done
echo "✅ 所有服务已关闭"
}
main() {
echo "🚀 开始启动所有 mini services..."
# 检查 dist 目录是否存在
if [ ! -d "$DIST_DIR" ]; then
echo "️ 目录 $DIST_DIR 不存在"
return
fi
# 查找所有 mini-service-*.js 文件
service_files=""
for file in "$DIST_DIR"/mini-service-*.js; do
if [ -f "$file" ]; then
if [ -z "$service_files" ]; then
service_files="$file"
else
service_files="$service_files $file"
fi
fi
done
# 计算服务文件数量
service_count=0
for file in $service_files; do
service_count=$((service_count + 1))
done
if [ $service_count -eq 0 ]; then
echo "️ 未找到任何 mini service 文件"
return
fi
echo "📦 找到 $service_count 个服务,开始启动..."
echo ""
# 启动每个服务
for file in $service_files; do
service_name=$(basename "$file" .js | sed 's/mini-service-//')
echo "▶️ 启动服务: $service_name..."
# 使用 bun 运行服务(后台运行)
bun "$file" &
pid=$!
if [ -z "$pids" ]; then
pids="$pid"
else
pids="$pids $pid"
fi
# 等待一小段时间检查进程是否成功启动
sleep 0.5
if ! kill -0 "$pid" 2>/dev/null; then
echo "$service_name 启动失败"
# 从字符串中移除失败的 PID
pids=$(echo "$pids" | sed "s/\b$pid\b//" | sed 's/ */ /g' | sed 's/^ *//' | sed 's/ *$//')
else
echo "$service_name 已启动 (PID: $pid)"
fi
done
# 计算运行中的服务数量
running_count=0
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
running_count=$((running_count + 1))
fi
done
echo ""
echo "🎉 所有服务已启动!共 $running_count 个服务正在运行"
echo ""
echo "💡 按 Ctrl+C 停止所有服务"
echo ""
# 等待所有后台进程
wait
}
main
+126
View File
@@ -0,0 +1,126 @@
#!/bin/sh
set -e
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$SCRIPT_DIR"
# 存储所有子进程的 PID
pids=""
# 清理函数:优雅关闭所有服务
cleanup() {
echo ""
echo "🛑 正在关闭所有服务..."
# 发送 SIGTERM 信号给所有子进程
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
service_name=$(ps -p "$pid" -o comm= 2>/dev/null || echo "unknown")
echo " 关闭进程 $pid ($service_name)..."
kill -TERM "$pid" 2>/dev/null
fi
done
# 等待所有进程退出(最多等待 5 秒)
sleep 1
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
# 如果还在运行,等待最多 4 秒
timeout=4
while [ $timeout -gt 0 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1
timeout=$((timeout - 1))
done
# 如果仍然在运行,强制关闭
if kill -0 "$pid" 2>/dev/null; then
echo " 强制关闭进程 $pid..."
kill -KILL "$pid" 2>/dev/null
fi
fi
done
echo "✅ 所有服务已关闭"
exit 0
}
echo "🚀 开始启动所有服务..."
echo ""
# 切换到构建目录
cd "$BUILD_DIR" || exit 1
ls -lah
# 初始化数据库(如果存在)
if [ -d "./next-service-dist/db" ] && [ "$(ls -A ./next-service-dist/db 2>/dev/null)" ] && [ -d "/db" ]; then
echo "🗄️ 初始化数据库从 ./next-service-dist/db 到 /db..."
cp -r ./next-service-dist/db/* /db/ 2>/dev/null || echo " ⚠️ 无法复制到 /db,跳过数据库初始化"
echo "✅ 数据库初始化完成"
fi
# 启动 Next.js 服务器
if [ -f "./next-service-dist/server.js" ]; then
echo "🚀 启动 Next.js 服务器..."
cd next-service-dist/ || exit 1
# 设置环境变量
export NODE_ENV=production
export PORT=${PORT:-3000}
export HOSTNAME=${HOSTNAME:-0.0.0.0}
# 后台启动 Next.js
bun server.js &
NEXT_PID=$!
pids="$NEXT_PID"
# 等待一小段时间检查进程是否成功启动
sleep 1
if ! kill -0 "$NEXT_PID" 2>/dev/null; then
echo "❌ Next.js 服务器启动失败"
exit 1
else
echo "✅ Next.js 服务器已启动 (PID: $NEXT_PID, Port: $PORT)"
fi
cd ../
else
echo "⚠️ 未找到 Next.js 服务器文件: ./next-service-dist/server.js"
fi
# 启动 mini-services
if [ -f "./mini-services-start.sh" ]; then
echo "🚀 启动 mini-services..."
# 运行启动脚本(从根目录运行,脚本内部会处理 mini-services-dist 目录)
sh ./mini-services-start.sh &
MINI_PID=$!
pids="$pids $MINI_PID"
# 等待一小段时间检查进程是否成功启动
sleep 1
if ! kill -0 "$MINI_PID" 2>/dev/null; then
echo "⚠️ mini-services 可能启动失败,但继续运行..."
else
echo "✅ mini-services 已启动 (PID: $MINI_PID)"
fi
elif [ -d "./mini-services-dist" ]; then
echo "⚠️ 未找到 mini-services 启动脚本,但目录存在"
else
echo "️ mini-services 目录不存在,跳过"
fi
# 启动 Caddy(如果存在 Caddyfile
echo "🚀 启动 Caddy..."
# Caddy 作为前台进程运行(主进程)
echo "✅ Caddy 已启动(前台运行)"
echo ""
echo "🎉 所有服务已启动!"
echo ""
echo "💡 按 Ctrl+C 停止所有服务"
echo ""
# Caddy 作为主进程运行
exec caddy run --config Caddyfile --adapter caddyfile
+332 -132
View File
@@ -1,167 +1,367 @@
# Mana Loop — Agent Guide # Mana Loop - Project Architecture Guide
Browser incremental/idle game. Next.js 16 + Zustand, no backend, localStorage persistence. This document provides a comprehensive overview of the project architecture for AI agents working on this codebase.
## 🔑 Git ---
## 🔑 Git Credentials (SAVE THESE)
**Repository:** `git@gitea.tailf367e3.ts.net:Anexim/Mana-Loop.git`
**HTTPS URL with credentials:**
``` ```
https://n8n-gitea:tkF9HFgxL2k4cmT@gitea.tailf367e3.ts.net/Anexim/Mana-Loop.git https://zhipu:5LlnutmdsC2WirDwWgnZuRH7@gitea.tailf367e3.ts.net/Anexim/Mana-Loop.git
``` ```
**Credentials:**
- **User:** zhipu
- **Email:** zhipu@local.local
- **Password:** 5LlnutmdsC2WirDwWgnZuRH7
**To configure git:**
```bash ```bash
git config --global user.name "n8n-gitea" git config --global user.name "zhipu"
git config --global user.email "n8n-gitea@anexim.local" git config --global user.email "zhipu@local.local"
``` ```
## Workflow ---
```bash ## ⚠️ MANDATORY GIT WORKFLOW - MUST BE FOLLOWED
cd /home/user/repos/Mana-Loop && git pull origin master
# ... work ... **Before starting ANY work, you MUST:**
git add -A && git commit -m "type: desc" && git push origin master
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)
``` ```
## Session Start ## Key Systems
1. `docs/project-structure.txt` ### 1. State Management (`store.ts`)
2. `docs/dependency-graph.json`
3. `gitea_get_project_boards` → resume in-progress or pick top todo
4. `gitea_update_issue_status``ai_state: "in-progress"`
5. Work, log with `gitea_add_comment`, then `gitea_update_issue_status``ai_state: "done"`
## Labels The game uses a Zustand store organized with **slice pattern** for better maintainability:
`ai_state: todo` | `in_state: in-progress` | `ai_state: review` | `ai_state: blocked` | `ai_state: done` #### 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)
## Terminal Tool #### Computed Stats (`computed-stats.ts`)
Extracted utility functions for stat calculations:
- `computeMaxMana()`, `computeRegen()`, `computeEffectiveRegen()`
- `calcDamage()`, `calcInsight()`, `getElementalBonus()`
- `getFloorMaxHP()`, `getFloorElement()`, `getMeditationBonus()`
- `canAffordSpellCost()`, `deductSpellCost()`
Always pair `run_command``get_process_status` in same turn. Use `wait: 120` for long tasks. ```typescript
interface GameState {
## Sub-Agents // Time
day: number;
Use for 3+ sequential independent calls. Zero context from parent — paste everything needed. hour: number;
paused: boolean;
## Architecture
// Mana
- **Stack:** Next.js 16, TS 5, Tailwind 4 + shadcn/ui, Zustand+persist, Vitest, Bun rawMana: number;
- **No backend:** Pure client-side. No Prisma, no database. State persisted to localStorage. elements: Record<string, ElementState>;
- **Active stores (7 Zustand stores):**
- `useGameStore` — Coordinator/tick pipeline, imports all other stores // Combat
- `useManaStore` — Mana pools, regen, element conversion currentFloor: number;
- `useCombatStore` — Spire/floors, combat, spells, achievements floorHP: number;
- `useCraftingStore` — Enchanting (Design/Prepare/Apply), equipment instances, loot activeSpell: string;
- `useAttunementStore` — Enchanter/Invoker/Fabricator attunement levels & XP castProgress: number;
- `usePrestigeStore` — Insight, prestige upgrades, pact persistence, loop state
- `useDisciplineStore` — Discipline activation, XP ticking, perk evaluation (slice) // Progression
- `useUIStore` — Logs, pause, game over/victory flags skills: Record<string, number>;
- **Legacy:** Fully migrated. No legacy `store.ts`, `store/`, or `store-modules/` directories remain. spells: Record<string, SpellState>;
skillUpgrades: Record<string, string[]>;
### Adding Effects skillTiers: Record<string, number>;
1. `data/enchantments/` — Add effect definition in the appropriate category file
2. `craftingStore.ts` → effects computation // Equipment
3. Equipment effects flow through `src/lib/game/effects.ts``getUnifiedEffects()` equipmentInstances: Record<string, EquipmentInstance>;
equippedInstances: Record<string, string | null>;
### Adding Disciplines enchantmentDesigns: EnchantmentDesign[];
1. Choose the correct data file under `data/disciplines/`:
- `base.ts` — Raw Mana Mastery (available to all) // Prestige
- `elemental.ts` — Elemental Attunement (7 base+ elements) insight: number;
- `elemental-regen.ts` — Elemental Regen (7 base + transference) prestigeUpgrades: Record<string, number>;
- `elemental-regen-advanced.ts` — Advanced Regen (3 composite + 3 exotic) signedPacts: number[];
- `enchanter.ts` — Core Enchanter disciplines (4 disciplines) }
- `enchanter-utility.ts` — Utility enchantment disciplines (2 disciplines)
- `enchanter-spells.ts` — Spell enchantment disciplines (3 disciplines)
- `enchanter-special.ts` — Special enchantment disciplines (1 discipline)
- `invoker.ts` — Invoker combat disciplines (2 disciplines)
- `fabricator.ts` — Fabricator crafting/golem disciplines (2 disciplines)
2. Define a `DisciplineDefinition` (see `types/disciplines.ts`):
- `statBonus.stat` must match a key consumed by `computeDisciplineEffects()`
- Set `difficultyFactor` and `scalingFactor` to control growth rate
- Add perks (`once`, `capped`, or `infinite`)
3. Re-export from `data/disciplines/index.ts` so it appears in `ALL_DISCIPLINES`
4. Add any new `statBonus.stat` keys to `discipline-effects.ts``computeDisciplineEffects()`
### Discipline Math (quick reference)
``` ```
StatBonus = baseValue × (XP / scalingFactor)^0.65
ManaDrainPerTick = drainBase × (1 + (XP / difficultyFactor)^0.4) ### 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
}
``` ```
- XP accrues every tick the discipline is active and mana drain is met
- `concurrentLimit` starts at 1 and expands by 1 per 500 total XP (max 4)
### Adding Spells **When adding new stats**:
1. `constants/spells-modules/` — Add to the appropriate category file 1. Add to `ComputedEffects` interface in `upgrade-effects.ts`
2. `data/enchantments/spell-effects/` — Add enchantment effect for the spell 2. Add mapping in `computeEquipmentEffects()` in `effects.ts`
3. Re-export from barrel files 3. Apply in the relevant game logic (tick, damage calc, etc.)
### Store Architecture (Key Files) ### 3. Combat System
- `stores/gameStore.ts` — Main coordinator, combines all stores, tick orchestration
- `stores/tick-pipeline.ts``buildTickContext()` / `applyTickWrites()` pattern
- `stores/combat-actions.ts` — Combat tick processing
- `stores/gameLoopActions.ts` — Climb/spire actions
- `stores/pipelines/[name].ts` — Individual pipeline phases
## Crafting System 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`
### Enchanting: 3-Step Flow — Design → Prepare → Apply Damage calculation order:
- **Design:** Select effects for a named design. Time: 1h + 0.5h per effect slot. Dual design slot with Enchant Mastery special. 1. Base spell damage
- **Prepare:** Clears existing enchantments, costs `capacity × 10` raw mana, time: `2h + 1h per 50 capacity`. ONLY stage where explicit disenchanting occurs. 2. Skill bonuses (combatTrain, arcaneFury, etc.)
- **Apply:** Applies saved design to prepared equipment. Time: `2h + stacks` hours. Mana: `20 + 5×stacks` per hour. 3. Upgrade effects (multipliers, bonuses)
4. Special effects (Overpower, Berserker, etc.)
5. Elemental modifiers (same element +25%, super effective +50%)
### Equipment ### 4. Crafting/Enchantment System
- 8 slots: mainHand, offHand, head, body, hands, feet, accessory1, accessory2
- 50 equipment types across 9 categories (casters, swords, shields, catalysts, head, body, hands, feet, accessories)
- Instance fields: `instanceId`, `typeId`, `name`, `enchantments[]`, `usedCapacity`, `totalCapacity`, `rarity`, `quality`
- Stacking cost: each additional stack costs 20% more
### Golemancy Three-stage process:
- 10 golems total: 1 base (Earth) + 3 elemental (Steel, Crystal, Sand) + 6 hybrid (Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone) 1. **Design**: Select effects, takes time based on complexity
- Golems slots: `floor(fabricatorLevel / 2)`, max 5 at level 10 2. **Prepare**: Pay mana to prepare equipment, takes time
- Hybrid golems require Enchanter 5 + Fabricator 5 3. **Apply**: Apply design to equipment, costs mana per hour
### Guardian System Equipment has **capacity** that limits total enchantment power.
- Guardians on every 10th floor
- **Base (floors 1080):** 7 base elements + Transference, static definitions with unique names
- **Compound (floors 90110):** Metal, Sand, Lightning — procedurally named
- **Exotic (floors 120140):** Crystal, Stellar, Void — procedurally named
- **Combination bosses (floor 150+):** Dual-element procedural guardians cycling through 9 element pairs, scaling indefinitely
- HP formula: `floor(5000 × (floor/10) ^ (1.1 + floor/200))`
- Pact signing: costs raw mana + time, grants permanent boons
### Combat ### 5. Skill Evolution System
- Cast-speed based: `castProgress += HOURS_PER_TICK × spellCastSpeed × attackSpeedMult`
- Elemental bonuses: super effective (1.5×), same element (1.25×), weak (0.75×), neutral (1.0×)
- Element opposites: fire↔water, air↔earth, light↔dark, lightning→earth
- Enemy modifiers (max 2 per enemy): Armored, Agile, Mage, Shield, Swarm
- Room types: Combat (default), Guardian (every 10th), Swarm (15%), Speed (10%), Puzzle (20% on every 7th floor)
- Floor HP: `100 + floor × 50 + floor^1.7` for non-guardian floors
### Time & Incursion Skills have 5 tiers of evolution:
- `TICK_MS`: 200ms, `HOURS_PER_TICK`: 0.04, `MAX_DAY`: 30 - At level 5: Choose 2 of 4 milestone upgrades
- Incursion starts day 5 (not day 20) - At level 10: Choose 2 more upgrades, then tier up
- Incursion strength: `min(0.95, (totalHours / maxHours) × 0.95)` - Each tier multiplies the skill's base effect by 10x
### Prestige (Insight) ## Important Patterns
- `baseInsight = floor(maxFloorReached × 15 + totalManaGathered / 500 + signedPacts.length × 150)`
- Multiplied by discipline and boon bonuses. ×3 for victory (floor 100 + pact signed)
- 14 prestige upgrade types: manaWell, manaFlow, insightAmp, spireKey, temporalEcho, steadyHand, ancientKnowledge, elementalAttune, spellMemory, guardianPact, quickStart, elemStart, unlockedManaTypeCapacity
- Signed pacts persist through prestige (bounded by `pactSlots`)
### Starting State ### Adding a New Effect
- Attunement: Enchanter only (level 1)
- Mana: Only Transference unlocked
- Equipment: Basic Staff with Mana Bolt enchantment (mainHand), Civilian Shirt (body), Civilian Shoes (feet)
- 1 discipline slot, 1 concurrent discipline
## Banned 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 }
}
```
Lifesteal/healing, scroll crafting, ascension skills, LabTab, pause mechanics, familiar system, shields, mana types: `life`, `blood`, `wood`, `mental`, `force` 2. **Add stat mapping in `effects.ts`** (if new stat):
```typescript
// In computeEquipmentEffects()
if (effect.stat === 'myNewStat') {
bonuses.myNewStat = (bonuses.myNewStat || 0) + effect.value;
}
```
## File Limit 3. **Apply in game logic**:
```typescript
const effects = getUnifiedEffects(state);
damage *= effects.myNewStatMultiplier;
```
400 lines max (pre-commit hook enforces). ### Adding a New Skill
## Mana Types 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`**
**Base (7):** Fire 🔥 Water 💧 Air 🌬️ Earth ⛰️ Light ☀️ Dark 🌑 Death 💀 ### Adding a New Spell
**Utility (1):** Transference 🔗
**Composite (3):** Fire+Earth=Metal ⚙️, Earth+Water=Sand ⏳, Fire+Air=Lightning ⚡ 1. **Define in `constants.ts` SPELLS_DEF**
**Exotic (3):** Sand+Sand+Light=Crystal 💎, Fire+Fire+Light=Stellar ⭐, Dark+Dark+Death=Void 🕳️ 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)
+313
View File
@@ -0,0 +1,313 @@
# Mana Loop - Codebase Audit Report
**Task ID:** 4
**Date:** Audit of unimplemented effects, upgrades, and missing functionality
---
## 1. Special Effects Status
### SPECIAL_EFFECTS Constant (upgrade-effects.ts)
The `SPECIAL_EFFECTS` constant defines 32 special effect IDs. Here's the implementation status:
| Effect ID | Name | Status | Notes |
|-----------|------|--------|-------|
| `MANA_CASCADE` | Mana Cascade | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but that function is NOT called from store.ts |
| `STEADY_STREAM` | Steady Stream | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but not called from tick |
| `MANA_TORRENT` | Mana Torrent | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but not called |
| `FLOW_SURGE` | Flow Surge | ❌ Missing | Not implemented anywhere |
| `MANA_EQUILIBRIUM` | Mana Equilibrium | ❌ Missing | Not implemented |
| `DESPERATE_WELLS` | Desperate Wells | ⚠️ Partially Implemented | Defined in `computeDynamicRegen()` but not called |
| `MANA_ECHO` | Mana Echo | ❌ Missing | Not implemented in gatherMana() |
| `EMERGENCY_RESERVE` | Emergency Reserve | ❌ Missing | Not implemented in startNewLoop() |
| `BATTLE_FURY` | Battle Fury | ⚠️ Partially Implemented | In `computeDynamicDamage()` but function not called |
| `ARMOR_PIERCE` | Armor Pierce | ❌ Missing | Floor defense not implemented |
| `OVERPOWER` | Overpower | ✅ Implemented | store.ts line 627 |
| `BERSERKER` | Berserker | ✅ Implemented | store.ts line 632 |
| `COMBO_MASTER` | Combo Master | ❌ Missing | Not implemented |
| `ADRENALINE_RUSH` | Adrenaline Rush | ❌ Missing | Not implemented on enemy defeat |
| `PERFECT_MEMORY` | Perfect Memory | ❌ Missing | Not implemented in cancel study |
| `QUICK_MASTERY` | Quick Mastery | ❌ Missing | Not implemented |
| `PARALLEL_STUDY` | Parallel Study | ⚠️ Partially Implemented | State exists but logic incomplete |
| `STUDY_INSIGHT` | Study Insight | ❌ Missing | Not implemented |
| `STUDY_MOMENTUM` | Study Momentum | ❌ Missing | Not implemented |
| `KNOWLEDGE_ECHO` | Knowledge Echo | ❌ Missing | Not implemented |
| `KNOWLEDGE_TRANSFER` | Knowledge Transfer | ❌ Missing | Not implemented |
| `MENTAL_CLARITY` | Mental Clarity | ❌ Missing | Not implemented |
| `STUDY_REFUND` | Study Refund | ❌ Missing | Not implemented |
| `FREE_STUDY` | Free Study | ❌ Missing | Not implemented |
| `MIND_PALACE` | Mind Palace | ❌ Missing | Not implemented |
| `STUDY_RUSH` | Study Rush | ❌ Missing | Not implemented |
| `CHAIN_STUDY` | Chain Study | ❌ Missing | Not implemented |
| `ELEMENTAL_HARMONY` | Elemental Harmony | ❌ Missing | Not implemented |
| `DEEP_STORAGE` | Deep Storage | ❌ Missing | Not implemented |
| `DOUBLE_CRAFT` | Double Craft | ❌ Missing | Not implemented |
| `ELEMENTAL_RESONANCE` | Elemental Resonance | ❌ Missing | Not implemented |
| `PURE_ELEMENTS` | Pure Elements | ❌ Missing | Not implemented |
**Summary:** 2 fully implemented, 6 partially implemented (function exists but not called), 24 not implemented.
---
## 2. Enchantment Effects Status
### Equipment Enchantment Effects (enchantment-effects.ts)
The following effect types are defined:
| Effect Type | Status | Notes |
|-------------|--------|-------|
| **Spell Effects** (`type: 'spell'`) | ✅ Working | Spells granted via `getSpellsFromEquipment()` |
| **Bonus Effects** (`type: 'bonus'`) | ✅ Working | Applied in `computeEquipmentEffects()` |
| **Multiplier Effects** (`type: 'multiplier'`) | ✅ Working | Applied in `computeEquipmentEffects()` |
| **Special Effects** (`type: 'special'`) | ⚠️ Tracked Only | Added to `specials` Set but NOT applied in game logic |
### Special Enchantment Effects Not Applied:
| Effect ID | Description | Issue |
|-----------|-------------|-------|
| `spellEcho10` | 10% chance cast twice | Tracked but not implemented in combat |
| `lifesteal5` | 5% damage as mana | Tracked but not implemented in combat |
| `overpower` | +50% damage at 80% mana | Tracked but separate from skill upgrade version |
**Location of Issue:**
```typescript
// effects.ts line 58-60
} else if (effect.type === 'special' && effect.specialId) {
specials.add(effect.specialId);
}
// Effect is tracked but never used in combat/damage calculations
```
---
## 3. Skill Effects Status
### SKILLS_DEF Analysis (constants.ts)
Skills with direct effects that should apply per level:
| Skill | Effect | Status |
|-------|--------|--------|
| `manaWell` | +100 max mana per level | ✅ Implemented |
| `manaFlow` | +1 regen/hr per level | ✅ Implemented |
| `elemAttune` | +50 elem mana cap | ✅ Implemented |
| `manaOverflow` | +25% click mana | ✅ Implemented |
| `quickLearner` | +10% study speed | ✅ Implemented |
| `focusedMind` | -5% study cost | ✅ Implemented |
| `meditation` | 2.5x regen after 4hrs | ✅ Implemented |
| `knowledgeRetention` | +20% progress saved | ⚠️ Partially Implemented |
| `enchanting` | Unlocks designs | ✅ Implemented |
| `efficientEnchant` | -5% capacity cost | ⚠️ Not verified |
| `disenchanting` | 20% mana recovery | ⚠️ Not verified |
| `enchantSpeed` | -10% enchant time | ⚠️ Not verified |
| `scrollCrafting` | Create scrolls | ❌ Not implemented |
| `essenceRefining` | +10% effect power | ⚠️ Not verified |
| `effCrafting` | -10% craft time | ⚠️ Not verified |
| `fieldRepair` | +15% repair | ❌ Repair not implemented |
| `elemCrafting` | +25% craft output | ✅ Implemented |
| `manaTap` | +1 mana/click | ✅ Implemented |
| `manaSurge` | +3 mana/click | ✅ Implemented |
| `manaSpring` | +2 regen | ✅ Implemented |
| `deepTrance` | 3x after 6hrs | ✅ Implemented |
| `voidMeditation` | 5x after 8hrs | ✅ Implemented |
| `insightHarvest` | +10% insight | ✅ Implemented |
| `temporalMemory` | Keep spells | ✅ Implemented |
| `guardianBane` | +20% vs guardians | ⚠️ Tracked but not verified |
---
## 4. Missing Implementations
### 4.1 Dynamic Effect Functions Not Called
The following functions exist in `upgrade-effects.ts` but are NOT called from `store.ts`:
```typescript
// upgrade-effects.ts - EXISTS but NOT USED
export function computeDynamicRegen(
effects: ComputedEffects,
baseRegen: number,
maxMana: number,
currentMana: number,
incursionStrength: number
): number { ... }
export function computeDynamicDamage(
effects: ComputedEffects,
baseDamage: number,
floorHPPct: number,
currentMana: number,
maxMana: number,
consecutiveHits: number
): number { ... }
```
**Where it should be called:**
- `store.ts` tick() function around line 414 for regen
- `store.ts` tick() function around line 618 for damage
### 4.2 Missing Combat Special Effects
Location: `store.ts` tick() combat section (lines 510-760)
Missing implementations:
```typescript
// BATTLE_FURY - +10% damage per consecutive hit
if (hasSpecial(effects, SPECIAL_EFFECTS.BATTLE_FURY)) {
// Need to track consecutiveHits in state
}
// ARMOR_PIERCE - Ignore 10% floor defense
// Floor defense not implemented in game
// COMBO_MASTER - Every 5th attack deals 3x damage
if (hasSpecial(effects, SPECIAL_EFFECTS.COMBO_MASTER)) {
// Need to track hitCount in state
}
// ADRENALINE_RUSH - Restore 5% mana on kill
// Should be added after floorHP <= 0 check
```
### 4.3 Missing Study Special Effects
Location: `store.ts` tick() study section (lines 440-485)
Missing implementations:
```typescript
// MENTAL_CLARITY - +10% study speed when mana > 75%
// STUDY_RUSH - First hour is 2x speed
// STUDY_REFUND - 25% mana back on completion
// KNOWLEDGE_ECHO - 10% instant study chance
// STUDY_MOMENTUM - +5% speed per consecutive hour
```
### 4.4 Missing Loop/Click Effects
Location: `store.ts` gatherMana() and startNewLoop()
```typescript
// gatherMana() - MANA_ECHO
// 10% chance to gain double mana from clicks
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_ECHO) && Math.random() < 0.1) {
cm *= 2;
}
// startNewLoop() - EMERGENCY_RESERVE
// Keep 10% max mana when starting new loop
if (hasSpecial(effects, SPECIAL_EFFECTS.EMERGENCY_RESERVE)) {
newState.rawMana = maxMana * 0.1;
}
```
### 4.5 Parallel Study Incomplete
`parallelStudyTarget` exists in state but the logic is not fully implemented in tick():
- State field exists (line 203)
- No tick processing for parallel study
- UI may show it but actual progress not processed
---
## 5. Balance Concerns
### 5.1 Weak Upgrades
| Upgrade | Issue | Suggestion |
|---------|-------|------------|
| `manaThreshold` | +20% mana for -10% regen is a net negative early | Change to +30% mana for -5% regen |
| `manaOverflow` | +25% click mana at 5 levels is only +5%/level | Increase to +10% per level |
| `fieldRepair` | Repair system not implemented | Remove or implement repair |
| `scrollCrafting` | Scroll system not implemented | Remove or implement scrolls |
### 5.2 Tier Scaling Issues
From `skill-evolution.ts`, tier multipliers are 10x per tier:
- Tier 1: multiplier 1
- Tier 2: multiplier 10
- Tier 3: multiplier 100
- Tier 4: multiplier 1000
- Tier 5: multiplier 10000
This creates massive power jumps that may trivialize content when tiering up.
### 5.3 Special Effect Research Costs
Research skills for effects are expensive but effects may not be implemented:
- `researchSpecialEffects` costs 500 mana + 10 hours study
- Effects like `spellEcho10` are tracked but not applied
- Player invests resources for non-functional upgrades
---
## 6. Critical Issues
### 6.1 computeDynamicRegen Not Used
**File:** `computed-stats.ts` lines 210-225
The function exists but only applies incursion penalty. It should call the more comprehensive `computeDynamicRegen` from `upgrade-effects.ts` that handles:
- Mana Cascade
- Mana Torrent
- Desperate Wells
- Steady Stream
### 6.2 No Consecutive Hit Tracking
`BATTLE_FURY` and `COMBO_MASTER` require tracking consecutive hits, but this state doesn't exist. Need:
```typescript
// In GameState
consecutiveHits: number;
totalHitsThisLoop: number;
```
### 6.3 Enchantment Special Effects Not Applied
The `specials` Set is populated but never checked in combat for enchantment-specific effects like:
- `lifesteal5`
- `spellEcho10`
---
## 7. Recommendations
### Priority 1 - Core Effects
1. Call `computeDynamicRegen()` from tick() instead of inline calculation
2. Call `computeDynamicDamage()` from combat section
3. Implement MANA_ECHO in gatherMana()
4. Implement EMERGENCY_RESERVE in startNewLoop()
### Priority 2 - Combat Effects
1. Add `consecutiveHits` to GameState
2. Implement BATTLE_FURY damage scaling
3. Implement COMBO_MASTER every 5th hit
4. Implement ADRENALINE_RUSH on kill
### Priority 3 - Study Effects
1. Implement MENTAL_CLARITY conditional speed
2. Implement STUDY_RUSH first hour bonus
3. Implement STUDY_REFUND on completion
4. Implement KNOWLEDGE_ECHO instant chance
### Priority 4 - Missing Systems
1. Implement or remove `scrollCrafting` skill
2. Implement or remove `fieldRepair` skill
3. Complete parallel study tick processing
4. Implement floor defense for ARMOR_PIERCE
---
## 8. Files Affected
| File | Changes Needed |
|------|----------------|
| `src/lib/game/store.ts` | Call dynamic effect functions, implement specials |
| `src/lib/game/computed-stats.ts` | Integrate with upgrade-effects dynamic functions |
| `src/lib/game/types.ts` | Add consecutiveHits to GameState |
| `src/lib/game/skill-evolution.ts` | Consider removing unimplementable upgrades |
---
**End of Audit Report**
+55 -9
View File
@@ -1,20 +1,66 @@
FROM node:20-alpine AS base # Mana Loop - Next.js Game Docker Image
FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libc6-compat openssl
RUN npm install -g bun # 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 # Install dependencies
COPY package.json bun.lock* bun.lockb* ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
# Copy source
# Copy the rest of the application
COPY . . COPY . .
# Set environment variables for build
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ENV DATABASE_URL="file:./dev.db"
# Generate Prisma client # Generate Prisma client
RUN bunx prisma generate --schema=./prisma/schema.prisma
# Build the application # 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 NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN bun run build 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
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000 || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:3000 || exit 1
CMD ["bun", "run", "start"]
# Start the server (running as root)
CMD ["node", "server.js"]
+154 -277
View File
@@ -1,126 +1,85 @@
# Mana Loop # Mana Loop
<p align="center"> An incremental/idle game about climbing a magical spire, mastering skills, and uncovering the secrets of an ancient tower.
<img src="public/logo.svg" alt="Mana Loop Logo" width="200" />
<br />
<em>An incremental/idle game about climbing a magical spire, mastering disciplines, 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.3.0-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
<img src="https://img.shields.io/badge/Next.js-16.1.1-black" alt="Next.js" />
<img src="https://img.shields.io/badge/TypeScript-5-blue" alt="TypeScript" />
<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 ## Overview
**Mana Loop** is a browser-based incremental/idle game where players gather mana, practice disciplines, climb a mysterious spire, craft enchanted equipment, and summon magical golems. The game features a unique time-loop prestige system (Insight) that provides permanent progression bonuses across playthroughs. **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.
### Core Game Loop ### The Game Loop
1. **Gather Mana** - Click to collect mana or let it regenerate automatically (14 total mana types) 1. **Gather Mana** - Click to collect mana or let it regenerate automatically
2. **Practice Disciplines** - Continuously train abilities that drain mana each tick in exchange for growing stat bonuses 2. **Study Skills & Spells** - Spend mana to learn new abilities and unlock upgrades
3. **Climb the Spire** - Battle through procedurally-generated floors; every 10th floor is a guardian encounter 3. **Climb the Spire** - Battle through floors, defeat guardians, and sign pacts for power
4. **Craft & Enchant** - 3-stage equipment enchantment system with capacity limits 4. **Craft Equipment** - Enchant your gear with powerful effects
5. **Summon Golems** - Magical constructs that fight alongside you (4 base + 6 hybrid types) 5. **Prestige** - Reset for Insight, gaining permanent bonuses
6. **Prestige (Loop)** - Reset progress for Insight currency, gain permanent bonuses
--- ---
## Features ## Features
### 🔮 Mana System ### Mana Gathering & Management
- **14 Mana Types**: 7 base elements + 1 utility + 3 compound + 3 exotic - Click-based mana collection with automatic regeneration
- Elemental conversion, regeneration mechanics, and meditation bonuses - Elemental mana system with five elements (Fire, Water, Earth, Air, Void)
- Mana types: Fire, Water, Air, Earth, Light, Dark, Death (base), Transference (utility), Metal, Sand, Lightning (compound), Crystal, Stellar, Void (exotic) - Mana conversion between raw and elemental forms
- Meditation system for boosted regeneration
### 📜 Discipline System ### Skill Progression with Tier Evolution
- Practice-based progression - no discrete levels, only continuous XP growth - 20+ skills across multiple categories (mana, combat, enchanting, familiar)
- Disciplines drain mana each tick; stat bonuses grow as a power curve of accumulated XP - 5-tier evolution system for each skill
- Perks unlock at XP thresholds (once, capped, or infinite stacking) - Milestone upgrades at levels 5 and 10 for each tier
- Attunement-gated discipline pools (Base / Enchanter / Invoker / Fabricator) - Unique special effects unlocked through skill upgrades
- Concurrent discipline slots unlock as total XP grows (max 4)
### ⚔️ Combat & Spire ### Equipment Crafting & Enchanting
- Cast-speed based combat system with elemental effectiveness - 3-stage enchantment process (Design → Prepare → Apply)
- Multi-spell support from equipped weapons
- Every 10th floor is a guardian: base elements (1080), compound (90110), exotic (120140), then procedural combination bosses (150+)
- Golem allies that deal automatic damage each tick
- Enemy modifiers: Armored, Agile, Mage, Shield, Swarm
### 🛡️ Equipment & Enchanting
- 3-stage enchantment process: Design → Prepare → Apply
- Equipment capacity system limiting total enchantment power - Equipment capacity system limiting total enchantment power
- Enchantment effects: stat bonuses, multipliers, spell grants - Enchantment effects including stat bonuses, multipliers, and spell grants
- Disenchanting to recover mana (only in Prepare stage) - Disenchanting to recover mana from unwanted enchantments
- Weapon/armor slots with 2-handed weapon support
### 🤖 Golemancy System ### Combat System
- Summon magical constructs (Earth, Steel, Crystal, Sand + 6 hybrid types) - Cast speed-based spell casting
- Golem slots unlock every 2 Fabricator levels (max 5 slots at Level 10) - Multi-spell support from equipped weapons
- Hybrid golems require Enchanter 5 + Fabricator 5 - Elemental damage bonuses and effectiveness
- Floor guardians with unique boons and pacts
### 🔄 Prestige (Insight) ### Familiar System
- Reset progress for permanent Insight currency - Collect and train magical companions
- Familiars provide passive bonuses and active abilities
- Growth and evolution mechanics
### Floor Navigation & Guardian Battles
- Procedurally generated spire floors
- Elemental floor themes affecting combat
- Guardian bosses with unique mechanics
- Pact system for permanent power boosts
### Prestige System (Insight)
- Reset progress for permanent bonuses
- Insight upgrades across multiple categories - Insight upgrades across multiple categories
- Signed pacts and attunements persist through prestige - Signed pacts persist through prestige
- Three attunement classes: Enchanter (Transference), Invoker (Spells/Pacts), Fabricator (Golems/Equipment)
--- ---
## Tech Stack ## Tech Stack
| Technology | Version | Purpose | | Technology | Purpose |
|------------|---------|---------| |------------|---------|
| **Next.js** | ^16.1.1 | Full-stack framework (App Router) | | **Next.js 16** | Full-stack framework with App Router |
| **React** | ^19.0.0 | UI library | | **TypeScript 5** | Type-safe development |
| **TypeScript** | ^5 | Type-safe development | | **Tailwind CSS 4** | Utility-first styling |
| **Tailwind CSS** | ^4 | Utility-first styling | | **shadcn/ui** | Reusable UI components |
| **shadcn/ui** | Radix-based | Reusable UI components | | **Zustand** | Client state management with persistence |
| **Zustand** | ^5.0.6 | Client state management (with persist) | | **Prisma ORM** | Database abstraction (SQLite) |
| **Prisma ORM** | ^6.11.1 | Database abstraction (SQLite) | | **Bun** | JavaScript runtime and package manager |
| **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 ## Getting Started
### Prerequisites ### Prerequisites
- **Bun** runtime (recommended) or Node.js 18+
- **SQLite** (for local development, included with Prisma) - **Node.js** 18+ or **Bun** runtime
- Git - **npm**, **yarn**, or **bun** package manager
### Installation ### Installation
@@ -129,10 +88,9 @@
git clone git@gitea.tailf367e3.ts.net:Anexim/Mana-Loop.git git clone git@gitea.tailf367e3.ts.net:Anexim/Mana-Loop.git
cd Mana-Loop cd Mana-Loop
# Install dependencies (using Bun - recommended) # Install dependencies
bun install bun install
# or
# Or using npm
npm install npm install
# Set up the database # Set up the database
@@ -144,7 +102,7 @@ npm run db:push
### Development ### Development
```bash ```bash
# Start the development server (runs on port 3000) # Start the development server
bun run dev bun run dev
# or # or
npm run dev npm run dev
@@ -152,168 +110,117 @@ npm run dev
The game will be available at `http://localhost:3000`. The game will be available at `http://localhost:3000`.
### Available Scripts ### Other Commands
| Script | Description | ```bash
|--------|-------------| # Run linting
| `dev` | Start Next.js development server with logging | bun run lint
| `build` | Build for production (outputs to `.next/standalone`) |
| `start` | Start production server (requires build first) | # Build for production
| `lint` | Run ESLint | bun run build
| `test` | Run Vitest tests |
| `test:coverage` | Run tests with coverage report | # Start production server
| `db:push` | Push Prisma schema to database | bun run start
| `db:generate` | Generate Prisma client | ```
| `db:migrate` | Run database migrations |
| `db:reset` | Reset database |
--- ---
## Project Structure ## Project Structure
``` ```
Mana-Loop/ src/
├── src/ # Application source code ├── app/
│ ├── app/ # Next.js App Router │ ├── page.tsx # Main game UI (single-page application)
│ ├── layout.tsx # Root layout (metadata, fonts, providers) │ ├── layout.tsx # Root layout with providers
│ ├── page.tsx # Main game UI └── api/ # API routes
│ │ ├── globals.css # Global styles ├── components/
│ └── api/ # API routes (minimal) ── ui/ # shadcn/ui components
── components/ # React components ── game/ # Game-specific components
├── ui/ # shadcn/ui components (20+ components) ├── tabs/ # Tab-based UI components
└── game/ # Game-specific components │ ├── CraftingTab.tsx
├── tabs/ # Tab components (SpireTab, DisciplinesTab, etc.) ├── LabTab.tsx
├── ManaDisplay.tsx, ActionButtons.tsx, TimeDisplay.tsx ├── SpellsTab.tsx
── crafting/, debug/, shared/, stats/ subdirectories ── SpireTab.tsx
├── hooks/ # Custom React hooks (use-mobile, use-toast) └── FamiliarTab.tsx
└── lib/ # Utility libraries ├── ManaDisplay.tsx
── game/ # Core game logic ── TimeDisplay.tsx
├── stores/ # Modular Zustand stores ├── ActionButtons.tsx
│ ├── gameStore.ts # Core state & tick logic └── ...
│ │ ├── manaStore.ts # Mana gathering & conversion └── lib/
│ │ ├── combatStore.ts # Combat, spells, floor progression ├── game/
│ ├── prestigeStore.ts # Prestige/loop & insight │ ├── store.ts # Zustand store (state + actions)
│ ├── discipline-slice.ts # Discipline activation & XP │ ├── effects.ts # Unified effect computation
│ ├── attunementStore.ts # Attunement classes │ ├── upgrade-effects.ts # Skill upgrade definitions
│ ├── craftingStore.ts # Crafting state │ ├── skill-evolution.ts # Tier progression paths
── uiStore.ts # UI state & modals ── constants.ts # Game data definitions
├── crafting-actions/ # Modular crafting stage handlers ├── computed-stats.ts # Stat calculation functions
├── constants/ # Elements, spells, rooms, prestige ├── crafting-slice.ts # Equipment/enchantment actions
├── data/ # Game data ├── familiar-slice.ts # Familiar system actions
│ ├── disciplines/ # Per-attunement discipline definitions │ ├── navigation-slice.ts # Floor navigation actions
│ ├── enchantments/ # Enchantment effects by category │ ├── study-slice.ts # Study system actions
│ ├── equipment/ # Equipment type definitions │ ├── types.ts # TypeScript interfaces
│ ├── golems/ # Golem definitions │ ├── formatting.ts # Display formatters
│ ├── guardian-data.ts # Static guardian definitions (floors 10140) │ ├── utils.ts # Utility functions
│ └── guardian-encounters.ts # Procedural guardian lookup & combo bosses │ └── data/
├── effects/ # Unified stat computation ├── equipment.ts # Equipment definitions
── discipline-effects.ts # Discipline → getUnifiedEffects() ── enchantment-effects.ts # Enchantment catalog
├── types/ # TypeScript types (disciplines, elements, etc.) ├── familiars.ts # Familiar definitions
└── utils/ # Combat, floor, enemy, discipline math helpers ├── crafting-recipes.ts # Crafting recipes
├── prisma/ # Database schema and migrations │ ├── achievements.ts # Achievement definitions
├── public/ # Static assets │ └── loot-drops.ts # Loot drop tables
├── docs/ # Project documentation └── utils.ts # General utilities
│ ├── AGENTS.md # Architecture guide for AI agents
│ └── GAME_BRIEFING.md # Comprehensive game design document
└── Configuration Files:
├── package.json, tsconfig.json, next.config.ts
├── vitest.config.ts, eslint.config.mjs
├── Dockerfile, docker-compose.yml, Caddyfile
└── .gitea/workflows/ # Gitea Actions CI/CD pipeline
``` ```
For detailed architecture patterns and coding guidelines, see [AGENTS.md](./AGENTS.md). For detailed architecture documentation, see [AGENTS.md](./AGENTS.md).
--- ---
## Game Systems ## Game Systems Overview
### Mana System ### Mana System
The core resource of the game with 14 distinct types organized in a hierarchy: The core resource of the game. Mana is gathered manually or automatically and used for studying skills, casting spells, and crafting.
- **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**: `src/lib/game/stores/manaStore.ts`, `src/lib/game/constants/elements.ts` **Key Files:**
- `store.ts` - Mana state and actions
- `computed-stats.ts` - Mana calculations
### Discipline System ### Skill System
Disciplines replace the old skill system entirely. There are no discrete levels - disciplines grow **continuously** through practice. The player activates a discipline and it drains mana each tick in exchange for permanent stat growth within the run. Skills provide passive bonuses and unlock new abilities. Each skill can evolve through 5 tiers with milestone upgrades.
- **Stat bonus** grows as a power curve of XP: `baseValue × (XP / scalingFactor)^0.65` **Key Files:**
- **Mana drain** also increases with XP: `drainBase × (1 + (XP / difficultyFactor)^0.4)` - `constants.ts` - Skill definitions (`SKILLS_DEF`)
- **Perks** unlock at XP thresholds (`once`, `capped`, or `infinite`) - `skill-evolution.ts` - Evolution paths and upgrades
- **Concurrent slots** start at 1 and unlock as total XP grows (max 4) - `upgrade-effects.ts` - Effect computation
**Key Files**: `src/lib/game/data/disciplines/`, `src/lib/game/stores/discipline-slice.ts`, `src/lib/game/utils/discipline-math.ts`
### Guardian & Spire System
Every 10th floor is a guardian encounter. Guardians progress through four tiers of complexity:
1. **Base Elements (Floors 1080)**: One guardian per base element + Transference. Static definitions with named guardians (Ignis Prime, Aqua Regia, etc.). Defeating them unlocks their associated mana types.
2. **Compound Elements (Floors 90110)**: Metal, Sand, and Lightning guardians with procedurally generated names.
3. **Exotic Elements (Floors 120140)**: Crystal, Stellar, and Void guardians - the most powerful single-element encounters.
4. **Combination Bosses (Floor 150+)**: Fully procedural dual-element guardians. Each one wields two base elements simultaneously (e.g. Fire+Water, Light+Dark) and grows stronger every 10 floors.
**Key Files**: `src/lib/game/data/guardian-data.ts`, `src/lib/game/data/guardian-encounters.ts`
### Combat System ### Combat System
- Cast-speed based spell casting with elemental effectiveness multipliers 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.
- Enemy modifiers: Armored, Agile, Mage (barrier), Shielded, Swarm
- Golem allies deal automatic damage each tick
- Discipline bonuses feed into damage via `getUnifiedEffects()`
**Key Files**: `src/lib/game/stores/combatStore.ts`, `src/lib/game/utils/combat-utils.ts`, `src/lib/game/utils/enemy-generator.ts` **Key Files:**
- `store.ts` - Combat tick logic
- `constants.ts` - Spell definitions (`SPELLS_DEF`)
- `effects.ts` - Damage calculations
### Enchanting System ### Crafting System
3-stage equipment enchantment process: A 3-stage enchantment system for equipment. Design effects, prepare equipment, and apply enchantments within capacity limits.
1. **Design**: Choose effects for your equipment type
2. **Prepare**: Ready equipment (ONLY stage where disenchanting is possible)
3. **Apply**: Apply designed enchantments (cannot re-enchant already enchanted gear)
**Key Files**: `src/lib/game/crafting-actions/`, `src/lib/game/data/enchantments/` **Key Files:**
- `crafting-slice.ts` - Crafting actions
- `data/equipment.ts` - Equipment types
- `data/enchantment-effects.ts` - Available effects
### Golemancy System ### Familiar System
- **Base Golems**: Earth (Fabricator 2), Steel (Metal), Crystal, Sand Magical companions that provide bonuses and can be trained and evolved.
- **Hybrid Golems** (Enchanter 5 + Fabricator 5): Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone
- **Golem Slots**: 1 slot at Fabricator Level 2, +1 every 2 levels (max 5 at Level 10)
**Key Files**: `src/lib/game/data/golems/`, `src/lib/game/stores/gameStore.ts` **Key Files:**
- `familiar-slice.ts` - Familiar actions
- `data/familiars.ts` - Familiar definitions
### Prestige (Insight) ### Prestige System
Reset progress to gain Insight currency for permanent upgrades: Reset progress for Insight, which provides permanent bonuses. Signed pacts persist through prestige.
- Signed pacts persist through prestige
- Attunement choices affect gameplay (Enchanter/Invoker/Fabricator)
- Insight upgrades provide bonuses across all loops
--- **Key Files:**
- `store.ts` - Prestige logic
## Deployment - `constants.ts` - Insight upgrades
### Docker 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`
- **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
```
--- ---
@@ -322,50 +229,30 @@ NODE_ENV=production bun .next/standalone/server.js
We welcome contributions! Please follow these guidelines: We welcome contributions! Please follow these guidelines:
### Development Workflow ### Development Workflow
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` 1. **Pull the latest changes** before starting work
3. **Follow existing patterns** in the codebase (see AGENTS.md) 2. **Create a feature branch** for your changes
4. **Run linting** before committing: `bun run lint` 3. **Follow existing patterns** in the codebase
5. **Test your changes** thoroughly: `bun run test` 4. **Run linting** before committing (`bun run lint`)
6. **Commit and push** to your branch, then create a pull request 5. **Test your changes** thoroughly
### Code Style ### Code Style
- TypeScript throughout with strict typing - TypeScript throughout with strict typing
- Use existing shadcn/ui components over custom implementations - Use existing shadcn/ui components over custom implementations
- Follow the modular store pattern (`src/lib/game/stores/`) - Follow the slice pattern for store actions
- Keep files under 400 lines (enforced by pre-commit hook) - Keep components focused and extract to separate files when >50 lines
- Use path aliases: `@/*` maps to `./src/*`
### Adding New Features ### Adding New Features
For detailed patterns on adding new effects, disciplines, spells, or systems, see the comprehensive [AGENTS.md](./AGENTS.md) guide, which includes architecture overview, coding patterns, and git workflow.
--- For detailed patterns on adding new effects, skills, spells, or systems, see [AGENTS.md](./AGENTS.md).
## Banned Content
The following content has been removed from the game and must not be re-added:
### Banned Mechanics
- **Lifesteal** - Player cannot heal from dealing damage
- **Healing** - Player cannot heal themselves (floors take damage, not the player)
- **Scroll crafting** - Violates the no-instant-finishing design pillar
- **Ascension skills** - Removed; no replacement
### Banned Mana Types
- **Life** - Removed (healing theme conflicts with core design)
- **Blood** - Removed (life derivative)
- **Wood** - Removed (life derivative)
- **Mental** - Removed
- **Force** - Removed
### Banned Systems
- **Familiar System** - Removed in favour of Golemancy and Pact systems
- **Skill System** (study, tiers T1T5, milestone upgrades) - Fully replaced by the Discipline System
--- ---
## License ## License
This project is licensed under the MIT License.
``` ```
MIT License MIT License
@@ -394,14 +281,4 @@ SOFTWARE.
## Acknowledgments ## Acknowledgments
- Built with modern web technologies (Next.js, React, TypeScript, Tailwind CSS) Built with love using modern web technologies. Special thanks to the open-source community for the amazing tools that make this project possible.
- 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>
+118
View File
@@ -0,0 +1,118 @@
# Mana Loop - Component Refactoring Plan
## Current State
The main `page.tsx` file is ~2000+ lines and contains all game logic and rendering in a single component. This makes it:
- Hard to maintain
- Hard to test individual features
- Prone to performance issues (entire component re-renders on any state change)
- Difficult for multiple developers to work on
## Proposed Component Structure
### Directory: `/src/components/game/`
#### Layout Components
```
GameLayout.tsx - Main layout wrapper with header/footer
Calendar.tsx - Day calendar display in header
ResourceBar.tsx - Day/time/insight display in header
```
#### Sidebar Components
```
ManaSidebar.tsx - Left sidebar container
ManaDisplay.tsx - Current mana, max mana, regen display
GatherButton.tsx - Click-to-gather button with hold functionality
ActionButtons.tsx - Meditate/Climb/Study/Convert buttons
FloorStatus.tsx - Current floor display with HP bar
```
#### Tab Content Components
```
SpireTab.tsx - Main spire/combat tab
├── CurrentFloor.tsx - Floor info, guardian, HP bar
├── ActiveSpell.tsx - Active spell details and cast progress
├── StudyProgress.tsx - Current study progress display
├── KnownSpells.tsx - Grid of learned spells
└── ActivityLog.tsx - Game event log
SpellsTab.tsx - Spell learning tab
├── SpellTier.tsx - Spells grouped by tier
└── SpellCard.tsx - Individual spell card
LabTab.tsx - Elemental mana lab tab
├── ElementalMana.tsx - Grid of elemental mana
├── ElementConversion.tsx - Convert raw to element
├── UnlockElements.tsx - Unlock new elements
└── CompositeCrafting.tsx - Craft composite elements
SkillsTab.tsx - Skill learning tab
├── SkillCategory.tsx - Skills grouped by category
└── SkillCard.tsx - Individual skill card
GrimoireTab.tsx - Prestige upgrades tab
└── PrestigeCard.tsx - Individual prestige upgrade
StatsTab.tsx - Detailed stats breakdown
├── ManaStats.tsx - Mana-related stats
├── CombatStats.tsx - Combat-related stats
├── StudyStats.tsx - Study-related stats
└── ActiveUpgrades.tsx - List of active skill upgrades
```
#### Shared Components
```
SpellCost.tsx - Formatted spell cost display
DamageBreakdown.tsx - Damage calculation breakdown
BuffIndicator.tsx - Active buff/debuff display
UpgradeDialog.tsx - Skill upgrade selection dialog
```
## Implementation Phases
### Phase 1: Extract Simple Components (Low Risk)
- [ ] Calendar.tsx
- [ ] ActivityLog.tsx
- [ ] SpellCost.tsx
- [ ] KnownSpells.tsx
### Phase 2: Extract Tab Components (Medium Risk)
- [ ] SpireTab.tsx
- [ ] SpellsTab.tsx
- [ ] LabTab.tsx
- [ ] SkillsTab.tsx
- [ ] GrimoireTab.tsx
- [ ] StatsTab.tsx
### Phase 3: Extract Sidebar Components (Medium Risk)
- [ ] ManaDisplay.tsx
- [ ] GatherButton.tsx
- [ ] ActionButtons.tsx
- [ ] FloorStatus.tsx
### Phase 4: Create Shared Hooks
- [ ] useGameState.ts - Custom hook for game state access
- [ ] useComputedStats.ts - Memoized computed stats
- [ ] useSpellCast.ts - Spell casting logic
- [ ] useStudy.ts - Skill/spell study logic
## Benefits
1. **Maintainability**: Each component has a single responsibility
2. **Performance**: React can skip re-rendering unchanged components
3. **Testability**: Individual components can be tested in isolation
4. **Collaboration**: Multiple developers can work on different components
5. **Code Reuse**: Shared components can be used across tabs
## Migration Strategy
1. Create new component file
2. Move relevant code from page.tsx
3. Pass required props or use store directly
4. Add TypeScript types
5. Test functionality
6. Remove code from page.tsx
7. Repeat for each component
## Estimated Lines of Code Reduction
- Current page.tsx: ~2000 lines
- After refactoring: ~200-300 lines (main layout only)
- Total component files: ~30-40 files averaging 50-100 lines each
Regular → Executable
+625 -433
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
[test]
dir = "./src/test"
preload = ["./src/test/setup.ts"]
+124
View File
@@ -0,0 +1,124 @@
# Crafting & Equipment System Implementation Summary
## Overview
Replaced the combat skills system with a comprehensive equipment and enchantment system. Players now gain combat abilities through enchanted equipment rather than skills.
## Completed Tasks
### 1. Equipment System (✅ Complete)
- **File**: `/src/lib/game/data/equipment.ts`
- 8 equipment slots: mainHand, offHand, head, body, hands, feet, accessory1, accessory2
- 20+ equipment types across categories: caster, shield, catalyst, head, body, hands, feet, accessory
- Capacity system: Each equipment has base capacity for enchantments
- Starting equipment: Basic Staff (with Mana Bolt), Civilian Shirt/Gloves/Shoes
### 2. Enchantment Effects Catalogue (✅ Complete)
- **File**: `/src/lib/game/data/enchantment-effects.ts`
- 100+ enchantment effects across 7 categories:
- **Spell** (40+): Grant ability to cast specific spells
- **Mana** (20+): Capacity, regen, efficiency bonuses
- **Combat** (15+): Damage, crit, attack speed
- **Elemental** (10+): Elemental damage bonuses
- **Defense** (4): Damage reduction, mana shields
- **Utility** (8): Study speed, meditation, insight
- **Special** (12): Unique effects like echo, lifesteal, executioner
### 3. Crafting Store Slice (✅ Complete)
- **File**: `/src/lib/game/store/craftingSlice.ts`
- Equipment instance management
- Enchantment design workflow
- Preparation progress tracking
- Application progress with mana consumption
- Helper functions for computing equipment effects
### 4. CraftingTab UI Component (✅ Complete)
- **File**: `/src/components/game/tabs/CraftingTab.tsx`
- 4 sub-tabs: Equipment, Design, Enchant, Craft
- Equipment slot visualization with rarity colors
- Effect catalogue with capacity preview
- Design creation workflow
### 5. Type Definitions (✅ Complete)
- **File**: `/src/lib/game/types.ts`
- EquipmentInstance, AppliedEnchantment, EnchantmentDesign interfaces
- Crafting progress states (DesignProgress, PreparationProgress, ApplicationProgress)
### 6. Skill Evolution Cleanup (✅ Complete)
- **File**: `/src/lib/game/skill-evolution.ts`
- Removed combatTrain evolution path (orphaned - skill not in SKILLS_DEF)
- Removed COMBAT_TRAIN upgrade definitions
### 7. Bug Fixes (✅ Complete)
- Fixed CraftingTab icon imports (Ring → Circle, HandMetal → Hand)
## Remaining Tasks (Future Work)
### 1. Equipment-Granted Spells in Combat (Medium Priority)
- The `equipmentSpellStates` array exists but isn't fully utilized
- Combat tick should check for equipment-granted spells
- Multi-spell casting from different equipment pieces
- Individual spell cooldown tracking
### 2. SpellsTab Integration (Low Priority)
- Add section showing equipment-granted spells
- Indicate which equipment piece grants each spell
- Distinguish between learned vs equipment-granted spells
### 3. Evolution Paths for Crafting Skills (Low Priority)
- Add evolution paths for existing crafting skills:
- `effCrafting` → Efficient Enchanter
- `durableConstruct` → Master Artisan
- `fieldRepair` → Field Engineer
## System Architecture
```
Equipment System Flow:
┌─────────────────────────────────────────────────────────────┐
│ Equipment Instance │
│ - typeId: Reference to EquipmentTypeDef │
│ - enchantments: AppliedEnchantment[] │
│ - usedCapacity / totalCapacity │
│ - rarity: Based on total capacity used │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Enchantment Design │
│ 1. Select effects from catalogue │
│ 2. Check capacity constraints │
│ 3. Pay design time cost │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Preparation │
│ - Clear existing enchantments │
│ - Pay mana cost (equipment capacity × 5) │
│ - Wait preparation time (capacity / 5 hours) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Application │
│ - Continuous mana drain during application │
│ - Time based on total effect capacity │
│ - Can be paused if mana runs low │
│ - On completion: Apply enchantments to equipment │
└─────────────────────────────────────────────────────────────┘
```
## Key Design Decisions
1. **Capacity System**: Equipment has capacity that limits total enchantment power. This creates meaningful choices about which effects to apply.
2. **Effect Categories**: Each equipment type only accepts certain effect categories, creating specialization:
- Staves: All spell types + mana + combat + elemental
- Shields: Defense + utility only
- Accessories: Small bonuses, no spells
3. **Stacking Effects**: Many effects can be stacked multiple times for increased power, with increasing capacity costs.
4. **Rarity from Power**: Rarity is automatically calculated from total capacity used, making powerful enchantments visually distinctive.
5. **Time-Gated Application**: Enchantment application takes real time with continuous mana drain, preventing instant power spikes.
Executable
BIN
View File
Binary file not shown.
Executable
+31
View File
@@ -0,0 +1,31 @@
## Crafting and Equipment System - Implementation Notes
### Date: $(date)
### Completed Tasks:
1. ✅ Fixed CraftingTab icon imports (Ring → Circle, HandMetal → Hand)
2. ✅ Removed combat skill evolution paths from skill-evolution.ts:
- Removed COMBAT_TRAIN_TIER1_UPGRADES_L5 upgrade definitions
- Removed COMBAT_TRAIN_TIER1_UPGRADES_L10 upgrade definitions
- Removed combatTrain evolution path (all 5 tiers)
### System Architecture:
- **Equipment System**: 8 slots (mainHand, offHand, head, body, hands, feet, accessory1, accessory2)
- **Equipment Types**: Different categories (caster, shield, catalyst, head, body, hands, feet, accessory)
- **Capacity System**: Each equipment has base capacity, effects cost capacity
- **Enchantment Effects**: 7 categories (spell, mana, combat, elemental, defense, utility, special)
- **Starting Equipment**: Basic Staff (with Mana Bolt), Civilian Shirt/Gloves/Shoes
### Key Files:
- `/src/lib/game/data/equipment.ts` - Equipment types and capacity system
- `/src/lib/game/data/enchantment-effects.ts` - Enchantment effect catalogue
- `/src/lib/game/store/craftingSlice.ts` - Crafting store slice
- `/src/components/game/tabs/CraftingTab.tsx` - Crafting UI component
- `/src/lib/game/skill-evolution.ts` - Skill evolution paths (combat skills removed)
### Remaining Tasks:
- Update SpellsTab to work with equipment-granted spells
- Add evolution paths for crafting skills (optional)
- Add tests for new crafting system
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1,4 +0,0 @@
# Circular Dependencies
Generated: 2026-05-28T10:11:54.061Z
No circular dependencies found. ✅
-727
View File
@@ -1,727 +0,0 @@
{
"_meta": {
"generated": "2026-05-28T10:11:52.202Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
},
"graph": {
"constants.ts": [
"constants/index.ts"
],
"constants/core.ts": [],
"constants/elements.ts": [
"types.ts"
],
"constants/index.ts": [
"constants/core.ts",
"constants/elements.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/crafting-material-actions.ts": [
"crafting-fabricator.ts",
"stores/combatStore.ts",
"stores/manaStore.ts",
"stores/uiStore.ts"
],
"crafting-actions/design-actions.ts": [
"crafting-design.ts",
"crafting-utils.ts",
"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": [
"constants.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": [
"constants.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": [
"constants.ts",
"data/crafting-recipes.ts",
"data/equipment/index.ts",
"types.ts",
"utils/result.ts"
],
"crafting-fabricator.ts": [
"data/fabricator-recipes.ts",
"stores/combatStore.ts",
"stores/manaStore.ts",
"stores/uiStore.ts",
"types.ts"
],
"crafting-loot.ts": [
"data/crafting-recipes.ts",
"types.ts"
],
"crafting-prep.ts": [
"constants.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/disciplines/base.ts": [
"types/disciplines.ts"
],
"data/disciplines/elemental-regen-advanced.ts": [
"types/disciplines.ts"
],
"data/disciplines/elemental-regen.ts": [
"types/disciplines.ts"
],
"data/disciplines/elemental.ts": [
"types/disciplines.ts"
],
"data/disciplines/enchanter-special.ts": [
"types/disciplines.ts"
],
"data/disciplines/enchanter-spells.ts": [
"types/disciplines.ts"
],
"data/disciplines/enchanter-utility.ts": [
"types/disciplines.ts"
],
"data/disciplines/enchanter.ts": [
"types/disciplines.ts"
],
"data/disciplines/fabricator.ts": [
"types/disciplines.ts"
],
"data/disciplines/index.ts": [
"data/disciplines/base.ts",
"data/disciplines/elemental-regen-advanced.ts",
"data/disciplines/elemental-regen.ts",
"data/disciplines/elemental.ts",
"data/disciplines/enchanter-special.ts",
"data/disciplines/enchanter-spells.ts",
"data/disciplines/enchanter-utility.ts",
"data/disciplines/enchanter.ts",
"data/disciplines/fabricator.ts",
"data/disciplines/invoker.ts",
"types/disciplines.ts"
],
"data/disciplines/invoker.ts": [
"types/disciplines.ts"
],
"data/enchantment-effects.ts": [
"data/enchantment-types.ts",
"data/enchantments/index.ts"
],
"data/enchantment-types.ts": [
"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/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/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/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-material-recipes.ts": [
"data/fabricator-recipe-types.ts"
],
"data/fabricator-physical-recipes.ts": [
"data/fabricator-recipe-types.ts"
],
"data/fabricator-recipe-types.ts": [
"data/equipment/types.ts",
"types/equipment.ts"
],
"data/fabricator-recipes.ts": [
"data/fabricator-material-recipes.ts",
"data/fabricator-physical-recipes.ts",
"data/fabricator-recipe-types.ts",
"data/fabricator-wizard-recipes.ts"
],
"data/fabricator-wizard-recipes.ts": [
"data/fabricator-recipe-types.ts"
],
"data/golems/base-golems.ts": [
"data/golems/types.ts"
],
"data/golems/elemental-golems.ts": [
"data/golems/types.ts"
],
"data/golems/golems-data.ts": [
"data/golems/base-golems.ts",
"data/golems/elemental-golems.ts",
"data/golems/hybrid-golems.ts"
],
"data/golems/hybrid-golems.ts": [
"data/golems/types.ts"
],
"data/golems/index.ts": [
"data/golems/golems-data.ts",
"data/golems/types.ts",
"data/golems/utils.ts"
],
"data/golems/types.ts": [
"types.ts"
],
"data/golems/utils.ts": [
"data/golems/golems-data.ts",
"data/golems/types.ts"
],
"data/guardian-data.ts": [
"types.ts"
],
"data/guardian-encounters.ts": [
"data/guardian-data.ts",
"types.ts"
],
"data/loot-drops.ts": [
"types/game.ts"
],
"effects.ts": [
"data/enchantment-effects.ts",
"effects/discipline-effects.ts",
"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",
"data/guardian-encounters.ts",
"effects/discipline-effects.ts",
"effects/special-effects.ts",
"effects/upgrade-effects.ts",
"stores/combatStore.ts",
"stores/gameStore.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
"utils/index.ts",
"utils/pact-utils.ts"
],
"stores/attunementStore.ts": [
"data/attunements.ts",
"types.ts",
"utils/safe-persist.ts"
],
"stores/combat-actions.ts": [
"constants.ts",
"data/guardian-encounters.ts",
"effects/discipline-effects.ts",
"stores/combat-state.types.ts",
"types.ts",
"utils/index.ts"
],
"stores/combat-state.types.ts": [
"types.ts"
],
"stores/combatStore.ts": [
"stores/combat-actions.ts",
"stores/combat-state.types.ts",
"types.ts",
"utils/activity-log.ts",
"utils/index.ts",
"utils/room-utils.ts",
"utils/safe-persist.ts",
"utils/spire-utils.ts"
],
"stores/crafting-equipment-tick.ts": [
"constants.ts",
"crafting-equipment.ts",
"data/crafting-recipes.ts",
"data/fabricator-recipes.ts",
"stores/combatStore.ts",
"stores/craftingStore.types.ts",
"types/equipment.ts"
],
"stores/crafting-initial-state.ts": [
"crafting-utils.ts",
"types.ts"
],
"stores/craftingStore.ts": [
"crafting-actions/application-actions.ts",
"crafting-actions/crafting-material-actions.ts",
"crafting-actions/equipment-actions.ts",
"crafting-actions/preparation-actions.ts",
"crafting-design.ts",
"crafting-equipment.ts",
"crafting-fabricator.ts",
"crafting-utils.ts",
"stores/combatStore.ts",
"stores/crafting-equipment-tick.ts",
"stores/crafting-initial-state.ts",
"stores/craftingStore.types.ts",
"stores/manaStore.ts",
"stores/uiStore.ts",
"types/equipmentSlot.ts",
"utils/result.ts",
"utils/safe-persist.ts"
],
"stores/craftingStore.types.ts": [
"types.ts",
"types/equipmentSlot.ts"
],
"stores/discipline-slice.ts": [
"data/disciplines/base.ts",
"data/disciplines/elemental-regen-advanced.ts",
"data/disciplines/elemental-regen.ts",
"data/disciplines/elemental.ts",
"data/disciplines/enchanter-special.ts",
"data/disciplines/enchanter-spells.ts",
"data/disciplines/enchanter-utility.ts",
"data/disciplines/enchanter.ts",
"data/disciplines/fabricator.ts",
"data/disciplines/invoker.ts",
"types.ts",
"types/disciplines.ts",
"utils/discipline-math.ts",
"utils/safe-persist.ts"
],
"stores/gameActions.ts": [
"effects/discipline-effects.ts",
"stores/combatStore.ts",
"stores/gameStore.types.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
"stores/uiStore.ts",
"utils/index.ts"
],
"stores/gameHooks.ts": [
"constants.ts",
"effects.ts",
"effects/discipline-effects.ts",
"stores/craftingStore.ts",
"stores/gameStore.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
"utils/index.ts"
],
"stores/gameLoopActions.ts": [
"constants.ts",
"effects/discipline-effects.ts",
"stores/combatStore.ts",
"stores/gameStore.types.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
"stores/uiStore.ts",
"utils/index.ts"
],
"stores/gameStore.ts": [
"constants.ts",
"data/attunements.ts",
"data/guardian-encounters.ts",
"effects.ts",
"effects/discipline-effects.ts",
"effects/special-effects.ts",
"effects/upgrade-effects.types.ts",
"stores/attunementStore.ts",
"stores/combatStore.ts",
"stores/craftingStore.ts",
"stores/discipline-slice.ts",
"stores/gameActions.ts",
"stores/gameLoopActions.ts",
"stores/gameStore.types.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
"stores/tick-pipeline.ts",
"stores/uiStore.ts",
"utils/index.ts",
"utils/safe-persist.ts"
],
"stores/gameStore.types.ts": [],
"stores/index.ts": [
"constants.ts",
"stores/attunementStore.ts",
"stores/combat-state.types.ts",
"stores/combatStore.ts",
"stores/craftingStore.ts",
"stores/craftingStore.types.ts",
"stores/discipline-slice.ts",
"stores/gameHooks.ts",
"stores/gameStore.ts",
"stores/gameStore.types.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
"stores/uiStore.ts",
"utils/index.ts"
],
"stores/manaStore.ts": [
"constants.ts",
"types.ts",
"utils/result.ts",
"utils/safe-persist.ts"
],
"stores/prestigeStore.ts": [
"constants.ts",
"data/guardian-encounters.ts",
"stores/manaStore.ts",
"utils/result.ts",
"utils/safe-persist.ts"
],
"stores/tick-pipeline.ts": [
"stores/attunementStore.ts",
"stores/combat-state.types.ts",
"stores/craftingStore.types.ts",
"stores/discipline-slice.ts",
"stores/gameStore.types.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
"stores/uiStore.ts"
],
"stores/uiStore.ts": [
"utils/safe-persist.ts"
],
"types.ts": [
"data/equipment/types.ts",
"types/attunements.ts",
"types/elements.ts",
"types/equipment.ts",
"types/equipmentSlot.ts",
"types/game.ts",
"types/spells.ts"
],
"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",
"data/guardian-data.ts",
"data/guardian-encounters.ts",
"types.ts",
"utils/mana-utils.ts"
],
"utils/discipline-math.ts": [
"types/disciplines.ts"
],
"utils/enemy-generator.ts": [
"types.ts",
"utils/enemy-utils.ts",
"utils/floor-utils.ts"
],
"utils/enemy-utils.ts": [
"constants.ts",
"types.ts",
"utils/floor-utils.ts"
],
"utils/floor-utils.ts": [
"constants.ts",
"data/guardian-encounters.ts"
],
"utils/formatting.ts": [],
"utils/index.ts": [
"utils/combat-utils.ts",
"utils/floor-utils.ts",
"utils/formatting.ts",
"utils/mana-utils.ts",
"utils/result.ts",
"utils/safe-persist.ts"
],
"utils/mana-utils.ts": [
"constants.ts",
"data/attunements.ts",
"effects/upgrade-effects.types.ts",
"types.ts"
],
"utils/pact-utils.ts": [
"data/guardian-encounters.ts"
],
"utils/result.ts": [],
"utils/room-utils.ts": [
"constants.ts",
"data/guardian-encounters.ts",
"types.ts",
"utils/enemy-utils.ts",
"utils/floor-utils.ts"
],
"utils/safe-persist.ts": [],
"utils/spire-utils.ts": [
"constants.ts",
"data/guardian-encounters.ts",
"types.ts",
"utils/enemy-utils.ts",
"utils/floor-utils.ts"
]
}
}
-411
View File
@@ -1,411 +0,0 @@
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
│ │ │ │ │ └── MaterialRecipeCard.tsx
│ │ │ │ ├── DebugTab/
│ │ │ │ │ ├── AchievementDebugSection.tsx
│ │ │ │ │ ├── AttunementDebugSection.tsx
│ │ │ │ │ ├── DisciplineDebugSection.tsx
│ │ │ │ │ ├── ElementDebugSection.tsx
│ │ │ │ │ ├── GameStateDebugSection.tsx
│ │ │ │ │ ├── GolemDebugSection.tsx
│ │ │ │ │ ├── PactDebugSection.tsx
│ │ │ │ │ └── SpireDebugSection.tsx
│ │ │ │ ├── EquipmentTab/
│ │ │ │ │ ├── EquipmentEffectsSummary.tsx
│ │ │ │ │ ├── EquipmentSlotGrid.tsx
│ │ │ │ │ └── InventoryList.tsx
│ │ │ │ ├── SpireCombatPage/
│ │ │ │ │ ├── RoomDisplay.tsx
│ │ │ │ │ ├── SpireActivityLog.tsx
│ │ │ │ │ ├── SpireCombatControls.tsx
│ │ │ │ │ ├── SpireCombatPage.tsx
│ │ │ │ │ ├── SpireHeader.tsx
│ │ │ │ │ ├── SpireManaDisplay.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ ├── StatsTab/
│ │ │ │ │ ├── CombatStatsSection.tsx
│ │ │ │ │ ├── DisciplineStatsSection.tsx
│ │ │ │ │ ├── ElementStatsSection.tsx
│ │ │ │ │ ├── LoopStatsSection.tsx
│ │ │ │ │ ├── ManaStatsSection.tsx
│ │ │ │ │ ├── PactStatusSection.tsx
│ │ │ │ │ └── StudyStatsSection.tsx
│ │ │ │ ├── AchievementsTab.tsx
│ │ │ │ ├── ActivityLog.tsx
│ │ │ │ ├── AttunementsTab.test.ts
│ │ │ │ ├── AttunementsTab.tsx
│ │ │ │ ├── CraftingTab.test.ts
│ │ │ │ ├── CraftingTab.tsx
│ │ │ │ ├── DebugTab.test.ts
│ │ │ │ ├── DebugTab.tsx
│ │ │ │ ├── DisciplinesTab.tsx
│ │ │ │ ├── EquipmentTab.test.ts
│ │ │ │ ├── EquipmentTab.tsx
│ │ │ │ ├── GolemancyTab.test.ts
│ │ │ │ ├── GolemancyTab.tsx
│ │ │ │ ├── GuardianPactsTab.test.ts
│ │ │ │ ├── GuardianPactsTab.tsx
│ │ │ │ ├── PrestigeTab.test.ts
│ │ │ │ ├── PrestigeTab.tsx
│ │ │ │ ├── SpellsTab.tsx
│ │ │ │ ├── SpireSummaryTab.test.ts
│ │ │ │ ├── SpireSummaryTab.tsx
│ │ │ │ ├── StatsTab.tsx
│ │ │ │ ├── guardian-pacts-components.tsx
│ │ │ │ └── index.ts
│ │ │ ├── ActionButtons.tsx
│ │ │ ├── ActivityLogPanel.tsx
│ │ │ ├── AttunementStatus.tsx
│ │ │ ├── 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
│ │ │ ├── ui-components.test.tsx
│ │ │ └── value-display.tsx
│ │ └── ErrorBoundary.tsx
│ ├── hooks/
│ │ ├── use-mobile.ts
│ │ └── use-toast.ts
│ └── lib/
│ ├── game/
│ │ ├── __tests__/
│ │ │ ├── store-method-tests/
│ │ │ ├── achievements.test.ts
│ │ │ ├── activity-log.test.ts
│ │ │ ├── bug-fixes.test.ts
│ │ │ ├── combat-actions.test.ts
│ │ │ ├── combat-utils.test.ts
│ │ │ ├── computed-stats.test.ts
│ │ │ ├── crafting-utils-basic.test.ts
│ │ │ ├── crafting-utils-equipment.test.ts
│ │ │ ├── crafting-utils-recipe.test.ts
│ │ │ ├── crafting-utils-time.test.ts
│ │ │ ├── cross-module-combat-meditation.test.ts
│ │ │ ├── cross-module-helpers.ts
│ │ │ ├── cross-module-lifecycle-consistency.test.ts
│ │ │ ├── cross-module-prestige-discipline.test.ts
│ │ │ ├── discipline-math.test.ts
│ │ │ ├── discipline-prerequisites.test.ts
│ │ │ ├── discipline-reactivate-bug.test.ts
│ │ │ ├── enemy-barrier-utils.test.ts
│ │ │ ├── enemy-generator.test.ts
│ │ │ ├── enemy-utils.test.ts
│ │ │ ├── floor-utils.test.ts
│ │ │ ├── floor-utils.upgraded.test.ts
│ │ │ ├── formatting.test.ts
│ │ │ ├── guardian-names.test.ts
│ │ │ ├── mana-utils.test.ts
│ │ │ ├── pact-utils.test.ts
│ │ │ ├── persistence.test.ts
│ │ │ ├── regression-fixes.test.ts
│ │ │ ├── room-utils-floor-state.test.ts
│ │ │ ├── room-utils.test.ts
│ │ │ ├── spire-utils.test.ts
│ │ │ ├── store-actions-combat-prestige.test.ts
│ │ │ ├── store-actions-discipline.test.ts
│ │ │ ├── store-actions-mana.test.ts
│ │ │ ├── store-actions.test.ts
│ │ │ └── tick-integration.test.ts
│ │ ├── constants/
│ │ │ ├── spells-modules/
│ │ │ │ ├── advanced-spells.ts
│ │ │ │ ├── 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
│ │ │ ├── index.ts
│ │ │ ├── prestige.ts
│ │ │ ├── rooms.ts
│ │ │ └── spells.ts
│ │ ├── crafting-actions/
│ │ │ ├── application-actions.ts
│ │ │ ├── computed-getters.ts
│ │ │ ├── crafting-equipment-actions.ts
│ │ │ ├── crafting-material-actions.ts
│ │ │ ├── design-actions.ts
│ │ │ ├── disenchant-actions.ts
│ │ │ ├── equipment-actions.ts
│ │ │ ├── index.ts
│ │ │ └── preparation-actions.ts
│ │ ├── data/
│ │ │ ├── disciplines/
│ │ │ │ ├── base.ts
│ │ │ │ ├── elemental-regen-advanced.ts
│ │ │ │ ├── elemental-regen.ts
│ │ │ │ ├── elemental.ts
│ │ │ │ ├── enchanter-special.ts
│ │ │ │ ├── enchanter-spells.ts
│ │ │ │ ├── enchanter-utility.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-material-recipes.ts
│ │ │ ├── fabricator-physical-recipes.ts
│ │ │ ├── fabricator-recipe-types.ts
│ │ │ ├── fabricator-recipes.ts
│ │ │ ├── fabricator-wizard-recipes.ts
│ │ │ ├── guardian-data.ts
│ │ │ ├── guardian-encounters.ts
│ │ │ └── loot-drops.ts
│ │ ├── effects/
│ │ │ ├── discipline-effects.ts
│ │ │ ├── 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
│ │ │ ├── crafting-equipment-tick.ts
│ │ │ ├── crafting-initial-state.ts
│ │ │ ├── craftingStore.ts
│ │ │ ├── craftingStore.types.ts
│ │ │ ├── discipline-slice.ts
│ │ │ ├── gameActions.ts
│ │ │ ├── gameHooks.ts
│ │ │ ├── gameLoopActions.ts
│ │ │ ├── gameStore.ts
│ │ │ ├── gameStore.types.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-fabricator.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
View File
@@ -0,0 +1 @@
Here are all the generated files.
+196
View File
@@ -0,0 +1,196 @@
'use client';
import { useEffect, useState } from 'react';
import { io } from 'socket.io-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
type User = {
id: string;
username: string;
}
type Message = {
id: string;
username: string;
content: string;
timestamp: Date | string;
type: 'user' | 'system';
}
export default function SocketDemo() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [username, setUsername] = useState('');
const [isUsernameSet, setIsUsernameSet] = useState(false);
const [socket, setSocket] = useState<any>(null);
const [isConnected, setIsConnected] = useState(false);
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
// Connect to websocket server
// Never use PORT in the URL, alyways use XTransformPort
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
const socketInstance = io('/?XTransformPort=3003', {
transports: ['websocket', 'polling'],
forceNew: true,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
timeout: 10000
})
setSocket(socketInstance);
socketInstance.on('connect', () => {
setIsConnected(true);
});
socketInstance.on('disconnect', () => {
setIsConnected(false);
});
socketInstance.on('message', (msg: Message) => {
setMessages(prev => [...prev, msg]);
});
socketInstance.on('user-joined', (data: { user: User; message: Message }) => {
setMessages(prev => [...prev, data.message]);
setUsers(prev => {
if (!prev.find(u => u.id === data.user.id)) {
return [...prev, data.user];
}
return prev;
});
});
socketInstance.on('user-left', (data: { user: User; message: Message }) => {
setMessages(prev => [...prev, data.message]);
setUsers(prev => prev.filter(u => u.id !== data.user.id));
});
socketInstance.on('users-list', (data: { users: User[] }) => {
setUsers(data.users);
});
return () => {
socketInstance.disconnect();
};
}, []);
const handleJoin = () => {
if (socket && username.trim() && isConnected) {
socket.emit('join', { username: username.trim() });
setIsUsernameSet(true);
}
};
const sendMessage = () => {
if (socket && inputMessage.trim() && username.trim()) {
socket.emit('message', {
content: inputMessage.trim(),
username: username.trim()
});
setInputMessage('');
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
sendMessage();
}
};
return (
<div className="container mx-auto p-4 max-w-2xl">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
WebSocket Demo
<span className={`text-sm px-2 py-1 rounded ${isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!isUsernameSet ? (
<div className="space-y-2">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleJoin();
}
}}
placeholder="Enter your username..."
disabled={!isConnected}
className="flex-1"
/>
<Button
onClick={handleJoin}
disabled={!isConnected || !username.trim()}
className="w-full"
>
Join Chat
</Button>
</div>
) : (
<>
<ScrollArea className="h-80 w-full border rounded-md p-4">
<div className="space-y-2">
{messages.length === 0 ? (
<p className="text-gray-500 text-center">No messages yet</p>
) : (
messages.map((msg) => (
<div key={msg.id} className="border-b pb-2 last:border-b-0">
<div className="flex justify-between items-start">
<div className="flex-1">
<p className={`text-sm font-medium ${msg.type === 'system'
? 'text-blue-600 italic'
: 'text-gray-700'
}`}>
{msg.username}
</p>
<p className={`${msg.type === 'system'
? 'text-blue-500 italic'
: 'text-gray-900'
}`}>
{msg.content}
</p>
</div>
<span className="text-xs text-gray-500">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
</div>
))
)}
</div>
</ScrollArea>
<div className="flex space-x-2">
<Input
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
disabled={!isConnected}
className="flex-1"
/>
<Button
onClick={sendMessage}
disabled={!isConnected || !inputMessage.trim()}
>
Send
</Button>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { createServer } from 'http'
import { Server } from 'socket.io'
const httpServer = createServer()
const io = new Server(httpServer, {
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
path: '/',
cors: {
origin: "*",
methods: ["GET", "POST"]
},
pingTimeout: 60000,
pingInterval: 25000,
})
interface User {
id: string
username: string
}
interface Message {
id: string
username: string
content: string
timestamp: Date
type: 'user' | 'system'
}
const users = new Map<string, User>()
const generateMessageId = () => Math.random().toString(36).substr(2, 9)
const createSystemMessage = (content: string): Message => ({
id: generateMessageId(),
username: 'System',
content,
timestamp: new Date(),
type: 'system'
})
const createUserMessage = (username: string, content: string): Message => ({
id: generateMessageId(),
username,
content,
timestamp: new Date(),
type: 'user'
})
io.on('connection', (socket) => {
console.log(`User connected: ${socket.id}`)
// Add test event handler
socket.on('test', (data) => {
console.log('Received test message:', data)
socket.emit('test-response', {
message: 'Server received test message',
data: data,
timestamp: new Date().toISOString()
})
})
socket.on('join', (data: { username: string }) => {
const { username } = data
// Create user object
const user: User = {
id: socket.id,
username
}
// Add to user list
users.set(socket.id, user)
// Send join message to all users
const joinMessage = createSystemMessage(`${username} joined the chat room`)
io.emit('user-joined', { user, message: joinMessage })
// Send current user list to new user
const usersList = Array.from(users.values())
socket.emit('users-list', { users: usersList })
console.log(`${username} joined the chat room, current online users: ${users.size}`)
})
socket.on('message', (data: { content: string; username: string }) => {
const { content, username } = data
const user = users.get(socket.id)
if (user && user.username === username) {
const message = createUserMessage(username, content)
io.emit('message', message)
console.log(`${username}: ${content}`)
}
})
socket.on('disconnect', () => {
const user = users.get(socket.id)
if (user) {
// Remove from user list
users.delete(socket.id)
// Send leave message to all users
const leaveMessage = createSystemMessage(`${user.username} left the chat room`)
io.emit('user-left', { user: { id: socket.id, username: user.username }, message: leaveMessage })
console.log(`${user.username} left the chat room, current online users: ${users.size}`)
} else {
console.log(`User disconnected: ${socket.id}`)
}
})
socket.on('error', (error) => {
console.error(`Socket error (${socket.id}):`, error)
})
})
const PORT = 3003
httpServer.listen(PORT, () => {
console.log(`WebSocket server running on port ${PORT}`)
})
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('Received SIGTERM signal, shutting down server...')
httpServer.close(() => {
console.log('WebSocket server closed')
process.exit(0)
})
})
process.on('SIGINT', () => {
console.log('Received SIGINT signal, shutting down server...')
httpServer.close(() => {
console.log('WebSocket server closed')
process.exit(0)
})
})
View File
-13694
View File
File diff suppressed because it is too large Load Diff
+66 -63
View File
@@ -3,95 +3,98 @@
"version": "0.2.0", "version": "0.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000 --hostname 0.0.0.0 2>&1 | tee dev.log", "dev": "next dev -p 3000 2>&1 | tee dev.log",
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/", "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", "start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log",
"typecheck": "tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest", "test": "vitest",
"test:e2e": "playwright test",
"test:coverage": "vitest --coverage", "test:coverage": "vitest --coverage",
"prepare": "husky" "db:push": "prisma db push",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.1.1",
"@radix-ui/react-accordion": "^1.2.12", "@mdxeditor/editor": "^3.39.1",
"@radix-ui/react-alert-dialog": "^1.1.15", "@prisma/client": "^6.11.1",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-toggle": "^1.1.9",
"@reactuses/core": "^6.3.1", "@radix-ui/react-toggle-group": "^1.1.10",
"@tanstack/react-query": "^5.100.10", "@radix-ui/react-tooltip": "^1.2.7",
"@reactuses/core": "^6.0.5",
"@tanstack/react-query": "^5.82.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.23.2",
"husky": "^9.1.7",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^16.2.6", "next": "^16.1.1",
"next-auth": "^4.24.11",
"next-intl": "^4.3.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.6", "prisma": "^6.11.1",
"react-day-picker": "^9.14.0", "react": "^19.0.0",
"react-dom": "^19.2.6", "react-day-picker": "^9.8.0",
"react-hook-form": "^7.76.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.60.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.3",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"sharp": "^0.34.5", "sharp": "^0.34.3",
"sonner": "^2.0.7", "sonner": "^2.0.6",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.1", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.4.3", "z-ai-web-dev-sdk": "^0.0.17",
"zustand": "^5.0.13" "zod": "^4.0.2",
"zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4",
"@tailwindcss/postcss": "^4.3.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14", "@types/react": "^19",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19",
"bun-types": "^1.3.14", "bun-types": "^1.3.4",
"eslint": "^9.39.4", "eslint": "^9",
"eslint-config-next": "^16.2.6", "eslint-config-next": "^16.1.1",
"jsdom": "^29.1.1", "jsdom": "^29.0.1",
"lint-staged": "^17.0.5", "tailwindcss": "^4",
"madge": "^8.0.0", "tw-animate-css": "^1.3.5",
"tailwindcss": "^4.3.0", "typescript": "^5",
"tw-animate-css": "^1.4.0", "vitest": "^4.1.2"
"typescript": "^5.9.3",
"vitest": "^4.1.6"
} }
} }
@@ -1,285 +0,0 @@
# 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 | });
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

@@ -1,348 +0,0 @@
# 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 | });
```
@@ -1,285 +0,0 @@
# 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 | });
```
@@ -1,260 +0,0 @@
# 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 | });
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

@@ -1,280 +0,0 @@
# 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 | });
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

@@ -1,375 +0,0 @@
# 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 | });
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

@@ -1,280 +0,0 @@
# 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 | });
```
@@ -1,280 +0,0 @@
# 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 | });
```
File diff suppressed because one or more lines are too long
-22
View File
@@ -1,22 +0,0 @@
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'] },
},
],
});
+32
View File
@@ -0,0 +1,32 @@
// 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
}
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello, world!" });
}
-63
View File
@@ -1,63 +0,0 @@
'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>
);
}
-72
View File
@@ -1,72 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { DebugName } from '@/components/game/debug/debug-context';
import { SPELLS_DEF } from '@/lib/game/constants';
import type { SpellDef } from '@/lib/game/types';
export function GrimoireTab() {
const [grimoireSpells, setGrimoireSpells] = useState<[string, SpellDef][]>([]);
useEffect(() => {
if (typeof window !== 'undefined' && SPELLS_DEF) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setGrimoireSpells(
Object.entries(SPELLS_DEF).filter((entry): entry is [string, SpellDef] => !!entry[1].grimoire)
);
}
}, []);
if (grimoireSpells.length === 0) {
return (
<div className="p-4 text-center text-gray-400">
No grimoire spells available yet. Defeat guardians to unlock spells.
</div>
);
}
const availablePages = Math.ceil(grimoireSpells.length / 12);
return (
<DebugName name="GrimoireTab">
<div className="space-y-4">
<div className="text-sm text-gray-400">
<p className="mb-2">A vast tome of arcane knowledge. Study carefully each spell costs insight to transcribe into your repertoire.</p>
<p>Available pages: {availablePages}. Spells in grimoire: {grimoireSpells.length}.</p>
</div>
<ScrollArea className="h-[600px] rounded border border-gray-700 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{grimoireSpells.map(([id, spell]) => (
<div
key={id}
className="p-4 bg-gray-800/50 rounded border border-gray-600 hover:border-gray-500 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<span className="font-bold text-gray-100">{spell.name}</span>
<Badge variant="outline" className="border-gray-600">
{spell.elem}
</Badge>
</div>
{spell.desc && <p className="text-sm text-gray-400 mb-3">{spell.desc}</p>}
<div className="text-xs text-gray-500 space-y-1">
<div>Cost: {spell.cost.amount} {
spell.cost.type === 'element'
? spell.cost.element
: 'raw mana'
}</div>
<div>Power: {spell.dmg}</div>
{spell.effects && spell.effects.length > 0 && (
<div>Effects: {spell.effects.map(e => e.type).join(', ')}</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
</DebugName>
);
}
-127
View File
@@ -1,127 +0,0 @@
'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 { getUnifiedEffects } from '@/lib/game/effects';
import { getMeditationBonus, getIncursionStrength } from '@/lib/game/stores';
import { computeTotalMaxMana, computeTotalRegen, computeTotalClickMana } from '@/lib/game/effects';
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
export function LeftPanel() {
const [isGathering, setIsGathering] = useState(false);
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 disciplineEffects = computeDisciplineEffects();
const maxMana = computeTotalMaxMana({ skills: {}, prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, equippedInstances, equipmentInstances }, upgradeEffects);
const baseRegen = computeTotalRegen({ skills: {}, prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, equippedInstances, equipmentInstances }, upgradeEffects);
const clickMana = computeTotalClickMana({ skills: {}, skillUpgrades: {}, skillTiers: {}, equippedInstances, equipmentInstances }, upgradeEffects);
const meditationMultiplier = getMeditationBonus(meditateTicks, {}, upgradeEffects.meditationEfficiency, disciplineEffects.meditationCapBonus);
const incursionStrength = getIncursionStrength(useGameStore((s) => s.day), useGameStore((s) => s.hour));
const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
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>
);
}
+117 -163
View File
@@ -1,163 +1,136 @@
@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 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 "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --font-sans: var(--font-geist-sans);
--color-card-foreground: var(--card-foreground); --font-mono: var(--font-geist-mono);
--color-primary: var(--primary); --color-sidebar-ring: var(--sidebar-ring);
--color-primary-foreground: var(--primary-foreground); --color-sidebar-border: var(--sidebar-border);
--color-muted: var(--muted); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-muted-foreground: var(--muted-foreground); --color-sidebar-accent: var(--sidebar-accent);
--color-border: var(--border); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-input: var(--input); --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-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive); --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);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
} }
:root { :root {
--radius: 0.5rem; --radius: 0.625rem;
--background: #060811;
/* === Background Colors (Depth Levels) === */ --foreground: #c8d8f8;
--bg-base: #060811; --card: #0C1020;
--bg-surface: #0C1020; --card-foreground: #c8d8f8;
--bg-elevated: #111628; --popover: #111628;
--bg-sunken: #181f35; --popover-foreground: #c8d8f8;
--primary: #3B6FE8;
/* === 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; --primary-foreground: #ffffff;
--secondary: var(--bg-sunken); --secondary: #1e2a45;
--secondary-foreground: var(--text-primary); --secondary-foreground: #c8d8f8;
--muted: var(--bg-sunken); --muted: #181f35;
--muted-foreground: var(--text-secondary); --muted-foreground: #7a92c0;
--accent: var(--interactive-secondary); --accent: #2a3a60;
--accent-foreground: var(--text-primary); --accent-foreground: #c8d8f8;
--destructive: var(--color-danger); --destructive: #C0392B;
--border: var(--border-subtle); --border: #1e2a45;
--input: var(--border-subtle); --input: #1e2a45;
--ring: var(--border-focus); --ring: #3B6FE8;
--chart-1: var(--mana-fire); --chart-1: #FF6B35;
--chart-2: var(--mana-water); --chart-2: #4ECDC4;
--chart-3: var(--mana-light); --chart-3: #9B59B6;
--chart-4: var(--color-success); --chart-4: #2ECC71;
--chart-5: var(--mana-lightning); --chart-5: #FFD700;
--sidebar: var(--bg-surface); --sidebar: #0C1020;
--sidebar-foreground: var(--text-primary); --sidebar-foreground: #c8d8f8;
--sidebar-primary: var(--mana-light); --sidebar-primary: #D4A843;
--sidebar-primary-foreground: #0C1020; --sidebar-primary-foreground: #0C1020;
--sidebar-accent: var(--interactive-secondary); --sidebar-accent: #1e2a45;
--sidebar-accent-foreground: var(--text-primary); --sidebar-accent-foreground: #c8d8f8;
--sidebar-border: var(--border-subtle); --sidebar-border: #1e2a45;
--sidebar-ring: var(--mana-light); --sidebar-ring: #D4A843;
/* Legacy game colors (kept for compatibility) */ /* Game-specific colors */
--game-bg: var(--bg-base); --game-bg: #060811;
--game-bg1: var(--bg-surface); --game-bg1: #0C1020;
--game-bg2: var(--bg-elevated); --game-bg2: #111628;
--game-bg3: var(--bg-sunken); --game-bg3: #181f35;
--game-border: var(--border-subtle); --game-border: #1e2a45;
--game-border2: var(--border-default); --game-border2: #2a3a60;
--game-text: var(--text-primary); --game-text: #c8d8f8;
--game-text2: var(--text-secondary); --game-text2: #7a92c0;
--game-text3: var(--text-muted); --game-text3: #4a5f8a;
--game-gold: var(--mana-light); --game-gold: #D4A843;
--game-gold2: #A87830; --game-gold2: #A87830;
--game-purple: #7C5CBF; --game-purple: #7C5CBF;
--game-purpleL: #A07EE0; --game-purpleL: #A07EE0;
--game-accent: var(--interactive-primary); --game-accent: #3B6FE8;
--game-accentL: var(--interactive-primary-hover); --game-accentL: #5B8FFF;
--game-danger: var(--color-danger); --game-danger: #C0392B;
--game-success: var(--color-success); --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;
} }
@layer base { @layer base {
@@ -166,13 +139,13 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: var(--font-body); font-family: 'Crimson Text', Georgia, serif;
} }
} }
/* Game-specific styles */ /* Game-specific styles */
.game-root { .game-root {
font-family: var(--font-body); font-family: 'Crimson Text', Georgia, serif;
background: var(--game-bg); background: var(--game-bg);
color: var(--game-text); color: var(--game-text);
min-height: 100vh; min-height: 100vh;
@@ -186,7 +159,7 @@
} }
.game-title { .game-title {
font-family: var(--font-display); font-family: 'Cinzel', serif;
background: linear-gradient(135deg, var(--game-gold) 0%, var(--game-purpleL) 50%, var(--game-accentL) 100%); background: linear-gradient(135deg, var(--game-gold) 0%, var(--game-purpleL) 50%, var(--game-accentL) 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
@@ -194,13 +167,13 @@
} }
.game-panel-title { .game-panel-title {
font-family: var(--font-display); font-family: 'Cinzel', serif;
letter-spacing: 2px; letter-spacing: 2px;
text-transform: uppercase; text-transform: uppercase;
} }
.game-mono { .game-mono {
font-family: var(--font-ui); font-family: 'JetBrains Mono', monospace;
} }
/* Scrollbar */ /* Scrollbar */
@@ -245,25 +218,6 @@
box-shadow: 0 0 15px rgba(60, 111, 232, 0.4); 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 */ /* Button hover effects */
.btn-game { .btn-game {
transition: all 0.2s ease; transition: all 0.2s ease;
+28 -18
View File
@@ -1,25 +1,38 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import localFont from "next/font/local"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { GameToaster } from "@/components/game/GameToast";
import { DebugProvider } from "@/components/game/debug/debug-context";
const geistSans = localFont({ const geistSans = Geist({
src: '../../public/fonts/GeistVF.woff', variable: "--font-geist-sans",
variable: '--font-geist-sans', subsets: ["latin"],
}); });
const geistMono = localFont({ const geistMono = Geist_Mono({
src: '../../public/fonts/GeistMonoVF.woff', variable: "--font-geist-mono",
variable: '--font-geist-mono', subsets: ["latin"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Mana Loop", title: "Z.ai Code Scaffold - AI-Powered Development",
description: "A time-loop incremental game where you climb the Spire and sign pacts with guardians.", description: "Modern Next.js scaffold optimized for AI-powered development with Z.ai. Built with TypeScript, Tailwind CSS, and shadcn/ui.",
keywords: ["Mana Loop", "incremental game", "idle game", "time loop"], keywords: ["Z.ai", "Next.js", "TypeScript", "Tailwind CSS", "shadcn/ui", "AI development", "React"],
authors: [{ name: "Mana Loop Team" }], authors: [{ name: "Z.ai Team" }],
icons: {
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
},
openGraph: {
title: "Z.ai Code Scaffold",
description: "AI-powered development with modern React stack",
url: "https://chat.z.ai",
siteName: "Z.ai",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Z.ai Code Scaffold",
description: "AI-powered development with modern React stack",
},
}; };
export default function RootLayout({ export default function RootLayout({
@@ -32,11 +45,8 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`} className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
> >
<DebugProvider> {children}
{children} <Toaster />
<Toaster />
<GameToaster />
</DebugProvider>
</body> </body>
</html> </html>
); );
Regular → Executable
+419 -219
View File
@@ -1,239 +1,439 @@
'use client'; 'use client';
import { useEffect, useState, lazy, Suspense } from 'react'; import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useGameStore, useGameLoop, fmt, fmtDec, getFloorElement, computeMaxMana, computeRegen, computeClickMana, getMeditationBonus, getIncursionStrength, canAffordSpellCost } from '@/lib/game/store';
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
import { import { getDamageBreakdown } from '@/lib/game/computed-stats';
useGameStore, import { ELEMENTS, GUARDIANS, SPELLS_DEF, PRESTIGE_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
useUIStore, import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
useManaStore, import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
useCombatStore, import { formatHour } from '@/lib/game/formatting';
usePrestigeStore, import { Button } from '@/components/ui/button';
useCraftingStore,
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TooltipProvider } from '@/components/ui/tooltip'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ErrorBoundary } from '@/components/ErrorBoundary'; import { Badge } from '@/components/ui/badge';
import { RotateCcw } from 'lucide-react';
import { GameOverScreen } from './components/GameOverScreen'; import { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab, EquipmentTab, AttunementsTab, DebugTab } from '@/components/game/tabs';
import { LeftPanel } from './components/LeftPanel'; import { ActionButtons, CalendarDisplay, ManaDisplay, TimeDisplay } from '@/components/game';
import { GrimoireTab } from './components/GrimoireTab'; import { LootInventoryDisplay } from '@/components/game/LootInventory';
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
// 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({}, disciplineEffects);
const meditationMultiplier = getMeditationBonus(meditateTicks, {}, upgradeEffects.meditationEfficiency, disciplineEffects.meditationCapBonus);
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() { export default function ManaLoopGame() {
const [activeTab, setActiveTab] = useState('spells'); const [activeTab, setActiveTab] = useState('spire');
const [isGathering, setIsGathering] = useState(false);
useGameLoop();
// Game store
const { day, hour, initGame } = useGameStore(useShallow(s => ({ const store = useGameStore();
day: s.day, const gameLoop = useGameLoop();
hour: s.hour,
initGame: s.initGame, // Computed effects from upgrades and equipment
}))); const upgradeEffects = getUnifiedEffects(store);
const { insight, loopInsight } = usePrestigeStore(useShallow(s => ({
insight: s.insight, // Derived stats
loopInsight: s.loopInsight, const maxMana = computeMaxMana(store, upgradeEffects);
}))); const baseRegen = computeRegen(store, upgradeEffects);
const spireMode = useCombatStore((s) => s.spireMode); const clickMana = computeClickMana(store);
const gameOver = useUIStore((s) => s.gameOver); const floorElem = getFloorElement(store.currentFloor);
const floorElemDef = ELEMENTS[floorElem];
useGameDerivedStats(); 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(() => { useEffect(() => {
initGame(); if (!isGathering) return;
}, [initGame]);
let lastGatherTime = 0;
const [mounted, setMounted] = useState(false); const minGatherInterval = 100;
useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect 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(() => { useEffect(() => {
if (spireMode) { const cleanup = gameLoop.start();
setActiveTab('spells'); // eslint-disable-line react-hooks/set-state-in-effect return cleanup;
} }, [gameLoop]);
}, [spireMode]);
if (gameOver) { // Check if spell can be cast
return <GameOverScreen day={day} hour={hour} insightGained={loopInsight} totalInsight={insight} />; const canCastSpell = (spellId: string): boolean => {
} const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
if (!mounted) return <div className="p-4 text-center text-gray-400">Loading...</div>; // Game Over Screen
if (store.gameOver) {
if (spireMode) {
return ( return (
<ErrorBoundary <div className="fixed inset-0 game-overlay flex items-center justify-center z-50">
onReset={() => { <Card className="bg-gray-900 border-gray-600 max-w-md w-full mx-4 shadow-2xl">
useCombatStore.getState().exitSpireMode(); <CardHeader>
}} <CardTitle className={`text-3xl text-center game-title ${store.victory ? 'text-amber-400' : 'text-red-400'}`}>
> {store.victory ? 'VICTORY!' : 'LOOP ENDS'}
<Suspense fallback={<div className="p-4 text-center text-gray-400">Loading spire...</div>}> </CardTitle>
<SpireCombatPage /> </CardHeader>
</Suspense> <CardContent className="space-y-4">
</ErrorBoundary> <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>
); );
} }
return ( return (
<ErrorBoundary> <TooltipProvider>
<TooltipProvider> <div className="game-root min-h-screen flex flex-col">
<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"> <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"> <div className="flex items-center justify-between">
<h1 className="text-xl font-bold game-title tracking-wider">MANA LOOP</h1> <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 className="flex items-center gap-4">
<TimeDisplay
day={store.day}
hour={store.hour}
isPaused={store.isPaused}
togglePause={store.togglePause}
/>
</div>
</div>
</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 */}
<ManaDisplay
rawMana={store.rawMana}
maxMana={maxMana}
effectiveRegen={effectiveRegen}
meditationMultiplier={meditationMultiplier}
clickMana={clickMana}
isGathering={isGathering}
onGatherStart={handleGatherStart}
onGatherEnd={handleGatherEnd}
elements={store.elements}
/>
{/* Action Buttons */}
<ActionButtons
currentAction={store.currentAction}
designProgress={store.designProgress}
preparationProgress={store.preparationProgress}
applicationProgress={store.applicationProgress}
setAction={store.setAction}
/>
{/* Calendar */}
<CalendarDisplay
day={store.day}
hour={store.hour}
incursionStrength={incursionStrength}
/>
{/* Loot Inventory */}
<LootInventoryDisplay
inventory={store.lootInventory}
elements={store.elements}
equipmentInstances={store.equipmentInstances}
onDeleteMaterial={store.deleteMaterial}
onDeleteEquipment={store.deleteEquipmentInstance}
/>
{/* Achievements */}
<AchievementsDisplay
achievements={store.achievements}
gameState={{
maxFloorReached: store.maxFloorReached,
totalManaGathered: store.totalManaGathered,
signedPacts: store.signedPacts,
totalSpellsCast: store.totalSpellsCast,
totalDamageDealt: store.totalDamageDealt,
totalCraftsCompleted: store.totalCraftsCompleted,
combo: store.combo,
}}
/>
</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="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="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">
<SpireTab store={store} />
</TabsContent>
<TabsContent value="attunements">
<AttunementsTab store={store} />
</TabsContent>
<TabsContent value="skills">
<SkillsTab store={store} />
</TabsContent>
<TabsContent value="spells">
<SpellsTab store={store} />
</TabsContent>
<TabsContent value="equipment">
<EquipmentTab store={store} />
</TabsContent>
<TabsContent value="crafting">
<CraftingTab store={store} />
</TabsContent>
<TabsContent value="lab">
<LabTab store={store} />
</TabsContent>
<TabsContent value="stats">
<StatsTab
store={store}
upgradeEffects={upgradeEffects}
maxMana={maxMana}
baseRegen={baseRegen}
clickMana={clickMana}
meditationMultiplier={meditationMultiplier}
effectiveRegen={effectiveRegen}
incursionStrength={incursionStrength}
manaCascadeBonus={manaCascadeBonus}
studySpeedMult={studySpeedMult}
studyCostMult={studyCostMult}
/>
</TabsContent>
<TabsContent value="grimoire">
{renderGrimoireTab()}
</TabsContent>
<TabsContent value="debug">
<DebugTab store={store} />
</TabsContent>
</Tabs>
</div>
</main>
</div>
</TooltipProvider>
);
// 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>
</div> </div>
</header> </CardContent>
</Card>
<main className="flex-1 flex flex-col md:flex-row gap-4 p-4"> {/* Signed Pacts */}
<LeftPanel /> <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>
<div className="flex-1 min-w-0"> {/* Prestige Upgrades */}
<Tabs value={activeTab} onValueChange={setActiveTab}> <Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<TabTriggers /> <CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
<TabsContent value="spells"><LazyTab name="spells"><SpellsTab /></LazyTab></TabsContent> </CardHeader>
<TabsContent value="stats"><LazyTab name="stats"><StatsTab /></LazyTab></TabsContent> <CardContent>
<TabsContent value="disciplines"><LazyTab name="disciplines"><DisciplinesTab /></LazyTab></TabsContent> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<TabsContent value="grimoire"><GrimoireTab /></TabsContent> {Object.entries(PRESTIGE_DEF).map(([id, def]) => {
<TabsContent value="debug"><LazyTab name="debug"><DebugTab /></LazyTab></TabsContent> const level = store.prestigeUpgrades[id] || 0;
<TabsContent value="attunements"><LazyTab name="attunements"><AttunementsTab /></LazyTab></TabsContent> const maxed = level >= def.max;
<TabsContent value="achievements"><LazyTab name="achievements"><AchievementsTab /></LazyTab></TabsContent> const canBuy = !maxed && store.insight >= def.cost;
<TabsContent value="prestige"><LazyTab name="prestige"><PrestigeTab /></LazyTab></TabsContent>
<TabsContent value="equipment"><LazyTab name="equipment"><EquipmentTab /></LazyTab></TabsContent> return (
<TabsContent value="golemancy"><LazyTab name="golemancy"><GolemancyTab /></LazyTab></TabsContent> <div
<TabsContent value="pacts"><LazyTab name="pacts"><GuardianPactsTab /></LazyTab></TabsContent> key={id}
<TabsContent value="spire"><LazyTab name="spire"><SpireSummaryTab /></LazyTab></TabsContent> className="p-3 rounded border border-gray-700 bg-gray-800/50"
<TabsContent value="crafting"><LazyTab name="crafting"><CraftingTab /></LazyTab></TabsContent> >
</Tabs> <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> </div>
</main>
</div> {/* Reset Game Button */}
</TooltipProvider> <div className="mt-4 pt-4 border-t border-gray-700">
</ErrorBoundary> <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';
-48
View File
@@ -1,48 +0,0 @@
'use client';
import { Component, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onReset?: () => void;
}
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) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="p-4 bg-red-900/20 border border-red-600/50 rounded">
<h3 className="text-red-400 font-bold mb-2">Something went wrong:</h3>
<pre className="text-xs text-red-300">{this.state.error?.message}</pre>
<pre className="text-xs text-gray-500 mt-2">{this.state.error?.stack}</pre>
{this.props.onReset && (
<button
onClick={this.props.onReset}
className="mt-3 px-3 py-1 bg-red-700 hover:bg-red-600 text-white text-xs rounded"
>
Reset &amp; Recover
</button>
)}
</div>
);
}
return this.props.children;
}
}
+175
View File
@@ -0,0 +1,175 @@
'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' | 'combo'>;
}
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 'combo':
return gameState.combo?.maxCombo || 0;
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>
);
}
+61 -128
View File
@@ -1,153 +1,86 @@
'use client'; 'use client';
import { Sparkles, Swords, BookOpen, Target, FlaskConical, Cog, Hammer, Dumbbell } from 'lucide-react'; import { Button } from '@/components/ui/button';
import { Sparkles, Swords, BookOpen, Target, FlaskConical } from 'lucide-react';
import type { GameAction } from '@/lib/game/types'; import type { GameAction } from '@/lib/game/types';
interface ActionButtonsProps { interface ActionButtonsProps {
currentAction: GameAction; currentAction: GameAction;
currentStudyTarget?: { type: 'skill' | 'spell'; id: string; progress: number; required: number } | null;
designProgress: { progress: number; required: number } | null; designProgress: { progress: number; required: number } | null;
designProgress2: { progress: number; required: number } | null;
preparationProgress: { progress: number; required: number } | null; preparationProgress: { progress: number; required: number } | null;
applicationProgress: { progress: number; required: number } | null; applicationProgress: { progress: number; required: number } | null;
equipmentCraftingProgress: { progress: number; required: number } | null; setAction: (action: GameAction) => void;
}
// Map action IDs to labels and icons
const ACTION_CONFIG: Record<string, { label: string; icon: typeof Sparkles; color: string }> = {
meditate: { label: 'Meditating', icon: Sparkles, color: 'text-blue-400' },
practicing: { label: 'Practicing Discipline', icon: Dumbbell, color: 'text-amber-400' },
climb: { label: 'Climbing', icon: Swords, color: 'text-green-400' },
study: { label: 'Studying', icon: BookOpen, color: 'text-yellow-400' },
design: { label: 'Designing Enchantment', icon: Target, color: 'text-purple-400' },
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({ export function ActionButtons({
currentAction, currentAction,
currentStudyTarget,
designProgress, designProgress,
designProgress2,
preparationProgress, preparationProgress,
applicationProgress, applicationProgress,
equipmentCraftingProgress, setAction,
}: ActionButtonsProps) { }: ActionButtonsProps) {
const config = ACTION_CONFIG[currentAction] || { label: currentAction, icon: Sparkles, color: 'text-gray-400' }; const actions: { id: GameAction; label: string; icon: typeof Swords }[] = [
const Icon = config.icon; { id: 'meditate', label: 'Meditate', icon: Sparkles },
{ id: 'climb', label: 'Climb', icon: Swords },
{ id: 'study', label: 'Study', icon: BookOpen },
];
// Calculate additional info for specific actions const hasDesignProgress = designProgress !== null;
const getActionDetails = () => { const hasPrepProgress = preparationProgress !== null;
switch (currentAction) { const hasAppProgress = applicationProgress !== null;
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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="bg-gray-800/50 rounded-lg p-3 border border-gray-700"> <div className="grid grid-cols-3 gap-2">
<div className="flex items-center gap-2"> {actions.map(({ id, label, icon: Icon }) => (
<Icon className={`w-4 h-4 ${config.color}`} /> <Button
<span className="text-sm font-medium text-gray-200">Current Activity</span> key={id}
</div> variant={currentAction === id ? 'default' : 'outline'}
<div className={`text-lg font-semibold mt-1 ${config.color}`}> size="sm"
{config.label} className={`h-9 ${currentAction === id ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
</div> onClick={() => setAction(id)}
{getActionDetails()} >
<Icon className="w-4 h-4 mr-1" />
{/* Show second design slot if active */} {label}
{designProgress2 && ( </Button>
<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>
{/* 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>
)}
</div> </div>
); );
} }
ActionButtons.displayName = "ActionButtons";
ProgressBar.displayName = "ProgressBar";
-19
View File
@@ -1,19 +0,0 @@
'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';
-108
View File
@@ -1,108 +0,0 @@
'use client';
import { useMemo } from 'react';
import { useAttunementStore } from '@/lib/game/stores';
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
import { Separator } from '@/components/ui/separator';
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 attunementOrder = useMemo(() => {
const map = new Map<string, number>();
Object.values(ATTUNEMENTS_DEF).forEach((d, i) => map.set(d.id, i));
return map;
}, []);
const activeAttunements = useMemo(() => {
return Object.entries(attunements)
.filter(([, state]) => state.active)
.sort(([, a], [, b]) => (attunementOrder.get(a.id) ?? 0) - (attunementOrder.get(b.id) ?? 0));
}, [attunements, attunementOrder]);
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';
+50
View File
@@ -0,0 +1,50 @@
'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>
);
}
+143
View File
@@ -0,0 +1,143 @@
'use client';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Zap, Flame, Sparkles } from 'lucide-react';
import type { ComboState } from '@/lib/game/types';
import { ELEMENTS } from '@/lib/game/constants';
interface ComboMeterProps {
combo: ComboState;
isClimbing: boolean;
}
export function ComboMeter({ combo, isClimbing }: ComboMeterProps) {
const comboPercent = Math.min(100, combo.count);
const multiplierPercent = Math.min(100, ((combo.multiplier - 1) / 2) * 100); // Max 300% = 200% bonus
// Combo tier names
const getComboTier = (count: number): { name: string; color: string } => {
if (count >= 100) return { name: 'LEGENDARY', color: 'text-amber-400' };
if (count >= 75) return { name: 'Master', color: 'text-purple-400' };
if (count >= 50) return { name: 'Expert', color: 'text-blue-400' };
if (count >= 25) return { name: 'Adept', color: 'text-green-400' };
if (count >= 10) return { name: 'Novice', color: 'text-cyan-400' };
return { name: 'Building...', color: 'text-gray-400' };
};
const tier = getComboTier(combo.count);
const hasElementChain = combo.elementChain.length === 3 && new Set(combo.elementChain).size === 3;
if (!isClimbing && combo.count === 0) {
return null;
}
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Zap className="w-4 h-4" />
Combo Meter
{combo.count >= 10 && (
<Badge className={`ml-auto ${tier.color} bg-gray-800`}>
{tier.name}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Combo Count */}
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Hits</span>
<span className={`font-bold ${tier.color}`}>
{combo.count}
{combo.maxCombo > combo.count && (
<span className="text-gray-500 text-xs ml-2">max: {combo.maxCombo}</span>
)}
</span>
</div>
<Progress
value={comboPercent}
className="h-2 bg-gray-800"
/>
</div>
{/* Multiplier */}
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Multiplier</span>
<span className="font-bold text-amber-400">
{combo.multiplier.toFixed(2)}x
</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${multiplierPercent}%`,
background: `linear-gradient(90deg, #F59E0B, #EF4444)`,
}}
/>
</div>
</div>
{/* Element Chain */}
{combo.elementChain.length > 0 && (
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Element Chain</span>
{hasElementChain && (
<span className="text-green-400 text-xs">+25% bonus!</span>
)}
</div>
<div className="flex gap-1">
{combo.elementChain.map((elem, i) => {
const elemDef = ELEMENTS[elem];
return (
<div
key={i}
className="w-8 h-8 rounded border flex items-center justify-center text-xs"
style={{
borderColor: elemDef?.color || '#60A5FA',
backgroundColor: `${elemDef?.color}20`,
color: elemDef?.color || '#60A5FA',
}}
>
{elemDef?.sym || '?'}
</div>
);
})}
{/* Empty slots */}
{Array.from({ length: 3 - combo.elementChain.length }).map((_, i) => (
<div
key={`empty-${i}`}
className="w-8 h-8 rounded border border-gray-700 bg-gray-800/50 flex items-center justify-center text-gray-600"
>
?
</div>
))}
</div>
</div>
)}
{/* Decay Warning */}
{isClimbing && combo.count > 0 && combo.decayTimer <= 3 && (
<div className="text-xs text-red-400 flex items-center gap-1">
<Flame className="w-3 h-3" />
Combo decaying soon!
</div>
)}
{/* Not climbing warning */}
{!isClimbing && combo.count > 0 && (
<div className="text-xs text-amber-400 flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Resume climbing to maintain combo
</div>
)}
</CardContent>
</Card>
);
}
+161
View File
@@ -0,0 +1,161 @@
'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;
}
+405
View File
@@ -0,0 +1,405 @@
'use client';
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import { useSkillStore } from '@/lib/game/stores/skillStore';
import { useManaStore } from '@/lib/game/stores/manaStore';
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
import { useUIStore } from '@/lib/game/stores/uiStore';
import { useCombatStore } from '@/lib/game/stores/combatStore';
import { useGameStore, useGameLoop } from '@/lib/game/stores/gameStore';
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
import { getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
import { getTierMultiplier } from '@/lib/game/skill-evolution';
import {
computeMaxMana,
computeRegen,
computeClickMana,
getMeditationBonus,
canAffordSpellCost,
calcDamage,
getFloorElement,
getBoonBonuses,
getIncursionStrength,
} from '@/lib/game/utils';
import {
ELEMENTS,
GUARDIANS,
SPELLS_DEF,
HOURS_PER_TICK,
TICK_MS,
} from '@/lib/game/constants';
import type { ElementDef, GuardianDef, SpellDef, GameAction } from '@/lib/game/types';
// Define a unified store type that combines all stores
interface UnifiedStore {
// From gameStore (coordinator)
day: number;
hour: number;
incursionStrength: number;
containmentWards: number;
initialized: boolean;
tick: () => void;
resetGame: () => void;
gatherMana: () => void;
startNewLoop: () => void;
// From manaStore
rawMana: number;
meditateTicks: number;
totalManaGathered: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
setRawMana: (amount: number) => void;
addRawMana: (amount: number, max: number) => void;
spendRawMana: (amount: number) => boolean;
convertMana: (element: string, amount: number) => boolean;
unlockElement: (element: string, cost: number) => boolean;
craftComposite: (target: string, recipe: string[]) => boolean;
// From skillStore
skills: Record<string, number>;
skillProgress: Record<string, number>;
skillUpgrades: Record<string, string[]>;
skillTiers: Record<string, number>;
paidStudySkills: Record<string, number>;
currentStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
parallelStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
setSkillLevel: (skillId: string, level: number) => void;
startStudyingSkill: (skillId: string, rawMana: number) => { started: boolean; cost: number };
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => { started: boolean; cost: number };
cancelStudy: (retentionBonus: number) => void;
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
tierUpSkill: (skillId: string) => void;
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: Array<{ id: string; name: string; desc: string; milestone: 5 | 10; effect: { type: string; stat?: string; value?: number; specialId?: string } }>; selected: string[] };
// From prestigeStore
loopCount: number;
insight: number;
totalInsight: number;
loopInsight: number;
prestigeUpgrades: Record<string, number>;
memorySlots: number;
pactSlots: number;
memories: Array<{ skillId: string; level: number; tier: number; upgrades: string[] }>;
defeatedGuardians: number[];
signedPacts: number[];
pactRitualFloor: number | null;
pactRitualProgress: number;
doPrestige: (id: string) => void;
addMemory: (memory: { skillId: string; level: number; tier: number; upgrades: string[] }) => void;
removeMemory: (skillId: string) => void;
clearMemories: () => void;
startPactRitual: (floor: number, rawMana: number) => boolean;
cancelPactRitual: () => void;
removePact: (floor: number) => void;
defeatGuardian: (floor: number) => void;
// From combatStore
currentFloor: number;
floorHP: number;
floorMaxHP: number;
maxFloorReached: number;
activeSpell: string;
currentAction: GameAction;
castProgress: number;
spells: Record<string, { learned: boolean; level: number; studyProgress?: number }>;
setAction: (action: GameAction) => void;
setSpell: (spellId: string) => void;
learnSpell: (spellId: string) => void;
advanceFloor: () => void;
// From uiStore
log: string[];
paused: boolean;
gameOver: boolean;
victory: boolean;
addLog: (message: string) => void;
togglePause: () => void;
setPaused: (paused: boolean) => void;
setGameOver: (gameOver: boolean, victory?: boolean) => void;
}
interface GameContextValue {
// Unified store for backward compatibility
store: UnifiedStore;
// Individual stores for direct access if needed
skillStore: ReturnType<typeof useSkillStore.getState>;
manaStore: ReturnType<typeof useManaStore.getState>;
prestigeStore: ReturnType<typeof usePrestigeStore.getState>;
uiStore: ReturnType<typeof useUIStore.getState>;
combatStore: ReturnType<typeof useCombatStore.getState>;
// Computed effects from upgrades
upgradeEffects: ReturnType<typeof computeEffects>;
// Derived stats
maxMana: number;
baseRegen: number;
clickMana: number;
floorElem: string;
floorElemDef: ElementDef | undefined;
isGuardianFloor: boolean;
currentGuardian: GuardianDef | undefined;
activeSpellDef: SpellDef | undefined;
meditationMultiplier: number;
incursionStrength: number;
studySpeedMult: number;
studyCostMult: number;
// Effective regen calculations
effectiveRegenWithSpecials: number;
manaCascadeBonus: number;
effectiveRegen: number;
// DPS calculation
dps: number;
// Boons
activeBoons: ReturnType<typeof getBoonBonuses>;
// Helpers
canCastSpell: (spellId: string) => boolean;
hasSpecial: (effects: ReturnType<typeof computeEffects>, specialId: string) => boolean;
SPECIAL_EFFECTS: typeof SPECIAL_EFFECTS;
}
const GameContext = createContext<GameContextValue | null>(null);
export function GameProvider({ children }: { children: ReactNode }) {
// Get all individual stores
const gameStore = useGameStore();
const skillState = useSkillStore();
const manaState = useManaStore();
const prestigeState = usePrestigeStore();
const uiState = useUIStore();
const combatState = useCombatStore();
// Create unified store object for backward compatibility
const unifiedStore = useMemo<UnifiedStore>(() => ({
// From gameStore
day: gameStore.day,
hour: gameStore.hour,
incursionStrength: gameStore.incursionStrength,
containmentWards: gameStore.containmentWards,
initialized: gameStore.initialized,
tick: gameStore.tick,
resetGame: gameStore.resetGame,
gatherMana: gameStore.gatherMana,
startNewLoop: gameStore.startNewLoop,
// From manaStore
rawMana: manaState.rawMana,
meditateTicks: manaState.meditateTicks,
totalManaGathered: manaState.totalManaGathered,
elements: manaState.elements,
setRawMana: manaState.setRawMana,
addRawMana: manaState.addRawMana,
spendRawMana: manaState.spendRawMana,
convertMana: manaState.convertMana,
unlockElement: manaState.unlockElement,
craftComposite: manaState.craftComposite,
// From skillStore
skills: skillState.skills,
skillProgress: skillState.skillProgress,
skillUpgrades: skillState.skillUpgrades,
skillTiers: skillState.skillTiers,
paidStudySkills: skillState.paidStudySkills,
currentStudyTarget: skillState.currentStudyTarget,
parallelStudyTarget: skillState.parallelStudyTarget,
setSkillLevel: skillState.setSkillLevel,
startStudyingSkill: skillState.startStudyingSkill,
startStudyingSpell: skillState.startStudyingSpell,
cancelStudy: skillState.cancelStudy,
selectSkillUpgrade: skillState.selectSkillUpgrade,
deselectSkillUpgrade: skillState.deselectSkillUpgrade,
commitSkillUpgrades: skillState.commitSkillUpgrades,
tierUpSkill: skillState.tierUpSkill,
getSkillUpgradeChoices: skillState.getSkillUpgradeChoices,
// From prestigeStore
loopCount: prestigeState.loopCount,
insight: prestigeState.insight,
totalInsight: prestigeState.totalInsight,
loopInsight: prestigeState.loopInsight,
prestigeUpgrades: prestigeState.prestigeUpgrades,
memorySlots: prestigeState.memorySlots,
pactSlots: prestigeState.pactSlots,
memories: prestigeState.memories,
defeatedGuardians: prestigeState.defeatedGuardians,
signedPacts: prestigeState.signedPacts,
pactRitualFloor: prestigeState.pactRitualFloor,
pactRitualProgress: prestigeState.pactRitualProgress,
doPrestige: prestigeState.doPrestige,
addMemory: prestigeState.addMemory,
removeMemory: prestigeState.removeMemory,
clearMemories: prestigeState.clearMemories,
startPactRitual: prestigeState.startPactRitual,
cancelPactRitual: prestigeState.cancelPactRitual,
removePact: prestigeState.removePact,
defeatGuardian: prestigeState.defeatGuardian,
// From combatStore
currentFloor: combatState.currentFloor,
floorHP: combatState.floorHP,
floorMaxHP: combatState.floorMaxHP,
maxFloorReached: combatState.maxFloorReached,
activeSpell: combatState.activeSpell,
currentAction: combatState.currentAction,
castProgress: combatState.castProgress,
spells: combatState.spells,
setAction: combatState.setAction,
setSpell: combatState.setSpell,
learnSpell: combatState.learnSpell,
advanceFloor: combatState.advanceFloor,
// From uiStore
log: uiState.logs,
paused: uiState.paused,
gameOver: uiState.gameOver,
victory: uiState.victory,
addLog: uiState.addLog,
togglePause: uiState.togglePause,
setPaused: uiState.setPaused,
setGameOver: uiState.setGameOver,
}), [gameStore, skillState, manaState, prestigeState, uiState, combatState]);
// Computed effects from upgrades
const upgradeEffects = useMemo(
() => computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {}),
[skillState.skillUpgrades, skillState.skillTiers]
);
// Create a minimal state object for compute functions
const stateForCompute = useMemo(() => ({
skills: skillState.skills,
prestigeUpgrades: prestigeState.prestigeUpgrades,
skillUpgrades: skillState.skillUpgrades,
skillTiers: skillState.skillTiers,
signedPacts: prestigeState.signedPacts,
rawMana: manaState.rawMana,
meditateTicks: manaState.meditateTicks,
incursionStrength: gameStore.incursionStrength,
}), [skillState, prestigeState, manaState, gameStore.incursionStrength]);
// Derived stats
const maxMana = useMemo(
() => computeMaxMana(stateForCompute, upgradeEffects),
[stateForCompute, upgradeEffects]
);
const baseRegen = useMemo(
() => computeRegen(stateForCompute, upgradeEffects),
[stateForCompute, upgradeEffects]
);
const clickMana = useMemo(() => computeClickMana(stateForCompute), [stateForCompute]);
// Floor element from combat store
const floorElem = useMemo(() => getFloorElement(combatState.currentFloor), [combatState.currentFloor]);
const floorElemDef = ELEMENTS[floorElem];
const isGuardianFloor = !!GUARDIANS[combatState.currentFloor];
const currentGuardian = GUARDIANS[combatState.currentFloor];
const activeSpellDef = SPELLS_DEF[combatState.activeSpell];
const meditationMultiplier = useMemo(
() => getMeditationBonus(manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency),
[manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency]
);
const incursionStrength = useMemo(
() => getIncursionStrength(gameStore.day, gameStore.hour),
[gameStore.day, gameStore.hour]
);
const studySpeedMult = useMemo(
() => getStudySpeedMultiplier(skillState.skills),
[skillState.skills]
);
const studyCostMult = useMemo(
() => getStudyCostMultiplier(skillState.skills),
[skillState.skills]
);
// Effective regen calculations
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
? Math.floor(maxMana / 100) * 0.1
: 0;
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
// Active boons
const activeBoons = useMemo(
() => getBoonBonuses(prestigeState.signedPacts),
[prestigeState.signedPacts]
);
// DPS calculation - based on active spell, attack speed, and damage
const dps = useMemo(() => {
if (!activeSpellDef) return 0;
const baseDmg = calcDamage(
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
combatState.activeSpell,
floorElem
);
const dmgWithEffects = baseDmg * upgradeEffects.baseDamageMultiplier + upgradeEffects.baseDamageBonus;
const attackSpeed = (1 + (skillState.skills.quickCast || 0) * 0.05) * upgradeEffects.attackSpeedMultiplier;
const castSpeed = activeSpellDef.castSpeed || 1;
return dmgWithEffects * attackSpeed * castSpeed;
}, [activeSpellDef, skillState.skills, prestigeState.signedPacts, floorElem, upgradeEffects, combatState.activeSpell]);
// Helper functions
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, manaState.rawMana, manaState.elements);
};
const value: GameContextValue = {
store: unifiedStore,
skillStore: skillState,
manaStore: manaState,
prestigeStore: prestigeState,
uiStore: uiState,
combatStore: combatState,
upgradeEffects,
maxMana,
baseRegen,
clickMana,
floorElem,
floorElemDef,
isGuardianFloor,
currentGuardian,
activeSpellDef,
meditationMultiplier,
incursionStrength,
studySpeedMult,
studyCostMult,
effectiveRegenWithSpecials,
manaCascadeBonus,
effectiveRegen,
dps,
activeBoons,
canCastSpell,
hasSpecial,
SPECIAL_EFFECTS,
};
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
}
export function useGameContext() {
const context = useContext(GameContext);
if (!context) {
throw new Error('useGameContext must be used within a GameProvider');
}
return context;
}
// Re-export useGameLoop for convenience
export { useGameLoop };
-134
View File
@@ -1,134 +0,0 @@
'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: title as string,
description: description as string,
className: toastTypeClass,
});
};
}
export { type ToastType };
+193
View File
@@ -0,0 +1,193 @@
'use client';
import { useGameStore, fmt, fmtDec, computePactMultiplier } from '@/lib/game/store';
import { ELEMENTS, GUARDIANS, PRESTIGE_DEF } from '@/lib/game/constants';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { RotateCcw } from 'lucide-react';
export function GrimoireTab() {
const store = useGameStore();
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Current Status */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
<div className="text-xs text-gray-400">Loops Completed</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
<div className="text-xs text-gray-400">Current Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
<div className="text-xs text-gray-400">Total Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
<div className="text-xs text-gray-400">Memory Slots</div>
</div>
</div>
</CardContent>
</Card>
{/* Signed Pacts */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-amber-400 game-panel-title text-xs">Signed Pacts ({store.signedPacts.length}/{1 + (store.prestigeUpgrades.pactCapacity || 0)})</CardTitle>
{store.signedPacts.length > 1 && (
<div className="text-xs text-gray-400">
Combined: ×{fmtDec(computePactMultiplier(store), 2)} damage
</div>
)}
</div>
</CardHeader>
<CardContent>
{store.signedPacts.length === 0 ? (
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{store.signedPacts.map((floor) => {
const guardian = GUARDIANS[floor];
if (!guardian) return null;
return (
<div
key={floor}
className="p-3 rounded border"
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}10` }}
>
<div className="flex items-center justify-between mb-2">
<div>
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">{guardian.theme} Floor {floor}</div>
</div>
<Badge style={{ backgroundColor: `${guardian.color}30`, color: guardian.color }}>
{guardian.damageMultiplier}x dmg / {guardian.insightMultiplier}x insight
</Badge>
</div>
{/* Unique Boon */}
{guardian.uniqueBoon && (
<div className="mb-2 p-2 bg-cyan-900/20 rounded border border-cyan-800/30">
<div className="text-xs font-semibold text-cyan-300"> {guardian.uniqueBoon.name}</div>
<div className="text-xs text-cyan-200/70">{guardian.uniqueBoon.desc}</div>
</div>
)}
{/* Perks & Costs */}
<div className="grid grid-cols-2 gap-2 text-xs">
{guardian.perks.length > 0 && (
<div>
<div className="text-green-400 font-semibold mb-1">Perks</div>
{guardian.perks.map(perk => (
<div key={perk.id} className="text-green-300/70"> {perk.desc}</div>
))}
</div>
)}
{guardian.costs.length > 0 && (
<div>
<div className="text-red-400 font-semibold mb-1">Costs</div>
{guardian.costs.map(cost => (
<div key={cost.id} className="text-red-300/70"> {cost.desc}</div>
))}
</div>
)}
</div>
{/* Unlocked Mana */}
{guardian.unlocksMana.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{guardian.unlocksMana.map(elemId => {
const elem = ELEMENTS[elemId];
return (
<Badge key={elemId} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
{elem?.sym}
</Badge>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Prestige Upgrades */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
const level = store.prestigeUpgrades[id] || 0;
const maxed = level >= def.max;
const canBuy = !maxed && store.insight >= def.cost;
return (
<div
key={id}
className="p-3 rounded border border-gray-700 bg-gray-800/50"
>
<div className="flex items-center justify-between mb-2">
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
<Badge variant="outline" className="text-xs">
{level}/{def.max}
</Badge>
</div>
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
<Button
size="sm"
variant={canBuy ? 'default' : 'outline'}
className="w-full"
disabled={!canBuy}
onClick={() => store.doPrestige(id)}
>
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
</Button>
</div>
);
})}
</div>
{/* Reset Game Button */}
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400">Reset All Progress</div>
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
</div>
<Button
size="sm"
variant="outline"
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
onClick={() => {
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
store.resetGame();
}
}}
>
<RotateCcw className="w-4 h-4 mr-1" />
Reset
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
'use client';
import { useState } from 'react';
import { useGameStore } from '@/lib/game/store';
import { ELEMENTS, MANA_PER_ELEMENT } from '@/lib/game/constants';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export function LabTab() {
const store = useGameStore();
const [convertTarget, setConvertTarget] = useState('fire');
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Elemental Mana Display */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Elemental Mana</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked && state.current >= 1)
.map(([id, state]) => {
const def = ELEMENTS[id];
const isSelected = convertTarget === id;
return (
<div
key={id}
className={`p-2 rounded border cursor-pointer transition-all ${isSelected ? 'border-blue-500 bg-blue-900/20' : 'border-gray-700 bg-gray-800/50 hover:border-gray-600'}`}
style={{ borderColor: isSelected ? def?.color : undefined }}
onClick={() => setConvertTarget(id)}
>
<div className="text-lg text-center">{def?.sym}</div>
<div className="text-xs font-semibold text-center" style={{ color: def?.color }}>{def?.name}</div>
<div className="text-xs text-gray-400 game-mono text-center">{state.current}/{state.max}</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Element Conversion */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Element Conversion</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-400 mb-3">
Convert raw mana to elemental mana (100:1 ratio)
</p>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => store.convertMana(convertTarget, 1)}
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT}
>
+1 ({MANA_PER_ELEMENT})
</Button>
<Button
size="sm"
variant="outline"
onClick={() => store.convertMana(convertTarget, 10)}
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 10}
>
+10 ({MANA_PER_ELEMENT * 10})
</Button>
<Button
size="sm"
variant="outline"
onClick={() => store.convertMana(convertTarget, 100)}
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 100}
>
+100 ({MANA_PER_ELEMENT * 100})
</Button>
</div>
</CardContent>
</Card>
{/* Unlock Elements */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Unlock Elements</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-400 mb-3">
Unlock new elemental affinities (500 mana each)
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{Object.entries(store.elements)
.filter(([id, state]) => !state.unlocked && ELEMENTS[id]?.cat !== 'exotic')
.map(([id]) => {
const def = ELEMENTS[id];
return (
<div
key={id}
className="p-2 rounded border border-gray-700 bg-gray-800/50"
>
<div className="text-lg opacity-50">{def?.sym}</div>
<div className="text-xs font-semibold text-gray-500">{def?.name}</div>
<Button
size="sm"
variant="outline"
className="mt-1 w-full"
disabled={store.rawMana < 500}
onClick={() => store.unlockElement(id)}
>
Unlock
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Composite Crafting */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Composite & Exotic Crafting</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{Object.entries(ELEMENTS)
.filter(([, def]) => def.recipe)
.map(([id, def]) => {
const state = store.elements[id];
const recipe = def.recipe!;
const canCraft = recipe.every(
(r) => (store.elements[r]?.current || 0) >= recipe.filter((x) => x === r).length
);
return (
<div
key={id}
className={`p-3 rounded border ${canCraft ? 'border-gray-600 bg-gray-800/50' : 'border-gray-700 bg-gray-800/30 opacity-50'}`}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{def.sym}</span>
<div>
<div className="text-sm font-semibold" style={{ color: def.color }}>
{def.name}
</div>
<div className="text-xs text-gray-500">{def.cat}</div>
</div>
</div>
<div className="text-xs text-gray-400 mb-2">
{recipe.map((r) => ELEMENTS[r]?.sym).join(' + ')}
</div>
<Button
size="sm"
variant={canCraft ? 'default' : 'outline'}
className="w-full"
disabled={!canCraft}
onClick={() => store.craftComposite(id)}
>
Craft
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
}
+460
View File
@@ -0,0 +1,460 @@
'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>
</>
);
}
@@ -1,46 +0,0 @@
'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>
);
}
@@ -1,87 +0,0 @@
'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>
);
}
@@ -1,55 +0,0 @@
'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>
);
}
@@ -1,86 +0,0 @@
'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>
);
}
@@ -1,19 +0,0 @@
import {
Gem,
Sparkles,
Package,
Sword,
Shirt,
Crown,
Wrench
} from 'lucide-react';
export const CATEGORY_ICONS: Record<string, typeof Sword> = {
caster: Sword,
catalyst: Sparkles,
head: Crown,
body: Shirt,
hands: Wrench,
feet: Package,
accessory: Gem,
};
@@ -1,33 +0,0 @@
'use client';
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)',
};
+19 -37
View File
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Zap, ChevronDown, ChevronUp } from 'lucide-react'; import { Zap, ChevronDown, ChevronUp } from 'lucide-react';
import { fmt, fmtDec } from '@/lib/game/stores'; import { fmt, fmtDec } from '@/lib/game/store';
import { ELEMENTS } from '@/lib/game/constants'; import { ELEMENTS } from '@/lib/game/constants';
import { useState } from 'react'; import { useState } from 'react';
@@ -33,43 +33,32 @@ export function ManaDisplay({
}: ManaDisplayProps) { }: ManaDisplayProps) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
// Get unlocked elements with current > 0, sorted by current amount // Get unlocked elements with mana, sorted by current amount
const unlockedElements = Object.entries(elements) const unlockedElements = Object.entries(elements)
.filter(([, state]) => state.unlocked && state.current > 0) .filter(([, state]) => state.unlocked && state.current >= 1)
.sort((a, b) => b[1].current - a[1].current); .sort((a, b) => b[1].current - a[1].current);
return ( return (
<Card className="bg-[var(--bg-panel)] border-[var(--border-subtle)]"> <Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-4 space-y-3"> <CardContent className="pt-4 space-y-3">
{/* Raw Mana - Main Display */} {/* Raw Mana - Main Display */}
<div> <div>
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-3xl font-bold game-mono" style={{ color: 'var(--mana-raw)' }}>{fmt(rawMana)}</span> <span className="text-3xl font-bold game-mono text-blue-400">{fmt(rawMana)}</span>
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>/ {fmt(maxMana)}</span> <span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
</div> </div>
<div className="text-xs" style={{ color: 'var(--text-muted)' }}> <div className="text-xs text-gray-400">
+{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && <span style={{ color: 'var(--mana-light)' }}>({fmtDec(meditationMultiplier, 1)}x med)</span>} +{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && <span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>}
</div> </div>
</div> </div>
<Progress <Progress
value={(rawMana / maxMana) * 100} value={(rawMana / maxMana) * 100}
className="h-2 bg-[var(--bg-sunken)]" className="h-2 bg-gray-800"
style={{ '--progress-bg': 'var(--mana-raw)' } as React.CSSProperties}
/> />
<Button <Button
className={`w-full transition-all text-[var(--font-display)] tracking-wider className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${isGathering ? 'animate-pulse' : ''}`}
${isGathering
? 'animate-gather-glow'
: 'hover:scale-[1.02]'}
`}
style={{
background: 'var(--mana-raw)',
border: '1px solid var(--border-accent)',
color: 'var(--bg-gather-btn)',
fontWeight: 600,
}}
onMouseDown={onGatherStart} onMouseDown={onGatherStart}
onMouseUp={onGatherEnd} onMouseUp={onGatherEnd}
onMouseLeave={onGatherEnd} onMouseLeave={onGatherEnd}
@@ -78,23 +67,22 @@ export function ManaDisplay({
> >
<Zap className="w-4 h-4 mr-2" /> <Zap className="w-4 h-4 mr-2" />
Gather +{clickMana} Mana Gather +{clickMana} Mana
{isGathering && <span className="ml-2 text-xs" style={{ opacity: 0.8 }}>(Holding...)</span>} {isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
</Button> </Button>
{/* Elemental Mana Pools */} {/* Elemental Mana Pools */}
{unlockedElements.length > 0 && ( {unlockedElements.length > 0 && (
<div className="border-t border-[var(--border-subtle)] pt-3 mt-3"> <div className="border-t border-gray-700 pt-3 mt-3">
<button <button
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-xs transition-colors" className="flex items-center justify-between w-full text-xs text-gray-400 hover:text-gray-300 mb-2"
style={{ color: 'var(--text-muted)' }}
> >
<span style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.5px' }}>ELEMENTAL MANA ({unlockedElements.length})</span> <span>Elemental Mana ({unlockedElements.length})</span>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />} {expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button> </button>
{expanded && ( {expanded && (
<div className="grid grid-cols-2 gap-2 mt-2"> <div className="grid grid-cols-2 gap-2">
{unlockedElements.map(([id, state]) => { {unlockedElements.map(([id, state]) => {
const elem = ELEMENTS[id]; const elem = ELEMENTS[id];
if (!elem) return null; if (!elem) return null;
@@ -102,11 +90,7 @@ export function ManaDisplay({
return ( return (
<div <div
key={id} key={id}
className="p-2 transition-all border rounded-sm" className="p-2 rounded bg-gray-800/50 border border-gray-700"
style={{
background: 'var(--bg-sunken)/30',
borderColor: `${elem.color}30`,
}}
> >
<div className="flex items-center gap-1 mb-1"> <div className="flex items-center gap-1 mb-1">
<span style={{ color: elem.color }}>{elem.sym}</span> <span style={{ color: elem.color }}>{elem.sym}</span>
@@ -114,16 +98,16 @@ export function ManaDisplay({
{elem.name} {elem.name}
</span> </span>
</div> </div>
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-void)' }}> <div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
<div <div
className="h-full transition-all rounded-full" className="h-full rounded-full transition-all"
style={{ style={{
width: `${Math.min(100, (state.current / state.max) * 100)}%`, width: `${Math.min(100, (state.current / state.max) * 100)}%`,
backgroundColor: elem.color backgroundColor: elem.color
}} }}
/> />
</div> </div>
<div className="text-xs game-mono" style={{ color: 'var(--text-muted)' }}> <div className="text-xs text-gray-400 game-mono">
{fmt(state.current)}/{fmt(state.max)} {fmt(state.current)}/{fmt(state.max)}
</div> </div>
</div> </div>
@@ -137,5 +121,3 @@ export function ManaDisplay({
</Card> </Card>
); );
} }
ManaDisplay.displayName = "ManaDisplay";
+418
View File
@@ -0,0 +1,418 @@
'use client';
import { useState } from 'react';
import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
import { SKILLS_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { BookOpen, X } from 'lucide-react';
import type { SkillUpgradeChoice } from '@/lib/game/types';
// Format study time
function formatStudyTime(hours: number): string {
if (hours < 1) return `${Math.round(hours * 60)}m`;
return `${hours.toFixed(1)}h`;
}
export function SkillsTab() {
const store = useGameStore();
const { studySpeedMult, studyCostMult, hasParallelStudy } = useStudyStats();
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
const upgradeEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
// Check if skill has milestone available
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) return null;
if (level >= 5) {
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, store.skillTiers);
const selected5 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
if (upgrades5.length > 0 && selected5.length < 2) {
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
}
}
if (level >= 10) {
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, store.skillTiers);
const selected10 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
if (upgrades10.length > 0 && selected10.length < 2) {
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
}
}
return null;
};
// Render upgrade selection dialog
const renderUpgradeDialog = () => {
if (!upgradeDialogSkill) return null;
const skillDef = SKILLS_DEF[upgradeDialogSkill];
const level = store.skills[upgradeDialogSkill] || 0;
const { available, selected: alreadySelected } = store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
const toggleUpgrade = (upgradeId: string) => {
if (currentSelections.includes(upgradeId)) {
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
} else if (currentSelections.length < 2) {
setPendingUpgradeSelections([...currentSelections, upgradeId]);
}
};
const handleDone = () => {
if (currentSelections.length === 2 && upgradeDialogSkill) {
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
}
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
};
const handleCancel = () => {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
};
return (
<Dialog open={!!upgradeDialogSkill} onOpenChange={(open) => {
if (!open) {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
}
}}>
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
<DialogHeader>
<DialogTitle className="text-amber-400">
Choose Upgrade - {skillDef?.name || upgradeDialogSkill}
</DialogTitle>
<DialogDescription className="text-gray-400">
Level {upgradeDialogMilestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
</DialogDescription>
</DialogHeader>
<div className="space-y-2 mt-4">
{available.map((upgrade) => {
const isSelected = currentSelections.includes(upgrade.id);
const canToggle = currentSelections.length < 2 || isSelected;
return (
<div
key={upgrade.id}
className={`p-3 rounded border cursor-pointer transition-all ${
isSelected
? 'border-amber-500 bg-amber-900/30'
: canToggle
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
}`}
onClick={() => {
if (canToggle) {
toggleUpgrade(upgrade.id);
}
}}
>
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
</div>
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
{upgrade.effect.type === 'multiplier' && (
<div className="text-xs text-green-400 mt-1">
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'bonus' && (
<div className="text-xs text-blue-400 mt-1">
+{upgrade.effect.value} {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'special' && (
<div className="text-xs text-cyan-400 mt-1">
{upgrade.effect.specialDesc || 'Special effect'}
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="default"
onClick={handleDone}
disabled={currentSelections.length !== 2}
>
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
// Render study progress
const renderStudyProgress = () => {
if (!store.currentStudyTarget) return null;
const target = store.currentStudyTarget;
const progressPct = Math.min(100, (target.progress / target.required) * 100);
const def = SKILLS_DEF[target.id] || SKILLS_DEF[target.id.split('_t')[0]];
return (
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-300">
{def?.name}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelStudy()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
<span>{studySpeedMult.toFixed(1)}x speed</span>
</div>
</div>
);
};
return (
<div className="space-y-4">
{/* Upgrade Selection Dialog */}
{renderUpgradeDialog()}
{/* Current Study Progress */}
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
<Card className="bg-gray-900/80 border-purple-600/50">
<CardContent className="pt-4">
{renderStudyProgress()}
</CardContent>
</Card>
)}
{SKILL_CATEGORIES.map((cat) => {
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
if (skillsInCat.length === 0) return null;
return (
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
{cat.icon} {cat.name}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{skillsInCat.map(([id, def]) => {
const currentTier = store.skillTiers?.[id] || 1;
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
const tierMultiplier = getTierMultiplier(tieredSkillId);
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
const maxed = level >= def.max;
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
const savedProgress = store.skillProgress[tieredSkillId] || store.skillProgress[id] || 0;
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
const skillDisplayName = tierDef?.name || def.name;
// Check prerequisites
let prereqMet = true;
if (def.req) {
for (const [r, rl] of Object.entries(def.req)) {
if ((store.skills[r] || 0) < rl) {
prereqMet = false;
break;
}
}
}
// Apply skill modifiers
const studyEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
const effectiveSpeedMult = studySpeedMult * studyEffects.studySpeedMultiplier;
const tierStudyTime = def.studyTime * currentTier;
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
const baseCost = def.base * (level + 1) * currentTier;
const cost = Math.floor(baseCost * studyCostMult);
const canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level);
const nextTierSkill = getNextTierSkill(tieredSkillId);
const canTierUp = maxed && nextTierSkill;
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
return (
<div
key={id}
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
isStudying ? 'border-purple-500 bg-purple-900/20' :
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
'border-gray-700 bg-gray-800/30'
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">{skillDisplayName}</span>
{currentTier > 1 && (
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
)}
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
{selectedUpgrades.length > 0 && (
<div className="flex gap-1">
{selectedL5.length > 0 && (
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
)}
{selectedL10.length > 0 && (
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
)}
</div>
)}
</div>
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
{!prereqMet && def.req && (
<div className="text-xs text-red-400 mt-1">
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
</div>
)}
<div className="text-xs text-gray-500 mt-1">
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
</span>
{' • '}
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
Cost: {fmt(cost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
</span>
</div>
{milestoneInfo && (
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
</div>
)}
</div>
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
{/* Level dots */}
<div className="flex gap-1 shrink-0">
{Array.from({ length: def.max }).map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full border ${
i < level ? 'bg-purple-500 border-purple-400' :
i === 4 || i === 9 ? 'border-amber-500' :
'border-gray-600'
}`}
/>
))}
</div>
{isStudying ? (
<div className="text-xs text-purple-400">
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
</div>
) : milestoneInfo ? (
<Button
size="sm"
className="bg-amber-600 hover:bg-amber-700"
onClick={() => {
setUpgradeDialogSkill(tieredSkillId);
setUpgradeDialogMilestone(milestoneInfo.milestone);
}}
>
Choose Upgrades
</Button>
) : canTierUp ? (
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => store.tierUpSkill(tieredSkillId)}
>
Tier Up
</Button>
) : maxed ? (
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
) : (
<div className="flex gap-1">
<Button
size="sm"
variant={canStudy ? 'default' : 'outline'}
disabled={!canStudy}
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
onClick={() => store.startStudyingSkill(tieredSkillId)}
>
Study ({fmt(cost)})
</Button>
{/* Parallel Study button */}
{hasParallelStudy &&
store.currentStudyTarget &&
!store.parallelStudyTarget &&
store.currentStudyTarget.id !== tieredSkillId &&
canStudy && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
onClick={() => store.startParallelStudySkill(tieredSkillId)}
>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Study in parallel (50% speed)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
'use client';
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage } from '@/lib/game/store';
import { ELEMENTS, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
// Format spell cost for display
function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
if (cost.type === 'raw') {
return `${cost.amount} raw`;
}
const elemDef = ELEMENTS[cost.element || ''];
return `${cost.amount} ${elemDef?.sym || '?'}`;
}
// Get cost color
function getSpellCostColor(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
if (cost.type === 'raw') {
return '#60A5FA';
}
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
}
// Format study time
function formatStudyTime(hours: number): string {
if (hours < 1) return `${Math.round(hours * 60)}m`;
return `${hours.toFixed(1)}h`;
}
export function SpellsTab() {
const store = useGameStore();
const { studySpeedMult, studyCostMult } = useStudyStats();
const spellTiers = [0, 1, 2, 3, 4];
return (
<div className="space-y-6">
{spellTiers.map(tier => {
const spellsInTier = Object.entries(SPELLS_DEF).filter(([, def]) => def.tier === tier);
if (spellsInTier.length === 0) return null;
const tierNames = ['Basic Spells (Raw Mana)', 'Tier 1 - Elemental', 'Tier 2 - Advanced', 'Tier 3 - Master', 'Tier 4 - Legendary'];
const tierColors = ['text-gray-400', 'text-green-400', 'text-blue-400', 'text-purple-400', 'text-amber-400'];
return (
<div key={tier}>
<h3 className={`text-lg font-semibold mb-3 ${tierColors[tier]}`}>{tierNames[tier]}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{spellsInTier.map(([id, def]) => {
const state = store.spells[id];
const learned = state?.learned;
const isStudying = store.currentStudyTarget?.id === id;
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
const baseStudyTime = def.studyTime || (def.tier * 4);
const isActive = store.activeSpell === id;
const canCast = learned && canAffordSpellCost(def.cost, store.rawMana, store.elements);
// Apply skill modifiers
const studyTime = baseStudyTime / studySpeedMult;
const unlockCost = Math.floor(def.unlock * studyCostMult);
// Can start studying?
const canStudy = !learned && !isStudying && store.rawMana >= unlockCost;
return (
<Card
key={id}
className={`bg-gray-900/80 border-gray-700 ${learned ? '' : 'opacity-75'} ${isStudying ? 'border-purple-500' : ''} ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm game-panel-title" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</CardTitle>
{def.tier > 0 && (
<Badge variant="outline" className="text-xs">
T{def.tier}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-gray-400">
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
<span className="mr-2"> {def.dmg} dmg</span>
</div>
{/* Cost display */}
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
Cost: {formatSpellCost(def.cost)}
</div>
{def.desc && (
<div className="text-xs text-gray-500 italic">{def.desc}</div>
)}
{def.effects && def.effects.length > 0 && (
<div className="flex gap-1 flex-wrap">
{def.effects.map((eff, i) => (
<Badge key={i} variant="outline" className="text-xs">
{eff.type === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}% lifesteal`}
</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>
);
}
+318
View File
@@ -0,0 +1,318 @@
'use client';
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage, computePactMultiplier } from '@/lib/game/store';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, MANA_PER_ELEMENT } from '@/lib/game/constants';
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { X, BookOpen } from 'lucide-react';
export function SpireTab() {
const store = useGameStore();
const { effectiveRegen, meditationMultiplier, incursionStrength } = useManaStats();
const {
floorElem, floorElemDef, isGuardianFloor, currentGuardian,
activeSpellDef, dps, damageBreakdown
} = useCombatStats();
const { effectiveStudySpeedMult } = useStudyStats();
// Check if spell can be cast
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
// Render study progress
const renderStudyProgress = () => {
if (!store.currentStudyTarget) return null;
const target = store.currentStudyTarget;
const progressPct = Math.min(100, (target.progress / target.required) * 100);
const isSkill = target.type === 'skill';
const def = isSkill ? SPELLS_DEF[target.id] : SPELLS_DEF[target.id];
return (
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-300">
{def?.name}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelStudy()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
<span>{effectiveStudySpeedMult.toFixed(1)}x speed</span>
</div>
</div>
);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Current Floor Card */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
{store.currentFloor}
</span>
<span className="text-gray-400 text-sm">/ 100</span>
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
{floorElemDef?.sym} {floorElemDef?.name}
</span>
{isGuardianFloor && (
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
)}
</div>
{isGuardianFloor && currentGuardian && (
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
{currentGuardian.name}
</div>
)}
{/* HP Bar */}
<div className="space-y-1">
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-400 game-mono">
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
<span>DPS: {store.currentAction === 'climb' && canCastSpell(store.activeSpell) ? fmtDec(dps) : '—'}</span>
</div>
</div>
<Separator className="bg-gray-700" />
<div className="text-sm text-gray-400">
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong>
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
</div>
</CardContent>
</Card>
{/* Active Spell Card */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Spell</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{activeSpellDef ? (
<>
<div className="text-lg font-semibold game-panel-title" style={{ color: activeSpellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[activeSpellDef.elem]?.color }}>
{activeSpellDef.name}
{activeSpellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200">Basic</Badge>}
{activeSpellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100">Legendary</Badge>}
</div>
<div className="text-sm text-gray-400 game-mono">
{fmt(calcDamage(store, store.activeSpell))} dmg
<span style={{ color: getSpellCostColor(activeSpellDef.cost) }}>
{' '}{formatSpellCost(activeSpellDef.cost)}
</span>
{' '} {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
</div>
{/* Cast progress bar when climbing */}
{store.currentAction === 'climb' && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-400">
<span>Cast Progress</span>
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
</div>
<Progress value={Math.min(100, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
</div>
)}
{activeSpellDef.desc && (
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</div>
)}
{activeSpellDef.effects && activeSpellDef.effects.length > 0 && (
<div className="flex gap-1 flex-wrap">
{activeSpellDef.effects.map((eff, i) => (
<Badge key={i} variant="outline" className="text-xs">
{eff.type === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}% lifesteal`}
{eff.type === 'burn' && `🔥 Burn`}
{eff.type === 'freeze' && `❄️ Freeze`}
</Badge>
))}
</div>
)}
</>
) : (
<div className="text-gray-500">No spell selected</div>
)}
{/* Can cast indicator */}
{activeSpellDef && (
<div className={`text-xs ${canCastSpell(store.activeSpell) ? 'text-green-400' : 'text-red-400'}`}>
{canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
</div>
)}
{incursionStrength > 0 && (
<div className="p-2 bg-red-900/20 border border-red-800/50 rounded">
<div className="text-xs text-red-400 game-panel-title mb-1">LABYRINTH INCURSION</div>
<div className="text-sm text-gray-300">
-{Math.round(incursionStrength * 100)}% mana regen
</div>
</div>
)}
</CardContent>
</Card>
{/* Current Study (if any) */}
{store.currentStudyTarget && (
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
<CardContent className="pt-4 space-y-3">
{renderStudyProgress()}
{/* Parallel Study Progress */}
{store.parallelStudyTarget && (
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-semibold text-cyan-300">
Parallel: {store.parallelStudyTarget.type === 'skill' ? store.parallelStudyTarget.id : store.parallelStudyTarget.id}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelParallelStudy()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
<span>50% speed (Parallel Study)</span>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Pact Signing Progress */}
{store.pactSigningProgress && (
<Card className="bg-gray-900/80 border-amber-600/50 lg:col-span-2">
<CardContent className="pt-4 space-y-3">
<div className="p-3 rounded border border-amber-500/30 bg-amber-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-2xl">📜</span>
<div>
<div className="text-sm font-semibold text-amber-300">
Signing Pact: {GUARDIANS[store.pactSigningProgress.floor]?.name}
</div>
<div className="text-xs text-amber-400">
Floor {store.pactSigningProgress.floor}
</div>
</div>
</div>
</div>
<Progress
value={Math.min(100, (store.pactSigningProgress.progress / store.pactSigningProgress.required) * 100)}
className="h-2 bg-gray-800"
/>
<div className="flex justify-between text-xs text-amber-400 mt-1">
<span>{formatStudyTime(store.pactSigningProgress.progress)} / {formatStudyTime(store.pactSigningProgress.required)}</span>
<span>Cost: {fmt(store.pactSigningProgress.manaCost)} mana</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Spells Available */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Known Spells</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{Object.entries(store.spells)
.filter(([, state]) => state.learned)
.map(([id, state]) => {
const def = SPELLS_DEF[id];
if (!def) return null;
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
const isActive = store.activeSpell === id;
const canCast = canCastSpell(id);
return (
<Button
key={id}
variant="outline"
className={`h-auto py-2 px-3 flex flex-col items-start ${isActive ? 'border-amber-500 bg-amber-900/20' : canCast ? 'border-gray-600 bg-gray-800/50 hover:bg-gray-700/50' : 'border-gray-700 bg-gray-800/30 opacity-60'}`}
onClick={() => store.setSpell(id)}
>
<div className="text-sm font-semibold" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</div>
<div className="text-xs text-gray-400 game-mono">
{fmt(calcDamage(store, id))} dmg
</div>
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
{formatSpellCost(def.cost)}
</div>
</Button>
);
})}
</div>
</CardContent>
</Card>
{/* Activity Log */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-32">
<div className="space-y-1">
{store.log.slice(0, 20).map((entry, i) => (
<div
key={i}
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
>
{entry}
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
);
}
+551
View File
@@ -0,0 +1,551 @@
'use client';
import { useGameStore, fmt, fmtDec, calcDamage, computePactMultiplier, computePactInsightMultiplier } from '@/lib/game/store';
import { ELEMENTS, GUARDIANS, SPELLS_DEF } from '@/lib/game/constants';
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Droplet, Swords, BookOpen, FlaskConical, RotateCcw, Trophy, Star } from 'lucide-react';
import type { SkillUpgradeChoice } from '@/lib/game/types';
export function StatsTab() {
const store = useGameStore();
const {
upgradeEffects, maxMana, baseRegen, clickMana,
meditationMultiplier, incursionStrength, manaCascadeBonus, effectiveRegen,
hasSteadyStream, hasManaTorrent, hasDesperateWells
} = useManaStats();
const { activeSpellDef, pactMultiplier, pactInsightMultiplier } = useCombatStats();
const { studySpeedMult, studyCostMult } = useStudyStats();
// Compute element max
const elemMax = (() => {
const ea = store.skillTiers?.elemAttune || 1;
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return 10 + level * 50 * tierMult + (store.prestigeUpgrades.elementalAttune || 0) * 25;
})();
// Get all selected skill upgrades
const getAllSelectedUpgrades = () => {
const upgrades: { skillId: string; upgrade: SkillUpgradeChoice }[] = [];
for (const [skillId, selectedIds] of Object.entries(store.skillUpgrades)) {
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) continue;
for (const tier of path.tiers) {
if (tier.skillId === skillId) {
for (const upgradeId of selectedIds) {
const upgrade = tier.upgrades.find(u => u.id === upgradeId);
if (upgrade) {
upgrades.push({ skillId, upgrade });
}
}
}
}
}
return upgrades;
};
const selectedUpgrades = getAllSelectedUpgrades();
return (
<div className="space-y-4">
{/* Mana Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-blue-400 game-panel-title text-xs flex items-center gap-2">
<Droplet className="w-4 h-4" />
Mana Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Base Max Mana:</span>
<span className="text-gray-200">100</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Well Bonus:</span>
<span className="text-blue-300">
{(() => {
const mw = store.skillTiers?.manaWell || 1;
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
const level = store.skills[tieredSkillId] || store.skills.manaWell || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${fmt(level * 100 * tierMult)} (${level} lvl × 100 × ${tierMult}x tier)`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Mana Well:</span>
<span className="text-blue-300">+{fmt((store.prestigeUpgrades.manaWell || 0) * 500)}</span>
</div>
{upgradeEffects.maxManaBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Mana Bonus:</span>
<span className="text-amber-300">+{fmt(upgradeEffects.maxManaBonus)}</span>
</div>
)}
{upgradeEffects.maxManaMultiplier > 1 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Mana Multiplier:</span>
<span className="text-amber-300">×{fmtDec(upgradeEffects.maxManaMultiplier, 2)}</span>
</div>
)}
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
<span className="text-gray-300">Total Max Mana:</span>
<span className="text-blue-400">{fmt(maxMana)}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Base Regen:</span>
<span className="text-gray-200">2/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Flow Bonus:</span>
<span className="text-blue-300">
{(() => {
const mf = store.skillTiers?.manaFlow || 1;
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
const level = store.skills[tieredSkillId] || store.skills.manaFlow || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${fmtDec(level * 1 * tierMult)}/hr (${level} lvl × 1 × ${tierMult}x tier)`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Spring Bonus:</span>
<span className="text-blue-300">+{(store.skills.manaSpring || 0) * 2}/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Mana Flow:</span>
<span className="text-blue-300">+{fmtDec((store.prestigeUpgrades.manaFlow || 0) * 0.5)}/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Temporal Echo:</span>
<span className="text-blue-300">×{fmtDec(1 + (store.prestigeUpgrades.temporalEcho || 0) * 0.1, 2)}</span>
</div>
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
<span className="text-gray-300">Base Regen:</span>
<span className="text-blue-400">{fmtDec(baseRegen, 2)}/hr</span>
</div>
{upgradeEffects.regenBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Regen Bonus:</span>
<span className="text-amber-300">+{fmtDec(upgradeEffects.regenBonus, 2)}/hr</span>
</div>
)}
{upgradeEffects.permanentRegenBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Permanent Regen Bonus:</span>
<span className="text-amber-300">+{fmtDec(upgradeEffects.permanentRegenBonus, 2)}/hr</span>
</div>
)}
{upgradeEffects.regenMultiplier > 1 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Regen Multiplier:</span>
<span className="text-amber-300">×{fmtDec(upgradeEffects.regenMultiplier, 2)}</span>
</div>
)}
</div>
</div>
<Separator className="bg-gray-700 my-3" />
{/* Skill Upgrade Effects Summary */}
{upgradeEffects.activeUpgrades.length > 0 && (
<>
<div className="mb-2">
<span className="text-xs text-amber-400 game-panel-title">Active Skill Upgrades</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3">
{upgradeEffects.activeUpgrades.map((upgrade, idx) => (
<div key={idx} className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
<span className="text-gray-300">{upgrade.name}</span>
<span className="text-gray-400">{upgrade.desc}</span>
</div>
))}
</div>
<Separator className="bg-gray-700 my-3" />
</>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Click Mana Value:</span>
<span className="text-purple-300">+{clickMana}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Tap Bonus:</span>
<span className="text-purple-300">+{store.skills.manaTap || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Surge Bonus:</span>
<span className="text-purple-300">+{(store.skills.manaSurge || 0) * 3}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Overflow:</span>
<span className="text-purple-300">×{fmtDec(1 + (store.skills.manaOverflow || 0) * 0.25, 2)}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Meditation Multiplier:</span>
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
{fmtDec(meditationMultiplier, 2)}x
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Effective Regen:</span>
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
</div>
{incursionStrength > 0 && !hasSteadyStream && (
<div className="flex justify-between text-sm">
<span className="text-red-400">Incursion Penalty:</span>
<span className="text-red-400">-{Math.round(incursionStrength * 100)}%</span>
</div>
)}
{hasSteadyStream && incursionStrength > 0 && (
<div className="flex justify-between text-sm">
<span className="text-green-400">Steady Stream:</span>
<span className="text-green-400">Immune to incursion</span>
</div>
)}
{manaCascadeBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Mana Cascade Bonus:</span>
<span className="text-cyan-400">+{fmtDec(manaCascadeBonus, 2)}/hr</span>
</div>
)}
{hasManaTorrent && store.rawMana > maxMana * 0.75 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Mana Torrent:</span>
<span className="text-cyan-400">+50% regen (high mana)</span>
</div>
)}
{hasDesperateWells && store.rawMana < maxMana * 0.25 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Desperate Wells:</span>
<span className="text-cyan-400">+50% regen (low mana)</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Combat Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-red-400 game-panel-title text-xs flex items-center gap-2">
<Swords className="w-4 h-4" />
Combat Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Active Spell Base Damage:</span>
<span className="text-gray-200">{activeSpellDef?.dmg || 5}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Combat Training Bonus:</span>
<span className="text-red-300">+{(store.skills.combatTrain || 0) * 5}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Arcane Fury Multiplier:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.arcaneFury || 0) * 0.1, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elemental Mastery:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.elementalMastery || 0) * 0.15, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Guardian Bane:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Critical Hit Chance:</span>
<span className="text-amber-300">{((store.skills.precision || 0) * 5)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Critical Multiplier:</span>
<span className="text-amber-300">1.5x</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Spell Echo Chance:</span>
<span className="text-amber-300">{((store.skills.spellEcho || 0) * 10)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Pact Multiplier:</span>
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
</div>
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
<span className="text-gray-300">Total Damage:</span>
<span className="text-red-400">{fmt(calcDamage(store, store.activeSpell))}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Pact Status */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Trophy className="w-4 h-4" />
Pact Status
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Pact Slots:</span>
<span className="text-amber-300">{store.signedPacts.length} / {1 + (store.prestigeUpgrades.pactCapacity || 0)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Damage Multiplier:</span>
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Insight Multiplier:</span>
<span className="text-purple-300">×{fmtDec(pactInsightMultiplier, 2)}</span>
</div>
{store.signedPacts.length > 1 && (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Interference Mitigation:</span>
<span className="text-green-300">{Math.min(store.pactInterferenceMitigation || 0, 5) * 10}%</span>
</div>
{(store.pactInterferenceMitigation || 0) >= 5 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Synergy Bonus:</span>
<span className="text-cyan-300">+{((store.pactInterferenceMitigation || 0) - 5) * 10}%</span>
</div>
)}
</>
)}
</div>
<div className="space-y-2">
<div className="text-sm text-gray-400 mb-2">Unlocked Mana Types:</div>
<div className="flex flex-wrap gap-1">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked)
.map(([id]) => {
const elem = ELEMENTS[id];
return (
<Badge key={id} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
{elem?.sym} {elem?.name}
</Badge>
);
})}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Study Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Study Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Study Speed:</span>
<span className="text-purple-300">×{fmtDec(studySpeedMult, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Quick Learner Bonus:</span>
<span className="text-purple-300">+{((store.skills.quickLearner || 0) * 10)}%</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Study Cost:</span>
<span className="text-purple-300">{Math.round(studyCostMult * 100)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Focused Mind Bonus:</span>
<span className="text-purple-300">-{((store.skills.focusedMind || 0) * 5)}%</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Progress Retention:</span>
<span className="text-purple-300">{Math.round((1 + (store.skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Element Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-green-400 game-panel-title text-xs flex items-center gap-2">
<FlaskConical className="w-4 h-4" />
Element Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Element Capacity:</span>
<span className="text-green-300">{elemMax}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elem. Attunement Bonus:</span>
<span className="text-green-300">
{(() => {
const ea = store.skillTiers?.elemAttune || 1;
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${level * 50 * tierMult}`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Attunement:</span>
<span className="text-green-300">+{(store.prestigeUpgrades.elementalAttune || 0) * 25}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Unlocked Elements:</span>
<span className="text-green-300">{Object.values(store.elements).filter(e => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elem. Crafting Bonus:</span>
<span className="text-green-300">×{fmtDec(1 + (store.skills.elemCrafting || 0) * 0.25, 2)}</span>
</div>
</div>
</div>
<Separator className="bg-gray-700 my-3" />
<div className="text-xs text-gray-400 mb-2">Elemental Mana Pools:</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked)
.map(([id, state]) => {
const def = ELEMENTS[id];
return (
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 text-center">
<div className="text-lg">{def?.sym}</div>
<div className="text-xs text-gray-400">{state.current}/{state.max}</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Active Upgrades */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Star className="w-4 h-4" />
Active Skill Upgrades ({selectedUpgrades.length})
</CardTitle>
</CardHeader>
<CardContent>
{selectedUpgrades.length === 0 ? (
<div className="text-gray-500 text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{selectedUpgrades.map(({ skillId, upgrade }) => (
<div key={upgrade.id} className="p-2 rounded border border-amber-600/30 bg-amber-900/10">
<div className="flex items-center justify-between">
<span className="text-amber-300 text-sm font-semibold">{upgrade.name}</span>
<Badge variant="outline" className="text-xs text-gray-400">
{SKILLS_DEF[skillId]?.name || skillId}
</Badge>
</div>
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
{upgrade.effect.type === 'multiplier' && (
<div className="text-xs text-green-400 mt-1">
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'bonus' && (
<div className="text-xs text-blue-400 mt-1">
+{upgrade.effect.value} {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'special' && (
<div className="text-xs text-cyan-400 mt-1">
{upgrade.effect.specialDesc || 'Special effect active'}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Loop Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
<RotateCcw className="w-4 h-4" />
Loop Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
<div className="text-xs text-gray-400">Loops Completed</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
<div className="text-xs text-gray-400">Current Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
<div className="text-xs text-gray-400">Total Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-green-400 game-mono">{store.maxFloorReached}</div>
<div className="text-xs text-gray-400">Max Floor</div>
</div>
</div>
<Separator className="bg-gray-700 my-3" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.spells).filter(s => s.learned).length}</div>
<div className="text-xs text-gray-400">Spells Learned</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.skills).reduce((a, b) => a + b, 0)}</div>
<div className="text-xs text-gray-400">Total Skill Levels</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{fmt(store.totalManaGathered)}</div>
<div className="text-xs text-gray-400">Total Mana Gathered</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{store.memorySlots}</div>
<div className="text-xs text-gray-400">Memory Slots</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
'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>
);
}
+17 -4
View File
@@ -1,18 +1,24 @@
'use client'; 'use client';
import { fmt } from '@/lib/game/stores'; import { Play, Pause } from 'lucide-react';
import { formatHour } from '@/lib/game/utils/formatting'; import { Button } from '@/components/ui/button';
import { fmt } from '@/lib/game/store';
import { formatHour } from '@/lib/game/formatting';
interface TimeDisplayProps { interface TimeDisplayProps {
day: number; day: number;
hour: number; hour: number;
insight: number; insight: number;
paused: boolean;
onTogglePause: () => void;
} }
export function TimeDisplay({ export function TimeDisplay({
day, day,
hour, hour,
insight, insight,
paused,
onTogglePause,
}: TimeDisplayProps) { }: TimeDisplayProps) {
return ( return (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -31,8 +37,15 @@ export function TimeDisplay({
</div> </div>
<div className="text-xs text-gray-400">Insight</div> <div className="text-xs text-gray-400">Insight</div>
</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> </div>
); );
} }
TimeDisplay.displayName = "TimeDisplay";

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