Binary file not shown.
40
scripts/build-gate.mjs
Normal file
40
scripts/build-gate.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
|
||||
const args = [viteCliPath, 'build', ...process.argv.slice(2)];
|
||||
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
if ((result.status ?? 0) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const warningPattern = /\bwarn(?:ing)?\b/i;
|
||||
const ignoredWarningPatterns = [
|
||||
/ExperimentalWarning/u,
|
||||
];
|
||||
|
||||
const warningLines = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
|
||||
.split(/\r?\n/u)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.filter(line => warningPattern.test(line))
|
||||
.filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line)));
|
||||
|
||||
if (warningLines.length > 0) {
|
||||
console.error('Build gate failed because warnings were emitted:');
|
||||
[...new Set(warningLines)].forEach(line => console.error(`- ${line}`));
|
||||
process.exit(1);
|
||||
}
|
||||
166
scripts/check-encoding.mjs
Normal file
166
scripts/check-encoding.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.cjs',
|
||||
'.controller',
|
||||
'.css',
|
||||
'.env',
|
||||
'.html',
|
||||
'.js',
|
||||
'.json',
|
||||
'.jsx',
|
||||
'.md',
|
||||
'.meta',
|
||||
'.mjs',
|
||||
'.ps1',
|
||||
'.py',
|
||||
'.scss',
|
||||
'.sh',
|
||||
'.toml',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.txt',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
]);
|
||||
|
||||
const TEXT_FILENAMES = new Set([
|
||||
'.editorconfig',
|
||||
'.gitattributes',
|
||||
'.gitignore',
|
||||
'.prettierignore',
|
||||
'.prettierrc',
|
||||
'.prettierrc.json',
|
||||
'AGENTS.md',
|
||||
'README.md',
|
||||
]);
|
||||
|
||||
const EXCLUDED_PREFIXES = [
|
||||
'.codex-logs/',
|
||||
'.git/',
|
||||
'dist/',
|
||||
'media/',
|
||||
'node_modules/',
|
||||
'public/Icons/',
|
||||
];
|
||||
|
||||
const IGNORE_FILE = '.encoding-check-ignore';
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function shouldCheck(filePath) {
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
|
||||
if (EXCLUDED_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileName = basename(normalizedPath);
|
||||
const extension = extname(fileName).toLowerCase();
|
||||
|
||||
if (TEXT_FILENAMES.has(fileName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fileName.startsWith('.env')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return TEXT_EXTENSIONS.has(extension);
|
||||
}
|
||||
|
||||
function listFilesFromGit() {
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }
|
||||
);
|
||||
|
||||
return output
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
.map(normalizePath)
|
||||
.filter(shouldCheck);
|
||||
}
|
||||
|
||||
function loadIgnoreList() {
|
||||
if (!existsSync(IGNORE_FILE)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
readFileSync(IGNORE_FILE, 'utf8')
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== '' && !line.startsWith('#'))
|
||||
.map(normalizePath)
|
||||
);
|
||||
}
|
||||
|
||||
function hasNullByte(buffer) {
|
||||
for (const byte of buffer) {
|
||||
if (byte === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateUtf8(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = readFileSync(filePath);
|
||||
|
||||
if (hasNullByte(bytes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let text;
|
||||
|
||||
try {
|
||||
text = decoder.decode(bytes);
|
||||
} catch {
|
||||
return `${filePath} is not valid UTF-8.`;
|
||||
}
|
||||
|
||||
if (text.includes('\uFFFD')) {
|
||||
return `${filePath} contains Unicode replacement characters (U+FFFD), which usually means text was already decoded incorrectly before being saved.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitFiles = process.argv.slice(2).map(normalizePath);
|
||||
const ignoreList = loadIgnoreList();
|
||||
const filesToCheck = (explicitFiles.length ? explicitFiles : listFilesFromGit())
|
||||
.filter(shouldCheck)
|
||||
.filter((filePath) => !ignoreList.has(filePath));
|
||||
const failures = [];
|
||||
|
||||
for (const filePath of filesToCheck) {
|
||||
const failure = validateUtf8(filePath);
|
||||
|
||||
if (failure) {
|
||||
failures.push(failure);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('Encoding check failed:');
|
||||
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Encoding check passed for ${filesToCheck.length} file(s).`);
|
||||
1471
scripts/dev-server/localApiPlugins.ts
Normal file
1471
scripts/dev-server/localApiPlugins.ts
Normal file
File diff suppressed because it is too large
Load Diff
357
scripts/generate-build-tag-similarity.py
Normal file
357
scripts/generate-build-tag-similarity.py
Normal file
@@ -0,0 +1,357 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from vikingdb import VikingDB, IAM, EmbeddingClient
|
||||
from vikingdb.vector import EmbeddingData, EmbeddingModelOpt, EmbeddingRequest
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"Missing dependency: vikingdb-python-sdk.\n"
|
||||
"Install it with: py -3 -m pip install vikingdb-python-sdk"
|
||||
) from exc
|
||||
|
||||
|
||||
def zh(value: str) -> str:
|
||||
return value.encode("utf-8").decode("unicode_escape")
|
||||
|
||||
|
||||
BUILD_TAGS = [
|
||||
{
|
||||
"label": zh(r"\u5feb\u5251"),
|
||||
"aliases": ["duelist", "swift blade", "swiftblade", zh(r"\u5251\u5feb"), zh(r"\u5feb\u5203")],
|
||||
"description": zh(r"\u4ee5\u9ad8\u901f\u8f7b\u5175\u5668\u3001\u8fde\u7eed\u51fa\u624b\u548c\u8d34\u8eab\u538b\u8feb\u4e3a\u6838\u5fc3\u7684\u8fd1\u6218\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u8fde\u6bb5"),
|
||||
"aliases": ["combo", "chain", zh(r"\u8fde\u51fb")],
|
||||
"description": zh(r"\u4f9d\u8d56\u8fde\u7eed\u547d\u4e2d\u4e0e\u591a\u6bb5\u8282\u594f\u538b\u5236\u7684\u8f93\u51fa\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7a81\u8fdb"),
|
||||
"aliases": ["dash", "lunge", "mobility engage"],
|
||||
"description": zh(r"\u5f3a\u8c03\u5feb\u901f\u8d34\u8fd1\u76ee\u6807\u3001\u62a2\u5360\u8eab\u4f4d\u548c\u5148\u624b\u5207\u5165\u7684\u6218\u6597\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u8ffd\u51fb"),
|
||||
"aliases": ["chase", "follow-up", "finisher chase"],
|
||||
"description": zh(r"\u64c5\u957f\u5728\u5bf9\u624b\u5931\u8861\u6216\u88ab\u51fb\u9000\u540e\u7ee7\u7eed\u8ffd\u6253\u7684\u6218\u6597\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5feb\u88ad"),
|
||||
"aliases": ["assassin", "rogue", "ambush", zh(r"\u523a\u51fb")],
|
||||
"description": zh(r"\u5f3a\u8c03\u77ed\u65f6\u5207\u5165\u3001\u70b9\u6740\u5f31\u70b9\u548c\u8fc5\u901f\u8131\u79bb\u7684\u523a\u51fb\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u8fdc\u5c04"),
|
||||
"aliases": ["projectile", "ranged", "arrow", zh(r"\u5c04\u51fb")],
|
||||
"description": zh(r"\u4ee5\u6295\u5c04\u7269\u3001\u4e2d\u8fdc\u8ddd\u79bb\u7275\u5236\u548c\u5b89\u5168\u8f93\u51fa\u4e3a\u6838\u5fc3\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u6e38\u51fb"),
|
||||
"aliases": ["scout", "skirmish", "harass", "fieldcraft"],
|
||||
"description": zh(r"\u5f3a\u8c03\u8fb9\u79fb\u52a8\u8fb9\u8f93\u51fa\u3001\u8bd5\u63a2\u62c9\u626f\u548c\u62e9\u673a\u518d\u5165\u573a\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u673a\u52a8"),
|
||||
"aliases": ["mobility", "nimble", "agile"],
|
||||
"description": zh(r"\u4ee3\u8868\u9ad8\u4f4d\u79fb\u3001\u9ad8\u8eab\u6cd5\u548c\u5feb\u901f\u6362\u4f4d\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u98ce\u884c"),
|
||||
"aliases": ["wind", "gust", "speed", zh(r"\u75be\u884c")],
|
||||
"description": zh(r"\u5f3a\u8c03\u8f7b\u7075\u6b65\u6cd5\u3001\u79fb\u901f\u4f18\u52bf\u548c\u8fc5\u901f\u8c03\u4f4d\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u91cd\u51fb"),
|
||||
"aliases": ["heavy", "slam", "mighty", "crush"],
|
||||
"description": zh(r"\u5f3a\u8c03\u539a\u91cd\u6253\u51fb\u3001\u5355\u6b21\u9ad8\u538b\u8f93\u51fa\u548c\u6b63\u9762\u7838\u7a7f\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7206\u53d1"),
|
||||
"aliases": ["burst", "nova", "sudden damage"],
|
||||
"description": zh(r"\u4ee3\u8868\u77ed\u7a97\u53e3\u5185\u8fc5\u901f\u62ac\u9ad8\u4f24\u5bb3\u5cf0\u503c\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7834\u7532"),
|
||||
"aliases": ["breaker", "armor break", "shatter"],
|
||||
"description": zh(r"\u64c5\u957f\u6495\u5f00\u9632\u5fa1\u3001\u6253\u65ad\u5b88\u52bf\u548c\u9488\u5bf9\u786c\u76ee\u6807\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u538b\u5236"),
|
||||
"aliases": ["tempo", "pressure", "control offense"],
|
||||
"description": zh(r"\u901a\u8fc7\u6301\u7eed\u4e3b\u52a8\u8fdb\u653b\u4e0e\u8282\u594f\u5360\u4f18\u8feb\u4f7f\u5bf9\u624b\u5931\u8bef\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u538b\u8840"),
|
||||
"aliases": ["low hp", "berserk", "risk damage"],
|
||||
"description": zh(r"\u4ee5\u5192\u9669\u538b\u4f4e\u8840\u7ebf\u6362\u53d6\u66f4\u5f3a\u653b\u51fb\u6027\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5b88\u5fa1"),
|
||||
"aliases": ["ward", "guard", "protector", "defense"],
|
||||
"description": zh(r"\u5f3a\u8c03\u51cf\u4f24\u3001\u7a33\u5b88\u548c\u9876\u4f4f\u6b63\u9762\u4f24\u5bb3\u7684\u9632\u5fa1\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u62a4\u4f53"),
|
||||
"aliases": ["barrier", "shielding", "spirit guard", "spirit"],
|
||||
"description": zh(r"\u504f\u5411\u62a4\u7f69\u3001\u62a4\u8eab\u6c14\u52b2\u548c\u72b6\u6001\u6297\u538b\u7684\u9632\u5fa1\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u91cd\u7532"),
|
||||
"aliases": ["tank", "heavy armor", "iron wall"],
|
||||
"description": zh(r"\u4ee3\u8868\u9ad8\u786c\u5ea6\u62a4\u7532\u3001\u6b63\u9762\u627f\u4f24\u4e0e\u7a33\u5b9a\u7ad9\u573a\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u53cd\u51fb"),
|
||||
"aliases": ["counter", "riposte", "retaliate"],
|
||||
"description": zh(r"\u901a\u8fc7\u683c\u6321\u3001\u7ad9\u6869\u4e0e\u540e\u624b\u60e9\u7f5a\u5f62\u6210\u6536\u76ca\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u9547\u90aa"),
|
||||
"aliases": ["banish", "holy ward", "warding seal"],
|
||||
"description": zh(r"\u64c5\u957f\u538b\u5236\u90aa\u795f\u3001\u5492\u715e\u548c\u5f02\u7c7b\u80fd\u91cf\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u6cd5\u4fee"),
|
||||
"aliases": ["caster", "mage", "arcane", "spell"],
|
||||
"description": zh(r"\u4ee5\u6cd5\u672f\u9a71\u52a8\u8f93\u51fa\u3001\u63a7\u5236\u548c\u8d44\u6e90\u8fd0\u8f6c\u7684\u6838\u5fc3\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u6cd5\u529b"),
|
||||
"aliases": ["mana", "magic", "essence", "spirit power"],
|
||||
"description": zh(r"\u56f4\u7ed5\u6cd5\u529b\u4e0a\u9650\u3001\u6cd5\u672f\u6d88\u8017\u4e0e\u6cd5\u80fd\u5faa\u73af\u6784\u7b51\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u96f7\u6cd5"),
|
||||
"aliases": ["lightning", "thunder", "storm"],
|
||||
"description": zh(r"\u4ee3\u8868\u9ad8\u538b\u96f7\u7cfb\u672f\u6cd5\u3001\u77ac\u65f6\u9707\u8361\u548c\u9ebb\u75f9\u538b\u5236\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7b26\u9635"),
|
||||
"aliases": ["sigil", "formation", "seal", "rune"],
|
||||
"description": zh(r"\u901a\u8fc7\u7b26\u7bb4\u3001\u6cd5\u9635\u548c\u9884\u5e03\u7f6e\u6548\u679c\u6539\u53d8\u6218\u573a\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u63a7\u573a"),
|
||||
"aliases": ["control", "crowd control", "lockdown"],
|
||||
"description": zh(r"\u4ee5\u9650\u5236\u884c\u52a8\u3001\u5c01\u9501\u7a7a\u95f4\u548c\u538b\u7f29\u9009\u62e9\u4e3a\u6838\u5fc3\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u8fc7\u8f7d"),
|
||||
"aliases": ["overload", "surge", "power spike"],
|
||||
"description": zh(r"\u5728\u77ed\u65f6\u95f4\u5185\u63a8\u52a8\u9ad8\u6cd5\u8017\u4e0e\u9ad8\u5f3a\u5ea6\u91ca\u653e\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u56de\u590d"),
|
||||
"aliases": ["heal", "healing", "recovery", "restore"],
|
||||
"description": zh(r"\u5f3a\u8c03\u5373\u65f6\u6062\u590d\u4e0e\u6218\u540e\u7eed\u63a5\u80fd\u529b\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u62a4\u6301"),
|
||||
"aliases": ["support", "aid", "blessing"],
|
||||
"description": zh(r"\u901a\u8fc7\u589e\u76ca\u3001\u62ac\u7a33\u6001\u548c\u4fdd\u62a4\u961f\u53cb\u6765\u5efa\u7acb\u4f18\u52bf\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7eed\u6218"),
|
||||
"aliases": ["sustain", "endurance", "long fight"],
|
||||
"description": zh(r"\u9762\u5411\u957f\u7ebf\u6218\u6597\u3001\u8d44\u6e90\u6301\u7eed\u4e0e\u5bb9\u9519\u63d0\u5347\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u547d\u7eb9"),
|
||||
"aliases": ["fate", "omen", "destiny"],
|
||||
"description": zh(r"\u56f4\u7ed5\u547d\u8fd0\u3001\u5370\u8bb0\u4e0e\u89e6\u53d1\u5f0f\u8fde\u9501\u6536\u76ca\u6784\u7b51\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u673a\u7f18"),
|
||||
"aliases": ["fortune", "luck", "opportunity"],
|
||||
"description": zh(r"\u4f9d\u8d56\u65f6\u673a\u3001\u8fd0\u52bf\u548c\u989d\u5916\u6536\u76ca\u89e6\u53d1\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u51b7\u5374"),
|
||||
"aliases": ["cooldown", "cdr", "recharge"],
|
||||
"description": zh(r"\u901a\u8fc7\u66f4\u5feb\u5468\u8f6c\u6280\u80fd\u4e0e\u9053\u5177\u6765\u6eda\u52a8\u4f18\u52bf\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7edf\u5fa1"),
|
||||
"aliases": ["commander", "command", "leader"],
|
||||
"description": zh(r"\u5f3a\u8c03\u6574\u4f53\u534f\u8c03\u3001\u56e2\u961f\u6536\u76ca\u548c\u7efc\u5408\u8c03\u5ea6\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5747\u8861"),
|
||||
"aliases": ["balanced", "adaptable", "all-round"],
|
||||
"description": zh(r"\u6ca1\u6709\u660e\u663e\u77ed\u677f\uff0c\u504f\u91cd\u4e2d\u540e\u671f\u7a33\u5b9a\u6210\u578b\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5de5\u5de7"),
|
||||
"aliases": ["craft", "artisan", "utility", "socket"],
|
||||
"description": zh(r"\u504f\u5411\u5de5\u827a\u3001\u5668\u68b0\u3001\u9576\u5d4c\u548c\u8f85\u52a9\u6784\u7b51\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u70bc\u836f"),
|
||||
"aliases": ["alchemy", "potion", "tonic"],
|
||||
"description": zh(r"\u56f4\u7ed5\u836f\u5242\u3001\u4e34\u65f6\u5f3a\u5316\u548c\u6218\u4e2d\u8865\u7ed9\u7684\u5de5\u827a\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5148\u950b"),
|
||||
"aliases": ["vanguard", "frontline"],
|
||||
"description": zh(r"\u4ee3\u8868\u961f\u4f0d\u4e2d\u7684\u6b63\u9762\u5f00\u8def\u3001\u5403\u7ebf\u4e0e\u538b\u524d\u6392\u804c\u8d23\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u72c2\u6218"),
|
||||
"aliases": ["berserker", "rage"],
|
||||
"description": zh(r"\u4ee5\u8840\u91cf\u4ea4\u6362\u3001\u731b\u653b\u548c\u9ad8\u98ce\u9669\u9ad8\u56de\u62a5\u4e3a\u7279\u8272\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u6cd5\u5251"),
|
||||
"aliases": ["spellblade", "bladecaster"],
|
||||
"description": zh(r"\u878d\u5408\u5175\u5203\u4e0e\u672f\u6cd5\uff0c\u64c5\u957f\u4e2d\u8ddd\u79bb\u538b\u8feb\u7684\u6df7\u5408\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5723\u4f51"),
|
||||
"aliases": ["paladin", "holy guard"],
|
||||
"description": zh(r"\u517c\u5177\u9632\u62a4\u3001\u56de\u590d\u548c\u60e9\u6212\u80fd\u529b\u7684\u795d\u798f\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5821\u5792"),
|
||||
"aliases": ["fortress", "bulwark"],
|
||||
"description": zh(r"\u4ee5\u7a33\u5b9a\u7ad9\u573a\u3001\u786c\u6297\u4e0e\u53cd\u6253\u4e3a\u6838\u5fc3\u7684\u91cd\u9632\u5fa1\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u8d77\u624b"),
|
||||
"aliases": ["starter", "legacy"],
|
||||
"description": zh(r"\u504f\u8fc7\u6e21\u4e0e\u8d77\u6b65\u7528\u9014\u7684\u65e9\u671f\u6784\u7b51\u6807\u7b7e\u3002"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_prompt(definition: dict) -> str:
|
||||
aliases = "\u3001".join(definition["aliases"])
|
||||
return f"{definition['label']}:{definition['description']} 别名:{aliases}。"
|
||||
|
||||
|
||||
def load_env_file(path: Path, protected_keys: set[str]) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key or key in protected_keys:
|
||||
continue
|
||||
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
value = value[1:-1]
|
||||
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def load_local_env() -> None:
|
||||
root_dir = Path(__file__).resolve().parents[1]
|
||||
protected_keys = set(os.environ)
|
||||
|
||||
load_env_file(root_dir / ".env", protected_keys)
|
||||
load_env_file(root_dir / ".env.local", protected_keys)
|
||||
|
||||
|
||||
def create_embedding_client() -> EmbeddingClient:
|
||||
access_key = os.getenv("VOLCENGINE_ACCESS_KEY_ID") or os.getenv("VIKINGDB_ACCESS_KEY_ID")
|
||||
secret_key = os.getenv("VOLCENGINE_SECRET_ACCESS_KEY") or os.getenv("VIKINGDB_SECRET_ACCESS_KEY")
|
||||
host = os.getenv("VIKINGDB_HOST", "api-vikingdb.vikingdb.cn-beijing.volces.com")
|
||||
region = os.getenv("VIKINGDB_REGION", "cn-beijing")
|
||||
|
||||
if not access_key or not secret_key:
|
||||
raise SystemExit(
|
||||
"Missing VikingDB credentials.\n"
|
||||
"Required:\n"
|
||||
" VOLCENGINE_ACCESS_KEY_ID\n"
|
||||
" VOLCENGINE_SECRET_ACCESS_KEY\n"
|
||||
"Optional:\n"
|
||||
" VIKINGDB_HOST (default: api-vikingdb.vikingdb.cn-beijing.volces.com)\n"
|
||||
" VIKINGDB_REGION (default: cn-beijing)\n"
|
||||
)
|
||||
|
||||
service = VikingDB(
|
||||
host=host,
|
||||
region=region,
|
||||
auth=IAM(ak=access_key, sk=secret_key),
|
||||
)
|
||||
return EmbeddingClient(service)
|
||||
|
||||
|
||||
def encode_texts(client: EmbeddingClient, texts: list[str]) -> np.ndarray:
|
||||
request = EmbeddingRequest(
|
||||
data=[EmbeddingData(text=text) for text in texts],
|
||||
dense_model=EmbeddingModelOpt(name="bge-large-zh"),
|
||||
)
|
||||
response = client.embedding(request)
|
||||
result = getattr(response, "result", None)
|
||||
data = getattr(result, "data", None) if result is not None else None
|
||||
if data is None and isinstance(result, dict):
|
||||
data = result.get("data")
|
||||
if data is None:
|
||||
data = getattr(response, "data", None)
|
||||
|
||||
if data is None:
|
||||
raise ValueError("Embedding response did not include any data entries.")
|
||||
|
||||
embeddings: list[list[float]] = []
|
||||
for item in data:
|
||||
dense = getattr(item, "dense", None)
|
||||
if dense is None and isinstance(item, dict):
|
||||
dense = item.get("dense")
|
||||
if dense is None:
|
||||
dense = getattr(item, "embedding", None)
|
||||
if dense is None and isinstance(item, dict):
|
||||
dense = item.get("embedding")
|
||||
if dense is None:
|
||||
raise ValueError("Embedding response item did not include a dense vector.")
|
||||
embeddings.append(dense)
|
||||
|
||||
matrix = np.array(embeddings, dtype=np.float32)
|
||||
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
|
||||
norms[norms == 0] = 1.0
|
||||
return matrix / norms
|
||||
|
||||
|
||||
def main():
|
||||
load_local_env()
|
||||
client = create_embedding_client()
|
||||
prompts = [build_prompt(definition) for definition in BUILD_TAGS]
|
||||
embeddings = encode_texts(client, prompts)
|
||||
|
||||
threshold = 0.35
|
||||
pairs: list[tuple[str, str, float]] = []
|
||||
for index, left in enumerate(BUILD_TAGS):
|
||||
for other_index in range(index + 1, len(BUILD_TAGS)):
|
||||
right = BUILD_TAGS[other_index]
|
||||
similarity = float(np.dot(embeddings[index], embeddings[other_index]))
|
||||
if similarity < threshold:
|
||||
continue
|
||||
pairs.append((left["label"], right["label"], round(similarity, 4)))
|
||||
|
||||
output_path = Path(__file__).resolve().parents[1] / "src" / "data" / "buildTagSimilarity.generated.ts"
|
||||
lines = [
|
||||
"export const BUILD_TAG_SIMILARITY_PAIRS: Array<readonly [string, string, number]> = ["
|
||||
]
|
||||
for left, right, similarity in pairs:
|
||||
lines.append(f" ['{left}', '{right}', {similarity}],")
|
||||
lines.append("] as const;")
|
||||
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
print(json.dumps({
|
||||
"output": str(output_path),
|
||||
"pair_count": len(pairs),
|
||||
"model": "bge-large-zh",
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
15
scripts/run-tsx.cjs
Normal file
15
scripts/run-tsx.cjs
Normal file
@@ -0,0 +1,15 @@
|
||||
const path = require('node:path');
|
||||
|
||||
const {require: tsxRequire} = require('tsx/cjs/api');
|
||||
|
||||
const [, , entry, ...restArgs] = process.argv;
|
||||
|
||||
if (!entry) {
|
||||
console.error('Usage: node scripts/run-tsx.cjs <entry.ts> [...args]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const resolvedEntry = path.resolve(process.cwd(), entry);
|
||||
process.argv = [process.argv[0], resolvedEntry, ...restArgs];
|
||||
|
||||
tsxRequire(resolvedEntry, path.join(process.cwd(), '__tsx_runner__.cjs'));
|
||||
412
scripts/smoke-content.ts
Normal file
412
scripts/smoke-content.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { buildCompanionState, PRESET_CHARACTERS, resolveEncounterRecruitCharacter } from '../src/data/characterPresets.ts';
|
||||
import { activateRosterCompanion, benchActiveCompanion, recruitCompanionToParty } from '../src/data/companionRoster.ts';
|
||||
import { getInventoryItemValue, getNpcPurchasePrice } from '../src/data/economy.ts';
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
buildEncounterTransitionState,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../src/data/encounterTransition.ts';
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
buildInitialEquipmentLoadout,
|
||||
createEmptyEquipmentLoadout,
|
||||
getEquipmentBonuses,
|
||||
} from '../src/data/equipmentEffects.ts';
|
||||
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../src/data/inventoryEffects.ts';
|
||||
import { createSceneMonstersFromIds } from '../src/data/monsters.ts';
|
||||
import { buildInitialNpcState, buildInitialPlayerInventory, buildNpcEncounterStoryMoment, checkTradeItem, createNpcBattleMonster } from '../src/data/npcInteractions.ts';
|
||||
import {
|
||||
acceptQuest,
|
||||
applyQuestProgressFromHostileNpcDefeat,
|
||||
applyQuestProgressFromNpcTalk,
|
||||
buildQuestForEncounter,
|
||||
findQuestById,
|
||||
markQuestTurnedIn,
|
||||
} from '../src/data/questFlow.ts';
|
||||
import { createInitialGameRuntimeStats } from '../src/data/runtimeStats.ts';
|
||||
import { createSceneCallOutEncounter, createSceneEncounterPreview, ensureSceneEncounterPreview } from '../src/data/sceneEncounterPreviews.ts';
|
||||
import { buildSceneObserveSignsStoryText } from '../src/data/sceneObservation.ts';
|
||||
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
||||
import { buildTreasureEncounterStoryMoment, buildTreasureResultText, resolveTreasureReward } from '../src/data/treasureInteractions.ts';
|
||||
import { AnimationState, GameState, WorldType } from '../src/types.ts';
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function createBaseState(worldType: WorldType, sceneId?: string): GameState {
|
||||
const playerCharacter = PRESET_CHARACTERS[0];
|
||||
const currentScenePreset = sceneId
|
||||
? getScenePresetsByWorld(worldType).find(scene => scene.id === sceneId) ?? null
|
||||
: getScenePresetsByWorld(worldType)[0] ?? null;
|
||||
|
||||
return {
|
||||
worldType,
|
||||
customWorldProfile: null,
|
||||
playerCharacter,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
ambientIdleMode: undefined,
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 180,
|
||||
playerMaxHp: 180,
|
||||
playerMana: 100,
|
||||
playerMaxMana: 100,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 180,
|
||||
playerInventory: [],
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function smokeScenePreviews() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const scene = getScenePresetsByWorld(worldType)[0];
|
||||
assert(scene, `[preview] missing first scene for ${worldType}`);
|
||||
|
||||
const preview = createSceneEncounterPreview(createBaseState(worldType, scene.id));
|
||||
assert(preview.currentEncounter?.kind !== 'treasure', `[preview] treasure encounter should be disabled for ${worldType}`);
|
||||
assert(preview.currentEncounter || preview.sceneMonsters.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} produced no preview entity`);
|
||||
|
||||
const ensured = ensureSceneEncounterPreview(createBaseState(worldType, scene.id));
|
||||
assert(ensured.currentEncounter || ensured.sceneMonsters.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} failed ensureSceneEncounterPreview`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeNpcStories() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.length > 0);
|
||||
assert(sceneWithNpc, `[npc] missing npc scene for ${worldType}`);
|
||||
|
||||
const encounter = {
|
||||
id: sceneWithNpc.npcs[0].id,
|
||||
kind: 'npc' as const,
|
||||
characterId: sceneWithNpc.npcs[0].characterId,
|
||||
npcName: sceneWithNpc.npcs[0].name,
|
||||
npcDescription: sceneWithNpc.npcs[0].description,
|
||||
npcAvatar: sceneWithNpc.npcs[0].avatar,
|
||||
context: sceneWithNpc.npcs[0].role,
|
||||
xMeters: 3.2,
|
||||
};
|
||||
const playerCharacter = PRESET_CHARACTERS[0];
|
||||
const npcState = buildInitialNpcState(encounter, worldType);
|
||||
const story = buildNpcEncounterStoryMoment({
|
||||
encounter,
|
||||
npcState,
|
||||
playerCharacter,
|
||||
playerInventory: [],
|
||||
activeQuests: [],
|
||||
scene: sceneWithNpc,
|
||||
worldType,
|
||||
partySize: 0,
|
||||
});
|
||||
|
||||
assert(story.options.length >= 3, `[npc] ${sceneWithNpc.id} npc story returned too few options`);
|
||||
const battleMonster = createNpcBattleMonster(encounter, npcState, 'spar');
|
||||
assert(battleMonster.hp >= 7 && battleMonster.hp <= 12, `[npc] spar hp for ${encounter.npcName} out of expected range`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeTreasureStories() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithTreasure = getScenePresetsByWorld(worldType).find(scene => scene.treasureHints.length > 0);
|
||||
assert(sceneWithTreasure, `[treasure] missing treasure scene for ${worldType}`);
|
||||
const state = createBaseState(worldType, sceneWithTreasure.id);
|
||||
|
||||
const encounter = {
|
||||
id: `treasure-${sceneWithTreasure.id}`,
|
||||
kind: 'treasure' as const,
|
||||
npcName: '前方宝藏',
|
||||
npcDescription: `你在前方发现了${sceneWithTreasure.treasureHints[0]}的痕迹。`,
|
||||
npcAvatar: '/Icons/47_treasure.png',
|
||||
context: '宝藏',
|
||||
xMeters: 3.2,
|
||||
};
|
||||
|
||||
const story = buildTreasureEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
});
|
||||
|
||||
assert(story.options.length === 3, `[treasure] ${sceneWithTreasure.id} treasure story should provide exactly 3 options`);
|
||||
const inspectReward = resolveTreasureReward(state, encounter, 'inspect');
|
||||
assert(inspectReward.items.length >= 2, `[treasure] ${sceneWithTreasure.id} inspect reward should contain at least 2 items`);
|
||||
assert(buildTreasureResultText(encounter, 'inspect', inspectReward).includes('收'), `[treasure] ${sceneWithTreasure.id} inspect result text should describe loot`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeMonsterCreation() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => scene.monsterIds.length > 0);
|
||||
assert(sceneWithMonster, `[monster] missing monster scene for ${worldType}`);
|
||||
const monsters = createSceneMonstersFromIds(worldType, sceneWithMonster.monsterIds, 0);
|
||||
assert(monsters.length > 0, `[monster] ${sceneWithMonster.id} failed to create scene monsters`);
|
||||
assert(
|
||||
monsters.length === Math.min(sceneWithMonster.monsterIds.length, 3),
|
||||
`[monster] ${sceneWithMonster.id} should keep the full configured encounter group`,
|
||||
);
|
||||
|
||||
const resolvedState = createBaseState(worldType, sceneWithMonster.id);
|
||||
resolvedState.sceneMonsters = monsters;
|
||||
resolvedState.inBattle = true;
|
||||
assert(
|
||||
resolvedState.sceneMonsters.length === monsters.length,
|
||||
`[monster] ${sceneWithMonster.id} multi-enemy battle state lost monsters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeRecruitmentData() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithCharacterNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.some(npc => npc.characterId));
|
||||
assert(sceneWithCharacterNpc, `[recruit] missing recruitable character npc scene for ${worldType}`);
|
||||
const recruitableNpc = sceneWithCharacterNpc.npcs.find(npc => npc.characterId)!;
|
||||
const recruitCharacter = resolveEncounterRecruitCharacter({
|
||||
characterId: recruitableNpc.characterId,
|
||||
context: recruitableNpc.role,
|
||||
npcName: recruitableNpc.name,
|
||||
});
|
||||
assert(recruitCharacter, `[recruit] failed to resolve recruit character for ${recruitableNpc.id}`);
|
||||
const companionState = buildCompanionState(recruitableNpc.id, recruitCharacter, 60);
|
||||
assert(companionState.hp > 0 && companionState.maxHp >= companionState.hp, `[recruit] invalid hp for ${recruitableNpc.id}`);
|
||||
assert(Object.keys(companionState.skillCooldowns).length === recruitCharacter.skills.length, `[recruit] cooldown map mismatch for ${recruitableNpc.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeObserveAndCallOut() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const scene = getScenePresetsByWorld(worldType)[0];
|
||||
assert(scene, `[idle] missing first scene for ${worldType}`);
|
||||
const baseState = createBaseState(worldType, scene.id);
|
||||
const callOutResult = createSceneCallOutEncounter(baseState);
|
||||
assert(callOutResult.currentEncounter?.kind !== 'treasure', `[idle] treasure call_out should be disabled for ${worldType}`);
|
||||
assert(callOutResult.currentEncounter || callOutResult.sceneMonsters.length > 0 || scene.monsterIds.length === 0, `[idle] call_out failed for ${scene.id}`);
|
||||
|
||||
const observeText = buildSceneObserveSignsStoryText(worldType, scene.id);
|
||||
assert(observeText.length > 12, `[idle] observe_signs text too short for ${scene.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeInventoryUseLoop() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const playerCharacter = PRESET_CHARACTERS[0];
|
||||
const inventory = buildInitialPlayerInventory(playerCharacter, worldType);
|
||||
const usableItem = inventory.find(item => isInventoryItemUsable(item));
|
||||
assert(usableItem, `[inventory] missing usable starter item for ${worldType}`);
|
||||
|
||||
const effect = resolveInventoryItemUseEffect(usableItem, playerCharacter);
|
||||
assert(effect, `[inventory] failed to resolve use effect for ${usableItem.name}`);
|
||||
assert(
|
||||
effect.hpRestore > 0 || effect.manaRestore > 0 || effect.cooldownReduction > 0,
|
||||
`[inventory] ${usableItem.name} should provide at least one useful effect`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeEquipmentLoop() {
|
||||
const playerCharacter = PRESET_CHARACTERS[0];
|
||||
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
|
||||
const starterBonuses = getEquipmentBonuses(starterLoadout);
|
||||
|
||||
assert(starterBonuses.maxHpBonus > 0, '[equipment] starter loadout should provide HP bonus');
|
||||
assert(starterBonuses.outgoingDamageMultiplier > 1, '[equipment] starter loadout should provide damage bonus');
|
||||
|
||||
const baseState = createBaseState(WorldType.WUXIA);
|
||||
const equippedState = applyEquipmentLoadoutToState(baseState, starterLoadout);
|
||||
assert(equippedState.playerMaxHp > baseState.playerMaxHp, '[equipment] applying loadout should increase max HP');
|
||||
assert(equippedState.playerMaxMana > baseState.playerMaxMana, '[equipment] applying loadout should increase max mana');
|
||||
}
|
||||
|
||||
function smokeTradeEconomyLoop() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.length > 0);
|
||||
assert(sceneWithNpc, `[trade] missing npc scene for ${worldType}`);
|
||||
|
||||
const encounter = {
|
||||
id: sceneWithNpc.npcs[0].id,
|
||||
kind: 'npc' as const,
|
||||
characterId: sceneWithNpc.npcs[0].characterId,
|
||||
npcName: sceneWithNpc.npcs[0].name,
|
||||
npcDescription: sceneWithNpc.npcs[0].description,
|
||||
npcAvatar: sceneWithNpc.npcs[0].avatar,
|
||||
context: sceneWithNpc.npcs[0].role,
|
||||
xMeters: 3.2,
|
||||
};
|
||||
const npcState = buildInitialNpcState(encounter, worldType);
|
||||
const npcItem = npcState.inventory[0];
|
||||
const playerItem = buildInitialPlayerInventory(PRESET_CHARACTERS[0], worldType)[0];
|
||||
assert(npcItem, `[trade] missing npc item for ${worldType}`);
|
||||
assert(playerItem, `[trade] missing player item for ${worldType}`);
|
||||
|
||||
const npcItemValue = getInventoryItemValue(npcItem);
|
||||
const playerItemValue = getInventoryItemValue(playerItem);
|
||||
assert(npcItemValue > 0 && playerItemValue > 0, `[trade] item values should be positive for ${worldType}`);
|
||||
|
||||
const purchasePrice = getNpcPurchasePrice(npcItem, npcState.affinity);
|
||||
assert(purchasePrice > 0, `[trade] purchase price should be positive for ${worldType}`);
|
||||
|
||||
const purchaseCheck = checkTradeItem(null, npcItem, npcState.affinity, purchasePrice);
|
||||
assert(purchaseCheck.canPurchase, `[trade] direct purchase should succeed when currency matches price for ${worldType}`);
|
||||
|
||||
const barterCheck = checkTradeItem(playerItem, npcItem, npcState.affinity, 0);
|
||||
assert(typeof barterCheck.canBarter === 'boolean', `[trade] barter check should return a boolean for ${worldType}`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeEncounterTransitionLoop() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => scene.monsterIds.length >= 2);
|
||||
assert(sceneWithMonster, `[transition] missing multi-monster scene for ${worldType}`);
|
||||
|
||||
const finalMonsters = createSceneMonstersFromIds(worldType, sceneWithMonster.monsterIds, 0);
|
||||
const finalState = {
|
||||
...createBaseState(worldType, sceneWithMonster.id),
|
||||
inBattle: true,
|
||||
sceneMonsters: finalMonsters,
|
||||
};
|
||||
const previewState = {
|
||||
...finalState,
|
||||
inBattle: false,
|
||||
sceneMonsters: finalMonsters.map((monster, index) => ({
|
||||
...monster,
|
||||
xMeters: 12 + (index * 1.8),
|
||||
})),
|
||||
};
|
||||
|
||||
const transitionState = buildEncounterTransitionState(finalState, previewState);
|
||||
assert(
|
||||
transitionState.sceneMonsters[1]?.xMeters === previewState.sceneMonsters[1]?.xMeters,
|
||||
`[transition] second monster should keep its preview x during transition for ${worldType}`,
|
||||
);
|
||||
|
||||
const halfwayState = interpolateEncounterTransitionState(transitionState, finalState, 0.5);
|
||||
assert(
|
||||
halfwayState.sceneMonsters.every((monster, index) => {
|
||||
const startX = transitionState.sceneMonsters[index]?.xMeters ?? monster.xMeters;
|
||||
const endX = finalState.sceneMonsters[index]?.xMeters ?? monster.xMeters;
|
||||
return monster.xMeters !== startX && monster.xMeters !== endX;
|
||||
}),
|
||||
`[transition] all monsters should interpolate instead of only the first one for ${worldType}`,
|
||||
);
|
||||
|
||||
const offscreenState = buildEncounterEntryState(finalState, 18);
|
||||
assert(
|
||||
offscreenState.sceneMonsters.every(monster => monster.xMeters >= 18),
|
||||
`[transition] offscreen entry should place the entire encounter group offscreen for ${worldType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeRosterLoop() {
|
||||
const playerCharacter = PRESET_CHARACTERS[0];
|
||||
const reserveCharacter = PRESET_CHARACTERS[1];
|
||||
const recruitCharacter = PRESET_CHARACTERS[2];
|
||||
const activeCompanion = buildCompanionState('active-npc', playerCharacter, 68);
|
||||
const reserveCompanion = buildCompanionState('reserve-npc', reserveCharacter, 62);
|
||||
const recruitedCompanion = buildCompanionState('new-npc', recruitCharacter, 72);
|
||||
|
||||
const baseState = {
|
||||
...createBaseState(WorldType.WUXIA),
|
||||
companions: [activeCompanion],
|
||||
roster: [reserveCompanion],
|
||||
};
|
||||
|
||||
const benchedState = benchActiveCompanion(baseState, activeCompanion.npcId);
|
||||
assert(benchedState.companions.length === 0, '[roster] active companion should move off active team');
|
||||
assert(benchedState.roster.some(companion => companion.npcId === activeCompanion.npcId), '[roster] benched companion should enter reserve roster');
|
||||
|
||||
const activatedState = activateRosterCompanion(baseState, reserveCompanion.npcId);
|
||||
assert(activatedState.companions.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] reserve companion should be activatable');
|
||||
assert(!activatedState.roster.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] activated companion should leave reserve roster');
|
||||
|
||||
const swappedState = recruitCompanionToParty(
|
||||
{
|
||||
...baseState,
|
||||
companions: [activeCompanion, reserveCompanion],
|
||||
roster: [],
|
||||
},
|
||||
recruitedCompanion,
|
||||
reserveCompanion.npcId,
|
||||
);
|
||||
assert(swappedState.companions.some(companion => companion.npcId === recruitedCompanion.npcId), '[roster] recruited companion should join active party');
|
||||
assert(swappedState.roster.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] replaced companion should move to reserve roster');
|
||||
}
|
||||
|
||||
function smokeQuestLoop() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneWithNpcAndMonster = getScenePresetsByWorld(worldType).find(
|
||||
scene => scene.npcs.length > 0 && scene.monsterIds.length > 0,
|
||||
);
|
||||
assert(sceneWithNpcAndMonster, `[quest] missing npc+monster scene for ${worldType}`);
|
||||
|
||||
const issuer = sceneWithNpcAndMonster.npcs[0];
|
||||
const quest = buildQuestForEncounter({
|
||||
issuerNpcId: issuer.id,
|
||||
issuerNpcName: issuer.name,
|
||||
roleText: issuer.role,
|
||||
scene: sceneWithNpcAndMonster,
|
||||
worldType,
|
||||
});
|
||||
|
||||
assert(quest, `[quest] failed to build quest for ${sceneWithNpcAndMonster.id}`);
|
||||
const accepted = acceptQuest([], quest);
|
||||
assert(findQuestById(accepted, quest.id)?.status === 'active', `[quest] ${quest.id} should be active after accept`);
|
||||
|
||||
const afterBattle = applyQuestProgressFromHostileNpcDefeat(
|
||||
accepted,
|
||||
sceneWithNpcAndMonster.id,
|
||||
quest.objective.targetHostileNpcId ? [quest.objective.targetHostileNpcId] : [],
|
||||
);
|
||||
assert(findQuestById(afterBattle, quest.id)?.status === 'active', `[quest] ${quest.id} should stay active until report back`);
|
||||
|
||||
const afterReport = applyQuestProgressFromNpcTalk(afterBattle, issuer.id);
|
||||
assert(findQuestById(afterReport, quest.id)?.status === 'ready_to_turn_in', `[quest] ${quest.id} should become reward-ready after reporting back`);
|
||||
|
||||
const turnedIn = markQuestTurnedIn(afterReport, quest.id);
|
||||
assert(findQuestById(turnedIn, quest.id)?.status === 'turned_in', `[quest] ${quest.id} should turn in successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
smokeScenePreviews();
|
||||
smokeNpcStories();
|
||||
smokeTreasureStories();
|
||||
smokeMonsterCreation();
|
||||
smokeRecruitmentData();
|
||||
smokeObserveAndCallOut();
|
||||
smokeInventoryUseLoop();
|
||||
smokeEquipmentLoop();
|
||||
smokeTradeEconomyLoop();
|
||||
smokeEncounterTransitionLoop();
|
||||
smokeRosterLoop();
|
||||
smokeQuestLoop();
|
||||
console.log('Content smoke checks passed.');
|
||||
}
|
||||
|
||||
main();
|
||||
116
scripts/validate-content.ts
Normal file
116
scripts/validate-content.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { getCharacterHomeSceneId, getCharacterNpcSceneIds, PRESET_CHARACTERS } from '../src/data/characterPresets.ts';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
|
||||
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
||||
import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts';
|
||||
import { WorldType } from '../src/types.ts';
|
||||
|
||||
function addError(errors: string[], message: string) {
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
function validateScenes(errors: string[]) {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const scenes = getScenePresetsByWorld(worldType);
|
||||
const sceneIdSet = new Set(scenes.map(scene => scene.id));
|
||||
const monsterIdSet = new Set(MONSTER_PRESETS_BY_WORLD[worldType].map(monster => monster.id));
|
||||
const duplicateSceneIds = scenes
|
||||
.map(scene => scene.id)
|
||||
.filter((id, index, all) => all.indexOf(id) !== index);
|
||||
|
||||
duplicateSceneIds.forEach(sceneId => {
|
||||
addError(errors, `[scene] duplicate id "${sceneId}" in ${worldType}`);
|
||||
});
|
||||
|
||||
scenes.forEach(scene => {
|
||||
if (scene.forwardSceneId && !sceneIdSet.has(scene.forwardSceneId)) {
|
||||
addError(errors, `[scene] ${scene.id} forwardSceneId "${scene.forwardSceneId}" not found in ${worldType}`);
|
||||
}
|
||||
|
||||
scene.connectedSceneIds.forEach(connectedSceneId => {
|
||||
if (!sceneIdSet.has(connectedSceneId)) {
|
||||
addError(errors, `[scene] ${scene.id} connectedSceneId "${connectedSceneId}" not found in ${worldType}`);
|
||||
}
|
||||
});
|
||||
|
||||
scene.monsterIds.forEach(monsterId => {
|
||||
if (!monsterIdSet.has(monsterId)) {
|
||||
addError(errors, `[scene] ${scene.id} references unknown monster "${monsterId}" in ${worldType}`);
|
||||
}
|
||||
});
|
||||
|
||||
const npcIds = new Set<string>();
|
||||
scene.npcs.forEach(npc => {
|
||||
if (npcIds.has(npc.id)) {
|
||||
addError(errors, `[scene] ${scene.id} has duplicate npc id "${npc.id}"`);
|
||||
}
|
||||
npcIds.add(npc.id);
|
||||
|
||||
if (npc.characterId && !PRESET_CHARACTERS.some(character => character.id === npc.characterId)) {
|
||||
addError(errors, `[scene] ${scene.id} npc "${npc.id}" references unknown character "${npc.characterId}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateCharacters(errors: string[]) {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const sceneIdSet = new Set(getScenePresetsByWorld(worldType).map(scene => scene.id));
|
||||
|
||||
PRESET_CHARACTERS.forEach(character => {
|
||||
const homeSceneId = getCharacterHomeSceneId(worldType, character.id);
|
||||
if (homeSceneId && !sceneIdSet.has(homeSceneId)) {
|
||||
addError(errors, `[character] ${character.id} homeSceneId "${homeSceneId}" not found in ${worldType}`);
|
||||
}
|
||||
|
||||
getCharacterNpcSceneIds(worldType, character.id).forEach(sceneId => {
|
||||
if (!sceneIdSet.has(sceneId)) {
|
||||
addError(errors, `[character] ${character.id} npc scene "${sceneId}" not found in ${worldType}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateStateFunctions(errors: string[]) {
|
||||
const definitions = buildStateFunctionDefinitions();
|
||||
const duplicateIds = definitions
|
||||
.map(definition => definition.id)
|
||||
.filter((id, index, all) => all.indexOf(id) !== index);
|
||||
|
||||
duplicateIds.forEach(id => {
|
||||
addError(errors, `[function] duplicate function id "${id}"`);
|
||||
});
|
||||
|
||||
definitions.forEach(definition => {
|
||||
if (!definition.text.trim()) {
|
||||
addError(errors, `[function] ${definition.id} has empty text`);
|
||||
}
|
||||
if (!definition.description.trim()) {
|
||||
addError(errors, `[function] ${definition.id} has empty description`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const errors: string[] = [];
|
||||
|
||||
validateScenes(errors);
|
||||
validateCharacters(errors);
|
||||
validateStateFunctions(errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Content validation failed with ${errors.length} issue(s):`);
|
||||
errors.forEach(error => console.error(`- ${error}`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneCount = getScenePresetsByWorld(WorldType.WUXIA).length + getScenePresetsByWorld(WorldType.XIANXIA).length;
|
||||
const monsterCount = MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA].length + MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA].length;
|
||||
const functionCount = buildStateFunctionDefinitions().length;
|
||||
|
||||
console.log(`Content validation passed. scenes=${sceneCount} monsters=${monsterCount} characters=${PRESET_CHARACTERS.length} functions=${functionCount}`);
|
||||
}
|
||||
|
||||
main();
|
||||
288
scripts/validate-overrides.ts
Normal file
288
scripts/validate-overrides.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { PRESET_CHARACTERS } from '../src/data/characterPresets.ts';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
|
||||
import { buildItemCatalogId } from '../src/data/itemCatalog.ts';
|
||||
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
||||
import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts';
|
||||
import { WorldType } from '../src/types.ts';
|
||||
|
||||
function readJsonFile<T>(relativePath: string): T {
|
||||
const absolutePath = path.resolve(process.cwd(), relativePath);
|
||||
return JSON.parse(readFileSync(absolutePath, 'utf8')) as T;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isKnownGender(value: unknown): value is 'male' | 'female' {
|
||||
return value === 'male' || value === 'female';
|
||||
}
|
||||
|
||||
function expectPlainObject(errors: string[], label: string, value: unknown) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`[override] ${label} must be an object map`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateCharacterOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/characterOverrides.json');
|
||||
if (!expectPlainObject(errors, 'characterOverrides', overrides)) return;
|
||||
|
||||
const characterIds = new Set(PRESET_CHARACTERS.map(character => character.id));
|
||||
const sceneIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([characterId, override]) => {
|
||||
if (!characterIds.has(characterId)) {
|
||||
errors.push(`[override] characterOverrides contains unknown character id "${characterId}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const gender = override.gender;
|
||||
if (gender !== undefined && !isKnownGender(gender)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"].gender must be "male" or "female"`);
|
||||
}
|
||||
|
||||
const sceneBindings = override.sceneBindings;
|
||||
if (sceneBindings !== undefined) {
|
||||
if (!isPlainObject(sceneBindings)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"].sceneBindings must be an object`);
|
||||
} else {
|
||||
Object.entries(sceneBindings).forEach(([worldKey, binding]) => {
|
||||
if (!isPlainObject(binding)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"].sceneBindings["${worldKey}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const homeSceneId = binding.homeSceneId;
|
||||
if (homeSceneId !== undefined && (typeof homeSceneId !== 'string' || !sceneIds.has(homeSceneId))) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"] has invalid homeSceneId "${String(homeSceneId)}"`);
|
||||
}
|
||||
|
||||
const npcSceneIds = binding.npcSceneIds;
|
||||
if (npcSceneIds !== undefined) {
|
||||
if (!Array.isArray(npcSceneIds) || npcSceneIds.some(sceneId => typeof sceneId !== 'string' || !sceneIds.has(sceneId))) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"] has invalid npcSceneIds`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateMonsterOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/monsterOverrides.json');
|
||||
if (!expectPlainObject(errors, 'monsterOverrides', overrides)) return;
|
||||
|
||||
const monsterIds = new Set(
|
||||
[...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA], ...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA]].map(monster => monster.id),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([monsterId, override]) => {
|
||||
if (!monsterIds.has(monsterId)) {
|
||||
errors.push(`[override] monsterOverrides contains unknown monster id "${monsterId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] monsterOverrides["${monsterId}"] must be an object`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateSceneOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/sceneOverrides.json');
|
||||
if (!expectPlainObject(errors, 'sceneOverrides', overrides)) return;
|
||||
|
||||
const sceneIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)),
|
||||
);
|
||||
const monsterIds = new Set(
|
||||
[...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA], ...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA]].map(monster => monster.id),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([sceneId, override]) => {
|
||||
if (!sceneIds.has(sceneId)) {
|
||||
errors.push(`[override] sceneOverrides contains unknown scene id "${sceneId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] sceneOverrides["${sceneId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const forwardSceneId = override.forwardSceneId;
|
||||
if (forwardSceneId !== undefined && (typeof forwardSceneId !== 'string' || !sceneIds.has(forwardSceneId))) {
|
||||
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid forwardSceneId "${String(forwardSceneId)}"`);
|
||||
}
|
||||
|
||||
const connectedSceneIds = override.connectedSceneIds;
|
||||
if (connectedSceneIds !== undefined) {
|
||||
if (!Array.isArray(connectedSceneIds) || connectedSceneIds.some(id => typeof id !== 'string' || !sceneIds.has(id))) {
|
||||
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid connectedSceneIds`);
|
||||
}
|
||||
}
|
||||
|
||||
const overrideMonsterIds = override.monsterIds;
|
||||
if (overrideMonsterIds !== undefined) {
|
||||
if (!Array.isArray(overrideMonsterIds) || overrideMonsterIds.some(id => typeof id !== 'string' || !monsterIds.has(id))) {
|
||||
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid monsterIds`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateSceneNpcOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/sceneNpcOverrides.json');
|
||||
if (!expectPlainObject(errors, 'sceneNpcOverrides', overrides)) return;
|
||||
|
||||
const npcIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType =>
|
||||
getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)),
|
||||
),
|
||||
);
|
||||
const characterIds = new Set(PRESET_CHARACTERS.map(character => character.id));
|
||||
|
||||
Object.entries(overrides).forEach(([npcId, override]) => {
|
||||
if (!npcIds.has(npcId)) {
|
||||
errors.push(`[override] sceneNpcOverrides contains unknown npc id "${npcId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] sceneNpcOverrides["${npcId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const gender = override.gender;
|
||||
if (gender !== undefined && !isKnownGender(gender)) {
|
||||
errors.push(`[override] sceneNpcOverrides["${npcId}"].gender must be "male" or "female"`);
|
||||
}
|
||||
|
||||
const characterId = override.characterId;
|
||||
if (characterId !== undefined && (typeof characterId !== 'string' || !characterIds.has(characterId))) {
|
||||
errors.push(`[override] sceneNpcOverrides["${npcId}"] has invalid characterId "${String(characterId)}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateStateFunctionOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/stateFunctionOverrides.json');
|
||||
if (!expectPlainObject(errors, 'stateFunctionOverrides', overrides)) return;
|
||||
|
||||
const functionIds = new Set(buildStateFunctionDefinitions().map(definition => definition.id));
|
||||
Object.entries(overrides).forEach(([functionId, override]) => {
|
||||
if (!functionIds.has(functionId)) {
|
||||
errors.push(`[override] stateFunctionOverrides contains unknown function id "${functionId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] stateFunctionOverrides["${functionId}"] must be an object`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateNpcVisualOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/npcVisualOverrides.json');
|
||||
if (!expectPlainObject(errors, 'npcVisualOverrides', overrides)) return;
|
||||
|
||||
const npcIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType =>
|
||||
getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)),
|
||||
),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([npcId, override]) => {
|
||||
if (!npcIds.has(npcId)) {
|
||||
errors.push(`[override] npcVisualOverrides contains unknown npc id "${npcId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] npcVisualOverrides["${npcId}"] must be an object`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collectItemAssetPaths(rootDir: string, relativeDir = 'Icons'): string[] {
|
||||
const entries = readdirSync(rootDir, { withFileTypes: true });
|
||||
const collected: string[] = [];
|
||||
|
||||
entries.forEach(entry => {
|
||||
const absolutePath = path.join(rootDir, entry.name);
|
||||
const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/');
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
collected.push(...collectItemAssetPaths(absolutePath, relativePath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) {
|
||||
collected.push(relativePath);
|
||||
}
|
||||
});
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function validateItemOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/itemOverrides.json');
|
||||
if (!expectPlainObject(errors, 'itemOverrides', overrides)) return;
|
||||
|
||||
const validItemIds = new Set(
|
||||
collectItemAssetPaths(path.resolve(process.cwd(), 'public/Icons'))
|
||||
.map(assetPath => buildItemCatalogId(assetPath)),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([itemId, override]) => {
|
||||
if (!validItemIds.has(itemId)) {
|
||||
errors.push(`[override] itemOverrides contains unknown item id "${itemId}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] itemOverrides["${itemId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = override.tags;
|
||||
if (tags !== undefined) {
|
||||
if (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||
errors.push(`[override] itemOverrides["${itemId}"] has invalid tags`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const errors: string[] = [];
|
||||
|
||||
validateCharacterOverrides(errors);
|
||||
validateMonsterOverrides(errors);
|
||||
validateSceneOverrides(errors);
|
||||
validateSceneNpcOverrides(errors);
|
||||
validateStateFunctionOverrides(errors);
|
||||
validateNpcVisualOverrides(errors);
|
||||
validateItemOverrides(errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Override validation failed with ${errors.length} issue(s):`);
|
||||
errors.forEach(error => console.error(`- ${error}`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Override validation passed.');
|
||||
}
|
||||
|
||||
main();
|
||||
16
scripts/vite-cli.mjs
Normal file
16
scripts/vite-cli.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
if (crypto.webcrypto) {
|
||||
if (typeof crypto.getRandomValues !== 'function') {
|
||||
crypto.getRandomValues = crypto.webcrypto.getRandomValues.bind(crypto.webcrypto);
|
||||
}
|
||||
|
||||
if (!globalThis.crypto || typeof globalThis.crypto.getRandomValues !== 'function') {
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
value: crypto.webcrypto,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await import('../node_modules/vite/bin/vite.js');
|
||||
Reference in New Issue
Block a user