初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

40
scripts/build-gate.mjs Normal file
View 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
View 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).`);

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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();

View 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
View 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');