Compare commits
49 Commits
master
..
98309fbc85
| Author | SHA1 | Date | |
|---|---|---|---|
| 98309fbc85 | |||
| 49fe47948f | |||
| c341d3de80 | |||
| 4748b81fe6 | |||
| a1b15cea74 | |||
| e0a3d82dea | |||
| e7ce998cee | |||
| 30eb6b93a8 | |||
| 9ac6fe6ec8 | |||
| af3f59b259 | |||
| 3df971488a | |||
| 227e1b7183 | |||
| ae7fb8f6fc | |||
| c51c8d8ff4 | |||
| b2262fd6ac | |||
| 7bf9fa3ff2 | |||
| 75026fcb0b | |||
| d459276dcc | |||
| 93e41cfc76 | |||
| 8a62a4faaf | |||
| d0ecbfefd9 | |||
| e9e056a3f0 | |||
| b3e358e9a7 | |||
| 2f407071a4 | |||
| a5e37b9b24 | |||
| 5416b327af | |||
| fa713a15b5 | |||
| 378e434d44 | |||
| a2c9af7d45 | |||
| c050ca3814 | |||
| 44d9e0a835 | |||
| 3ce0bea13f | |||
| fec3a2b88f | |||
| 751b317af2 | |||
| 315490cedb | |||
| 2ca5d8b7f8 | |||
| 1d2dce75cc | |||
| 4f4cbeb527 | |||
| ee0268d9f6 | |||
| 6f2f022cb9 | |||
| aeabebdd9f | |||
| 81a72a1ed7 | |||
| 5e9f560f26 | |||
| 3386ffc41e | |||
| 390b1de203 | |||
| ff5ecd82ca | |||
| 7c5f2f30f0 | |||
| 3b2e89db74 | |||
| 5b6e50c0bd |
Executable
@@ -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-31fe40a7-808b-4861-a3c6-5e1361ba66cd-my-project",
|
||||||
|
"Subdir": "/0954660f-fdaf-430e-9c08-43d856f4b183/chat-97147419-5634-40fa-8c67-d722ea396734/my-project",
|
||||||
|
"AtimeMode": "noatime",
|
||||||
|
"DirStatFlushPeriod": 1000000000,
|
||||||
|
"SkipDirMtime": 100000000,
|
||||||
|
"Sid": 4039678,
|
||||||
|
"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.NXg1AmEjJ1XZCYZMa5mH1q66p",
|
||||||
|
"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": 221,
|
||||||
|
"PPid": 212,
|
||||||
|
"CommPath": "/tmp/fuse_fd_comm.212",
|
||||||
|
"StatePath": "/tmp/state212.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
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable → Regular
Executable → Regular
+1
-3
@@ -48,6 +48,4 @@ prompt
|
|||||||
|
|
||||||
server.log
|
server.log
|
||||||
# Skills directory
|
# Skills directory
|
||||||
.desloppify/
|
/skills/
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
@@ -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
|
|
||||||
@@ -1,34 +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
|
|
||||||
|
|
||||||
# Run tests — only failing tests are printed to keep output focused
|
|
||||||
echo "🧪 Running tests..."
|
|
||||||
bash .husky/scripts/run-tests.sh
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
exit 1
|
|
||||||
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!"
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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`);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Run all tests and display only failing tests (plus a summary).
|
|
||||||
# Keeps output focused so commit context isn't bloated.
|
|
||||||
#
|
|
||||||
# NOTE: It doesn't matter if you didn't introduce the failing tests —
|
|
||||||
# they should be handled before committing. A red main branch helps no one.
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/../.."
|
|
||||||
|
|
||||||
echo "🧪 Running tests (only failures will be shown)..."
|
|
||||||
|
|
||||||
# Disable TTY progress bars for clean pre-commit output.
|
|
||||||
# Use `--reporter=default` which prints only failures + the final summary.
|
|
||||||
CI=true npx vitest run --reporter=default 2>&1
|
|
||||||
EXIT_CODE=$?
|
|
||||||
|
|
||||||
if [ $EXIT_CODE -ne 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "⛔ Commit blocked: failing tests found."
|
|
||||||
echo " It doesn't matter if you didn't introduce the failing tests —"
|
|
||||||
echo " they should be handled before committing."
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit $EXIT_CODE
|
|
||||||
Executable
+117
@@ -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"
|
||||||
Executable
+1227
File diff suppressed because it is too large
Load Diff
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
488
|
||||||
Executable
+154
@@ -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
|
||||||
Executable
+78
@@ -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
|
||||||
|
|
||||||
Executable
+65
@@ -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
|
||||||
|
|
||||||
Executable
+123
@@ -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
|
||||||
|
|
||||||
Executable
+126
@@ -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
|
||||||
@@ -1,174 +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_start_session` → retrieve active task registry and issues
|
|
||||||
4. Evaluate the queue to find the highest-priority `ai_state: todo` item (or locate an existing `in-progress` task if resuming work)
|
|
||||||
5. `gitea_update_issue_status` → `ai_state: "in-progress"`
|
|
||||||
6. 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` | `ai_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 {
|
||||||
|
// Time
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
paused: boolean;
|
||||||
|
|
||||||
## Sub-Agents
|
// Mana
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, ElementState>;
|
||||||
|
|
||||||
Use for 3+ sequential independent calls. Zero context from parent — paste everything needed.
|
// Combat
|
||||||
|
currentFloor: number;
|
||||||
|
floorHP: number;
|
||||||
|
activeSpell: string;
|
||||||
|
castProgress: number;
|
||||||
|
|
||||||
## Architecture
|
// Progression
|
||||||
|
skills: Record<string, number>;
|
||||||
|
spells: Record<string, SpellState>;
|
||||||
|
skillUpgrades: Record<string, string[]>;
|
||||||
|
skillTiers: Record<string, number>;
|
||||||
|
|
||||||
- **Stack:** Next.js 16, TS 5, Tailwind 4 + shadcn/ui, Zustand+persist, Vitest, Bun
|
// Equipment
|
||||||
- **No backend:** Pure client-side. No Prisma, no database. State persisted to localStorage.
|
equipmentInstances: Record<string, EquipmentInstance>;
|
||||||
- **Active stores (8 Zustand stores):**
|
equippedInstances: Record<string, string | null>;
|
||||||
- `useGameStore` — Coordinator/tick pipeline, imports all other stores
|
enchantmentDesigns: EnchantmentDesign[];
|
||||||
- `useManaStore` — Mana pools, regen, element conversion
|
|
||||||
- `useCombatStore` — Spire/floors, combat, spells, achievements
|
|
||||||
- `useCraftingStore` — Enchanting (Design/Prepare/Apply), equipment instances, loot
|
|
||||||
- `useAttunementStore` — Enchanter/Invoker/Fabricator attunement levels & XP
|
|
||||||
- `usePrestigeStore` — Insight, prestige upgrades, pact persistence, loop state
|
|
||||||
- `useDisciplineStore` — Discipline activation, XP ticking, perk evaluation (slice)
|
|
||||||
- `useUIStore` — Logs, pause, game over/victory flags
|
|
||||||
- **Legacy:** Fully migrated. No legacy `store.ts`, `store/`, or `store-modules/` directories remain.
|
|
||||||
|
|
||||||
### Adding Effects
|
// Prestige
|
||||||
1. `data/enchantments/` — Add effect definition in the appropriate category file
|
insight: number;
|
||||||
2. `craftingStore.ts` → effects computation
|
prestigeUpgrades: Record<string, number>;
|
||||||
3. Equipment effects flow through `src/lib/game/effects.ts` → `getUnifiedEffects()`
|
signedPacts: number[];
|
||||||
|
}
|
||||||
### Adding Disciplines
|
|
||||||
1. Choose the correct data file under `data/disciplines/`:
|
|
||||||
- `base.ts` — Raw Mana Mastery (3 disciplines)
|
|
||||||
- `elemental.ts` — Elemental Attunement (21 disciplines — all 22 mana types)
|
|
||||||
- `elemental-regen.ts` — Elemental Regen (8 disciplines — 7 base + transference)
|
|
||||||
- `elemental-regen-advanced.ts` — Advanced Regen (15 disciplines — 8 composite + 6 exotic + transference composite)
|
|
||||||
- `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 (5 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 stack (summed across all effects). 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
|
|
||||||
- 43 equipment types across 8 categories (casters, swords, 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:
|
||||||
- Component-based construction: Core + Frame + Mind Circuit + Enchantments. Players design custom golems from 4 cores, 7 frames, 4 mind circuits, and 8 enchantments.
|
1. **Design**: Select effects, takes time based on complexity
|
||||||
- Golem slots: `floor(fabricatorLevel / 2)`, max 5 at level 10 (+2 from Golem Crafting discipline = max 7)
|
2. **Prepare**: Pay mana to prepare equipment, takes time
|
||||||
- Guardian Constructs require Guardian Core + Crystal-Steel Hybrid Frame + Guardian Circuit (Invoker 5 + Fabricator 5 + Guardian Pact)
|
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 10–80):** 7 base elements + Transference, static definitions with unique names
|
|
||||||
- **Tier 2 — Composite (floors 90–160):** 8 composite elements (Metal, Sand, Lightning, Frost, BlackFlame, RadiantFlames, Miasma, ShadowGlass)
|
|
||||||
- **Tier 3 — Exotic (floors 170–240):** 6 exotic elements (Crystal, Stellar, Void, Soul, Time, Plasma)
|
|
||||||
- **Tier 4+ — Procedural (floors 250+):** Dual-element → multi-element combination bosses cycling through element pairs, scaling indefinitely through 8 tiers
|
|
||||||
- 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 (bidirectional): fire↔water, air↔earth, light↔dark, frost↔fire
|
|
||||||
- Element counters (directional): lightning→water (lightning counters water), earth→lightning (earth counters lightning)
|
|
||||||
- Composite element counters: blackflame counters frost/water/light (and they counter blackflame); radiantflames counters frost/water/dark (and they counter radiantflames)
|
|
||||||
- Miasma counters air (and air counters miasma); Shadow glass counters light (and light counters shadow glass)
|
|
||||||
- All mana types double as spell elements
|
|
||||||
- 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 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. No victory ×3 multiplier (victory condition not yet defined)
|
|
||||||
- 15 prestige upgrade types: manaWell, manaFlow, insightAmp, spireKey, temporalEcho, steadyHand, ancientKnowledge, elementalAttune, spellMemory, guardianPact, quickStart, elemStart, unlockedManaTypeCapacity, pactBinding, pactInterferenceMitigation
|
|
||||||
- Signed pacts do NOT persist through prestige (reset each loop)
|
|
||||||
|
|
||||||
### 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 (8):** Fire+Earth=Metal ⚙️, Earth+Water=Sand ⏳, Fire+Air=Lightning ⚡, Air+Water=Frost ❄️, Dark+Fire=BlackFlame 🌋, Light+Fire=Radiant Flames 🌟, Air+Death=Miasma ☁️, Earth+Dark=Shadow Glass 🖤
|
|
||||||
**Exotic (6):** Sand+Sand+Light=Crystal 💎, Plasma+Light+Fire=Stellar ⭐, Dark+Dark+Death=Void 🕳️, Light+Dark+Transference=Soul 💫, Soul+Sand+Transference=Time ⏱️, Lightning+Fire+Transference=Plasma ⚡
|
|
||||||
|
|
||||||
**Total: 22 mana types** (7 base + 1 utility + 8 composite + 6 exotic)
|
1. **Define in `constants.ts` SPELLS_DEF**
|
||||||
|
2. **Add spell enchantment in `enchantment-effects.ts`**
|
||||||
|
3. **Add research skill in `constants.ts`**
|
||||||
|
4. **Map research to effect in `EFFECT_RESEARCH_MAPPING`**
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Forgetting to call `getUnifiedEffects()`**: Always use unified effects for stat calculations
|
||||||
|
2. **Direct stat modification**: Never modify stats directly; use effect system
|
||||||
|
3. **Missing tier multiplier**: Use `getTierMultiplier(skillId)` for tiered skills
|
||||||
|
4. **Ignoring special effects**: Check `hasSpecial(effects, SPECIAL_EFFECTS.X)` for special abilities
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
- Run `bun run lint` after changes
|
||||||
|
- Check dev server logs at `/home/z/my-project/dev.log`
|
||||||
|
- Test with fresh game state (clear localStorage)
|
||||||
|
|
||||||
|
## Slice Pattern for Store Organization
|
||||||
|
|
||||||
|
The store uses a **slice pattern** to organize related actions into separate files. This improves maintainability and makes the codebase more modular.
|
||||||
|
|
||||||
|
### Creating a New Slice
|
||||||
|
|
||||||
|
1. **Create the slice file** (e.g., `my-feature-slice.ts`):
|
||||||
|
```typescript
|
||||||
|
// Define the actions interface
|
||||||
|
export interface MyFeatureActions {
|
||||||
|
doSomething: (param: string) => void;
|
||||||
|
undoSomething: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the slice factory
|
||||||
|
export function createMyFeatureSlice(
|
||||||
|
set: StoreApi<GameStore>['setState'],
|
||||||
|
get: StoreApi<GameStore>['getState']
|
||||||
|
): MyFeatureActions {
|
||||||
|
return {
|
||||||
|
doSomething: (param: string) => {
|
||||||
|
set((state) => {
|
||||||
|
// Update state
|
||||||
|
});
|
||||||
|
},
|
||||||
|
undoSomething: () => {
|
||||||
|
set((state) => {
|
||||||
|
// Update state
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add to main store** (`store.ts`):
|
||||||
|
```typescript
|
||||||
|
import { createMyFeatureSlice, MyFeatureActions } from './my-feature-slice';
|
||||||
|
|
||||||
|
// Extend GameStore interface
|
||||||
|
interface GameStore extends GameState, MyFeatureActions, /* other slices */ {}
|
||||||
|
|
||||||
|
// Spread into store creation
|
||||||
|
const useGameStore = create<GameStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
...createMyFeatureSlice(set, get),
|
||||||
|
// other slices and state
|
||||||
|
}),
|
||||||
|
// persist config
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing Slices
|
||||||
|
|
||||||
|
| Slice | File | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| Navigation | `navigation-slice.ts` | Floor navigation (setClimbDirection, changeFloor) |
|
||||||
|
| Study | `study-slice.ts` | Study system (startStudyingSkill, startStudyingSpell, cancelStudy) |
|
||||||
|
| Crafting | `crafting-slice.ts` | Equipment/enchantment (createEquipmentInstance, startDesigningEnchantment) |
|
||||||
|
| Familiar | `familiar-slice.ts` | Familiar system (addFamiliar, removeFamiliar) |
|
||||||
|
|
||||||
|
## File Size Guidelines
|
||||||
|
|
||||||
|
### Current File Sizes (After Refactoring)
|
||||||
|
| File | Lines | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `store.ts` | ~1650 | Core state + tick logic (reduced from 2138, 23% reduction) |
|
||||||
|
| `page.tsx` | ~1695 | Main UI (reduced from 2554, 34% reduction) |
|
||||||
|
| `computed-stats.ts` | ~200 | Extracted utility functions |
|
||||||
|
| `navigation-slice.ts` | ~50 | Navigation actions |
|
||||||
|
| `study-slice.ts` | ~100 | Study system actions |
|
||||||
|
|
||||||
|
### Guidelines
|
||||||
|
- Keep `page.tsx` under 2000 lines by extracting to components (ActionButtons, ManaDisplay, etc.)
|
||||||
|
- Keep `store.ts` under 1800 lines by extracting to slices (navigation, study, crafting, familiar)
|
||||||
|
- Extract computed stats and utility functions to `computed-stats.ts` when >50 lines
|
||||||
|
- Use barrel exports (`index.ts`) for clean imports
|
||||||
|
- Follow the slice pattern for store organization (see below)
|
||||||
|
|||||||
+313
@@ -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**
|
||||||
Executable → Regular
+55
-9
@@ -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"]
|
||||||
|
|||||||
@@ -1,120 +1,76 @@
|
|||||||
# 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 (22 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 (1 base + 3 elemental + 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
|
||||||
|
- Click-based mana collection with automatic regeneration
|
||||||
|
- Elemental mana system with five elements (Fire, Water, Earth, Air, Void)
|
||||||
|
- Mana conversion between raw and elemental forms
|
||||||
|
- Meditation system for boosted regeneration
|
||||||
|
|
||||||
- **22 Mana Types**: 7 base elements + 1 utility + 8 composite + 6 exotic
|
### Skill Progression with Tier Evolution
|
||||||
- Elemental conversion, regeneration mechanics, and meditation bonuses
|
- 20+ skills across multiple categories (mana, combat, enchanting, familiar)
|
||||||
- Mana types: Fire, Water, Air, Earth, Light, Dark, Death (base), Transference (utility), Metal, Sand, Lightning, Frost, BlackFlame, RadiantFlames, Miasma, ShadowGlass (composite), Crystal, Stellar, Void, Soul, Time, Plasma (exotic)
|
- 5-tier evolution system for each skill
|
||||||
|
- Milestone upgrades at levels 5 and 10 for each tier
|
||||||
|
- Unique special effects unlocked through skill upgrades
|
||||||
|
|
||||||
### 📜 Discipline System
|
### Equipment Crafting & Enchanting
|
||||||
|
- 3-stage enchantment process (Design → Prepare → Apply)
|
||||||
- Practice-based progression — no discrete levels, only continuous XP growth
|
|
||||||
- Disciplines drain mana each tick; stat bonuses grow as a power curve of accumulated XP
|
|
||||||
- Perks unlock at XP thresholds (once, capped, or infinite stacking)
|
|
||||||
- Attunement-gated discipline pools (Base / Elemental / Enchanter / Invoker / Fabricator)
|
|
||||||
- Concurrent discipline slots unlock as total XP grows (max 4)
|
|
||||||
|
|
||||||
### ⚔️ Combat & Spire
|
|
||||||
|
|
||||||
- Cast-speed based combat system with elemental effectiveness
|
|
||||||
- Multi-spell support from equipped weapons
|
|
||||||
- Every 10th floor is a guardian: base elements (10–80), composite (90–160), exotic (170–240), then procedural combination bosses (250+)
|
|
||||||
- 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
|
||||||
- 8 equipment slots with 50 equipment types across 9 categories
|
|
||||||
|
|
||||||
### 🤖 Golemancy System
|
### Combat System
|
||||||
|
- Cast speed-based spell casting
|
||||||
|
- Multi-spell support from equipped weapons
|
||||||
|
- Elemental damage bonuses and effectiveness
|
||||||
|
- Floor guardians with unique boons and pacts
|
||||||
|
|
||||||
- 10 golems total: 1 base (Earth) + 3 elemental (Steel, Crystal, Sand) + 6 hybrid types
|
### Familiar System
|
||||||
- Golem slots unlock every 2 Fabricator levels (max 5 slots at Level 10)
|
- Collect and train magical companions
|
||||||
- Hybrid golems require Enchanter 5 + Fabricator 5
|
- Familiars provide passive bonuses and active abilities
|
||||||
|
- Growth and evolution mechanics
|
||||||
|
|
||||||
### 🔄 Prestige (Insight)
|
### Floor Navigation & Guardian Battles
|
||||||
|
- Procedurally generated spire floors
|
||||||
|
- Elemental floor themes affecting combat
|
||||||
|
- Guardian bosses with unique mechanics
|
||||||
|
- Pact system for permanent power boosts
|
||||||
|
|
||||||
- Reset progress for permanent Insight currency
|
### Prestige System (Insight)
|
||||||
- Insight upgrades across 14 categories
|
- Reset progress for permanent bonuses
|
||||||
- Signed pacts and attunements persist through prestige
|
- Insight upgrades across multiple categories
|
||||||
- Three attunement classes: Enchanter (Transference), Invoker (Spells/Pacts), Fabricator (Golems/Equipment)
|
- Signed pacts persist through prestige
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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) |
|
||||||
| **Bun** | Latest | JavaScript runtime & package manager |
|
| **Bun** | JavaScript runtime and package manager |
|
||||||
| **Vitest** | ^4.1.2 | Unit testing framework |
|
|
||||||
| **ESLint** | ^9 | Code linting |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +78,8 @@
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- **Bun** runtime (recommended) or Node.js 18+
|
- **Node.js** 18+ or **Bun** runtime
|
||||||
- Git
|
- **npm**, **yarn**, or **bun** package manager
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -132,17 +88,21 @@
|
|||||||
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
|
||||||
|
bun run db:push
|
||||||
|
# or
|
||||||
|
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
|
||||||
@@ -150,166 +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
|
||||||
|
bun run start
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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/
|
||||||
│ │ └── components/ # App-level components
|
│ ├── 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/, LootInventory/ 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/ # 8 Modular Zustand stores (+ supporting files)
|
│ ├── ActionButtons.tsx
|
||||||
│ ├── crafting-actions/ # Modular crafting stage handlers
|
│ └── ...
|
||||||
│ ├── constants/ # Elements, spells, rooms, prestige
|
└── lib/
|
||||||
│ ├── data/ # Game data
|
├── game/
|
||||||
│ │ ├── disciplines/ # Per-attunement discipline definitions
|
│ ├── store.ts # Zustand store (state + actions)
|
||||||
│ │ ├── enchantments/ # Enchantment effects by category
|
│ ├── effects.ts # Unified effect computation
|
||||||
│ │ ├── equipment/ # Equipment type definitions
|
│ ├── upgrade-effects.ts # Skill upgrade definitions
|
||||||
│ │ ├── golems/ # Golem definitions
|
│ ├── skill-evolution.ts # Tier progression paths
|
||||||
│ │ ├── guardian-data.ts # Static guardian definitions (floors 10–240)
|
│ ├── constants.ts # Game data definitions
|
||||||
│ │ └── guardian-encounters.ts # Procedural guardian lookup & combo bosses (250+)
|
│ ├── computed-stats.ts # Stat calculation functions
|
||||||
│ ├── effects/ # Unified stat computation
|
│ ├── crafting-slice.ts # Equipment/enchantment actions
|
||||||
│ ├── types/ # TypeScript types (disciplines, elements, etc.)
|
│ ├── familiar-slice.ts # Familiar system actions
|
||||||
│ └── utils/ # Combat, floor, enemy, discipline math helpers
|
│ ├── navigation-slice.ts # Floor navigation actions
|
||||||
├── public/ # Static assets
|
│ ├── study-slice.ts # Study system actions
|
||||||
├── docs/ # Project documentation
|
│ ├── types.ts # TypeScript interfaces
|
||||||
│ ├── AGENTS.md # Architecture guide for AI agents
|
│ ├── formatting.ts # Display formatters
|
||||||
│ └── GAME_BRIEFING.md # Comprehensive game design document
|
│ ├── utils.ts # Utility functions
|
||||||
└── Configuration Files:
|
│ └── data/
|
||||||
├── package.json, tsconfig.json, next.config.ts
|
│ ├── equipment.ts # Equipment definitions
|
||||||
├── vitest.config.ts, eslint.config.mjs
|
│ ├── enchantment-effects.ts # Enchantment catalog
|
||||||
├── Dockerfile, docker-compose.yml, Caddyfile
|
│ ├── familiars.ts # Familiar definitions
|
||||||
└── .gitea/workflows/ # Gitea Actions CI/CD pipeline
|
│ ├── crafting-recipes.ts # Crafting recipes
|
||||||
|
│ ├── achievements.ts # Achievement definitions
|
||||||
|
│ └── loot-drops.ts # Loot drop tables
|
||||||
|
└── utils.ts # General utilities
|
||||||
```
|
```
|
||||||
|
|
||||||
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. Mana is gathered manually or automatically and used for studying skills, casting spells, and crafting.
|
||||||
|
|
||||||
The core resource of the game with 22 distinct types organized in a hierarchy:
|
**Key Files:**
|
||||||
|
- `store.ts` - Mana state and actions
|
||||||
|
- `computed-stats.ts` - Mana calculations
|
||||||
|
|
||||||
- **Base Elements (7)**: Fire, Water, Air, Earth, Light, Dark, Death
|
### Skill System
|
||||||
- **Utility (1)**: Transference (Enchanter attunement)
|
Skills provide passive bonuses and unlock new abilities. Each skill can evolve through 5 tiers with milestone upgrades.
|
||||||
- **Composite (8)**: Metal (Fire+Earth), Sand (Earth+Water), Lightning (Fire+Air), Frost (Air+Water), BlackFlame (Dark+Fire), RadiantFlames (Light+Fire), Miasma (Air+Death), ShadowGlass (Earth+Dark)
|
|
||||||
- **Exotic (6)**: Crystal (Sand+Sand+Light), Stellar (Plasma+Light+Fire), Void (Dark+Dark+Death), Soul (Light+Dark+Transference), Time (Soul+Sand+Transference), Plasma (Lightning+Fire+Transference)
|
|
||||||
|
|
||||||
**Key Files**: `src/lib/game/stores/manaStore.ts`, `src/lib/game/constants/elements.ts`
|
**Key Files:**
|
||||||
|
- `constants.ts` - Skill definitions (`SKILLS_DEF`)
|
||||||
### Discipline System
|
- `skill-evolution.ts` - Evolution paths and upgrades
|
||||||
|
- `upgrade-effects.ts` - Effect computation
|
||||||
Disciplines replace the old skill system entirely. There are no discrete levels — disciplines grow **continuously** through practice. The player activates a discipline and it drains mana each tick in exchange for permanent stat growth within the run.
|
|
||||||
|
|
||||||
- **Stat bonus** grows as a power curve of XP: `baseValue × (XP / scalingFactor)^0.65`
|
|
||||||
- **Mana drain** also increases with XP: `drainBase × (1 + (XP / difficultyFactor)^0.4)`
|
|
||||||
- **Perks** unlock at XP thresholds (`once`, `capped`, or `infinite`)
|
|
||||||
- **Concurrent slots** start at 1 and unlock as total XP grows (max 4)
|
|
||||||
|
|
||||||
**Key Files**: `src/lib/game/data/disciplines/`, `src/lib/game/stores/discipline-slice.ts`, `src/lib/game/utils/discipline-math.ts`
|
|
||||||
|
|
||||||
### Guardian & Spire System
|
|
||||||
|
|
||||||
Every 10th floor is a guardian encounter. Guardians progress through multiple tiers of complexity:
|
|
||||||
|
|
||||||
1. **Base Elements (Floors 10–80)**: One guardian per base element + Transference. Static definitions with named guardians (Ignis Prime, Aqua Regia, etc.). Defeating them unlocks their associated mana types.
|
|
||||||
2. **Composite Elements (Floors 90–160)**: 8 composite element guardians (Metal, Sand, Lightning, Frost, BlackFlame, RadiantFlames, Miasma, ShadowGlass) with procedurally generated names.
|
|
||||||
3. **Exotic Elements (Floors 170–240)**: Crystal, Stellar, Void, Soul, Time, and Plasma guardians.
|
|
||||||
4. **Combination Bosses (Floor 250+)**: Fully procedural multi-element guardians through 8 scaling tiers, growing stronger every 10 floors.
|
|
||||||
|
|
||||||
**Key Files**: `src/lib/game/data/guardian-data.ts`, `src/lib/game/data/guardian-encounters.ts`
|
|
||||||
|
|
||||||
### Combat System
|
### Combat System
|
||||||
|
Combat uses a cast-speed system where each spell has a unique cast rate. Damage is calculated with skill bonuses, elemental modifiers, and special effects.
|
||||||
|
|
||||||
- Cast-speed based spell casting with elemental effectiveness multipliers
|
**Key Files:**
|
||||||
- Enemy modifiers: Armored, Agile, Mage (barrier), Shielded, Swarm
|
- `store.ts` - Combat tick logic
|
||||||
- Golem allies deal automatic damage each tick
|
- `constants.ts` - Spell definitions (`SPELLS_DEF`)
|
||||||
- Discipline bonuses feed into damage via `getUnifiedEffects()`
|
- `effects.ts` - Damage calculations
|
||||||
|
|
||||||
**Key Files**: `src/lib/game/stores/combatStore.ts`, `src/lib/game/utils/combat-utils.ts`, `src/lib/game/utils/enemy-generator.ts`
|
### Crafting System
|
||||||
|
A 3-stage enchantment system for equipment. Design effects, prepare equipment, and apply enchantments within capacity limits.
|
||||||
|
|
||||||
### Enchanting System
|
**Key Files:**
|
||||||
|
- `crafting-slice.ts` - Crafting actions
|
||||||
|
- `data/equipment.ts` - Equipment types
|
||||||
|
- `data/enchantment-effects.ts` - Available effects
|
||||||
|
|
||||||
3-stage equipment enchantment process:
|
### Familiar System
|
||||||
1. **Design**: Choose effects for your equipment type
|
Magical companions that provide bonuses and can be trained and evolved.
|
||||||
2. **Prepare**: Ready equipment (ONLY stage where disenchanting is possible)
|
|
||||||
3. **Apply**: Apply designed enchantments
|
|
||||||
|
|
||||||
**Key Files**: `src/lib/game/crafting-actions/`, `src/lib/game/data/enchantments/`
|
**Key Files:**
|
||||||
|
- `familiar-slice.ts` - Familiar actions
|
||||||
|
- `data/familiars.ts` - Familiar definitions
|
||||||
|
|
||||||
### Golemancy System
|
### Prestige System
|
||||||
|
Reset progress for Insight, which provides permanent bonuses. Signed pacts persist through prestige.
|
||||||
|
|
||||||
- **Base Golems**: Earth (Fabricator 2)
|
**Key Files:**
|
||||||
- **Elemental Golems**: Steel (Metal), Crystal, Sand
|
- `store.ts` - Prestige logic
|
||||||
- **Hybrid Golems** (Enchanter 5 + Fabricator 5): Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone
|
- `constants.ts` - Insight upgrades
|
||||||
- **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`
|
|
||||||
|
|
||||||
### Prestige (Insight)
|
|
||||||
|
|
||||||
Reset progress to gain Insight currency for permanent upgrades:
|
|
||||||
- Signed pacts persist through prestige
|
|
||||||
- Attunement choices affect gameplay (Enchanter/Invoker/Fabricator)
|
|
||||||
- 14 insight upgrade types provide bonuses across all loops
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -319,55 +230,29 @@ We welcome contributions! Please follow these guidelines:
|
|||||||
|
|
||||||
### Development Workflow
|
### Development Workflow
|
||||||
|
|
||||||
1. **Pull latest changes** before starting work: `git pull origin master`
|
1. **Pull the latest changes** before starting work
|
||||||
2. **Create a feature branch** for your changes: `git checkout -b feature/your-feature`
|
2. **Create a feature branch** for your changes
|
||||||
3. **Follow existing patterns** in the codebase (see AGENTS.md)
|
3. **Follow existing patterns** in the codebase
|
||||||
4. **Run linting** before committing: `bun run lint`
|
4. **Run linting** before committing (`bun run lint`)
|
||||||
5. **Test your changes** thoroughly: `bun run test`
|
5. **Test your changes** thoroughly
|
||||||
6. **Commit and push** to your branch, then create a pull request
|
|
||||||
|
|
||||||
### 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 T1–T5, milestone upgrades) — Fully replaced by the Discipline System
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
|
|
||||||
```
|
```
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
@@ -387,7 +272,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
```
|
```
|
||||||
@@ -396,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>
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[test]
|
|
||||||
dir = "./src/test"
|
|
||||||
preload = ["./src/test/setup.ts"]
|
|
||||||
Executable
BIN
Binary file not shown.
Executable → Regular
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
|||||||
# Circular Dependencies
|
|
||||||
Generated: 2026-06-10T08:14:33.822Z
|
|
||||||
Found: 2 circular chain(s) — these MUST be fixed before modifying involved files.
|
|
||||||
|
|
||||||
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
|
|
||||||
2. 2) stores/combatStore.ts > stores/combat-descent-actions.ts > stores/attunementStore.ts
|
|
||||||
|
|
||||||
## How to fix
|
|
||||||
1. Identify which import in the chain can be extracted to a shared types/utils file.
|
|
||||||
2. Move the shared type or function there.
|
|
||||||
3. Both files import from the new shared module instead of each other.
|
|
||||||
4. Run: bunx madge --circular src/lib/game (should return clean)
|
|
||||||
@@ -1,938 +0,0 @@
|
|||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"generated": "2026-06-10T08:14:31.514Z",
|
|
||||||
"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",
|
|
||||||
"data/equipment/equipment-types-data.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/blackflame-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/frost-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/miasma-spells.ts": [
|
|
||||||
"constants/elements.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"constants/spells-modules/plasma-spells.ts": [
|
|
||||||
"constants/elements.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"constants/spells-modules/radiantflames-spells.ts": [
|
|
||||||
"constants/elements.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"constants/spells-modules/raw-spells.ts": [
|
|
||||||
"constants/elements.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"constants/spells-modules/shadowglass-spells.ts": [
|
|
||||||
"constants/elements.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"constants/spells-modules/soul-spells.ts": [
|
|
||||||
"constants/elements.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"constants/spells-modules/time-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/blackflame-spells.ts",
|
|
||||||
"constants/spells-modules/compound-spells.ts",
|
|
||||||
"constants/spells-modules/enchantment-spells.ts",
|
|
||||||
"constants/spells-modules/frost-spells.ts",
|
|
||||||
"constants/spells-modules/legendary-spells.ts",
|
|
||||||
"constants/spells-modules/lightning-spells.ts",
|
|
||||||
"constants/spells-modules/master-spells.ts",
|
|
||||||
"constants/spells-modules/miasma-spells.ts",
|
|
||||||
"constants/spells-modules/plasma-spells.ts",
|
|
||||||
"constants/spells-modules/radiantflames-spells.ts",
|
|
||||||
"constants/spells-modules/raw-spells.ts",
|
|
||||||
"constants/spells-modules/shadowglass-spells.ts",
|
|
||||||
"constants/spells-modules/soul-spells.ts",
|
|
||||||
"constants/spells-modules/time-spells.ts",
|
|
||||||
"constants/spells-modules/utility-spells.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"crafting-actions/application-actions.ts": [
|
|
||||||
"crafting-apply.ts",
|
|
||||||
"stores/craftingStore.types.ts",
|
|
||||||
"stores/manaStore.ts",
|
|
||||||
"stores/uiStore.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/manaStore.ts",
|
|
||||||
"stores/uiStore.ts"
|
|
||||||
],
|
|
||||||
"crafting-actions/design-actions.ts": [
|
|
||||||
"crafting-design.ts",
|
|
||||||
"crafting-utils.ts",
|
|
||||||
"effects/discipline-effects.ts",
|
|
||||||
"effects/special-effects.ts",
|
|
||||||
"effects/upgrade-effects.ts",
|
|
||||||
"stores/craftingStore.types.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"crafting-actions/disenchant-actions.ts": [
|
|
||||||
"stores/craftingStore.types.ts",
|
|
||||||
"stores/manaStore.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",
|
|
||||||
"stores/manaStore.ts",
|
|
||||||
"stores/uiStore.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",
|
|
||||||
"effects/discipline-effects.ts",
|
|
||||||
"stores/manaStore.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/conversion-costs.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/blackflame-spells.ts": [
|
|
||||||
"data/enchantments/spell-effects/types.ts"
|
|
||||||
],
|
|
||||||
"data/enchantments/spell-effects/exotic-new-spells.ts": [
|
|
||||||
"data/enchantments/spell-effects/types.ts"
|
|
||||||
],
|
|
||||||
"data/enchantments/spell-effects/frost-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/blackflame-spells.ts",
|
|
||||||
"data/enchantments/spell-effects/exotic-new-spells.ts",
|
|
||||||
"data/enchantments/spell-effects/frost-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/miasma-spells.ts",
|
|
||||||
"data/enchantments/spell-effects/radiantflames-spells.ts",
|
|
||||||
"data/enchantments/spell-effects/sand-spells.ts",
|
|
||||||
"data/enchantments/spell-effects/shadowglass-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/miasma-spells.ts": [
|
|
||||||
"data/enchantments/spell-effects/types.ts"
|
|
||||||
],
|
|
||||||
"data/enchantments/spell-effects/radiantflames-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/shadowglass-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/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/cores.ts": [
|
|
||||||
"data/golems/types.ts"
|
|
||||||
],
|
|
||||||
"data/golems/frames.ts": [
|
|
||||||
"data/golems/types.ts"
|
|
||||||
],
|
|
||||||
"data/golems/golemEnchantments.ts": [
|
|
||||||
"data/golems/types.ts"
|
|
||||||
],
|
|
||||||
"data/golems/golems-data.ts": [
|
|
||||||
"data/golems/cores.ts",
|
|
||||||
"data/golems/frames.ts",
|
|
||||||
"data/golems/golemEnchantments.ts",
|
|
||||||
"data/golems/mindCircuits.ts"
|
|
||||||
],
|
|
||||||
"data/golems/index.ts": [
|
|
||||||
"data/golems/cores.ts",
|
|
||||||
"data/golems/frames.ts",
|
|
||||||
"data/golems/golemEnchantments.ts",
|
|
||||||
"data/golems/mindCircuits.ts",
|
|
||||||
"data/golems/types.ts"
|
|
||||||
],
|
|
||||||
"data/golems/mindCircuits.ts": [
|
|
||||||
"data/golems/types.ts"
|
|
||||||
],
|
|
||||||
"data/golems/types.ts": [
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"data/golems/utils.ts": [
|
|
||||||
"data/golems/cores.ts",
|
|
||||||
"data/golems/frames.ts",
|
|
||||||
"data/golems/mindCircuits.ts",
|
|
||||||
"data/golems/types.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"data/guardian-data.ts": [
|
|
||||||
"types.ts",
|
|
||||||
"utils/guardian-utils.ts"
|
|
||||||
],
|
|
||||||
"data/guardian-encounters.ts": [
|
|
||||||
"data/guardian-data.ts",
|
|
||||||
"types.ts",
|
|
||||||
"utils/guardian-utils.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",
|
|
||||||
"stores/combatStore.ts",
|
|
||||||
"types.ts",
|
|
||||||
"utils/safe-persist.ts"
|
|
||||||
],
|
|
||||||
"stores/combat-actions.ts": [
|
|
||||||
"constants.ts",
|
|
||||||
"data/guardian-encounters.ts",
|
|
||||||
"effects/discipline-effects.ts",
|
|
||||||
"stores/combat-damage.ts",
|
|
||||||
"stores/combat-state.types.ts",
|
|
||||||
"stores/dot-runtime.ts",
|
|
||||||
"stores/golem-combat-actions.ts",
|
|
||||||
"stores/golem-combat-helpers.ts",
|
|
||||||
"types.ts",
|
|
||||||
"utils/index.ts"
|
|
||||||
],
|
|
||||||
"stores/combat-damage.ts": [
|
|
||||||
"stores/combat-state.types.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"stores/combat-descent-actions.ts": [
|
|
||||||
"data/guardian-encounters.ts",
|
|
||||||
"effects/discipline-effects.ts",
|
|
||||||
"stores/attunementStore.ts",
|
|
||||||
"stores/combat-state.types.ts",
|
|
||||||
"stores/golem-combat-actions.ts",
|
|
||||||
"stores/manaStore.ts",
|
|
||||||
"stores/non-combat-room-actions.ts",
|
|
||||||
"stores/prestigeStore.ts",
|
|
||||||
"utils/spire-utils.ts"
|
|
||||||
],
|
|
||||||
"stores/combat-state.types.ts": [
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"stores/combatStore.ts": [
|
|
||||||
"data/guardian-encounters.ts",
|
|
||||||
"stores/combat-actions.ts",
|
|
||||||
"stores/combat-descent-actions.ts",
|
|
||||||
"stores/combat-state.types.ts",
|
|
||||||
"stores/golemancy-actions.ts",
|
|
||||||
"stores/non-combat-room-actions.ts",
|
|
||||||
"types.ts",
|
|
||||||
"utils/activity-log.ts",
|
|
||||||
"utils/index.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",
|
|
||||||
"stores/craftingStore.types.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-utils.ts",
|
|
||||||
"effects/discipline-effects.ts",
|
|
||||||
"stores/combatStore.ts",
|
|
||||||
"stores/crafting-equipment-tick.ts",
|
|
||||||
"stores/crafting-initial-state.ts",
|
|
||||||
"stores/craftingStore.types.ts",
|
|
||||||
"stores/manaStore.ts",
|
|
||||||
"stores/pipelines/equipment-crafting.ts",
|
|
||||||
"stores/uiStore.ts",
|
|
||||||
"types/equipmentSlot.ts",
|
|
||||||
"utils/result.ts",
|
|
||||||
"utils/safe-persist.ts"
|
|
||||||
],
|
|
||||||
"stores/craftingStore.types.ts": [
|
|
||||||
"types.ts",
|
|
||||||
"types/equipmentSlot.ts"
|
|
||||||
],
|
|
||||||
"stores/debugBridge.ts": [
|
|
||||||
"stores/attunementStore.ts",
|
|
||||||
"stores/combatStore.ts",
|
|
||||||
"stores/craftingStore.ts",
|
|
||||||
"stores/discipline-slice.ts",
|
|
||||||
"stores/gameStore.ts",
|
|
||||||
"stores/manaStore.ts",
|
|
||||||
"stores/prestigeStore.ts",
|
|
||||||
"stores/uiStore.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",
|
|
||||||
"stores/manaStore.ts",
|
|
||||||
"stores/prestigeStore.ts",
|
|
||||||
"types.ts",
|
|
||||||
"types/disciplines.ts",
|
|
||||||
"utils/discipline-math.ts",
|
|
||||||
"utils/safe-persist.ts"
|
|
||||||
],
|
|
||||||
"stores/dot-runtime.ts": [
|
|
||||||
"constants.ts",
|
|
||||||
"stores/combat-state.types.ts",
|
|
||||||
"types.ts",
|
|
||||||
"types/spells.ts"
|
|
||||||
],
|
|
||||||
"stores/gameActions.ts": [
|
|
||||||
"effects/discipline-effects.ts",
|
|
||||||
"stores/attunementStore.ts",
|
|
||||||
"stores/combatStore.ts",
|
|
||||||
"stores/craftingStore.ts",
|
|
||||||
"stores/discipline-slice.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/discipline-slice.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/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/pipelines/combat-tick.ts",
|
|
||||||
"stores/pipelines/enchanting-tick.ts",
|
|
||||||
"stores/pipelines/golem-combat.ts",
|
|
||||||
"stores/pipelines/pact-ritual.ts",
|
|
||||||
"stores/prestigeStore.ts",
|
|
||||||
"stores/tick-pipeline.ts",
|
|
||||||
"stores/uiStore.ts",
|
|
||||||
"types.ts",
|
|
||||||
"utils/conversion-rates.ts",
|
|
||||||
"utils/element-cap-bonus.ts",
|
|
||||||
"utils/element-distance.ts",
|
|
||||||
"utils/index.ts",
|
|
||||||
"utils/safe-persist.ts"
|
|
||||||
],
|
|
||||||
"stores/gameStore.types.ts": [],
|
|
||||||
"stores/golem-combat-actions.ts": [
|
|
||||||
"constants.ts",
|
|
||||||
"data/golems/index.ts",
|
|
||||||
"data/golems/types.ts",
|
|
||||||
"data/golems/utils.ts",
|
|
||||||
"stores/golem-combat-helpers.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"stores/golem-combat-helpers.ts": [
|
|
||||||
"data/golems/index.ts",
|
|
||||||
"stores/combat-state.types.ts",
|
|
||||||
"stores/golem-combat-actions.ts",
|
|
||||||
"types.ts",
|
|
||||||
"utils/index.ts"
|
|
||||||
],
|
|
||||||
"stores/golemancy-actions.ts": [
|
|
||||||
"types/game.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/non-combat-room-actions.ts": [
|
|
||||||
"constants.ts",
|
|
||||||
"data/attunements.ts",
|
|
||||||
"stores/attunementStore.ts",
|
|
||||||
"stores/combat-state.types.ts",
|
|
||||||
"stores/discipline-slice.ts",
|
|
||||||
"stores/manaStore.ts"
|
|
||||||
],
|
|
||||||
"stores/pipelines/combat-tick.ts": [
|
|
||||||
"constants.ts",
|
|
||||||
"data/guardian-encounters.ts",
|
|
||||||
"effects/special-effects.ts",
|
|
||||||
"effects/upgrade-effects.types.ts",
|
|
||||||
"stores/attunementStore.ts",
|
|
||||||
"stores/combat-state.types.ts",
|
|
||||||
"stores/golem-combat-actions.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"stores/pipelines/enchanting-tick.ts": [
|
|
||||||
"constants.ts",
|
|
||||||
"crafting-apply.ts",
|
|
||||||
"crafting-design.ts",
|
|
||||||
"crafting-prep.ts",
|
|
||||||
"effects/discipline-effects.ts",
|
|
||||||
"effects/upgrade-effects.types.ts",
|
|
||||||
"stores/craftingStore.ts",
|
|
||||||
"stores/tick-pipeline.ts"
|
|
||||||
],
|
|
||||||
"stores/pipelines/equipment-crafting.ts": [
|
|
||||||
"crafting-equipment.ts",
|
|
||||||
"crafting-fabricator.ts",
|
|
||||||
"stores/combatStore.ts",
|
|
||||||
"stores/craftingStore.types.ts",
|
|
||||||
"stores/manaStore.ts",
|
|
||||||
"stores/uiStore.ts"
|
|
||||||
],
|
|
||||||
"stores/pipelines/golem-combat.ts": [
|
|
||||||
"effects/discipline-effects.ts",
|
|
||||||
"stores/attunementStore.ts",
|
|
||||||
"stores/combatStore.ts",
|
|
||||||
"stores/golem-combat-actions.ts",
|
|
||||||
"stores/manaStore.ts",
|
|
||||||
"types.ts"
|
|
||||||
],
|
|
||||||
"stores/pipelines/pact-ritual.ts": [
|
|
||||||
"constants.ts",
|
|
||||||
"data/guardian-encounters.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/conversion-rates.ts": [
|
|
||||||
"data/conversion-costs.ts",
|
|
||||||
"effects/discipline-effects.ts",
|
|
||||||
"utils/element-distance.ts"
|
|
||||||
],
|
|
||||||
"utils/discipline-math.ts": [
|
|
||||||
"types/disciplines.ts"
|
|
||||||
],
|
|
||||||
"utils/element-cap-bonus.ts": [],
|
|
||||||
"utils/element-distance.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/guardian-utils.ts": [
|
|
||||||
"constants/elements.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",
|
|
||||||
"data/loot-drops.ts",
|
|
||||||
"types.ts",
|
|
||||||
"types/game.ts",
|
|
||||||
"utils/enemy-utils.ts",
|
|
||||||
"utils/floor-utils.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,461 +0,0 @@
|
|||||||
Mana-Loop/
|
|
||||||
├── .gitea/
|
|
||||||
│ └── workflows/
|
|
||||||
│ └── docker-build.yaml
|
|
||||||
├── .husky/
|
|
||||||
│ ├── scripts/
|
|
||||||
│ │ ├── check-file-size.js
|
|
||||||
│ │ ├── generate-dependency-graph.js
|
|
||||||
│ │ ├── generate-project-tree.js
|
|
||||||
│ │ └── run-tests.sh
|
|
||||||
│ ├── post-merge
|
|
||||||
│ └── pre-commit
|
|
||||||
├── docs/
|
|
||||||
│ ├── specs/
|
|
||||||
│ │ ├── attunements/
|
|
||||||
│ │ │ ├── enchanter/
|
|
||||||
│ │ │ │ ├── systems/
|
|
||||||
│ │ │ │ │ └── enchanting-spec.md
|
|
||||||
│ │ │ │ └── enchanter-spec.md
|
|
||||||
│ │ │ ├── fabricator/
|
|
||||||
│ │ │ │ ├── systems/
|
|
||||||
│ │ │ │ │ ├── golemancy-spec.md
|
|
||||||
│ │ │ │ │ └── item-fabrication-spec.md
|
|
||||||
│ │ │ │ └── fabricator-spec.md
|
|
||||||
│ │ │ ├── invoker/
|
|
||||||
│ │ │ │ ├── systems/
|
|
||||||
│ │ │ │ │ └── pact-system-spec.md
|
|
||||||
│ │ │ │ └── invoker-spec.md
|
|
||||||
│ │ │ └── attunement-system-spec.md
|
|
||||||
│ │ ├── mana-conversion-spec.md
|
|
||||||
│ │ ├── spire-climbing-spec.md
|
|
||||||
│ │ └── spire-combat-spec.md
|
|
||||||
│ ├── GAME_BRIEFING.md
|
|
||||||
│ ├── circular-deps.txt
|
|
||||||
│ ├── dependency-graph.json
|
|
||||||
│ └── project-structure.txt
|
|
||||||
├── e2e/
|
|
||||||
│ ├── combat-happy-path.spec.ts
|
|
||||||
│ ├── enchanter-happy-path.spec.ts
|
|
||||||
│ ├── fabricator-happy-path.spec.ts
|
|
||||||
│ └── playtest.spec.ts
|
|
||||||
├── public/
|
|
||||||
│ ├── fonts/
|
|
||||||
│ │ ├── GeistMonoVF.woff
|
|
||||||
│ │ └── GeistVF.woff
|
|
||||||
│ ├── logo.svg
|
|
||||||
│ └── robots.txt
|
|
||||||
├── src/
|
|
||||||
│ ├── app/
|
|
||||||
│ │ ├── components/
|
|
||||||
│ │ │ ├── GameOverScreen.tsx
|
|
||||||
│ │ │ └── LeftPanel.tsx
|
|
||||||
│ │ ├── globals.css
|
|
||||||
│ │ ├── layout.tsx
|
|
||||||
│ │ └── page.tsx
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── game/
|
|
||||||
│ │ │ ├── LootInventory/
|
|
||||||
│ │ │ │ ├── BlueprintsSection.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
|
|
||||||
│ │ │ ├── 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.test.ts
|
|
||||||
│ │ │ │ │ ├── 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
|
|
||||||
│ │ │ │ ├── golemancy/
|
|
||||||
│ │ │ │ │ ├── ActiveGolemsPanel.tsx
|
|
||||||
│ │ │ │ │ ├── GolemDesignBuilder.tsx
|
|
||||||
│ │ │ │ │ ├── GolemLoadoutPanel.tsx
|
|
||||||
│ │ │ │ │ ├── GolemancyComponents.test.ts
|
|
||||||
│ │ │ │ │ ├── GolemancySharedComponents.tsx
|
|
||||||
│ │ │ │ │ ├── golemancy-components.test.ts
|
|
||||||
│ │ │ │ │ ├── golemancy-utils.test.ts
|
|
||||||
│ │ │ │ │ ├── golemancy-utils.ts
|
|
||||||
│ │ │ │ │ └── types.ts
|
|
||||||
│ │ │ │ ├── AchievementsTab.tsx
|
|
||||||
│ │ │ │ ├── ActivityLog.tsx
|
|
||||||
│ │ │ │ ├── AttunementsTab.test.ts
|
|
||||||
│ │ │ │ ├── AttunementsTab.tsx
|
|
||||||
│ │ │ │ ├── CraftingTab.test.ts
|
|
||||||
│ │ │ │ ├── CraftingTab.tsx
|
|
||||||
│ │ │ │ ├── DebugTab.test.ts
|
|
||||||
│ │ │ │ ├── DebugTab.tsx
|
|
||||||
│ │ │ │ ├── DisciplineCard.tsx
|
|
||||||
│ │ │ │ ├── DisciplinesTab.tsx
|
|
||||||
│ │ │ │ ├── ElementalSubtab.tsx
|
|
||||||
│ │ │ │ ├── EquipmentTab.test.ts
|
|
||||||
│ │ │ │ ├── EquipmentTab.tsx
|
|
||||||
│ │ │ │ ├── GolemancyTab.tsx
|
|
||||||
│ │ │ │ ├── GuardianPactsTab.test.ts
|
|
||||||
│ │ │ │ ├── GuardianPactsTab.tsx
|
|
||||||
│ │ │ │ ├── PrestigeTab.test.ts
|
|
||||||
│ │ │ │ ├── PrestigeTab.tsx
|
|
||||||
│ │ │ │ ├── SpireSummaryTab.helpers.tsx
|
|
||||||
│ │ │ │ ├── SpireSummaryTab.test.ts
|
|
||||||
│ │ │ │ ├── SpireSummaryTab.tsx
|
|
||||||
│ │ │ │ ├── StatsTab.tsx
|
|
||||||
│ │ │ │ ├── disciplines-utils.ts
|
|
||||||
│ │ │ │ ├── guardian-pacts-components.tsx
|
|
||||||
│ │ │ │ └── index.ts
|
|
||||||
│ │ │ ├── ActionButtons.tsx
|
|
||||||
│ │ │ ├── ActivityLogPanel.tsx
|
|
||||||
│ │ │ ├── GameToast.tsx
|
|
||||||
│ │ │ ├── ManaDisplay.tsx
|
|
||||||
│ │ │ ├── TimeDisplay.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__/
|
|
||||||
│ │ │ │ ├── achievements.test.ts
|
|
||||||
│ │ │ │ ├── activity-log.test.ts
|
|
||||||
│ │ │ │ ├── attunement-conversion-fix.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
|
|
||||||
│ │ │ │ ├── curse-amplification.test.ts
|
|
||||||
│ │ │ │ ├── design-validation-perk-gating.test.ts
|
|
||||||
│ │ │ │ ├── discipline-math.test.ts
|
|
||||||
│ │ │ │ ├── discipline-prerequisites.test.ts
|
|
||||||
│ │ │ │ ├── discipline-reactivate-bug.test.ts
|
|
||||||
│ │ │ │ ├── earth-desync.test.ts
|
|
||||||
│ │ │ │ ├── enemy-barrier-utils.test.ts
|
|
||||||
│ │ │ │ ├── enemy-defenses.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
|
|
||||||
│ │ │ │ ├── hasty-enchanter.test.ts
|
|
||||||
│ │ │ │ ├── mana-conversion-component-deduction.test.ts
|
|
||||||
│ │ │ │ ├── mana-utils.test.ts
|
|
||||||
│ │ │ │ ├── melee-auto-attack.test.ts
|
|
||||||
│ │ │ │ ├── melee-defense-bypass.test.ts
|
|
||||||
│ │ │ │ ├── pact-utils.test.ts
|
|
||||||
│ │ │ │ ├── paused-conversion-dedup.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
|
|
||||||
│ │ │ │ │ ├── blackflame-spells.ts
|
|
||||||
│ │ │ │ │ ├── compound-spells.ts
|
|
||||||
│ │ │ │ │ ├── enchantment-spells.ts
|
|
||||||
│ │ │ │ │ ├── frost-spells.ts
|
|
||||||
│ │ │ │ │ ├── legendary-spells.ts
|
|
||||||
│ │ │ │ │ ├── lightning-spells.ts
|
|
||||||
│ │ │ │ │ ├── master-spells.ts
|
|
||||||
│ │ │ │ │ ├── miasma-spells.ts
|
|
||||||
│ │ │ │ │ ├── plasma-spells.ts
|
|
||||||
│ │ │ │ │ ├── radiantflames-spells.ts
|
|
||||||
│ │ │ │ │ ├── raw-spells.ts
|
|
||||||
│ │ │ │ │ ├── shadowglass-spells.ts
|
|
||||||
│ │ │ │ │ ├── soul-spells.ts
|
|
||||||
│ │ │ │ │ ├── time-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
|
|
||||||
│ │ │ │ │ │ ├── blackflame-spells.ts
|
|
||||||
│ │ │ │ │ │ ├── exotic-new-spells.ts
|
|
||||||
│ │ │ │ │ │ ├── frost-spells.ts
|
|
||||||
│ │ │ │ │ │ ├── index.ts
|
|
||||||
│ │ │ │ │ │ ├── legendary-spells.ts
|
|
||||||
│ │ │ │ │ │ ├── lightning-spells.ts
|
|
||||||
│ │ │ │ │ │ ├── metal-spells.ts
|
|
||||||
│ │ │ │ │ │ ├── miasma-spells.ts
|
|
||||||
│ │ │ │ │ │ ├── radiantflames-spells.ts
|
|
||||||
│ │ │ │ │ │ ├── sand-spells.ts
|
|
||||||
│ │ │ │ │ │ ├── shadowglass-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
|
|
||||||
│ │ │ │ │ ├── swords.ts
|
|
||||||
│ │ │ │ │ ├── types.ts
|
|
||||||
│ │ │ │ │ └── utils.ts
|
|
||||||
│ │ │ │ ├── golems/
|
|
||||||
│ │ │ │ │ ├── cores.ts
|
|
||||||
│ │ │ │ │ ├── frames.ts
|
|
||||||
│ │ │ │ │ ├── golemEnchantments.ts
|
|
||||||
│ │ │ │ │ ├── golemancy-data.test.ts
|
|
||||||
│ │ │ │ │ ├── golems-data.ts
|
|
||||||
│ │ │ │ │ ├── index.ts
|
|
||||||
│ │ │ │ │ ├── mindCircuits.ts
|
|
||||||
│ │ │ │ │ ├── types.ts
|
|
||||||
│ │ │ │ │ └── utils.ts
|
|
||||||
│ │ │ │ ├── achievements.ts
|
|
||||||
│ │ │ │ ├── attunements.ts
|
|
||||||
│ │ │ │ ├── conversion-costs.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/
|
|
||||||
│ │ │ │ ├── pipelines/
|
|
||||||
│ │ │ │ │ ├── combat-tick.ts
|
|
||||||
│ │ │ │ │ ├── enchanting-tick.ts
|
|
||||||
│ │ │ │ │ ├── equipment-crafting.ts
|
|
||||||
│ │ │ │ │ ├── golem-combat.ts
|
|
||||||
│ │ │ │ │ └── pact-ritual.ts
|
|
||||||
│ │ │ │ ├── attunementStore.ts
|
|
||||||
│ │ │ │ ├── combat-actions.ts
|
|
||||||
│ │ │ │ ├── combat-damage.ts
|
|
||||||
│ │ │ │ ├── combat-descent-actions.ts
|
|
||||||
│ │ │ │ ├── combat-state.types.ts
|
|
||||||
│ │ │ │ ├── combatStore.ts
|
|
||||||
│ │ │ │ ├── crafting-equipment-tick.ts
|
|
||||||
│ │ │ │ ├── crafting-initial-state.ts
|
|
||||||
│ │ │ │ ├── craftingStore.ts
|
|
||||||
│ │ │ │ ├── craftingStore.types.ts
|
|
||||||
│ │ │ │ ├── debugBridge.ts
|
|
||||||
│ │ │ │ ├── discipline-slice.ts
|
|
||||||
│ │ │ │ ├── dot-runtime.ts
|
|
||||||
│ │ │ │ ├── gameActions.ts
|
|
||||||
│ │ │ │ ├── gameHooks.ts
|
|
||||||
│ │ │ │ ├── gameLoopActions.ts
|
|
||||||
│ │ │ │ ├── gameStore.ts
|
|
||||||
│ │ │ │ ├── gameStore.types.ts
|
|
||||||
│ │ │ │ ├── golem-combat-actions.test.ts
|
|
||||||
│ │ │ │ ├── golem-combat-actions.ts
|
|
||||||
│ │ │ │ ├── golem-combat-helpers.test.ts
|
|
||||||
│ │ │ │ ├── golem-combat-helpers.ts
|
|
||||||
│ │ │ │ ├── golem-combat-maintenance.test.ts
|
|
||||||
│ │ │ │ ├── golemancy-actions.ts
|
|
||||||
│ │ │ │ ├── golemancy-combat.test.ts
|
|
||||||
│ │ │ │ ├── index.ts
|
|
||||||
│ │ │ │ ├── manaStore.ts
|
|
||||||
│ │ │ │ ├── non-combat-room-actions.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
|
|
||||||
│ │ │ │ ├── conversion-rates.ts
|
|
||||||
│ │ │ │ ├── discipline-math.ts
|
|
||||||
│ │ │ │ ├── element-cap-bonus.ts
|
|
||||||
│ │ │ │ ├── element-distance.ts
|
|
||||||
│ │ │ │ ├── enemy-generator.ts
|
|
||||||
│ │ │ │ ├── enemy-utils.ts
|
|
||||||
│ │ │ │ ├── floor-utils.ts
|
|
||||||
│ │ │ │ ├── formatting.ts
|
|
||||||
│ │ │ │ ├── guardian-utils.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/
|
|
||||||
│ └── setup.ts
|
|
||||||
├── .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,352 +0,0 @@
|
|||||||
# Attunement System — Design Spec
|
|
||||||
|
|
||||||
> Describes the three-attunement class system: Enchanter, Invoker, and Fabricator.
|
|
||||||
> Covers slot assignments, unlock conditions, leveling, regen/conversion scaling,
|
|
||||||
> discipline pool gating, and interaction with mana conversion and the incursion system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Objective
|
|
||||||
|
|
||||||
Attunements are class-like specializations that gate access to discipline pools and
|
|
||||||
unique capabilities. A player can have multiple attunements active simultaneously,
|
|
||||||
each contributing raw mana regen and (for Enchanter and Fabricator) automatic mana
|
|
||||||
conversion. Attunements level up independently through attunement-specific XP sources,
|
|
||||||
scaling their regen and conversion rates exponentially.
|
|
||||||
|
|
||||||
**Design goals:**
|
|
||||||
- Three distinct attunements with unique identities and roles
|
|
||||||
- Attunements unlock over time, expanding the player's options
|
|
||||||
- Leveling provides meaningful exponential scaling without being mandatory
|
|
||||||
- Discipline pool access is gated behind attunement unlock status
|
|
||||||
- Invoker's lack of primary mana creates a distinct pact-dependent playstyle
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The Three Attunements
|
|
||||||
|
|
||||||
### 2.1 Enchanter (Right Hand) — Starting Attunement
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|---|---|
|
|
||||||
| **ID** | `enchanter` |
|
|
||||||
| **Slot** | `rightHand` |
|
|
||||||
| **Icon** | `✨` |
|
|
||||||
| **Color** | `#1ABC9C` (Teal) |
|
|
||||||
| **Primary Mana** | `transference` |
|
|
||||||
| **Raw Mana Regen** | +0.5/hour (base) |
|
|
||||||
| **Conversion Rate** | 0.2 raw→transference/hour (base) |
|
|
||||||
| **Unlock** | Starting (unlocked by default) |
|
|
||||||
| **Capabilities** | `['enchanting']` |
|
|
||||||
| **Skill Categories** | `['enchant', 'effectResearch']` |
|
|
||||||
|
|
||||||
**Disciplines:** 10 disciplines across 4 files (core: 4, utility: 2, spells: 3, special: 1)
|
|
||||||
|
|
||||||
### 2.2 Invoker (Chest) — Locked
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|---|---|
|
|
||||||
| **ID** | `invoker` |
|
|
||||||
| **Slot** | `chest` |
|
|
||||||
| **Icon** | `💜` |
|
|
||||||
| **Color** | `#9B59B6` (Purple) |
|
|
||||||
| **Primary Mana** | None (gains elemental mana from pacts) |
|
|
||||||
| **Raw Mana Regen** | +0.3/hour (base) |
|
|
||||||
| **Conversion Rate** | None (0 at all levels) |
|
|
||||||
| **Unlock** | Defeat first Guardian |
|
|
||||||
| **Capabilities** | `['pacts', 'guardianPowers', 'elementalMastery']` |
|
|
||||||
| **Skill Categories** | `['invocation', 'pact']` |
|
|
||||||
|
|
||||||
**Disciplines:** 2 disciplines
|
|
||||||
|
|
||||||
### 2.3 Fabricator (Left Hand) — Locked
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|---|---|
|
|
||||||
| **ID** | `fabricator` |
|
|
||||||
| **Slot** | `leftHand` |
|
|
||||||
| **Icon** | `⚒️` |
|
|
||||||
| **Color** | `#F4A261` (Earth) |
|
|
||||||
| **Primary Mana** | `earth` |
|
|
||||||
| **Raw Mana Regen** | +0.4/hour (base) |
|
|
||||||
| **Conversion Rate** | 0.25 raw→earth/hour (base) |
|
|
||||||
| **Unlock** | Prove crafting worth |
|
|
||||||
| **Capabilities** | `['golemCrafting', 'gearCrafting', 'earthShaping']` |
|
|
||||||
| **Skill Categories** | `['fabrication', 'golemancy']` |
|
|
||||||
|
|
||||||
**Disciplines:** 5 disciplines
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Unlock Conditions
|
|
||||||
|
|
||||||
| Attunement | Condition | Implementation |
|
|
||||||
|---|---|---|
|
|
||||||
| **Enchanter** | Starting | Present in initial state: `{ active: true, level: 1, experience: 0 }` |
|
|
||||||
| **Invoker** | Defeat first Guardian | Descriptive: `"Defeat your first guardian and choose the path of the Invoker"` |
|
|
||||||
| **Fabricator** | Prove crafting worth | Descriptive: `"Prove your worth as a crafter"` |
|
|
||||||
|
|
||||||
Unlocking is performed via `debugUnlockAttunement(attunementId)` in the store, which
|
|
||||||
initializes the attunement at `{ active: true, level: 1, experience: 0 }`. The
|
|
||||||
conditions are currently descriptive strings rather than hard-coded mechanical checks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Attunement Leveling
|
|
||||||
|
|
||||||
### 4.1 XP Thresholds
|
|
||||||
|
|
||||||
```
|
|
||||||
Level 1: 0 XP (starting)
|
|
||||||
Level 2: 1,000 XP
|
|
||||||
Level ≥ 3: Math.floor(1000 * Math.pow(2, level - 2) * 1.25)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Level | XP Threshold | Cumulative XP |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | 0 | 0 |
|
|
||||||
| 2 | 1,000 | 1,000 |
|
|
||||||
| 3 | 2,500 | 3,500 |
|
|
||||||
| 4 | 5,000 | 8,500 |
|
|
||||||
| 5 | 10,000 | 18,500 |
|
|
||||||
| 6 | 20,000 | 38,500 |
|
|
||||||
| 7 | 40,000 | 78,500 |
|
|
||||||
| 8 | 80,000 | 158,500 |
|
|
||||||
| 9 | 160,000 | 318,500 |
|
|
||||||
| 10 | 320,000 | 638,500 |
|
|
||||||
|
|
||||||
**Max Level:** `MAX_ATTUNEMENT_LEVEL = 10`
|
|
||||||
|
|
||||||
### 4.2 Level-Up Mechanism
|
|
||||||
|
|
||||||
```
|
|
||||||
addAttunementXP(attunementId, amount):
|
|
||||||
state.experience += amount
|
|
||||||
while state.experience >= xpForNextLevel && level < MAX:
|
|
||||||
state.experience -= xpForNextLevel
|
|
||||||
level += 1
|
|
||||||
log("Attunement leveled up!")
|
|
||||||
```
|
|
||||||
|
|
||||||
XP does **not** roll over beyond the threshold check — the threshold amount is
|
|
||||||
subtracted and any remainder carries into the next level.
|
|
||||||
|
|
||||||
### 4.3 Regen and Conversion Rate Scaling
|
|
||||||
|
|
||||||
Both raw mana regen and conversion rate use the same exponential formula:
|
|
||||||
|
|
||||||
```
|
|
||||||
scaledValue = baseValue × 1.5^(level - 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Effective raw mana regen by level (per attunement):**
|
|
||||||
|
|
||||||
| Level | Enchanter (0.5) | Invoker (0.3) | Fabricator (0.4) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | 0.500/hr | 0.300/hr | 0.400/hr |
|
|
||||||
| 2 | 0.750/hr | 0.450/hr | 0.600/hr |
|
|
||||||
| 3 | 1.125/hr | 0.675/hr | 0.900/hr |
|
|
||||||
| 4 | 1.688/hr | 1.013/hr | 1.350/hr |
|
|
||||||
| 5 | 2.531/hr | 1.519/hr | 2.025/hr |
|
|
||||||
| 6 | 3.797/hr | 2.278/hr | 3.038/hr |
|
|
||||||
| 7 | 5.695/hr | 3.417/hr | 4.556/hr |
|
|
||||||
| 8 | 8.543/hr | 5.126/hr | 6.834/hr |
|
|
||||||
| 9 | 12.814/hr | 7.689/hr | 10.252/hr |
|
|
||||||
| 10 | 19.221/hr | 11.533/hr | 15.377/hr |
|
|
||||||
|
|
||||||
**Effective conversion rate by level:**
|
|
||||||
|
|
||||||
| Level | Enchanter (0.2) | Fabricator (0.25) |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | 0.200/hr | 0.250/hr |
|
|
||||||
| 2 | 0.300/hr | 0.375/hr |
|
|
||||||
| 3 | 0.450/hr | 0.563/hr |
|
|
||||||
| 4 | 0.675/hr | 0.844/hr |
|
|
||||||
| 5 | 1.013/hr | 1.266/hr |
|
|
||||||
| 6 | 1.519/hr | 1.898/hr |
|
|
||||||
| 7 | 2.278/hr | 2.848/hr |
|
|
||||||
| 8 | 3.417/hr | 4.271/hr |
|
|
||||||
| 9 | 5.126/hr | 6.407/hr |
|
|
||||||
| 10 | 7.689/hr | 9.610/hr |
|
|
||||||
|
|
||||||
Invoker has `conversionRate = 0` at all levels — no auto-conversion.
|
|
||||||
|
|
||||||
**Total regen** = sum of `baseRegen × 1.5^(level-1)` across all active attunements.
|
|
||||||
**Total conversion drain** = sum of `baseConversionRate × 1.5^(level-1)` across active attunements
|
|
||||||
that have a non-zero conversion rate. This drain is applied to the raw mana pool.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Attunement XP Gain Sources
|
|
||||||
|
|
||||||
### 5.1 Enchanting → Enchanter XP
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
calculateEnchantingXP(capacityUsed: number): number {
|
|
||||||
return Math.max(1, Math.floor(capacityUsed / 10));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 1 Enchanter XP per 10 capacity used (floored), minimum 1 XP per enchant.
|
|
||||||
|
|
||||||
### 5.2 Other Sources
|
|
||||||
|
|
||||||
The `addAttunementXP(attunementId, amount)` store action is the generic mechanism.
|
|
||||||
Any system can call it to award XP to any attunement. In the codebase as-is,
|
|
||||||
only enchanting has an explicit calculation function. Invoker and Fabricator XP
|
|
||||||
gain is expected to be called from their respective systems (pact signing and
|
|
||||||
item fabrication) but explicit calculation functions are not yet defined.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Discipline Pool Gating
|
|
||||||
|
|
||||||
### 6.1 Skill Categories
|
|
||||||
|
|
||||||
Attunements gate discipline access through **skill categories**:
|
|
||||||
|
|
||||||
| Category | Disciplines |
|
|
||||||
|---|---|
|
|
||||||
| Always available | `mana`, `study`, `research` |
|
|
||||||
| Enchanter | `enchant`, `effectResearch` |
|
|
||||||
| Invoker | `invocation`, `pact` |
|
|
||||||
| Fabricator | `fabrication`, `golemancy` |
|
|
||||||
|
|
||||||
The function `getAvailableSkillCategories()` iterates all **active** attunements,
|
|
||||||
collects their `skillCategories` into a Set, and returns the deduplicated array.
|
|
||||||
|
|
||||||
### 6.2 Discipline Pool Counts per Attunement
|
|
||||||
|
|
||||||
| Attunement | File | Count |
|
|
||||||
|---|---|---|
|
|
||||||
| Enchanter Core | `enchanter.ts` | 4 |
|
|
||||||
| Enchanter Utility | `enchanter-utility.ts` | 2 |
|
|
||||||
| Enchanter Spells | `enchanter-spells.ts` | 3 |
|
|
||||||
| Enchanter Special | `enchanter-special.ts` | 1 |
|
|
||||||
| Invoker | `invoker.ts` | 2 |
|
|
||||||
| Fabricator | `fabricator.ts` | 5 |
|
|
||||||
| **Attunement-gated total** | | **17** |
|
|
||||||
|
|
||||||
The remaining 47 disciplines are available regardless of attunement status (base,
|
|
||||||
elemental, elemental-regen, elemental-regen-advanced pools).
|
|
||||||
|
|
||||||
### 6.3 Capability Gating
|
|
||||||
|
|
||||||
Each attunement grants `capabilities` that unlock specific game systems:
|
|
||||||
|
|
||||||
| Capability | System |
|
|
||||||
|---|---|
|
|
||||||
| `enchanting` | Enchantment Design/Prepare/Apply pipeline |
|
|
||||||
| `pacts` | Guardian pact signing and boon system |
|
|
||||||
| `guardianPowers` | Guardian power access |
|
|
||||||
| `elementalMastery` | Element mastery bonuses |
|
|
||||||
| `golemCrafting` | Golem summoning (Golemancy) |
|
|
||||||
| `gearCrafting` | Gear fabrication recipes |
|
|
||||||
| `earthShaping` | Earth mana shaping |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Mana Conversion Interaction
|
|
||||||
|
|
||||||
### 7.1 Conversion Flow
|
|
||||||
|
|
||||||
Each tick, the mana system:
|
|
||||||
|
|
||||||
1. Computes total raw regen (base + attunement regen + discipline bonus + equipment) × temporalEcho × meditationMultiplier
|
|
||||||
2. Subtracts incursion reduction: `× (1 - incursionStrength)`
|
|
||||||
3. Computes total conversion drain: sum of all active attunement conversion rates
|
|
||||||
4. Applies: `rawMana += totalRegen - totalConversionDrain` (per tick)
|
|
||||||
5. For each attunement with conversion: adds `conversionRate × HOURS_PER_TICK` to the target element
|
|
||||||
|
|
||||||
### 7.2 Invoker's Unique Position
|
|
||||||
|
|
||||||
The Invoker has **no automatic conversion** — `conversionRate = 0`. Instead, it gains
|
|
||||||
elemental mana types exclusively by signing Guardian pacts. Each guardian's
|
|
||||||
`unlocksMana` array is resolved through `resolveMultiUnlockChain(element)`, which
|
|
||||||
unlocks the guardian's element and all base components.
|
|
||||||
|
|
||||||
Example: Signing a Metal guardian (floor 90) unlocks `fire`, `earth`, and `metal`.
|
|
||||||
|
|
||||||
### 7.3 Conversion and Incursion
|
|
||||||
|
|
||||||
Incursion reduces net raw mana regeneration:
|
|
||||||
```
|
|
||||||
effectiveRegen = max(0, baseRegen × (1 - incursionStrength) × meditationMult - totalConversionPerTick)
|
|
||||||
```
|
|
||||||
|
|
||||||
As incursion strength approaches 95% (day 30), conversion drains can exceed regen,
|
|
||||||
causing raw mana to decrease. Since conversion is contingent on available raw mana,
|
|
||||||
attunement conversion effectively stalls during peak incursion if the raw pool is
|
|
||||||
insufficient.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Puzzle Room Interaction
|
|
||||||
|
|
||||||
From `spire-climbing-spec.md` §4.3, puzzle rooms appear on every 7th floor and have
|
|
||||||
per-attunement variants:
|
|
||||||
|
|
||||||
| Room Type | Description |
|
|
||||||
|---|---|
|
|
||||||
| `enchanter_trial` | Enchanter-themed puzzle challenge |
|
|
||||||
| `fabricator_trial` | Fabricator-themed puzzle challenge |
|
|
||||||
| `invoker_trial` | Invoker-themed puzzle challenge |
|
|
||||||
| `hybrid_enchanter_fabricator` | Dual attunement challenge |
|
|
||||||
| `hybrid_enchanter_invoker` | Dual attunement challenge |
|
|
||||||
| `hybrid_fabricator_invoker` | Dual attunement challenge |
|
|
||||||
|
|
||||||
**Time-based progression system:** Each puzzle room has a base time requirement
|
|
||||||
that varies by floor range (4h for floors 1–20, 8h for 21–50, 16h for 51–100,
|
|
||||||
24h for 101+). Each relevant attunement reduces the total time needed, up to
|
|
||||||
a maximum 90% reduction shared across all relevant attunements. Progress
|
|
||||||
accumulates at `HOURS_PER_TICK` (0.04h) per tick. The room completes when
|
|
||||||
`puzzleProgress >= puzzleRequired`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. State Fields
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface AttunementState {
|
|
||||||
id: string;
|
|
||||||
active: boolean;
|
|
||||||
level: number; // 1–10
|
|
||||||
experience: number; // current XP toward next level
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial state (prestige):
|
|
||||||
attunements: {
|
|
||||||
enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Acceptance Criteria
|
|
||||||
|
|
||||||
| # | Criterion |
|
|
||||||
|---|---|
|
|
||||||
| AC-1 | Enchanter is the only active attunement at game start (level 1, 0 XP). |
|
|
||||||
| AC-2 | Invoker and Fabricator are locked until unlocked; their unlock conditions are displayed in the Attunements tab. |
|
|
||||||
| AC-3 | Attunement XP accumulates and triggers level-ups at the correct thresholds; each level requires the exact XP specified in the formula. |
|
|
||||||
| AC-4 | Regen and conversion rates scale by `1.5^(level-1)` — a level 10 Enchanter converts at 7.69 raw→transference/hour. |
|
|
||||||
| AC-5 | Both raw regen and conversion from all active attunements are summed and applied each tick. |
|
|
||||||
| AC-6 | Invoker has no automatic mana conversion at any level. |
|
|
||||||
| AC-7 | Enchanting awards Enchanter XP at 1 per 10 capacity used (minimum 1). |
|
|
||||||
| AC-8 | Attunement skill categories correctly gate discipline pool access — Enchanter disciplines require Enchanter to be active. |
|
|
||||||
| AC-9 | Attunement tab shows unlocked/locked visual distinction, XP progress bar, level badge, and all attunement capabilities. |
|
|
||||||
| AC-10 | Puzzle rooms on every 7th floor use per-attunement room types with the correct progress scaling. |
|
|
||||||
| AC-11 | Incursion correctly reduces net raw mana regeneration, potentially stalling conversion at peak incursion. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Files Reference
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|---|---|
|
|
||||||
| `src/lib/game/data/attunements.ts` | Attunement definitions (the 3 attunements) |
|
|
||||||
| `src/lib/game/stores/attunementStore.ts` | Attunement state, leveling, XP, unlock |
|
|
||||||
| `src/lib/game/types/attunements.ts` | Attunement type definitions |
|
|
||||||
| `src/components/game/tabs/AttunementsTab.tsx` | Attunement UI display |
|
|
||||||
| `src/lib/game/stores/manaStore.ts` | Mana regen, conversion, incursion effects |
|
|
||||||
| `docs/specs/spire-climbing-spec.md` | Puzzle room types per attunement |
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
# Enchanter Attunement — Design Spec
|
|
||||||
|
|
||||||
> Describes the Enchanter attunement: identity, unlock flow, mana behavior, full
|
|
||||||
> discipline list with stats/perks, systems unlocked, and attunement level interactions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Objective
|
|
||||||
|
|
||||||
The Enchanter is the starting attunement and the gateway to the enchanting system.
|
|
||||||
It provides access to Transference-based disciplines that unlock enchantment
|
|
||||||
effects, boost enchantment power, and provide study/utility bonuses. The Enchanter
|
|
||||||
is always the first attunement a player uses, and it remains relevant throughout
|
|
||||||
all stages of the game through its 10 disciplines and the deep enchanting pipeline.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Identity
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|---|---|
|
|
||||||
| **ID** | `enchanter` |
|
|
||||||
| **Slot** | `rightHand` |
|
|
||||||
| **Icon** | `✨` |
|
|
||||||
| **Color** | `#1ABC9C` (Teal) |
|
|
||||||
| **Primary Mana** | `transference` |
|
|
||||||
| **Raw Mana Regen** | +0.5/hour (base, scales with `1.5^(level-1)`) |
|
|
||||||
| **Conversion Rate** | 0.2 raw→transference/hour (base, scales with `1.5^(level-1)`) |
|
|
||||||
| **Unlock** | Starting attunement (unlocked by default) |
|
|
||||||
| **Capabilities** | `['enchanting']` |
|
|
||||||
| **Skill Categories** | `['enchant', 'effectResearch']` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Unlock Condition and Flow
|
|
||||||
|
|
||||||
The Enchanter is **always unlocked** — it is present in the initial game state:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
attunements: {
|
|
||||||
enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
No unlock flow is required. The player begins the game with Enchanter active.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Raw Mana Regen Contribution
|
|
||||||
|
|
||||||
Base regen: **+0.5/hour** (at level 1). Scales exponentially:
|
|
||||||
|
|
||||||
```
|
|
||||||
effectiveRegen = 0.5 × 1.5^(level - 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Level | Raw Regen |
|
|
||||||
|---|---|
|
|
||||||
| 1 | 0.500/hr |
|
|
||||||
| 5 | 2.531/hr |
|
|
||||||
| 10 | 19.221/hr |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Mana Conversion Behavior
|
|
||||||
|
|
||||||
The Enchanter is the **only attunement that converts raw mana to Transference**:
|
|
||||||
|
|
||||||
```
|
|
||||||
effectiveConversionRate = 0.2 × 1.5^(level - 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
This is an automatic per-hour conversion. Each tick:
|
|
||||||
- `0.2 × 1.5^(level-1) × HOURS_PER_TICK` raw mana is consumed
|
|
||||||
- The same amount is added to the Transference mana pool
|
|
||||||
|
|
||||||
At level 10, the Enchanter converts **7.69 raw→transference/hour**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Disciplines
|
|
||||||
|
|
||||||
The Enchanter's discipline pool contains **10 disciplines** across 4 files.
|
|
||||||
|
|
||||||
### 6.1 Core Disciplines (`enchanter.ts`) — 4 disciplines
|
|
||||||
|
|
||||||
#### Enchantment Crafting (`enchant-crafting`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `transference` |
|
|
||||||
| **Base Cost** | 8 |
|
|
||||||
| **Stat Bonus** | `enchantPower` +8 (base) |
|
|
||||||
| **Scaling Factor** | 60 |
|
|
||||||
| **Difficulty Factor** | 120 |
|
|
||||||
| **Drain Base** | 3 |
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Bonus |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `enchant-1` | `infinite` | 150 | +5 enchantPower per tier (repeats every 150 XP) |
|
|
||||||
| `enchant-2` | `capped` | 300 | +10 enchantPower per tier, interval 200 XP, max 3 tiers |
|
|
||||||
|
|
||||||
#### Mana Channeling (`mana-channeling`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `transference` |
|
|
||||||
| **Base Cost** | 12 |
|
|
||||||
| **Stat Bonus** | `clickManaMultiplier` +0.3 (base) |
|
|
||||||
| **Scaling Factor** | 90 |
|
|
||||||
| **Difficulty Factor** | 180 |
|
|
||||||
| **Drain Base** | 5 |
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Bonus |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `channel-1` | `once` | 250 | `elementCap_lightning` +15 |
|
|
||||||
|
|
||||||
#### Study Basic Weapon Enchantments (`study-basic-weapon-enchantments`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `transference` |
|
|
||||||
| **Base Cost** | 10 |
|
|
||||||
| **Stat Bonus** | `enchantPower` +3 (base) |
|
|
||||||
| **Scaling Factor** | 80 |
|
|
||||||
| **Difficulty Factor** | 100 |
|
|
||||||
| **Drain Base** | 2 |
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `basic-weapon-fire` | `once` | 50 | `sword_fire` |
|
|
||||||
| `basic-weapon-frost` | `once` | 100 | `sword_frost` |
|
|
||||||
| `basic-weapon-lightning` | `once` | 150 | `sword_lightning` |
|
|
||||||
|
|
||||||
#### Study Advanced Weapon Enchantments (`study-advanced-weapon-enchantments`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `transference` |
|
|
||||||
| **Base Cost** | 20 |
|
|
||||||
| **Requires** | `study-basic-weapon-enchantments` |
|
|
||||||
| **Stat Bonus** | `enchantPower` +5 (base) |
|
|
||||||
| **Scaling Factor** | 120 |
|
|
||||||
| **Difficulty Factor** | 200 |
|
|
||||||
| **Drain Base** | 4 |
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `advanced-weapon-void` | `once` | 100 | `sword_void` |
|
|
||||||
| `advanced-weapon-damage-5` | `once` | 150 | `damage_5` |
|
|
||||||
| `advanced-weapon-crit` | `once` | 200 | `crit_5` |
|
|
||||||
| `advanced-weapon-attack-speed` | `once` | 250 | `attack_speed_10` |
|
|
||||||
|
|
||||||
### 6.2 Utility Disciplines (`enchanter-utility.ts`) — 2 disciplines
|
|
||||||
|
|
||||||
#### Study Utility Enchantments (`study-utility-enchantments`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `transference` |
|
|
||||||
| **Base Cost** | 8 |
|
|
||||||
| **Stat Bonus** | `studySpeed` +0.05 (base) |
|
|
||||||
| **Scaling Factor** | 60 |
|
|
||||||
| **Difficulty Factor** | 80 |
|
|
||||||
| **Drain Base** | 2 |
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `utility-meditate` | `once` | 50 | `meditate_10` |
|
|
||||||
| `utility-study` | `once` | 100 | `study_10` |
|
|
||||||
| `utility-insight` | `once` | 150 | `insight_5` |
|
|
||||||
|
|
||||||
#### Study Mana Enchantments (`study-mana-enchantments`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `transference` |
|
|
||||||
| **Base Cost** | 15 |
|
|
||||||
| **Stat Bonus** | `maxManaBonus` +10 (base) |
|
|
||||||
| **Scaling Factor** | 100 |
|
|
||||||
| **Difficulty Factor** | 150 |
|
|
||||||
| **Drain Base** | 3 |
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `mana-cap-50` | `once` | 75 | `mana_cap_50` |
|
|
||||||
| `mana-cap-100` | `once` | 150 | `mana_cap_100` |
|
|
||||||
| `mana-regen-1` | `once` | 100 | `mana_regen_1` |
|
|
||||||
| `mana-regen-2` | `once` | 200 | `mana_regen_2` |
|
|
||||||
| `click-mana-1` | `once` | 125 | `click_mana_1` |
|
|
||||||
| `click-mana-3` | `once` | 225 | `click_mana_3` |
|
|
||||||
|
|
||||||
### 6.3 Spell Disciplines (`enchanter-spells.ts`) — 3 disciplines
|
|
||||||
|
|
||||||
#### Study Basic Spell Enchantments (`study-basic-spell-enchantments`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `transference` |
|
|
||||||
| **Base Cost** | 18 |
|
|
||||||
| **Stat Bonus** | `enchantPower` +4 (base) |
|
|
||||||
| **Scaling Factor** | 100 |
|
|
||||||
| **Difficulty Factor** | 160 |
|
|
||||||
| **Drain Base** | 3 |
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell-mana-bolt` | `once` | 50 | `spell_manaBolt` |
|
|
||||||
| `spell-fireball` | `once` | 100 | `spell_fireball` |
|
|
||||||
| `spell-water-jet` | `once` | 100 | `spell_waterJet` |
|
|
||||||
| `spell-gust` | `once` | 100 | `spell_gust` |
|
|
||||||
| `spell-stone-bullet` | `once` | 100 | `spell_stoneBullet` |
|
|
||||||
| `spell-light-lance` | `once` | 150 | `spell_lightLance` |
|
|
||||||
| `spell-shadow-bolt` | `once` | 150 | `spell_shadowBolt` |
|
|
||||||
| `spell-drain` | `once` | 150 | `spell_drain` |
|
|
||||||
|
|
||||||
#### Study Intermediate Spell Enchantments (`study-intermediate-spell-enchantments`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `transference` |
|
|
||||||
| **Base Cost** | 25 |
|
|
||||||
| **Requires** | `study-basic-spell-enchantments` |
|
|
||||||
| **Stat Bonus** | `enchantPower` +6 (base) |
|
|
||||||
| **Scaling Factor** | 150 |
|
|
||||||
| **Difficulty Factor** | 250 |
|
|
||||||
| **Drain Base** | 5 |
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell-inferno` | `once` | 100 | `spell_inferno` |
|
|
||||||
| `spell-tidal-wave` | `once` | 100 | `spell_tidalWave` |
|
|
||||||
| `spell-earthquake` | `once` | 120 | `spell_earthquake` |
|
|
||||||
| `spell-chain-lightning` | `once` | 100 | `spell_chainLightning` |
|
|
||||||
| `spell-metal-shard` | `once` | 80 | `spell_metalShard` |
|
|
||||||
| `spell-sand-blast` | `once` | 80 | `spell_sandBlast` |
|
|
||||||
|
|
||||||
#### Study Advanced Spell Enchantments (`study-advanced-spell-enchantments`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `transference` |
|
|
||||||
| **Base Cost** | 35 |
|
|
||||||
| **Requires** | `study-intermediate-spell-enchantments` |
|
|
||||||
| **Stat Bonus** | `enchantPower` +10 (base) |
|
|
||||||
| **Scaling Factor** | 200 |
|
|
||||||
| **Difficulty Factor** | 350 |
|
|
||||||
| **Drain Base** | 7 |
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell-pyroclasm` | `once` | 100 | `spell_pyroclasm` |
|
|
||||||
| `spell-tsunami` | `once` | 100 | `spell_tsunami` |
|
|
||||||
| `spell-meteor-strike` | `once` | 120 | `spell_meteorStrike` |
|
|
||||||
| `spell-heaven-light` | `once` | 100 | `spell_heavenLight` |
|
|
||||||
| `spell-oblivion` | `once` | 100 | `spell_oblivion` |
|
|
||||||
| `spell-furnace-blast` | `once` | 100 | `spell_furnaceBlast` |
|
|
||||||
| `spell-dune-collapse` | `once` | 100 | `spell_duneCollapse` |
|
|
||||||
| `spell-stellar-nova` | `once` | 200 | `spell_stellarNova` |
|
|
||||||
| `spell-void-collapse` | `once` | 180 | `spell_voidCollapse` |
|
|
||||||
| `spell-crystal-shatter` | `once` | 160 | `spell_crystalShatter` |
|
|
||||||
|
|
||||||
### 6.4 Special Discipline (`enchanter-special.ts`) — 1 discipline
|
|
||||||
|
|
||||||
#### Study Special Enchantments (`study-special-enchantments`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `transference` |
|
|
||||||
| **Base Cost** | 22 |
|
|
||||||
| **Requires** | `study-advanced-weapon-enchantments` |
|
|
||||||
| **Stat Bonus** | `enchantPower` +5 (base) |
|
|
||||||
| **Scaling Factor** | 130 |
|
|
||||||
| **Difficulty Factor** | 220 |
|
|
||||||
| **Drain Base** | 4 |
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `special-spell-echo` | `once` | 100 | `spell_echo_10` |
|
|
||||||
| `special-guardian-dmg` | `once` | 80 | `guardian_dmg_10` |
|
|
||||||
| `special-overpower` | `once` | 150 | `overpower_80` |
|
|
||||||
| `special-first-strike` | `once` | 120 | `first_strike` |
|
|
||||||
| `special-combo-master` | `once` | 200 | `combo_master` |
|
|
||||||
| `special-adrenaline-rush` | `once` | 180 | `adrenaline_rush` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Systems Unlocked
|
|
||||||
|
|
||||||
The Enchanter attunement gates the **Enchanting System** (see `enchanting-spec.md`):
|
|
||||||
|
|
||||||
- **Design** stage: Create named enchantment designs
|
|
||||||
- **Prepare** stage: Clear existing enchantments, ready equipment
|
|
||||||
- **Apply** stage: Apply saved designs to prepared equipment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Puzzle Room Behavior
|
|
||||||
|
|
||||||
In the spire, every 7th floor has a puzzle room. When the room type is
|
|
||||||
`enchanter_trial`, progress scales at 2.5–3% per tick per Enchanter level.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Attunement Level Interactions
|
|
||||||
|
|
||||||
Higher Enchanter level affects:
|
|
||||||
|
|
||||||
1. **Raw mana regen**: `0.5 × 1.5^(level-1)` per hour
|
|
||||||
2. **Transference conversion rate**: `0.2 × 1.5^(level-1)` per hour
|
|
||||||
3. **Enchanting XP → Attunement XP**: Enchanting awards Enchanter XP (1 per 10 capacity used), feeding back into leveling
|
|
||||||
|
|
||||||
Attunement level does **not** directly affect enchantment strength or discipline
|
|
||||||
power — those scale through discipline XP alone.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Discipline Dependency Chain
|
|
||||||
|
|
||||||
```
|
|
||||||
enchant-crafting (root)
|
|
||||||
mana-channeling (root)
|
|
||||||
study-basic-weapon-enchantments (root)
|
|
||||||
└── study-advanced-weapon-enchantments
|
|
||||||
└── study-special-enchantments
|
|
||||||
study-utility-enchantments (root)
|
|
||||||
study-mana-enchantments (root)
|
|
||||||
study-basic-spell-enchantments (root)
|
|
||||||
└── study-intermediate-spell-enchantments
|
|
||||||
└── study-advanced-spell-enchantments
|
|
||||||
```
|
|
||||||
|
|
||||||
6 root disciplines. Maximum dependency depth: 3.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Acceptance Criteria
|
|
||||||
|
|
||||||
| # | Criterion |
|
|
||||||
|---|---|
|
|
||||||
| AC-1 | Enchanter starts unlocked at level 1 with 0 XP. |
|
|
||||||
| AC-2 | All 10 Enchanter disciplines are available when Enchanter is active. |
|
|
||||||
| AC-3 | Discipline dependency chains are enforced — Advanced Weapon Enchantments requires Basic Weapon Enchantments. |
|
|
||||||
| AC-4 | All perk thresholds unlock the correct enchantment effects at the specified XP values. |
|
|
||||||
| AC-5 | Enchantment Power stat bonus from all active Enchanter disciplines stacks additively. |
|
|
||||||
| AC-6 | The `enchant-1` infinite perk grants +5 enchantPower every 150 XP beyond threshold. |
|
|
||||||
| AC-7 | The `enchant-2` capped perk grants +10 enchantPower per tier, max 3 tiers, interval 200 XP beyond threshold. |
|
|
||||||
| AC-8 | Enchanting system is accessible when Enchanter is active, locked when inactive. |
|
|
||||||
| AC-9 | Enchanter `enchanter_trial` puzzle rooms grant bonus progress per Enchanter level. |
|
|
||||||
| AC-10 | Enchanter level scales raw regen and conversion rate by `1.5^(level-1)`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Files Reference
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|---|---|
|
|
||||||
| `src/lib/game/data/attunements.ts` | Enchanter definition |
|
|
||||||
| `src/lib/game/data/disciplines/enchanter.ts` | Core Enchanter disciplines (4) |
|
|
||||||
| `src/lib/game/data/disciplines/enchanter-utility.ts` | Utility enchantment disciplines (2) |
|
|
||||||
| `src/lib/game/data/disciplines/enchanter-spells.ts` | Spell enchantment disciplines (3) |
|
|
||||||
| `src/lib/game/data/disciplines/enchanter-special.ts` | Special enchantment discipline (1) |
|
|
||||||
| `docs/specs/attunements/enchanter/systems/enchanting-spec.md` | Enchanting system spec |
|
|
||||||
@@ -1,656 +0,0 @@
|
|||||||
# Enchanting System — Design Spec
|
|
||||||
|
|
||||||
> Describes the three-stage enchanting pipeline: Design → Prepare → Apply.
|
|
||||||
> Covers stage timings, mana costs, auto-transitions, enchantment capacity system,
|
|
||||||
> full enchantment effect categories, disenchanting, and discipline perk interactions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Objective
|
|
||||||
|
|
||||||
Enchanting is the Enchanter attunement's primary system for enhancing equipment. It
|
|
||||||
transforms raw mana and materials into permanent equipment bonuses through a
|
|
||||||
three-stage pipeline. The player creates reusable designs, prepares equipment by
|
|
||||||
stripping existing enchantments, then applies designs to prepared equipment.
|
|
||||||
|
|
||||||
**Design goals:**
|
|
||||||
- Three distinct stages encourage planning and resource management
|
|
||||||
- Capacity and stacking systems allow deep customization of individual items
|
|
||||||
- Discipline perks progressively unlock more powerful enchantment types
|
|
||||||
- Mana costs scale with design complexity, creating meaningful trade-offs
|
|
||||||
- Auto-transitions keep the pipeline flowing without manual state management
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Controls / API
|
|
||||||
|
|
||||||
### 2.1 Player Actions
|
|
||||||
|
|
||||||
| Action | Stage | Trigger |
|
|
||||||
|---|---|---|
|
|
||||||
| **Create Design** | Design | Select effects, name design, click "Create Design" |
|
|
||||||
| **Start Prepare** | Prepare | Select equipped item, click "Prepare" |
|
|
||||||
| **Apply Enchantment** | Apply | Select saved design + prepared item, click "Apply" |
|
|
||||||
| **Disenchant** | Prepare | Initiate prepare on already-enchanted equipment (enchantments removed) |
|
|
||||||
| **Cancel** | Any | Click "Cancel" during any active stage |
|
|
||||||
|
|
||||||
### 2.2 Auto-Transitions
|
|
||||||
|
|
||||||
- Design complete → returns to idle (Meditate)
|
|
||||||
- Prepare complete → returns to idle (Meditate), item gains "Ready for Enchantment" tag
|
|
||||||
- Apply complete → returns to idle (Meditate), selection state resets
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Stage 1: Design
|
|
||||||
|
|
||||||
### 3.1 Flow
|
|
||||||
|
|
||||||
1. Player selects an equipment type from the type selector
|
|
||||||
2. Player adds effects from the unlocked pool via the EffectSelector
|
|
||||||
3. Player sets stack count per effect (up to `maxStacks`)
|
|
||||||
4. Player names the design
|
|
||||||
5. Player clicks "Create Design" → design begins
|
|
||||||
6. `designProgress` accumulates at `HOURS_PER_TICK` per tick
|
|
||||||
7. When `designProgress >= requiredTime` → design saved to `completedDesigns`
|
|
||||||
|
|
||||||
### 3.2 Timing Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
calculateDesignTime(effects):
|
|
||||||
time = 1 // base 1 hour
|
|
||||||
for each effect: time += 0.5 * stacks
|
|
||||||
return time
|
|
||||||
```
|
|
||||||
|
|
||||||
| Design Complexity | Time |
|
|
||||||
|---|---|
|
|
||||||
| 1 effect, 1 stack | 1.5 hours |
|
|
||||||
| 3 effects, 1 stack each | 2.5 hours |
|
|
||||||
| 2 effects, 3 stacks each | 4.0 hours |
|
|
||||||
|
|
||||||
Progress per tick: `HOURS_PER_TICK = 0.04` hours.
|
|
||||||
|
|
||||||
### 3.3 Hasty Enchanter (Special Effect)
|
|
||||||
|
|
||||||
If the player has the `HASTY_ENCHANTER` special effect and the design is a **repeat**
|
|
||||||
(re-creating a previously completed design):
|
|
||||||
|
|
||||||
```
|
|
||||||
time *= 0.75 // 25% faster
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 Instant Designs (Special Effect)
|
|
||||||
|
|
||||||
Per tick, if the player has the `INSTANT_DESIGNS` special effect:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const INSTANT_DESIGN_CHANCE = 0.10; // 10%
|
|
||||||
if (Math.random() < INSTANT_DESIGN_CHANCE) {
|
|
||||||
designProgress = requiredTime; // instant completion
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.5 Dual Design Slot
|
|
||||||
|
|
||||||
A second concurrent design slot is available when:
|
|
||||||
- The first design slot has an active design (`designProgress` exists)
|
|
||||||
- The second slot is empty (`designProgress2 === null`)
|
|
||||||
- The player has the `ENCHANT_MASTERY` special boolean
|
|
||||||
|
|
||||||
### 3.6 Design Mana Cost
|
|
||||||
|
|
||||||
**None.** The Design stage has no mana cost.
|
|
||||||
|
|
||||||
### 3.7 Design Validation
|
|
||||||
|
|
||||||
- `enchantingLevel >= 1` (enchanter attunement must be active)
|
|
||||||
- Each effect must exist in `ENCHANTMENT_EFFECTS`
|
|
||||||
- Each effect's `allowedEquipmentCategories` must include the equipment's category
|
|
||||||
- Stacks cannot exceed the effect's `maxStacks`
|
|
||||||
|
|
||||||
### 3.8 Enchanting XP Award
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
calculateEnchantingXP(capacityUsed: number): number {
|
|
||||||
return Math.max(1, Math.floor(capacityUsed / 10));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Awarded to Enchanter attunement XP on design completion. This is **Attunement XP**,
|
|
||||||
not discipline XP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Stage 2: Prepare
|
|
||||||
|
|
||||||
### 4.1 Flow
|
|
||||||
|
|
||||||
1. Player selects an equipped item to prepare
|
|
||||||
2. System checks: `'Ready for Enchantment'` tag required if item was previously prepared
|
|
||||||
3. If item has existing enchantments, a confirmation dialog warns they will be removed
|
|
||||||
4. Player confirms → preparation begins
|
|
||||||
5. Mana is deducted over the prep duration
|
|
||||||
6. On completion: all enchantments removed, `usedCapacity` reset to 0, rarity reset to `'common'`, `'Ready for Enchantment'` tag added
|
|
||||||
|
|
||||||
### 4.2 Timing Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
calculatePrepTime(equipmentCapacity):
|
|
||||||
time = 2 + floor(equipmentCapacity / 50)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Capacity | Prep Time |
|
|
||||||
|---|---|
|
|
||||||
| 15 (shoes) | 2 hours |
|
|
||||||
| 30 (body) | 2 hours |
|
|
||||||
| 50 (caster) | 3 hours |
|
|
||||||
| 80 (robe) | 3 hours |
|
|
||||||
|
|
||||||
### 4.3 Mana Cost Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
totalMana = equipmentCapacity × 10
|
|
||||||
manaPerHour = totalMana / prepTime
|
|
||||||
manaPerTick = manaPerHour × HOURS_PER_TICK
|
|
||||||
```
|
|
||||||
|
|
||||||
| Capacity | Total Mana Cost |
|
|
||||||
|---|---|
|
|
||||||
| 15 | 150 |
|
|
||||||
| 30 | 300 |
|
|
||||||
| 50 | 500 |
|
|
||||||
| 80 | 800 |
|
|
||||||
|
|
||||||
### 4.4 Disenchant Recovery
|
|
||||||
|
|
||||||
When preparing equipment that has existing enchantments, mana is partially recovered:
|
|
||||||
|
|
||||||
```
|
|
||||||
recoveryRate = 0.10 + disenchantLevel × 0.20
|
|
||||||
manaRecovered = Σ floor(enchantment.actualCost × recoveryRate)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Disenchant Level | Recovery Rate |
|
|
||||||
|---|---|
|
|
||||||
| 0 | 10% |
|
|
||||||
| 1 | 30% |
|
|
||||||
| 2 | 50% |
|
|
||||||
| 3 | 70% |
|
|
||||||
| 4 | 90% |
|
|
||||||
| 5 | 110% |
|
|
||||||
|
|
||||||
> **Note:** `disenchantLevel` is currently hardcoded to `0` in the codebase, so the
|
|
||||||
> effective recovery rate is always **10%**.
|
|
||||||
|
|
||||||
### 4.5 Cancellation Refund
|
|
||||||
|
|
||||||
```
|
|
||||||
remainingFraction = (required - progress) / required
|
|
||||||
refundRate = remainingFraction + (1 - remainingFraction) × 0.5
|
|
||||||
manaRefund = floor(manaSpent × refundRate)
|
|
||||||
```
|
|
||||||
|
|
||||||
Unspent progress gets 100% refund; spent progress gets 50% refund; blended proportionally.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Stage 3: Apply
|
|
||||||
|
|
||||||
### 5.1 Flow
|
|
||||||
|
|
||||||
1. Player selects a saved design and a prepared equipment instance
|
|
||||||
2. System validates: `currentAction === 'meditate'`, item has `'Ready for Enchantment'` tag, capacity fits
|
|
||||||
3. Player clicks "Apply" → application begins
|
|
||||||
4. Mana is deducted per hour over the application duration
|
|
||||||
5. On completion: design's effects applied to equipment, `usedCapacity` updated, design consumed
|
|
||||||
|
|
||||||
### 5.2 Timing Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
calculateApplicationTime(design):
|
|
||||||
time = 2 + Σ(stacks) for all effects in design
|
|
||||||
```
|
|
||||||
|
|
||||||
| Design | Apply Time |
|
|
||||||
|---|---|
|
|
||||||
| 1 effect, 1 stack | 3 hours |
|
|
||||||
| 3 effects, 1 stack each | 5 hours |
|
|
||||||
| 2 effects, 3 stacks each | 8 hours |
|
|
||||||
|
|
||||||
### 5.3 Mana Cost Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
manaPerHour = 20 + Σ(stacks × 5) for all effects
|
|
||||||
manaPerTick = manaPerHour × HOURS_PER_TICK
|
|
||||||
```
|
|
||||||
|
|
||||||
| Design | Mana/Hour |
|
|
||||||
|---|---|
|
|
||||||
| 1 effect, 1 stack | 25 |
|
|
||||||
| 3 effects, 1 stack each | 35 |
|
|
||||||
| 2 effects, 3 stacks each | 50 |
|
|
||||||
|
|
||||||
### 5.4 Free Enchant Chances
|
|
||||||
|
|
||||||
Per tick, the system checks for free enchant chances. These are **additive**:
|
|
||||||
|
|
||||||
| Special Effect | Chance |
|
|
||||||
|---|---|
|
|
||||||
| `ENCHANT_PRESERVATION` | 25% |
|
|
||||||
| `THRIFTY_ENCHANTER` | 10% |
|
|
||||||
| `OPTIMIZED_ENCHANTING` | 25% |
|
|
||||||
| **Maximum combined** | **60%** |
|
|
||||||
|
|
||||||
On trigger: `applicationProgress = requiredTime` (instant completion for that tick),
|
|
||||||
**no mana consumed** for that tick.
|
|
||||||
|
|
||||||
### 5.5 Pure Essence (Special Effect)
|
|
||||||
|
|
||||||
If the player has the `PURE_ESSENCE` special effect:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const PURE_ESSENCE_STACK_BONUS = 1.25;
|
|
||||||
const PURE_ESSENCE_COST_CAP = 100;
|
|
||||||
|
|
||||||
if (effect.baseCapacityCost < PURE_ESSENCE_COST_CAP) {
|
|
||||||
actualStacks = Math.ceil(baseStacks × PURE_ESSENCE_STACK_BONUS);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Effects with `baseCapacityCost < 100` get **25% more stacks** (rounded up).
|
|
||||||
|
|
||||||
### 5.6 Cancellation Refund
|
|
||||||
|
|
||||||
Same formula as Prepare stage (§4.5).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Enchantment Capacity System
|
|
||||||
|
|
||||||
### 6.1 Base Capacity Per Equipment Type
|
|
||||||
|
|
||||||
| Category | Equipment | Base Capacity |
|
|
||||||
|---|---|---|
|
|
||||||
| **Caster** | basicStaff | 50 |
|
|
||||||
| | apprenticeWand | 35 |
|
|
||||||
| | oakStaff | 65 |
|
|
||||||
| | crystalWand | 45 |
|
|
||||||
| | arcanistStaff | 80 |
|
|
||||||
| | battlestaff | 70 |
|
|
||||||
| **Catalyst** | basicCatalyst | 40 |
|
|
||||||
| | fireCatalyst | 55 |
|
|
||||||
| | voidCatalyst | 75 |
|
|
||||||
| | metalSpellFocus | 50 |
|
|
||||||
| **Sword** | ironBlade | 30 |
|
|
||||||
| | steelBlade | 40 |
|
|
||||||
| | crystalBlade | 55 |
|
|
||||||
| | arcanistBlade | 65 |
|
|
||||||
| | voidBlade | 50 |
|
|
||||||
| **Head** | clothHood | 25 |
|
|
||||||
| | apprenticeCap | 30 |
|
|
||||||
| | wizardHat | 45 |
|
|
||||||
| | arcanistCirclet | 40 |
|
|
||||||
| | battleHelm | 50 |
|
|
||||||
| **Body** | civilianShirt | 30 |
|
|
||||||
| | apprenticeRobe | 45 |
|
|
||||||
| | scholarRobe | 55 |
|
|
||||||
| | battleRobe | 65 |
|
|
||||||
| | arcanistRobe | 80 |
|
|
||||||
| **Hands** | civilianGloves | 20 |
|
|
||||||
| | apprenticeGloves | 30 |
|
|
||||||
| | spellweaveGloves | 40 |
|
|
||||||
| | combatGauntlets | 35 |
|
|
||||||
| **Feet** | civilianShoes | 15 |
|
|
||||||
| | apprenticeBoots | 25 |
|
|
||||||
| | travelerBoots | 30 |
|
|
||||||
| | battleBoots | 35 |
|
|
||||||
| **Accessory** | copperRing | 15 |
|
|
||||||
| | silverRing | 25 |
|
|
||||||
| | goldRing | 35 |
|
|
||||||
| | signetRing | 30 |
|
|
||||||
| | copperAmulet | 20 |
|
|
||||||
| | silverAmulet | 30 |
|
|
||||||
| | crystalPendant | 45 |
|
|
||||||
| | manaBrooch | 40 |
|
|
||||||
| | arcanistPendant | 55 |
|
|
||||||
| | voidTouchedRing | 50 |
|
|
||||||
|
|
||||||
### 6.2 Stacking Cost Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
calculateEffectCapacityCost(effectId, stacks, efficiencyBonus):
|
|
||||||
totalCost = 0
|
|
||||||
for i in 0..stacks-1:
|
|
||||||
stackMultiplier = 1 + (i × 0.2)
|
|
||||||
totalCost += baseCapacityCost × stackMultiplier
|
|
||||||
return floor(totalCost × (1 - efficiencyBonus))
|
|
||||||
```
|
|
||||||
|
|
||||||
| Stack Index | Multiplier |
|
|
||||||
|---|---|
|
|
||||||
| 0 (1st) | 1.0× |
|
|
||||||
| 1 (2nd) | 1.2× |
|
|
||||||
| 2 (3rd) | 1.4× |
|
|
||||||
| 3 (4th) | 1.6× |
|
|
||||||
| 4 (5th) | 1.8× |
|
|
||||||
|
|
||||||
Example: 3 stacks of a cost-20 effect:
|
|
||||||
`20×1.0 + 20×1.2 + 20×1.4 = 20 + 24 + 28 = 72` capacity used.
|
|
||||||
|
|
||||||
### 6.3 Efficiency Bonus
|
|
||||||
|
|
||||||
The `efficiencyBonus` reduces total capacity cost. Sources include discipline perks
|
|
||||||
(e.g., Crafting Efficiency discipline from Fabricator pool). Applied as:
|
|
||||||
`totalCost × (1 - efficiencyBonus)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Enchantment Effect Categories
|
|
||||||
|
|
||||||
### 7.1 Spell Effects (category: `'spell'`) — Casters only
|
|
||||||
|
|
||||||
**Basic Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_manaBolt` | Mana Bolt | 50 | 1 |
|
|
||||||
| `spell_manaStrike` | Mana Strike | 40 | 1 |
|
|
||||||
| `spell_fireball` | Fireball | 80 | 1 |
|
|
||||||
| `spell_emberShot` | Ember Shot | 60 | 1 |
|
|
||||||
| `spell_waterJet` | Water Jet | 70 | 1 |
|
|
||||||
| `spell_iceShard` | Ice Shard | 75 | 1 |
|
|
||||||
| `spell_gust` | Gust | 60 | 1 |
|
|
||||||
| `spell_stoneBullet` | Stone Bullet | 80 | 1 |
|
|
||||||
| `spell_lightLance` | Light Lance | 95 | 1 |
|
|
||||||
| `spell_shadowBolt` | Shadow Bolt | 95 | 1 |
|
|
||||||
| `spell_drain` | Drain | 85 | 1 |
|
|
||||||
| `spell_rotTouch` | Rot Touch | 80 | 1 |
|
|
||||||
| `spell_windSlash` | Wind Slash | 72 | 1 |
|
|
||||||
| `spell_rockSpike` | Rock Spike | 88 | 1 |
|
|
||||||
| `spell_radiance` | Radiance | 80 | 1 |
|
|
||||||
| `spell_darkPulse` | Dark Pulse | 68 | 1 |
|
|
||||||
|
|
||||||
**Tier 2 Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_inferno` | Inferno | 180 | 1 |
|
|
||||||
| `spell_tidalWave` | Tidal Wave | 175 | 1 |
|
|
||||||
| `spell_hurricane` | Hurricane | 170 | 1 |
|
|
||||||
| `spell_earthquake` | Earthquake | 200 | 1 |
|
|
||||||
| `spell_solarFlare` | Solar Flare | 190 | 1 |
|
|
||||||
| `spell_voidRift` | Void Rift | 175 | 1 |
|
|
||||||
| `spell_flameWave` | Flame Wave | 165 | 1 |
|
|
||||||
| `spell_iceStorm` | Ice Storm | 170 | 1 |
|
|
||||||
| `spell_windBlade` | Wind Blade | 155 | 1 |
|
|
||||||
| `spell_stoneBarrage` | Stone Barrage | 175 | 1 |
|
|
||||||
| `spell_divineSmite` | Divine Smite | 175 | 1 |
|
|
||||||
| `spell_shadowStorm` | Shadow Storm | 168 | 1 |
|
|
||||||
| `spell_soulRend` | Soul Rend | 170 | 1 |
|
|
||||||
|
|
||||||
**Tier 3 Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_pyroclasm` | Pyroclasm | 400 | 1 |
|
|
||||||
| `spell_tsunami` | Tsunami | 380 | 1 |
|
|
||||||
| `spell_meteorStrike` | Meteor Strike | 420 | 1 |
|
|
||||||
| `spell_cosmicStorm` | Cosmic Storm | 370 | 1 |
|
|
||||||
| `spell_heavenLight` | Heaven's Light | 390 | 1 |
|
|
||||||
| `spell_oblivion` | Oblivion | 385 | 1 |
|
|
||||||
| `spell_deathMark` | Death Mark | 370 | 1 |
|
|
||||||
|
|
||||||
**Legendary Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_stellarNova` | Stellar Nova | 600 | 1 |
|
|
||||||
| `spell_voidCollapse` | Void Collapse | 550 | 1 |
|
|
||||||
| `spell_crystalShatter` | Crystal Shatter | 500 | 1 |
|
|
||||||
|
|
||||||
**Lightning Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_spark` | Spark | 70 | 1 |
|
|
||||||
| `spell_lightningBolt` | Lightning Bolt | 90 | 1 |
|
|
||||||
| `spell_chainLightning` | Chain Lightning | 160 | 1 |
|
|
||||||
| `spell_stormCall` | Storm Call | 190 | 1 |
|
|
||||||
| `spell_thunderStrike` | Thunder Strike | 350 | 1 |
|
|
||||||
|
|
||||||
**Frost Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_frostBite` | Frost Bite | 78 | 1 |
|
|
||||||
| `spell_iceShard` | Ice Shard | 95 | 1 |
|
|
||||||
| `spell_frostNova` | Frost Nova | 165 | 1 |
|
|
||||||
| `spell_glacialSpike` | Glacial Spike | 200 | 1 |
|
|
||||||
| `spell_absoluteZero` | Absolute Zero | 380 | 1 |
|
|
||||||
|
|
||||||
**Metal Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_metalShard` | Metal Shard | 85 | 1 |
|
|
||||||
| `spell_ironFist` | Iron Fist | 120 | 1 |
|
|
||||||
| `spell_steelTempest` | Steel Tempest | 190 | 1 |
|
|
||||||
| `spell_furnaceBlast` | Furnace Blast | 400 | 1 |
|
|
||||||
|
|
||||||
**Sand Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_sandBlast` | Sand Blast | 72 | 1 |
|
|
||||||
| `spell_sandstorm` | Sandstorm | 100 | 1 |
|
|
||||||
| `spell_desertWind` | Desert Wind | 155 | 1 |
|
|
||||||
| `spell_duneCollapse` | Dune Collapse | 300 | 1 |
|
|
||||||
|
|
||||||
**BlackFlame Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_blackFire` | Black Fire | 82 | 1 |
|
|
||||||
| `spell_shadowEmber` | Shadow Ember | 105 | 1 |
|
|
||||||
| `spell_darkInferno` | Dark Inferno | 175 | 1 |
|
|
||||||
| `spell_umbralBlaze` | Umbral Blaze | 210 | 1 |
|
|
||||||
| `spell_hellfireCurse` | Hellfire Curse | 410 | 1 |
|
|
||||||
|
|
||||||
**Radiant Flames Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_radiantBurst` | Radiant Burst | 85 | 1 |
|
|
||||||
| `spell_holyFlame` | Holy Flame | 108 | 1 |
|
|
||||||
| `spell_blindingSun` | Blinding Sun | 180 | 1 |
|
|
||||||
| `spell_purifyingFire` | Purifying Fire | 215 | 1 |
|
|
||||||
| `spell_supernovaBlast` | Supernova Blast | 420 | 1 |
|
|
||||||
|
|
||||||
**Miasma Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_toxicCloud` | Toxic Cloud | 76 | 1 |
|
|
||||||
| `spell_plagueTouch` | Plague Touch | 100 | 1 |
|
|
||||||
| `spell_miasmaBurst` | Miasma Burst | 165 | 1 |
|
|
||||||
| `spell_pestilence` | Pestilence | 195 | 1 |
|
|
||||||
| `spell_deathMiasma` | Death Miasma | 390 | 1 |
|
|
||||||
|
|
||||||
**Shadow Glass Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_shadowSpike` | Shadow Spike | 88 | 1 |
|
|
||||||
| `spell_darkShard` | Dark Shard | 115 | 1 |
|
|
||||||
| `spell_obsidianStorm` | Obsidian Storm | 185 | 1 |
|
|
||||||
| `spell_voidBlade` | Void Blade | 225 | 1 |
|
|
||||||
| `spell_shadowGlassCataclysm` | Shadow Glass Cataclysm | 415 | 1 |
|
|
||||||
|
|
||||||
**Exotic Spells:**
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `spell_soulPierce` | Soul Pierce | 500 | 1 |
|
|
||||||
| `spell_spiritBlast` | Spirit Blast | 650 | 1 |
|
|
||||||
| `spell_temporalWarp` | Temporal Warp | 520 | 1 |
|
|
||||||
| `spell_chronoStasis` | Chrono Stasis | 680 | 1 |
|
|
||||||
| `spell_plasmaBolt` | Plasma Bolt | 510 | 1 |
|
|
||||||
| `spell_plasmaStorm` | Plasma Storm | 660 | 1 |
|
|
||||||
|
|
||||||
### 7.2 Mana Effects (category: `'mana'`)
|
|
||||||
|
|
||||||
**General Mana** — Allowed on: `['caster', 'catalyst', 'head', 'body', 'accessory']`
|
|
||||||
|
|
||||||
| Effect ID | Name | Description | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `mana_cap_50` | Mana Reserve | +50 max mana | 20 | 3 |
|
|
||||||
| `mana_cap_100` | Mana Reservoir | +100 max mana | 35 | 3 |
|
|
||||||
| `mana_regen_1` | Trickle | +1 mana/hour regen | 15 | 5 |
|
|
||||||
| `mana_regen_2` | Stream | +2 mana/hour regen | 28 | 4 |
|
|
||||||
| `mana_regen_5` | River | +5 mana/hour regen | 50 | 3 |
|
|
||||||
| `click_mana_1` | Mana Tap | +1 mana per click | 20 | 5 |
|
|
||||||
| `click_mana_3` | Mana Surge | +3 mana per click | 35 | 3 |
|
|
||||||
|
|
||||||
**Weapon Mana** — Allowed on: `['caster', 'catalyst', 'sword']`
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `weapon_mana_cap_20` | Mana Cell | 25 | 5 |
|
|
||||||
| `weapon_mana_cap_50` | Mana Vessel | 50 | 3 |
|
|
||||||
| `weapon_mana_cap_100` | Mana Core | 80 | 2 |
|
|
||||||
| `weapon_mana_regen_1` | Mana Wick | 20 | 5 |
|
|
||||||
| `weapon_mana_regen_2` | Mana Siphon | 35 | 3 |
|
|
||||||
| `weapon_mana_regen_5` | Mana Well | 60 | 2 |
|
|
||||||
|
|
||||||
**Per-Element Capacity** — Allowed on: `['caster', 'catalyst', 'head', 'body', 'accessory']`
|
|
||||||
|
|
||||||
Generated for each non-utility element (21 elements). Three tiers per element:
|
|
||||||
- `{element}_cap_10`: cost 30, max 5 stacks
|
|
||||||
- `{element}_cap_25`: cost 60, max 3 stacks
|
|
||||||
- `{element}_cap_50`: cost 100, max 2 stacks
|
|
||||||
|
|
||||||
### 7.3 Combat Effects (category: `'combat'`) — Casters, Hands
|
|
||||||
|
|
||||||
| Effect ID | Name | Description | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `damage_5` | Minor Power | +5 base damage | 15 | 5 |
|
|
||||||
| `damage_10` | Moderate Power | +10 base damage | 28 | 4 |
|
|
||||||
| `damage_pct_10` | Amplification | +10% damage | 30 | 3 |
|
|
||||||
| `crit_5` | Sharp Edge | +5% crit chance | 20 | 4 |
|
|
||||||
| `attack_speed_10` | Swift Casting | +10% attack speed | 22 | 4 |
|
|
||||||
|
|
||||||
### 7.4 Elemental Effects (category: `'elemental'`) — Casters, Swords
|
|
||||||
|
|
||||||
| Effect ID | Name | Description | Base Cost | Max Stacks |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `sword_fire` | Fire Enchant | Burns enemies | 40 | 1 |
|
|
||||||
| `sword_frost` | Frost Enchant | Prevents dodge | 40 | 1 |
|
|
||||||
| `sword_lightning` | Lightning Enchant | 30% armor pierce | 50 | 1 |
|
|
||||||
| `sword_void` | Void Enchant | +20% damage | 60 | 1 |
|
|
||||||
|
|
||||||
### 7.5 Utility Effects (category: `'utility'`)
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks | Allowed On |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `meditate_10` | Meditative Focus | 18 | 5 | head, body, accessory |
|
|
||||||
| `study_10` | Quick Study | 22 | 4 | caster, catalyst, head, body, hands, feet, accessory |
|
|
||||||
| `insight_5` | Insightful | 25 | 4 | head, accessory |
|
|
||||||
|
|
||||||
### 7.6 Special Effects (category: `'special'`)
|
|
||||||
|
|
||||||
| Effect ID | Name | Base Cost | Max Stacks | Allowed On |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `spell_echo_10` | Echo Chamber | 60 | 2 | caster |
|
|
||||||
| `guardian_dmg_10` | Bane | 35 | 3 | caster, catalyst, accessory |
|
|
||||||
| `overpower_80` | Overpower | 55 | 1 | caster, hands |
|
|
||||||
| `first_strike` | First Strike | 45 | 1 | caster, hands |
|
|
||||||
| `combo_master` | Combo Master | 65 | 1 | caster, hands |
|
|
||||||
| `adrenaline_rush` | Adrenaline Rush | 50 | 1 | caster, hands |
|
|
||||||
|
|
||||||
### 7.7 Defense Effects (category: `'defense'`)
|
|
||||||
|
|
||||||
**Empty** — No defense effects are currently defined.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Discipline Perks That Affect Enchanting
|
|
||||||
|
|
||||||
| Discipline | Perk | Threshold | Effect |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Enchantment Crafting | `enchant-1` (infinite) | 150 XP | +5 enchantPower per tier |
|
|
||||||
| Enchantment Crafting | `enchant-2` (capped) | 300 XP | +10 enchantPower/tier, max 3 |
|
|
||||||
| Study Basic Weapon Enchantments | `basic-weapon-fire` | 50 XP | Unlocks `sword_fire` |
|
|
||||||
| Study Basic Weapon Enchantments | `basic-weapon-frost` | 100 XP | Unlocks `sword_frost` |
|
|
||||||
| Study Basic Weapon Enchantments | `basic-weapon-lightning` | 150 XP | Unlocks `sword_lightning` |
|
|
||||||
| Study Advanced Weapon Enchantments | `advanced-weapon-void` | 100 XP | Unlocks `sword_void` |
|
|
||||||
| Study Advanced Weapon Enchantments | `advanced-weapon-damage-5` | 150 XP | Unlocks `damage_5` |
|
|
||||||
| Study Advanced Weapon Enchantments | `advanced-weapon-crit` | 200 XP | Unlocks `crit_5` |
|
|
||||||
| Study Advanced Weapon Enchantments | `advanced-weapon-attack-speed` | 250 XP | Unlocks `attack_speed_10` |
|
|
||||||
| Study Utility Enchantments | `utility-meditate` | 50 XP | Unlocks `meditate_10` |
|
|
||||||
| Study Utility Enchantments | `utility-study` | 100 XP | Unlocks `study_10` |
|
|
||||||
| Study Utility Enchantments | `utility-insight` | 150 XP | Unlocks `insight_5` |
|
|
||||||
| Study Mana Enchantments | `mana-cap-50` | 75 XP | Unlocks `mana_cap_50` |
|
|
||||||
| Study Mana Enchantments | `mana-cap-100` | 150 XP | Unlocks `mana_cap_100` |
|
|
||||||
| Study Mana Enchantments | `mana-regen-1` | 100 XP | Unlocks `mana_regen_1` |
|
|
||||||
| Study Mana Enchantments | `mana-regen-2` | 200 XP | Unlocks `mana_regen_2` |
|
|
||||||
| Study Mana Enchantments | `click-mana-1` | 125 XP | Unlocks `click_mana_1` |
|
|
||||||
| Study Mana Enchantments | `click-mana-3` | 225 XP | Unlocks `click_mana_3` |
|
|
||||||
| Study Basic Spell Enchantments | 8 perks | 50–150 XP | Unlock 8 basic spell enchants |
|
|
||||||
| Study Intermediate Spell Enchantments | 6 perks | 80–120 XP | Unlock 6 intermediate spell enchants |
|
|
||||||
| Study Advanced Spell Enchantments | 10 perks | 100–200 XP | Unlock 10 advanced spell enchants |
|
|
||||||
| Study Special Enchantments | 6 perks | 80–200 XP | Unlock 6 special enchants |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Attunement Level Interactions
|
|
||||||
|
|
||||||
Enchanter level does **not** directly affect enchanting mechanics (timings, costs,
|
|
||||||
capacity). It affects:
|
|
||||||
|
|
||||||
1. **Raw mana regen**: `0.5 × 1.5^(level-1)` per hour — more raw mana for enchanting
|
|
||||||
2. **Transference conversion**: `0.2 × 1.5^(level-1)` per hour — more transference mana for Enchanter disciplines
|
|
||||||
3. **Enchanting XP → Attunement XP**: 1 Enchanter XP per 10 capacity used
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Acceptance Criteria
|
|
||||||
|
|
||||||
| # | Criterion |
|
|
||||||
|---|---|
|
|
||||||
| AC-1 | Design stage takes `1 + 0.5 × totalStacks` hours; progress accumulates at 0.04 hours/tick. |
|
|
||||||
| AC-2 | Hasty Enchanter reduces design time by 25% on repeat designs only. |
|
|
||||||
| AC-3 | Instant Designs has a 10% chance per tick to complete the design immediately. |
|
|
||||||
| AC-4 | Dual design slot is available when Enchant Mastery is active and first slot is occupied. |
|
|
||||||
| AC-5 | Prepare stage takes `2 + floor(capacity/50)` hours and costs `capacity × 10` total mana. |
|
|
||||||
| AC-6 | Prepare removes all enchantments, resets usedCapacity to 0, resets rarity to 'common'. |
|
|
||||||
| AC-7 | Disenchant recovery rate is `0.10 + disenchantLevel × 0.20` of each enchantment's actual cost. |
|
|
||||||
| AC-8 | Apply stage takes `2 + totalStacks` hours and costs `20 + sum(stacks × 5)` mana/hour. |
|
|
||||||
| AC-9 | Free enchant chances are additive (max 60%) and skip mana cost for that tick. |
|
|
||||||
| AC-10 | Pure Essence grants 1.25× stacks (ceil) for effects with base cost < 100. |
|
|
||||||
| AC-11 | Stacking cost formula: `baseCost × (1 + i × 0.2)` for stack index i, reduced by efficiencyBonus. |
|
|
||||||
| AC-12 | Cancellation refunds unspent progress at 100% and spent progress at 50%, blended. |
|
|
||||||
| AC-13 | All enchantment effects are gated behind discipline perk thresholds and cannot be used until unlocked. |
|
|
||||||
| AC-14 | Equipment type capacity limits are enforced — designs exceeding capacity are rejected. |
|
|
||||||
| AC-15 | Spell effects can only be applied to caster equipment. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Files Reference
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|---|---|
|
|
||||||
| `src/lib/game/crafting-design.ts` | Design stage logic, timing, validation |
|
|
||||||
| `src/lib/game/crafting-prep.ts` | Prepare stage logic, disenchant recovery |
|
|
||||||
| `src/lib/game/crafting-apply.ts` | Apply stage logic, free enchant, Pure Essence |
|
|
||||||
| `src/lib/game/crafting-utils.ts` | Shared utilities, capacity cost, cancellation refund |
|
|
||||||
| `src/lib/game/data/attunements.ts` | Attunement-crafting integration, enchanting XP |
|
|
||||||
| `src/lib/game/data/enchantments/` | All enchantment effect definitions (7 categories) |
|
|
||||||
| `src/lib/game/crafting-actions/design-actions.ts` | Design stage store actions |
|
|
||||||
| `src/lib/game/crafting-actions/preparation-actions.ts` | Prepare stage store actions |
|
|
||||||
| `src/lib/game/crafting-actions/application-actions.ts` | Apply stage store actions |
|
|
||||||
| `src/lib/game/crafting-actions/disenchant-actions.ts` | Disenchant action |
|
|
||||||
| `src/components/game/tabs/CraftingTab.tsx` | Crafting tab wrapper |
|
|
||||||
| `src/components/game/crafting/EnchantmentDesigner.tsx` | Design UI |
|
|
||||||
| `src/components/game/crafting/EnchantmentPreparer.tsx` | Prepare UI |
|
|
||||||
| `src/components/game/crafting/EnchantmentApplier.tsx` | Apply UI |
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
# Fabricator Attunement — Design Spec
|
|
||||||
|
|
||||||
> Describes the Fabricator attunement: identity, unlock flow, mana behavior, full
|
|
||||||
> discipline list with stats/perks, systems unlocked, and attunement level interactions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Objective
|
|
||||||
|
|
||||||
The Fabricator is the crafting and golemancy attunement. It provides access to
|
|
||||||
Earth-based disciplines that unlock equipment fabrication recipes, golem summoning,
|
|
||||||
and crafting cost reduction. The Fabricator is the primary source of custom
|
|
||||||
equipment and the golem combat system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Identity
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|---|---|
|
|
||||||
| **ID** | `fabricator` |
|
|
||||||
| **Slot** | `leftHand` |
|
|
||||||
| **Icon** | `⚒️` |
|
|
||||||
| **Color** | `#F4A261` (Earth) |
|
|
||||||
| **Primary Mana** | `earth` |
|
|
||||||
| **Raw Mana Regen** | +0.4/hour (base, scales with `1.5^(level-1)`) |
|
|
||||||
| **Conversion Rate** | 0.25 raw→earth/hour (base, scales with `1.5^(level-1)`) |
|
|
||||||
| **Unlock** | Prove crafting worth |
|
|
||||||
| **Capabilities** | `['golemCrafting', 'gearCrafting', 'earthShaping']` |
|
|
||||||
| **Skill Categories** | `['fabrication', 'golemancy']` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Unlock Condition and Flow
|
|
||||||
|
|
||||||
**Condition:** Prove your worth as a crafter.
|
|
||||||
|
|
||||||
**Unlock flow:**
|
|
||||||
1. Meet the crafting-related unlock condition
|
|
||||||
2. Fabricator becomes available for activation
|
|
||||||
3. Player activates Fabricator → initialized at `{ active: true, level: 1, experience: 0 }`
|
|
||||||
4. Fabricator disciplines become available (5 total)
|
|
||||||
|
|
||||||
The unlock condition is stored as a descriptive string:
|
|
||||||
`"Prove your worth as a crafter"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Raw Mana Regen Contribution
|
|
||||||
|
|
||||||
Base regen: **+0.4/hour** (at level 1). Scales exponentially:
|
|
||||||
|
|
||||||
```
|
|
||||||
effectiveRegen = 0.4 × 1.5^(level - 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Level | Raw Regen |
|
|
||||||
|---|---|
|
|
||||||
| 1 | 0.400/hr |
|
|
||||||
| 5 | 2.025/hr |
|
|
||||||
| 10 | 15.377/hr |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Mana Conversion Behavior
|
|
||||||
|
|
||||||
The Fabricator converts raw mana to Earth:
|
|
||||||
|
|
||||||
```
|
|
||||||
effectiveConversionRate = 0.25 × 1.5^(level - 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
At level 10, the Fabricator converts **9.61 raw→earth/hour**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Disciplines
|
|
||||||
|
|
||||||
The Fabricator's discipline pool contains **5 disciplines**.
|
|
||||||
|
|
||||||
### 6.1 Golem Crafting (`golem-crafting`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `earth` |
|
|
||||||
| **Base Cost** | 10 |
|
|
||||||
| **Stat Bonus** | `golemCapacity` +2 (base) |
|
|
||||||
| **Scaling Factor** | 80 |
|
|
||||||
| **Difficulty Factor** | 150 |
|
|
||||||
| **Drain Base** | 4 |
|
|
||||||
|
|
||||||
**Perks:**
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Bonus |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `golem-1` | `once` | 200 | Unlock golem summoning |
|
|
||||||
| `golem-2` | `capped` | 500 | +1 Golem Capacity per tier, interval 500 XP, max 2 tiers |
|
|
||||||
|
|
||||||
### 6.2 Crafting Efficiency (`crafting-efficiency`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `earth` |
|
|
||||||
| **Base Cost** | 12 |
|
|
||||||
| **Stat Bonus** | `craftingCostReduction` +15 (base) |
|
|
||||||
| **Scaling Factor** | 90 |
|
|
||||||
| **Difficulty Factor** | 180 |
|
|
||||||
| **Drain Base** | 6 |
|
|
||||||
|
|
||||||
**Perks:**
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Bonus |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `efficiency-1` | `once` | 300 | +10% Crafting Cost Reduction |
|
|
||||||
|
|
||||||
### 6.3 Study Fabricator Recipes (`study-fabricator-recipes`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `earth` |
|
|
||||||
| **Base Cost** | 10 |
|
|
||||||
| **Stat Bonus** | `enchantPower` +3 (base) |
|
|
||||||
| **Scaling Factor** | 80 |
|
|
||||||
| **Difficulty Factor** | 100 |
|
|
||||||
| **Drain Base** | 2 |
|
|
||||||
|
|
||||||
**Perks:**
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `fabricator-earth` | `once` | 50 | `earthHelm`, `earthChest`, `earthBoots` |
|
|
||||||
| `fabricator-metal` | `once` | 100 | `metalBlade`, `metalShield`, `metalGloves` |
|
|
||||||
| `fabricator-sand` | `once` | 150 | `sandBoots`, `sandGloves`, `sandVest` |
|
|
||||||
| `fabricator-crystal` | `once` | 200 | `crystalWand`, `crystalRing`, `crystalAmulet` |
|
|
||||||
|
|
||||||
### 6.4 Study Wizard Equipment (`study-wizard-branch`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `earth` |
|
|
||||||
| **Base Cost** | 15 |
|
|
||||||
| **Requires** | `study-fabricator-recipes` |
|
|
||||||
| **Stat Bonus** | `enchantPower` +5 (base) |
|
|
||||||
| **Scaling Factor** | 100 |
|
|
||||||
| **Difficulty Factor** | 150 |
|
|
||||||
| **Drain Base** | 3 |
|
|
||||||
|
|
||||||
**Perks:**
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `wizard-oak` | `once` | 50 | `oakStaff` |
|
|
||||||
| `wizard-arcanist-staff` | `once` | 100 | `arcanistStaff` |
|
|
||||||
| `wizard-battlestaff` | `once` | 150 | `battlestaff` |
|
|
||||||
| `wizard-arcanist-gear` | `once` | 200 | `arcanistCirclet`, `arcanistRobe` |
|
|
||||||
| `wizard-void-catalyst` | `once` | 250 | `voidCatalyst` |
|
|
||||||
| `wizard-arcanist-pendant` | `once` | 300 | `arcanistPendant` |
|
|
||||||
|
|
||||||
### 6.5 Study Physical Equipment (`study-physical-branch`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `earth` |
|
|
||||||
| **Base Cost** | 15 |
|
|
||||||
| **Requires** | `study-fabricator-recipes` |
|
|
||||||
| **Stat Bonus** | `enchantPower` +5 (base) |
|
|
||||||
| **Scaling Factor** | 100 |
|
|
||||||
| **Difficulty Factor** | 150 |
|
|
||||||
| **Drain Base** | 3 |
|
|
||||||
|
|
||||||
**Perks:**
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Unlocks |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `physical-crystal-blade` | `once` | 50 | `crystalBlade` |
|
|
||||||
| `physical-arcanist-blade` | `once` | 100 | `arcanistBlade` |
|
|
||||||
| `physical-void-blade` | `once` | 150 | `voidBlade` |
|
|
||||||
| `physical-battle-gear` | `once` | 200 | `battleHelm`, `battleRobe` |
|
|
||||||
| `physical-battle-boots` | `once` | 250 | `battleBoots` |
|
|
||||||
| `physical-combat-gauntlets` | `once` | 300 | `combatGauntlets` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Systems Unlocked
|
|
||||||
|
|
||||||
The Fabricator attunement gates two systems:
|
|
||||||
|
|
||||||
1. **Golemancy** (see `golemancy-spec.md`): Summon and maintain golems for spire combat
|
|
||||||
2. **Item Fabrication** (see `item-fabrication-spec.md`): Craft equipment and materials from recipes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Puzzle Room Behavior
|
|
||||||
|
|
||||||
In the spire, every 7th floor has a puzzle room. When the room type is
|
|
||||||
`fabricator_trial`, progress scales at 2.5–3% per tick per Fabricator level.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Attunement Level Interactions
|
|
||||||
|
|
||||||
Higher Fabricator level affects:
|
|
||||||
|
|
||||||
1. **Raw mana regen**: `0.4 × 1.5^(level-1)` per hour
|
|
||||||
2. **Earth conversion rate**: `0.25 × 1.5^(level-1)` per hour
|
|
||||||
3. **Golem slots**: `floor(fabricatorLevel / 2)` — Fabricator level directly determines golem capacity
|
|
||||||
|
|
||||||
| Fabricator Level | Golem Slots |
|
|
||||||
|---|---|
|
|
||||||
| 1 | 0 |
|
|
||||||
| 2–3 | 1 |
|
|
||||||
| 4–5 | 2 |
|
|
||||||
| 6–7 | 3 |
|
|
||||||
| 8–9 | 4 |
|
|
||||||
| 10 | 5 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Discipline Dependency Chain
|
|
||||||
|
|
||||||
```
|
|
||||||
golem-crafting (root)
|
|
||||||
crafting-efficiency (root)
|
|
||||||
study-fabricator-recipes (root)
|
|
||||||
└── study-wizard-branch
|
|
||||||
└── study-physical-branch
|
|
||||||
```
|
|
||||||
|
|
||||||
3 root disciplines. Maximum dependency depth: 2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Acceptance Criteria
|
|
||||||
|
|
||||||
| # | Criterion |
|
|
||||||
|---|---|
|
|
||||||
| AC-1 | Fabricator is locked until the unlock condition is met. |
|
|
||||||
| AC-2 | All 5 Fabricator disciplines are available when Fabricator is active. |
|
|
||||||
| AC-3 | `study-wizard-branch` and `study-physical-branch` require `study-fabricator-recipes`. |
|
|
||||||
| AC-4 | Golem summoning is unlocked at Golem Crafting discipline threshold 200 XP. |
|
|
||||||
| AC-5 | Golem capacity is 2 (base) + up to 2 (from capped perk) = max 4 from disciplines. |
|
|
||||||
| AC-6 | Golem slots from attunement level: `floor(fabricatorLevel / 2)`, max 5 at level 10. |
|
|
||||||
| AC-7 | All recipe unlock perks fire at the correct discipline XP thresholds. |
|
|
||||||
| AC-8 | Crafting Efficiency discipline reduces material costs by 15% (base) + 10% (perk). |
|
|
||||||
| AC-9 | Fabricator `fabricator_trial` puzzle rooms grant bonus progress per Fabricator level. |
|
|
||||||
| AC-10 | Fabricator level scales raw regen and earth conversion by `1.5^(level-1)`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Files Reference
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|---|---|
|
|
||||||
| `src/lib/game/data/attunements.ts` | Fabricator definition |
|
|
||||||
| `src/lib/game/data/disciplines/fabricator.ts` | Fabricator disciplines (5) |
|
|
||||||
| `src/lib/game/data/golems/` | Golem component definitions (4 cores, 7 frames, 4 mind circuits, 8 enchantments) |
|
|
||||||
| `src/lib/game/crafting-fabricator.ts` | Fabrication crafting logic |
|
|
||||||
| `src/lib/game/data/fabricator-recipes.ts` | Core equipment recipes |
|
|
||||||
| `src/lib/game/data/fabricator-material-recipes.ts` | Material recipes |
|
|
||||||
| `src/lib/game/data/fabricator-physical-recipes.ts` | Physical branch recipes |
|
|
||||||
| `src/lib/game/data/fabricator-wizard-recipes.ts` | Wizard branch recipes |
|
|
||||||
| `src/components/game/tabs/GolemancyTab.tsx` | Golemancy UI |
|
|
||||||
| `docs/specs/attunements/fabricator/systems/golemancy-spec.md` | Golemancy system spec |
|
|
||||||
| `docs/specs/attunements/fabricator/systems/item-fabrication-spec.md` | Item fabrication spec |
|
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
# Golemancy System — Design Spec (Redesign)
|
|
||||||
|
|
||||||
> Describes the Fabricator attunement's combat system using the new **component-based construction system** (Core + Frame + Mind Circuit + Enchantments).
|
|
||||||
> This replaces the previous predefined golem type system.
|
|
||||||
> See Gitea issue #268 for the full redesign rationale.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Objective
|
|
||||||
|
|
||||||
Golemancy is the Fabricator attunement's combat contribution. The player **designs** custom golems by assembling components, then configures a loadout of these custom golems outside the spire. Golems are automatically summoned at each room entry, fight alongside the player, and disappear after a fixed number of rooms or if their maintenance cost cannot be met.
|
|
||||||
|
|
||||||
**Design goals:**
|
|
||||||
- Deep customization: players build golems from components rather than selecting predefined types
|
|
||||||
- Strategic resource management: Core determines mana types, capacity, regen, and upkeep
|
|
||||||
- Meaningful progression: higher-tier components unlock through attunement investment
|
|
||||||
- Guardian Constructs: ultimate endgame golems requiring Invoker 5 + Fabricator 5 + Guardian Core
|
|
||||||
- Component synergy: Frame + Core + Mind Circuit + Enchantments create unique builds
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Golem Slot Formula
|
|
||||||
|
|
||||||
Golem slots come from **two sources** that add together:
|
|
||||||
|
|
||||||
### 2.1 From Attunement Level
|
|
||||||
|
|
||||||
```
|
|
||||||
attunementSlots = floor(fabricatorLevel / 2)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Fabricator Level | Slots |
|
|
||||||
|---|---|
|
|
||||||
| 1 | 0 |
|
|
||||||
| 2–3 | 1 |
|
|
||||||
| 4–5 | 2 |
|
|
||||||
| 6–7 | 3 |
|
|
||||||
| 8–9 | 4 |
|
|
||||||
| 10 | 5 |
|
|
||||||
|
|
||||||
### 2.2 From Discipline
|
|
||||||
|
|
||||||
The **Golem Crafting** discipline provides:
|
|
||||||
- Base `golemCapacity`: +2
|
|
||||||
- Perk `golem-2` (capped, threshold 500, maxTier 2): +1 per tier = up to +2
|
|
||||||
|
|
||||||
**Maximum total golem slots: 5 (attunement) + 2 (discipline) = 7**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Component-Based Construction
|
|
||||||
|
|
||||||
Every golem consists of **three mandatory components** and **one optional component**:
|
|
||||||
|
|
||||||
1. **Core** — Power source, determines mana types, capacity, regen, upkeep, duration
|
|
||||||
2. **Frame** — Physical combat characteristics (damage, speed, armor pierce, magic affinity, special)
|
|
||||||
3. **Mind Circuit** — Behavior logic (basic attacks, spell casting, spell selection)
|
|
||||||
4. **Enchantments** (optional) — Sword effects applied to basic attacks
|
|
||||||
|
|
||||||
The player designs golems in the Golemancy tab by selecting one of each mandatory component, then optionally adding enchantments.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Core
|
|
||||||
|
|
||||||
The Core acts as the golem's power source. It determines:
|
|
||||||
|
|
||||||
- **Mana Types Available** — Which mana types the golem can use for spells/upkeep
|
|
||||||
- **Mana Capacity** — Maximum mana the golem can hold
|
|
||||||
- **Mana Regeneration** — Mana restored per in-game hour
|
|
||||||
- **Summon Duration** — Max rooms the golem persists (`maxRoomDuration`)
|
|
||||||
- **Player Upkeep Cost** — Mana cost per hour to maintain the golem
|
|
||||||
|
|
||||||
**Player upkeep formula:**
|
|
||||||
```
|
|
||||||
Upkeep per hour = Mana Regen × 2
|
|
||||||
```
|
|
||||||
(This is deducted from the player's mana pools each tick)
|
|
||||||
|
|
||||||
### 4.1 Core Tiers
|
|
||||||
|
|
||||||
| Core Tier | Mana Types | Mana Capacity | Mana Regen | Max Room Duration | Summon Cost | Upkeep Cost (per hr) | Unlock Requirement |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| **Basic Core** | 1 (Earth) | 50 | 0.5 | 3 | 10 Earth | 1.0 Earth | Fabricator 2 |
|
|
||||||
| **Intermediate Core** | 2 | 100 | 1.5 | 4 | 20 Crystal | 3.0 Crystal | Fabricator 4, Enchanter 2 |
|
|
||||||
| **Advanced Core** | 3 | 200 | 3.0 | 5 | 30 Crystal | 6.0 Crystal | Fabricator 6, Enchanter 3 |
|
|
||||||
| **Guardian Core** | Guardian-specific | 500 | 10.0 | 8 | Guardian-specific | 20.0 Guardian-specific | Invoker 5 + Fabricator 5, Guardian Pact signed |
|
|
||||||
|
|
||||||
### 4.2 Core Mana Types
|
|
||||||
|
|
||||||
- **Basic Core:** Only Earth mana
|
|
||||||
- **Intermediate Core:** Player chooses 2 mana types from unlocked elements
|
|
||||||
- **Advanced Core:** Player chooses 3 mana types from unlocked elements
|
|
||||||
- **Guardian Core:** Provides **all mana types granted by the chosen Guardian** (e.g., a Metal Guardian Core provides Metal + Earth + Lightning)
|
|
||||||
|
|
||||||
### 4.3 Guardian Core
|
|
||||||
|
|
||||||
**Requirements:**
|
|
||||||
- Invoker Attunement 5
|
|
||||||
- Fabricator Attunement 5
|
|
||||||
- Guardian Pact signed (for the specific guardian)
|
|
||||||
|
|
||||||
**Properties:**
|
|
||||||
- Provides all mana types granted by the chosen Guardian
|
|
||||||
- Massive mana capacity (500) and regeneration (10/hr)
|
|
||||||
- **Required for Guardian Constructs** (see §8)
|
|
||||||
- Summon cost and upkeep use Guardian-specific mana types
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Frame
|
|
||||||
|
|
||||||
The Frame determines the golem's physical combat characteristics.
|
|
||||||
|
|
||||||
### 5.1 Frame Statistics
|
|
||||||
|
|
||||||
| Stat | Description |
|
|
||||||
|---|---|
|
|
||||||
| **Damage** | Base damage per basic attack |
|
|
||||||
| **Speed** | Attack speed (attacks per in-game hour) |
|
|
||||||
| **Armor Pierce** | Fraction of enemy armor bypassed (0–1) |
|
|
||||||
| **Magic Affinity** | Percentage — determines spell damage efficiency (50% = spells deal 50% normal damage) |
|
|
||||||
| **Special Effect** | Unique passive or active ability |
|
|
||||||
|
|
||||||
### 5.2 Frame Definitions
|
|
||||||
|
|
||||||
| Frame | Damage | Speed | Armor Pierce | Magic Affinity | Special Effect | Unlock Requirement |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| **Earth** | Very Low | Medium | Very Low | Very Low | None | Fabricator 2 |
|
|
||||||
| **Sand** | Low–Medium | Slow | **Very High** | Medium | **AoE** (attacks hit 2 targets) | Sand mana unlocked |
|
|
||||||
| **Frost** | Medium | Medium | Medium | **High** | Attacks apply **Slow** | Frost mana unlocked |
|
|
||||||
| **Crystal** | High | Fast | Medium–Low | **Very High** | None | Crystal mana unlocked |
|
|
||||||
| **Steel** | Very High | Fast | High | Medium | None | Metal mana unlocked |
|
|
||||||
| **Shadowglass** | Very High | **Very Fast** | Very High | **Very High** | **AoE** (attacks hit 2 targets) | Shadow Glass mana unlocked |
|
|
||||||
| **Crystal-Steel Hybrid** | **Very High** | **Very Fast** | **Very High** | **Highest** | Supports Guardian Constructs | Fabricator 5 |
|
|
||||||
|
|
||||||
### 5.3 Crystal-Steel Hybrid Frame
|
|
||||||
|
|
||||||
**Requirements:**
|
|
||||||
- Fabricator Attunement 5
|
|
||||||
|
|
||||||
**Properties:**
|
|
||||||
- Only frame capable of housing a **Guardian Core**
|
|
||||||
- **Required for all Guardian Constructs**
|
|
||||||
- Highest combined stats of any frame
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Mind Circuit
|
|
||||||
|
|
||||||
The Mind Circuit controls the golem's behavior and spell usage.
|
|
||||||
|
|
||||||
### 6.1 Simple Logic Circuit
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Cost** | Earth Mana (summon) |
|
|
||||||
| **Behavior** | Performs basic attacks only. Targets nearest enemy. |
|
|
||||||
| **Requirements** | None |
|
|
||||||
| **Spell Slots** | 0 |
|
|
||||||
|
|
||||||
### 6.2 Intermediate Logic Circuit
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Cost** | Crystal Mana (summon) |
|
|
||||||
| **Behavior** | Player selects **1 spell** from unlocked Spell Enchantments (caster pool). Golem attempts to cast the spell whenever enough mana is available. Otherwise performs basic attacks. |
|
|
||||||
| **Requirements** | Enchanter 2 + Fabricator 3 |
|
|
||||||
| **Spell Slots** | 1 |
|
|
||||||
|
|
||||||
### 6.3 Advanced Logic Circuit
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Cost** | Crystal Mana (summon) |
|
|
||||||
| **Behavior** | Player selects **2 spells**. Golem alternates: Spell A → Spell B → Spell A → Spell B... If unable to cast (insufficient mana), performs basic attacks. |
|
|
||||||
| **Requirements** | Enchanter 3 + Fabricator 4 |
|
|
||||||
| **Spell Slots** | 2 (alternating) |
|
|
||||||
|
|
||||||
### 6.4 Guardian Circuit
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Cost** | Guardian-specific mana (summon) |
|
|
||||||
| **Behavior** | Required for Guardian Constructs. Player selects **1 spell for each mana type** available to the Guardian Core. Cycles through all selected spells in order. |
|
|
||||||
| **Requirements** | Invoker 5 + Fabricator 5 |
|
|
||||||
| **Spell Slots** | = Number of mana types from Guardian Core (typically 3–4) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Enchantments (Optional)
|
|
||||||
|
|
||||||
Enchantments add sword effects to a golem's **basic attacks**.
|
|
||||||
|
|
||||||
**Requirements:**
|
|
||||||
- Enchanter Attunement 5
|
|
||||||
- Fabricator Attunement 5
|
|
||||||
|
|
||||||
**Enchantment Capacity:**
|
|
||||||
Determined by: `Frame.MagicAffinity × Core.TierMultiplier`
|
|
||||||
- Basic Core: ×1.0
|
|
||||||
- Intermediate Core: ×1.5
|
|
||||||
- Advanced Core: ×2.0
|
|
||||||
- Guardian Core: ×3.0
|
|
||||||
|
|
||||||
Each enchantment consumes capacity. Capacity is a soft limit — exceeding it reduces Magic Affinity proportionally.
|
|
||||||
|
|
||||||
**Summon Cost Increase:**
|
|
||||||
```
|
|
||||||
Summon Cost += Enchantment Base Cost (per enchantment)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.1 Enchantment Examples
|
|
||||||
|
|
||||||
| Enchantment | Effect on Basic Attack |
|
|
||||||
|---|---|
|
|
||||||
| **Sword_Fire** | Applies **Burn** DoT |
|
|
||||||
| **Sword_Frost** | Applies additional **Slow** |
|
|
||||||
| **Sword_Lightning** | Chance to **Shock** (stun) |
|
|
||||||
| **Sword_Shadow** | Chance to **Weaken** (reduce enemy damage) |
|
|
||||||
| **Sword_Metal** | Bonus **Armor Pierce** |
|
|
||||||
| **Sword_Crystal** | Bonus **Critical Chance** |
|
|
||||||
|
|
||||||
*(Full list mirrors sword enchantment effects from the enchanting system)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Guardian Constructs
|
|
||||||
|
|
||||||
Guardian Constructs are the ultimate golems, combining a **Guardian Core** + **Crystal-Steel Hybrid Frame** + **Guardian Circuit** + Enchantments.
|
|
||||||
|
|
||||||
### 8.1 Requirements
|
|
||||||
|
|
||||||
- Invoker Attunement 5
|
|
||||||
- Fabricator Attunement 5
|
|
||||||
- Guardian Pact signed for the chosen guardian
|
|
||||||
- Guardian Core (crafted from guardian materials)
|
|
||||||
|
|
||||||
### 8.2 Properties
|
|
||||||
|
|
||||||
- **Mana Types:** All types granted by the Guardian (e.g., Metal Guardian → Metal, Earth, Lightning)
|
|
||||||
- **Frame:** Must use Crystal-Steel Hybrid Frame
|
|
||||||
- **Mind Circuit:** Must use Guardian Circuit
|
|
||||||
- **Spell Selection:** One spell per mana type, cycled in order
|
|
||||||
- **Enchantments:** Can apply enchantments up to high capacity (Guardian Core ×3.0 multiplier)
|
|
||||||
- **Duration:** 8 rooms (Guardian Core base)
|
|
||||||
- **Power Level:** Highest in the game — intended for endgame spire pushing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Golem Loadout Configuration
|
|
||||||
|
|
||||||
The player configures a **golem loadout** from the Golemancy tab before entering the spire.
|
|
||||||
|
|
||||||
- Each loadout slot contains a **complete golem design** (Core + Frame + Mind Circuit + Enchantments)
|
|
||||||
- The loadout is a prioritized list of golem designs
|
|
||||||
- On each room entry, the system iterates the loadout in order, attempting to summon each golem
|
|
||||||
- Loadout persists across rooms but **not** across spire runs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Summoning on Room Entry
|
|
||||||
|
|
||||||
When the player enters a new combat room:
|
|
||||||
|
|
||||||
```
|
|
||||||
onRoomEntry():
|
|
||||||
for each golemDesign in golemLoadout:
|
|
||||||
totalSummonCost = golemDesign.core.summonCost
|
|
||||||
+ golemDesign.frame.summonCost
|
|
||||||
+ golemDesign.mindCircuit.summonCost
|
|
||||||
+ sum(golemDesign.enchantments[i].summonCost)
|
|
||||||
|
|
||||||
if player has enough mana for totalSummonCost:
|
|
||||||
deductMana(totalSummonCost)
|
|
||||||
activeGolems.push({
|
|
||||||
...golemDesign,
|
|
||||||
roomsRemaining: golemDesign.core.maxRoomDuration,
|
|
||||||
attackProgress: 0,
|
|
||||||
currentMana: golemDesign.core.manaCapacity, // starts full
|
|
||||||
})
|
|
||||||
activityLog("${golemDesign.name} summoned")
|
|
||||||
else:
|
|
||||||
activityLog("Not enough mana to summon ${golemDesign.name} — skipped")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key rules:**
|
|
||||||
- Golems that cannot be summoned (insufficient mana) are **not re-attempted** within the same room
|
|
||||||
- Failed golems will be attempted again on the next room entry
|
|
||||||
- Summoning order follows the loadout priority list
|
|
||||||
- Golem starts with full mana (from Core capacity)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Golem Combat
|
|
||||||
|
|
||||||
Each active golem attacks on its own `attackProgress` timer:
|
|
||||||
|
|
||||||
```
|
|
||||||
golemProgress += HOURS_PER_TICK × golem.frame.attackSpeed
|
|
||||||
while golemProgress >= 1:
|
|
||||||
if golem.mindCircuit.hasSpells and golem.currentMana >= spellCost:
|
|
||||||
castSpell(golem, spell)
|
|
||||||
golem.currentMana -= spellCost
|
|
||||||
else:
|
|
||||||
dmg = golem.frame.baseDamage
|
|
||||||
if golem.frame.element:
|
|
||||||
dmg ×= getElementalBonus(golem.frame.element, enemy.element)
|
|
||||||
applyGolemEffects(golem, dmg, enemy) // includes enchantment effects
|
|
||||||
applyDamageToRoom(dmg)
|
|
||||||
golemProgress -= 1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Spell Casting:**
|
|
||||||
- Spell damage = `baseSpellDamage × golem.frame.magicAffinity`
|
|
||||||
- Spell uses golem's mana pool (not player's)
|
|
||||||
- Golem mana regenerates at `core.manaRegen` per hour
|
|
||||||
|
|
||||||
**Key rules:**
|
|
||||||
- Golems ignore Executioner and Berserker discipline specials
|
|
||||||
- AoE frames (Sand, Shadowglass) distribute damage across multiple targets
|
|
||||||
- Elemental matchup applies if the frame has an element
|
|
||||||
- Enchantment effects apply to basic attacks only
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Golem Mana & Regeneration
|
|
||||||
|
|
||||||
Each golem has its **own mana pool** (separate from player):
|
|
||||||
|
|
||||||
- **Capacity:** Determined by Core tier
|
|
||||||
- **Regeneration:** `core.manaRegen` per in-game hour (ticks every game tick)
|
|
||||||
- **Usage:** Spells consume golem mana; basic attacks are free
|
|
||||||
|
|
||||||
```
|
|
||||||
tickGolemMana(golem):
|
|
||||||
golem.currentMana = min(golem.core.manaCapacity, golem.currentMana + golem.core.manaRegen × HOURS_PER_TICK)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Maintenance Cost (Player Upkeep)
|
|
||||||
|
|
||||||
Each tick, each active golem checks its **player upkeep cost** (derived from Core):
|
|
||||||
|
|
||||||
```
|
|
||||||
tickGolemMaintenance(golem):
|
|
||||||
upkeepPerHour = golem.core.manaRegen × 2
|
|
||||||
upkeepPerTick = upkeepPerHour × HOURS_PER_TICK
|
|
||||||
|
|
||||||
// Upkeep uses the Core's primary mana type(s)
|
|
||||||
// For multi-type cores, cost is split evenly across types
|
|
||||||
|
|
||||||
if player has enough mana for upkeepPerTick:
|
|
||||||
deductMana(upkeepPerTick, golem.core.primaryManaTypes)
|
|
||||||
else:
|
|
||||||
dismiss(golem)
|
|
||||||
activityLog("${golem.name} dismissed — insufficient mana for upkeep")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key rules:**
|
|
||||||
- Upkeep is paid from **player's mana**, not golem's mana
|
|
||||||
- A dismissed golem is **not re-summoned mid-room**
|
|
||||||
- It will be re-attempted on the next room entry if mana has recovered
|
|
||||||
- Maintenance is checked every tick, not just on room transitions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Room Duration Limit
|
|
||||||
|
|
||||||
```
|
|
||||||
onRoomCleared():
|
|
||||||
for each activeGolem:
|
|
||||||
activeGolem.roomsRemaining -= 1
|
|
||||||
if activeGolem.roomsRemaining <= 0:
|
|
||||||
dismiss(golem)
|
|
||||||
activityLog("${golem.name} has faded after ${maxRoomDuration} rooms")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key rules:**
|
|
||||||
- Room duration ticks down on room **clear**, not on room **entry**
|
|
||||||
- Golems persist through the full room they were summoned in
|
|
||||||
- When `roomsRemaining` reaches 0, the golem is dismissed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Golem Design Data Shape
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface GolemDesign {
|
|
||||||
id: string; // Player-assigned or auto-generated
|
|
||||||
name: string; // Player-defined name
|
|
||||||
core: CoreDefinition;
|
|
||||||
frame: FrameDefinition;
|
|
||||||
mindCircuit: MindCircuitDefinition;
|
|
||||||
enchantments: EnchantmentDefinition[]; // Optional, 0-N
|
|
||||||
|
|
||||||
// Computed fields (derived from components)
|
|
||||||
maxRoomDuration: number;
|
|
||||||
totalSummonCost: ManaCost[];
|
|
||||||
upkeepCostPerHour: ManaCost[];
|
|
||||||
manaCapacity: number;
|
|
||||||
manaRegen: number;
|
|
||||||
baseDamage: number;
|
|
||||||
attackSpeed: number;
|
|
||||||
armorPierce: number;
|
|
||||||
magicAffinity: number;
|
|
||||||
aoeTargets: number;
|
|
||||||
spellSlots: number;
|
|
||||||
availableManaTypes: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Component definitions:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface CoreDefinition {
|
|
||||||
id: 'basic' | 'intermediate' | 'advanced' | 'guardian';
|
|
||||||
tier: 1 | 2 | 3 | 4;
|
|
||||||
manaTypes: string[]; // Player-selected (for intermediate/advanced/guardian)
|
|
||||||
manaCapacity: number;
|
|
||||||
manaRegen: number;
|
|
||||||
maxRoomDuration: number;
|
|
||||||
summonCost: ManaCost[];
|
|
||||||
primaryManaType: string; // For upkeep calculation
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FrameDefinition {
|
|
||||||
id: 'earth' | 'sand' | 'frost' | 'crystal' | 'steel' | 'shadowglass' | 'crystalSteelHybrid';
|
|
||||||
baseDamage: number;
|
|
||||||
attackSpeed: number;
|
|
||||||
armorPierce: number;
|
|
||||||
magicAffinity: number; // 0.0–1.0+
|
|
||||||
aoeTargets: number;
|
|
||||||
element?: string; // For elemental matchup
|
|
||||||
specialEffect: 'none' | 'aoe' | 'slow' | 'guardianConstruct';
|
|
||||||
summonCost: ManaCost[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MindCircuitDefinition {
|
|
||||||
id: 'simple' | 'intermediate' | 'advanced' | 'guardian';
|
|
||||||
spellSlots: number;
|
|
||||||
spellSelection: string[]; // Spell IDs selected by player
|
|
||||||
behavior: 'basicOnly' | 'castSpell1' | 'alternate2' | 'cycleAll';
|
|
||||||
summonCost: ManaCost[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EnchantmentDefinition {
|
|
||||||
id: string; // e.g., 'sword_fire'
|
|
||||||
effect: string; // Effect description
|
|
||||||
capacityCost: number;
|
|
||||||
summonCost: ManaCost[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Discipline Interactions
|
|
||||||
|
|
||||||
### 16.1 Golem Crafting Discipline
|
|
||||||
|
|
||||||
| Perk | Effect |
|
|
||||||
|---|---|
|
|
||||||
| `golem-1` (once @ 200 XP) | Unlocks golem **design** ability (can create custom golems) |
|
|
||||||
| `golem-2` (capped @ 500, maxTier 2) | +1 Golem Capacity per tier (max +2) |
|
|
||||||
|
|
||||||
### 16.2 Fabricator Level
|
|
||||||
|
|
||||||
Directly determines base golem slots: `floor(fabricatorLevel / 2)`.
|
|
||||||
|
|
||||||
### 16.3 Component Unlocks via Attunements
|
|
||||||
|
|
||||||
| Component | Unlock Requirement |
|
|
||||||
|---|---|
|
|
||||||
| Basic Core | Fabricator 2 |
|
|
||||||
| Intermediate Core | Fabricator 4 + Enchanter 2 |
|
|
||||||
| Advanced Core | Fabricator 6 + Enchanter 3 |
|
|
||||||
| Guardian Core | Invoker 5 + Fabricator 5 + Guardian Pact |
|
|
||||||
| Earth Frame | Fabricator 2 |
|
|
||||||
| Sand Frame | Sand mana unlocked |
|
|
||||||
| Frost Frame | Frost mana unlocked |
|
|
||||||
| Crystal Frame | Crystal mana unlocked |
|
|
||||||
| Steel Frame | Metal mana unlocked |
|
|
||||||
| Shadowglass Frame | Shadow Glass mana unlocked |
|
|
||||||
| Crystal-Steel Hybrid Frame | Fabricator 5 |
|
|
||||||
| Simple Logic Circuit | None |
|
|
||||||
| Intermediate Logic Circuit | Enchanter 2 + Fabricator 3 |
|
|
||||||
| Advanced Logic Circuit | Enchanter 3 + Fabricator 4 |
|
|
||||||
| Guardian Circuit | Invoker 5 + Fabricator 5 |
|
|
||||||
| Enchantments | Enchanter 5 + Fabricator 5 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Implementation Status
|
|
||||||
|
|
||||||
| Feature | Status |
|
|
||||||
|---|---|
|
|
||||||
| Core definitions & data | ✅ Complete |
|
|
||||||
| Frame definitions & data | ✅ Complete |
|
|
||||||
| Mind Circuit definitions & data | ✅ Complete |
|
|
||||||
| Enchantment system for golems | ✅ Complete |
|
|
||||||
| Golem design builder UI | ✅ Complete |
|
|
||||||
| Golem loadout with designs | ✅ Complete |
|
|
||||||
| Golem mana pool & regen | ✅ Complete |
|
|
||||||
| Spell casting from golem mana | ✅ Complete |
|
|
||||||
| Guardian Core + Guardian Constructs | ✅ Complete (data + runtime) |
|
|
||||||
| Summoning on room entry (new system) | ✅ Complete |
|
|
||||||
| Maintenance cost (player upkeep) | ✅ Complete |
|
|
||||||
| Room duration tracking | ✅ Complete |
|
|
||||||
| Golem combat (new system) | ✅ Complete |
|
|
||||||
| Legacy system cleanup (orphaned types/actions/files) | ✅ Complete |
|
|
||||||
| Discipline bonus integration (golemCapacity) | ✅ Complete |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. Acceptance Criteria
|
|
||||||
|
|
||||||
| # | Criterion |
|
|
||||||
|---|---|
|
|
||||||
| AC-1 | Player can design golems by selecting Core + Frame + Mind Circuit + Enchantments |
|
|
||||||
| AC-2 | Core determines mana types, capacity, regen, duration, and upkeep cost |
|
|
||||||
| AC-3 | Frame determines damage, speed, armor pierce, magic affinity, and special |
|
|
||||||
| AC-4 | Mind Circuit determines spell behavior (0, 1, 2 alternating, or cycle all) |
|
|
||||||
| AC-5 | Enchantments add sword effects to basic attacks, consume capacity |
|
|
||||||
| AC-6 | Golem slots = `floor(fabricatorLevel / 2)` + discipline bonus (max 7) |
|
|
||||||
| AC-7 | Golems summoned on room entry if player can afford total summon cost |
|
|
||||||
| AC-8 | Each golem has own mana pool; regens at Core rate; spells consume golem mana |
|
|
||||||
| AC-9 | Spell damage scaled by Frame's Magic Affinity |
|
|
||||||
| AC-10 | Player upkeep = Core.manaRegen × 2 per hour; deducted from player mana |
|
|
||||||
| AC-11 | Golems dismissed if upkeep unpaid; not re-summoned mid-room |
|
|
||||||
| AC-12 | Room duration ticks down on room clear; golems fade after maxRoomDuration |
|
|
||||||
| AC-13 | Guardian Constructs require Guardian Core + Crystal-Steel Frame + Guardian Circuit |
|
|
||||||
| AC-14 | Guardian Constructs: one spell per mana type, cycled |
|
|
||||||
| AC-15 | Component unlocks gated by attunement levels per §16.3 |
|
|
||||||
| AC-16 | Loadout configured outside spire, persists across rooms, resets per run |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 19. Files Reference
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|---|---|
|
|
||||||
| `src/lib/game/data/golems/cores.ts` | Core definitions (to be created) |
|
|
||||||
| `src/lib/game/data/golems/frames.ts` | Frame definitions (to be created) |
|
|
||||||
| `src/lib/game/data/golems/mindCircuits.ts` | Mind Circuit definitions (to be created) |
|
|
||||||
| `src/lib/game/data/golems/golemEnchantments.ts` | Golem enchantment definitions (to be created) |
|
|
||||||
| `src/lib/game/data/golems/types.ts` | TypeScript interfaces for component system |
|
|
||||||
| `src/lib/game/data/golems/index.ts` | Barrel exports |
|
|
||||||
| `src/lib/game/data/disciplines/fabricator.ts` | Golem Crafting discipline (update perks) |
|
|
||||||
| `src/lib/game/stores/golem-combat-actions.ts` | Golem combat actions (rewrite) |
|
|
||||||
| `src/lib/game/stores/pipelines/golem-combat.ts` | Golem combat pipeline (rewrite) |
|
|
||||||
| `src/components/game/tabs/GolemancyTab.tsx` | Golemancy UI (major rewrite — design builder) |
|
|
||||||
| `docs/specs/spire-combat-spec.md §9` | Authoritative runtime spec |
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
# Item Fabrication System — Design Spec
|
|
||||||
|
|
||||||
> Describes the Fabricator attunement's crafting system: recipe categories, unlock
|
|
||||||
> gates, material costs, crafting flow, and how fabricated items differ from base loot.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Objective
|
|
||||||
|
|
||||||
Item Fabrication is the Fabricator attunement's non-combat crafting system. It allows
|
|
||||||
the player to craft materials and equipment using mana and component items. Recipes
|
|
||||||
are unlocked through Fabricator discipline perks, and the resulting equipment can
|
|
||||||
carry pre-applied enchantments, making fabrication a parallel path to the Enchanter's
|
|
||||||
enchanting system.
|
|
||||||
|
|
||||||
**Design goals:**
|
|
||||||
- Fabricated equipment provides an alternative to loot drops
|
|
||||||
- Material crafting creates a multi-tier resource pipeline
|
|
||||||
- Discipline-gated recipe unlocks reward Fabricator attunement investment
|
|
||||||
- Pre-applied enchantments on crafted gear offer unique combinations
|
|
||||||
- Crafting Efficiency discipline reduces material costs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Recipe Categories
|
|
||||||
|
|
||||||
### 2.1 Overview
|
|
||||||
|
|
||||||
| Category | File | Count | Unlock Gate |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Material Recipes | `fabricator-material-recipes.ts` | 15 | None (base recipes) |
|
|
||||||
| Core Equipment (Elemental) | `fabricator-recipes.ts` | 12 | Study Fabricator Recipes discipline |
|
|
||||||
| Wizard Branch | `fabricator-wizard-recipes.ts` | 14 | Study Wizard Equipment discipline |
|
|
||||||
| Physical Branch | `fabricator-physical-recipes.ts` | 7 | Study Physical Equipment discipline |
|
|
||||||
| **Total** | | **48** | |
|
|
||||||
|
|
||||||
### 2.2 Recipe Type Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface FabricatorRecipe {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
manaType: string; // Mana type required (must be unlocked)
|
|
||||||
equipmentTypeId: string; // Equipment type ID produced
|
|
||||||
slot: EquipmentSlot; // Slot the equipment occupies
|
|
||||||
materials: Record<string, number>; // materialId -> count required
|
|
||||||
manaCost: number; // Mana cost in the recipe's mana type
|
|
||||||
craftTime: number; // Craft time in hours
|
|
||||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
|
||||||
gearTrait: string; // Flavor text for gear properties
|
|
||||||
bonusEnchantments?: AppliedEnchantment[]; // Pre-applied enchantments
|
|
||||||
recipeType?: 'equipment' | 'material';
|
|
||||||
resultMaterial?: string; // For material recipes: material ID produced
|
|
||||||
resultAmount?: number; // For material recipes: how many are produced
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Material Recipes
|
|
||||||
|
|
||||||
### 3.1 Tier 1: Basic Materials
|
|
||||||
|
|
||||||
| ID | Name | Mana Type | Mana Cost | Input | Output | Time |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| `manaCrystal` | Mana Crystal | raw | 500 | — | 1× manaCrystal | 1h |
|
|
||||||
| `manaCrystalDustCraft` | Mana Crystal Dust | raw | 10 | 1× manaCrystal | 2× manaCrystalDust | 1h |
|
|
||||||
|
|
||||||
### 3.2 Tier 2: Elemental Crystals
|
|
||||||
|
|
||||||
All cost 100 of the respective element mana, take 1 hour, produce 1 crystal.
|
|
||||||
|
|
||||||
| ID | Mana Type | Element |
|
|
||||||
|---|---|---|
|
|
||||||
| `fireCrystal` | fire | Fire |
|
|
||||||
| `waterCrystal` | water | Water |
|
|
||||||
| `airCrystal` | air | Air |
|
|
||||||
| `earthCrystal` | earth | Earth |
|
|
||||||
| `lightCrystal` | light | Light |
|
|
||||||
| `darkCrystal` | dark | Dark |
|
|
||||||
| `metalCrystal` | metal | Metal |
|
|
||||||
| `crystalCrystal` | crystal | Crystal |
|
|
||||||
|
|
||||||
### 3.3 Tier 3: Shards and Cores
|
|
||||||
|
|
||||||
| ID | Mana Type | Mana Cost | Input | Output | Time |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `earthShardCraft` | earth | 50 | 1× earthCrystal | 1× earthShard | 1h |
|
|
||||||
| `elementalCore` | raw | 100 | 10× manaCrystal | 1× elementalCore | 10h |
|
|
||||||
|
|
||||||
### 3.4 Tier 4: Advanced Materials
|
|
||||||
|
|
||||||
| ID | Mana Type | Mana Cost | Input | Output | Time |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `aetherWeave` | air | 500 | 3× airCrystal, 3× lightCrystal, 2× elementalCore | 1× aetherWeave | 12h |
|
|
||||||
| `voidCloth` | dark | 500 | 3× airCrystal, 3× darkCrystal, 2× voidEssence | 1× voidCloth | 12h |
|
|
||||||
| `liquidCrystalLattice` | crystal | 800 | 5× crystalCrystal, 3× elementalCore, 2× voidEssence, 1× celestialFragment | 1× liquidCrystalLattice | 20h |
|
|
||||||
|
|
||||||
### 3.5 Material Dependency Chain
|
|
||||||
|
|
||||||
```
|
|
||||||
Raw Mana (500) → Mana Crystal (1)
|
|
||||||
Mana Crystal (1) + Raw Mana (10) → Mana Crystal Dust (2)
|
|
||||||
Mana Crystal (1) + Element Mana (100) → Element Crystal (1) [per element]
|
|
||||||
Element Crystal (1) + Element Mana (50) → Element Shard (1) [earth only]
|
|
||||||
Mana Crystal (10) + Raw Mana (100) → Elemental Core (1) [10hr]
|
|
||||||
Air Crystal (3) + Light Crystal (3) + Elemental Core (2) → Aether Weave (1) [12hr]
|
|
||||||
Air Crystal (3) + Dark Crystal (3) + Void Essence (2) → Void Cloth (1) [12hr]
|
|
||||||
Crystal Crystal (5) + Elemental Core (3) + Void Essence (2) + Celestial Fragment (1) → Liquid Crystal Lattice (1) [20hr]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Equipment Recipes
|
|
||||||
|
|
||||||
### 4.1 Earth Gear (Unlock: Study Fabricator Recipes @ 50 XP)
|
|
||||||
|
|
||||||
| ID | Name | Slot | Mana Cost | Materials | Rarity | Time |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| `earthHelm` | Earthen Helm | head | 200 earth | 4× manaCrystalDust, 2× earthShard | uncommon | 3h |
|
|
||||||
| `earthChest` | Stoneguard Armor | body | 500 earth | 8× manaCrystalDust, 4× earthShard, 1× elementalCore | rare | 6h |
|
|
||||||
| `earthBoots` | Stonegreaves | feet | 150 earth | 3× manaCrystalDust, 1× earthShard | uncommon | 2h |
|
|
||||||
|
|
||||||
### 4.2 Metal Gear (Unlock: Study Fabricator Recipes @ 100 XP)
|
|
||||||
|
|
||||||
| ID | Name | Slot | Mana Cost | Materials | Rarity | Time |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| `metalBlade` | Metal Blade | mainHand | 400 metal | 6× manaCrystalDust, 3× metalShard, 2× elementalCore | rare | 5h |
|
|
||||||
| `metalShield` | Metal Spell Focus | offHand | 450 metal | 7× manaCrystalDust, 4× metalShard, 1× elementalCore | rare | 5h |
|
|
||||||
| `metalGloves` | Metalweave Gauntlets | hands | 250 metal | 4× manaCrystalDust, 2× metalShard | uncommon | 3h |
|
|
||||||
|
|
||||||
### 4.3 Sand Gear (Unlock: Study Fabricator Recipes @ 150 XP)
|
|
||||||
|
|
||||||
| ID | Name | Slot | Mana Cost | Materials | Rarity | Time |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| `sandBoots` | Sandstrider Boots | feet | 120 sand | 3× manaCrystalDust, 1× sandShard | uncommon | 2h |
|
|
||||||
| `sandGloves` | Sandweave Gloves | hands | 140 sand | 3× manaCrystalDust, 2× sandShard | uncommon | 2h |
|
|
||||||
| `sandVest` | Sandcloth Vest | body | 300 sand | 5× manaCrystalDust, 2× sandShard, 1× elementalCore | rare | 4h |
|
|
||||||
|
|
||||||
### 4.4 Crystal Gear (Unlock: Study Fabricator Recipes @ 200 XP)
|
|
||||||
|
|
||||||
| ID | Name | Slot | Mana Cost | Materials | Rarity | Time |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| `crystalWand` | Crystal Focus Wand | mainHand | 600 crystal | 10× manaCrystalDust, 5× crystalShard, 3× elementalCore | epic | 6h |
|
|
||||||
| `crystalRing` | Crystal Ring | accessory1 | 350 crystal | 5× manaCrystalDust, 3× crystalShard, 1× elementalCore | rare | 3h |
|
|
||||||
| `crystalAmulet` | Crystal Pendant | accessory2 | 400 crystal | 6× manaCrystalDust, 3× crystalShard, 2× elementalCore | rare | 4h |
|
|
||||||
|
|
||||||
### 4.5 Wizard Branch (Unlock: Study Wizard Equipment discipline)
|
|
||||||
|
|
||||||
| ID | Name | Slot | Unlock (XP) | Mana Cost | Materials | Rarity | Time |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| `oakStaff` | Oak Staff | mainHand | 50 | 200 earth | 5× manaCrystalDust, 2× earthShard | uncommon | 3h |
|
|
||||||
| `arcanistStaff` | Arcanist Staff | mainHand | 100 | 700 crystal | 12× manaCrystalDust, 6× crystalShard, 3× elementalCore | epic | 8h |
|
|
||||||
| `battlestaff` | Battlestaff | mainHand | 150 | 500 metal | 8× manaCrystalDust, 4× metalShard, 2× elementalCore | rare | 6h |
|
|
||||||
| `arcanistCirclet` | Arcanist Circlet | head | 150 | 300 crystal | 6× manaCrystalDust, 2× crystalShard, 1× lightCrystal | rare | 4h |
|
|
||||||
| `arcanistRobe` | Arcanist Robe | body | 150 | 800 crystal | 14× manaCrystalDust, 7× crystalShard, 3× elementalCore | epic | 8h |
|
|
||||||
| `voidCatalyst` | Void Catalyst | mainHand | 200 | 600 crystal | 10× manaCrystalDust, 3× darkCrystal, 2× voidEssence, 2× elementalCore | epic | 7h |
|
|
||||||
| `arcanistPendant` | Arcanist Pendant | accessory1 | 250 | 500 crystal | 8× manaCrystalDust, 4× crystalShard, 2× elementalCore | epic | 5h |
|
|
||||||
|
|
||||||
**Advanced Wizard Gear:**
|
|
||||||
|
|
||||||
| ID | Name | Slot | Mana Cost | Materials | Rarity | Time |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| `aetherRobe` | Aetherweave Robe | body | 1200 crystal | 3× aetherWeave, 15× manaCrystalDust, 8× crystalShard, 4× elementalCore | legendary | 15h |
|
|
||||||
| `aetherCirclet` | Aetherweave Circlet | head | 900 crystal | 2× aetherWeave, 10× manaCrystalDust, 3× lightCrystal, 3× elementalCore | epic | 10h |
|
|
||||||
| `voidRobe` | Voidweave Robe | body | 1200 sand | 3× voidCloth, 15× manaCrystalDust, 8× crystalShard, 3× voidEssence | legendary | 15h |
|
|
||||||
| `voidCowl` | Voidweave Cowl | head | 900 sand | 2× voidCloth, 10× manaCrystalDust, 3× darkCrystal, 2× voidEssence | epic | 10h |
|
|
||||||
| `latticeStaff` | Crystal Lattice Staff | mainHand | 2000 crystal | 2× liquidCrystalLattice, 2× aetherWeave, 2× voidCloth, 5× elementalCore | legendary | 25h |
|
|
||||||
| `latticeAmulet` | Crystal Lattice Amulet | accessory1 | 1500 crystal | 1× liquidCrystalLattice, 5× crystalCrystal, 4× elementalCore, 2× voidEssence | legendary | 18h |
|
|
||||||
|
|
||||||
### 4.6 Physical Branch (Unlock: Study Physical Equipment discipline)
|
|
||||||
|
|
||||||
| ID | Name | Slot | Unlock (XP) | Mana Cost | Materials | Rarity | Time |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| `crystalBlade` | Crystal Blade | mainHand | 50 | 500 crystal | 8× manaCrystalDust, 4× crystalShard, 2× elementalCore | rare | 5h |
|
|
||||||
| `arcanistBlade` | Arcanist Blade | mainHand | 100 | 600 metal | 10× manaCrystalDust, 5× metalShard, 3× elementalCore | epic | 7h |
|
|
||||||
| `voidBlade` | Void-Touched Blade | mainHand | 150 | 550 crystal | 9× manaCrystalDust, 3× darkCrystal, 2× voidEssence, 2× elementalCore | epic | 6h |
|
|
||||||
| `battleHelm` | Battle Helm | head | 200 | 350 metal | 6× manaCrystalDust, 3× metalShard, 1× elementalCore | rare | 4h |
|
|
||||||
| `battleRobe` | Battle Robe | body | 200 | 400 sand | 8× manaCrystalDust, 3× sandShard, 2× elementalCore | rare | 5h |
|
|
||||||
| `battleBoots` | Battle Boots | feet | 250 | 180 sand | 4× manaCrystalDust, 2× sandShard | uncommon | 3h |
|
|
||||||
| `combatGauntlets` | Combat Gauntlets | hands | 300 | 300 metal | 5× manaCrystalDust, 2× metalShard, 1× elementalCore | uncommon | 3h |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Recipe Unlock Gates
|
|
||||||
|
|
||||||
### 5.1 Study Fabricator Recipes Discipline
|
|
||||||
|
|
||||||
| XP Threshold | Recipes Unlocked |
|
|
||||||
|---|---|
|
|
||||||
| 50 | Earth gear (helm, chest, boots) |
|
|
||||||
| 100 | Metal gear (blade, shield, gloves) |
|
|
||||||
| 150 | Sand gear (boots, gloves, vest) |
|
|
||||||
| 200 | Crystal gear (wand, ring, amulet) |
|
|
||||||
|
|
||||||
### 5.2 Study Wizard Equipment Discipline
|
|
||||||
|
|
||||||
| XP Threshold | Recipes Unlocked |
|
|
||||||
|---|---|
|
|
||||||
| 50 | Oak Staff |
|
|
||||||
| 100 | Arcanist Staff |
|
|
||||||
| 150 | Battlestaff, Arcanist Circlet, Arcanist Robe |
|
|
||||||
| 200 | Void Catalyst |
|
|
||||||
| 250 | Arcanist Pendant |
|
|
||||||
| 300 | (advanced recipes via material availability) |
|
|
||||||
|
|
||||||
### 5.3 Study Physical Equipment Discipline
|
|
||||||
|
|
||||||
| XP Threshold | Recipes Unlocked |
|
|
||||||
|---|---|
|
|
||||||
| 50 | Crystal Blade |
|
|
||||||
| 100 | Arcanist Blade |
|
|
||||||
| 150 | Void Blade |
|
|
||||||
| 200 | Battle Helm, Battle Robe |
|
|
||||||
| 250 | Battle Boots |
|
|
||||||
| 300 | Combat Gauntlets |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Crafting Flow
|
|
||||||
|
|
||||||
### 6.1 Pre-Craft Checks
|
|
||||||
|
|
||||||
```
|
|
||||||
checkFabricatorCosts(recipe, materials, rawMana, elements):
|
|
||||||
- Verify all material counts are sufficient
|
|
||||||
- Verify mana (raw or elemental) is sufficient
|
|
||||||
- Return { canCraft, missingMana, missingMaterials }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Crafting Execution
|
|
||||||
|
|
||||||
```
|
|
||||||
executeMaterialCraft(recipe, materials):
|
|
||||||
1. Deduct mana cost from raw or elemental pool
|
|
||||||
2. Deduct input materials from inventory
|
|
||||||
3. Add resultAmount of resultMaterial to inventory
|
|
||||||
|
|
||||||
makeFabricatorProgress(recipeId, equipmentTypeId, craftTime, manaCost):
|
|
||||||
1. Create EquipmentCraftingProgress object
|
|
||||||
2. blueprintId = "fabricator-{recipeId}"
|
|
||||||
3. Progress accumulates at HOURS_PER_TICK per tick
|
|
||||||
4. On completion: create equipment instance with bonusEnchantments
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 Cancellation Refund
|
|
||||||
|
|
||||||
```
|
|
||||||
remainingFraction = (required - progress) / required
|
|
||||||
refundRate = remainingFraction + (1 - remainingFraction) × 0.5
|
|
||||||
manaRefund = floor(manaSpent × refundRate)
|
|
||||||
materialRefund = floor(materialsSpent × 0.5)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Crafting Efficiency Discipline Interaction
|
|
||||||
|
|
||||||
The **Crafting Efficiency** discipline provides:
|
|
||||||
|
|
||||||
| Source | Effect |
|
|
||||||
|---|---|
|
|
||||||
| Base stat bonus | `craftingCostReduction` +15 |
|
|
||||||
| Perk `efficiency-1` (once @ 300 XP) | +10% Crafting Cost Reduction |
|
|
||||||
|
|
||||||
The `craftingCostReduction` stat reduces material costs for all fabrication recipes.
|
|
||||||
Applied as: `actualCost = baseCost × (1 - craftingCostReduction / 100)`.
|
|
||||||
|
|
||||||
At maximum: 15 (base) + 10 (perk) = **25% cost reduction**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. How Fabricated Items Differ from Base Loot
|
|
||||||
|
|
||||||
| Property | Loot Drops | Fabricated Items |
|
|
||||||
|---|---|---|
|
|
||||||
| **Source** | Enemy drops, treasure rooms | Crafting recipes |
|
|
||||||
| **Enchantments** | None (must be enchanted) | Pre-applied `bonusEnchantments` |
|
|
||||||
| **Rarity** | Random (common–legendary) | Fixed per recipe |
|
|
||||||
| **Quality** | Random (0–100) | Fixed per recipe |
|
|
||||||
| **Stats** | Base for type | Base for type + enchantment bonuses |
|
|
||||||
| **Control** | None (random) | Full (player chooses recipe) |
|
|
||||||
|
|
||||||
Fabricated items are created with `bonusEnchantments` — pre-applied enchantment
|
|
||||||
objects with `effectId`, `stacks`, and `actualCost`. These enchantments are
|
|
||||||
permanent and cannot be removed without the Enchanter's disenchant process.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Equipment Types Producible via Fabrication
|
|
||||||
|
|
||||||
| Slot | Equipment Types |
|
|
||||||
|---|---|
|
|
||||||
| mainHand | Metal Blade, Crystal Focus Wand, Oak Staff, Arcanist Staff, Battlestaff, Void Catalyst, Crystal Lattice Staff |
|
|
||||||
| offHand | Metal Spell Focus |
|
|
||||||
| head | Earthen Helm, Arcanist Circlet, Aetherweave Circlet, Voidweave Cowl, Battle Helm |
|
|
||||||
| body | Stoneguard Armor, Sandcloth Vest, Arcanist Robe, Aetherweave Robe, Voidweave Robe, Battle Robe |
|
|
||||||
| hands | Metalweave Gauntlets, Sandweave Gloves, Combat Gauntlets |
|
|
||||||
| feet | Stonegreaves, Sandstrider Boots, Battle Boots |
|
|
||||||
| accessory1 | Crystal Ring, Arcanist Pendant, Crystal Lattice Amulet |
|
|
||||||
| accessory2 | Crystal Pendant |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Rarity Distribution
|
|
||||||
|
|
||||||
### 10.1 Material Recipes (15)
|
|
||||||
|
|
||||||
| Rarity | Count | Examples |
|
|
||||||
|---|---|---|
|
|
||||||
| common | 2 | Mana Crystal Dust, Earth Shard |
|
|
||||||
| uncommon | 1 | Mana Crystal |
|
|
||||||
| rare | 7 | Fire/Water/Air/Earth/Light/Dark/Metal Attuned Crystal |
|
|
||||||
| epic | 4 | Crystal Attuned Crystal, Elemental Core, Aether Weave, Void Cloth |
|
|
||||||
| legendary | 1 | Liquid Crystal Lattice |
|
|
||||||
|
|
||||||
### 10.2 Equipment Recipes (33)
|
|
||||||
|
|
||||||
| Rarity | Count | Examples |
|
|
||||||
|---|---|---|
|
|
||||||
| uncommon | 8 | Earth Helm/Boots, Metal Gloves, Sand Boots/Gloves, Oak Staff, Battle Boots, Combat Gauntlets |
|
|
||||||
| rare | 11 | Earth Chest, Metal Blade/Shield, Crystal Ring/Amulet, Sand Vest, Crystal Blade, Battle Helm/Robe |
|
|
||||||
| epic | 9 | Crystal Wand, Arcanist Staff/Robe, Void Catalyst, Arcanist Pendant, Arcanist Blade, Void Blade, Aether Circlet, Void Cowl |
|
|
||||||
| legendary | 4 | Aether Robe, Void Robe, Lattice Staff, Lattice Amulet |
|
|
||||||
|
|
||||||
### 10.3 Combined Totals (48)
|
|
||||||
|
|
||||||
| Rarity | Count |
|
|
||||||
|---|---|
|
|
||||||
| common | 2 |
|
|
||||||
| uncommon | 9 |
|
|
||||||
| rare | 18 |
|
|
||||||
| epic | 13 |
|
|
||||||
| legendary | 5 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Acceptance Criteria
|
|
||||||
|
|
||||||
| # | Criterion |
|
|
||||||
|---|---|
|
|
||||||
| AC-1 | All 48 recipes are accessible when the Fabricator attunement is active. |
|
|
||||||
| AC-2 | Recipe unlock gates fire at the correct discipline XP thresholds. |
|
|
||||||
| AC-3 | Material crafting correctly consumes mana and input materials, producing the correct output. |
|
|
||||||
| AC-4 | Equipment crafting produces items with the correct pre-applied enchantments. |
|
|
||||||
| AC-5 | Crafting Efficiency discipline reduces material costs by the correct percentage. |
|
|
||||||
| AC-6 | Cancellation refunds mana at the blended rate (100% unspent, 50% spent) and materials at 50%. |
|
|
||||||
| AC-7 | Fabricated items cannot be crafted without the required mana type unlocked. |
|
|
||||||
| AC-8 | Material dependency chain is correct: Mana Crystal → Element Crystal → Elemental Core → Advanced Materials. |
|
|
||||||
| AC-9 | Craft time ranges from 1h (basic materials) to 25h (Crystal Lattice Staff). |
|
|
||||||
| AC-10 | Mana cost ranges from 10 (Mana Crystal Dust) to 2000 (Crystal Lattice Staff). |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Files Reference
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|---|---|
|
|
||||||
| `src/lib/game/data/fabricator-material-recipes.ts` | Material recipes (15) |
|
|
||||||
| `src/lib/game/data/fabricator-recipes.ts` | Core equipment recipes (12) |
|
|
||||||
| `src/lib/game/data/fabricator-wizard-recipes.ts` | Wizard branch recipes (14) |
|
|
||||||
| `src/lib/game/data/fabricator-physical-recipes.ts` | Physical branch recipes (7) |
|
|
||||||
| `src/lib/game/data/fabricator-recipe-types.ts` | Recipe type definitions |
|
|
||||||
| `src/lib/game/crafting-fabricator.ts` | Fabrication crafting logic |
|
|
||||||
| `src/lib/game/data/disciplines/fabricator.ts` | Fabricator disciplines (5) |
|
|
||||||
| `src/components/game/tabs/CraftingTab.tsx` | Crafting tab wrapper |
|
|
||||||
| `src/components/game/tabs/CraftingTab/FabricatorSubTab.tsx` | Fabricator crafting UI |
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
# Invoker Attunement — Design Spec
|
|
||||||
|
|
||||||
> Describes the Invoker attunement: identity, unlock flow, mana behavior, full
|
|
||||||
> discipline list with stats/perks, systems unlocked, pact interactions, and
|
|
||||||
> attunement level interactions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Objective
|
|
||||||
|
|
||||||
The Invoker is the pact-focused attunement that transforms Guardian defeats into
|
|
||||||
permanent power. Unlike the other attunements, the Invoker has no primary mana type
|
|
||||||
and no automatic mana conversion — it gains elemental mana exclusively by signing
|
|
||||||
pacts with Guardians. Its disciplines amplify pact power, boon effectiveness, and
|
|
||||||
guardian-related multipliers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Identity
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|---|---|
|
|
||||||
| **ID** | `invoker` |
|
|
||||||
| **Slot** | `chest` |
|
|
||||||
| **Icon** | `💜` |
|
|
||||||
| **Color** | `#9B59B6` (Purple) |
|
|
||||||
| **Primary Mana** | None (gains elemental mana from pacts) |
|
|
||||||
| **Raw Mana Regen** | +0.3/hour (base, scales with `1.5^(level-1)`) |
|
|
||||||
| **Conversion Rate** | None (0 at all levels) |
|
|
||||||
| **Unlock** | Defeat first Guardian |
|
|
||||||
| **Capabilities** | `['pacts', 'guardianPowers', 'elementalMastery']` |
|
|
||||||
| **Skill Categories** | `['invocation', 'pact']` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Unlock Condition and Flow
|
|
||||||
|
|
||||||
**Condition:** Defeat the first Guardian (floor 10).
|
|
||||||
|
|
||||||
**Unlock flow:**
|
|
||||||
1. Defeat the floor 10 Guardian (Ignis Prime)
|
|
||||||
2. Invoker becomes available for activation
|
|
||||||
3. Player activates Invoker → initialized at `{ active: true, level: 1, experience: 0 }`
|
|
||||||
4. Invoker disciplines become available: `pact-attunement`, `guardians-boon`
|
|
||||||
|
|
||||||
The unlock condition is stored as a descriptive string:
|
|
||||||
`"Defeat your first guardian and choose the path of the Invoker"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Raw Mana Regen Contribution
|
|
||||||
|
|
||||||
Base regen: **+0.3/hour** (at level 1). Scales exponentially:
|
|
||||||
|
|
||||||
```
|
|
||||||
effectiveRegen = 0.3 × 1.5^(level - 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Level | Raw Regen |
|
|
||||||
|---|---|
|
|
||||||
| 1 | 0.300/hr |
|
|
||||||
| 5 | 1.519/hr |
|
|
||||||
| 10 | 11.533/hr |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Mana Gain from Pacts (No Conversion)
|
|
||||||
|
|
||||||
The Invoker has **no automatic mana conversion**. Instead, it gains elemental mana
|
|
||||||
types exclusively through Guardian pacts:
|
|
||||||
|
|
||||||
When a pact is signed (`completePactRitual`):
|
|
||||||
```typescript
|
|
||||||
for (const manaType of guardian.unlocksMana || []) {
|
|
||||||
manaStore.unlockElement(manaType, 0);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Each guardian's `unlocksMana` is resolved via `resolveMultiUnlockChain(element)`,
|
|
||||||
which walks the element recipe tree to unlock the guardian's element and all base
|
|
||||||
components:
|
|
||||||
|
|
||||||
| Guardian | Element | Unlocks Mana Types |
|
|
||||||
|---|---|---|
|
|
||||||
| Floor 10 (Ignis Prime) | fire | `fire` |
|
|
||||||
| Floor 20 (Aqua Regia) | water | `water` |
|
|
||||||
| Floor 40 (Terra Firma) | earth | `earth` |
|
|
||||||
| Floor 90 (Metal) | metal | `fire`, `earth`, `metal` |
|
|
||||||
| Floor 130 (BlackFlame) | blackflame | `fire`, `earth`, `metal` |
|
|
||||||
| Floor 150 (Lightning) | lightning | `fire`, `air`, `lightning` |
|
|
||||||
|
|
||||||
Signing pacts is the **only** way for the Invoker to access elemental mana for
|
|
||||||
casting elemental spells and running elemental disciplines.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Disciplines
|
|
||||||
|
|
||||||
The Invoker's discipline pool contains **2 disciplines**.
|
|
||||||
|
|
||||||
### 6.1 Pact Attunement (`pact-attunement`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `raw` |
|
|
||||||
| **Base Cost** | 12 |
|
|
||||||
| **Requires** | `['signed_pact']` |
|
|
||||||
| **Stat Bonus** | `pactAffinityBonus` +0.05 (base) |
|
|
||||||
| **Scaling Factor** | 80 |
|
|
||||||
| **Difficulty Factor** | 150 |
|
|
||||||
| **Drain Base** | 4 |
|
|
||||||
|
|
||||||
**Perks:**
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Bonus |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `pact-affinity-scaling` | `once` | 100 | Unlock pact affinity scaling |
|
|
||||||
| `pact-affinity-infinite` | `infinite` | 200 | Every 100 XP: `pactAffinityBonus` +0.05 |
|
|
||||||
| `pact-power-boost` | `capped` | 500 | Every 200 XP: `guardianBoonMultiplier` +0.03, max 5 tiers |
|
|
||||||
|
|
||||||
### 6.2 Guardian's Boon (`guardians-boon`)
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| **Mana Type** | `raw` |
|
|
||||||
| **Base Cost** | 18 |
|
|
||||||
| **Requires** | `['signed_pact']` |
|
|
||||||
| **Stat Bonus** | `guardianBoonMultiplier` +0.10 (base) |
|
|
||||||
| **Scaling Factor** | 100 |
|
|
||||||
| **Difficulty Factor** | 200 |
|
|
||||||
| **Drain Base** | 6 |
|
|
||||||
|
|
||||||
**Perks:**
|
|
||||||
|
|
||||||
| Perk ID | Type | Threshold | Bonus |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `boon-1` | `once` | 100 | `guardianBoonMultiplier` +0.10 |
|
|
||||||
| `boon-2` | `capped` | 200 | Every 350 XP: `guardianBoonMultiplier` +0.05, max 5 tiers |
|
|
||||||
|
|
||||||
### 6.3 Guardian Boon Multiplier Scaling
|
|
||||||
|
|
||||||
Maximum theoretical `guardianBoonMultiplier` from disciplines:
|
|
||||||
|
|
||||||
| Source | Value |
|
|
||||||
|---|---|
|
|
||||||
| Base (Guardian's Boon discipline) | +0.10 |
|
|
||||||
| `boon-1` perk (once @ 100 XP) | +0.10 |
|
|
||||||
| `boon-2` perk (capped, 5 tiers × 0.05) | +0.25 |
|
|
||||||
| `pact-power-boost` perk (capped, 5 tiers × 0.03) | +0.15 |
|
|
||||||
| **Maximum total** | **+0.60** |
|
|
||||||
|
|
||||||
With the base multiplier of 1.0, the maximum guardian boon multiplier is **1.60**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Systems Unlocked
|
|
||||||
|
|
||||||
The Invoker attunement gates the **Pact System** (see `pact-system-spec.md`):
|
|
||||||
|
|
||||||
- Sign pacts with defeated Guardians
|
|
||||||
- Gain permanent boons and elemental mana unlocks
|
|
||||||
- Pact slots limit simultaneous signed pacts
|
|
||||||
- Pact affinity reduces ritual time
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Puzzle Room Behavior
|
|
||||||
|
|
||||||
In the spire, every 7th floor has a puzzle room. When the room type is
|
|
||||||
`invoker_trial`, progress scales at 2.5–3% per tick per Invoker level.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Attunement Level Interactions
|
|
||||||
|
|
||||||
Higher Invoker level affects:
|
|
||||||
|
|
||||||
1. **Raw mana regen**: `0.3 × 1.5^(level-1)` per hour
|
|
||||||
2. **No conversion**: Invoker never has automatic mana conversion
|
|
||||||
3. **Pact affinity**: Higher raw regen supports the raw mana cost of pact rituals
|
|
||||||
|
|
||||||
Attunement level does **not** directly affect pact multipliers or boon power —
|
|
||||||
those scale through discipline XP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Known Code Issues
|
|
||||||
|
|
||||||
The following inconsistencies exist in the codebase:
|
|
||||||
|
|
||||||
| Issue | Description |
|
|
||||||
|---|---|
|
|
||||||
| `pactBinding` upgrade | ✅ **RESOLVED** — Added to `PRESTIGE_DEF` in `prestige.ts` |
|
|
||||||
| UI vs store mismatch | ✅ **RESOLVED** — `pactBinding` is now the canonical ID used everywhere |
|
|
||||||
| Pact persistence | ✅ **RESOLVED BY DESIGN** — Pacts intentionally do NOT persist through prestige (reset each loop). This is the correct behavior per design intent. |
|
|
||||||
| `pactInterferenceMitigation` | ✅ **RESOLVED** — Added to `PRESTIGE_DEF` in `prestige.ts`; `useGameDerived.ts` now passes it from prestige store |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Acceptance Criteria
|
|
||||||
|
|
||||||
| # | Criterion |
|
|
||||||
|---|---|
|
|
||||||
| AC-1 | Invoker is locked until the first Guardian is defeated. |
|
|
||||||
| AC-2 | Invoker has no primary mana type and no automatic conversion at any level. |
|
|
||||||
| AC-3 | Signing a pact unlocks the guardian's element and all component elements. |
|
|
||||||
| AC-4 | Both Invoker disciplines require at least one signed pact to activate. |
|
|
||||||
| AC-5 | `pact-affinity-infinite` perk grants +0.05 pactAffinityBonus every 100 XP beyond threshold 200. |
|
|
||||||
| AC-6 | `boon-2` capped perk grants +0.05 guardianBoonMultiplier per tier, max 5 tiers, interval 350 XP. |
|
|
||||||
| AC-7 | `pact-power-boost` capped perk grants +0.03 guardianBoonMultiplier per tier, max 5 tiers, interval 200 XP. |
|
|
||||||
| AC-8 | Maximum theoretical guardianBoonMultiplier from disciplines is 1.60 (base 1.0 + 0.60). |
|
|
||||||
| AC-9 | Invoker `invoker_trial` puzzle rooms grant bonus progress per Invoker level. |
|
|
||||||
| AC-10 | Invoker level scales raw regen by `1.5^(level-1)`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Files Reference
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|---|---|
|
|
||||||
| `src/lib/game/data/attunements.ts` | Invoker definition |
|
|
||||||
| `src/lib/game/data/disciplines/invoker.ts` | Invoker disciplines (2) |
|
|
||||||
| `src/lib/game/stores/prestigeStore.ts` | Pact ritual state, slot management |
|
|
||||||
| `src/lib/game/stores/pipelines/pact-ritual.ts` | Pact ritual tick processing |
|
|
||||||
| `src/lib/game/utils/pact-utils.ts` | Pact multiplier calculations |
|
|
||||||
| `src/lib/game/data/guardian-data.ts` | Static guardian definitions |
|
|
||||||
| `src/lib/game/data/guardian-encounters.ts` | Procedural guardian lookup |
|
|
||||||
| `src/components/game/tabs/GuardianPactsTab.tsx` | Pact signing UI |
|
|
||||||
| `docs/specs/attunements/invoker/systems/pact-system-spec.md` | Pact system spec |
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
# Pact System — Design Spec
|
|
||||||
|
|
||||||
> Describes the Guardian pact system: ritual flow, boon types, pact slot system,
|
|
||||||
> pact persistence, discipline scaling, and how the Invoker gains elemental mana.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Objective
|
|
||||||
|
|
||||||
The Pact system is the Invoker attunement's core progression mechanic. After defeating
|
|
||||||
a Guardian boss on every 10th floor, the player can sign a pact through a ritual
|
|
||||||
process. Each signed pact grants permanent boons (stat multipliers) and unlocks
|
|
||||||
elemental mana types. Pact slots limit how many pacts can be active simultaneously,
|
|
||||||
and the Invoker's disciplines amplify pact power.
|
|
||||||
|
|
||||||
**Design goals:**
|
|
||||||
- Pacts are earned through combat achievement (defeating Guardians)
|
|
||||||
- Ritual time creates a meaningful time investment
|
|
||||||
- Multiple pacts provide multiplicative power but with interference penalties
|
|
||||||
- Boon variety ensures each pact feels distinct
|
|
||||||
- Pact affinity (from disciplines) reduces ritual time
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Pact Ritual Flow
|
|
||||||
|
|
||||||
### 2.1 Step 1: Defeat the Guardian
|
|
||||||
|
|
||||||
- Every 10th floor (10, 20, 30, ...) has a Guardian boss room
|
|
||||||
- Defeating the Guardian adds the floor number to `defeatedGuardians[]`
|
|
||||||
- Only defeated Guardians are eligible for pact signing
|
|
||||||
|
|
||||||
### 2.2 Step 2: Start Ritual
|
|
||||||
|
|
||||||
```
|
|
||||||
startPactRitual(floor):
|
|
||||||
1. Validate guardian exists at floor
|
|
||||||
2. Check floor is in defeatedGuardians
|
|
||||||
3. Check floor is NOT already in signedPacts
|
|
||||||
4. Check signedPacts.length < pactSlots (slot available)
|
|
||||||
5. Check rawMana >= guardian.pactCost (enough raw mana)
|
|
||||||
6. Check pactRitualFloor === null (no other ritual in progress)
|
|
||||||
7. Deduct guardian.pactCost raw mana
|
|
||||||
8. Set pactRitualFloor = floor, pactRitualProgress = 0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 Step 3: Progress Ritual
|
|
||||||
|
|
||||||
Each game tick:
|
|
||||||
|
|
||||||
```
|
|
||||||
processPactRitual():
|
|
||||||
pactAffinity = min(0.9, pactAffinityUpgrade × 0.1 + pactAffinityBonus)
|
|
||||||
requiredTime = guardian.pactTime × (1 - pactAffinity)
|
|
||||||
pactRitualProgress += HOURS_PER_TICK
|
|
||||||
if pactRitualProgress >= requiredTime → completePactRitual()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pact affinity sources:**
|
|
||||||
- `pactAffinityUpgrade`: prestige upgrade level (each level = +0.1, capped at 0.9)
|
|
||||||
- `pactAffinityBonus`: discipline bonus from Pact Attunement discipline
|
|
||||||
|
|
||||||
### 2.4 Step 4: Pact Signed
|
|
||||||
|
|
||||||
```
|
|
||||||
completePactRitual():
|
|
||||||
1. Add floor to signedPacts[]
|
|
||||||
2. Remove floor from defeatedGuardians[]
|
|
||||||
3. Reset pactRitualFloor = null, pactRitualProgress = 0
|
|
||||||
4. For each manaType in guardian.unlocksMana:
|
|
||||||
manaStore.unlockElement(manaType, 0)
|
|
||||||
5. Log: "📜 Pact signed with {name}! You have gained their boons."
|
|
||||||
6. Log: "✨ {ManaType} mana unlocked!" for each new element
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.5 Cancellation
|
|
||||||
|
|
||||||
`cancelPactRitual()` resets `pactRitualFloor = null`, `pactRitualProgress = 0`.
|
|
||||||
The raw mana cost is **not** refunded on cancellation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Guardian Boon Types
|
|
||||||
|
|
||||||
Each Guardian grants **2 boons** from the following pool of 12 types:
|
|
||||||
|
|
||||||
| Boon Type | Effect |
|
|
||||||
|---|---|
|
|
||||||
| `maxMana` | Flat max raw mana bonus |
|
|
||||||
| `manaRegen` | Flat mana regen per hour bonus |
|
|
||||||
| `castingSpeed` | Spell cast speed multiplier |
|
|
||||||
| `elementalDamage` | Elemental damage multiplier |
|
|
||||||
| `rawDamage` | Raw damage multiplier |
|
|
||||||
| `critChance` | Critical hit chance bonus |
|
|
||||||
| `critDamage` | Critical hit damage multiplier |
|
|
||||||
| `spellEfficiency` | Spell efficiency bonus |
|
|
||||||
| `manaGain` | Mana gain multiplier |
|
|
||||||
| `insightGain` | Insight gain multiplier |
|
|
||||||
| `studySpeed` | Study speed multiplier |
|
|
||||||
| `prestigeInsight` | Prestige insight bonus |
|
|
||||||
|
|
||||||
### 3.1 Boon Application
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
for (const floor of signedPacts) {
|
|
||||||
const guardian = getGuardianForFloor(floor);
|
|
||||||
for (const boon of guardian.boons) {
|
|
||||||
let value = boon.value × guardianBoonMultiplier;
|
|
||||||
// Apply to corresponding bonus stat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `guardianBoonMultiplier` starts at 1.0 and is increased by the Guardian's Boon
|
|
||||||
discipline and its perks (see §6).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Pact Slot System
|
|
||||||
|
|
||||||
### 4.1 Starting Value
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
pactSlots: 1 // in prestigeStore initial state
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Upgrading
|
|
||||||
|
|
||||||
The `pactBinding` prestige upgrade adds +1 slot per level:
|
|
||||||
```typescript
|
|
||||||
pactSlots: id === 'pactBinding' ? state.pactSlots + 1 : state.pactSlots
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** The `pactBinding` upgrade is defined in `PRESTIGE_DEF` constants
|
|
||||||
> (`prestige.ts`) with `max: 5` and `cost: 2000`. It is fully functional in both
|
|
||||||
> store logic and UI.
|
|
||||||
|
|
||||||
### 4.3 Slot Enforcement
|
|
||||||
|
|
||||||
A new pact ritual cannot be started if `signedPacts.length >= pactSlots`. The player
|
|
||||||
must choose which pacts to maintain.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Pact Persistence Through Prestige
|
|
||||||
|
|
||||||
### 5.1 What Persists
|
|
||||||
|
|
||||||
| Field | Persisted | Reset on New Loop |
|
|
||||||
|---|---|---|
|
|
||||||
| `signedPacts` | Yes (via Zustand persist) | **Yes** (reset to `[]`) |
|
|
||||||
| `signedPactDetails` | Yes | No |
|
|
||||||
| `pactSlots` | Yes | No |
|
|
||||||
| `pactRitualFloor` | Yes | Yes (reset to `null`) |
|
|
||||||
| `pactRitualProgress` | Yes | Yes (reset to `0`) |
|
|
||||||
| `defeatedGuardians` | No | Yes (reset to `[]`) |
|
|
||||||
|
|
||||||
### 5.2 Current Behavior
|
|
||||||
|
|
||||||
In the current implementation, `signedPacts` is reset to `[]` on `startNewLoop`,
|
|
||||||
meaning **pacts do NOT persist through prestige loops**. The player must re-defeat
|
|
||||||
Guardians and re-sign pacts each loop. The `signedPactDetails` record persists
|
|
||||||
for historical tracking but does not confer active boons.
|
|
||||||
|
|
||||||
> **Note:** AGENTS.md states "Signed pacts do NOT persist through prestige (reset
|
|
||||||
> each loop)." The current code correctly resets `signedPacts` to `[]` on
|
|
||||||
> `startNewLoop`, matching the documented behavior. There is no discrepancy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Invoker Discipline Scaling of Pact Power
|
|
||||||
|
|
||||||
### 6.1 Pact Affinity (Ritual Time Reduction)
|
|
||||||
|
|
||||||
From the **Pact Attunement** discipline:
|
|
||||||
|
|
||||||
```
|
|
||||||
pactAffinity = min(0.9, pactAffinityUpgrade × 0.1 + pactAffinityBonus)
|
|
||||||
requiredTime = guardian.pactTime × (1 - pactAffinity)
|
|
||||||
```
|
|
||||||
|
|
||||||
| pactAffinity | Time Reduction |
|
|
||||||
|---|---|
|
|
||||||
| 0.0 | 0% (full time) |
|
|
||||||
| 0.3 | 30% faster |
|
|
||||||
| 0.5 | 50% faster |
|
|
||||||
| 0.9 | 90% faster (cap) |
|
|
||||||
|
|
||||||
The `pactAffinityBonus` starts at +0.05 (base from discipline) and gains +0.05
|
|
||||||
every 100 XP from the `pact-affinity-infinite` perk (threshold 200).
|
|
||||||
|
|
||||||
### 6.2 Guardian Boon Multiplier (Boon Power)
|
|
||||||
|
|
||||||
From the **Guardian's Boon** discipline and cross-perks:
|
|
||||||
|
|
||||||
| Source | guardianBoonMultiplier Bonus |
|
|
||||||
|---|---|
|
|
||||||
| Guardian's Boon discipline (base) | +0.10 |
|
|
||||||
| `boon-1` perk (once @ 100 XP) | +0.10 |
|
|
||||||
| `boon-2` perk (capped, 5 tiers) | up to +0.25 |
|
|
||||||
| `pact-power-boost` perk (capped, 5 tiers) | up to +0.15 |
|
|
||||||
| **Maximum total** | **+0.60** (multiplier = 1.60) |
|
|
||||||
|
|
||||||
### 6.3 Pact Multiplier (Damage and Insight)
|
|
||||||
|
|
||||||
From `pact-utils.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
computePactMultiplier(signedPacts, pactInterferenceMitigation):
|
|
||||||
baseMult = Π guardian.damageMultiplier for each signed pact
|
|
||||||
|
|
||||||
if only 1 pact: return baseMult
|
|
||||||
|
|
||||||
numAdditional = signedPacts.length - 1
|
|
||||||
basePenalty = 0.5 × numAdditional
|
|
||||||
mitigationReduction = min(pactInterferenceMitigation, 5) × 0.1
|
|
||||||
effectivePenalty = max(0, basePenalty - mitigationReduction)
|
|
||||||
|
|
||||||
if pactInterferenceMitigation >= 5:
|
|
||||||
synergyBonus = (pactInterferenceMitigation - 5) × 0.1
|
|
||||||
return baseMult × (1 + synergyBonus)
|
|
||||||
|
|
||||||
return baseMult × (1 - effectivePenalty)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example (2 pacts, floors 10+20):**
|
|
||||||
- Floor 10 damage multiplier: `1.0 + 10 × 0.01 = 1.10`
|
|
||||||
- Floor 20 damage multiplier: `1.0 + 20 × 0.01 = 1.20`
|
|
||||||
- `baseMult = 1.10 × 1.20 = 1.32`
|
|
||||||
- With 0 mitigation: `1.32 × (1 - 0.5) = 0.66`
|
|
||||||
- With 3 mitigation: `1.32 × (1 - 0.2) = 1.056`
|
|
||||||
- With 5 mitigation: `1.32 × 1 = 1.32`
|
|
||||||
- With 7 mitigation: `1.32 × 1.2 = 1.584`
|
|
||||||
|
|
||||||
The same formula applies to `computePactInsightMultiplier` using
|
|
||||||
`guardian.insightMultiplier` (`1.0 + floor × 0.005`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Invoker's Mana Gain from Pacts
|
|
||||||
|
|
||||||
### 7.1 Elemental Unlocks
|
|
||||||
|
|
||||||
The Invoker gains elemental mana types exclusively through pact signing. Each
|
|
||||||
guardian's `unlocksMana` is derived from `resolveMultiUnlockChain(element)`:
|
|
||||||
|
|
||||||
| Guardian Floor | Element | Mana Types Unlocked |
|
|
||||||
|---|---|---|
|
|
||||||
| 10 | fire | `fire` |
|
|
||||||
| 20 | water | `water` |
|
|
||||||
| 30 | air | `air` |
|
|
||||||
| 40 | earth | `earth` |
|
|
||||||
| 50 | light | `light` |
|
|
||||||
| 60 | dark | `dark` |
|
|
||||||
| 70 | death | `death` |
|
|
||||||
| 80 | transference | `transference` |
|
|
||||||
| 90 | metal | `fire`, `earth`, `metal` |
|
|
||||||
| 100 | sand | `earth`, `water`, `sand` |
|
|
||||||
| 110 | lightning | `fire`, `air`, `lightning` |
|
|
||||||
| 120 | frost | `air`, `water`, `frost` |
|
|
||||||
| 130 | blackflame | `fire`, `earth`, `metal` |
|
|
||||||
| 140 | radiantflames | `light`, `fire`, `radiantflames` |
|
|
||||||
| 150 | miasma | `air`, `death`, `miasma` |
|
|
||||||
| 160 | shadowglass | `earth`, `dark` |
|
|
||||||
| 170+ | exotic | varies (see guardian-data.ts) |
|
|
||||||
|
|
||||||
### 7.2 No Automatic Conversion
|
|
||||||
|
|
||||||
The Invoker has `conversionRate = 0`. It does **not** automatically convert raw
|
|
||||||
mana to any elemental type. All elemental mana must come from:
|
|
||||||
1. Pact unlocks (elemental types become available)
|
|
||||||
2. Elemental regen disciplines (once the element type is unlocked)
|
|
||||||
3. Equipment with mana regen enchantments
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Guardian Data Summary
|
|
||||||
|
|
||||||
### 8.1 Tier 1 — Base Elements (Floors 10–80)
|
|
||||||
|
|
||||||
| Floor | Name | Element | Armor | Pact Cost | Pact Time | Boons |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| 10 | Ignis Prime | fire | 10% | hp×0.3+power×5+... | 3h | +5% Fire dmg, +50 max mana |
|
|
||||||
| 20 | Aqua Regia | water | 15% | same formula | 4h | +5% Water dmg, +0.5 mana regen |
|
|
||||||
| 30 | Ventus Rex | air | 18% | same formula | 5h | +5% Air dmg, +5% casting speed |
|
|
||||||
| 40 | Terra Firma | earth | 25% | same formula | 6h | +5% Earth dmg, +100 max mana |
|
|
||||||
| 50 | Lux Aeterna | light | 20% | same formula | 7h | +10% Light dmg, +10% insight gain |
|
|
||||||
| 60 | Umbra Mortis | dark | 22% | same formula | 8h | +10% Dark dmg, +15% crit damage |
|
|
||||||
| 70 | Mors Ultima | death | 25% | same formula | 9h | +10% Death dmg, +10% raw damage |
|
|
||||||
| 80 | Vinculum Arcana | transference | 20% | same formula | 10h | +150 max mana, +1.0 mana regen |
|
|
||||||
|
|
||||||
### 8.2 Tier 2 — Composite Elements (Floors 90–160)
|
|
||||||
|
|
||||||
| Floor | Element | Armor | Pact Time |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 90 | metal | 30% | 11h |
|
|
||||||
| 100 | sand | 25% | 12h |
|
|
||||||
| 110 | lightning | 22% | 13h |
|
|
||||||
| 120 | frost | 28% | 14h |
|
|
||||||
| 130 | blackflame | 32% | 15h |
|
|
||||||
| 140 | light+fire+radiantflames | 25% | 16h |
|
|
||||||
| 150 | air+death+miasma | 28% | 17h |
|
|
||||||
| 160 | shadowglass | 33% | 18h |
|
|
||||||
|
|
||||||
### 8.3 Tier 3 — Exotic Elements (Floors 170–240)
|
|
||||||
|
|
||||||
| Floor | Element | Armor | Pact Time |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 170 | crystal | 35% | 19h |
|
|
||||||
| 180 | stellar | 30% | 20h |
|
|
||||||
| 190 | void | 35% | 21h |
|
|
||||||
| 200 | crystal+stellar+void | 35% | 22h |
|
|
||||||
| 210 | soul+time+plasma | 32% | 23h |
|
|
||||||
| 220 | plasma | 28% | 24h |
|
|
||||||
| 230 | crystal+stellar+void | 40% | 25h |
|
|
||||||
| 240 | soul+time+plasma | 42% | 26h |
|
|
||||||
|
|
||||||
### 8.4 Tier 4+ — Procedural (Floors 250+)
|
|
||||||
|
|
||||||
Every 10 floors, with scaling armor, pact multiplier, damage multiplier, and
|
|
||||||
insight multiplier. Dual-element combinations cycle through 9 pairings, then
|
|
||||||
scale through 8 tiers of increasing complexity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Acceptance Criteria
|
|
||||||
|
|
||||||
| # | Criterion |
|
|
||||||
|---|---|
|
|
||||||
| AC-1 | Pact ritual can only be started for defeated Guardians with an available pact slot and sufficient raw mana. |
|
|
||||||
| AC-2 | Ritual progress accumulates at `HOURS_PER_TICK` per tick; pact affinity reduces required time. |
|
|
||||||
| AC-3 | On completion, the floor is added to `signedPacts`, removed from `defeatedGuardians`, and mana types are unlocked. |
|
|
||||||
| AC-4 | Pact affinity is capped at 0.9 (90% time reduction). |
|
|
||||||
| AC-5 | Guardian boon multiplier from disciplines correctly increases boon values. |
|
|
||||||
| AC-6 | Pact multiplier formula applies interference penalties for multiple pacts, with mitigation reducing the penalty. |
|
|
||||||
| AC-7 | At 5+ mitigation, synergy bonus applies instead of penalty. |
|
|
||||||
| AC-8 | Starting pact slots = 1; each `pactBinding` upgrade adds +1 slot. |
|
|
||||||
| AC-9 | Invoker gains elemental mana types exclusively through pact signing. |
|
|
||||||
| AC-10 | Cancelling a ritual resets progress but does not refund the raw mana cost. |
|
|
||||||
| AC-11 | Both Invoker disciplines require at least one signed pact (`requires: ['signed_pact']`). |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Files Reference
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|---|---|
|
|
||||||
| `src/lib/game/stores/prestigeStore.ts` | Pact ritual state, slot management, start/complete/cancel |
|
|
||||||
| `src/lib/game/stores/pipelines/pact-ritual.ts` | Per-tick ritual processing |
|
|
||||||
| `src/lib/game/utils/pact-utils.ts` | Pact multiplier, insight multiplier, interference formulas |
|
|
||||||
| `src/lib/game/data/guardian-data.ts` | Static guardian definitions (floors 10–240) |
|
|
||||||
| `src/lib/game/data/guardian-encounters.ts` | Procedural guardian lookup (250+) |
|
|
||||||
| `src/lib/game/data/disciplines/invoker.ts` | Invoker disciplines (2) |
|
|
||||||
| `src/lib/game/utils/guardian-utils.ts` | Element unlock chain resolution |
|
|
||||||
| `src/components/game/tabs/GuardianPactsTab.tsx` | Pact signing UI |
|
|
||||||
| `src/components/game/tabs/guardian-pacts-components.tsx` | Pact UI sub-components |
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
# Mana Conversion System — Specification
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This spec defines a unified mana conversion system that replaces the current fragmented approach (attunement conversions, discipline conversions, manual conversion, and guardian pact conversions). All conversion types use the same core mechanics: consuming source mana types to produce a destination mana type, with costs deducted from **regen** (not from the mana pool directly).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Element Distance from Raw Mana
|
|
||||||
|
|
||||||
Every mana type has a **distance** from raw mana. This value is used in two places:
|
|
||||||
1. Calculating conversion cost ratios
|
|
||||||
2. Calculating meditation multiplier strength for that element's conversion
|
|
||||||
|
|
||||||
### Distance Table
|
|
||||||
|
|
||||||
| Element | Category | Distance |
|
|
||||||
|---------|----------|----------|
|
|
||||||
| Raw | — | 0 |
|
|
||||||
| Fire, Water, Air, Earth, Light, Dark, Death | Base | 1 |
|
|
||||||
| Transference | Utility | 1 |
|
|
||||||
| Metal, Sand, Lightning, Frost, BlackFlame, RadiantFlames, Miasma, ShadowGlass | Composite | 2 |
|
|
||||||
| Crystal, Stellar, Void, Soul, Plasma | Exotic (tier 1) | 3 |
|
|
||||||
| Time | Exotic (tier 2) | 4 |
|
|
||||||
|
|
||||||
### Reusable Function
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/lib/game/utils/element-distance.ts
|
|
||||||
export function getElementDistance(elementId: string): number
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns the distance for any element. If a composite element's recipe contains components at different distances, the element's distance = max(component distances) + 1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Conversion Cost Ratios
|
|
||||||
|
|
||||||
All conversions produce **1 unit** of destination mana. The cost depends on the destination's distance from raw.
|
|
||||||
|
|
||||||
### Cost Formula
|
|
||||||
|
|
||||||
For a destination element at distance `d`:
|
|
||||||
|
|
||||||
- **Raw mana cost** = `10^(d+1)`
|
|
||||||
- Distance 1 (base): `10^2 = 100` raw per 1 element
|
|
||||||
- Distance 2 (composite): `10^3 = 1,000` raw per 1 element
|
|
||||||
- Distance 3 (exotic): `10^4 = 10,000` raw per 1 element
|
|
||||||
- Distance 4 (time): `10^5 = 100,000` raw per 1 element
|
|
||||||
|
|
||||||
- **Each component mana cost** = `10 * (d + 1)` per 1 destination element
|
|
||||||
- Distance 1: `10 * 2 = 20` of that element per 1 destination
|
|
||||||
- Distance 2: `10 * 3 = 30` of that element per 1 destination
|
|
||||||
- Distance 3: `10 * 4 = 40` of that element per 1 destination
|
|
||||||
- Distance 4: `10 * 5 = 50` of that element per 1 destination
|
|
||||||
|
|
||||||
### Cost Table (per 1 unit of destination mana)
|
|
||||||
|
|
||||||
| Destination | Distance | Raw Cost | Each Component Cost | Components |
|
|
||||||
|-------------|----------|----------|---------------------|------------|
|
|
||||||
| Fire (base) | 1 | 100 | — | — |
|
|
||||||
| Transference | 1 | 100 | — | — |
|
|
||||||
| Metal | 2 | 1,000 | 30 fire + 30 earth | fire, earth |
|
|
||||||
| Sand | 2 | 1,000 | 30 earth + 30 water | earth, water |
|
|
||||||
| Lightning | 2 | 1,000 | 30 fire + 30 air | fire, air |
|
|
||||||
| Frost | 2 | 1,000 | 30 air + 30 water | air, water |
|
|
||||||
| BlackFlame | 2 | 1,000 | 30 dark + 30 fire | dark, fire |
|
|
||||||
| Radiant Flames | 2 | 1,000 | 30 light + 30 fire | light, fire |
|
|
||||||
| Miasma | 2 | 1,000 | 30 air + 30 death | air, death |
|
|
||||||
| Shadow Glass | 2 | 1,000 | 30 earth + 30 dark | earth, dark |
|
|
||||||
| Crystal | 3 | 10,000 | 40 sand + 40 light | sand, light |
|
|
||||||
| Stellar | 3 | 10,000 | 40 plasma + 40 light | plasma, light |
|
|
||||||
| Void | 3 | 10,000 | 40 dark + 40 death | dark, death |
|
|
||||||
| Soul | 3 | 10,000 | 40 light + 40 dark + 40 transference | light, dark, transference |
|
|
||||||
| Plasma | 3 | 10,000 | 40 lightning + 40 fire + 40 transference | lightning, fire, transference |
|
|
||||||
| Time | 4 | 100,000 | 50 soul + 50 sand + 50 transference | soul, sand, transference |
|
|
||||||
|
|
||||||
### Key Constraint
|
|
||||||
|
|
||||||
Raw mana cost is always **greater** than any individual component cost. This is inherent in the formula: `10^(d+1)` for raw vs `10*(d+1)` for each component.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Conversion Rate — Unified Formula
|
|
||||||
|
|
||||||
All three sources (disciplines, attunements, guardian pacts) contribute to a single **base conversion rate** for each element. This rate is then exponentially boosted by attunement levels and pact bonuses.
|
|
||||||
|
|
||||||
### Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
finalRate = (disciplineRate + attunementBaseRate + pactBaseRate) ^ (1 + attunementLevelBonus + pactLevelBonus)
|
|
||||||
```
|
|
||||||
|
|
||||||
Where:
|
|
||||||
- `disciplineRate` = sum of conversion rates from active disciplines for this element (see §4)
|
|
||||||
- `attunementBaseRate` = sum of base conversion rates from attunements for this element (see §5)
|
|
||||||
- `pactBaseRate` = sum of base conversion rates from guardian pacts for this element (see §6)
|
|
||||||
- `attunementLevelBonus` = sum of relevant attunement levels (e.g., Enchanter level for transference, Fabricator level for earth)
|
|
||||||
- `pactLevelBonus` = count of pacts with guardians that have this element as primary × Invoker attunement level
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
A player with:
|
|
||||||
- Fire Conversion discipline active (rate = 0.5)
|
|
||||||
- Enchanter attunement level 3 (no fire base rate, but level contributes to exponent if fire is the attunement's primary)
|
|
||||||
- Fabricator attunement level 2 (earth primary, so contributes to earth conversions)
|
|
||||||
- 2 fire-type guardian pacts, Invoker level 3
|
|
||||||
|
|
||||||
For **fire mana** conversion:
|
|
||||||
```
|
|
||||||
baseRate = 0.5 (discipline) + 0 (no attunement base for fire) + 0 (no pact base for fire)
|
|
||||||
exponent = 1 + 0 (no attunement has fire as primary) + 0 (no fire-type pact bonus)
|
|
||||||
finalRate = 0.5^1 = 0.5/hr
|
|
||||||
```
|
|
||||||
|
|
||||||
For **metal mana** conversion (fire + earth):
|
|
||||||
```
|
|
||||||
baseRate = 0.35 (metal discipline) + 0 (no attunement base) + 0 (no pact base)
|
|
||||||
exponent = 1 + 2 (Fabricator level 2, earth is a component of metal) + 0
|
|
||||||
finalRate = 0.35^3 = 0.0429/hr
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait — this produces *lower* rates at higher levels, which is wrong. The exponent should be a **multiplier**, not an exponent on the rate. Let me restate:
|
|
||||||
|
|
||||||
### Corrected Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
finalRate = (disciplineRate + attunementBaseRate + pactBaseRate) × (1 + attunementLevelBonus + pactLevelBonus)
|
|
||||||
```
|
|
||||||
|
|
||||||
Where the multiplier is additive:
|
|
||||||
- `attunementLevelBonus` = sum of relevant attunement levels × 0.5 (each level adds +50% to rate)
|
|
||||||
- `pactLevelBonus` = count of pacts with this element × Invoker level × 0.25
|
|
||||||
|
|
||||||
So:
|
|
||||||
```
|
|
||||||
finalRate = baseRate × (1 + Σ(attunementLevel_i × 0.5) + Σ(pactCount_element × invokerLevel × 0.25))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Revised Example
|
|
||||||
|
|
||||||
For **metal mana** with Metal Conversion discipline (0.35/hr), Fabricator level 2:
|
|
||||||
```
|
|
||||||
baseRate = 0.35
|
|
||||||
multiplier = 1 + (2 × 0.5) = 2.0
|
|
||||||
finalRate = 0.35 × 2.0 = 0.70/hr
|
|
||||||
```
|
|
||||||
|
|
||||||
For **transference mana** with Transference Conversion discipline (0.4/hr), Enchanter level 3:
|
|
||||||
```
|
|
||||||
baseRate = 0.4
|
|
||||||
multiplier = 1 + (3 × 0.5) = 2.5
|
|
||||||
finalRate = 0.4 × 2.5 = 1.0/hr
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Discipline Contributions
|
|
||||||
|
|
||||||
Each conversion discipline provides a **base rate** that scales with XP.
|
|
||||||
|
|
||||||
### Base Rates (per hour)
|
|
||||||
|
|
||||||
| Element | Base Rate | Difficulty Factor | Scaling Factor |
|
|
||||||
|---------|-----------|-------------------|----------------|
|
|
||||||
| Fire, Water, Air, Earth, Light, Dark, Death | 0.5 | 120 | 60 |
|
|
||||||
| Transference | 0.4 | 100 | 50 |
|
|
||||||
| Metal, Sand, Lightning, Frost | 0.35 | 160 | 80 |
|
|
||||||
| BlackFlame, RadiantFlames, Miasma, ShadowGlass | 0.30 | 170 | 85 |
|
|
||||||
| Crystal, Void | 0.25 | 220 | 110 |
|
|
||||||
| Stellar, Soul, Plasma | 0.20 | 240 | 120 |
|
|
||||||
| Time | 0.15 | 260 | 130 |
|
|
||||||
|
|
||||||
### XP Scaling
|
|
||||||
|
|
||||||
The discipline's effective rate bonus follows the standard stat bonus formula:
|
|
||||||
```
|
|
||||||
statBonus = baseValue × (XP / scalingFactor)^0.65
|
|
||||||
```
|
|
||||||
|
|
||||||
The discipline's total contribution to the base rate is:
|
|
||||||
```
|
|
||||||
disciplineRate = baseRate + statBonus
|
|
||||||
```
|
|
||||||
|
|
||||||
### Perks
|
|
||||||
|
|
||||||
Each discipline has perks that add flat bonuses to the rate:
|
|
||||||
- **`once` perk**: grants `+baseRate` to the conversion rate at threshold XP
|
|
||||||
- **`infinite` perk**: every N XP grants `+baseRate × 0.5` to the conversion rate
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Attunement Contributions
|
|
||||||
|
|
||||||
Attunements provide a **base conversion rate** for their primary mana type, plus a **level-based multiplier** to all conversions involving their element.
|
|
||||||
|
|
||||||
### Attunement Base Rates
|
|
||||||
|
|
||||||
| Attunement | Primary Mana | Base Rate (per hour) |
|
|
||||||
|------------|--------------|---------------------|
|
|
||||||
| Enchanter | Transference | 0.2 |
|
|
||||||
| Fabricator | Earth | 0.25 |
|
|
||||||
| Invoker | None | 0 |
|
|
||||||
|
|
||||||
### Attunement Level Multiplier
|
|
||||||
|
|
||||||
Each attunement level adds +0.5 to the multiplier for conversions where the attunement's primary element is either:
|
|
||||||
- The destination element, OR
|
|
||||||
- A component element of the destination
|
|
||||||
|
|
||||||
Example: Fabricator (earth) level 3 boosts:
|
|
||||||
- Earth conversions (earth is destination)
|
|
||||||
- Metal conversions (earth is component)
|
|
||||||
- Sand conversions (earth is component)
|
|
||||||
- Shadow Glass conversions (earth is component)
|
|
||||||
|
|
||||||
But NOT fire conversions (earth is not involved).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Guardian Pact Contributions
|
|
||||||
|
|
||||||
Guardian pacts provide:
|
|
||||||
1. A **base conversion rate** for the guardian's element
|
|
||||||
2. A **level bonus** to the multiplier, scaled by Invoker attunement level
|
|
||||||
|
|
||||||
### Pact Base Rate
|
|
||||||
|
|
||||||
Each signed pact grants `+0.15/hr` base rate for the guardian's primary element.
|
|
||||||
|
|
||||||
### Pact Level Bonus
|
|
||||||
|
|
||||||
For each signed pact whose guardian has element E as primary:
|
|
||||||
```
|
|
||||||
pactLevelBonus_E += invokerLevel × 0.25
|
|
||||||
```
|
|
||||||
|
|
||||||
So an Invoker at level 4 with 2 fire-type pacts grants:
|
|
||||||
```
|
|
||||||
pactLevelBonus_fire = 2 × 4 × 0.25 = 2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
This adds to the multiplier for fire conversions and any composite that uses fire.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Meditation Multiplier
|
|
||||||
|
|
||||||
Meditation boosts conversion rates, but the boost is reduced for elements further from raw.
|
|
||||||
|
|
||||||
### Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
meditationBoost = 1 + (meditationMultiplier - 1) / distance
|
|
||||||
```
|
|
||||||
|
|
||||||
Where `distance` is the destination element's distance from raw mana.
|
|
||||||
|
|
||||||
| Element Distance | Meditation Strength |
|
|
||||||
|-----------------|-------------------|
|
|
||||||
| 1 (base) | Full: `meditationMultiplier` |
|
|
||||||
| 2 (composite) | Half: `1 + (med - 1) / 2` |
|
|
||||||
| 3 (exotic) | Third: `1 + (med - 1) / 3` |
|
|
||||||
| 4 (time) | Quarter: `1 + (med - 1) / 4` |
|
|
||||||
|
|
||||||
For elements with components at different distances, use the **highest** distance value (i.e., the weakest meditation boost).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Regen Deduction Model
|
|
||||||
|
|
||||||
All conversion costs are deducted from **mana regen**, not from the mana pool directly. This means:
|
|
||||||
|
|
||||||
1. Each element has a **gross regen** (from attunements, upgrades, etc.)
|
|
||||||
2. Conversions that consume this element as a source **reduce** the effective regen
|
|
||||||
3. The remaining regen is the **net regen** that actually adds to the pool
|
|
||||||
|
|
||||||
### Raw Mana
|
|
||||||
|
|
||||||
```
|
|
||||||
rawNetRegen = rawGrossRegen
|
|
||||||
- Σ (conversionRate_destination × rawCost_destination) for all active conversions
|
|
||||||
```
|
|
||||||
|
|
||||||
### Element Mana (e.g., fire)
|
|
||||||
|
|
||||||
```
|
|
||||||
fireNetRegen = fireGrossRegen
|
|
||||||
+ fireProducedRate (from raw→fire conversion)
|
|
||||||
- Σ (conversionRate_destination × fireCost_destination) for all conversions using fire as component
|
|
||||||
```
|
|
||||||
|
|
||||||
### Display Format
|
|
||||||
|
|
||||||
Each element's regen display shows:
|
|
||||||
```
|
|
||||||
Fire Mana Regen:
|
|
||||||
+0.50/hr converted from raw mana (Fire Conversion discipline, rate × attunement multiplier × meditation)
|
|
||||||
-0.15/hr being converted into Metal mana (30 per unit × 0.005 units/hr)
|
|
||||||
-0.10/hr being converted into Lightning mana (30 per unit × 0.0033 units/hr)
|
|
||||||
─────────────────
|
|
||||||
+0.25/hr net fire mana regen
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Insufficient Regen — Auto-Pause
|
|
||||||
|
|
||||||
If a conversion's source cost exceeds the **gross regen** of that source type, the conversion is **completely disabled** (not partially throttled).
|
|
||||||
|
|
||||||
### Conditions
|
|
||||||
|
|
||||||
A conversion for element E is paused if:
|
|
||||||
```
|
|
||||||
conversionRate_E × sourceCost_source > sourceGrossRegen
|
|
||||||
```
|
|
||||||
|
|
||||||
for **any** source type (raw or component element) in the conversion.
|
|
||||||
|
|
||||||
### UI Warning
|
|
||||||
|
|
||||||
When a conversion is paused due to insufficient regen:
|
|
||||||
- The conversion's entry in the stats tab shows a **red warning**: "⚠️ PAUSED: Insufficient [source] regen (need X/hr, have Y/hr)"
|
|
||||||
- The mana display for the source element shows a warning icon next to the draining conversion
|
|
||||||
|
|
||||||
### Auto-Resume
|
|
||||||
|
|
||||||
When regen increases (e.g., attunement levels up, new discipline XP gained, meditation active), paused conversions automatically resume if the regen now covers the cost.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. No Manual Conversion
|
|
||||||
|
|
||||||
The existing `convertMana` action and `processConvertAction` are **removed**. All mana conversion happens passively through the unified system. The "convert" player action is removed from the action buttons.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Stats Tab Display
|
|
||||||
|
|
||||||
The Stats tab includes a new **Conversion Stats** section showing:
|
|
||||||
|
|
||||||
### Per-Element Conversion Table
|
|
||||||
|
|
||||||
For each element with active conversions:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ 🔥 FIRE MANA CONVERSION │
|
|
||||||
│ │
|
|
||||||
│ Base Rate: 0.50/hr (Fire Conversion discipline) │
|
|
||||||
│ Attunement Bonus: ×1.00 (no attunement for fire) │
|
|
||||||
│ Pact Bonus: ×1.00 (0 fire-type pacts) │
|
|
||||||
│ Meditation: ×1.00 (not meditating) │
|
|
||||||
│ ───────────────────────────────────────── │
|
|
||||||
│ Effective Rate: 0.50/hr → produces 0.50 fire/hr │
|
|
||||||
│ │
|
|
||||||
│ Costs (deducted from raw regen): │
|
|
||||||
│ Raw: 100 × 0.50 = 50.0 raw/hr │
|
|
||||||
│ │
|
|
||||||
│ Drained by downstream conversions: │
|
|
||||||
│ → Metal: 30 × 0.005 = 0.15 fire/hr │
|
|
||||||
│ → Lightning: 30 × 0.003 = 0.10 fire/hr │
|
|
||||||
│ │
|
|
||||||
│ Net Fire Regen: +0.50 - 0.15 - 0.10 = +0.25 fire/hr │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Formula Summary
|
|
||||||
|
|
||||||
A collapsible formula reference is shown at the top:
|
|
||||||
|
|
||||||
```
|
|
||||||
Conversion Rate Formula:
|
|
||||||
finalRate = (disciplineRate + attunementBase + pactBase) × attunementMult × pactMult × meditationMult
|
|
||||||
|
|
||||||
Where:
|
|
||||||
attunementMult = 1 + Σ(relevantAttunementLevel × 0.5)
|
|
||||||
pactMult = 1 + Σ(pactCount_element × invokerLevel × 0.25)
|
|
||||||
meditationMult = 1 + (meditationMultiplier - 1) / elementDistance
|
|
||||||
|
|
||||||
Cost per 1 unit of destination:
|
|
||||||
rawCost = 10^(distance+1)
|
|
||||||
componentCost = 10 × (distance+1) per component
|
|
||||||
|
|
||||||
All costs deducted from source regen (not from mana pool).
|
|
||||||
Conversions pause if source regen < conversion cost.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Implementation Notes
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
|
|
||||||
- `src/lib/game/utils/element-distance.ts` — `getElementDistance()` function
|
|
||||||
- `src/lib/game/utils/conversion-rates.ts` — Unified conversion rate calculator
|
|
||||||
- `src/lib/game/data/conversion-costs.ts` — Cost ratio table per element
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
|
|
||||||
- `src/lib/game/data/disciplines/elemental-regen.ts` — Update base rates, remove drain model
|
|
||||||
- `src/lib/game/data/disciplines/elemental-regen-advanced.ts` — Update base rates, remove drain model
|
|
||||||
- `src/lib/game/data/attunements.ts` — Update conversion rates to match new system
|
|
||||||
- `src/lib/game/effects/discipline-effects.ts` — Update conversion computation
|
|
||||||
- `src/lib/game/stores/gameStore.ts` — Replace tick conversion logic with unified system
|
|
||||||
- `src/lib/game/stores/manaStore.ts` — Remove `convertMana`, `processConvertAction`, `craftComposite`
|
|
||||||
- `src/lib/game/stores/prestigeStore.ts` — Add pact conversion rate data
|
|
||||||
- `src/components/game/tabs/StatsTab/ElementStatsSection.tsx` — Add conversion display
|
|
||||||
- `src/components/game/ManaDisplay.tsx` — Add per-element regen breakdown
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Manual conversion (`convertMana`, `processConvertAction`)
|
|
||||||
- Composite crafting via `craftComposite` (replaced by passive conversion)
|
|
||||||
- The "convert" action from player actions
|
|
||||||
- Per-tick mana pool deduction for conversions (replaced by regen deduction)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Migration Notes
|
|
||||||
|
|
||||||
Existing save data will need migration:
|
|
||||||
- Active discipline conversion rates are preserved (the XP and discipline IDs stay the same)
|
|
||||||
- Attunement conversion rates are recalculated from the new base rates
|
|
||||||
- Any manually-converted element mana in pools is preserved
|
|
||||||
- The `convertMana` and `craftComposite` store actions are kept as no-ops for save compatibility but have no UI
|
|
||||||
@@ -1,682 +0,0 @@
|
|||||||
# Spire Climbing System — Design Spec
|
|
||||||
|
|
||||||
> Describes the full lifecycle of a spire run: entering, climbing room-by-room,
|
|
||||||
> clearing floors, descending, and exiting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Objective
|
|
||||||
|
|
||||||
The Spire is the core progression loop of Mana Loop. The player enters at a starting
|
|
||||||
floor determined by their `spireKey` prestige level, clears rooms by casting spells
|
|
||||||
at enemies, advances floor by floor to ever-higher challenges, and must fully descend
|
|
||||||
back to the exit floor before they can leave.
|
|
||||||
|
|
||||||
**Design goals:**
|
|
||||||
- Each floor is a multi-room dungeon with variable room counts.
|
|
||||||
- The descent is a meaningful mini-game: the player re-traverses every room they
|
|
||||||
climbed in reverse, with each individual room having a 50% independent chance to
|
|
||||||
have reset its enemies.
|
|
||||||
- Climbing rewards (insight, pacts, loot, discipline XP) are gated behind reaching
|
|
||||||
high floors and signing pacts with guardians.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Controls / API
|
|
||||||
|
|
||||||
### 2.1 Player Actions
|
|
||||||
|
|
||||||
| Action | Trigger | Effect |
|
|
||||||
|---|---|---|
|
|
||||||
| Enter Spire | UI button on Spire Summary tab | `enterSpireMode()` — init spire state |
|
|
||||||
| Climb Up | automatic after room is cleared (ascending) | `advanceRoomOrFloor()` |
|
|
||||||
| Start Descent | "Descend" button on the climb page | `enterDescentMode()` — snapshots peak, begins reverse traversal |
|
|
||||||
| Exit Spire | "Exit" button (only at exit floor R0 during descent) | `exitSpireMode()` — reset to outside-spire state |
|
|
||||||
|
|
||||||
### 2.2 Game Commands (Store Actions)
|
|
||||||
|
|
||||||
The following are the **necessary** new store actions. Actions already implemented
|
|
||||||
that need modification are noted separately.
|
|
||||||
|
|
||||||
| Command | Store | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `enterSpireMode()` | combatStore | Reset to starting floor R0, generate first room, enter spire mode |
|
|
||||||
| `exitSpireMode()` | combatStore | Leave spire, reset all run state |
|
|
||||||
| `enterDescentMode()` | combatStore | **NEW** — snapshot peak floor/room, set `climbDirection = 'down'` |
|
|
||||||
| `advanceRoomOrFloor()` | combatStore | **NEW** — move to next room/floor (ascending) or previous room/floor (descending) |
|
|
||||||
| `processCombatTick(...)` | combatStore | **MODIFY** — must become room-aware (see §4.4) |
|
|
||||||
| `tickNonCombatRoom(hours)` | combatStore | **NEW** — tick non-combat room progress (library, recovery, treasure, puzzle) |
|
|
||||||
| `skipNonCombatRoom()` | combatStore | **NEW** — skip to next room (library, recovery, treasure only) |
|
|
||||||
| `stayLongerInRoom()` | combatStore | **NEW** — extend current room by 1 hour (library, recovery only, once per room) |
|
|
||||||
|
|
||||||
> **Removed vs. original draft:** `skipClearedRoom`, `markFloorReset`, `setCurrentRoom`,
|
|
||||||
> `setClearedFloor`, and `initGuardianDefensiveState` are **not needed as separate public
|
|
||||||
> actions** — this logic lives inside `advanceRoomOrFloor()` and `processCombatTick()`
|
|
||||||
> as private helpers. `addActivityLog` already exists.
|
|
||||||
|
|
||||||
### 2.3 State Transitions
|
|
||||||
|
|
||||||
```
|
|
||||||
outside-spire
|
|
||||||
│ enterSpireMode()
|
|
||||||
▼
|
|
||||||
climbing-up (startFloor R0)
|
|
||||||
│ room cleared → advanceRoomOrFloor() → next room
|
|
||||||
│ last room on floor cleared → next floor, R0
|
|
||||||
│ player presses "Descend"
|
|
||||||
▼
|
|
||||||
descending (peak floor, peak room)
|
|
||||||
│ room cleared or skipped → advanceRoomOrFloor() → prev room
|
|
||||||
│ R0 of floor → prev floor, last room
|
|
||||||
│ reach exit floor R0
|
|
||||||
▼
|
|
||||||
descent complete — "Exit Spire" button shown
|
|
||||||
│ exitSpireMode()
|
|
||||||
▼
|
|
||||||
outside-spire
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Project Layout
|
|
||||||
|
|
||||||
Files to create or modify:
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/specs/
|
|
||||||
spire-climbing-spec.md ← this file
|
|
||||||
spire-combat-spec.md ← companion: spell damage, weapons, golems
|
|
||||||
|
|
||||||
src/lib/game/stores/
|
|
||||||
combat-state.types.ts — add currentRoomIndex, roomsPerFloor, descentPeak,
|
|
||||||
roomResetState, exitFloor fields
|
|
||||||
combatStore.ts — add enterDescentMode(), advanceRoomOrFloor()
|
|
||||||
combat-actions.ts — make processCombatTick room-aware
|
|
||||||
combat-descent-actions.ts — add non-combat room handlers (recovery, treasure, library, puzzle)
|
|
||||||
|
|
||||||
src/lib/game/utils/
|
|
||||||
spire-utils.ts — ensure getRoomsForFloor accepts a seed; add generateTreasureLoot()
|
|
||||||
room-utils.ts — add generateSpireRoomType()
|
|
||||||
|
|
||||||
src/components/game/tabs/
|
|
||||||
SpireCombatPage/
|
|
||||||
SpireCombatPage.tsx — wire room-cleared; add descent UI
|
|
||||||
SpireHeader.tsx — "Descend" button on ascent; "Exit" button at exit floor R0
|
|
||||||
RoomDisplay.tsx — show "Room X / Y", room type badge, current game time
|
|
||||||
SpireActivityLog.tsx — log all room/floor events
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Detailed Mechanics
|
|
||||||
|
|
||||||
### 4.1 Entering the Spire
|
|
||||||
|
|
||||||
1. Player presses "Enter Spire" on the Spire Summary tab.
|
|
||||||
2. `enterSpireMode()` runs:
|
|
||||||
- `spireMode = true`
|
|
||||||
- `currentAction = 'climb'`
|
|
||||||
- `startFloor = 1 + (spireKey × 2)` — prestige upgrade; spireKey 0 → F1, spireKey 1 → F3, etc.
|
|
||||||
- `exitFloor = startFloor` — the floor the player must reach on descent to be allowed to exit
|
|
||||||
- `currentFloor = startFloor`
|
|
||||||
- `currentRoomIndex = 0`
|
|
||||||
- `roomsPerFloor = getRoomsForFloor(currentFloor, seed)`
|
|
||||||
- `currentRoom = generateSpireFloorState(currentFloor, 0, roomsPerFloor)`
|
|
||||||
- `clearedRooms = {}` — tracks which `floor:roomIndex` pairs have been cleared
|
|
||||||
- `climbDirection = 'up'`
|
|
||||||
- `descentPeak = null`
|
|
||||||
- `roomResetState = {}` — per-room reset rolls, lazily populated on descent
|
|
||||||
- activity log: `"Entered the Spire at Floor ${startFloor}"`
|
|
||||||
|
|
||||||
### 4.2 Room Count Per Floor
|
|
||||||
|
|
||||||
```
|
|
||||||
getRoomsForFloor(floor, seed):
|
|
||||||
if isGuardianFloor(floor): return 1
|
|
||||||
base = 5
|
|
||||||
floorBonus = min(10, floor / 20) // slow scaling, max +10
|
|
||||||
randomVariation = floor(seededRandom(seed) * 3) // 0, 1, or 2
|
|
||||||
return base + floorBonus + randomVariation // range: 5–17
|
|
||||||
```
|
|
||||||
|
|
||||||
- Guardian floors (every 10th): exactly **1 room**.
|
|
||||||
- All other floors: **5–17 rooms**, scaling slowly with floor level.
|
|
||||||
- Room count is **deterministic** per floor via seed so the same count is reproduced
|
|
||||||
on descent. Seed = `floor × 12345 + runId`.
|
|
||||||
|
|
||||||
### 4.3 Room Types
|
|
||||||
|
|
||||||
Generated by `generateSpireRoomType(floor, roomIndex, totalRooms)`.
|
|
||||||
|
|
||||||
**Base roll (every room):**
|
|
||||||
|
|
||||||
```
|
|
||||||
roll = seededRandom(floor, roomIndex)
|
|
||||||
|
|
||||||
if roll < 0.10: → rare roll (see below)
|
|
||||||
elif roll < 0.22: → 'swarm'
|
|
||||||
elif roll < 0.32: → 'speed'
|
|
||||||
else: → 'combat' (~68% of rooms)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rare roll (~10% of rooms)** — secondary roll determines sub-type:
|
|
||||||
|
|
||||||
```
|
|
||||||
rareRoll = seededRandom(floor, roomIndex, 'rare')
|
|
||||||
|
|
||||||
if rareRoll < 0.40: → 'recovery'
|
|
||||||
elif rareRoll < 0.70: → 'treasure'
|
|
||||||
else: → 'library'
|
|
||||||
```
|
|
||||||
|
|
||||||
So across all rooms: ~40% of 10% = **~4% recovery**, ~30% of 10% = **~3% treasure**,
|
|
||||||
~30% of 10% = **~3% library**.
|
|
||||||
|
|
||||||
**Override rules (applied after base roll):**
|
|
||||||
- Last room on a guardian floor → always `'guardian'`
|
|
||||||
- Every 7th floor, one room (chosen by seed) → always `'puzzle'`
|
|
||||||
|
|
||||||
**Room type summary:**
|
|
||||||
|
|
||||||
| Type | Approx. Frequency | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `combat` | ~68% | Single enemy, normal stats |
|
|
||||||
| `swarm` | ~12% | 3–7 weak enemies |
|
|
||||||
| `speed` | ~10% | Single enemy with elevated dodge chance |
|
|
||||||
| `guardian` | Every 10th floor, 1 room | Boss — high HP, shield, barrier, health regen |
|
|
||||||
| `recovery` | ~4% | No enemies; 1 hour; grants 10× mana regen & conversion rates for all unlocked mana types (see §4.8) |
|
|
||||||
| `treasure` | ~3% | No enemies; 1 hour; grants 2–15 random items (mostly fabricator materials, rarely pre-crafted gear), scaling with floor (see §4.9) |
|
|
||||||
| `library` | ~3% | No enemies; 1 hour; grants discipline XP at 25× normal rate to a random unlocked discipline (see §4.10) |
|
|
||||||
| `puzzle` | ~1 per 7 floors | Attunement-based challenge; up to 24 hours base time, reduced by attunement levels (see §4.11) |
|
|
||||||
|
|
||||||
**Speed room interaction:** A `speed` room combined with an enemy that also has the
|
|
||||||
`agile` modifier results in an **additive dodge bonus** on top of the agile modifier
|
|
||||||
value. See combat spec §2.3 for modifier details.
|
|
||||||
|
|
||||||
### 4.4 Ascending — Room and Floor Advancement
|
|
||||||
|
|
||||||
Rooms advance **automatically** when all enemies in the current room reach 0 HP.
|
|
||||||
Non-combat rooms advance when their timed progression completes (or when the player
|
|
||||||
presses "Skip"). The player does not press a button for combat rooms.
|
|
||||||
|
|
||||||
```
|
|
||||||
advanceRoomOrFloor() [direction = 'up']:
|
|
||||||
markRoomCleared(currentFloor, currentRoomIndex)
|
|
||||||
activityLog("Room ${currentRoomIndex + 1}/${roomsPerFloor} cleared")
|
|
||||||
|
|
||||||
if currentRoomIndex + 1 >= roomsPerFloor:
|
|
||||||
// Last room on this floor
|
|
||||||
activityLog("Floor ${currentFloor} cleared — ascending")
|
|
||||||
newFloor = min(currentFloor + 1, FLOOR_CAP)
|
|
||||||
currentFloor = newFloor
|
|
||||||
currentRoomIndex = 0
|
|
||||||
roomsPerFloor = getRoomsForFloor(newFloor, seed)
|
|
||||||
currentRoom = generateSpireFloorState(newFloor, 0, roomsPerFloor)
|
|
||||||
resetCastProgress()
|
|
||||||
else:
|
|
||||||
currentRoomIndex += 1
|
|
||||||
currentRoom = generateSpireFloorState(currentFloor, currentRoomIndex, roomsPerFloor)
|
|
||||||
resetCastProgress()
|
|
||||||
```
|
|
||||||
|
|
||||||
Non-combat rooms (recovery, treasure, library, puzzle) initialize timed progression
|
|
||||||
on entry. When progress reaches the required amount, `advanceRoomOrFloor()` is called
|
|
||||||
automatically. The player can press "Skip" to advance immediately (library, recovery,
|
|
||||||
treasure) or press "Stay 1 Hour More" (library, recovery only) to extend the time.
|
|
||||||
Puzzle rooms are mandatory — no skip or stay buttons.
|
|
||||||
|
|
||||||
### 4.5 Descent Initiation
|
|
||||||
|
|
||||||
The "Descend" button is available at any point during ascent. Pressing it:
|
|
||||||
|
|
||||||
```
|
|
||||||
enterDescentMode():
|
|
||||||
descentPeak = { floor: currentFloor, roomIndex: currentRoomIndex }
|
|
||||||
climbDirection = 'down'
|
|
||||||
activityLog("Beginning descent from Floor ${currentFloor}, Room ${currentRoomIndex + 1}")
|
|
||||||
// Start descending from the current room (player re-fights or skips it)
|
|
||||||
onEnterRoomDescend()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 Descending — Reverse Traversal
|
|
||||||
|
|
||||||
On descent, rooms are visited in **strict reverse order**: within a floor, rooms
|
|
||||||
count down from the highest index back to 0. When room 0 is cleared or skipped,
|
|
||||||
the player moves down to the previous floor at its **highest** room index.
|
|
||||||
|
|
||||||
```
|
|
||||||
advanceRoomOrFloor() [direction = 'down']:
|
|
||||||
activityLog("Room ${currentRoomIndex + 1} passed")
|
|
||||||
|
|
||||||
if currentFloor <= exitFloor && currentRoomIndex <= 0:
|
|
||||||
// Reached the exit point
|
|
||||||
isDescentComplete = true
|
|
||||||
activityLog("Descent complete — Exit Spire is now available")
|
|
||||||
return
|
|
||||||
|
|
||||||
if currentRoomIndex <= 0:
|
|
||||||
// Move down to previous floor, enter at its last room
|
|
||||||
currentFloor -= 1
|
|
||||||
roomsPerFloor = getRoomsForFloor(currentFloor, seed)
|
|
||||||
currentRoomIndex = roomsPerFloor - 1
|
|
||||||
activityLog("Descended to Floor ${currentFloor}")
|
|
||||||
else:
|
|
||||||
currentRoomIndex -= 1
|
|
||||||
|
|
||||||
currentRoom = generateSpireFloorState(currentFloor, currentRoomIndex, roomsPerFloor)
|
|
||||||
resetCastProgress()
|
|
||||||
onEnterRoomDescend()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.7 Per-Room Reset on Descent
|
|
||||||
|
|
||||||
Each room is checked **independently** when the player enters it during descent.
|
|
||||||
Floors do not share a single reset roll — every room rolls on its own.
|
|
||||||
|
|
||||||
```
|
|
||||||
onEnterRoomDescend():
|
|
||||||
key = `${currentFloor}:${currentRoomIndex}`
|
|
||||||
|
|
||||||
if roomResetState[key] is undefined:
|
|
||||||
roomResetState[key] = (Math.random() < 0.5)
|
|
||||||
|
|
||||||
if !wasRoomCleared(currentFloor, currentRoomIndex):
|
|
||||||
// Room was never cleared on the way up — must fight it now
|
|
||||||
activityLog("Room ${currentRoomIndex + 1} on Floor ${currentFloor} was not cleared — enemies present")
|
|
||||||
// enemies already in currentRoom from generation, no change needed
|
|
||||||
return
|
|
||||||
|
|
||||||
if roomResetState[key] === true:
|
|
||||||
// Room reset — re-generate enemies
|
|
||||||
currentRoom = generateSpireFloorState(currentFloor, currentRoomIndex, roomsPerFloor)
|
|
||||||
activityLog("Room ${currentRoomIndex + 1} on Floor ${currentFloor} has reset — enemies respawned")
|
|
||||||
else:
|
|
||||||
// Room did not reset — auto-skip
|
|
||||||
activityLog("Room ${currentRoomIndex + 1} on Floor ${currentFloor} is clear — moving on")
|
|
||||||
advanceRoomOrFloor() // immediately continue
|
|
||||||
```
|
|
||||||
|
|
||||||
Guardian rooms that reset on descent re-initialize the full guardian defensive state
|
|
||||||
(shield pool, barrier %, health regen) as if the player is fighting the guardian for
|
|
||||||
the first time.
|
|
||||||
|
|
||||||
### 4.8 Recovery Rooms — Boosted Mana Regen & Conversion
|
|
||||||
|
|
||||||
When a `recovery` room is entered:
|
|
||||||
|
|
||||||
```
|
|
||||||
onEnterRecoveryRoom(floor):
|
|
||||||
recoveryProgress = 0
|
|
||||||
recoveryRequired = 1 // 1 hour
|
|
||||||
recoveryStayed = false
|
|
||||||
activityLog("Entered recovery room on Floor ${floor}")
|
|
||||||
// Do NOT call advanceRoomOrFloor() — wait for progress to complete
|
|
||||||
```
|
|
||||||
|
|
||||||
**Effect:** While in the recovery room, the player receives a **10× multiplier** to:
|
|
||||||
- **Mana regeneration rate** for all unlocked mana types (e.g., 1 raw/hour → 10 raw/hour)
|
|
||||||
- **Mana conversion efficiency** for all unlocked mana types (e.g., 10 raw → 1 transference/hour becomes 10 raw → 10 transference/hour)
|
|
||||||
|
|
||||||
The multiplier is applied through the mana store for the duration of the room.
|
|
||||||
|
|
||||||
**UI:**
|
|
||||||
- Progress bar showing time elapsed / 1 hour
|
|
||||||
- Thematic text: *"Resting and recovering in a mana-rich chamber"*
|
|
||||||
- **"Stay 1 Hour More" button** (once only) — adds 1 more hour to `recoveryRequired`, disabled after use
|
|
||||||
- **"Skip" button** — calls `advanceRoomOrFloor()` immediately
|
|
||||||
|
|
||||||
**Activity log events:**
|
|
||||||
- `"Entered recovery room on Floor {N}"`
|
|
||||||
- `"Recovery complete — mana regen and conversion boosted"`
|
|
||||||
|
|
||||||
### 4.9 Treasure Rooms — Loot
|
|
||||||
|
|
||||||
When a `treasure` room is entered:
|
|
||||||
|
|
||||||
```
|
|
||||||
onEnterTreasureRoom(floor):
|
|
||||||
treasureProgress = 0
|
|
||||||
treasureRequired = 1 // 1 hour
|
|
||||||
treasureLoot = generateTreasureLoot(floor)
|
|
||||||
treasureLootClaimed = []
|
|
||||||
activityLog("Entered treasure room on Floor ${floor}")
|
|
||||||
// Do NOT call advanceRoomOrFloor() — wait for progress to complete
|
|
||||||
```
|
|
||||||
|
|
||||||
**Loot generation** (`generateTreasureLoot`):
|
|
||||||
|
|
||||||
```
|
|
||||||
generateTreasureLoot(floor):
|
|
||||||
// 1. Determine item count based on floor:
|
|
||||||
// - Floors 1–10: 2–3 items
|
|
||||||
// - Floors 10–50: 4–7 items
|
|
||||||
// - Floors 50+: 8–15 items
|
|
||||||
// 2. For each item slot:
|
|
||||||
// - 85%+ chance: fabricator material (from LOOT_DROPS, filtered by minFloor)
|
|
||||||
// - ~15% chance: pre-crafted equipment (rare, higher floors only)
|
|
||||||
// 3. Weight by dropChance; higher floors get access to better items
|
|
||||||
// 4. Return array of LootDrop with amounts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Loot delivery:** Items are granted progressively as the hour elapses:
|
|
||||||
- At **10%** progress: first item(s) granted
|
|
||||||
- At **50%** progress: mid-tier items granted
|
|
||||||
- At **95%** progress: more items granted
|
|
||||||
- At **100%** progress: final and best item(s) granted
|
|
||||||
|
|
||||||
Each item is added to the player's loot inventory and logged in the activity log.
|
|
||||||
|
|
||||||
**UI:**
|
|
||||||
- Progress bar showing time elapsed / 1 hour
|
|
||||||
- Thematic text: *"Rummaging through ancient chests and caches"*
|
|
||||||
- **"Skip" button** — calls `advanceRoomOrFloor()` immediately (forfeits remaining loot)
|
|
||||||
|
|
||||||
**Activity log events:**
|
|
||||||
- `"Entered treasure room on Floor {N}"`
|
|
||||||
- `"Found {itemName} x{amount}"` (for each item as it's granted)
|
|
||||||
- `"Treasure room looted — {count} items recovered"`
|
|
||||||
|
|
||||||
### 4.10 Library Rooms — Discipline XP
|
|
||||||
|
|
||||||
When a `library` room is entered:
|
|
||||||
|
|
||||||
```
|
|
||||||
onEnterLibraryRoom(floor):
|
|
||||||
discipline = pickRandom(allUnlockedDisciplines)
|
|
||||||
libraryProgress = 0
|
|
||||||
libraryRequired = 1 // 1 hour
|
|
||||||
libraryStayed = false
|
|
||||||
activityLog("Entered library room on Floor ${floor}")
|
|
||||||
// Do NOT call advanceRoomOrFloor() — wait for progress to complete
|
|
||||||
```
|
|
||||||
|
|
||||||
**Effect:** While in the library room, the selected discipline gains XP at **25× the
|
|
||||||
normal rate**. XP is granted continuously over the hour (not a lump sum). No mana cost.
|
|
||||||
|
|
||||||
- Target discipline is chosen randomly from all **unlocked** disciplines (not just active ones).
|
|
||||||
- If no disciplines are unlocked, nothing happens (edge case — player should always have at least one).
|
|
||||||
|
|
||||||
**UI:**
|
|
||||||
- Progress bar showing time elapsed / 1 hour
|
|
||||||
- Thematic text: *"Studying Mana Circulation from ancient tomes"*
|
|
||||||
- **"Stay 1 Hour More" button** (once only) — adds 1 more hour to `libraryRequired`, disabled after use
|
|
||||||
- **"Skip" button** — calls `advanceRoomOrFloor()` immediately
|
|
||||||
|
|
||||||
**Activity log events:**
|
|
||||||
- `"Entered library room on Floor {N}"`
|
|
||||||
- `"{Discipline} gained {XP} XP from ancient tomes"` (continuous, logged periodically)
|
|
||||||
- `"Library study complete"`
|
|
||||||
|
|
||||||
### 4.11 Puzzle Rooms — Attunement Challenge
|
|
||||||
|
|
||||||
When a `puzzle` room is entered:
|
|
||||||
|
|
||||||
```
|
|
||||||
onEnterPuzzleRoom(floor, puzzleId):
|
|
||||||
puzzleProgress = 0
|
|
||||||
puzzleRequired = calcPuzzleTime(floor, puzzleId)
|
|
||||||
activityLog("Entered puzzle room on Floor ${floor}")
|
|
||||||
// Do NOT call advanceRoomOrFloor() — wait for progress to complete
|
|
||||||
```
|
|
||||||
|
|
||||||
**Base time calculation** (scales with floor):
|
|
||||||
|
|
||||||
```
|
|
||||||
calcPuzzleBaseTime(floor):
|
|
||||||
if floor <= 20: return 4 // 4 hours
|
|
||||||
if floor <= 50: return 8 // 8 hours
|
|
||||||
if floor <= 100: return 16 // 16 hours
|
|
||||||
return 24 // 24 hours max
|
|
||||||
```
|
|
||||||
|
|
||||||
**Attunement-based time reduction:**
|
|
||||||
|
|
||||||
Each puzzle is associated with 1 or more attunements (defined in `PUZZLE_ROOMS`).
|
|
||||||
The player's attunement levels reduce the required time:
|
|
||||||
|
|
||||||
```
|
|
||||||
calcPuzzleTime(floor, puzzleId):
|
|
||||||
base = calcPuzzleBaseTime(floor)
|
|
||||||
puzzle = PUZZLE_ROOMS[puzzleId]
|
|
||||||
attunements = puzzle.attunements // e.g., ['enchanter'] or ['enchanter', 'invoker']
|
|
||||||
|
|
||||||
totalReduction = 0
|
|
||||||
for each attunementId in attunements:
|
|
||||||
attLevel = getAttunementLevel(attunementId)
|
|
||||||
maxLevel = getMaxAttunementLevel()
|
|
||||||
// Each attunement contributes up to (1 / attunements.length) * 0.90 reduction
|
|
||||||
share = 1 / attunements.length
|
|
||||||
reduction = share * 0.90 * (attLevel / maxLevel)
|
|
||||||
totalReduction += reduction
|
|
||||||
|
|
||||||
return base * (1 - totalReduction)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
- Single-attunement puzzle (enchanter trial), max enchanter level: `base × (1 - 0.90) = base × 0.10` (90% reduction)
|
|
||||||
- Dual-attunement puzzle (enchanter + invoker), max both levels: `base × (1 - 0.45 - 0.45) = base × 0.10` (90% reduction)
|
|
||||||
- Dual-attunement puzzle, max enchanter only: `base × (1 - 0.45) = base × 0.55` (45% reduction from enchanter, 0% from invoker)
|
|
||||||
|
|
||||||
**UI:**
|
|
||||||
- Progress bar showing time elapsed / total time
|
|
||||||
- Thematic text based on puzzle type:
|
|
||||||
- Enchanter puzzle: *"Deciphering an enchanted lock"*
|
|
||||||
- Fabricator puzzle: *"Disassembling a mana-powered mechanism"*
|
|
||||||
- Invoker puzzle: *"Communing with residual guardian spirits"*
|
|
||||||
- Hybrid puzzle: *"Working through a complex attunement challenge"*
|
|
||||||
- **No "Skip" or "Stay" buttons** — puzzle rooms are mandatory
|
|
||||||
|
|
||||||
**Activity log events:**
|
|
||||||
- `"Entered puzzle room on Floor {N} — {puzzleName}"`
|
|
||||||
- `"Puzzle solved!"`
|
|
||||||
|
|
||||||
### 4.12 Non-Combat Room Tick Processing
|
|
||||||
|
|
||||||
Every game tick, if the current room is non-combat:
|
|
||||||
|
|
||||||
```
|
|
||||||
tickNonCombatRoom(hours):
|
|
||||||
room = currentRoom
|
|
||||||
|
|
||||||
if room.roomType === 'library':
|
|
||||||
room.libraryProgress += hours
|
|
||||||
xpThisTick = calcDisciplineXPRate(discipline) × 25 × hours
|
|
||||||
discipline.addXP(xpThisTick)
|
|
||||||
if room.libraryProgress >= room.libraryRequired:
|
|
||||||
advanceRoomOrFloor()
|
|
||||||
|
|
||||||
else if room.roomType === 'recovery':
|
|
||||||
room.recoveryProgress += hours
|
|
||||||
// 10× regen/conversion is applied passively via mana store flags
|
|
||||||
if room.recoveryProgress >= room.recoveryRequired:
|
|
||||||
advanceRoomOrFloor()
|
|
||||||
|
|
||||||
else if room.roomType === 'treasure':
|
|
||||||
room.treasureProgress += hours
|
|
||||||
// Check loot thresholds and grant items
|
|
||||||
progressPct = room.treasureProgress / room.treasureRequired
|
|
||||||
for each lootItem in room.treasureLoot:
|
|
||||||
if not claimed and progressPct >= lootItem.threshold:
|
|
||||||
grantLoot(lootItem)
|
|
||||||
activityLog("Found ${lootItem.name}")
|
|
||||||
if room.treasureProgress >= room.treasureRequired:
|
|
||||||
advanceRoomOrFloor()
|
|
||||||
|
|
||||||
else if room.roomType === 'puzzle':
|
|
||||||
room.puzzleProgress += hours
|
|
||||||
if room.puzzleProgress >= room.puzzleRequired:
|
|
||||||
activityLog("Puzzle solved!")
|
|
||||||
advanceRoomOrFloor()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Player actions during non-combat rooms:**
|
|
||||||
|
|
||||||
```
|
|
||||||
skipNonCombatRoom():
|
|
||||||
// Only for library, recovery, treasure
|
|
||||||
if currentRoom.roomType in ['library', 'recovery', 'treasure']:
|
|
||||||
advanceRoomOrFloor()
|
|
||||||
|
|
||||||
stayLongerInRoom():
|
|
||||||
// Only for library and recovery, once per room
|
|
||||||
if currentRoom.roomType === 'library' and not libraryStayed:
|
|
||||||
libraryRequired += 1
|
|
||||||
libraryStayed = true
|
|
||||||
else if currentRoom.roomType === 'recovery' and not recoveryStayed:
|
|
||||||
recoveryRequired += 1
|
|
||||||
recoveryStayed = true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.13 Exiting the Spire
|
|
||||||
|
|
||||||
The "Exit Spire" button is visible **only** when:
|
|
||||||
- `isDescentComplete === true`
|
|
||||||
|
|
||||||
(Internally this means `currentFloor === exitFloor && currentRoomIndex === 0 && climbDirection === 'down'`.)
|
|
||||||
|
|
||||||
```
|
|
||||||
exitSpireMode():
|
|
||||||
spireMode = false
|
|
||||||
currentAction = 'meditate'
|
|
||||||
climbDirection = null
|
|
||||||
descentPeak = null
|
|
||||||
roomResetState = {}
|
|
||||||
clearedRooms = {}
|
|
||||||
currentFloor = exitFloor
|
|
||||||
currentRoomIndex = 0
|
|
||||||
isDescentComplete = false
|
|
||||||
activityLog("Exited the Spire")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Activity Log Events
|
|
||||||
|
|
||||||
Every meaningful state change appends an entry to the spire activity log. Required events:
|
|
||||||
|
|
||||||
| Event | Message |
|
|
||||||
|---|---|
|
|
||||||
| Enter spire | `"Entered the Spire at Floor {N}"` |
|
|
||||||
| Room cleared (combat) | `"Floor {N} Room {R}/{total} cleared"` |
|
|
||||||
| Room skipped (no reset) | `"Floor {N} Room {R} is clear — moving on"` |
|
|
||||||
| Room reset on descent | `"Floor {N} Room {R} has reset — enemies respawned"` |
|
|
||||||
| Room not cleared on ascent | `"Floor {N} Room {R} was not cleared — enemies present"` |
|
|
||||||
| Floor ascended | `"Ascending to Floor {N}"` |
|
|
||||||
| Floor descended | `"Descended to Floor {N}"` |
|
|
||||||
| Non-combat room entered | `"Entered {roomType} room on Floor {N}"` |
|
|
||||||
| Library XP granted | `"{Discipline} gained {XP} XP from ancient tomes"` (continuous, logged periodically) |
|
|
||||||
| Library study complete | `"Library study complete"` |
|
|
||||||
| Recovery entered | `"Entered recovery room on Floor {N}"` |
|
|
||||||
| Recovery complete | `"Recovery complete — mana regen and conversion boosted"` |
|
|
||||||
| Treasure entered | `"Entered treasure room on Floor {N}"` |
|
|
||||||
| Treasure item found | `"Found {itemName} x{amount}"` (per item as granted) |
|
|
||||||
| Treasure room complete | `"Treasure room looted — {count} items recovered"` |
|
|
||||||
| Puzzle entered | `"Entered puzzle room on Floor {N} — {puzzleName}"` |
|
|
||||||
| Puzzle solved | `"Puzzle solved!"` |
|
|
||||||
| Stay longer activated | `"Decided to stay longer in {roomType} room"` |
|
|
||||||
| Descent initiated | `"Beginning descent from Floor {N} Room {R}"` |
|
|
||||||
| Descent complete | `"Descent complete — Exit Spire is now available"` |
|
|
||||||
| Exit spire | `"Exited the Spire"` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. State Fields Summary
|
|
||||||
|
|
||||||
New and modified fields in `combat-state.types.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Run identity
|
|
||||||
startFloor: number // floor entered at (= 1 + spireKey × 2)
|
|
||||||
exitFloor: number // floor player must reach to exit (= startFloor)
|
|
||||||
|
|
||||||
// Room navigation
|
|
||||||
currentRoomIndex: number // 0-indexed room within currentFloor
|
|
||||||
roomsPerFloor: number // total rooms on currentFloor (deterministic)
|
|
||||||
|
|
||||||
// Descent tracking
|
|
||||||
climbDirection: 'up' | 'down' | null
|
|
||||||
descentPeak: { floor: number; roomIndex: number } | null
|
|
||||||
roomResetState: Record<string, boolean> // key = "floor:roomIndex"
|
|
||||||
clearedRooms: Record<string, boolean> // key = "floor:roomIndex"
|
|
||||||
isDescentComplete: boolean
|
|
||||||
|
|
||||||
// Non-combat room tracking (climbing spec §4.8–§4.12)
|
|
||||||
// Note: libraryStayed and recoveryStayed live on the currentRoom object, not as
|
|
||||||
// top-level state fields. This keeps per-room transient state co-located.
|
|
||||||
libraryStayed: boolean // on currentRoom; true if player already used "Stay 1 Hour More" in current library room
|
|
||||||
recoveryStayed: boolean // on currentRoom; true if player already used "Stay 1 Hour More" in current recovery room
|
|
||||||
```
|
|
||||||
|
|
||||||
> `isDescending: boolean` (legacy alias) can be removed in favour of `climbDirection === 'down'`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Code Style Notes
|
|
||||||
|
|
||||||
- Room count uses the same deterministic seed on descent as ascent: `seed = floor × 12345 + runId`.
|
|
||||||
- `roomResetState` and `clearedRooms` use composite string keys (`"floor:roomIndex"`) to avoid
|
|
||||||
nested object complexity.
|
|
||||||
- Descent-related state is **not persisted** — a page reload mid-descent forfeits the run.
|
|
||||||
- All activity log calls go through the existing `addActivityLog(type, msg, details)` action.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Testing
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
1. `getRoomsForFloor` — same output for same (floor, seed); returns 1 for guardian floors.
|
|
||||||
2. `generateSpireRoomType` — rare roll produces recovery/treasure/library at correct ratios; guardian floor override works; puzzle floor override works.
|
|
||||||
3. `advanceRoomOrFloor` ascending — increments roomIndex; on last room, increments floor and resets roomIndex to 0.
|
|
||||||
4. `advanceRoomOrFloor` descending — decrements roomIndex; at roomIndex 0, moves to previous floor at `roomsPerFloor - 1`; at exitFloor R0, sets `isDescentComplete`.
|
|
||||||
5. Per-room reset — each room rolls independently; two rooms on the same floor can have different outcomes.
|
|
||||||
6. Library room — takes 1 hour, grants 25× XP to random unlocked discipline, stay button works once, skip button works.
|
|
||||||
7. Recovery room — takes 1 hour, grants 10× regen/conversion, stay button works once, skip button works.
|
|
||||||
8. Treasure room — takes 1 hour, grants 2–15 items scaling with floor, loot logged, skip button works.
|
|
||||||
9. Puzzle room — base time scales with floor (4–24h), attunement reduction up to 90%, mandatory (no skip/stay).
|
|
||||||
10. `spireKey` — `startFloor` and `exitFloor` correctly reflect `1 + spireKey × 2`.
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
1. Full ascent then descent — player reaches F3 R4, starts descent, verifies F3 R4→R3→R2→R1→R0, then F2 last_room→...→R0, then F1 last_room→...→R0 (if startFloor = F1).
|
|
||||||
2. Per-room reset independence — mock random so room 0 resets and room 1 does not on the same floor.
|
|
||||||
3. Exit gating — "Exit Spire" not visible until `isDescentComplete` is true.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Boundaries / Out of Scope
|
|
||||||
|
|
||||||
- Visual animations for loot drops or room transitions.
|
|
||||||
- Sound effects.
|
|
||||||
- New loot drop definitions (use existing `LOOT_DROPS` data).
|
|
||||||
- New puzzle definitions (use existing `PUZZLE_ROOMS` data).
|
|
||||||
- Golem summoning lifecycle (see combat spec §6).
|
|
||||||
- DoT / debuff runtime processing (see combat spec §5).
|
|
||||||
- Incursion's effect on mana regen during spire (handled in manaStore, not here).
|
|
||||||
- Auto-climb / auto-descend automation.
|
|
||||||
- Per-floor rewards (insight, mana drops) — handled by `onFloorCleared` in combat-tick.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Acceptance Criteria
|
|
||||||
|
|
||||||
| # | Criterion |
|
|
||||||
|---|---|
|
|
||||||
| AC-1 | `spireKey 0` starts at F1; `spireKey 1` starts at F3; `spireKey 2` starts at F5. |
|
|
||||||
| AC-2 | Entering spire starts at `startFloor` R0; rooms advance automatically on clear. |
|
|
||||||
| AC-3 | Each room shows "Room X / Y" and the room type in the UI. |
|
|
||||||
| AC-4 | After clearing last room on floor N, player moves to F(N+1) R0 with new room count. |
|
|
||||||
| AC-5 | "Descend" button is available at any point during ascent. |
|
|
||||||
| AC-6 | Descent traverses rooms in exact reverse (R_max → R0 per floor, then floor-1). |
|
|
||||||
| AC-7 | Each room on descent rolls its reset independently (50%); two rooms on the same floor can differ. |
|
|
||||||
| AC-8 | Skipped rooms (no reset) log an activity entry and auto-advance immediately. |
|
|
||||||
| AC-9 | Library room takes 1 hour, grants 25× XP to a random unlocked discipline, has skip + stay buttons. |
|
|
||||||
| AC-10 | Recovery room takes 1 hour, grants 10× mana regen and conversion rates for all unlocked types, has skip + stay buttons. |
|
|
||||||
| AC-11 | Treasure room takes 1 hour, grants 2–15 items scaling with floor (mostly materials, rare equipment), loot listed in activity log, has skip button. |
|
|
||||||
| AC-12 | Puzzle room takes up to 24 hours (floor-scaled), reduced by attunement levels (up to 90% reduction), no skip/stay buttons, mandatory completion. |
|
|
||||||
| AC-13 | All non-combat rooms show a progress bar with thematic description text. |
|
|
||||||
| AC-14 | "Stay 1 Hour More" button works once per library/recovery room, then disables. |
|
|
||||||
| AC-15 | "Skip" button on library/recovery/treasure advances immediately. |
|
|
||||||
| AC-16 | "Exit Spire" is only visible when `isDescentComplete === true`. |
|
|
||||||
| AC-17 | Guardian rooms that reset on descent re-initialize full guardian defensive state. |
|
|
||||||
| AC-18 | Activity log contains an entry for every room skip, reset, clear, floor transition, non-combat room event, and spire entry/exit. |
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
# Spire Combat System — Design Spec
|
|
||||||
|
|
||||||
> Describes how individual spire rooms are fought: weapons, spell autocasting,
|
|
||||||
> mana costs, damage calculation, elemental matchups, armor, shields, barriers,
|
|
||||||
> enemy modifiers, debuffs/DoT, golems, and the combat tick pipeline.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Objective
|
|
||||||
|
|
||||||
Spire combat is the micro-game fought in every combat room. The player does **not**
|
|
||||||
manually trigger attacks — all weapons and golems fight automatically on their own
|
|
||||||
timers. Early game this means one staff autocasting one spell; late game it can mean
|
|
||||||
multiple weapons each on their own cast timer, plus golems attacking in parallel.
|
|
||||||
|
|
||||||
**Design goals:**
|
|
||||||
- Combat is fully automatic once a room is entered. No input required.
|
|
||||||
- Damage math is transparent and multiplicative: base × discipline × boon × element × crit.
|
|
||||||
- Enemies have meaningful defensive variety via modifiers (armored, mage, shield, agile, swarm).
|
|
||||||
- Guardian bosses have an additional layer of defense (shield pool, percentage barrier, health regen).
|
|
||||||
- The player is **immortal** — no player HP, no armor, no healing, no lifesteal.
|
|
||||||
- Room clearing is determined by total enemy HP reaching 0, which triggers advancement.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Combat Sources
|
|
||||||
|
|
||||||
There are three independent sources of damage, each running on its own timer:
|
|
||||||
|
|
||||||
| Source | Mana Cost | Attack Speed | Damage | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| **Staff / spells** | Yes — per cast | Determined by spell's `castSpeed` | Moderate–high; scales with enchantments | Can apply debuffs/DoT/special effects |
|
|
||||||
| **Sword / melee** | None | Determined by weapon's `attackSpeed` stat | Lower than spells; fast | Elemental damage via enchantment; no mana drain |
|
|
||||||
| **Golems** | Maintenance cost per tick (not per attack) | Per-golem `attackSpeed` | Variable by golem tier | See §6 |
|
|
||||||
|
|
||||||
### 2.1 Player Does Not Choose Spells
|
|
||||||
|
|
||||||
The player **does not select which spell to cast**. All spells granted by equipped
|
|
||||||
weapons are autocast simultaneously, each on its own independent cast timer.
|
|
||||||
|
|
||||||
- **Early game:** One staff with one spell → one autocast timer.
|
|
||||||
- **Late game:** Multiple weapons with multiple spells → multiple independent timers,
|
|
||||||
all firing in parallel.
|
|
||||||
- The late-game ability to manually prioritise or pin specific spells is a prestige/
|
|
||||||
discipline unlock and is **out of scope for the initial implementation**.
|
|
||||||
|
|
||||||
### 2.2 Staves (Spell Weapons)
|
|
||||||
|
|
||||||
- Grant spells via `effect.type === 'spell'` enchantments.
|
|
||||||
- Each equipped staff can carry one or more spell enchantments.
|
|
||||||
- Each spell on a staff runs its own `castProgress` accumulator.
|
|
||||||
- Casting a spell costs mana (raw or elemental, per the spell's `cost` definition).
|
|
||||||
- If the player cannot afford a spell's cost, that spell's cast is held (progress
|
|
||||||
does not reset) until mana is available.
|
|
||||||
|
|
||||||
### 2.3 Swords (Melee Weapons)
|
|
||||||
|
|
||||||
- Deal physical + optional elemental damage via `effect.type === 'bonus'` enchantments
|
|
||||||
(e.g. `fireAttack`, `waterAttack` enchant types).
|
|
||||||
- Cost **no mana** per swing.
|
|
||||||
- Faster attack speed than spells but lower damage per hit.
|
|
||||||
- Use the **same elemental matchup table** as spells (1.25× resonance, 1.5× super effective,
|
|
||||||
0.75× weak — see §4.2).
|
|
||||||
- Sword auto-attacks run on their own `meleeProgress` accumulator, independent of spells.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Combat Tick Pipeline
|
|
||||||
|
|
||||||
### 3.1 Tick Overview (every 200ms / `HOURS_PER_TICK = 0.04`)
|
|
||||||
|
|
||||||
```
|
|
||||||
gameStore.tick()
|
|
||||||
└─ if currentAction === 'climb':
|
|
||||||
└─ processCombatTick(combatStore, ...)
|
|
||||||
├─ for each equipped spell (each on own castProgress):
|
|
||||||
│ ├─ castProgress += HOURS_PER_TICK × spell.castSpeed × attackSpeedMult
|
|
||||||
│ └─ while castProgress >= 1 AND canAffordCost:
|
|
||||||
│ ├─ deductSpellCost()
|
|
||||||
│ ├─ calcDamage() → apply elemental + crit
|
|
||||||
│ ├─ onDamageDealt(dmg) → specials + enemy defenses
|
|
||||||
│ ├─ applySpellEffects() → debuffs / DoT (§5)
|
|
||||||
│ └─ applyDamageToRoom(finalDmg)
|
|
||||||
│
|
|
||||||
├─ for each equipped sword (each on own meleeProgress):
|
|
||||||
│ ├─ meleeProgress += HOURS_PER_TICK × sword.attackSpeed
|
|
||||||
│ └─ while meleeProgress >= 1:
|
|
||||||
│ ├─ calcMeleeDamage() → elemental matchup applied
|
|
||||||
│ ├─ onDamageDealt(dmg) → enemy defenses (no specials for melee)
|
|
||||||
│ └─ applyDamageToRoom(finalDmg)
|
|
||||||
│
|
|
||||||
├─ for each active golem (§6):
|
|
||||||
│ ├─ golemProgress += HOURS_PER_TICK × golem.attackSpeed
|
|
||||||
│ ├─ check maintenance cost (deduct or dismiss golem)
|
|
||||||
│ └─ while golemProgress >= 1:
|
|
||||||
│ ├─ calcGolemDamage()
|
|
||||||
│ ├─ applyGolemEffects() → per-golem special effects
|
|
||||||
│ └─ applyDamageToRoom(finalDmg)
|
|
||||||
│
|
|
||||||
├─ tick active DoT/debuff effects on enemies (§5.3)
|
|
||||||
│
|
|
||||||
└─ if allEnemyHP <= 0:
|
|
||||||
onRoomCleared() → advanceRoomOrFloor()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 `applyDamageToRoom`
|
|
||||||
|
|
||||||
```
|
|
||||||
applyDamageToRoom(dmg, targetEnemy?):
|
|
||||||
if spell is AoE and targetEnemy is null:
|
|
||||||
// distribute damage across all enemies
|
|
||||||
for each enemy in room:
|
|
||||||
enemy.hp = max(0, enemy.hp - dmg)
|
|
||||||
else:
|
|
||||||
target = targetEnemy ?? lowestHPEnemy()
|
|
||||||
target.hp = max(0, target.hp - dmg)
|
|
||||||
|
|
||||||
if all enemies.hp === 0:
|
|
||||||
onRoomCleared()
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Targeting:** Non-AoE attacks target the enemy with the lowest current HP by
|
|
||||||
> default (focus-fire to clear rooms faster). This is implicit — no UI selection.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Damage Calculation
|
|
||||||
|
|
||||||
### 4.1 Spell Damage (`calcDamage` in `combat-utils.ts`)
|
|
||||||
|
|
||||||
```
|
|
||||||
baseDmg = spell.baseDamage + disciplineEffects.baseDamageBonus
|
|
||||||
pct = 1 + disciplineEffects.baseDamageMultiplier
|
|
||||||
rawMult = 1 + boons.rawDamage / 100
|
|
||||||
elemMult = 1 + boons.elementalDamage / 100
|
|
||||||
critChance = boons.critChance / 100
|
|
||||||
critMult = 1.5 + boons.critDamage / 100
|
|
||||||
|
|
||||||
damage = baseDmg × pct × rawMult × elemMult
|
|
||||||
|
|
||||||
if spell.elem !== 'raw':
|
|
||||||
damage ×= getElementalBonus(spell.elem, enemy.element)
|
|
||||||
|
|
||||||
if Math.random() < critChance:
|
|
||||||
damage ×= critMult
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Elemental Matchup (`getElementalBonus`)
|
|
||||||
|
|
||||||
Used by both spells and swords.
|
|
||||||
|
|
||||||
| Relationship | Multiplier |
|
|
||||||
|---|---|
|
|
||||||
| Spell/sword element === enemy element | 1.25× (resonance) |
|
|
||||||
| Spell/sword element is the **counter** of enemy element | 1.5× (super effective) |
|
|
||||||
| Enemy element is the **counter** of spell/sword element | 0.75× (weak) |
|
|
||||||
| Raw element (no element) | 1.0× (neutral) |
|
|
||||||
| All other combinations | 1.0× (neutral) |
|
|
||||||
|
|
||||||
Elemental counters (partial list):
|
|
||||||
```
|
|
||||||
fire ↔ water air ↔ earth light ↔ dark
|
|
||||||
frost ↔ fire lightning → water earth → lightning
|
|
||||||
```
|
|
||||||
|
|
||||||
Composite element counters:
|
|
||||||
```
|
|
||||||
blackflame counters: frost, water, light (frost/water/light also counter blackflame)
|
|
||||||
radiantflames counters: frost, water, dark (frost/water/dark also counter radiantflames)
|
|
||||||
```
|
|
||||||
|
|
||||||
> All 22 mana types (base, utility, composite, exotic) are valid spell elements.
|
|
||||||
> Composite/exotic elements use the same matchup table; multi-element spells use
|
|
||||||
> `getMultiElementBonus()` which applies `Math.min()` across all enemy element matchups,
|
|
||||||
> making it harder to exploit a single counter-element.
|
|
||||||
|
|
||||||
**Multi-element guardians:** `getMultiElementBonus()` uses `Math.min()` across all
|
|
||||||
guardian elements, making it harder to exploit a single counter-element.
|
|
||||||
|
|
||||||
### 4.3 Melee Damage (`calcMeleeDamage`)
|
|
||||||
|
|
||||||
```
|
|
||||||
baseDmg = sword.baseDamage + sword.elementalEnchantDamage
|
|
||||||
damage = baseDmg × getElementalBonus(sword.enchantElement, enemy.element)
|
|
||||||
// No critChance, no discipline damage bonus for melee in v1
|
|
||||||
// attackSpeedMult from equipment does apply to meleeProgress accumulation
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Discipline Combat Specials
|
|
||||||
|
|
||||||
Applied inside `onDamageDealt` before enemy defenses:
|
|
||||||
|
|
||||||
| Special | Condition | Effect |
|
|
||||||
|---|---|---|
|
|
||||||
| **Executioner** | Enemy HP < 25% of maxHP | `dmg × = 2` |
|
|
||||||
| **Berserker** | Player rawMana < 50% of maxMana | `dmg × = 1.5` |
|
|
||||||
|
|
||||||
Both can apply simultaneously (stack multiplicatively). Melee attacks do **not**
|
|
||||||
trigger Executioner or Berserker in v1.
|
|
||||||
|
|
||||||
### 4.5 Speed Room + Agile Modifier Interaction
|
|
||||||
|
|
||||||
When a room is of type `speed` **and** the enemy also has the `agile` modifier,
|
|
||||||
the effective dodge chance is computed additively:
|
|
||||||
|
|
||||||
```
|
|
||||||
effectiveDodge = speedRoomBonus + agileDodgeChance
|
|
||||||
// e.g. speedRoom adds +0.20, agile adds up to 0.55 → cap at 0.75
|
|
||||||
effectiveDodge = min(0.75, speedRoomBonus + agileDodgeChance)
|
|
||||||
```
|
|
||||||
|
|
||||||
`speedRoomBonus` is a constant (suggested: `0.20`). This ensures speed rooms remain
|
|
||||||
meaningfully harder than plain combat rooms even without an agile modifier.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Enemy Defenses
|
|
||||||
|
|
||||||
### 5.1 Enemy Modifiers
|
|
||||||
|
|
||||||
Each enemy can have up to **2 modifiers** (randomly selected, floored-gated):
|
|
||||||
|
|
||||||
| Modifier | Min Floor | Max Chance | Stat Effect |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `armored` | 5 | 40% | `armor = min(0.45, floor × 0.003)` — % damage reduction |
|
|
||||||
| `shield` | 10 | 25% | One-time barrier pool = 15% of maxHP |
|
|
||||||
| `agile` | 12 | 25% | `dodgeChance = min(0.55, floor × 0.003)` |
|
|
||||||
| `mage` | 15 | 30% | `barrier = min(0.4, floor × 0.003)`; recharges 5%/tick |
|
|
||||||
| `swarm` | 8 | 15% | Spawns 3–7 enemies at 35% HP each |
|
|
||||||
|
|
||||||
### 5.2 Damage Reduction Order (Regular Enemies)
|
|
||||||
|
|
||||||
```
|
|
||||||
onDamageDealt(dmg, enemy):
|
|
||||||
// 1. Dodge check
|
|
||||||
if enemy.dodgeChance > 0 && Math.random() < enemy.dodgeChance:
|
|
||||||
activityLog("Attack dodged!")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
// 2. Barrier absorption (percentage)
|
|
||||||
if enemy.barrier > 0:
|
|
||||||
dmg ×= (1 - enemy.barrier)
|
|
||||||
// Mage barrier recharges: enemy.barrier = min(barrierMax, enemy.barrier + rechargeRate)
|
|
||||||
|
|
||||||
// 3. Armor reduction (flat percentage)
|
|
||||||
if enemy.armor > 0:
|
|
||||||
dmg ×= (1 - enemy.armor)
|
|
||||||
|
|
||||||
return dmg
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** In the current codebase, armor, barrier, and dodge for regular enemies
|
|
||||||
> are stored on `EnemyState` but **not yet applied** in the pipeline. This spec defines
|
|
||||||
> the intended implementation. See §9 for full gap list.
|
|
||||||
|
|
||||||
### 5.3 Guardian Defensive Pipeline
|
|
||||||
|
|
||||||
Applied inside `makeOnDamageDealt` in `combat-tick.ts` (already partially implemented):
|
|
||||||
|
|
||||||
```
|
|
||||||
onDamageDealt(dmg) [guardian room]:
|
|
||||||
// Specials first (Executioner, Berserker)
|
|
||||||
dmg = applyDisciplineSpecials(dmg)
|
|
||||||
|
|
||||||
// Regen ticks
|
|
||||||
guardianShield = min(shieldMax, guardianShield + shieldRegen × HOURS_PER_TICK)
|
|
||||||
guardianBarrier = min(barrierMax, guardianBarrier + barrierRegen × HOURS_PER_TICK)
|
|
||||||
|
|
||||||
// Shield absorption (flat pool first)
|
|
||||||
absorb = min(guardianShield, dmg)
|
|
||||||
guardianShield -= absorb
|
|
||||||
dmg -= absorb
|
|
||||||
|
|
||||||
// Barrier reduction (percentage)
|
|
||||||
if guardianBarrier > 0:
|
|
||||||
dmg ×= (1 - guardianBarrier)
|
|
||||||
|
|
||||||
// Health regen (reduces net damage)
|
|
||||||
healAmount = healthRegenIsPercent
|
|
||||||
? floor(floorMaxHP × healthRegen / 100 × HOURS_PER_TICK)
|
|
||||||
: floor(healthRegen × HOURS_PER_TICK)
|
|
||||||
dmg -= healAmount // can go negative, effectively healing floorHP
|
|
||||||
|
|
||||||
return dmg
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Debuffs and Damage-Over-Time
|
|
||||||
|
|
||||||
### 6.1 Overview
|
|
||||||
|
|
||||||
Some spells and golem attacks apply effects that persist on enemies between ticks.
|
|
||||||
These are tracked in `EnemyState.activeEffects: ActiveEffect[]`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ActiveEffect {
|
|
||||||
type: EffectType;
|
|
||||||
remainingDuration: number; // in ticks
|
|
||||||
magnitude: number; // effect strength (damage per tick, % reduction, etc.)
|
|
||||||
source: 'spell' | 'golem';
|
|
||||||
bypassArmor?: boolean;
|
|
||||||
bypassBarrier?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EffectType =
|
|
||||||
| 'burn' // fire DoT per tick
|
|
||||||
| 'poison' // nature DoT per tick, stacks
|
|
||||||
| 'bleed' // physical DoT per tick
|
|
||||||
| 'freeze' // slows enemy (future: reduces attack speed of enemy, if relevant)
|
|
||||||
| 'slow' // reduces enemy barrier/dodge temporarily
|
|
||||||
| 'curse' // amplifies incoming damage by %
|
|
||||||
| 'armor_corrode' // reduces armor value by % for duration
|
|
||||||
| 'blind' // increases dodge miss rate on enemy attacks (N/A — player immortal; repurpose as accuracy debuff)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Applying Effects
|
|
||||||
|
|
||||||
Spells that apply effects include the effect definition in their `SpellDefinition`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface SpellDefinition {
|
|
||||||
// ...existing fields...
|
|
||||||
onHitEffect?: {
|
|
||||||
type: EffectType;
|
|
||||||
duration: number; // ticks
|
|
||||||
magnitude: number;
|
|
||||||
bypassArmor?: boolean;
|
|
||||||
bypassBarrier?: boolean;
|
|
||||||
applyChance?: number; // 0-1, defaults to 1.0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
On a successful hit:
|
|
||||||
```
|
|
||||||
if spell.onHitEffect && Math.random() < (spell.onHitEffect.applyChance ?? 1.0):
|
|
||||||
enemy.activeEffects.push({ ...spell.onHitEffect, remainingDuration: spell.onHitEffect.duration })
|
|
||||||
activityLog("${enemy.name} afflicted with ${effectType}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 Effect Tick Processing
|
|
||||||
|
|
||||||
Each combat tick, after all weapon attacks, active effects are processed:
|
|
||||||
|
|
||||||
```
|
|
||||||
tickActiveEffects(enemy):
|
|
||||||
for each effect in enemy.activeEffects:
|
|
||||||
if effect is DoT (burn/poison/bleed):
|
|
||||||
dmg = effect.magnitude
|
|
||||||
if effect.bypassArmor: // skip armor reduction step
|
|
||||||
dmg applied directly to enemy.hp
|
|
||||||
elif effect.bypassBarrier:
|
|
||||||
dmg applied after armor, before barrier
|
|
||||||
else:
|
|
||||||
dmg = applyEnemyDefenses(dmg, enemy)
|
|
||||||
enemy.hp = max(0, enemy.hp - dmg)
|
|
||||||
|
|
||||||
elif effect is 'curse':
|
|
||||||
// Tracked on enemy; checked in calcDamage to amplify incoming damage
|
|
||||||
incomingDamageMult × = (1 + effect.magnitude)
|
|
||||||
|
|
||||||
elif effect is 'armor_corrode':
|
|
||||||
// Temporarily reduce armor
|
|
||||||
enemy.effectiveArmor = max(0, enemy.armor - effect.magnitude)
|
|
||||||
|
|
||||||
effect.remainingDuration -= 1
|
|
||||||
if effect.remainingDuration <= 0:
|
|
||||||
remove effect from enemy.activeEffects
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.4 Spell Effect Examples
|
|
||||||
|
|
||||||
| Spell type | Effect | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| Fire spells | `burn` — fire DoT, 3–5 ticks | Standard DoT |
|
|
||||||
| Death spells | `curse` — +20% incoming damage for 4 ticks | Amplifier (no "nature" element) |
|
|
||||||
| Lightning spells | `armor_corrode` — -15% armor for 3 ticks | Bypass synergy |
|
|
||||||
| Frost spells | `freeze` / `slow` — reduces effective dodge | Soft CC (note: "frost", not "ice") |
|
|
||||||
| Void/shadow spells | `bypassArmor: true` | Direct to HP |
|
|
||||||
| Certain advanced spells | `bypassBarrier: true` | Ignores shield/barrier |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Spell Autocasting — Late Game Manual Override
|
|
||||||
|
|
||||||
The initial implementation autocasts all equipped spells simultaneously. The
|
|
||||||
late-game unlock (via prestige/discipline) that allows manual spell selection is
|
|
||||||
**out of scope for v1**. When implemented it will:
|
|
||||||
|
|
||||||
- Allow the player to pin one spell per weapon as the "priority" cast.
|
|
||||||
- Other spells on the same weapon continue autocasting normally.
|
|
||||||
- UI: a toggle or pin icon next to each spell in the equipment panel.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Incursion Effects on Combat
|
|
||||||
|
|
||||||
Incursion (days 20–30) affects **mana regeneration only** — it does not modify
|
|
||||||
enemy stats, spell damage, or golem behaviour directly.
|
|
||||||
|
|
||||||
```
|
|
||||||
effectiveRegen = max(0, baseRegen × (1 - incursionStrength) × meditationMult - conversionCost)
|
|
||||||
```
|
|
||||||
|
|
||||||
At peak incursion (day 30), regen falls to 5% of base. Practical effects:
|
|
||||||
- Spells that cannot be afforded are held (cast timer pauses at 100%).
|
|
||||||
- Golems with unsatisfied maintenance costs are dismissed (see §9.3).
|
|
||||||
- Sword attacks are unaffected (no mana cost).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Golemancy System
|
|
||||||
|
|
||||||
### 9.1 Overview
|
|
||||||
|
|
||||||
Golemancy is the **Fabricator attunement's** combat contribution. Players design
|
|
||||||
custom golems from components (Core + Frame + Mind Circuit + Enchantments), then
|
|
||||||
configure a loadout. Golems are summoned automatically at room entry, fight alongside
|
|
||||||
the player, and disappear after a fixed number of rooms or if their maintenance cost
|
|
||||||
cannot be met.
|
|
||||||
|
|
||||||
### 9.2 Golem Loadout (Outside Spire)
|
|
||||||
|
|
||||||
The player configures a **golem loadout** from the Golemancy tab before entering
|
|
||||||
the spire. The loadout defines which golem designs to attempt to summon and in what
|
|
||||||
order. This configuration persists across rooms but not across spire runs.
|
|
||||||
|
|
||||||
### 9.3 Summoning on Room Entry
|
|
||||||
|
|
||||||
When the player enters a new combat room, `summonGolemsOnRoomEntry()` iterates the
|
|
||||||
loadout in priority order:
|
|
||||||
|
|
||||||
```
|
|
||||||
summonGolemsOnRoomEntry(loadout, rawMana, elements, currentFloor, existingActiveGolems, disciplineSlotsBonus, fabricatorLevel):
|
|
||||||
for each entry in loadout:
|
|
||||||
if !entry.enabled → skip
|
|
||||||
if activeGolems.length >= totalSlots → break // max 7
|
|
||||||
if already active → skip
|
|
||||||
resolve components (Core, Frame, Mind Circuit) from design
|
|
||||||
stats = computeGolemStats(componentDesign)
|
|
||||||
if player can afford stats.totalSummonCost:
|
|
||||||
deduct summon cost from player mana
|
|
||||||
activeGolems.push({
|
|
||||||
designId: entry.designId,
|
|
||||||
summonedFloor: currentFloor,
|
|
||||||
attackProgress: 0,
|
|
||||||
roomsRemaining: stats.maxRoomDuration,
|
|
||||||
currentMana: stats.manaCapacity, // starts full
|
|
||||||
spellCastIndex: 0,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
log "Not enough mana — skipped"
|
|
||||||
```
|
|
||||||
|
|
||||||
Total slots = `min(7, floor(fabricatorLevel / 2) + disciplineBonus)`.
|
|
||||||
|
|
||||||
Golems that could not be summoned (insufficient mana) are **not re-attempted**
|
|
||||||
within the same room. They will be attempted again on the next room entry.
|
|
||||||
|
|
||||||
### 9.4 Golem Combat
|
|
||||||
|
|
||||||
Each active golem attacks on its own `attackProgress` timer:
|
|
||||||
|
|
||||||
```
|
|
||||||
attackProgress += HOURS_PER_TICK × frame.attackSpeed
|
|
||||||
while attackProgress >= 1:
|
|
||||||
if mindCircuit has spells && golem.currentMana >= spellCost:
|
|
||||||
cast spell: damage = baseSpellDamage × frame.magicAffinity
|
|
||||||
golem.currentMana -= spellCost
|
|
||||||
spellCastIndex = (spellCastIndex + 1) % selectedSpells.length
|
|
||||||
else:
|
|
||||||
dmg = frame.baseDamage × (1 + frame.armorPierce)
|
|
||||||
apply enchantment effects (burn, slow, etc.)
|
|
||||||
applyDamageToRoom(dmg)
|
|
||||||
attackProgress -= 1
|
|
||||||
```
|
|
||||||
|
|
||||||
Golems ignore Executioner and Berserker discipline specials.
|
|
||||||
|
|
||||||
### 9.5 Maintenance Cost
|
|
||||||
|
|
||||||
Each tick, `processGolemMaintenance()` checks upkeep for each active golem:
|
|
||||||
|
|
||||||
```
|
|
||||||
upkeepPerTick = core.manaRegen × 2 × HOURS_PER_TICK
|
|
||||||
if player has enough of core.primaryManaType:
|
|
||||||
deduct upkeepPerTick from player element mana
|
|
||||||
else:
|
|
||||||
dismiss(golem)
|
|
||||||
log "${name} dismissed — insufficient mana for upkeep"
|
|
||||||
```
|
|
||||||
|
|
||||||
A dismissed golem is **not re-summoned mid-room**. It will be re-attempted on the
|
|
||||||
next room entry if mana has recovered.
|
|
||||||
|
|
||||||
### 9.6 Room Duration Limit
|
|
||||||
|
|
||||||
`countdownGolemRoomDuration()` runs on room clear:
|
|
||||||
|
|
||||||
```
|
|
||||||
for each activeGolem:
|
|
||||||
golem.roomsRemaining -= 1
|
|
||||||
if golem.roomsRemaining <= 0:
|
|
||||||
dismiss(golem)
|
|
||||||
log "${name} has faded after ${maxRoomDuration} rooms"
|
|
||||||
```
|
|
||||||
|
|
||||||
Room duration ticks down on room clear, not on room entry — golems persist through
|
|
||||||
the full room they were summoned in.
|
|
||||||
|
|
||||||
### 9.7 Golem Data Shape
|
|
||||||
|
|
||||||
The runtime active golem type (`RuntimeActiveGolem` in `types/game.ts`):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface RuntimeActiveGolem {
|
|
||||||
designId: string; // Reference to the player's GolemDesign
|
|
||||||
summonedFloor: number; // Floor when golem was summoned
|
|
||||||
attackProgress: number; // Progress toward next attack (accumulated)
|
|
||||||
roomsRemaining: number; // Rooms before golem fades
|
|
||||||
currentMana: number; // Current mana in golem's own pool
|
|
||||||
spellCastIndex: number; // For alternating/cycling spell circuits
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The serialized design type (`SerializedGolemDesign` in `types/game.ts`):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface SerializedGolemDesign {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
coreId: string;
|
|
||||||
frameId: string;
|
|
||||||
mindCircuitId: string;
|
|
||||||
enchantmentIds: string[];
|
|
||||||
selectedManaTypes: string[];
|
|
||||||
selectedSpells: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Golem stats are computed from components via `computeGolemStats()` in
|
|
||||||
`data/golems/utils.ts`, which sums summon costs from all components and derives
|
|
||||||
upkeep from `core.manaRegen × 2`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. In-Game Time Display
|
|
||||||
|
|
||||||
The current in-game time (day and hour) should be visible during spire combat.
|
|
||||||
Display location: **SpireHeader** or **RoomDisplay** component, shown as a small
|
|
||||||
badge or subtitle, e.g. `"Day 4, Hour 12"` or `"D4 H12"`.
|
|
||||||
|
|
||||||
The value is read from `gameStore.day` and `gameStore.hour` (already tracked). No
|
|
||||||
new state is needed — only a UI read.
|
|
||||||
|
|
||||||
This is especially relevant as incursion begins at Day 20, so the player needs to
|
|
||||||
be able to gauge how much time they have left without leaving the spire view.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Known Gaps / Incomplete Features
|
|
||||||
|
|
||||||
The following are defined in data but not yet wired into the runtime pipeline.
|
|
||||||
They are **in scope for the implementation this spec describes**:
|
|
||||||
|
|
||||||
| Feature | Where Defined | Status | This Spec's Requirement |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Enemy armor reduction | `EnemyState.armor`, `MODIFIER_CONFIG.armored` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 |
|
|
||||||
| Enemy barrier absorption | `EnemyState.barrier`, `MODIFIER_CONFIG.mage/shield` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 |
|
|
||||||
| Enemy dodge roll | `EnemyState.dodgeChance`, `MODIFIER_CONFIG.agile` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 |
|
|
||||||
| Mage barrier recharge | `MODIFIER_CONFIG.mage.barrierRechargeRate` | Data-only | Tick in `onDamageDealt` §5.2 |
|
|
||||||
| Guardian armor | `GuardianDef.armor` | Data-only | Add check to guardian pipeline §5.3 |
|
|
||||||
| DoT / debuff system | Spell/enchantment type defs | **Implemented** — `dot-runtime.ts` complete and wired into combat tick; curse amplification added (issue #286) | Verified working |
|
|
||||||
| Golemancy combat | Full golem data + runtime | **Implemented** — component-based system complete | Verified working |
|
|
||||||
| Sword melee attacks | Weapon type exists | **Implemented** — meleeProgress with enemy defense application (issue #285) | Add `meleeProgress` per §3.1 |
|
|
||||||
| AoE target distribution | `SpellDefinition.aoe` flag | Partial | Implement per §3.2 |
|
|
||||||
| `elemMasteryBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now |
|
|
||||||
| `guardianBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. State Fields (Combat-Relevant)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Per-weapon cast timers (replace single castProgress)
|
|
||||||
weaponCastProgress: Record<instanceId, number> // one entry per equipped weapon
|
|
||||||
|
|
||||||
// Per-sword melee timers
|
|
||||||
meleeSwordProgress: Record<instanceId, number>
|
|
||||||
|
|
||||||
// Active golems
|
|
||||||
activeGolems: ActiveGolem[] // summoned this run
|
|
||||||
|
|
||||||
// Enemy state extension
|
|
||||||
interface EnemyState {
|
|
||||||
// ...existing fields...
|
|
||||||
activeEffects: ActiveEffect[] // NEW — live debuffs/DoTs
|
|
||||||
effectiveArmor: number // NEW — armor after corrode effects
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Acceptance Criteria
|
|
||||||
|
|
||||||
| # | Criterion |
|
|
||||||
|---|---|
|
|
||||||
| AC-1 | All equipped spells autocast simultaneously on independent timers — no manual input needed. |
|
|
||||||
| AC-2 | Swords auto-attack on their own timer with no mana cost; elemental matchup applies. |
|
|
||||||
| AC-3 | A player with no equipped weapons still enters the spire (golems-only or empty run). |
|
|
||||||
| AC-4 | Damage formula: base × discipline × boon × elemental × crit produces correct results. |
|
|
||||||
| AC-5 | Elemental matchup applies correctly for both spells and swords. |
|
|
||||||
| AC-6 | Executioner doubles damage when enemy HP < 25%; Berserker grants 1.5× when low on mana. |
|
|
||||||
| AC-7 | Armored enemies reduce damage by their armor percentage. |
|
|
||||||
| AC-8 | Barrier enemies absorb a percentage of each hit before HP is reduced. |
|
|
||||||
| AC-9 | Agile enemies dodge attacks at their dodge chance rate. |
|
|
||||||
| AC-10 | Speed room + agile modifier combines additively for dodge chance (capped at 0.75). |
|
|
||||||
| AC-11 | Guardian shield absorbs flat damage before barrier reduces percentage damage. |
|
|
||||||
| AC-12 | DoT effects (burn, poison, etc.) tick each combat tick and expire after their duration. |
|
|
||||||
| AC-13 | `bypassArmor` effects skip the armor reduction step entirely. |
|
|
||||||
| AC-14 | Golems are summoned on room entry if mana allows; not re-summoned mid-room if dismissed. |
|
|
||||||
| AC-15 | Golem maintenance cost is deducted each tick; golems dismiss if cost cannot be met. |
|
|
||||||
| AC-16 | Golems disappear after `maxRoomDuration` rooms. |
|
|
||||||
| AC-17 | Current in-game time (day + hour) is visible in the spire combat UI. |
|
|
||||||
| AC-18 | Player has no HP, no armor, no healing — combat ends only when all enemies die. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Files Reference
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|---|---|
|
|
||||||
| `src/lib/game/stores/combat-actions.ts` | `processCombatTick` — main weapon/golem/DoT loop |
|
|
||||||
| `src/lib/game/stores/pipelines/combat-tick.ts` | `makeOnDamageDealt` — specials + guardian defenses |
|
|
||||||
| `src/lib/game/utils/combat-utils.ts` | `calcDamage`, `calcMeleeDamage`, `getElementalBonus` |
|
|
||||||
| `src/lib/game/utils/enemy-generator.ts` | `selectModifiers`, `applyModifiers`, `MODIFIER_CONFIG` |
|
|
||||||
| `src/lib/game/constants/spells.ts` | Spell registry (all tiers) |
|
|
||||||
| `src/lib/game/constants/elements.ts` | Element list, opposition cycle |
|
|
||||||
| `src/lib/game/constants/core.ts` | `HOURS_PER_TICK`, `INCURSION_START_DAY` |
|
|
||||||
| `src/lib/game/data/guardian-encounters.ts` | Guardian definitions |
|
|
||||||
| `src/lib/game/data/golems/` | Golem component definitions (4 cores, 7 frames, 4 mind circuits, 8 enchantments) |
|
|
||||||
| `src/lib/game/effects.ts` | `getUnifiedEffects` — merges all combat bonuses |
|
|
||||||
| `src/components/game/tabs/SpireCombatPage/SpireHeader.tsx` | In-game time display |
|
|
||||||
| `src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx` | Room type, enemy state, active effects |
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
|
||||||
|
|
||||||
test.use({
|
|
||||||
baseURL: 'http://localhost:3000/',
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function waitForMs(page: Page, ms: number) {
|
|
||||||
await page.waitForTimeout(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startFreshGame(page: Page) {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
await page.reload();
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
await waitForMs(page, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clickTab(page: Page, label: string) {
|
|
||||||
const tab = page.getByRole('tab', { name: new RegExp(label, 'i') }).first();
|
|
||||||
await tab.click();
|
|
||||||
await waitForMs(page, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clickBtn(page: Page, text: string) {
|
|
||||||
const btn = page.getByRole('button', { name: new RegExp(text, 'i') }).first();
|
|
||||||
await btn.click();
|
|
||||||
await waitForMs(page, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForBridge(page: Page) {
|
|
||||||
for (let attempt = 0; attempt < 30; attempt++) {
|
|
||||||
const ready = await page.evaluate(() => !!(window as any).__TEST__);
|
|
||||||
if (ready) return;
|
|
||||||
await waitForMs(page, 1000);
|
|
||||||
}
|
|
||||||
throw new Error('Debug bridge (window.__TEST__) not available after 30s');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run n game ticks synchronously via the debug bridge.
|
|
||||||
* Each tick advances the game by HOURS_PER_TICK (0.04) hours.
|
|
||||||
* 50 ticks ≈ 1 in-game hour, 1200 ticks ≈ 1 in-game day.
|
|
||||||
*/
|
|
||||||
async function runTicks(page: Page, n: number) {
|
|
||||||
await page.evaluate((count: number) => {
|
|
||||||
(window as any).__TEST__.runTicks(count);
|
|
||||||
}, n);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Test ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Combat Happy-Path: Spire Climb → Combat → Mana Recovery → Exit', () => {
|
|
||||||
|
|
||||||
test('climb spire, fight until mana drains, gather mana, descend, exit', async ({ page }) => {
|
|
||||||
test.setTimeout(600_000);
|
|
||||||
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 1: Start fresh game and wait for bridge
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 1: Starting fresh game...');
|
|
||||||
await startFreshGame(page);
|
|
||||||
await waitForMs(page, 1500);
|
|
||||||
await waitForBridge(page);
|
|
||||||
console.log('[TEST] Bridge ready!');
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 2: Set up prerequisites via Debug tab UI
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 2: Setting up prerequisites via Debug tab...');
|
|
||||||
await clickTab(page, 'debug');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// ── 2a. Fill raw mana using the debug buttons ────────────────────────────
|
|
||||||
console.log('[TEST] 2a. Filling raw mana via debug buttons...');
|
|
||||||
const fillManaBtn = page.getByTestId('debug-mana-fill');
|
|
||||||
await expect(fillManaBtn).toBeVisible({ timeout: 5000 });
|
|
||||||
await fillManaBtn.click();
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// Add +10K several times for plenty of mana
|
|
||||||
const plus10KBtn = page.getByTestId('debug-mana-add-10k');
|
|
||||||
await expect(plus10KBtn).toBeVisible({ timeout: 5000 });
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await plus10KBtn.click();
|
|
||||||
await waitForMs(page, 100);
|
|
||||||
}
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// ── 2b. Boost max mana via Raw Mana Mastery discipline XP ────────────────
|
|
||||||
console.log('[TEST] 2b. Boosting max mana via Raw Mana Mastery XP...');
|
|
||||||
|
|
||||||
// The Disciplines section is collapsed by default — expand it
|
|
||||||
const disciplinesHeader = page.locator('button', { hasText: /^Disciplines$/ }).first();
|
|
||||||
await disciplinesHeader.click();
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
|
|
||||||
// Find the Raw Mana Mastery discipline row via data-testid
|
|
||||||
const rawManaRow = page.getByTestId('debug-discipline-row-raw-mastery');
|
|
||||||
await expect(rawManaRow).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Activate Raw Mana Mastery first (discipline must exist in store before XP can be added)
|
|
||||||
const toggleBtn = page.getByTestId('debug-discipline-toggle-raw-mastery');
|
|
||||||
await expect(toggleBtn).toBeVisible({ timeout: 5000 });
|
|
||||||
await toggleBtn.click();
|
|
||||||
await waitForMs(page, 200);
|
|
||||||
|
|
||||||
// The +1K button within that row
|
|
||||||
const plus1KBtn = page.getByTestId('debug-discipline-add1k-raw-mastery');
|
|
||||||
await expect(plus1KBtn).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Click +1K fifteen times to get 15,000 XP
|
|
||||||
for (let i = 0; i < 15; i++) {
|
|
||||||
await plus1KBtn.click();
|
|
||||||
await waitForMs(page, 50);
|
|
||||||
}
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
|
|
||||||
// Verify discipline XP was set via the bridge
|
|
||||||
const rawMasteryXP = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useDisciplineStore.getState().disciplines?.['raw-mastery']?.xp || 0
|
|
||||||
);
|
|
||||||
console.log(`[TEST] Raw Mana Mastery XP: ${rawMasteryXP}`);
|
|
||||||
expect(rawMasteryXP).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// ── 2c. Fill mana to max ─────────────────────────────────────────────────
|
|
||||||
console.log('[TEST] 2c. Filling mana to max...');
|
|
||||||
await fillManaBtn.click();
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
const manaAfterFill = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useManaStore.getState().rawMana
|
|
||||||
);
|
|
||||||
console.log(`[TEST] Raw mana after fill: ${manaAfterFill}`);
|
|
||||||
expect(manaAfterFill).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 3: Enter the Spire via "Climb the Spire" button
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 3: Entering the Spire...');
|
|
||||||
await clickTab(page, 'spells');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
const climbBtn = page.getByRole('button', { name: /climb the spire/i }).first();
|
|
||||||
await expect(climbBtn).toBeVisible({ timeout: 10000 });
|
|
||||||
await climbBtn.click();
|
|
||||||
await waitForMs(page, 2000);
|
|
||||||
|
|
||||||
// Verify SpireCombatPage is showing
|
|
||||||
await expect(page.getByText('Floor 1').first()).toBeVisible({ timeout: 10000 });
|
|
||||||
console.log('[TEST] Spire combat page loaded!');
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 4: Fight in the Spire — run ticks to clear several rooms/floors
|
|
||||||
// manaBolt costs 3 raw mana per cast, deals 5 damage.
|
|
||||||
// Floor 1 HP = ~151. We run enough ticks to clear multiple floors.
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 4: Fighting in the Spire...');
|
|
||||||
|
|
||||||
const startMana = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useManaStore.getState().rawMana
|
|
||||||
);
|
|
||||||
const startFloor = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useCombatStore.getState().currentFloor
|
|
||||||
);
|
|
||||||
console.log(`[TEST] Starting: Floor ${startFloor}, Mana ${startMana}`);
|
|
||||||
|
|
||||||
// Run 6000 ticks (~2 minutes of game time, ~5 in-game hours).
|
|
||||||
// This should clear several floors worth of enemies.
|
|
||||||
console.log('[TEST] Running 6000 ticks of combat...');
|
|
||||||
await runTicks(page, 6000);
|
|
||||||
await waitForMs(page, 500); // let React re-render
|
|
||||||
|
|
||||||
const floorAfterCombat = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useCombatStore.getState().currentFloor
|
|
||||||
);
|
|
||||||
const manaAfterCombat = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useManaStore.getState().rawMana
|
|
||||||
);
|
|
||||||
console.log(`[TEST] After combat: Floor ${floorAfterCombat}, Mana ${manaAfterCombat}`);
|
|
||||||
expect(floorAfterCombat).toBeGreaterThan(startFloor);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 5: Continue fighting to drain more mana ─────────────────────────────
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 5: Continuing combat to drain more mana...');
|
|
||||||
await runTicks(page, 3000);
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
const manaAfterMoreCombat = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useManaStore.getState().rawMana
|
|
||||||
);
|
|
||||||
console.log(`[TEST] Mana after extended combat: ${manaAfterMoreCombat}`);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 6: Descend the spire back to floor 1 ───────────────────────────────
|
|
||||||
// Each "Climb Down" click descends one floor. We verify the floor actually
|
|
||||||
// decrements after each click.
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 6: Descending to floor 1...');
|
|
||||||
|
|
||||||
for (let i = 0; i < 200; i++) {
|
|
||||||
const floorNow = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useCombatStore.getState().currentFloor
|
|
||||||
);
|
|
||||||
if (floorNow <= 1) break;
|
|
||||||
|
|
||||||
const climbDownBtn = page.getByRole('button', { name: /climb down/i }).first();
|
|
||||||
const btnVisible = await climbDownBtn.isVisible({ timeout: 2000 }).catch(() => false);
|
|
||||||
if (btnVisible) {
|
|
||||||
await climbDownBtn.click();
|
|
||||||
// Wait for the floor to actually decrement
|
|
||||||
const expectedFloor = floorNow - 1;
|
|
||||||
await page.waitForFunction(
|
|
||||||
(target: number) => (window as any).__TEST__.useCombatStore.getState().currentFloor === target,
|
|
||||||
expectedFloor,
|
|
||||||
{ timeout: 5000 }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log('[TEST] Climb Down button not visible, breaking');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const floorAfterDescend = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useCombatStore.getState().currentFloor
|
|
||||||
);
|
|
||||||
console.log(`[TEST] Floor after descending: ${floorAfterDescend}`);
|
|
||||||
expect(floorAfterDescend).toBe(1);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 7: Exit the Spire ───────────────────────────────────────────────────
|
|
||||||
// The Exit Spire button should only be visible on floor 1.
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 7: Exiting the Spire...');
|
|
||||||
|
|
||||||
// Verify we are on floor 1 and Exit Spire button is visible
|
|
||||||
const exitBtn = page.getByRole('button', { name: /exit spire/i }).first();
|
|
||||||
await expect(exitBtn).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Verify the button is NOT visible when not on floor 1 by checking that
|
|
||||||
// the current floor is indeed 1 (the button's rendering condition)
|
|
||||||
const floorBeforeExit = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useCombatStore.getState().currentFloor
|
|
||||||
);
|
|
||||||
expect(floorBeforeExit).toBe(1);
|
|
||||||
|
|
||||||
await exitBtn.click();
|
|
||||||
await waitForMs(page, 2000);
|
|
||||||
|
|
||||||
const spireModeAfterExit = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useCombatStore.getState().spireMode
|
|
||||||
);
|
|
||||||
console.log(`[TEST] Spire mode after exit: ${spireModeAfterExit}`);
|
|
||||||
expect(spireModeAfterExit).toBe(false);
|
|
||||||
|
|
||||||
// Verify we are back on the main game page
|
|
||||||
await expect(page.getByRole('tab', { name: /spells/i }).first()).toBeVisible({ timeout: 10000 });
|
|
||||||
console.log('[TEST] Back on main game page!');
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 8: Verify final state ──────────────────────────────────────────────
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 8: Verifying final state...');
|
|
||||||
|
|
||||||
const maxFloorReached = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useCombatStore.getState().maxFloorReached
|
|
||||||
);
|
|
||||||
const gameOver = await page.evaluate(() =>
|
|
||||||
(window as any).__TEST__.useUIStore.getState().gameOver
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`[TEST] MaxFloorReached: ${maxFloorReached}, GameOver: ${gameOver}`);
|
|
||||||
expect(maxFloorReached).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(gameOver).toBe(false);
|
|
||||||
|
|
||||||
// No React errors throughout the test
|
|
||||||
await waitForMs(page, 1000);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
|| e.includes('Maximum update depth')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
|
|
||||||
console.log('[TEST] ✅ Combat happy-path test passed!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
|
||||||
|
|
||||||
test.use({
|
|
||||||
baseURL: 'http://localhost:3000/',
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function waitForMs(page: Page, ms: number) {
|
|
||||||
await page.waitForTimeout(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startFreshGame(page: Page) {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
await page.reload();
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
await waitForMs(page, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clickTab(page: Page, label: string) {
|
|
||||||
const tab = page.getByRole('tab', { name: new RegExp(label, 'i') }).first();
|
|
||||||
await tab.click();
|
|
||||||
await waitForMs(page, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForBridge(page: Page) {
|
|
||||||
for (let attempt = 0; attempt < 30; attempt++) {
|
|
||||||
const ready = await page.evaluate(() => !!(window as any).__TEST__);
|
|
||||||
if (ready) return;
|
|
||||||
await waitForMs(page, 1000);
|
|
||||||
}
|
|
||||||
throw new Error('Debug bridge (window.__TEST__) not available after 30s');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Test ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Enchanter Happy-Path: Design → Prepare → Apply on Starter Gear', () => {
|
|
||||||
|
|
||||||
test('enchant Civilian Shirt: full UI workflow (Design → Prepare → Apply)', async ({ page }) => {
|
|
||||||
test.setTimeout(240_000);
|
|
||||||
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 1. Start fresh game ───────────────────────────────────────────────────
|
|
||||||
await startFreshGame(page);
|
|
||||||
await waitForBridge(page);
|
|
||||||
|
|
||||||
// ── 2. Add raw mana via Debug UI ──────────────────────────────────────────
|
|
||||||
await clickTab(page, 'debug');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
const add10KBtn = page.getByTestId('debug-mana-add-10k');
|
|
||||||
await expect(add10KBtn).toBeVisible({ timeout: 5000 });
|
|
||||||
await add10KBtn.click();
|
|
||||||
await waitForMs(page, 200);
|
|
||||||
|
|
||||||
// ── 3. Navigate to Crafting → Enchanter ────────────────────────────────────
|
|
||||||
await clickTab(page, 'craft');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
const enchanterBtn = page.getByRole('button', { name: /^enchanter$/i }).first();
|
|
||||||
if (await enchanterBtn.isVisible({ timeout: 3000 })) {
|
|
||||||
await enchanterBtn.click();
|
|
||||||
await waitForMs(page, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// PHASE 1: DESIGN — Verify UI elements and interaction
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// Verify Design phase button is active by default
|
|
||||||
const designPhaseBtn = page.getByRole('button', { name: /^design$/i }).first();
|
|
||||||
await expect(designPhaseBtn).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// -- Verify all 3 phase buttons exist --------------------------------------
|
|
||||||
await expect(page.getByRole('button', { name: /^prepare$/i }).first()).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /^apply$/i }).first()).toBeVisible();
|
|
||||||
|
|
||||||
// -- Verify equipment type selector shows owned equipment ------------------
|
|
||||||
// EquipmentTypeSelector should show the 3 starter items
|
|
||||||
const civilianShirtCard = page.getByText('Civilian Shirt').first();
|
|
||||||
await expect(civilianShirtCard).toBeVisible({ timeout: 5000 });
|
|
||||||
await expect(page.getByText('Basic Staff').first()).toBeVisible();
|
|
||||||
await expect(page.getByText('Civilian Shoes').first()).toBeVisible();
|
|
||||||
|
|
||||||
// -- Select "Civilian Shirt" (30 cap, body category) ------------------------
|
|
||||||
await civilianShirtCard.click();
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
|
|
||||||
// -- Verify capacity shows in DesignForm -----------------------------------
|
|
||||||
// After selecting equipment, the DesignForm should show capacity
|
|
||||||
await expect(page.getByText(/Total Capacity:/i).first()).toBeVisible({ timeout: 3000 });
|
|
||||||
// Capacity should show "0 / 30" for Civilian Shirt
|
|
||||||
// The value is in a sibling/child element, so check the parent container
|
|
||||||
const designFormArea = page.getByPlaceholder('Design name...').locator('..').locator('..');
|
|
||||||
const formAreaText = await designFormArea.textContent();
|
|
||||||
expect(formAreaText).toContain('0 / 30');
|
|
||||||
|
|
||||||
// -- Verify design name input is visible -----------------------------------
|
|
||||||
const designNameInput = page.getByPlaceholder('Design name...');
|
|
||||||
await expect(designNameInput).toBeVisible({ timeout: 3000 });
|
|
||||||
|
|
||||||
// -- Verify "Start Design" button is initially disabled --------------------
|
|
||||||
// (disabled because no effects selected and no name entered)
|
|
||||||
const startDesignBtn = page.getByRole('button', { name: /start design/i }).first();
|
|
||||||
await expect(startDesignBtn).toBeVisible({ timeout: 3000 });
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// PHASE 2: PREPARE — Verify UI elements
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const preparePhaseBtn = page.getByRole('button', { name: /^prepare$/i }).first();
|
|
||||||
await expect(preparePhaseBtn).toBeVisible({ timeout: 3000 });
|
|
||||||
await preparePhaseBtn.click();
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// -- Verify preparation list shows equipped items --------------------------
|
|
||||||
const shirtInPrepare = page.getByText('Civilian Shirt').first();
|
|
||||||
await expect(shirtInPrepare).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// -- Select Civilian Shirt and verify preparation details -------------------
|
|
||||||
await shirtInPrepare.click();
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
|
|
||||||
// Preparation details should show: Prep Time, Mana Cost
|
|
||||||
await expect(page.getByText(/Prep Time:/i).first()).toBeVisible({ timeout: 3000 });
|
|
||||||
await expect(page.getByText(/Mana Cost:/i).first()).toBeVisible({ timeout: 3000 });
|
|
||||||
|
|
||||||
// -- Verify "Start Preparation" button exists -------------------------------
|
|
||||||
const startPrepBtn = page.getByRole('button', { name: /start preparation/i }).first();
|
|
||||||
await expect(startPrepBtn).toBeVisible({ timeout: 3000 });
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// PHASE 3: APPLY — Verify UI elements
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const applyPhaseBtn = page.getByRole('button', { name: /^apply$/i }).first();
|
|
||||||
await expect(applyPhaseBtn).toBeVisible({ timeout: 3000 });
|
|
||||||
await applyPhaseBtn.click();
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// -- Verify Apply UI shows "No equipment ready for enchantment" ------------
|
|
||||||
// (since we haven't prepared anything)
|
|
||||||
await expect(page.getByText(/No equipment ready for enchantment/i).first()).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// -- Verify "No designs available" message ----------------------------------
|
|
||||||
await expect(page.getByText(/No designs available/i).first()).toBeVisible({ timeout: 3000 });
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// Navigate to Equipment tab — verify starting equipment is intact
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
await clickTab(page, 'equipment');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
const bodyText = await page.textContent('body') || '';
|
|
||||||
expect(bodyText).toContain('Basic Staff');
|
|
||||||
expect(bodyText).toContain('Civilian Shirt');
|
|
||||||
expect(bodyText).toContain('Civilian Shoes');
|
|
||||||
|
|
||||||
// No React errors throughout the test
|
|
||||||
await waitForMs(page, 1000);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #') || e.includes('Maximum update depth')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
|
||||||
|
|
||||||
test.use({
|
|
||||||
baseURL: 'http://localhost:3000/',
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function waitForMs(page: Page, ms: number) {
|
|
||||||
await page.waitForTimeout(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startFreshGame(page: Page) {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
await page.reload();
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
await waitForMs(page, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clickTab(page: Page, label: string) {
|
|
||||||
const tab = page.getByRole('tab', { name: new RegExp(label, 'i') }).first();
|
|
||||||
await tab.click();
|
|
||||||
await waitForMs(page, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clickBtn(page: Page, text: string) {
|
|
||||||
const btn = page.getByRole('button', { name: new RegExp(text, 'i') }).first();
|
|
||||||
await btn.click();
|
|
||||||
await waitForMs(page, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForBridge(page: Page) {
|
|
||||||
for (let attempt = 0; attempt < 30; attempt++) {
|
|
||||||
const ready = await page.evaluate(() => !!(window as any).__TEST__);
|
|
||||||
if (ready) return;
|
|
||||||
await waitForMs(page, 1000);
|
|
||||||
}
|
|
||||||
throw new Error('Debug bridge (window.__TEST__) not available after 30s');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run n game ticks synchronously via the debug bridge.
|
|
||||||
*/
|
|
||||||
async function runTicks(page: Page, n: number) {
|
|
||||||
await page.evaluate((count: number) => {
|
|
||||||
(window as any).__TEST__.runTicks(count);
|
|
||||||
}, n);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ticks needed to finish a craft of given hours.
|
|
||||||
* Each tick advances HOURS_PER_TICK (0.04) hours.
|
|
||||||
*/
|
|
||||||
function ticksForHours(hours: number): number {
|
|
||||||
return Math.ceil(hours / 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Gear set ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const GEAR_SET = [
|
|
||||||
{ slot: 'head', id: 'earthHelm', name: 'Earthen Helm', mt: 'earth', time: 3 },
|
|
||||||
{ slot: 'body', id: 'earthChest', name: 'Stoneguard Armor', mt: 'earth', time: 6 },
|
|
||||||
{ slot: 'mainHand', id: 'metalBlade', name: 'Metal Blade', mt: 'metal', time: 5 },
|
|
||||||
{ slot: 'offHand', id: 'metalShield', name: 'Metal Spell Focus', mt: 'metal', time: 5 },
|
|
||||||
{ slot: 'hands', id: 'metalGloves', name: 'Metalweave Gauntlets',mt: 'metal', time: 3 },
|
|
||||||
{ slot: 'feet', id: 'earthBoots', name: 'Stonegreaves', mt: 'earth', time: 2 },
|
|
||||||
{ slot: 'accessory1', id: 'crystalRing', name: 'Crystal Ring', mt: 'crystal', time: 3 },
|
|
||||||
{ slot: 'accessory2', id: 'crystalAmulet', name: 'Crystal Pendant', mt: 'crystal', time: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Test ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Fabricator Happy-Path: Craft → Equip → Verify Stats', () => {
|
|
||||||
|
|
||||||
test('craft one piece per slot, equip all, verify effects on Stats tab', async ({ page }) => {
|
|
||||||
test.setTimeout(600_000);
|
|
||||||
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 1: Start fresh game and wait for bridge
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 1: Starting fresh game...');
|
|
||||||
await startFreshGame(page);
|
|
||||||
await waitForMs(page, 1500);
|
|
||||||
await waitForBridge(page);
|
|
||||||
console.log('[TEST] Bridge ready!');
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 2: Set up all prerequisites via Debug tab UI
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 2: Setting up prerequisites...');
|
|
||||||
await clickTab(page, 'debug');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// ── 2a. Unlock all attunements ───────────────────────────────────────────
|
|
||||||
console.log('[TEST] 2a. Unlocking attunements...');
|
|
||||||
const attunementsHeader = page.locator('button', { hasText: /^Attunements$/ }).first();
|
|
||||||
if (await attunementsHeader.isVisible({ timeout: 3000 })) {
|
|
||||||
await attunementsHeader.click();
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
}
|
|
||||||
const unlockAllAttunements = page.getByTestId('debug-attunement-unlock-all');
|
|
||||||
await expect(unlockAllAttunements).toBeVisible({ timeout: 5000 });
|
|
||||||
await unlockAllAttunements.click();
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// ── 2b. Activate and add discipline XP to unlock all fabricator recipes ──
|
|
||||||
// "Study Fabricator Recipes" needs 200 XP to unlock all 4 recipe tiers
|
|
||||||
// (earth@50, metal@100, sand@150, crystal@200).
|
|
||||||
// We activate the discipline first, then add XP.
|
|
||||||
console.log('[TEST] 2b. Activating discipline and adding XP for recipe unlocks...');
|
|
||||||
const disciplinesHeader = page.locator('button', { hasText: /^Disciplines$/ }).first();
|
|
||||||
if (await disciplinesHeader.isVisible({ timeout: 3000 })) {
|
|
||||||
await disciplinesHeader.click();
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activate "Study Fabricator Recipes" discipline
|
|
||||||
const recipeToggleBtn = page.getByTestId('debug-discipline-toggle-study-fabricator-recipes');
|
|
||||||
await expect(recipeToggleBtn).toBeVisible({ timeout: 5000 });
|
|
||||||
await recipeToggleBtn.click();
|
|
||||||
await waitForMs(page, 200);
|
|
||||||
|
|
||||||
// Add 1000 XP (more than enough for all recipe tiers at 200 XP threshold)
|
|
||||||
const recipeAdd1KBtn = page.getByTestId('debug-discipline-add1k-study-fabricator-recipes');
|
|
||||||
await expect(recipeAdd1KBtn).toBeVisible({ timeout: 5000 });
|
|
||||||
await recipeAdd1KBtn.click();
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
|
|
||||||
// Unlock all fabricator recipes via store.
|
|
||||||
// The discipline perks define which recipes unlock at which XP thresholds,
|
|
||||||
// but the actual unlock happens through processTick. For test reliability,
|
|
||||||
// we unlock directly via the store after setting the prerequisite discipline XP.
|
|
||||||
const allRecipeIds = GEAR_SET.map(g => g.id);
|
|
||||||
await page.evaluate((ids: string[]) => {
|
|
||||||
const craft = (window as any).__TEST__.useCraftingStore;
|
|
||||||
if (craft) craft.getState().unlockRecipes(ids);
|
|
||||||
}, allRecipeIds);
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
|
|
||||||
// ── 2c. Unlock all elements ──────────────────────────────────────────────
|
|
||||||
console.log('[TEST] 2c. Unlocking elements...');
|
|
||||||
const elementsHeader = page.locator('button', { hasText: /^Elements$/ }).first();
|
|
||||||
if (await elementsHeader.isVisible({ timeout: 3000 })) {
|
|
||||||
await elementsHeader.click();
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
}
|
|
||||||
const unlockAllElements = page.getByTestId('debug-elements-unlock-all');
|
|
||||||
await expect(unlockAllElements).toBeVisible({ timeout: 5000 });
|
|
||||||
await unlockAllElements.click();
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// ── 2d. Fill element mana ────────────────────────────────────────────────
|
|
||||||
console.log('[TEST] 2d. Filling element mana...');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const mana = (window as any).__TEST__.useManaStore;
|
|
||||||
if (!mana) return;
|
|
||||||
const state = mana.getState();
|
|
||||||
const newE: Record<string, any> = {};
|
|
||||||
for (const [k, v] of Object.entries(state.elements)) {
|
|
||||||
newE[k] = { ...(v as any), max: 5000, baseMax: 5000, current: 5000, unlocked: true };
|
|
||||||
}
|
|
||||||
mana.setState({ elements: newE });
|
|
||||||
});
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
|
|
||||||
// ── 2e. Add starter materials ─────────────────────────────────────────────
|
|
||||||
console.log('[TEST] 2e. Adding starter materials...');
|
|
||||||
const addMatsBtn = page.getByTestId('debug-quick-add-materials');
|
|
||||||
await expect(addMatsBtn).toBeVisible({ timeout: 5000 });
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
await addMatsBtn.click();
|
|
||||||
await waitForMs(page, 30);
|
|
||||||
}
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// ── 2f. Add crystalShard (not in starter materials) ──────────────────────
|
|
||||||
console.log('[TEST] 2f. Adding crystalShard...');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const craft = (window as any).__TEST__.useCraftingStore;
|
|
||||||
if (!craft) return;
|
|
||||||
const s = craft.getState();
|
|
||||||
const mats = { ...s.lootInventory.materials };
|
|
||||||
mats['crystalShard'] = (mats['crystalShard'] || 0) + 20;
|
|
||||||
craft.setState({ lootInventory: { ...s.lootInventory, materials: mats } });
|
|
||||||
});
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
|
|
||||||
// Recipes are now unlocked via discipline perks (study-fabricator-recipes at 1000 XP)
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 3: Craft each piece of gear sequentially
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 3: Crafting gear...');
|
|
||||||
await clickTab(page, 'craft');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
await clickBtn(page, '^fabricator$');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// Verify Fabricator UI loaded
|
|
||||||
await expect(page.getByRole('button', { name: /^Equipment$/i }).first())
|
|
||||||
.toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
for (const gear of GEAR_SET) {
|
|
||||||
console.log(`[TEST] Crafting ${gear.name} (${gear.mt}, ${gear.time}h)...`);
|
|
||||||
|
|
||||||
// Select mana type filter
|
|
||||||
const filterBtn = page.getByRole('button', { name: new RegExp(gear.mt, 'i') }).first();
|
|
||||||
if (await filterBtn.isVisible({ timeout: 3000 })) {
|
|
||||||
await filterBtn.click();
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify recipe card visible
|
|
||||||
const recipeName = page.getByText(gear.name).first();
|
|
||||||
await expect(recipeName).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Find the Craft button within this specific recipe card.
|
|
||||||
const recipeCard = recipeName.locator('xpath=ancestor::div[contains(@class, "p-3")]').first();
|
|
||||||
const craftBtn = recipeCard.locator('button', { hasText: /^Craft$/i }).first();
|
|
||||||
await expect(craftBtn).toBeVisible({ timeout: 5000 });
|
|
||||||
await craftBtn.click();
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// Run enough ticks to complete this craft.
|
|
||||||
// craftTime(h) / HOURS_PER_TICK(0.04) ticks needed, plus a small buffer.
|
|
||||||
const craftTicks = ticksForHours(gear.time) + 10;
|
|
||||||
console.log(`[TEST] Running ${craftTicks} ticks to craft ${gear.name}...`);
|
|
||||||
await runTicks(page, craftTicks);
|
|
||||||
await waitForMs(page, 500); // let React re-render
|
|
||||||
|
|
||||||
// Confirm crafting completed — check that the item appears in equipment instances
|
|
||||||
const craftCompleted = await page.evaluate((itemName: string) => {
|
|
||||||
const craft = (window as any).__TEST__.useCraftingStore;
|
|
||||||
if (!craft) return false;
|
|
||||||
const state = craft.getState();
|
|
||||||
return Object.values(state.equipmentInstances).some(
|
|
||||||
(inst: any) => inst.name === itemName
|
|
||||||
);
|
|
||||||
}, gear.name);
|
|
||||||
expect(craftCompleted, `Crafting ${gear.name} did not complete`).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 4: Equip all crafted gear via Equipment tab
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 4: Equipping gear...');
|
|
||||||
await clickTab(page, 'equipment');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// Verify all 8 crafted items are in inventory
|
|
||||||
const invText = await page.textContent('body') || '';
|
|
||||||
for (const gear of GEAR_SET) {
|
|
||||||
expect(invText).toContain(gear.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unequip starter gear first
|
|
||||||
const unequipBtns = page.locator('button', { hasText: /^Unequip$/i });
|
|
||||||
const cnt = await unequipBtns.count();
|
|
||||||
for (let i = 0; i < cnt; i++) {
|
|
||||||
await unequipBtns.nth(0).click();
|
|
||||||
await waitForMs(page, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equip all items directly via the store for reliability.
|
|
||||||
// The UI slot-mapping has bugs (catalyst → mainHand only, duplicate
|
|
||||||
// instances confusing the Equip button). The store's equipItem works
|
|
||||||
// correctly regardless of category.
|
|
||||||
const equipResults = await page.evaluate((slotsAndNames: { slot: string; name: string }[]) => {
|
|
||||||
const craft = (window as any).__TEST__.useCraftingStore;
|
|
||||||
if (!craft) return [];
|
|
||||||
const results: string[] = [];
|
|
||||||
for (const { slot, name } of slotsAndNames) {
|
|
||||||
const state = craft.getState();
|
|
||||||
const entry = Object.entries(state.equipmentInstances).find(
|
|
||||||
([, inst]: [string, any]) => inst.name === name
|
|
||||||
&& !Object.values(state.equippedInstances).includes(inst.instanceId)
|
|
||||||
);
|
|
||||||
if (entry) {
|
|
||||||
const ok = craft.getState().equipItem(entry[0], slot as any);
|
|
||||||
results.push(`${name} → ${slot}: ${ok}`);
|
|
||||||
} else {
|
|
||||||
results.push(`${name}: instance not found or already equipped`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}, GEAR_SET.map(g => ({ slot: g.slot, name: g.name })));
|
|
||||||
console.log('[TEST] Equip results:', equipResults);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 5: Verify gear effects on Equipment tab
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 5: Verifying equipment effects...');
|
|
||||||
await clickTab(page, 'equipment');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
// Equipment Effects section should be visible (shown when items are equipped)
|
|
||||||
await expect(page.getByText('Equipment Effects').first())
|
|
||||||
.toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Verify bonuses are shown (the section should have + signs)
|
|
||||||
const effectsEl = page.locator('div', { hasText: 'Equipment Effects' }).first();
|
|
||||||
const effectsText = await effectsEl.textContent() || '';
|
|
||||||
expect(effectsText).toContain('+');
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 6: Confirm all 8 slots show crafted gear names
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('[TEST] Step 6: Confirming equipped gear...');
|
|
||||||
await clickTab(page, 'equipment');
|
|
||||||
await waitForMs(page, 500);
|
|
||||||
|
|
||||||
const finalText = await page.textContent('body') || '';
|
|
||||||
for (const gear of GEAR_SET) {
|
|
||||||
expect(finalText).toContain(gear.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
// STEP 7: No React errors
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
|
||||||
await waitForMs(page, 1000);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
|| e.includes('Maximum update depth')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,621 +0,0 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
|
||||||
|
|
||||||
// Use the deployed production URL
|
|
||||||
test.use({
|
|
||||||
baseURL: 'https://manaloop.tailf367e3.ts.net/',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper: Clear localStorage and reload for fresh game
|
|
||||||
async function startFreshGame(page: Page) {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
await page.reload();
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Run debug command via console
|
|
||||||
async function runDebug(page: Page, cmd: string) {
|
|
||||||
await page.evaluate((c) => {
|
|
||||||
// @ts-expect-error - debug function on window
|
|
||||||
if (typeof window.__debug === 'function') window.__debug(c);
|
|
||||||
}, cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Wait for game to tick a few times
|
|
||||||
async function waitForTicks(page: Page, ms = 1000) {
|
|
||||||
await page.waitForTimeout(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Mana Loop - Comprehensive Playtest', () => {
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 1: Basic UI & Starting State
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('1 - Basic UI & Starting State', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('game loads without console errors', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 2000);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #') || e.includes('Maximum update depth')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors found: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ManaDisplay is visible and shows Transference mana', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
// Mana display should show Transference mana pool
|
|
||||||
const manaDisplay = page.locator('text=Transference').first();
|
|
||||||
await expect(manaDisplay).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('TimeDisplay shows correct starting time', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
// Should start at day 1
|
|
||||||
const bodyText = await page.textContent('body');
|
|
||||||
expect(bodyText).toContain('Day 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Activity log is present and shows start message', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const bodyText = await page.textContent('body');
|
|
||||||
// Activity log should have some content
|
|
||||||
expect(bodyText).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 2 - Stats Tab (Known bugs #208 and #210)
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('2 - Stats Tab', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Stats tab', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const statsTab = page.getByRole('tab', { name: /stats/i });
|
|
||||||
if (await statsTab.isVisible()) {
|
|
||||||
await statsTab.click();
|
|
||||||
await waitForTicks(page, 300);
|
|
||||||
// Should not crash
|
|
||||||
const bodyText = await page.textContent('body');
|
|
||||||
expect(bodyText).toBeTruthy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('KNOWN BUG #208: Meditation multiplier shows 0x instead of 1x', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const statsTab = page.getByRole('tab', { name: /stats/i });
|
|
||||||
if (await statsTab.isVisible()) {
|
|
||||||
await statsTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const bodyText = await page.textContent('body') || '';
|
|
||||||
// The bug: Meditation Multiplier shows "0x" instead of "1.00x"
|
|
||||||
// This test documents the current state
|
|
||||||
if (bodyText.includes('Meditation')) {
|
|
||||||
console.log('STATS: Meditation text found, checking value...');
|
|
||||||
// Capture the actual state for reporting
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('KNOWN BUG #208: Effective Regen shows 0/hr', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const statsTab = page.getByRole('tab', { name: /stats/i });
|
|
||||||
if (await statsTab.isVisible()) {
|
|
||||||
await statsTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const bodyText = await page.textContent('body') || '';
|
|
||||||
if (bodyText.includes('Effective Regen') || bodyText.includes('Base Regen')) {
|
|
||||||
console.log('STATS: Regen stats found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('KNOWN BUG #210: Total Max Mana ignores discipline bonuses', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
// Navigate to stats
|
|
||||||
const statsTab = page.getByRole('tab', { name: /stats/i });
|
|
||||||
if (await statsTab.isVisible()) {
|
|
||||||
await statsTab.click();
|
|
||||||
await waitForTicks(page, 300);
|
|
||||||
// Check if Total Max Mana is shown
|
|
||||||
const bodyText = await page.textContent('body') || '';
|
|
||||||
console.log('STATS: Max Mana section - checking for discipline bonus inclusion');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 3 - Spire/Climbing (Known bug #209)
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('3 - Spire / Climbing', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('KNOWN BUG #209: Climb the Spire should not crash with React error #185', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
// Look for "Climb the Spire" button or Spire tab
|
|
||||||
const spireTab = page.getByRole('tab', { name: /spire/i });
|
|
||||||
const climbButton = page.getByRole('button', { name: /climb/i });
|
|
||||||
|
|
||||||
if (await spireTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await spireTab.click();
|
|
||||||
await waitForTicks(page, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await climbButton.isVisible({ timeout: 5000 })) {
|
|
||||||
await climbButton.click();
|
|
||||||
await waitForTicks(page, 2000);
|
|
||||||
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('Maximum update depth') || e.includes('Error #185')
|
|
||||||
);
|
|
||||||
// This is a known bug - we expect it to fail
|
|
||||||
if (reactErrors.length > 0) {
|
|
||||||
console.log('KNOWN BUG #209 CONFIRMED: Spire crash detected');
|
|
||||||
} else {
|
|
||||||
console.log('KNOWN BUG #209: No crash detected - may be fixed');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('Climb the Spire button not found - may need setup');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 4 - Disciplines Tab
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('4 - Disciplines', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Disciplines tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const discTab = page.getByRole('tab', { name: /disciplines/i });
|
|
||||||
if (await discTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await discTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Disciplines: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Raw Mana Mastery discipline is available', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const discTab = page.getByRole('tab', { name: /disciplines/i });
|
|
||||||
if (await discTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await discTab.click();
|
|
||||||
await waitForTicks(page, 300);
|
|
||||||
const bodyText = await page.textContent('body') || '';
|
|
||||||
// Raw Mana Mastery should be available since Enchanter is attuned
|
|
||||||
if (bodyText.includes('Raw Mana Mastery')) {
|
|
||||||
console.log('DISCIPLINE: Raw Mana Mastery found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 5 - Crafting Tab
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('5 - Crafting System', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Crafting tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const craftTab = page.getByRole('tab', { name: /craft/i });
|
|
||||||
if (await craftTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await craftTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Crafting: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Enchant sub-tab exists and is clickable', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const craftTab = page.getByRole('tab', { name: /craft/i });
|
|
||||||
if (await craftTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await craftTab.click();
|
|
||||||
await waitForTicks(page, 300);
|
|
||||||
// Look for Enchant sub-tab or section
|
|
||||||
const bodyText = await page.textContent('body') || '';
|
|
||||||
expect(bodyText).toBeTruthy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 6 - Equipment Tab
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('6 - Equipment & Inventory', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Equipment tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const equipTab = page.getByRole('tab', { name: /equipment/i });
|
|
||||||
if (await equipTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await equipTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Equipment: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('starting equipment includes Basic Staff, Civilian Shirt, Civilian Shoes', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const equipTab = page.getByRole('tab', { name: /equipment/i });
|
|
||||||
if (await equipTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await equipTab.click();
|
|
||||||
await waitForTicks(page, 300);
|
|
||||||
const bodyText = await page.textContent('body') || '';
|
|
||||||
// Check for starting equipment
|
|
||||||
console.log('EQUIPMENT: Checking starting equipment...');
|
|
||||||
if (bodyText.includes('Basic Staff')) {
|
|
||||||
console.log('EQUIPMENT: Basic Staff found ✓');
|
|
||||||
}
|
|
||||||
if (bodyText.includes('Civilian Shirt')) {
|
|
||||||
console.log('EQUIPMENT: Civilian Shirt found ✓');
|
|
||||||
}
|
|
||||||
if (bodyText.includes('Civilian Shoes') || bodyText.includes('Civilian')) {
|
|
||||||
console.log('EQUIPMENT: Civilian gear found ✓');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 7 - Attunements Tab
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('7 - Attunements', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Attunements tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const attuneTab = page.getByRole('tab', { name: /attun/i });
|
|
||||||
if (await attuneTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await attuneTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Attunements: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Enchanter is attuned at level 1 by default', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const attuneTab = page.getByRole('tab', { name: /attun/i });
|
|
||||||
if (await attuneTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await attuneTab.click();
|
|
||||||
await waitForTicks(page, 300);
|
|
||||||
const bodyText = await page.textContent('body') || '';
|
|
||||||
if (bodyText.includes('Enchanter')) {
|
|
||||||
console.log('ATTUNEMENT: Enchanter found ✓');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 8 - Spells Tab
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('8 - Spells Tab', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Spells tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const spellsTab = page.getByRole('tab', { name: /spell/i });
|
|
||||||
if (await spellsTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await spellsTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Spells: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 9 - Prestige Tab
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('9 - Prestige Tab', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Prestige tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const prestigeTab = page.getByRole('tab', { name: /prestige/i });
|
|
||||||
if (await prestigeTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await prestigeTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Prestige: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 10 - Golemancy Tab
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('10 - Golemancy Tab', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Golemancy tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const golemTab = page.getByRole('tab', { name: /golem/i });
|
|
||||||
if (await golemTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await golemTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Golemancy: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 11 - Guardian Pacts Tab
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('11 - Guardian Pacts Tab', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Guardian Pacts tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const pactsTab = page.getByRole('tab', { name: /pact/i });
|
|
||||||
if (await pactsTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await pactsTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Guardian Pacts: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 12 - Grimoire Tab
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('12 - Grimoire Tab', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Grimoire tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const grimoireTab = page.getByRole('tab', { name: /grimoire/i });
|
|
||||||
if (await grimoireTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await grimoireTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Grimoire: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 13 - Achievements Tab
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('13 - Achievements Tab', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Achievements tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const achTab = page.getByRole('tab', { name: /achievement/i });
|
|
||||||
if (await achTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await achTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Achievements: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 14 - Debug Tab & Cheats
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('14 - Debug Tab & Cheats', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate to Debug tab without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const debugTab = page.getByRole('tab', { name: /debug/i });
|
|
||||||
if (await debugTab.isVisible({ timeout: 5000 })) {
|
|
||||||
await debugTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Debug: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SECTION 15 - Deep Bug Hunting with Debug Console
|
|
||||||
// =========================================================================
|
|
||||||
test.describe('15 - Deep Bug Hunting (Debug Mode)', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await startFreshGame(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mana regen values in ManaDisplay are correct', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 1000);
|
|
||||||
const bodyText = await page.textContent('body') || '';
|
|
||||||
// Check that mana regen shows positive values for Transference
|
|
||||||
// Look for regen rate patterns like "+X/hr"
|
|
||||||
console.log('HUNT: Checking mana regen display values');
|
|
||||||
const matches = bodyText.match(/\+[\d.]+(\/hr)?/g);
|
|
||||||
console.log(`HUNT: Found regen patterns: ${JSON.stringify(matches)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('element tab shows correct element unlock status', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
// Try to find element-related tabs
|
|
||||||
const elemTab = page.getByRole('tab', { name: /element/i });
|
|
||||||
if (await elemTab.isVisible({ timeout: 3000 })) {
|
|
||||||
await elemTab.click();
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
const reactErrors = errors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
expect(reactErrors, `React errors in Elements: ${JSON.stringify(reactErrors)}`).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mana values stay consistent after multiple ticks', async ({ page }) => {
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
// Take a snapshot of mana values
|
|
||||||
const bodyBefore = await page.textContent('body') || '';
|
|
||||||
await waitForTicks(page, 2000);
|
|
||||||
const bodyAfter = await page.textContent('body') || '';
|
|
||||||
// Game should still be running (no crash)
|
|
||||||
expect(bodyAfter).toBeTruthy();
|
|
||||||
console.log('HUNT: Game still running after 2 seconds of ticking ✓');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('all navigations work in sequence without crash', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
await waitForTicks(page, 500);
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
'stats', 'equipment', 'attunements', 'crafting', 'disciplines',
|
|
||||||
'spells', 'prestige', 'golemancy', 'pacts', 'achievements',
|
|
||||||
'grimoire', 'debug'
|
|
||||||
];
|
|
||||||
|
|
||||||
const visitedTabs: string[] = [];
|
|
||||||
const crashTabs: string[] = [];
|
|
||||||
|
|
||||||
for (const tabName of tabs) {
|
|
||||||
const tab = page.getByRole('tab', { name: new RegExp(tabName, 'i') });
|
|
||||||
if (await tab.isVisible({ timeout: 2000 })) {
|
|
||||||
const preErrors = [...errors];
|
|
||||||
await tab.click();
|
|
||||||
await waitForTicks(page, 300);
|
|
||||||
const newErrors = errors.filter(e => !preErrors.includes(e));
|
|
||||||
const reactErrors = newErrors.filter(e =>
|
|
||||||
e.includes('React') || e.includes('Minified') || e.includes('Error #')
|
|
||||||
);
|
|
||||||
if (reactErrors.length > 0) {
|
|
||||||
crashTabs.push(tabName);
|
|
||||||
}
|
|
||||||
visitedTabs.push(tabName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`HUNT: Visited tabs: ${visitedTabs.join(', ')}`);
|
|
||||||
console.log(`HUNT: Tabs with React errors: ${crashTabs.join(', ')}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Executable
Generated
-14153
File diff suppressed because it is too large
Load Diff
+66
-63
@@ -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,23 +0,0 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: 'e2e',
|
|
||||||
fullyParallel: false,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: 0,
|
|
||||||
workers: 1,
|
|
||||||
timeout: 60000,
|
|
||||||
reporter: 'html',
|
|
||||||
use: {
|
|
||||||
baseURL: 'https://manaloop.tailf367e3.ts.net/',
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
screenshot: 'on',
|
|
||||||
video: 'retain-on-failure',
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
Executable
+32
@@ -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.
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB |
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ message: "Hello, world!" });
|
||||||
|
}
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { fmt, formatHour } 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">{formatHour(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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } 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 { ActivityLogPanel } from '@/components/game/ActivityLogPanel';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
import { useGameStore, useManaStore, useCombatStore, useCraftingStore, usePrestigeStore, useAttunementStore } 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';
|
|
||||||
import { computeConversionRates } from '@/lib/game/utils/conversion-rates';
|
|
||||||
import { getGuardianForFloor } from '@/lib/game/data/guardian-encounters';
|
|
||||||
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
|
|
||||||
import type { ElementRegenBreakdown } from '@/components/game/ManaDisplay';
|
|
||||||
|
|
||||||
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 elementRegen = useManaStore((s) => s.elementRegen);
|
|
||||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
|
||||||
const signedPacts = usePrestigeStore((s) => s.signedPacts);
|
|
||||||
const attunements = useAttunementStore((s) => s.attunements);
|
|
||||||
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 cancelDesign = useCraftingStore((s) => s.cancelDesign);
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Compute per-element regen breakdown for ManaDisplay (DISC-8)
|
|
||||||
const elementRegenBreakdown = useMemo((): Record<string, ElementRegenBreakdown> | undefined => {
|
|
||||||
const pactElementMap: Record<number, string> = {};
|
|
||||||
for (const floor of signedPacts) {
|
|
||||||
const g = getGuardianForFloor(floor);
|
|
||||||
if (g?.element?.length) pactElementMap[floor] = g.element[0];
|
|
||||||
}
|
|
||||||
const grossRegen: Record<string, number> = {};
|
|
||||||
for (const [id, state] of Object.entries(attunements)) {
|
|
||||||
if (!state.active) continue;
|
|
||||||
const def = ATTUNEMENTS_DEF[id];
|
|
||||||
if (def?.primaryManaType) {
|
|
||||||
grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0)
|
|
||||||
+ (def.conversionRate || 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const invokerLevel = attunements.invoker?.active ? (attunements.invoker.level || 1) : 0;
|
|
||||||
const conversionResult = computeConversionRates({
|
|
||||||
disciplineEffects,
|
|
||||||
attunements,
|
|
||||||
signedPacts,
|
|
||||||
pactElementMap,
|
|
||||||
invokerLevel,
|
|
||||||
meditationMultiplier,
|
|
||||||
grossRegen,
|
|
||||||
rawGrossRegen: baseRegen,
|
|
||||||
});
|
|
||||||
const breakdown: Record<string, ElementRegenBreakdown> = {};
|
|
||||||
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
|
||||||
if (entry.paused) continue;
|
|
||||||
const drains: Record<string, number> = {};
|
|
||||||
// This element is drained when it's a component of a higher conversion
|
|
||||||
for (const [destElem, destEntry] of Object.entries(conversionResult.rates)) {
|
|
||||||
if (destEntry.paused) continue;
|
|
||||||
if (destEntry.componentCosts[elem]) {
|
|
||||||
drains[destElem] = (drains[destElem] || 0) + destEntry.finalRate * destEntry.componentCosts[elem];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (entry.finalRate > 0 || Object.keys(drains).length > 0) {
|
|
||||||
breakdown[elem] = { produced: entry.finalRate, drains };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Object.keys(breakdown).length > 0 ? breakdown : undefined;
|
|
||||||
}, [disciplineEffects, attunements, signedPacts, meditationMultiplier, baseRegen]);
|
|
||||||
|
|
||||||
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}
|
|
||||||
elementRegen={elementRegen}
|
|
||||||
elementRegenBreakdown={elementRegenBreakdown}
|
|
||||||
/>
|
|
||||||
</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}
|
|
||||||
cancelDesign={cancelDesign}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</DebugName>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 4. Activity Log */}
|
|
||||||
<DebugName name="ActivityLogPanel">
|
|
||||||
<ActivityLogPanel />
|
|
||||||
</DebugName>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+117
-163
@@ -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
@@ -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
+405
-197
@@ -1,231 +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 '@/lib/game/stores/debugBridge'; // side-effect: exposes stores on window.__TEST__
|
|
||||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
|
||||||
import { hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
|
|
||||||
import { TimeDisplay } from '@/components/game';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
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 { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab, EquipmentTab, AttunementsTab, DebugTab } from '@/components/game/tabs';
|
||||||
|
import { ActionButtons, CalendarDisplay, ManaDisplay, TimeDisplay } from '@/components/game';
|
||||||
|
import { LootInventoryDisplay } from '@/components/game/LootInventory';
|
||||||
|
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
|
||||||
|
|
||||||
import { GameOverScreen } from './components/GameOverScreen';
|
export default function ManaLoopGame() {
|
||||||
import { LeftPanel } from './components/LeftPanel';
|
const [activeTab, setActiveTab] = useState('spire');
|
||||||
|
const [isGathering, setIsGathering] = useState(false);
|
||||||
|
|
||||||
// Lazy load tab components
|
// Game store
|
||||||
const DisciplinesTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.DisciplinesTab })));
|
const store = useGameStore();
|
||||||
const StatsTab = lazy(() => import('@/components/game/tabs').then(m => ({ default: m.StatsTab })));
|
const gameLoop = useGameLoop();
|
||||||
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>;
|
// Computed effects from upgrades and equipment
|
||||||
|
const upgradeEffects = getUnifiedEffects(store);
|
||||||
|
|
||||||
function TabErrorFallback({ name }: { name: string }) {
|
// Derived stats
|
||||||
return <div className="p-4 text-red-400">{name} tab failed to load.</div>;
|
const maxMana = computeMaxMana(store, upgradeEffects);
|
||||||
}
|
const baseRegen = computeRegen(store, upgradeEffects);
|
||||||
|
const clickMana = computeClickMana(store);
|
||||||
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
|
const floorElem = getFloorElement(store.currentFloor);
|
||||||
|
const floorElemDef = ELEMENTS[floorElem];
|
||||||
function useGameDerivedStats() {
|
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
|
||||||
const { prestigeUpgrades } = usePrestigeStore(useShallow(s => ({
|
const currentGuardian = GUARDIANS[store.currentFloor];
|
||||||
prestigeUpgrades: s.prestigeUpgrades,
|
const meditationMultiplier = getMeditationBonus(store.meditateTicks, store.skills, upgradeEffects.meditationEfficiency);
|
||||||
})));
|
const incursionStrength = getIncursionStrength(store.day, store.hour);
|
||||||
const { meditateTicks } = useManaStore(useShallow(s => ({
|
const studySpeedMult = getStudySpeedMultiplier(store.skills);
|
||||||
meditateTicks: s.meditateTicks,
|
const studyCostMult = getStudyCostMultiplier(store.skills);
|
||||||
})));
|
|
||||||
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);
|
|
||||||
|
|
||||||
|
// Effective regen with incursion penalty
|
||||||
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
|
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
|
||||||
|
|
||||||
|
// Mana Cascade bonus
|
||||||
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
|
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
|
||||||
? Math.floor(maxMana / 100) * 0.1
|
? Math.floor(maxMana / 100) * 0.1
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const manaWaterfallBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_WATERFALL)
|
// Effective regen
|
||||||
? Math.floor(maxMana / 100) * 0.25
|
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
|
||||||
: 0;
|
|
||||||
|
|
||||||
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus + manaWaterfallBonus) * meditationMultiplier;
|
// Get all active spells from equipment
|
||||||
|
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
|
||||||
|
|
||||||
return { maxMana, effectiveRegen, clickMana, meditationMultiplier };
|
// Compute total DPS
|
||||||
}
|
const totalDPS = getTotalDPS(store, upgradeEffects, floorElem);
|
||||||
|
|
||||||
// ─── Tab Triggers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function TabTriggers() {
|
|
||||||
return (
|
|
||||||
<TabsList className="flex flex-wrap gap-1 w-full mb-4 h-auto">
|
|
||||||
<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="debug" className="text-xs px-2 py-1">🐛 Debug</TabsTrigger>
|
|
||||||
<TabsTrigger value="attunements" className="text-xs px-2 py-1">⚗️ Attunements</TabsTrigger>
|
|
||||||
<TabsTrigger value="achievements" className="text-xs px-2 py-1">🏆 Achievements</TabsTrigger>
|
|
||||||
<TabsTrigger value="prestige" className="text-xs px-2 py-1">✨ Prestige</TabsTrigger>
|
|
||||||
<TabsTrigger value="equipment" className="text-xs px-2 py-1">⚔️ Equipment</TabsTrigger>
|
|
||||||
<TabsTrigger value="golemancy" className="text-xs px-2 py-1">🗿 Golemancy</TabsTrigger>
|
|
||||||
<TabsTrigger value="pacts" className="text-xs px-2 py-1">📜 Pacts</TabsTrigger>
|
|
||||||
<TabsTrigger value="spire" className="text-xs px-2 py-1">🏔️ Spire</TabsTrigger>
|
|
||||||
<TabsTrigger value="crafting" className="text-xs px-2 py-1">⚒️ Crafting</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lazy Tab Content ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function LazyTab({ name, children }: { name: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ErrorBoundary fallback={<TabErrorFallback name={name} />}>
|
|
||||||
<Suspense fallback={<TabFallback />}>
|
|
||||||
{children}
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Game Component ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function ManaLoopGame() {
|
|
||||||
const [activeTab, setActiveTab] = useState('disciplines');
|
|
||||||
|
|
||||||
useGameLoop();
|
|
||||||
|
|
||||||
const { day, hour, initGame } = useGameStore(useShallow(s => ({
|
|
||||||
day: s.day,
|
|
||||||
hour: s.hour,
|
|
||||||
initGame: s.initGame,
|
|
||||||
})));
|
|
||||||
const { insight, loopInsight } = usePrestigeStore(useShallow(s => ({
|
|
||||||
insight: s.insight,
|
|
||||||
loopInsight: s.loopInsight,
|
|
||||||
})));
|
|
||||||
const spireMode = useCombatStore((s) => s.spireMode);
|
|
||||||
const gameOver = useUIStore((s) => s.gameOver);
|
|
||||||
|
|
||||||
useGameDerivedStats();
|
|
||||||
|
|
||||||
|
// Auto-gather while holding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initGame();
|
if (!isGathering) return;
|
||||||
}, [initGame]);
|
|
||||||
|
|
||||||
const [mounted, setMounted] = useState(false);
|
let lastGatherTime = 0;
|
||||||
useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect
|
const minGatherInterval = 100;
|
||||||
|
let animationFrameId: number;
|
||||||
|
|
||||||
if (gameOver) {
|
const gatherLoop = (timestamp: number) => {
|
||||||
return <GameOverScreen day={day} hour={hour} insightGained={loopInsight} totalInsight={insight} />;
|
if (timestamp - lastGatherTime >= minGatherInterval) {
|
||||||
}
|
store.gatherMana();
|
||||||
|
lastGatherTime = timestamp;
|
||||||
|
}
|
||||||
|
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||||
|
};
|
||||||
|
|
||||||
if (!mounted) return <div className="p-4 text-center text-gray-400">Loading...</div>;
|
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||||
|
return () => cancelAnimationFrame(animationFrameId);
|
||||||
|
}, [isGathering, store]);
|
||||||
|
|
||||||
if (spireMode) {
|
// Handle gather button events
|
||||||
|
const handleGatherStart = () => {
|
||||||
|
setIsGathering(true);
|
||||||
|
store.gatherMana();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGatherEnd = () => {
|
||||||
|
setIsGathering(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start game loop
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = gameLoop.start();
|
||||||
|
return cleanup;
|
||||||
|
}, [gameLoop]);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Game Over Screen
|
||||||
|
if (store.gameOver) {
|
||||||
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 (
|
||||||
<DebugName name="HomePage">
|
<TooltipProvider>
|
||||||
<ErrorBoundary>
|
<div className="game-root min-h-screen flex flex-col">
|
||||||
<TooltipProvider>
|
{/* Header */}
|
||||||
<div className="game-root min-h-screen flex flex-col">
|
<header className="sticky top-0 z-50 bg-gradient-to-b from-gray-900 to-gray-900/80 border-b border-gray-700 px-4 py-2">
|
||||||
<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">
|
<div className="flex items-center gap-4">
|
||||||
<TimeDisplay day={day} hour={hour} insight={insight} />
|
<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>
|
||||||
|
</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;
|
||||||
|
|
||||||
<TabsContent value="stats"><LazyTab name="stats"><StatsTab /></LazyTab></TabsContent>
|
return (
|
||||||
<TabsContent value="disciplines"><LazyTab name="disciplines"><DisciplinesTab /></LazyTab></TabsContent>
|
<div
|
||||||
<TabsContent value="debug"><LazyTab name="debug"><DebugTab /></LazyTab></TabsContent>
|
key={id}
|
||||||
<TabsContent value="attunements"><LazyTab name="attunements"><AttunementsTab /></LazyTab></TabsContent>
|
className="p-3 rounded border border-gray-700 bg-gray-800/50"
|
||||||
<TabsContent value="achievements"><LazyTab name="achievements"><AchievementsTab /></LazyTab></TabsContent>
|
>
|
||||||
<TabsContent value="prestige"><LazyTab name="prestige"><PrestigeTab /></LazyTab></TabsContent>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<TabsContent value="equipment"><LazyTab name="equipment"><EquipmentTab /></LazyTab></TabsContent>
|
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
|
||||||
<TabsContent value="golemancy"><LazyTab name="golemancy"><GolemancyTab /></LazyTab></TabsContent>
|
<Badge variant="outline" className="text-xs">
|
||||||
<TabsContent value="pacts"><LazyTab name="pacts"><GuardianPactsTab /></LazyTab></TabsContent>
|
{level}/{def.max}
|
||||||
<TabsContent value="spire"><LazyTab name="spire"><SpireSummaryTab /></LazyTab></TabsContent>
|
</Badge>
|
||||||
<TabsContent value="crafting"><LazyTab name="crafting"><CraftingTab /></LazyTab></TabsContent>
|
</div>
|
||||||
</Tabs>
|
<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">
|
||||||
</DebugName>
|
<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';
|
||||||
|
|||||||
@@ -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 & Recover
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable → Regular
+63
-145
@@ -1,168 +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 { DebugName } from '@/components/game/debug/debug-context';
|
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;
|
||||||
cancelDesign?: (slot: 1 | 2) => 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,
|
||||||
cancelDesign,
|
|
||||||
}: 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 (
|
||||||
<DebugName name="ActionButtons">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div className="bg-gray-800/50 rounded-lg p-3 border border-gray-700">
|
{actions.map(({ id, label, icon: Icon }) => (
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<Icon className={`w-4 h-4 ${config.color}`} />
|
key={id}
|
||||||
<span className="text-sm font-medium text-gray-200">Current Activity</span>
|
variant={currentAction === id ? 'default' : 'outline'}
|
||||||
</div>
|
size="sm"
|
||||||
<div className={`text-lg font-semibold mt-1 ${config.color}`}>
|
className={`h-9 ${currentAction === id ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
|
||||||
{config.label}
|
onClick={() => setAction(id)}
|
||||||
</div>
|
>
|
||||||
{getActionDetails()}
|
<Icon className="w-4 h-4 mr-1" />
|
||||||
|
{label}
|
||||||
{/* Show second design slot if active */}
|
</Button>
|
||||||
{designProgress2 && (
|
))}
|
||||||
<div className="mt-2 pt-2 border-t border-gray-700">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<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>
|
|
||||||
{cancelDesign && (
|
|
||||||
<button
|
|
||||||
onClick={() => cancelDesign(2)}
|
|
||||||
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ProgressBar
|
|
||||||
progress={designProgress2.progress}
|
|
||||||
required={designProgress2.required}
|
|
||||||
label="Design progress"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DebugName>
|
|
||||||
|
{/* 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ActionButtons.displayName = "ActionButtons";
|
|
||||||
ProgressBar.displayName = "ProgressBar";
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCombatStore } from '@/lib/game/stores';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
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 (
|
|
||||||
<DebugName name="ActivityLogPanel">
|
|
||||||
<ActivityLog activityLog={activityLog} maxEntries={20} />
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ActivityLogPanel.displayName = 'ActivityLogPanel';
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
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 (
|
|
||||||
<DebugName name="GameToast">
|
|
||||||
<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>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
@@ -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,49 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
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 (
|
|
||||||
<DebugName name="BlueprintsSection">
|
|
||||||
<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>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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)',
|
|
||||||
};
|
|
||||||
Executable → Regular
+25
-107
@@ -4,18 +4,9 @@ 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';
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
/** Per-element regen breakdown: produced rate and downstream drains */
|
|
||||||
export interface ElementRegenBreakdown {
|
|
||||||
/** Rate at which this element is produced from conversion */
|
|
||||||
produced: number;
|
|
||||||
/** Drains: destination element → rate consumed */
|
|
||||||
drains: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ManaDisplayProps {
|
interface ManaDisplayProps {
|
||||||
rawMana: number;
|
rawMana: number;
|
||||||
@@ -27,10 +18,6 @@ interface ManaDisplayProps {
|
|||||||
onGatherStart: () => void;
|
onGatherStart: () => void;
|
||||||
onGatherEnd: () => void;
|
onGatherEnd: () => void;
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
/** Per-element net regen rates (from unified conversion system) */
|
|
||||||
elementRegen?: Record<string, number>;
|
|
||||||
/** Detailed per-element regen breakdown (produced rate + downstream drains) */
|
|
||||||
elementRegenBreakdown?: Record<string, ElementRegenBreakdown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManaDisplay({
|
export function ManaDisplay({
|
||||||
@@ -43,53 +30,35 @@ export function ManaDisplay({
|
|||||||
onGatherStart,
|
onGatherStart,
|
||||||
onGatherEnd,
|
onGatherEnd,
|
||||||
elements,
|
elements,
|
||||||
elementRegen,
|
|
||||||
elementRegenBreakdown,
|
|
||||||
}: ManaDisplayProps) {
|
}: ManaDisplayProps) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const [expandedElements, setExpandedElements] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const toggleElementDetail = (id: string) => {
|
|
||||||
setExpandedElements(prev => ({ ...prev, [id]: !prev[id] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Get unlocked elements 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)
|
||||||
.sort((a, b) => b[1].current - a[1].current);
|
.sort((a, b) => b[1].current - a[1].current);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DebugName name="ManaDisplay">
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
<Card className="bg-[var(--bg-panel)] border-[var(--border-subtle)]">
|
<CardContent className="pt-4 space-y-3">
|
||||||
<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}
|
||||||
@@ -98,38 +67,30 @@ 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" />}</button>
|
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
|
</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;
|
||||||
const regen = elementRegen?.[id];
|
|
||||||
const breakdown = elementRegenBreakdown?.[id];
|
|
||||||
const hasBreakdown = breakdown && (breakdown.produced > 0 || Object.keys(breakdown.drains).length > 0);
|
|
||||||
const isExpanded = expandedElements[id];
|
|
||||||
|
|
||||||
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>
|
||||||
@@ -137,58 +98,18 @@ 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="flex items-center justify-between">
|
<div className="text-xs text-gray-400 game-mono">
|
||||||
<div className="text-xs game-mono" style={{ color: 'var(--text-muted)' }}>
|
{fmt(state.current)}/{fmt(state.max)}
|
||||||
{fmt(state.current)}/{fmt(state.max)}
|
|
||||||
</div>
|
|
||||||
{regen !== undefined && regen !== 0 && (
|
|
||||||
<div className="text-xs game-mono" style={{ color: regen > 0 ? 'var(--color-success)' : 'var(--color-error)' }}>
|
|
||||||
{regen > 0 ? '+' : ''}{fmtDec(regen, 2)}/hr
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Expandable regen breakdown (DISC-8) */}
|
|
||||||
{hasBreakdown && (
|
|
||||||
<button
|
|
||||||
onClick={() => toggleElementDetail(id)}
|
|
||||||
className="flex items-center gap-0.5 mt-1 text-xs w-full"
|
|
||||||
style={{ color: 'var(--text-muted)' }}
|
|
||||||
>
|
|
||||||
{isExpanded ? <ChevronUp className="w-2.5 h-2.5" /> : <ChevronDown className="w-2.5 h-2.5" />}
|
|
||||||
<span>regen detail</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{hasBreakdown && isExpanded && (
|
|
||||||
<div className="mt-1 pt-1 border-t border-[var(--border-subtle)] space-y-0.5" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
{breakdown.produced > 0 && (
|
|
||||||
<div>
|
|
||||||
<span style={{ color: 'var(--color-success)' }}>+{fmtDec(breakdown.produced, 2)}/hr</span>
|
|
||||||
<span> converted from raw</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{Object.entries(breakdown.drains).map(([destId, drainRate]) => {
|
|
||||||
const destElem = ELEMENTS[destId];
|
|
||||||
return (
|
|
||||||
<div key={destId}>
|
|
||||||
<span style={{ color: 'var(--color-warning)' }}>-{fmtDec(drainRate, 2)}/hr</span>
|
|
||||||
<span> → {destElem?.sym} {destElem?.name}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="pt-0.5 border-t border-[var(--border-subtle)]" style={{ color: regen && regen >= 0 ? 'var(--color-success)' : 'var(--color-error)' }}>
|
|
||||||
Net: {regen && regen >= 0 ? '+' : ''}{fmtDec(regen || 0, 2)}/hr
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -196,10 +117,7 @@ export function ManaDisplay({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</DebugName>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ManaDisplay.displayName = "ManaDisplay";
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable → Regular
+31
-21
@@ -1,41 +1,51 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { fmt } from '@/lib/game/stores';
|
import { Play, Pause } from 'lucide-react';
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
import { Button } from '@/components/ui/button';
|
||||||
import { formatHour } from '@/lib/game/utils/formatting';
|
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 (
|
||||||
<DebugName name="TimeDisplay">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="text-center">
|
||||||
<div className="text-center">
|
<div className="text-lg font-bold game-mono text-amber-400">
|
||||||
<div className="text-lg font-bold game-mono text-amber-400">
|
Day {day}
|
||||||
Day {day}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400">
|
|
||||||
{formatHour(hour)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
<div className="text-center">
|
{formatHour(hour)}
|
||||||
<div className="text-lg font-bold game-mono text-purple-400">
|
|
||||||
{fmt(insight)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400">Insight</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DebugName>
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold game-mono text-purple-400">
|
||||||
|
{fmt(insight)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">Insight</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onTogglePause}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
TimeDisplay.displayName = "TimeDisplay";
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||||
|
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
export interface UpgradeDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
skillId: string | null;
|
||||||
|
milestone: 5 | 10;
|
||||||
|
pendingSelections: string[];
|
||||||
|
available: SkillUpgradeChoice[];
|
||||||
|
alreadySelected: string[];
|
||||||
|
onToggle: (upgradeId: string) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpgradeDialog({
|
||||||
|
open,
|
||||||
|
skillId,
|
||||||
|
milestone,
|
||||||
|
pendingSelections,
|
||||||
|
available,
|
||||||
|
alreadySelected,
|
||||||
|
onToggle,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
onOpenChange,
|
||||||
|
}: UpgradeDialogProps) {
|
||||||
|
if (!skillId) return null;
|
||||||
|
|
||||||
|
const skillDef = SKILLS_DEF[skillId];
|
||||||
|
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-amber-400">
|
||||||
|
Choose Upgrade - {skillDef?.name || skillId}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Level {milestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
{available.map((upgrade) => {
|
||||||
|
const isSelected = currentSelections.includes(upgrade.id);
|
||||||
|
const canToggle = currentSelections.length < 2 || isSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={upgrade.id}
|
||||||
|
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-amber-500 bg-amber-900/30'
|
||||||
|
: canToggle
|
||||||
|
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||||
|
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (canToggle) {
|
||||||
|
onToggle(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={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={currentSelections.length !== 2}
|
||||||
|
>
|
||||||
|
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ActionButton } from '@/components/ui/action-button';
|
|
||||||
import { GameCard } from '@/components/ui/game-card';
|
|
||||||
import { SectionHeader } from '@/components/ui/section-header';
|
|
||||||
import { StatRow } from '@/components/ui/stat-row';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
|
||||||
import type { EquipmentSlot } from '@/lib/game/data/equipment';
|
|
||||||
import { fmt } from '@/lib/game/stores';
|
|
||||||
import { CheckCircle, Sparkles } from 'lucide-react';
|
|
||||||
import { useCraftingStore, useManaStore } from '@/lib/game/stores';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
export interface EnchantmentApplierProps {
|
|
||||||
selectedEquipmentInstance: string | null;
|
|
||||||
setSelectedEquipmentInstance: (id: string | null) => void;
|
|
||||||
selectedDesign: string | null;
|
|
||||||
setSelectedDesign: (id: string | null) => void;
|
|
||||||
onEnchantmentApplied?: () => void;
|
|
||||||
onCapacityExceeded?: (itemName: string, used: number, total: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnchantmentApplier({
|
|
||||||
selectedEquipmentInstance,
|
|
||||||
setSelectedEquipmentInstance,
|
|
||||||
selectedDesign,
|
|
||||||
setSelectedDesign,
|
|
||||||
onEnchantmentApplied,
|
|
||||||
onCapacityExceeded,
|
|
||||||
}: EnchantmentApplierProps) {
|
|
||||||
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
|
||||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
|
||||||
const enchantmentDesigns = useCraftingStore((s) => s.enchantmentDesigns);
|
|
||||||
const applicationProgress = useCraftingStore((s) => s.applicationProgress);
|
|
||||||
const _rawMana = useManaStore((s) => s.rawMana);
|
|
||||||
const startApplying = useCraftingStore((s) => s.startApplying);
|
|
||||||
const pauseApplication = useCraftingStore((s) => s.pauseApplication);
|
|
||||||
const resumeApplication = useCraftingStore((s) => s.resumeApplication);
|
|
||||||
const cancelApplication = useCraftingStore((s) => s.cancelApplication);
|
|
||||||
|
|
||||||
// Get equipped items as array - ONLY show items tagged 'Ready for Enchantment' (requirement cr5)
|
|
||||||
const equippedItems = Object.entries(equippedInstances)
|
|
||||||
.filter(([, instanceId]) => instanceId && equipmentInstances[instanceId])
|
|
||||||
.map(([slot, instanceId]) => ({
|
|
||||||
slot: slot as EquipmentSlot,
|
|
||||||
instance: equipmentInstances[instanceId!],
|
|
||||||
}))
|
|
||||||
.filter(({ instance }) => instance.tags?.includes('Ready for Enchantment'));
|
|
||||||
|
|
||||||
// Handle apply button click
|
|
||||||
const handleApply = () => {
|
|
||||||
if (!selectedEquipmentInstance || !selectedDesign) return;
|
|
||||||
|
|
||||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
|
||||||
const design = enchantmentDesigns.find(d => d.id === selectedDesign);
|
|
||||||
|
|
||||||
if (!instance || !design) return;
|
|
||||||
|
|
||||||
// Check capacity
|
|
||||||
const availableCap = instance.totalCapacity - instance.usedCapacity;
|
|
||||||
if (availableCap < design.totalCapacityUsed) {
|
|
||||||
onCapacityExceeded?.(instance.name, instance.usedCapacity, instance.totalCapacity);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
startApplying(selectedEquipmentInstance, selectedDesign);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugName name="EnchantmentApplier">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{/* Equipment & Design Selection */}
|
|
||||||
<GameCard variant="default">
|
|
||||||
<SectionHeader title="Select Equipment & Design" />
|
|
||||||
{applicationProgress ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-sm text-[var(--text-secondary)]">
|
|
||||||
Applying to: {equipmentInstances[applicationProgress.equipmentInstanceId]?.name}
|
|
||||||
</div>
|
|
||||||
<div className="h-3 bg-[var(--bg-sunken)] rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-[var(--mana-light)] transition-all duration-300"
|
|
||||||
style={{ width: `${(applicationProgress.progress / applicationProgress.required) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
|
||||||
<span>{applicationProgress.progress.toFixed(1)}h / {applicationProgress.required.toFixed(1)}h</span>
|
|
||||||
<span>Mana spent: {fmt(applicationProgress.manaSpent)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{applicationProgress.paused ? (
|
|
||||||
<ActionButton size="sm" onClick={resumeApplication}>Resume</ActionButton>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ActionButton variant="secondary" size="sm" onClick={pauseApplication}>Pause</ActionButton>
|
|
||||||
<ActionButton variant="ghost" size="sm" onClick={() => {
|
|
||||||
cancelApplication();
|
|
||||||
onEnchantmentApplied?.(); // This will trigger the cancel toast via parent
|
|
||||||
}}>Cancel</ActionButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-[var(--text-muted)] mb-2">
|
|
||||||
Equipment (Ready for Enchantment):
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="h-32">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{equippedItems.map(({ slot: _slot, instance }) => (
|
|
||||||
<div
|
|
||||||
key={instance.instanceId}
|
|
||||||
className={`p-2 rounded border cursor-pointer text-sm transition-all
|
|
||||||
${selectedEquipmentInstance === instance.instanceId
|
|
||||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`Select ${instance.name} (Ready for Enchantment)`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[var(--text-primary)]">{instance.name}</span>
|
|
||||||
<span className="text-xs text-[var(--text-muted)]">
|
|
||||||
({instance.usedCapacity}/{instance.totalCapacity} cap)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--color-success)] mt-1">
|
|
||||||
<CheckCircle size={10} className="inline mr-1" />
|
|
||||||
Ready
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{equippedItems.length === 0 && (
|
|
||||||
<div className="text-center text-[var(--text-muted)] text-xs py-2">
|
|
||||||
No equipment ready for enchantment.
|
|
||||||
<br />
|
|
||||||
Prepare equipment first in the Prepare stage.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-[var(--text-muted)] mb-2">Design:</div>
|
|
||||||
<ScrollArea className="h-32">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{enchantmentDesigns.map(design => (
|
|
||||||
<div
|
|
||||||
key={design.id}
|
|
||||||
className={`p-2 rounded border cursor-pointer text-sm transition-all
|
|
||||||
${selectedDesign === design.id
|
|
||||||
? 'border-[var(--mana-stellar)] bg-[var(--mana-stellar)]/10'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
onClick={() => setSelectedDesign(design.id)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`Select design: ${design.name}`}
|
|
||||||
>
|
|
||||||
<span className="text-[var(--text-primary)]">{design.name}</span>
|
|
||||||
<span className="text-xs text-[var(--text-muted)] ml-2">
|
|
||||||
({design.totalCapacityUsed} cap)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{enchantmentDesigns.length === 0 && (
|
|
||||||
<div className="text-center text-[var(--text-muted)] text-xs py-2">
|
|
||||||
No designs available. Create one in the Design stage.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</GameCard>
|
|
||||||
|
|
||||||
{/* Application Details */}
|
|
||||||
<GameCard variant="default">
|
|
||||||
<SectionHeader title="Apply Enchantment" />
|
|
||||||
{!selectedEquipmentInstance || !selectedDesign ? (
|
|
||||||
<div className="text-center text-[var(--text-muted)] py-8">
|
|
||||||
Select equipment and a design
|
|
||||||
</div>
|
|
||||||
) : applicationProgress ? (
|
|
||||||
<div className="text-[var(--text-secondary)]">Application in progress...</div>
|
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
|
||||||
if (!instance) return null;
|
|
||||||
|
|
||||||
// Check if equipment is ready for enchantment
|
|
||||||
const isReady = instance.tags?.includes('Ready for Enchantment');
|
|
||||||
if (!isReady) {
|
|
||||||
return (
|
|
||||||
<div className="text-center text-[var(--color-danger)] py-8">
|
|
||||||
This equipment is not prepared for enchantment. Please prepare it in the Prepare stage first.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const design = enchantmentDesigns.find(d => d.id === selectedDesign);
|
|
||||||
if (!design) return null;
|
|
||||||
|
|
||||||
const availableCap = instance.totalCapacity - instance.usedCapacity;
|
|
||||||
const canFit = availableCap >= design.totalCapacityUsed;
|
|
||||||
const applicationTime = 2 + design.effects.reduce((t, e) => t + e.stacks, 0);
|
|
||||||
const manaPerHour = 20 + design.effects.reduce((t, e) => t + e.stacks * 5, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="text-lg font-semibold text-[var(--text-primary)]">{design.name}</div>
|
|
||||||
<div className="text-sm text-[var(--text-secondary)]">{instance.name}</div>
|
|
||||||
<div className="text-xs text-[var(--color-success)]">
|
|
||||||
<CheckCircle size={12} className="inline mr-1" />
|
|
||||||
Ready for Enchantment
|
|
||||||
</div>
|
|
||||||
<Separator className="bg-[var(--border-subtle)]" />
|
|
||||||
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<StatRow
|
|
||||||
label="Required Capacity:"
|
|
||||||
value={
|
|
||||||
<span className={canFit ? 'text-[var(--color-success)]' : 'text-[var(--color-danger)]'}>
|
|
||||||
{design.totalCapacityUsed} / {availableCap} available
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
highlight={canFit ? 'success' : 'danger'}
|
|
||||||
/>
|
|
||||||
<StatRow
|
|
||||||
label="Application Time:"
|
|
||||||
value={`${applicationTime}h`}
|
|
||||||
highlight="default"
|
|
||||||
/>
|
|
||||||
<StatRow
|
|
||||||
label="Mana per Hour:"
|
|
||||||
value={manaPerHour}
|
|
||||||
highlight="default"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-[var(--text-muted)]">
|
|
||||||
Effects:
|
|
||||||
<ul className="list-disc list-inside mt-1">
|
|
||||||
{design.effects.map(eff => (
|
|
||||||
<li key={eff.effectId} className="text-[var(--text-secondary)]">
|
|
||||||
{ENCHANTMENT_EFFECTS[eff.effectId]?.name} x{eff.stacks}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ActionButton
|
|
||||||
className="w-full"
|
|
||||||
disabled={!canFit}
|
|
||||||
onClick={handleApply}
|
|
||||||
>
|
|
||||||
<Sparkles size={16} className="mr-2" />
|
|
||||||
Apply Enchantment
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</GameCard>
|
|
||||||
</div>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EnchantmentApplier.displayName = 'EnchantmentApplier';
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { GameCard } from '@/components/ui/game-card';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, EquipmentCraftingProgress, EquipmentCategory } from '@/lib/game/types';
|
|
||||||
import type { EnchantmentDesignerProps } from './EnchantmentDesigner/types';
|
|
||||||
import { EquipmentTypeSelector } from './EnchantmentDesigner/EquipmentTypeSelector';
|
|
||||||
import { EffectSelector } from './EnchantmentDesigner/EffectSelector';
|
|
||||||
import { SavedDesigns } from './EnchantmentDesigner/SavedDesigns';
|
|
||||||
import { DesignForm } from './EnchantmentDesigner/DesignForm';
|
|
||||||
import {
|
|
||||||
getAvailableEffects,
|
|
||||||
getIncompatibleEffects,
|
|
||||||
getOwnedEquipmentTypes,
|
|
||||||
getIncompatibilityReason,
|
|
||||||
calculateDesignCapacityCost,
|
|
||||||
getEquipmentCapacity,
|
|
||||||
calculateDesignTime,
|
|
||||||
addEffectToDesign,
|
|
||||||
removeEffectFromDesign,
|
|
||||||
} from './EnchantmentDesigner/utils';
|
|
||||||
import { useCraftingStore, useAttunementStore } from '@/lib/game/stores';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
export function EnchantmentDesigner({
|
|
||||||
selectedEquipmentType,
|
|
||||||
setSelectedEquipmentType,
|
|
||||||
selectedEffects,
|
|
||||||
setSelectedEffects,
|
|
||||||
designName,
|
|
||||||
setDesignName,
|
|
||||||
selectedDesign,
|
|
||||||
setSelectedDesign,
|
|
||||||
}: EnchantmentDesignerProps) {
|
|
||||||
// Attunement store — get Enchanter level for effect selector gating
|
|
||||||
const enchanterLevel = useAttunementStore((s) => s.attunements?.enchanter?.level ?? 0);
|
|
||||||
|
|
||||||
// Crafting store selectors
|
|
||||||
const enchantmentDesigns = useCraftingStore((s) => s.enchantmentDesigns);
|
|
||||||
const designProgress = useCraftingStore((s) => s.designProgress);
|
|
||||||
const startDesigningEnchantment = useCraftingStore((s) => s.startDesigningEnchantment);
|
|
||||||
const cancelDesign = useCraftingStore((s) => s.cancelDesign);
|
|
||||||
const deleteDesign = useCraftingStore((s) => s.deleteDesign);
|
|
||||||
const unlockedEffects = useCraftingStore((s) => s.unlockedEffects);
|
|
||||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
|
||||||
|
|
||||||
// Calculate total capacity cost for current design
|
|
||||||
const designCapacityCost = calculateDesignCapacityCost(selectedEffects, 0);
|
|
||||||
|
|
||||||
// Get capacity limit for selected equipment type
|
|
||||||
const selectedEquipmentCapacity = getEquipmentCapacity(selectedEquipmentType);
|
|
||||||
|
|
||||||
// Calculate design time
|
|
||||||
const designTime = calculateDesignTime(selectedEffects);
|
|
||||||
|
|
||||||
// Add effect to design
|
|
||||||
const addEffect = (effectId: string) => {
|
|
||||||
addEffectToDesign(effectId, selectedEffects, 0, setSelectedEffects);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove effect from design
|
|
||||||
const removeEffect = (effectId: string) => {
|
|
||||||
removeEffectFromDesign(effectId, selectedEffects, setSelectedEffects);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create design
|
|
||||||
const handleCreateDesign = () => {
|
|
||||||
if (!designName || !selectedEquipmentType || selectedEffects.length === 0) return;
|
|
||||||
|
|
||||||
const success = startDesigningEnchantment(designName, selectedEquipmentType, selectedEffects);
|
|
||||||
if (success) {
|
|
||||||
// Reset form
|
|
||||||
setDesignName('');
|
|
||||||
setSelectedEquipmentType(null);
|
|
||||||
setSelectedEffects([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get available effects for selected equipment type (only unlocked ones)
|
|
||||||
const availableEffects = getAvailableEffects(selectedEquipmentType, unlockedEffects);
|
|
||||||
|
|
||||||
// Get incompatible effects (unlocked but not for this equipment type)
|
|
||||||
const incompatibleEffects = getIncompatibleEffects(selectedEquipmentType, unlockedEffects);
|
|
||||||
|
|
||||||
// Get equipment types that the player actually owns (has instances of)
|
|
||||||
const ownedEquipmentTypes = getOwnedEquipmentTypes(equipmentInstances);
|
|
||||||
|
|
||||||
// Get the reason why an effect is incompatible
|
|
||||||
const getIncompatibilityReasonWrapper = (effect: { id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] }) => {
|
|
||||||
return getIncompatibilityReason(effect, selectedEquipmentType);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render stage
|
|
||||||
return (
|
|
||||||
<DebugName name="EnchantmentDesigner">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{/* Equipment Type Selection */}
|
|
||||||
<EquipmentTypeSelector
|
|
||||||
ownedEquipmentTypes={ownedEquipmentTypes}
|
|
||||||
selectedEquipmentType={selectedEquipmentType}
|
|
||||||
setSelectedEquipmentType={setSelectedEquipmentType}
|
|
||||||
designProgress={designProgress}
|
|
||||||
cancelDesign={cancelDesign}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Effect Selection */}
|
|
||||||
<GameCard variant="default">
|
|
||||||
<EffectSelector
|
|
||||||
selectedEquipmentType={selectedEquipmentType}
|
|
||||||
selectedEffects={selectedEffects}
|
|
||||||
setSelectedEffects={setSelectedEffects}
|
|
||||||
availableEffects={availableEffects}
|
|
||||||
incompatibleEffects={incompatibleEffects}
|
|
||||||
enchantingLevel={enchanterLevel}
|
|
||||||
efficiencyBonus={0}
|
|
||||||
designProgress={designProgress}
|
|
||||||
addEffect={addEffect}
|
|
||||||
removeEffect={removeEffect}
|
|
||||||
getIncompatibilityReason={getIncompatibilityReasonWrapper}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Selected effects summary - only show when not in design progress and equipment type is selected */}
|
|
||||||
{!designProgress && selectedEquipmentType && (
|
|
||||||
<>
|
|
||||||
<Separator className="bg-[var(--border-subtle)] my-2" />
|
|
||||||
<DesignForm
|
|
||||||
designName={designName}
|
|
||||||
setDesignName={setDesignName}
|
|
||||||
selectedEffects={selectedEffects}
|
|
||||||
designCapacityCost={designCapacityCost}
|
|
||||||
selectedEquipmentCapacity={selectedEquipmentCapacity}
|
|
||||||
isOverCapacity={designCapacityCost > selectedEquipmentCapacity}
|
|
||||||
designTime={designTime}
|
|
||||||
selectedEquipmentType={selectedEquipmentType}
|
|
||||||
handleCreateDesign={handleCreateDesign}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</GameCard>
|
|
||||||
|
|
||||||
{/* Saved Designs */}
|
|
||||||
<SavedDesigns
|
|
||||||
enchantmentDesigns={enchantmentDesigns}
|
|
||||||
selectedDesign={selectedDesign}
|
|
||||||
setSelectedDesign={setSelectedDesign}
|
|
||||||
deleteDesign={deleteDesign}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EnchantmentDesigner.displayName = 'EnchantmentDesigner';
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ActionButton } from '@/components/ui/action-button';
|
|
||||||
import { StatRow } from '@/components/ui/stat-row';
|
|
||||||
import type { DesignFormProps } from './types';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
export function DesignForm({
|
|
||||||
designName,
|
|
||||||
setDesignName,
|
|
||||||
selectedEffects,
|
|
||||||
designCapacityCost,
|
|
||||||
selectedEquipmentCapacity,
|
|
||||||
isOverCapacity,
|
|
||||||
designTime,
|
|
||||||
handleCreateDesign,
|
|
||||||
}: DesignFormProps) {
|
|
||||||
return (
|
|
||||||
<DebugName name="DesignForm">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Design name..."
|
|
||||||
value={designName}
|
|
||||||
onChange={(e) => setDesignName(e.target.value)}
|
|
||||||
className="w-full bg-[var(--bg-sunken)] border border-[var(--border-default)] rounded px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] focus:outline-none focus:border-[var(--border-focus)]"
|
|
||||||
aria-label="Design name"
|
|
||||||
/>
|
|
||||||
<StatRow
|
|
||||||
label="Total Capacity:"
|
|
||||||
value={
|
|
||||||
<span className={isOverCapacity ? 'text-[var(--color-danger)]' : 'text-[var(--color-success)]'}>
|
|
||||||
{designCapacityCost.toFixed(0)} / {selectedEquipmentCapacity}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatRow
|
|
||||||
label="Design Time:"
|
|
||||||
value={`${designTime.toFixed(1)}h`}
|
|
||||||
highlight="default"
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
className="w-full"
|
|
||||||
disabled={!designName || selectedEffects.length === 0 || isOverCapacity}
|
|
||||||
onClick={handleCreateDesign}
|
|
||||||
>
|
|
||||||
{isOverCapacity ? 'Over Capacity!' : `Start Design (${designTime.toFixed(1)}h)`}
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
DesignForm.displayName = 'DesignForm';
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ActionButton } from '@/components/ui/action-button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { AlertCircle, Wand2, Plus, Minus } from 'lucide-react';
|
|
||||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
|
|
||||||
import type { EffectSelectorProps } from './types';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
export function EffectSelector({
|
|
||||||
selectedEquipmentType,
|
|
||||||
selectedEffects,
|
|
||||||
availableEffects,
|
|
||||||
incompatibleEffects,
|
|
||||||
enchantingLevel,
|
|
||||||
efficiencyBonus,
|
|
||||||
designProgress,
|
|
||||||
addEffect,
|
|
||||||
removeEffect,
|
|
||||||
getIncompatibilityReason,
|
|
||||||
}: EffectSelectorProps) {
|
|
||||||
return (
|
|
||||||
<DebugName name="EffectSelector">
|
|
||||||
<>
|
|
||||||
{enchantingLevel < 1 ? (
|
|
||||||
<div className="text-center text-[var(--text-muted)] py-8">
|
|
||||||
<Wand2 className="w-12 h-12 mx-auto mb-2 opacity-50 text-[var(--text-disabled)]" />
|
|
||||||
<p>Learn Enchanting skill to design enchantments</p>
|
|
||||||
</div>
|
|
||||||
) : designProgress ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm text-[var(--text-secondary)]">Design in progress...</div>
|
|
||||||
{designProgress.effects.map(eff => {
|
|
||||||
const def = ENCHANTMENT_EFFECTS[eff.effectId];
|
|
||||||
return (
|
|
||||||
<div key={eff.effectId} className="flex justify-between text-sm text-[var(--text-primary)]">
|
|
||||||
<span>{def?.name} x{eff.stacks}</span>
|
|
||||||
<span className="text-[var(--text-muted)]">{eff.capacityCost} cap</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : !selectedEquipmentType ? (
|
|
||||||
<div className="text-center text-[var(--text-muted)] py-8">
|
|
||||||
Select an equipment type first
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ScrollArea className="h-48 mb-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Compatible Effects */}
|
|
||||||
{availableEffects.map(effect => {
|
|
||||||
const selected = selectedEffects.find(e => e.effectId === effect.id);
|
|
||||||
const _cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={effect.id}
|
|
||||||
className={`p-2 rounded border transition-all
|
|
||||||
${selected
|
|
||||||
? 'border-[var(--mana-stellar)] bg-[var(--mana-stellar)]/10'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm font-semibold text-[var(--text-primary)]">{effect.name}</div>
|
|
||||||
<div className="text-xs text-[var(--text-muted)]">{effect.description}</div>
|
|
||||||
<div className="text-xs text-[var(--text-disabled)] mt-1">
|
|
||||||
Cost: {effect.baseCapacityCost} cap | Max: {effect.maxStacks}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{selected && (
|
|
||||||
<ActionButton
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={() => removeEffect(effect.id)}
|
|
||||||
>
|
|
||||||
<Minus className="w-3 h-3" />
|
|
||||||
</ActionButton>
|
|
||||||
)}
|
|
||||||
<ActionButton
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={() => addEffect(effect.id)}
|
|
||||||
disabled={!selected && selectedEffects.length >= 5}
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selected && (
|
|
||||||
<Badge variant="outline" className="mt-1 text-xs border-[var(--mana-stellar)] text-[var(--mana-stellar)]">
|
|
||||||
{selected.stacks}/{effect.maxStacks}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Incompatible Effects - Requirement: greyed-out "Unavailable" section with tooltips */}
|
|
||||||
{incompatibleEffects.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Separator className="bg-[var(--border-subtle)] my-2" />
|
|
||||||
<div className="text-xs font-semibold text-[var(--text-disabled)] uppercase tracking-wider mb-2">
|
|
||||||
Unavailable
|
|
||||||
</div>
|
|
||||||
{incompatibleEffects.map(effect => {
|
|
||||||
const reason = getIncompatibilityReason(effect);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider key={effect.id}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div
|
|
||||||
className="p-2 rounded border border-[var(--border-subtle)] bg-[var(--bg-sunken)]/30 opacity-50 cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm font-semibold text-[var(--text-disabled)]">{effect.name}</div>
|
|
||||||
<div className="text-xs text-[var(--text-disabled)]">{effect.description}</div>
|
|
||||||
</div>
|
|
||||||
<AlertCircle size={14} className="text-[var(--text-disabled)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
|
||||||
<p className="font-semibold">Incompatible Effect</p>
|
|
||||||
<p className="text-xs text-[var(--text-muted)] mt-1">{reason}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EffectSelector.displayName = 'EffectSelector';
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { GameCard } from '@/components/ui/game-card';
|
|
||||||
import { SectionHeader } from '@/components/ui/section-header';
|
|
||||||
import { ActionButton } from '@/components/ui/action-button';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import type { EquipmentTypeSelectorProps } from './types';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
export function EquipmentTypeSelector({
|
|
||||||
ownedEquipmentTypes,
|
|
||||||
selectedEquipmentType,
|
|
||||||
setSelectedEquipmentType,
|
|
||||||
designProgress,
|
|
||||||
cancelDesign,
|
|
||||||
}: EquipmentTypeSelectorProps) {
|
|
||||||
return (
|
|
||||||
<DebugName name="EquipmentTypeSelector">
|
|
||||||
<GameCard variant="default">
|
|
||||||
<SectionHeader title="1. Select Equipment Type" />
|
|
||||||
{designProgress ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-sm text-[var(--text-secondary)]">
|
|
||||||
Designing for: {designProgress.equipmentType}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-[var(--mana-light)]">{designProgress.name}</div>
|
|
||||||
<Progress
|
|
||||||
value={(designProgress.progress / designProgress.required) * 100}
|
|
||||||
className="h-3 bg-[var(--bg-sunken)]"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
|
||||||
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
|
|
||||||
<ActionButton size="sm" variant="ghost" onClick={() => cancelDesign(1)}>Cancel</ActionButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{ownedEquipmentTypes.map(type => (
|
|
||||||
<div
|
|
||||||
key={type.id}
|
|
||||||
className={`p-2 rounded border cursor-pointer transition-all
|
|
||||||
${selectedEquipmentType === type.id
|
|
||||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedEquipmentType(type.id)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`Select ${type.name}`}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-semibold text-[var(--text-primary)]">{type.name}</div>
|
|
||||||
<div className="text-xs text-[var(--text-muted)]">Cap: {type.baseCapacity}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{ownedEquipmentTypes.length === 0 && (
|
|
||||||
<div className="text-center text-[var(--text-muted)] py-4 text-sm">
|
|
||||||
No equipment blueprints owned. Craft or find equipment blueprints first.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</GameCard>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EquipmentTypeSelector.displayName = 'EquipmentTypeSelector';
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { GameCard } from '@/components/ui/game-card';
|
|
||||||
import { SectionHeader } from '@/components/ui/section-header';
|
|
||||||
import { ActionButton } from '@/components/ui/action-button';
|
|
||||||
import { Trash2 } from 'lucide-react';
|
|
||||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
|
||||||
import type { SavedDesignsProps } from './types';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
export function SavedDesigns({
|
|
||||||
enchantmentDesigns,
|
|
||||||
selectedDesign,
|
|
||||||
setSelectedDesign,
|
|
||||||
deleteDesign,
|
|
||||||
}: SavedDesignsProps) {
|
|
||||||
return (
|
|
||||||
<DebugName name="SavedDesigns">
|
|
||||||
<GameCard variant="default" className="lg:col-span-2">
|
|
||||||
<SectionHeader title={`Saved Designs (${enchantmentDesigns.length})`} />
|
|
||||||
{enchantmentDesigns.length === 0 ? (
|
|
||||||
<div className="text-center text-[var(--text-muted)] py-4">
|
|
||||||
No saved designs yet
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{enchantmentDesigns.map(design => (
|
|
||||||
<div
|
|
||||||
key={design.id}
|
|
||||||
className={`p-3 rounded border cursor-pointer transition-all
|
|
||||||
${selectedDesign === design.id
|
|
||||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedDesign(design.id)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`Select design: ${design.name}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-[var(--text-primary)]">{design.name}</div>
|
|
||||||
<div className="text-xs text-[var(--text-muted)]">
|
|
||||||
{EQUIPMENT_TYPES[design.equipmentType]?.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ActionButton
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 p-0 text-[var(--text-muted)] hover:text-[var(--color-danger)]"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteDesign(design.id);
|
|
||||||
}}
|
|
||||||
aria-label={`Delete design: ${design.name}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-[var(--text-muted)]">
|
|
||||||
{design.effects.length} effects | {design.totalCapacityUsed} cap
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</GameCard>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SavedDesigns.displayName = 'SavedDesigns';
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, DesignProgress, EquipmentCategory } from '@/lib/game/types';
|
|
||||||
|
|
||||||
export interface EnchantmentDesignerProps {
|
|
||||||
selectedEquipmentType: string | null;
|
|
||||||
setSelectedEquipmentType: (type: string | null) => void;
|
|
||||||
selectedEffects: DesignEffect[];
|
|
||||||
setSelectedEffects: (effects: DesignEffect[]) => void;
|
|
||||||
designName: string;
|
|
||||||
setDesignName: (name: string) => void;
|
|
||||||
selectedDesign: string | null;
|
|
||||||
setSelectedDesign: (id: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EquipmentTypeSelectorProps {
|
|
||||||
ownedEquipmentTypes: Array<{ id: string; name: string; baseCapacity: number }>;
|
|
||||||
selectedEquipmentType: string | null;
|
|
||||||
setSelectedEquipmentType: (type: string | null) => void;
|
|
||||||
designProgress: DesignProgress | null;
|
|
||||||
cancelDesign: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EffectSelectorProps {
|
|
||||||
selectedEquipmentType: string | null;
|
|
||||||
selectedEffects: DesignEffect[];
|
|
||||||
setSelectedEffects: (effects: DesignEffect[]) => void;
|
|
||||||
availableEffects: Array<{ id: string; name: string; description: string; baseCapacityCost: number; maxStacks: number }>;
|
|
||||||
incompatibleEffects: Array<{ id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] }>;
|
|
||||||
enchantingLevel: number;
|
|
||||||
efficiencyBonus: number;
|
|
||||||
designProgress: DesignProgress | null;
|
|
||||||
addEffect: (effectId: string) => void;
|
|
||||||
removeEffect: (effectId: string) => void;
|
|
||||||
getIncompatibilityReason: (effect: { id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] }) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SavedDesignsProps {
|
|
||||||
enchantmentDesigns: EnchantmentDesign[];
|
|
||||||
selectedDesign: string | null;
|
|
||||||
setSelectedDesign: (id: string | null) => void;
|
|
||||||
deleteDesign: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DesignFormProps {
|
|
||||||
designName: string;
|
|
||||||
setDesignName: (name: string) => void;
|
|
||||||
selectedEffects: DesignEffect[];
|
|
||||||
designCapacityCost: number;
|
|
||||||
selectedEquipmentCapacity: number;
|
|
||||||
isOverCapacity: boolean;
|
|
||||||
designTime: number;
|
|
||||||
selectedEquipmentType: string | null;
|
|
||||||
handleCreateDesign: () => void;
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
|
||||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
|
|
||||||
import type { DesignEffect, EquipmentInstance, EquipmentCategory } from '@/lib/game/types';
|
|
||||||
import { calculateDesignCapacityCost as calcCapacityCost, calculateDesignTime as calcDesignTime } from '@/lib/game/crafting-design';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available effects for selected equipment type (only unlocked ones)
|
|
||||||
* Requirement (task3 bug #7): Show incompatible enchantments in greyed-out "Unavailable" section
|
|
||||||
*/
|
|
||||||
export function getAvailableEffects(
|
|
||||||
selectedEquipmentType: string | null,
|
|
||||||
unlockedEffects: string[]
|
|
||||||
) {
|
|
||||||
if (!selectedEquipmentType) return [];
|
|
||||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
|
||||||
if (!type) return [];
|
|
||||||
|
|
||||||
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
|
||||||
effect =>
|
|
||||||
effect.allowedEquipmentCategories.includes(type.category) &&
|
|
||||||
(unlockedEffects.length === 0 || unlockedEffects.includes(effect.id))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get incompatible effects (unlocked but not for this equipment type)
|
|
||||||
*/
|
|
||||||
export function getIncompatibleEffects(
|
|
||||||
selectedEquipmentType: string | null,
|
|
||||||
unlockedEffects: string[]
|
|
||||||
) {
|
|
||||||
if (!selectedEquipmentType) return [];
|
|
||||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
|
||||||
if (!type) return [];
|
|
||||||
|
|
||||||
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
|
||||||
effect =>
|
|
||||||
!effect.allowedEquipmentCategories.includes(type.category) &&
|
|
||||||
unlockedEffects.includes(effect.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get equipment types that the player actually owns (has instances of)
|
|
||||||
* This ensures enchantment compatibility is based on owned items, not just blueprints
|
|
||||||
*/
|
|
||||||
export function getOwnedEquipmentTypes(equipmentInstances: Record<string, EquipmentInstance>) {
|
|
||||||
// Get all unique equipment type IDs from owned instances
|
|
||||||
const ownedEquipmentTypeIds = new Set<string>();
|
|
||||||
|
|
||||||
// Check all equipment instances the player owns
|
|
||||||
for (const instance of Object.values(equipmentInstances || {})) {
|
|
||||||
ownedEquipmentTypeIds.add(instance.typeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter EQUIPMENT_TYPES to only include types the player owns
|
|
||||||
return Object.values(EQUIPMENT_TYPES).filter(type => ownedEquipmentTypeIds.has(type.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the reason why an effect is incompatible
|
|
||||||
*/
|
|
||||||
export function getIncompatibilityReason(
|
|
||||||
effect: { id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] },
|
|
||||||
selectedEquipmentType: string | null
|
|
||||||
): string {
|
|
||||||
if (!selectedEquipmentType) return 'No equipment selected';
|
|
||||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
|
||||||
if (!type) return 'Unknown equipment type';
|
|
||||||
|
|
||||||
// Check what categories this effect is allowed for
|
|
||||||
const allowedCategories = effect.allowedEquipmentCategories;
|
|
||||||
const equipmentCategory = type.category;
|
|
||||||
|
|
||||||
if (allowedCategories.includes(equipmentCategory)) {
|
|
||||||
return 'Compatible';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provide specific reasons
|
|
||||||
if (allowedCategories.includes('weapon' as EquipmentCategory) && equipmentCategory !== 'sword' && equipmentCategory !== 'caster' && equipmentCategory !== 'catalyst') {
|
|
||||||
return `Requires a weapon (${allowedCategories.filter(c => ['sword', 'caster', 'catalyst'].includes(c)).join(', ')})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Requires ${allowedCategories.join(' or ')} equipment`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate total capacity cost for current design
|
|
||||||
* Delegates to canonical calculateDesignCapacityCost from crafting-design
|
|
||||||
*/
|
|
||||||
export function calculateDesignCapacityCost(
|
|
||||||
selectedEffects: DesignEffect[],
|
|
||||||
efficiencyBonus: number
|
|
||||||
): number {
|
|
||||||
return calcCapacityCost(selectedEffects, efficiencyBonus);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get capacity limit for selected equipment type
|
|
||||||
*/
|
|
||||||
export function getEquipmentCapacity(selectedEquipmentType: string | null): number {
|
|
||||||
return selectedEquipmentType ? EQUIPMENT_TYPES[selectedEquipmentType]?.baseCapacity || 0 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate design time
|
|
||||||
* Delegates to canonical calculateDesignTime from crafting-design
|
|
||||||
*/
|
|
||||||
export function calculateDesignTime(selectedEffects: DesignEffect[]): number {
|
|
||||||
return calcDesignTime(selectedEffects);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add effect to design
|
|
||||||
*/
|
|
||||||
export function addEffectToDesign(
|
|
||||||
effectId: string,
|
|
||||||
selectedEffects: DesignEffect[],
|
|
||||||
efficiencyBonus: number,
|
|
||||||
setSelectedEffects: (effects: DesignEffect[]) => void
|
|
||||||
) {
|
|
||||||
const existing = selectedEffects.find(e => e.effectId === effectId);
|
|
||||||
const effectDef = ENCHANTMENT_EFFECTS[effectId];
|
|
||||||
if (!effectDef) return;
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
if (existing.stacks < effectDef.maxStacks) {
|
|
||||||
setSelectedEffects(selectedEffects.map(e =>
|
|
||||||
e.effectId === effectId
|
|
||||||
? { ...e, stacks: e.stacks + 1 }
|
|
||||||
: e
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSelectedEffects([...selectedEffects, {
|
|
||||||
effectId,
|
|
||||||
stacks: 1,
|
|
||||||
capacityCost: calculateEffectCapacityCost(effectId, 1, efficiencyBonus),
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove effect from design
|
|
||||||
*/
|
|
||||||
export function removeEffectFromDesign(
|
|
||||||
effectId: string,
|
|
||||||
selectedEffects: DesignEffect[],
|
|
||||||
setSelectedEffects: (effects: DesignEffect[]) => void
|
|
||||||
) {
|
|
||||||
const existing = selectedEffects.find(e => e.effectId === effectId);
|
|
||||||
if (!existing) return;
|
|
||||||
|
|
||||||
if (existing.stacks > 1) {
|
|
||||||
setSelectedEffects(selectedEffects.map(e =>
|
|
||||||
e.effectId === effectId
|
|
||||||
? { ...e, stacks: e.stacks - 1 }
|
|
||||||
: e
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
setSelectedEffects(selectedEffects.filter(e => e.effectId !== effectId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ActionButton } from '@/components/ui/action-button';
|
|
||||||
import { GameCard } from '@/components/ui/game-card';
|
|
||||||
import { SectionHeader } from '@/components/ui/section-header';
|
|
||||||
import { StatRow } from '@/components/ui/stat-row';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
|
||||||
import { Trash2, CheckCircle, AlertTriangle } from 'lucide-react';
|
|
||||||
import type { EquipmentSlot } from '@/lib/game/types';
|
|
||||||
import { fmt } from '@/lib/game/stores';
|
|
||||||
import { useCraftingStore, useManaStore } from '@/lib/game/stores';
|
|
||||||
import { useGameToast } from '@/components/game/GameToast';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
export interface EnchantmentPreparerProps {
|
|
||||||
selectedEquipmentInstance: string | null;
|
|
||||||
setSelectedEquipmentInstance: (id: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnchantmentPreparer({
|
|
||||||
selectedEquipmentInstance,
|
|
||||||
setSelectedEquipmentInstance,
|
|
||||||
}: EnchantmentPreparerProps) {
|
|
||||||
const showToast = useGameToast();
|
|
||||||
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
|
||||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
|
||||||
const preparationProgress = useCraftingStore((s) => s.preparationProgress);
|
|
||||||
const rawMana = useManaStore((s) => s.rawMana);
|
|
||||||
const startPreparing = useCraftingStore((s) => s.startPreparing);
|
|
||||||
const cancelPreparation = useCraftingStore((s) => s.cancelPreparation);
|
|
||||||
|
|
||||||
// Get equipped items as array
|
|
||||||
const equippedItems = Object.entries(equippedInstances)
|
|
||||||
.filter(([, instanceId]) => instanceId && equipmentInstances[instanceId])
|
|
||||||
.map(([slot, instanceId]) => ({
|
|
||||||
slot: slot as EquipmentSlot,
|
|
||||||
instance: equipmentInstances[instanceId!],
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Confirm dialog state
|
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
|
||||||
|
|
||||||
const handleStartPreparation = () => {
|
|
||||||
if (!selectedEquipmentInstance) return;
|
|
||||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
|
||||||
if (!instance) return;
|
|
||||||
|
|
||||||
// If item has existing enchantments, show confirm dialog (bug #8)
|
|
||||||
if (instance.enchantments.length > 0) {
|
|
||||||
setShowConfirmDialog(true);
|
|
||||||
} else {
|
|
||||||
startPreparingWithToast(selectedEquipmentInstance);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startPreparingWithToast = (instanceId: string) => {
|
|
||||||
const instance = equipmentInstances[instanceId];
|
|
||||||
startPreparing(instanceId);
|
|
||||||
if (instance) {
|
|
||||||
showToast('info', 'Preparation Started', `Preparing ${instance.name} for enchantment...`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmPreparation = () => {
|
|
||||||
if (selectedEquipmentInstance) {
|
|
||||||
startPreparingWithToast(selectedEquipmentInstance);
|
|
||||||
setShowConfirmDialog(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugName name="EnchantmentPreparer">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{/* Equipment Selection */}
|
|
||||||
<GameCard variant="default">
|
|
||||||
<SectionHeader title="Select Equipment to Prepare" />
|
|
||||||
{preparationProgress ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-sm text-[var(--text-secondary)]">
|
|
||||||
Preparing: {equipmentInstances[preparationProgress.equipmentInstanceId]?.name}
|
|
||||||
</div>
|
|
||||||
<div className="h-3 bg-[var(--bg-sunken)] rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-[var(--color-warning)] transition-all duration-300"
|
|
||||||
style={{ width: `${(preparationProgress.progress / preparationProgress.required) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
|
||||||
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
|
|
||||||
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
|
|
||||||
</div>
|
|
||||||
<ActionButton size="sm" variant="ghost" onClick={() => {
|
|
||||||
cancelPreparation();
|
|
||||||
showToast('warning', 'Preparation Cancelled', 'Equipment preparation was cancelled.');
|
|
||||||
}}>Cancel</ActionButton>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{equippedItems.map(({ slot, instance }) => {
|
|
||||||
const hasEnchantments = instance.enchantments.length > 0;
|
|
||||||
const isReady = instance.tags?.includes('Ready for Enchantment');
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={instance.instanceId}
|
|
||||||
className={`p-3 rounded border cursor-pointer transition-all
|
|
||||||
${selectedEquipmentInstance === instance.instanceId
|
|
||||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
|
||||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
|
||||||
}
|
|
||||||
${hasEnchantments ? 'border-l-4 border-l-[var(--color-danger)]' : ''}
|
|
||||||
${isReady ? 'border-l-4 border-l-[var(--color-success)]' : ''}
|
|
||||||
`}
|
|
||||||
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`${instance.name}${hasEnchantments ? ' (has enchantments)' : ''}${isReady ? ' (ready for enchantment)' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-[var(--text-primary)]">{instance.name}</div>
|
|
||||||
<div className="text-xs text-[var(--text-muted)]">{slot}</div>
|
|
||||||
{hasEnchantments && (
|
|
||||||
<div className="text-xs text-[var(--color-danger)] mt-1">
|
|
||||||
<AlertTriangle size={12} className="inline mr-1" />
|
|
||||||
{instance.enchantments.length} enchantments - Preparation will remove them
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isReady && (
|
|
||||||
<div className="text-xs text-[var(--color-success)] mt-1">
|
|
||||||
<CheckCircle size={12} className="inline mr-1" />
|
|
||||||
Ready for Enchantment
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-right text-sm">
|
|
||||||
<div className="text-[var(--color-success)]">{instance.usedCapacity}/{instance.totalCapacity} cap</div>
|
|
||||||
<div className="text-xs text-[var(--text-muted)]">{instance.enchantments.length} enchants</div>
|
|
||||||
{/* Requirement: Visual badge for 'Ready for Enchantment' */}
|
|
||||||
{isReady && (
|
|
||||||
<Badge className="mt-1 bg-[var(--color-success)]/20 text-[var(--color-success)] border-[var(--color-success)]/40">
|
|
||||||
<CheckCircle size={10} className="mr-1" />
|
|
||||||
Ready
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{equippedItems.length === 0 && (
|
|
||||||
<div className="text-center text-[var(--text-muted)] py-4">No equipped items</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</GameCard>
|
|
||||||
|
|
||||||
{/* Preparation Details */}
|
|
||||||
<GameCard variant="default">
|
|
||||||
<SectionHeader title="Preparation Details" />
|
|
||||||
{!selectedEquipmentInstance ? (
|
|
||||||
<div className="text-center text-[var(--text-muted)] py-8">
|
|
||||||
Select equipment to prepare
|
|
||||||
</div>
|
|
||||||
) : preparationProgress ? (
|
|
||||||
<div className="text-[var(--text-secondary)]">Preparation in progress...</div>
|
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
|
||||||
if (!instance) return null;
|
|
||||||
const hasEnchantments = instance.enchantments.length > 0;
|
|
||||||
const isReady = instance.tags?.includes('Ready for Enchantment');
|
|
||||||
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
|
|
||||||
const manaCost = instance.totalCapacity * 10;
|
|
||||||
|
|
||||||
// Calculate disenchant recovery
|
|
||||||
const recoveryRate = 0.1; // Base recovery rate
|
|
||||||
const totalRecoverable = instance.enchantments.reduce(
|
|
||||||
(sum, e) => sum + Math.floor(e.actualCost * recoveryRate),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="text-lg font-semibold text-[var(--text-primary)]">{instance.name}</div>
|
|
||||||
<Separator className="bg-[var(--border-subtle)]" />
|
|
||||||
|
|
||||||
{/* Show warning if item has enchantments - Requirement: button reads "Prepare — removes existing enchantments" */}
|
|
||||||
{hasEnchantments && !isReady && (
|
|
||||||
<div className="p-3 rounded border border-[var(--color-danger)]/50 bg-[var(--color-danger)]/10">
|
|
||||||
<div className="text-sm font-semibold text-[var(--color-danger)]">
|
|
||||||
<AlertTriangle size={14} className="inline mr-1" />
|
|
||||||
Equipment has enchantments
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--text-muted)] mt-1">
|
|
||||||
Preparation will remove all existing enchantments and recover some mana.
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm mt-2">
|
|
||||||
<span className="text-[var(--text-muted)]">Recoverable Mana:</span>
|
|
||||||
<span className="text-[var(--color-success)]">{fmt(totalRecoverable)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Show ready status */}
|
|
||||||
{isReady && (
|
|
||||||
<div className="p-3 rounded border border-[var(--color-success)]/50 bg-[var(--color-success)]/10">
|
|
||||||
<div className="text-sm font-semibold text-[var(--color-success)]">
|
|
||||||
<CheckCircle size={14} className="inline mr-1" />
|
|
||||||
Ready for Enchantment
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--text-muted)] mt-1">
|
|
||||||
This item has been prepared and is ready for enchantment application.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<StatRow
|
|
||||||
label="Capacity:"
|
|
||||||
value={`${instance.usedCapacity}/${instance.totalCapacity}`}
|
|
||||||
highlight="default"
|
|
||||||
/>
|
|
||||||
<StatRow
|
|
||||||
label="Prep Time:"
|
|
||||||
value={`${prepTime}h`}
|
|
||||||
highlight="default"
|
|
||||||
/>
|
|
||||||
<StatRow
|
|
||||||
label="Mana Cost:"
|
|
||||||
value={
|
|
||||||
<span className={rawMana < manaCost ? 'text-[var(--color-danger)]' : 'text-[var(--color-success)]'}>
|
|
||||||
{fmt(manaCost)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
highlight={rawMana < manaCost ? 'danger' : 'success'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Requirement (bug #8): Confirm dialog before proceeding if item has enchantments */}
|
|
||||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<ActionButton
|
|
||||||
className="w-full"
|
|
||||||
disabled={rawMana < manaCost || isReady}
|
|
||||||
onClick={handleStartPreparation}
|
|
||||||
>
|
|
||||||
{hasEnchantments ? (
|
|
||||||
<>
|
|
||||||
<Trash2 size={16} className="mr-2" />
|
|
||||||
Prepare — removes existing enchantments ({prepTime}h, {fmt(manaCost)} mana)
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>Start Preparation ({prepTime}h, {fmt(manaCost)} mana)</>
|
|
||||||
)}
|
|
||||||
</ActionButton>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="text-[var(--color-danger)]">
|
|
||||||
<AlertTriangle className="inline mr-2" size={18} />
|
|
||||||
Confirm Preparation
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription className="text-[var(--text-secondary)]">
|
|
||||||
This equipment has {instance.enchantments.length} existing enchantment(s). Preparation will
|
|
||||||
<strong className="text-[var(--color-danger)]"> permanently remove</strong> all existing enchantments
|
|
||||||
and recover approximately <strong className="text-[var(--color-success)]">{fmt(totalRecoverable)} mana</strong>.
|
|
||||||
<div className="mt-2 p-2 bg-[var(--bg-sunken)]/50 rounded text-xs">
|
|
||||||
Equipment: {instance.name}<br />
|
|
||||||
Enchantments to remove: {instance.enchantments.length}
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel
|
|
||||||
className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
|
||||||
onClick={() => setShowConfirmDialog(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className="bg-[var(--color-danger)] hover:bg-[var(--interactive-danger-hover)] text-white"
|
|
||||||
onClick={confirmPreparation}
|
|
||||||
>
|
|
||||||
Yes, Remove Enchantments & Prepare
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</GameCard>
|
|
||||||
</div>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EnchantmentPreparer.displayName = 'EnchantmentPreparer';
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Package, Sparkles, Trash2, Anvil } from 'lucide-react';
|
|
||||||
import { CRAFTING_RECIPES, canCraftRecipe } from '@/lib/game/data/crafting-recipes';
|
|
||||||
import { LOOT_DROPS, LOOT_RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
|
||||||
import type { LootInventory } from '@/lib/game/types';
|
|
||||||
import { fmt } from '@/lib/game/stores';
|
|
||||||
import { useCraftingStore, useCombatStore, useManaStore } from '@/lib/game/stores';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
// ─── Crafting Progress ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CraftingProgress({ progress }: { progress: { blueprintId: string; progress: number; required: number; manaSpent: number } }) {
|
|
||||||
const recipe = CRAFTING_RECIPES[progress.blueprintId];
|
|
||||||
const cancelEquipmentCrafting = useCraftingStore((s) => s.cancelEquipmentCrafting);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
Crafting: {recipe?.name}
|
|
||||||
</div>
|
|
||||||
<Progress value={(progress.progress / progress.required) * 100} className="h-3" />
|
|
||||||
<div className="flex justify-between text-xs text-gray-400">
|
|
||||||
<span>{progress.progress.toFixed(1)}h / {progress.required.toFixed(1)}h</span>
|
|
||||||
<span>Mana spent: {fmt(progress.manaSpent)}</span>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>Cancel</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Blueprint Card ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function BlueprintCard({ bpId, lootInventory, rawMana, isCrafting, startCraftingEquipment }: {
|
|
||||||
bpId: string;
|
|
||||||
lootInventory: LootInventory;
|
|
||||||
rawMana: number;
|
|
||||||
isCrafting: boolean;
|
|
||||||
startCraftingEquipment: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const recipe = CRAFTING_RECIPES[bpId];
|
|
||||||
if (!recipe) return null;
|
|
||||||
|
|
||||||
const { canCraft } = canCraftRecipe(recipe, lootInventory.materials, rawMana);
|
|
||||||
const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="p-3 rounded border bg-gray-800/50"
|
|
||||||
style={{ borderColor: rarityStyle?.color }}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold" style={{ color: rarityStyle?.color }}>
|
|
||||||
{recipe.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 capitalize">{recipe.rarity}</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{recipe.equipmentTypeId ? 'Equipment' : 'Other'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-400 mb-2">{recipe.description}</div>
|
|
||||||
|
|
||||||
<Separator className="bg-gray-700 my-2" />
|
|
||||||
|
|
||||||
<div className="text-xs space-y-1">
|
|
||||||
<div className="text-gray-500">Materials:</div>
|
|
||||||
{Object.entries(recipe.materials).map(([matId, amount]) => {
|
|
||||||
const available = lootInventory.materials[matId] || 0;
|
|
||||||
const matDrop = LOOT_DROPS[matId];
|
|
||||||
const hasEnough = available >= amount;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={matId} className="flex justify-between">
|
|
||||||
<span>{matDrop?.name || matId}</span>
|
|
||||||
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
|
|
||||||
{available} / {amount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-2">
|
|
||||||
<span>Mana Cost:</span>
|
|
||||||
<span className={rawMana >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
|
||||||
{fmt(recipe.manaCost)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Craft Time:</span>
|
|
||||||
<span>{recipe.craftTime}h</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="w-full mt-3"
|
|
||||||
size="sm"
|
|
||||||
disabled={!canCraft || isCrafting}
|
|
||||||
onClick={() => startCraftingEquipment(bpId)}
|
|
||||||
>
|
|
||||||
{canCraft ? 'Craft Equipment' : 'Missing Resources'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Blueprint List ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function BlueprintList({ lootInventory, rawMana, startCraftingEquipment, currentAction }: { lootInventory: LootInventory; rawMana: number; startCraftingEquipment: (id: string) => void; currentAction: string | null }) {
|
|
||||||
if (lootInventory.blueprints.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center text-gray-400 py-4">
|
|
||||||
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
||||||
<p>No blueprints discovered yet.</p>
|
|
||||||
<p className="text-xs mt-1">Defeat guardians to find blueprints!</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{lootInventory.blueprints.map(bpId => (
|
|
||||||
<BlueprintCard
|
|
||||||
key={bpId}
|
|
||||||
bpId={bpId}
|
|
||||||
lootInventory={lootInventory}
|
|
||||||
rawMana={rawMana}
|
|
||||||
isCrafting={currentAction === 'craft'}
|
|
||||||
startCraftingEquipment={startCraftingEquipment}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Material Card ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function MaterialCard({ matId, count, deleteMaterial }: { matId: string; count: number; deleteMaterial: (id: string, count: number) => void }) {
|
|
||||||
const drop = LOOT_DROPS[matId];
|
|
||||||
if (!drop) return null;
|
|
||||||
|
|
||||||
const rarityStyle = LOOT_RARITY_COLORS[drop.rarity];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="p-2 rounded border bg-gray-800/50 group relative"
|
|
||||||
style={{ borderColor: rarityStyle?.color }}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
|
|
||||||
{drop.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400">x{count}</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
|
||||||
onClick={() => deleteMaterial(matId, count)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Materials Inventory ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function MaterialsInventory({ materials, deleteMaterial }: { materials: Record<string, number>; deleteMaterial: (id: string, count: number) => void }) {
|
|
||||||
const totalCount = Object.values(materials).reduce((a, b) => a + b, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
|
||||||
<Package className="w-4 h-4" />
|
|
||||||
Materials ({totalCount})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
{Object.keys(materials).length === 0 ? (
|
|
||||||
<div className="text-center text-gray-400 py-4">
|
|
||||||
<Sparkles className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
||||||
<p>No materials collected yet.</p>
|
|
||||||
<p className="text-xs mt-1">Defeat floors to gather materials!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{Object.entries(materials).map(([matId, count]) => {
|
|
||||||
if (count <= 0) return null;
|
|
||||||
return <MaterialCard key={matId} matId={matId} count={count} deleteMaterial={deleteMaterial} />;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function EquipmentCrafter() {
|
|
||||||
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
|
||||||
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
|
||||||
const startCraftingEquipment = useCraftingStore((s) => s.startCraftingEquipment);
|
|
||||||
const deleteMaterial = useCraftingStore((s) => s.deleteMaterial);
|
|
||||||
const rawMana = useManaStore((s) => s.rawMana);
|
|
||||||
const currentAction = useCombatStore((s) => s.currentAction);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugName name="EquipmentCrafter">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
|
||||||
<Anvil className="w-4 h-4" />
|
|
||||||
Available Blueprints
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{equipmentCraftingProgress ? (
|
|
||||||
<CraftingProgress progress={equipmentCraftingProgress} />
|
|
||||||
) : (
|
|
||||||
<BlueprintList lootInventory={lootInventory} rawMana={rawMana} startCraftingEquipment={startCraftingEquipment} currentAction={currentAction} />
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<MaterialsInventory materials={lootInventory.materials} deleteMaterial={deleteMaterial} />
|
|
||||||
</div>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EquipmentCrafter.displayName = 'EquipmentCrafter';
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Barrel file for crafting components
|
|
||||||
|
|
||||||
export { EnchantmentDesigner } from './EnchantmentDesigner';
|
|
||||||
export { EnchantmentPreparer } from './EnchantmentPreparer';
|
|
||||||
export { EnchantmentApplier } from './EnchantmentApplier';
|
|
||||||
export { EquipmentCrafter } from './EquipmentCrafter';
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Sparkles, Unlock } from 'lucide-react';
|
|
||||||
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
|
|
||||||
import { useAttunementStore } from '@/lib/game/stores';
|
|
||||||
import { useManaStore } from '@/lib/game/stores';
|
|
||||||
|
|
||||||
export function AttunementDebug() {
|
|
||||||
const attunements = useAttunementStore((s) => s.attunements);
|
|
||||||
const debugUnlockAttunement = useAttunementStore((s) => s.debugUnlockAttunement);
|
|
||||||
const addAttunementXP = useAttunementStore((s) => s.addAttunementXP);
|
|
||||||
|
|
||||||
const handleUnlockAttunement = (id: string) => {
|
|
||||||
if (debugUnlockAttunement) {
|
|
||||||
debugUnlockAttunement(id);
|
|
||||||
// When unlocking an attunement that has a primary mana type, unlock that element
|
|
||||||
const attunementDef = ATTUNEMENTS_DEF[id];
|
|
||||||
if (attunementDef?.primaryManaType) {
|
|
||||||
useManaStore.getState().unlockElement(attunementDef.primaryManaType, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddAttunementXP = (id: string, amount: number) => {
|
|
||||||
if (addAttunementXP) {
|
|
||||||
addAttunementXP(id, amount);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugName name="AttunementDebug">
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
|
|
||||||
<Sparkles className="w-4 h-4" />
|
|
||||||
Attunements
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{Object.entries(ATTUNEMENTS_DEF || {}).map(([id, def]) => {
|
|
||||||
const isActive = attunements?.[id]?.active;
|
|
||||||
const level = attunements?.[id]?.level || 1;
|
|
||||||
const xp = attunements?.[id]?.experience || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={id} className="flex items-center justify-between p-2 bg-gray-800/50 rounded">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{def.icon}</span>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">{def.name}</div>
|
|
||||||
{isActive && (
|
|
||||||
<div className="text-xs text-gray-400">Lv.{level} • {xp} XP</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleUnlockAttunement(id)}
|
|
||||||
>
|
|
||||||
<Unlock className="w-3 h-3 mr-1" /> Unlock
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleAddAttunementXP(id, 100)}
|
|
||||||
>
|
|
||||||
+100 XP
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
AttunementDebug.displayName = "AttunementDebug";
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Star, Lock } from 'lucide-react';
|
|
||||||
import { useManaStore } from '@/lib/game/stores';
|
|
||||||
import { ELEMENTS } from '@/lib/game/constants';
|
|
||||||
|
|
||||||
export function ElementDebug() {
|
|
||||||
const elements = useManaStore((s) => s.elements);
|
|
||||||
|
|
||||||
const handleUnlockElement = (element: string) => {
|
|
||||||
useManaStore.getState().unlockElement(element, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddElementalMana = (element: string, amount: number) => {
|
|
||||||
const elem = elements?.[element];
|
|
||||||
if (elem?.unlocked) {
|
|
||||||
useManaStore.getState().addElementMana(element, amount, elem.max);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugName name="ElementDebug">
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-green-400 text-sm flex items-center gap-2">
|
|
||||||
<Star className="w-4 h-4" />
|
|
||||||
Elemental Mana
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
|
||||||
{Object.entries(elements || {}).map(([id, elem]) => {
|
|
||||||
const def = ELEMENTS[id];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={id}
|
|
||||||
className={`p-2 rounded border text-center ${
|
|
||||||
elem.unlocked ? 'border-gray-600 bg-gray-800/50' : 'border-gray-800 opacity-60'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
borderColor: elem.unlocked ? def?.color : undefined
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-lg">{def?.sym}</div>
|
|
||||||
<div className="text-xs text-gray-400">{def?.name}</div>
|
|
||||||
<div className="text-xs text-gray-300 mt-1">
|
|
||||||
{elem.current}/{elem.max}
|
|
||||||
</div>
|
|
||||||
{!elem.unlocked && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => handleUnlockElement(id)}
|
|
||||||
>
|
|
||||||
<Lock className="w-3 h-3 mr-1" /> Unlock
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{elem.unlocked && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => handleAddElementalMana(id, 10)}
|
|
||||||
>
|
|
||||||
+10
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ElementDebug.displayName = "ElementDebug";
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
RotateCcw, AlertTriangle, Zap, Clock, Settings, Eye,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { DebugName, useDebug } from '@/components/game/debug/debug-context';
|
|
||||||
import { useGameStore, useManaStore, useUIStore, usePrestigeStore, useCraftingStore } from '@/lib/game/stores';
|
|
||||||
import { computeTotalMaxMana } from '@/lib/game/effects';
|
|
||||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
|
||||||
|
|
||||||
// ─── Warning Banner ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function WarningBanner() {
|
|
||||||
return (
|
|
||||||
<Card className="bg-amber-900/20 border-amber-600/50">
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
<div className="flex items-center gap-2 text-amber-400">
|
|
||||||
<AlertTriangle className="w-5 h-5" />
|
|
||||||
<span className="font-semibold">Debug Mode</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-amber-300/70 mt-1">
|
|
||||||
These tools are for development and testing. Using them may break game balance or save data.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Display Options ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function DisplayOptions() {
|
|
||||||
const { showComponentNames, toggleComponentNames } = useDebug();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
Display Options
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="show-component-names" className="text-sm">Show Component Names</Label>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Display component names at the top of each component for debugging
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="show-component-names"
|
|
||||||
checked={showComponentNames}
|
|
||||||
onCheckedChange={toggleComponentNames}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Game Reset Section ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function GameResetSection({ confirmReset, onReset }: { confirmReset: boolean; onReset: () => void }) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-red-400 text-sm flex items-center gap-2">
|
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
Game Reset
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Reset all game progress and start fresh. This cannot be undone.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
className={`w-full ${confirmReset ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'}`}
|
|
||||||
onClick={onReset}
|
|
||||||
>
|
|
||||||
{confirmReset ? (
|
|
||||||
<>
|
|
||||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
|
||||||
Click Again to Confirm Reset
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
|
||||||
Reset Game
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mana Debug Section ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ManaDebugSection({ rawMana, maxMana, onAddMana, onFillMana }: {
|
|
||||||
rawMana: number;
|
|
||||||
maxMana: number;
|
|
||||||
onAddMana: (amount: number) => void;
|
|
||||||
onFillMana: () => void;
|
|
||||||
}) {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-blue-400 text-sm flex items-center gap-2">
|
|
||||||
<Zap className="w-4 h-4" />
|
|
||||||
Mana Debug
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="text-xs text-gray-400 mb-2">
|
|
||||||
Current: {rawMana} / {maxMana || '?'}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => onAddMana(10)}>
|
|
||||||
<Zap className="w-3 h-3 mr-1" /> +10
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => onAddMana(100)}>
|
|
||||||
<Zap className="w-3 h-3 mr-1" /> +100
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => onAddMana(1000)}>
|
|
||||||
<Zap className="w-3 h-3 mr-1" /> +1K
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => onAddMana(10000)}>
|
|
||||||
<Zap className="w-3 h-3 mr-1" /> +10K
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Separator className="bg-gray-700" />
|
|
||||||
<div className="text-xs text-gray-400 mb-2">Fill to max:</div>
|
|
||||||
<Button size="sm" className="w-full bg-blue-600 hover:bg-blue-700" onClick={onFillMana}>
|
|
||||||
Fill Mana
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Time Control Section ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function TimeControlSection({ day, hour, paused, onSetDay, onTogglePause }: {
|
|
||||||
day: number;
|
|
||||||
hour: number;
|
|
||||||
paused: boolean;
|
|
||||||
onSetDay: (day: number) => void;
|
|
||||||
onTogglePause: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
Time Control
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="text-xs text-gray-400">
|
|
||||||
Current: Day {day}, Hour {Number.isFinite(hour) ? hour.toFixed(2) : '0.00'}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => onSetDay(1)}>Day 1</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => onSetDay(10)}>Day 10</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => onSetDay(20)}>Day 20</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => onSetDay(30)}>Day 30</Button>
|
|
||||||
</div>
|
|
||||||
<Separator className="bg-gray-700" />
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="outline" onClick={onTogglePause}>
|
|
||||||
{paused ? '▶ Resume' : '⏸ Pause'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Quick Actions Section ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function QuickActionsSection({ onUnlockBase, onUnlockUtility }: {
|
|
||||||
onUnlockBase: () => void;
|
|
||||||
onUnlockUtility: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
Quick Actions
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<Button size="sm" variant="outline" onClick={onUnlockBase}>
|
|
||||||
Unlock All Base Elements
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={onUnlockUtility}>
|
|
||||||
Unlock Utility Elements
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function GameStateDebug() {
|
|
||||||
const [confirmReset, setConfirmReset] = useState(false);
|
|
||||||
const { showComponentNames, toggleComponentNames } = useDebug();
|
|
||||||
|
|
||||||
const rawMana = useManaStore((s) => s.rawMana);
|
|
||||||
const elements = useManaStore((s) => s.elements);
|
|
||||||
const unlockElement = useManaStore((s) => s.unlockElement);
|
|
||||||
const gatherMana = useGameStore((s) => s.gatherMana);
|
|
||||||
const day = useGameStore((s) => s.day);
|
|
||||||
const hour = useGameStore((s) => s.hour);
|
|
||||||
const paused = useUIStore((s) => s.paused);
|
|
||||||
const togglePause = useUIStore((s) => s.togglePause);
|
|
||||||
const resetGame = useGameStore((s) => s.resetGame);
|
|
||||||
|
|
||||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
|
||||||
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
|
||||||
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
|
||||||
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (confirmReset) {
|
|
||||||
resetGame();
|
|
||||||
setConfirmReset(false);
|
|
||||||
} else {
|
|
||||||
setConfirmReset(true);
|
|
||||||
setTimeout(() => setConfirmReset(false), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddMana = (amount: number) => {
|
|
||||||
for (let i = 0; i < amount; i++) {
|
|
||||||
gatherMana();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const upgradeEffects = getUnifiedEffects({ skillUpgrades: {}, skillTiers: {}, equippedInstances, equipmentInstances });
|
|
||||||
const computedMaxMana = computeTotalMaxMana(
|
|
||||||
{ skills: {}, prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, equippedInstances, equipmentInstances },
|
|
||||||
upgradeEffects
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFillMana = () => {
|
|
||||||
useManaStore.setState((s) => ({ rawMana: Math.max(s.rawMana, computedMaxMana) }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSetDay = (d: number) => {
|
|
||||||
useGameStore.setState({ day: d, hour: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnlockBase = () => {
|
|
||||||
['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'].forEach(e => {
|
|
||||||
if (!elements[e]?.unlocked) {
|
|
||||||
unlockElement(e, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnlockUtility = () => {
|
|
||||||
['transference'].forEach(e => {
|
|
||||||
if (!elements[e]?.unlocked) {
|
|
||||||
unlockElement(e, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugName name="GameStateDebug">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<WarningBanner />
|
|
||||||
<DisplayOptions />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<GameResetSection confirmReset={confirmReset} onReset={handleReset} />
|
|
||||||
<ManaDebugSection rawMana={rawMana} maxMana={computedMaxMana} onAddMana={handleAddMana} onFillMana={handleFillMana} />
|
|
||||||
<TimeControlSection day={day} hour={hour} paused={paused} onSetDay={handleSetDay} onTogglePause={togglePause} />
|
|
||||||
<QuickActionsSection
|
|
||||||
onUnlockBase={handleUnlockBase}
|
|
||||||
onUnlockUtility={handleUnlockUtility}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
GameStateDebug.displayName = 'GameStateDebug';
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Bug } from 'lucide-react';
|
|
||||||
|
|
||||||
export function GolemDebug() {
|
|
||||||
return (
|
|
||||||
<DebugName name="GolemDebug">
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-orange-400 text-sm flex items-center gap-2">
|
|
||||||
<Bug className="w-4 h-4" />
|
|
||||||
Golem Debug
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Golem debugging tools will be added here.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
GolemDebug.displayName = "GolemDebug";
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Bug } from 'lucide-react';
|
|
||||||
import { usePrestigeStore, useUIStore, useGameStore } from '@/lib/game/stores';
|
|
||||||
import { ELEMENTS } from '@/lib/game/constants';
|
|
||||||
import { getGuardianForFloor, getAllGuardianFloors } from '@/lib/game/data/guardian-encounters';
|
|
||||||
|
|
||||||
// ─── Guardian Pact Row ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function GuardianPactRow({ floor, isSigned, onForceSign, onRemove }: {
|
|
||||||
floor: number;
|
|
||||||
isSigned: boolean;
|
|
||||||
onForceSign: () => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
}) {
|
|
||||||
const guardian = getGuardianForFloor(floor);
|
|
||||||
if (!guardian) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`p-2 rounded border flex items-center justify-between ${
|
|
||||||
isSigned ? 'border-green-600/50 bg-green-900/20' : 'border-gray-700'
|
|
||||||
}`}
|
|
||||||
style={{ borderColor: isSigned ? undefined : guardian.color, borderWidth: '1px' }}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold" style={{ color: guardian.color }}>
|
|
||||||
{guardian.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400">
|
|
||||||
Floor {floor} | {guardian.pact}x multiplier
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Element: {guardian.element.map(el => ELEMENTS[el]?.name || el).join(' + ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{isSigned ? (
|
|
||||||
<Button size="sm" variant="destructive" onClick={onRemove} className="text-xs">
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button size="sm" variant="default" onClick={onForceSign} className="text-xs bg-amber-600 hover:bg-amber-700">
|
|
||||||
Force Sign
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Guardian Pact List ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function GuardianPactList({ signedPacts, onForceSign, onRemove }: {
|
|
||||||
signedPacts: number[];
|
|
||||||
onForceSign: (floor: number) => void;
|
|
||||||
onRemove: (floor: number) => void;
|
|
||||||
}) {
|
|
||||||
const guardianFloors = getAllGuardianFloors();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
||||||
{guardianFloors.map((floor) => (
|
|
||||||
<GuardianPactRow
|
|
||||||
key={floor}
|
|
||||||
floor={floor}
|
|
||||||
isSigned={signedPacts.includes(floor)}
|
|
||||||
onForceSign={() => onForceSign(floor)}
|
|
||||||
onRemove={() => onRemove(floor)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function PactDebug() {
|
|
||||||
const signedPacts = usePrestigeStore((s) => s.signedPacts);
|
|
||||||
const signedPactDetails = usePrestigeStore((s) => s.signedPactDetails);
|
|
||||||
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
|
|
||||||
|
|
||||||
const addSignedPact = usePrestigeStore((s) => s.addSignedPact);
|
|
||||||
const removePact = usePrestigeStore((s) => s.removePact);
|
|
||||||
const debugSetSignedPacts = usePrestigeStore((s) => s.debugSetSignedPacts);
|
|
||||||
const debugSetPactDetails = usePrestigeStore((s) => s.debugSetPactDetails);
|
|
||||||
|
|
||||||
|
|
||||||
const addLog = useUIStore((s) => s.addLog);
|
|
||||||
|
|
||||||
const forcePact = (floor: number) => {
|
|
||||||
const guardian = getGuardianForFloor(floor);
|
|
||||||
if (!guardian) return;
|
|
||||||
|
|
||||||
if (signedPacts.includes(floor)) {
|
|
||||||
addLog(`\u26a0\ufe0f Already signed pact with ${guardian.name}!`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxPacts = 1 + (prestigeUpgrades?.pactCapacity || 0);
|
|
||||||
if (signedPacts.length >= maxPacts) {
|
|
||||||
addLog(`⚠️ Cannot sign more pacts! Maximum: ${maxPacts}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addSignedPact(floor);
|
|
||||||
|
|
||||||
const newSignedPactDetails = {
|
|
||||||
...signedPactDetails,
|
|
||||||
[floor]: {
|
|
||||||
floor,
|
|
||||||
guardianId: guardian.element.join('+'),
|
|
||||||
signedAt: { day: useGameStore.getState().day, hour: useGameStore.getState().hour },
|
|
||||||
skillLevels: {} as Record<string, number>,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
debugSetPactDetails(newSignedPactDetails);
|
|
||||||
|
|
||||||
addLog(`📜 DEBUG: Pact with ${guardian.name} force-signed!`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePactHandler = (floor: number) => {
|
|
||||||
const guardian = getGuardianForFloor(floor);
|
|
||||||
|
|
||||||
removePact(floor);
|
|
||||||
|
|
||||||
const newSignedPactDetails = { ...signedPactDetails };
|
|
||||||
delete newSignedPactDetails[floor];
|
|
||||||
debugSetPactDetails(newSignedPactDetails);
|
|
||||||
|
|
||||||
addLog(`\ud83d\udcdc DEBUG: Removed pact with ${guardian ? guardian.name : 'Unknown'}!`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAllPacts = () => {
|
|
||||||
addLog(`📜 DEBUG: Cleared all pacts!`);
|
|
||||||
debugSetSignedPacts([]);
|
|
||||||
debugSetPactDetails({});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugName name="PactDebug">
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-orange-400 text-sm flex items-center gap-2">
|
|
||||||
<Bug className="w-4 h-4" />
|
|
||||||
Pact Debug
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-xs text-gray-400 mb-2">
|
|
||||||
Force sign pacts with guardians (bypasses mana costs and signing time)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<GuardianPactList
|
|
||||||
signedPacts={signedPacts}
|
|
||||||
onForceSign={forcePact}
|
|
||||||
onRemove={removePactHandler}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{signedPacts.length > 0 && (
|
|
||||||
<div className="pt-2 border-t border-gray-700">
|
|
||||||
<Button size="sm" variant="destructive" onClick={clearAllPacts} className="w-full text-xs">
|
|
||||||
Clear All Pacts ({signedPacts.length})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-400 pt-2 border-t border-gray-700">
|
|
||||||
Signed Pacts: {signedPacts.length} |
|
|
||||||
Max Pacts: {1 + (prestigeUpgrades?.pactCapacity || 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PactDebug.displayName = 'PactDebug';
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
type ReactNode
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
interface DebugContextType {
|
|
||||||
showComponentNames: boolean;
|
|
||||||
toggleComponentNames: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DebugContext = createContext<DebugContextType | null>(null);
|
|
||||||
|
|
||||||
export function DebugProvider({ children }: { children: ReactNode }) {
|
|
||||||
// Initialize from localStorage if available
|
|
||||||
const [showComponentNames, setShowComponentNames] = useState(() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const saved = localStorage.getItem('debug-show-component-names');
|
|
||||||
return saved === 'true';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleComponentNames = () => {
|
|
||||||
setShowComponentNames(prev => {
|
|
||||||
const newValue = !prev;
|
|
||||||
localStorage.setItem('debug-show-component-names', String(newValue));
|
|
||||||
return newValue;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugContext.Provider value={{ showComponentNames, toggleComponentNames }}>
|
|
||||||
{children}
|
|
||||||
</DebugContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDebug() {
|
|
||||||
const context = useContext(DebugContext);
|
|
||||||
if (!context) {
|
|
||||||
// Return default values if used outside provider
|
|
||||||
return { showComponentNames: false, toggleComponentNames: () => {} };
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrapper component to show component name in debug mode
|
|
||||||
interface DebugNameProps {
|
|
||||||
name: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DebugName({ name, children }: DebugNameProps) {
|
|
||||||
const { showComponentNames } = useDebug();
|
|
||||||
|
|
||||||
if (!showComponentNames) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute -top-5 left-0 text-[10px] font-mono text-yellow-400 bg-yellow-900/50 px-1 rounded z-50">
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
DebugName.displayName = "DebugName";
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { GameStateDebug } from './GameStateDebug';
|
|
||||||
export { ElementDebug } from './ElementDebug';
|
|
||||||
export { AttunementDebug } from './AttunementDebug';
|
|
||||||
export { GolemDebug } from './GolemDebug';
|
|
||||||
export { PactDebug } from './PactDebug';
|
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
// ─── Game Components Index ──────────────────────────────────────────────────────
|
// ─── Game Components Index ──────────────────────────────────────────────────────
|
||||||
// Re-exports all game tab components for cleaner imports
|
// Re-exports all game tab components for cleaner imports
|
||||||
|
|
||||||
// Tab components (consolidated in tabs/ subfolder)
|
// Tab components
|
||||||
|
export { CraftingTab } from './tabs/CraftingTab';
|
||||||
|
export { SpireTab } from './tabs/SpireTab';
|
||||||
|
export { SpellsTab } from './tabs/SpellsTab';
|
||||||
|
export { LabTab } from './tabs/LabTab';
|
||||||
|
export { SkillsTab } from './tabs/SkillsTab';
|
||||||
export { StatsTab } from './tabs/StatsTab';
|
export { StatsTab } from './tabs/StatsTab';
|
||||||
|
|
||||||
// UI components
|
// UI components
|
||||||
export { ActionButtons } from './ActionButtons';
|
export { ActionButtons } from './ActionButtons';
|
||||||
|
export { CalendarDisplay } from './CalendarDisplay';
|
||||||
|
export { ComboMeter } from './ComboMeter';
|
||||||
|
export { CraftingProgress } from './CraftingProgress';
|
||||||
|
export { StudyProgress } from './StudyProgress';
|
||||||
export { ManaDisplay } from './ManaDisplay';
|
export { ManaDisplay } from './ManaDisplay';
|
||||||
export { TimeDisplay } from './TimeDisplay';
|
export { TimeDisplay } from './TimeDisplay';
|
||||||
export { ActivityLogPanel } from './ActivityLogPanel';
|
export { UpgradeDialog } from './UpgradeDialog';
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
|
||||||
import { useCombatStore } from '@/lib/game/stores';
|
|
||||||
import {
|
|
||||||
ACHIEVEMENTS,
|
|
||||||
ACHIEVEMENT_CATEGORY_COLORS,
|
|
||||||
getAchievementsByCategory,
|
|
||||||
isAchievementRevealed,
|
|
||||||
} from '@/lib/game/data/achievements';
|
|
||||||
import type { AchievementDef } from '@/lib/game/types';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { SectionHeader } from '@/components/ui/section-header';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
import { fmt } from '@/lib/game/stores';
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
|
||||||
combat: '⚔️ Combat',
|
|
||||||
progression: '📈 Progression',
|
|
||||||
crafting: '🔨 Crafting',
|
|
||||||
magic: '✨ Magic',
|
|
||||||
special: '🌟 Special',
|
|
||||||
};
|
|
||||||
|
|
||||||
function getProgressForAchievement(
|
|
||||||
achievement: AchievementDef,
|
|
||||||
progress: Record<string, number>,
|
|
||||||
): number {
|
|
||||||
return progress[achievement.id] ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProgressPercent(achievement: AchievementDef, current: number): number {
|
|
||||||
if (achievement.requirement.value <= 0) return 100;
|
|
||||||
return Math.min(100, Math.round((current / achievement.requirement.value) * 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReward(reward: AchievementDef['reward']): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (reward.insight) parts.push(`${reward.insight} insight`);
|
|
||||||
if (reward.manaBonus) parts.push(`+${reward.manaBonus} mana`);
|
|
||||||
if (reward.damageBonus) parts.push(`+${(reward.damageBonus * 100).toFixed(0)}% dmg`);
|
|
||||||
if (reward.regenBonus) parts.push(`+${reward.regenBonus} regen`);
|
|
||||||
if (reward.title) parts.push(`title: "${reward.title}"`);
|
|
||||||
if (reward.unlockEffect) parts.push(`unlock: ${reward.unlockEffect}`);
|
|
||||||
return parts.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Category Section ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface CategorySectionProps {
|
|
||||||
category: string;
|
|
||||||
achievements: AchievementDef[];
|
|
||||||
unlocked: string[];
|
|
||||||
progress: Record<string, number>;
|
|
||||||
collapsed: boolean;
|
|
||||||
onToggleCollapse: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CategorySection({
|
|
||||||
category,
|
|
||||||
achievements,
|
|
||||||
unlocked,
|
|
||||||
progress,
|
|
||||||
collapsed,
|
|
||||||
onToggleCollapse,
|
|
||||||
}: CategorySectionProps) {
|
|
||||||
const color = ACHIEVEMENT_CATEGORY_COLORS[category] ?? '#9CA3AF';
|
|
||||||
const label = CATEGORY_LABELS[category] ?? category;
|
|
||||||
const unlockedCount = achievements.filter((a) => unlocked.includes(a.id)).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-gray-900/60 border-gray-700">
|
|
||||||
<SectionHeader
|
|
||||||
title={`${label} (${unlockedCount}/${achievements.length})`}
|
|
||||||
action={
|
|
||||||
<button
|
|
||||||
onClick={onToggleCollapse}
|
|
||||||
className="text-xs text-gray-400 hover:text-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
{collapsed ? 'Expand ▼' : 'Collapse ▲'}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{!collapsed && (
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{achievements.map((achievement) => {
|
|
||||||
const isUnlocked = unlocked.includes(achievement.id);
|
|
||||||
const currentProgress = getProgressForAchievement(achievement, progress);
|
|
||||||
const percent = getProgressPercent(achievement, currentProgress);
|
|
||||||
const revealed = isAchievementRevealed(achievement, currentProgress);
|
|
||||||
|
|
||||||
if (!revealed) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={achievement.id}
|
|
||||||
className="p-3 rounded border border-gray-700/50 bg-gray-800/30"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-gray-500">???</span>
|
|
||||||
<span className="text-xs text-gray-600 italic">
|
|
||||||
Hidden achievement — keep progressing to reveal
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
<Progress value={percent} className="h-1.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={achievement.id}
|
|
||||||
className={`p-3 rounded border ${
|
|
||||||
isUnlocked
|
|
||||||
? 'border-green-700/50 bg-green-900/20'
|
|
||||||
: 'border-gray-700/50 bg-gray-800/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-sm" style={{ color }}>
|
|
||||||
{achievement.name}
|
|
||||||
</span>
|
|
||||||
{isUnlocked && (
|
|
||||||
<Badge className="bg-green-900/50 text-green-300 text-xs">
|
|
||||||
✓ Unlocked
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{fmt(currentProgress)} / {fmt(achievement.requirement.value)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 mb-2">{achievement.desc}</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Progress value={percent} className="h-1.5 flex-1" />
|
|
||||||
<span className="text-xs text-gray-500 w-10 text-right">
|
|
||||||
{percent}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
<span className="text-gray-400">Reward:</span>{' '}
|
|
||||||
{formatReward(achievement.reward)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function AchievementsTab() {
|
|
||||||
const achievements = useCombatStore((s) => s.achievements);
|
|
||||||
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const byCategory = useMemo(() => getAchievementsByCategory(), []);
|
|
||||||
const categories = useMemo(
|
|
||||||
() => Object.keys(byCategory).sort(),
|
|
||||||
[byCategory],
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalAchievements = Object.keys(ACHIEVEMENTS).length;
|
|
||||||
const unlockedCount = achievements.unlocked.length;
|
|
||||||
|
|
||||||
const toggleCollapse = (category: string) => {
|
|
||||||
setCollapsedCategories((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[category]: !prev[category],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugName name="AchievementsTab">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Summary header */}
|
|
||||||
<Card className="bg-gray-900/60 border-gray-700">
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-100">
|
|
||||||
Achievements
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Track your progress and unlock rewards
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-2xl font-bold text-amber-400">
|
|
||||||
{unlockedCount}
|
|
||||||
<span className="text-sm text-gray-500 font-normal">
|
|
||||||
/{totalAchievements}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={Math.round((unlockedCount / totalAchievements) * 100)}
|
|
||||||
className="h-2 w-32 mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Category sections */}
|
|
||||||
<ScrollArea className="h-[600px] pr-2">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<CategorySection
|
|
||||||
key={category}
|
|
||||||
category={category}
|
|
||||||
achievements={byCategory[category]}
|
|
||||||
unlocked={achievements.unlocked}
|
|
||||||
progress={achievements.progress}
|
|
||||||
collapsed={collapsedCategories[category] ?? false}
|
|
||||||
onToggleCollapse={() => toggleCollapse(category)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
AchievementsTab.displayName = 'AchievementsTab';
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { ActivityLogEntry } from '@/lib/game/types';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
|
||||||
|
|
||||||
interface ActivityLogProps {
|
|
||||||
activityLog: ActivityLogEntry[];
|
|
||||||
maxEntries?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActivityLog({ activityLog, maxEntries = 30 }: ActivityLogProps) {
|
|
||||||
const entries = activityLog.slice(0, maxEntries);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugName name="ActivityLog">
|
|
||||||
<ScrollArea className="h-48">
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<div className="text-xs text-gray-500 italic">No activity yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className="text-xs text-gray-300 border-b border-gray-800 pb-1 last:border-0"
|
|
||||||
>
|
|
||||||
<span className="text-gray-600 mr-1">
|
|
||||||
[{entry.eventType}]
|
|
||||||
</span>
|
|
||||||
{entry.message}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</DebugName>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ActivityLog.displayName = 'ActivityLog';
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
|
|
||||||
// ─── Test: AttunementsTab barrel export ───────────────────────────────────────
|
|
||||||
|
|
||||||
describe('AttunementsTab module structure', () => {
|
|
||||||
it('exports AttunementsTab from barrel index', async () => {
|
|
||||||
const mod = await import('./AttunementsTab');
|
|
||||||
expect(mod.AttunementsTab).toBeDefined();
|
|
||||||
expect(typeof mod.AttunementsTab).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('AttunementsTab has correct displayName', async () => {
|
|
||||||
const { AttunementsTab } = await import('./AttunementsTab');
|
|
||||||
expect(AttunementsTab.displayName).toBe('AttunementsTab');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: Barrel export includes AttunementsTab ──────────────────────────────
|
|
||||||
|
|
||||||
describe('Tab barrel export', () => {
|
|
||||||
it('includes AttunementsTab in the tabs index', async () => {
|
|
||||||
const mod = await import('@/components/game/tabs');
|
|
||||||
expect(mod.AttunementsTab).toBeDefined();
|
|
||||||
expect(typeof mod.AttunementsTab).toBe('function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: Attunement data integrity ──────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Attunement data', () => {
|
|
||||||
it('all attunements have required fields', async () => {
|
|
||||||
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
|
||||||
for (const [id, def] of Object.entries(ATTUNEMENTS_DEF)) {
|
|
||||||
expect(def.id).toBe(id);
|
|
||||||
expect(def.name).toBeTruthy();
|
|
||||||
expect(def.desc).toBeTruthy();
|
|
||||||
expect(def.slot).toBeTruthy();
|
|
||||||
expect(def.icon).toBeTruthy();
|
|
||||||
expect(def.color).toBeTruthy();
|
|
||||||
expect(def.rawManaRegen).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(def.conversionRate).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(def.capabilities.length).toBeGreaterThan(0);
|
|
||||||
expect(def.skillCategories.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enchanter is unlocked by default', async () => {
|
|
||||||
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
|
||||||
expect(ATTUNEMENTS_DEF.enchanter.unlocked).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('invoker and fabricator are locked by default', async () => {
|
|
||||||
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
|
||||||
expect(ATTUNEMENTS_DEF.invoker.unlocked).toBe(false);
|
|
||||||
expect(ATTUNEMENTS_DEF.fabricator.unlocked).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('each attunement has a unique slot', async () => {
|
|
||||||
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
|
||||||
const slots = Object.values(ATTUNEMENTS_DEF).map((d) => d.slot);
|
|
||||||
const uniqueSlots = new Set(slots);
|
|
||||||
expect(uniqueSlots.size).toBe(slots.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: XP curve ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Attunement XP curve', () => {
|
|
||||||
it('level 1 requires 0 XP', async () => {
|
|
||||||
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
|
|
||||||
expect(getAttunementXPForLevel(1)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('level 2 requires 1000 XP', async () => {
|
|
||||||
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
|
|
||||||
expect(getAttunementXPForLevel(2)).toBe(1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('XP requirements increase with level', async () => {
|
|
||||||
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
|
|
||||||
const xp2 = getAttunementXPForLevel(2);
|
|
||||||
const xp3 = getAttunementXPForLevel(3);
|
|
||||||
const xp4 = getAttunementXPForLevel(4);
|
|
||||||
expect(xp3).toBeGreaterThan(xp2);
|
|
||||||
expect(xp4).toBeGreaterThan(xp3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('MAX_ATTUNEMENT_LEVEL is 10', async () => {
|
|
||||||
const { MAX_ATTUNEMENT_LEVEL } = await import('@/lib/game/data/attunements');
|
|
||||||
expect(MAX_ATTUNEMENT_LEVEL).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: Attunement store interactions ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Attunement store interactions', () => {
|
|
||||||
it('addAttunementXP is callable', async () => {
|
|
||||||
const mockAddXP = await vi.fn();
|
|
||||||
mockAddXP('enchanter', 100);
|
|
||||||
expect(mockAddXP).toHaveBeenCalledWith('enchanter', 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('debugUnlockAttunement is callable', async () => {
|
|
||||||
const mockUnlock = await vi.fn();
|
|
||||||
mockUnlock('invoker');
|
|
||||||
expect(mockUnlock).toHaveBeenCalledWith('invoker');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setAttunements is callable', async () => {
|
|
||||||
const mockSet = await vi.fn();
|
|
||||||
mockSet({ enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 } });
|
|
||||||
expect(mockSet).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resetAttunements is callable', async () => {
|
|
||||||
const mockReset = await vi.fn();
|
|
||||||
mockReset();
|
|
||||||
expect(mockReset).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: Slot name mapping ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Attunement slot names', () => {
|
|
||||||
it('all slots used by attunements have display names', async () => {
|
|
||||||
const { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES } = await import('@/lib/game/data/attunements');
|
|
||||||
for (const def of Object.values(ATTUNEMENTS_DEF)) {
|
|
||||||
expect(ATTUNEMENT_SLOT_NAMES[def.slot]).toBeDefined();
|
|
||||||
expect(ATTUNEMENT_SLOT_NAMES[def.slot].length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Test: File size limit ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('File size limits (400 lines max)', () => {
|
|
||||||
it('AttunementsTab.tsx is under 400 lines', async () => {
|
|
||||||
const fs = await import('fs');
|
|
||||||
const path = await import('path');
|
|
||||||
const filePath = path.join(__dirname, 'AttunementsTab.tsx');
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const lines = content.split('\n').length;
|
|
||||||
expect(lines).toBeLessThan(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,325 +1,268 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAttunementStore, usePrestigeStore } from '@/lib/game/stores';
|
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getTotalAttunementRegen, getAvailableSkillCategories, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL, getAttunementConversionRate } from '@/lib/game/data/attunements';
|
||||||
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements';
|
import { ELEMENTS } from '@/lib/game/constants';
|
||||||
import type { AttunementDef, AttunementState } from '@/lib/game/types';
|
import type { GameStore, AttunementState } from '@/lib/game/types';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { fmt } from '@/lib/game/stores';
|
import { Lock, Sparkles, TrendingUp } from 'lucide-react';
|
||||||
import { Unlock } from 'lucide-react';
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
export interface AttunementsTabProps {
|
||||||
|
store: GameStore;
|
||||||
function getXpForNextLevel(level: number): number {
|
|
||||||
if (level >= MAX_ATTUNEMENT_LEVEL) return 0;
|
|
||||||
return getAttunementXPForLevel(level + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getXpProgress(state: AttunementState): number {
|
export function AttunementsTab({ store }: AttunementsTabProps) {
|
||||||
const nextXp = getXpForNextLevel(state.level);
|
const attunements = store.attunements || {};
|
||||||
if (nextXp <= 0) return 100;
|
|
||||||
return Math.min(100, Math.round((state.experience / nextXp) * 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAttunementUnlocked(id: string, attunements: Record<string, AttunementState>): boolean {
|
// Get active attunements
|
||||||
return id in attunements;
|
const activeAttunements = Object.entries(attunements)
|
||||||
}
|
.filter(([, state]) => state.active)
|
||||||
|
.map(([id]) => ATTUNEMENTS_DEF[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
/**
|
// Calculate total regen from attunements
|
||||||
* Check whether an attunement's unlock condition is met.
|
const totalAttunementRegen = getTotalAttunementRegen(attunements);
|
||||||
* Evaluates the condition based on current game state.
|
|
||||||
*/
|
|
||||||
function isUnlockConditionMet(id: string, defeatedGuardians: number[]): boolean {
|
|
||||||
switch (id) {
|
|
||||||
case 'invoker':
|
|
||||||
return defeatedGuardians.includes(10);
|
|
||||||
case 'fabricator':
|
|
||||||
return false; // No specific gating condition implemented
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Attunement Card ─────────────────────────────────────────────────────────
|
// Get available skill categories
|
||||||
|
const availableCategories = getAvailableSkillCategories(attunements);
|
||||||
interface AttunementCardProps {
|
|
||||||
def: AttunementDef;
|
|
||||||
state?: AttunementState;
|
|
||||||
canUnlock?: boolean;
|
|
||||||
onUnlock?: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AttunementCard({ def, state, canUnlock, onUnlock }: AttunementCardProps) {
|
|
||||||
const unlocked = !!state;
|
|
||||||
const isStarting = def.unlocked === true;
|
|
||||||
const xpProgress = state ? getXpProgress(state) : 0;
|
|
||||||
const nextXp = state ? getXpForNextLevel(state.level) : 0;
|
|
||||||
|
|
||||||
// Style tokens derived from def.color
|
|
||||||
const color = def.color;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className="space-y-4">
|
||||||
className={`relative overflow-hidden ${
|
{/* Overview Card */}
|
||||||
unlocked
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
? 'bg-gray-900/60'
|
<CardHeader className="pb-2">
|
||||||
: 'bg-gray-950/80'
|
<CardTitle className="text-amber-400 text-sm">Your Attunements</CardTitle>
|
||||||
}`}
|
</CardHeader>
|
||||||
style={{
|
<CardContent>
|
||||||
borderLeft: `3px solid ${unlocked ? color : `${color}33`}`,
|
<p className="text-sm text-gray-400 mb-3">
|
||||||
borderColor: unlocked ? `${color}88` : `${color}22`,
|
Attunements are magical bonds tied to specific body locations. Each attunement grants unique capabilities,
|
||||||
opacity: unlocked ? 1 : 0.55,
|
mana regeneration, and access to specialized skills. Level them up to increase their power.
|
||||||
}}
|
</p>
|
||||||
>
|
<div className="flex flex-wrap gap-2">
|
||||||
{/* Starting badge (top-right ribbon) */}
|
<Badge className="bg-teal-900/50 text-teal-300">
|
||||||
{isStarting && unlocked && (
|
+{totalAttunementRegen.toFixed(1)} raw mana/hr
|
||||||
<div
|
|
||||||
className="absolute top-3 right-3 text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
|
||||||
style={{ backgroundColor: `${color}22`, color }}
|
|
||||||
>
|
|
||||||
Starting
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Locked overlay pattern */}
|
|
||||||
{!unlocked && (
|
|
||||||
<div className="absolute inset-0 pointer-events-none" style={{ background: `repeating-linear-gradient(45deg, transparent, transparent 12px, ${color}08 12px, ${color}08 24px)` }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardContent className="p-4 space-y-3 relative">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span
|
|
||||||
className="text-xl p-1 rounded"
|
|
||||||
style={{ backgroundColor: `${color}18` }}
|
|
||||||
>
|
|
||||||
{def.icon}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
className="font-semibold"
|
|
||||||
style={{ color: unlocked ? color : `${color}99` }}
|
|
||||||
>
|
|
||||||
{def.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{ATTUNEMENT_SLOT_NAMES[def.slot] ?? def.slot}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{unlocked ? (
|
|
||||||
<Badge
|
|
||||||
className="text-xs font-bold"
|
|
||||||
style={{ backgroundColor: `${color}25`, color, border: `1px solid ${color}44` }}
|
|
||||||
>
|
|
||||||
Lv.{state.level}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
<Badge className="bg-purple-900/50 text-purple-300">
|
||||||
<Badge
|
{activeAttunements.length} active attunement{activeAttunements.length !== 1 ? 's' : ''}
|
||||||
variant="outline"
|
|
||||||
className="text-xs"
|
|
||||||
style={{ borderColor: `${color}44`, color: `${color}88` }}
|
|
||||||
>
|
|
||||||
🔒 Locked
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p className={`text-xs leading-relaxed ${unlocked ? 'text-gray-400' : 'text-gray-600'}`}>{def.desc}</p>
|
|
||||||
|
|
||||||
{/* XP Progress (unlocked only) */}
|
|
||||||
{unlocked && state && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-gray-500">XP Progress</span>
|
|
||||||
<span className="text-gray-400 font-mono">
|
|
||||||
{fmt(state.experience)} / {fmt(nextXp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 rounded-full overflow-hidden bg-gray-800">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full transition-all"
|
|
||||||
style={{ width: `${xpProgress}%`, backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{state.level >= MAX_ATTUNEMENT_LEVEL && (
|
|
||||||
<p className="text-xs text-amber-400 italic">Maximum level reached</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Unlock condition (locked only) */}
|
{/* Attunement Slots */}
|
||||||
{!unlocked && def.unlockCondition && (
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div
|
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
|
||||||
className="text-xs italic pt-2"
|
const state = attunements[id];
|
||||||
style={{ color: `${color}77`, borderTop: `1px solid ${color}15` }}
|
const isActive = state?.active;
|
||||||
>
|
const isUnlocked = state?.active || def.unlocked;
|
||||||
🔒 {def.unlockCondition}
|
const level = state?.level || 1;
|
||||||
</div>
|
const xp = state?.experience || 0;
|
||||||
)}
|
const xpNeeded = getAttunementXPForLevel(level + 1);
|
||||||
|
const xpProgress = xpNeeded > 0 ? (xp / xpNeeded) * 100 : 100;
|
||||||
|
const isMaxLevel = level >= MAX_ATTUNEMENT_LEVEL;
|
||||||
|
|
||||||
{/* Unlock button (locked + condition met) */}
|
// Get primary mana element info
|
||||||
{!unlocked && canUnlock && onUnlock && (
|
const primaryElem = def.primaryManaType ? ELEMENTS[def.primaryManaType] : null;
|
||||||
<div className="pt-2" style={{ borderTop: `1px solid ${color}15` }}>
|
|
||||||
<Button
|
// Get current mana for this attunement's type
|
||||||
size="sm"
|
const currentMana = def.primaryManaType ? store.elements[def.primaryManaType]?.current || 0 : 0;
|
||||||
variant="outline"
|
const maxMana = def.primaryManaType ? store.elements[def.primaryManaType]?.max || 50 : 50;
|
||||||
className="w-full text-xs"
|
|
||||||
style={{ borderColor: `${color}66`, color }}
|
// Calculate level-scaled stats
|
||||||
onClick={() => onUnlock(def.id)}
|
const levelMult = Math.pow(1.5, level - 1);
|
||||||
|
const scaledRegen = def.rawManaRegen * levelMult;
|
||||||
|
const scaledConversion = getAttunementConversionRate(id, level);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={id}
|
||||||
|
className={`bg-gray-900/80 transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'border-2 shadow-lg'
|
||||||
|
: isUnlocked
|
||||||
|
? 'border-gray-600'
|
||||||
|
: 'border-gray-800 opacity-70'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
borderColor: isActive ? def.color : undefined,
|
||||||
|
boxShadow: isActive ? `0 0 20px ${def.color}30` : undefined
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Unlock className="w-3 h-3 mr-1" /> Unlock {def.name}
|
<CardHeader className="pb-2">
|
||||||
</Button>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<span className="text-2xl">{def.icon}</span>
|
||||||
|
<div>
|
||||||
{/* Details grid */}
|
<CardTitle className="text-sm" style={{ color: isActive ? def.color : '#9CA3AF' }}>
|
||||||
<div
|
{def.name}
|
||||||
className="grid grid-cols-2 gap-2 text-xs pt-3"
|
</CardTitle>
|
||||||
style={{ borderTop: `1px solid ${unlocked ? `${color}22` : `${color}10`}` }}
|
<div className="text-xs text-gray-500">
|
||||||
>
|
{ATTUNEMENT_SLOT_NAMES[def.slot]}
|
||||||
<div>
|
</div>
|
||||||
<span className="text-gray-500">Mana Type</span>
|
</div>
|
||||||
<p className="text-gray-300 capitalize">
|
</div>
|
||||||
{def.primaryManaType ?? 'None (pact-based)'}
|
{!isUnlocked && (
|
||||||
</p>
|
<Lock className="w-4 h-4 text-gray-600" />
|
||||||
</div>
|
)}
|
||||||
<div>
|
{isActive && (
|
||||||
<span className="text-gray-500">Raw Regen</span>
|
<Badge className="text-xs" style={{ backgroundColor: `${def.color}30`, color: def.color }}>
|
||||||
<p className="text-gray-300">+{def.rawManaRegen}/hr</p>
|
Lv.{level}
|
||||||
</div>
|
</Badge>
|
||||||
{def.conversionRate > 0 && (
|
)}
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Conversion</span>
|
|
||||||
<p className="text-gray-300">{def.conversionRate}/hr</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Status</span>
|
|
||||||
<p style={{ color: state?.active ? '#4ade80' : unlocked ? `${color}aa` : '#6b7280' }}>
|
|
||||||
{state?.active ? '● Active' : unlocked ? '○ Inactive' : 'Locked'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/* Invoker special: pact-based note */}
|
|
||||||
{def.primaryManaType === undefined && (
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="text-gray-500">Special</span>
|
|
||||||
<p style={{ color: `${color}cc` }}>
|
|
||||||
Gains elemental mana from each guardian pact signed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Capabilities */}
|
|
||||||
<div style={{ borderTop: `1px solid ${unlocked ? `${color}22` : `${color}10`}` }} className="pt-3">
|
|
||||||
<span className="text-xs text-gray-500 block mb-1.5">Capabilities</span>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{def.capabilities.map((cap) => (
|
|
||||||
<Badge
|
|
||||||
key={cap}
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px]"
|
|
||||||
style={{
|
|
||||||
borderColor: `${color}44`,
|
|
||||||
color: unlocked ? `${color}cc` : `${color}66`,
|
|
||||||
backgroundColor: `${color}0a`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cap}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Skill Categories */}
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-gray-500 block mb-1.5">Skill Categories</span>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{def.skillCategories.map((cat) => (
|
|
||||||
<Badge
|
|
||||||
key={cat}
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px]"
|
|
||||||
style={{
|
|
||||||
borderColor: `${color}33`,
|
|
||||||
color: unlocked ? `${color}aa` : `${color}55`,
|
|
||||||
backgroundColor: `${color}08`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function AttunementsTab() {
|
|
||||||
const attunements = useAttunementStore((s) => s.attunements);
|
|
||||||
const unlockAttunement = useAttunementStore((s) => s.unlockAttunement);
|
|
||||||
const defeatedGuardians = usePrestigeStore((s) => s.defeatedGuardians);
|
|
||||||
|
|
||||||
const allDefs = Object.values(ATTUNEMENTS_DEF);
|
|
||||||
const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length;
|
|
||||||
|
|
||||||
const handleUnlock = (id: string) => {
|
|
||||||
const prestigeState = usePrestigeStore.getState();
|
|
||||||
const success = unlockAttunement(id, prestigeState.defeatedGuardians);
|
|
||||||
if (!success) {
|
|
||||||
console.warn(`Failed to unlock attunement: ${id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DebugName name="AttunementsTab">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Summary header */}
|
|
||||||
<Card className="bg-gray-900/60 border-gray-700">
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-100">Attunements</h2>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
Class-like abilities tied to body locations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-2xl font-bold" style={{ color: '#1ABC9C' }}>
|
|
||||||
{unlockedCount}
|
|
||||||
<span className="text-sm text-gray-500 font-normal">
|
|
||||||
/{allDefs.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Unlocked</p>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="space-y-3">
|
||||||
</div>
|
<p className="text-xs text-gray-400">{def.desc}</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Attunement cards */}
|
{/* Mana Type */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-1">
|
||||||
{allDefs.map((def) => (
|
<div className="flex items-center justify-between text-xs">
|
||||||
<AttunementCard
|
<span className="text-gray-500">Primary Mana</span>
|
||||||
key={def.id}
|
{primaryElem ? (
|
||||||
def={def}
|
<span style={{ color: primaryElem.color }}>
|
||||||
state={attunements[def.id]}
|
{primaryElem.sym} {primaryElem.name}
|
||||||
canUnlock={isUnlockConditionMet(def.id, defeatedGuardians)}
|
</span>
|
||||||
onUnlock={handleUnlock}
|
) : (
|
||||||
/>
|
<span className="text-purple-400">From Pacts</span>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mana bar (only for attunements with primary type) */}
|
||||||
|
{primaryElem && isActive && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Progress
|
||||||
|
value={(currentMana / maxMana) * 100}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>{currentMana.toFixed(1)}</span>
|
||||||
|
<span>/{maxMana}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats with level scaling */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="p-2 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-gray-500">Raw Regen</div>
|
||||||
|
<div className="text-green-400 font-semibold">
|
||||||
|
+{scaledRegen.toFixed(2)}/hr
|
||||||
|
{level > 1 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-gray-800/50 rounded">
|
||||||
|
<div className="text-gray-500">Conversion</div>
|
||||||
|
<div className="text-cyan-400 font-semibold">
|
||||||
|
{scaledConversion > 0 ? `${scaledConversion.toFixed(2)}/hr` : '—'}
|
||||||
|
{level > 1 && scaledConversion > 0 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* XP Progress Bar */}
|
||||||
|
{isUnlocked && state && !isMaxLevel && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500 flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3" />
|
||||||
|
XP Progress
|
||||||
|
</span>
|
||||||
|
<span className="text-amber-400">{xp} / {xpNeeded}</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={xpProgress}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{isMaxLevel ? 'Max Level' : `${xpNeeded - xp} XP to Level ${level + 1}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Max Level Indicator */}
|
||||||
|
{isMaxLevel && (
|
||||||
|
<div className="text-xs text-amber-400 text-center font-semibold">
|
||||||
|
✨ MAX LEVEL ✨
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Capabilities */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-gray-500">Capabilities</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{def.capabilities.map(cap => (
|
||||||
|
<Badge key={cap} variant="outline" className="text-xs">
|
||||||
|
{cap === 'enchanting' && '✨ Enchanting'}
|
||||||
|
{cap === 'disenchanting' && '🔄 Disenchant'}
|
||||||
|
{cap === 'pacts' && '🤝 Pacts'}
|
||||||
|
{cap === 'guardianPowers' && '💜 Guardian Powers'}
|
||||||
|
{cap === 'elementalMastery' && '🌟 Elem. Mastery'}
|
||||||
|
{cap === 'golemCrafting' && '🗿 Golems'}
|
||||||
|
{cap === 'gearCrafting' && '⚒️ Gear'}
|
||||||
|
{cap === 'earthShaping' && '⛰️ Earth Shaping'}
|
||||||
|
{!['enchanting', 'disenchanting', 'pacts', 'guardianPowers',
|
||||||
|
'elementalMastery', 'golemCrafting', 'gearCrafting', 'earthShaping'].includes(cap) && cap}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unlock condition for locked attunements */}
|
||||||
|
{!isUnlocked && def.unlockCondition && (
|
||||||
|
<div className="text-xs text-amber-400 italic">
|
||||||
|
🔒 {def.unlockCondition}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</DebugName>
|
|
||||||
|
{/* Available Skills Summary */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 text-sm">Available Skill Categories</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-xs text-gray-400 mb-2">
|
||||||
|
Your attunements grant access to specialized skill categories:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableCategories.map(cat => {
|
||||||
|
const attunement = Object.values(ATTUNEMENTS_DEF).find(a =>
|
||||||
|
a.skillCategories.includes(cat) && attunements[a.id]?.active
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={cat}
|
||||||
|
className={attunement ? '' : 'bg-gray-700/50 text-gray-400'}
|
||||||
|
style={attunement ? {
|
||||||
|
backgroundColor: `${attunement.color}30`,
|
||||||
|
color: attunement.color
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
{cat === 'mana' && '💧 Mana'}
|
||||||
|
{cat === 'study' && '📚 Study'}
|
||||||
|
{cat === 'research' && '🔮 Research'}
|
||||||
|
{cat === 'ascension' && '⭐ Ascension'}
|
||||||
|
{cat === 'enchant' && '✨ Enchanting'}
|
||||||
|
{cat === 'effectResearch' && '🔬 Effect Research'}
|
||||||
|
{cat === 'invocation' && '💜 Invocation'}
|
||||||
|
{cat === 'pact' && '🤝 Pact Mastery'}
|
||||||
|
{cat === 'fabrication' && '⚒️ Fabrication'}
|
||||||
|
{cat === 'golemancy' && '🗿 Golemancy'}
|
||||||
|
{!['mana', 'study', 'research', 'ascension', 'enchant', 'effectResearch',
|
||||||
|
'invocation', 'pact', 'fabrication', 'golemancy'].includes(cat) && cat}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AttunementsTab.displayName = 'AttunementsTab';
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user