清理过期测试与门禁
删除退役内容校验、视觉小说墓碑脚本和旧玩法门禁文档 移除死别名与退役分支触发并同步当前文档入口 为Gitea和Jenkins补齐可信SpacetimeDB schema比较基线 补充workflow、schema和生产运维防回归测试
This commit is contained in:
@@ -163,10 +163,10 @@ function assertRootNativeShellCheckScripts() {
|
||||
}
|
||||
if (
|
||||
rootPackageJson.scripts?.check !==
|
||||
'npm run lint && npm run test && npm run build && npm run check:content && npm run check:native-shells'
|
||||
'npm run lint && npm run test && npm run build && npm run check:native-shells'
|
||||
) {
|
||||
throw new Error(
|
||||
'root check script must include check:native-shells after build and content checks',
|
||||
'root check script must include check:native-shells after build',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ const checks = [
|
||||
includes: 'npm run check:server-rs-ddd',
|
||||
reason: 'API 生产构建必须执行 server-rs DDD/schema/runtime-access 门禁。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-api-build',
|
||||
includes: 'git fetch --no-tags --depth=2',
|
||||
reason: 'API 生产构建的浅克隆必须有界取得待构建提交的父提交。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-api-build',
|
||||
includes:
|
||||
"env.SPACETIME_SCHEMA_BASE_REF = readFile('.jenkins-spacetime-schema-base').trim()",
|
||||
reason: 'API 生产构建必须向 schema guard 显式传递可信父提交基线。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-api-build',
|
||||
includes:
|
||||
@@ -41,6 +52,17 @@ const checks = [
|
||||
includes: 'npm run check:server-rs-ddd',
|
||||
reason: 'Stdb module 生产构建必须执行 DDD/schema/runtime-access 门禁。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'git fetch --no-tags --depth=2',
|
||||
reason: 'Stdb module 生产构建的浅克隆必须有界取得待构建提交的父提交。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes:
|
||||
"env.SPACETIME_SCHEMA_BASE_REF = readFile('.jenkins-spacetime-schema-base').trim()",
|
||||
reason: 'Stdb module 生产构建必须向 schema guard 显式传递可信父提交基线。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'npm run check:admin-account-procedures',
|
||||
|
||||
@@ -22,5 +22,4 @@ echo "[repository-ci] base=${base_ref} head=$(git rev-parse "${head_ref}")"
|
||||
SPACETIME_SCHEMA_BASE_REF="${base_ref}" npm run lint
|
||||
npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts
|
||||
npm run build
|
||||
npm run check:content
|
||||
git diff --check "${base_ref}"..."${head_ref}"
|
||||
|
||||
@@ -8,7 +8,8 @@ const repoRoot = join(scriptDir, '..');
|
||||
const moduleSrcRoot = 'server-rs/crates/spacetime-module/src';
|
||||
const moduleManifestPath = 'server-rs/crates/spacetime-module/Cargo.toml';
|
||||
const migrationPath = `${moduleSrcRoot}/migration.rs`;
|
||||
const tableCatalogPath = 'docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md';
|
||||
const tableCatalogPath =
|
||||
'docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md';
|
||||
const bindingsRoot = 'server-rs/crates/spacetime-client/src/module_bindings/';
|
||||
const allowBreaking = process.env.SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING === '1';
|
||||
function normalizePath(path) {
|
||||
@@ -19,7 +20,9 @@ function runGit(args, options = {}) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: options.quiet ? ['ignore', 'pipe', 'ignore'] : ['ignore', 'pipe', 'pipe'],
|
||||
stdio: options.quiet
|
||||
? ['ignore', 'pipe', 'ignore']
|
||||
: ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
}).trim();
|
||||
}
|
||||
@@ -32,31 +35,77 @@ function tryGit(args) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBaseRef() {
|
||||
const explicitArgIndex = process.argv.indexOf('--base-ref');
|
||||
if (explicitArgIndex >= 0 && process.argv[explicitArgIndex + 1]) {
|
||||
return process.argv[explicitArgIndex + 1];
|
||||
export function isFormalGateEnvironment(env = process.env) {
|
||||
const enabled = (value) =>
|
||||
typeof value === 'string' &&
|
||||
value.trim() !== '' &&
|
||||
!['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase());
|
||||
|
||||
return [
|
||||
env.CI,
|
||||
env.JENKINS_URL,
|
||||
env.JENKINS_HOME,
|
||||
env.GITEA_ACTIONS,
|
||||
env.GITHUB_ACTIONS,
|
||||
env.BUILDKITE,
|
||||
env.TEAMCITY_VERSION,
|
||||
].some(enabled);
|
||||
}
|
||||
|
||||
export function resolveBaseRef({
|
||||
argv = process.argv,
|
||||
env = process.env,
|
||||
git = tryGit,
|
||||
} = {}) {
|
||||
const explicitArgIndex = argv.indexOf('--base-ref');
|
||||
if (explicitArgIndex >= 0 && argv[explicitArgIndex + 1]) {
|
||||
return argv[explicitArgIndex + 1];
|
||||
}
|
||||
|
||||
if (process.env.SPACETIME_SCHEMA_BASE_REF) {
|
||||
return process.env.SPACETIME_SCHEMA_BASE_REF;
|
||||
if (env.SPACETIME_SCHEMA_BASE_REF) {
|
||||
return env.SPACETIME_SCHEMA_BASE_REF;
|
||||
}
|
||||
|
||||
const mergeBase = tryGit(['merge-base', 'HEAD', 'origin/master']);
|
||||
if (mergeBase) {
|
||||
return mergeBase;
|
||||
const headCommit = git(['rev-parse', '--verify', 'HEAD^{commit}']);
|
||||
const candidates = [
|
||||
git(['merge-base', 'HEAD', 'origin/master']),
|
||||
git(['rev-parse', '--verify', 'origin/master^{commit}']),
|
||||
git(['rev-parse', '--verify', 'HEAD^']),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && candidate !== headCommit) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const originMaster = tryGit(['rev-parse', '--verify', 'origin/master']);
|
||||
if (originMaster) {
|
||||
return originMaster;
|
||||
if (isFormalGateEnvironment(env)) {
|
||||
throw new Error(
|
||||
'正式测试/构建环境无法取得与 HEAD 不同的 SpacetimeDB schema 比较基线。请获取待构建提交的父提交并设置 SPACETIME_SCHEMA_BASE_REF。',
|
||||
);
|
||||
}
|
||||
|
||||
return 'HEAD';
|
||||
}
|
||||
|
||||
function validateBaseRef(baseRef) {
|
||||
const baseCommit = tryGit(['rev-parse', '--verify', `${baseRef}^{commit}`]);
|
||||
if (!baseCommit) {
|
||||
throw new Error(
|
||||
`SpacetimeDB schema 比较基线 ${baseRef} 不是可读取的 commit。`,
|
||||
);
|
||||
}
|
||||
|
||||
const headCommit = tryGit(['rev-parse', '--verify', 'HEAD^{commit}']);
|
||||
if (isFormalGateEnvironment() && headCommit && baseCommit === headCommit) {
|
||||
throw new Error(
|
||||
`正式测试/构建环境的 SpacetimeDB schema 比较基线 ${baseRef} 与 HEAD 相同,拒绝无效自比。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCrateRootFromManifest(manifest) {
|
||||
const libSection = /\[lib\]\s*\n([\s\S]*?)(?=\n\[|$)/u.exec(manifest)?.[1] ?? '';
|
||||
const libSection =
|
||||
/\[lib\]\s*\n([\s\S]*?)(?=\n\[|$)/u.exec(manifest)?.[1] ?? '';
|
||||
const configuredPath = /^\s*path\s*=\s*"([^"]+)"/mu.exec(libSection)?.[1];
|
||||
return normalizePath(
|
||||
configuredPath
|
||||
@@ -83,7 +132,8 @@ export function listReachableRustFiles(readSource) {
|
||||
const crateRoot = resolveCrateRootFromManifest(manifest);
|
||||
const pending = [{ path: crateRoot, isCrateRoot: true }];
|
||||
const visited = new Set();
|
||||
const externalModulePattern = /((?:[ \t]*#\[[^\]\r\n]*\][ \t]*\r?\n)*)[ \t]*(?:pub(?:\([^)]*\))?[ \t]+)?mod[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*;/gmu;
|
||||
const externalModulePattern =
|
||||
/((?:[ \t]*#\[[^\]\r\n]*\][ \t]*\r?\n)*)[ \t]*(?:pub(?:\([^)]*\))?[ \t]+)?mod[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*;/gmu;
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
@@ -97,7 +147,10 @@ export function listReachableRustFiles(readSource) {
|
||||
}
|
||||
|
||||
visited.add(current.path);
|
||||
const defaultModuleDir = childModuleDirectory(current.path, current.isCrateRoot);
|
||||
const defaultModuleDir = childModuleDirectory(
|
||||
current.path,
|
||||
current.isCrateRoot,
|
||||
);
|
||||
let match;
|
||||
|
||||
externalModulePattern.lastIndex = 0;
|
||||
@@ -108,7 +161,9 @@ export function listReachableRustFiles(readSource) {
|
||||
}
|
||||
|
||||
const moduleName = match[2];
|
||||
const configuredPath = /#\[\s*path\s*=\s*"([^"]+)"\s*\]/u.exec(attributes)?.[1];
|
||||
const configuredPath = /#\[\s*path\s*=\s*"([^"]+)"\s*\]/u.exec(
|
||||
attributes,
|
||||
)?.[1];
|
||||
const candidates = configuredPath
|
||||
? [join(dirname(current.path), configuredPath)]
|
||||
: [
|
||||
@@ -426,10 +481,12 @@ function parseField(segment, fileText, bodyStartIndex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attrs = [...withoutLineComments.matchAll(/#\[[\s\S]*?\]/gu)].map((match) =>
|
||||
normalizeRustText(match[0]),
|
||||
const attrs = [...withoutLineComments.matchAll(/#\[[\s\S]*?\]/gu)].map(
|
||||
(match) => normalizeRustText(match[0]),
|
||||
);
|
||||
const fieldText = withoutLineComments.replace(/#\[[\s\S]*?\]\s*/gu, '').trim();
|
||||
const fieldText = withoutLineComments
|
||||
.replace(/#\[[\s\S]*?\]\s*/gu, '')
|
||||
.trim();
|
||||
const fieldMatch =
|
||||
/^(?:pub(?:\([^)]*\))?\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/u.exec(
|
||||
fieldText,
|
||||
@@ -467,9 +524,8 @@ function parseTablesFromFile(path, text) {
|
||||
}
|
||||
|
||||
const attrText = text.slice(attrStart, attrEnd);
|
||||
const accessorMatch = /accessor\s*=\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/u.exec(
|
||||
attrText,
|
||||
);
|
||||
const accessorMatch =
|
||||
/accessor\s*=\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/u.exec(attrText);
|
||||
const accessor = accessorMatch?.[1] ?? accessorMatch?.[2];
|
||||
if (!accessor) {
|
||||
continue;
|
||||
@@ -545,9 +601,21 @@ function loadBaseSources(baseRef) {
|
||||
function getChangedFiles(baseRef) {
|
||||
const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? '';
|
||||
const untrackedModuleOutput =
|
||||
tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? '';
|
||||
tryGit([
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
'-z',
|
||||
moduleSrcRoot,
|
||||
]) ?? '';
|
||||
const untrackedBindingsOutput =
|
||||
tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? '';
|
||||
tryGit([
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
'-z',
|
||||
bindingsRoot,
|
||||
]) ?? '';
|
||||
return new Set(
|
||||
[
|
||||
...diffOutput.split(String.fromCharCode(0)),
|
||||
@@ -587,7 +655,9 @@ function compareTables(baseTables, currentTables) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentFieldNames = new Set(currentTable.fields.map((field) => field.name));
|
||||
const currentFieldNames = new Set(
|
||||
currentTable.fields.map((field) => field.name),
|
||||
);
|
||||
if (currentTable.fields.length < baseTable.fields.length) {
|
||||
schemaChanged = true;
|
||||
breakingChanged = true;
|
||||
@@ -613,7 +683,9 @@ function compareTables(baseTables, currentTables) {
|
||||
|
||||
if (baseField.name !== currentField.name) {
|
||||
const baseFieldStillExists = currentFieldNames.has(baseField.name);
|
||||
const reason = baseFieldStillExists ? '字段顺序被调整' : '字段被删除或改名';
|
||||
const reason = baseFieldStillExists
|
||||
? '字段顺序被调整'
|
||||
: '字段被删除或改名';
|
||||
failures.push(
|
||||
`${currentTable.path}:${currentField.line}: SpacetimeDB 表 ${accessor} 的第 ${
|
||||
index + 1
|
||||
@@ -671,7 +743,9 @@ function checkSchemaSidecars(changedFiles, schemaChanged) {
|
||||
);
|
||||
}
|
||||
|
||||
const bindingsChanged = [...changedFiles].some((path) => path.startsWith(bindingsRoot));
|
||||
const bindingsChanged = [...changedFiles].some((path) =>
|
||||
path.startsWith(bindingsRoot),
|
||||
);
|
||||
if (!bindingsChanged) {
|
||||
failures.push(
|
||||
`SpacetimeDB schema 已变化,但 ${bindingsRoot} 下没有生成绑定变更。请重新生成并提交绑定。`,
|
||||
@@ -682,16 +756,30 @@ function checkSchemaSidecars(changedFiles, schemaChanged) {
|
||||
}
|
||||
|
||||
function main() {
|
||||
const baseRef = resolveBaseRef();
|
||||
let baseRef;
|
||||
try {
|
||||
baseRef = resolveBaseRef();
|
||||
validateBaseRef(baseRef);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[check:spacetime-schema] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const currentSources = loadCurrentSources();
|
||||
const baseSources = loadBaseSources(baseRef);
|
||||
const currentResult = collectTablesFromSources(currentSources);
|
||||
const baseResult = collectTablesFromSources(baseSources);
|
||||
const compareResult = compareTables(baseResult.tables, currentResult.tables);
|
||||
const changedFiles = getChangedFiles(baseRef);
|
||||
const sidecarFailures = checkSchemaSidecars(changedFiles, compareResult.schemaChanged);
|
||||
const sidecarFailures = checkSchemaSidecars(
|
||||
changedFiles,
|
||||
compareResult.schemaChanged,
|
||||
);
|
||||
const compareFailures =
|
||||
compareResult.breakingChanged && allowBreaking ? [] : compareResult.failures;
|
||||
compareResult.breakingChanged && allowBreaking
|
||||
? []
|
||||
: compareResult.failures;
|
||||
const failures = [
|
||||
...currentResult.failures,
|
||||
...baseResult.failures,
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectTablesFromSources,
|
||||
isFormalGateEnvironment,
|
||||
listReachableRustFiles,
|
||||
resolveBaseRef,
|
||||
} from './check-spacetime-schema-guard.mjs';
|
||||
|
||||
const manifestPath = 'server-rs/crates/spacetime-module/Cargo.toml';
|
||||
@@ -30,6 +32,67 @@ function tableSource(structName: string, accessor: string) {
|
||||
return `#[spacetimedb::table(accessor = ${accessor})]\npub struct ${structName} {\n pub id: u64,\n}\n`;
|
||||
}
|
||||
|
||||
function createGitResolver(entries: Record<string, string | null>) {
|
||||
return (args: string[]) => entries[args.join(' ')] ?? null;
|
||||
}
|
||||
|
||||
describe('SpacetimeDB schema guard base resolution', () => {
|
||||
it('prefers an explicit Jenkins-provided base ref', () => {
|
||||
expect(
|
||||
resolveBaseRef({
|
||||
argv: ['node', 'check-spacetime-schema-guard.mjs'],
|
||||
env: {
|
||||
CI: 'true',
|
||||
SPACETIME_SCHEMA_BASE_REF: 'parent-commit',
|
||||
},
|
||||
git: createGitResolver({}),
|
||||
}),
|
||||
).toBe('parent-commit');
|
||||
});
|
||||
|
||||
it('uses HEAD parent when the remote master ref resolves to HEAD', () => {
|
||||
expect(
|
||||
resolveBaseRef({
|
||||
argv: ['node', 'check-spacetime-schema-guard.mjs'],
|
||||
env: { CI: 'true' },
|
||||
git: createGitResolver({
|
||||
'rev-parse --verify HEAD^{commit}': 'current-commit',
|
||||
'merge-base HEAD origin/master': 'current-commit',
|
||||
'rev-parse --verify origin/master^{commit}': 'current-commit',
|
||||
'rev-parse --verify HEAD^': 'parent-commit',
|
||||
}),
|
||||
}),
|
||||
).toBe('parent-commit');
|
||||
});
|
||||
|
||||
it('fails closed in formal gates when no distinct commit is available', () => {
|
||||
expect(() =>
|
||||
resolveBaseRef({
|
||||
argv: ['node', 'check-spacetime-schema-guard.mjs'],
|
||||
env: { JENKINS_URL: 'https://jenkins.example.test/' },
|
||||
git: createGitResolver({
|
||||
'rev-parse --verify HEAD^{commit}': 'current-commit',
|
||||
'merge-base HEAD origin/master': 'current-commit',
|
||||
'rev-parse --verify origin/master^{commit}': 'current-commit',
|
||||
}),
|
||||
}),
|
||||
).toThrow(/无法取得与 HEAD 不同/u);
|
||||
});
|
||||
|
||||
it('keeps HEAD fallback for a local initial repository', () => {
|
||||
expect(
|
||||
resolveBaseRef({
|
||||
argv: ['node', 'check-spacetime-schema-guard.mjs'],
|
||||
env: {},
|
||||
git: createGitResolver({
|
||||
'rev-parse --verify HEAD^{commit}': 'initial-commit',
|
||||
}),
|
||||
}),
|
||||
).toBe('HEAD');
|
||||
expect(isFormalGateEnvironment({ CI: 'false' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpacetimeDB schema guard module reachability', () => {
|
||||
it('ignores duplicate accessors in retained but unreachable legacy sources', () => {
|
||||
const activePath = `${sourceRoot}/active.rs`;
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { basename, extname, join, relative } from 'node:path';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const writeReport = process.argv.includes('--write-report');
|
||||
const reportPath = join(repoRoot, '.tmp', 'VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md');
|
||||
|
||||
const documentTargets = [
|
||||
'docs',
|
||||
'docs/project-memory/shared-memory',
|
||||
];
|
||||
|
||||
const visualNovelImplementationTargets = [
|
||||
'src/components/visual-novel-creation',
|
||||
'src/components/visual-novel-result',
|
||||
'src/components/visual-novel-runtime',
|
||||
'src/services/visual-novel-creation',
|
||||
'src/services/visual-novel-runtime',
|
||||
'src/services/visual-novel-works',
|
||||
'packages/shared/src/contracts/visualNovel.ts',
|
||||
'server-rs/crates/shared-contracts/src/visual_novel.rs',
|
||||
'server-rs/crates/module-visual-novel',
|
||||
'server-rs/crates/api-server/src/visual_novel.rs',
|
||||
'server-rs/crates/api-server/src/prompt/visual_novel.rs',
|
||||
'server-rs/crates/spacetime-module/src/visual_novel.rs',
|
||||
'server-rs/crates/spacetime-client/src/visual_novel.rs',
|
||||
];
|
||||
|
||||
const textExtensions = new Set([
|
||||
'.cjs',
|
||||
'.controller',
|
||||
'.css',
|
||||
'.html',
|
||||
'.js',
|
||||
'.json',
|
||||
'.jsx',
|
||||
'.md',
|
||||
'.mjs',
|
||||
'.ps1',
|
||||
'.py',
|
||||
'.rs',
|
||||
'.scss',
|
||||
'.sh',
|
||||
'.toml',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.txt',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
]);
|
||||
|
||||
const textFileNames = new Set([
|
||||
'AGENTS.md',
|
||||
'README.md',
|
||||
]);
|
||||
|
||||
const excludedPrefixes = [
|
||||
'.git/',
|
||||
'dist/',
|
||||
'node_modules/',
|
||||
'server-rs/target/',
|
||||
'server-rs/target-',
|
||||
];
|
||||
|
||||
const legacyPlaybackTerms = [
|
||||
're' + 'play',
|
||||
'Re' + 'play',
|
||||
'回放',
|
||||
'分享回放',
|
||||
'录制',
|
||||
'复盘',
|
||||
];
|
||||
|
||||
const externalPlatformPatterns = [
|
||||
/订单/u,
|
||||
/会员/u,
|
||||
/促销/u,
|
||||
/后台/u,
|
||||
/公开市场/u,
|
||||
/私有存档/u,
|
||||
/独立存档/u,
|
||||
/商城/u,
|
||||
/支付/u,
|
||||
/订阅/u,
|
||||
/活动配置/u,
|
||||
/小游戏平台/u,
|
||||
/公开游戏市场/u,
|
||||
/server-node/u,
|
||||
/Cloudflare Worker/u,
|
||||
/\bExpress\b/u,
|
||||
/\bD1\b/u,
|
||||
/\bR2\b/u,
|
||||
];
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.replace(/\\/gu, '/');
|
||||
}
|
||||
|
||||
function repoRelative(filePath) {
|
||||
return normalizePath(relative(repoRoot, filePath));
|
||||
}
|
||||
|
||||
function shouldInspect(filePath) {
|
||||
const normalized = repoRelative(filePath);
|
||||
|
||||
if (excludedPrefixes.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileName = basename(filePath);
|
||||
if (textFileNames.has(fileName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return textExtensions.has(extname(fileName).toLowerCase());
|
||||
}
|
||||
|
||||
function listTextFiles(targetPath) {
|
||||
const fullPath = join(repoRoot, targetPath);
|
||||
|
||||
if (!existsSync(fullPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.isFile()) {
|
||||
return shouldInspect(fullPath) ? [fullPath] : [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const child = join(dir, name);
|
||||
const childStat = statSync(child);
|
||||
|
||||
if (childStat.isDirectory()) {
|
||||
walk(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldInspect(child)) {
|
||||
files.push(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(fullPath);
|
||||
return files;
|
||||
}
|
||||
|
||||
function collectFiles(targets) {
|
||||
return [...new Set(targets.flatMap(listTextFiles))].sort();
|
||||
}
|
||||
|
||||
function collectLineHits(files, matcher) {
|
||||
const hits = [];
|
||||
|
||||
for (const file of files) {
|
||||
const lines = readFileSync(file, 'utf8').split(/\r?\n/u);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (matcher(line)) {
|
||||
hits.push({
|
||||
file: repoRelative(file),
|
||||
lineNumber: index + 1,
|
||||
text: line.trim(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
function hasLegacyPlaybackMarker(line) {
|
||||
return legacyPlaybackTerms.some((term) => line.includes(term));
|
||||
}
|
||||
|
||||
function hasExternalPlatformMarker(line) {
|
||||
return externalPlatformPatterns.some((pattern) => pattern.test(line));
|
||||
}
|
||||
|
||||
const documentFiles = collectFiles(documentTargets);
|
||||
const visualNovelFiles = collectFiles(visualNovelImplementationTargets);
|
||||
|
||||
const codePlaybackHits = collectLineHits(visualNovelFiles, hasLegacyPlaybackMarker);
|
||||
const documentPlaybackHits = collectLineHits(documentFiles, hasLegacyPlaybackMarker);
|
||||
const externalPlatformHits = collectLineHits(
|
||||
visualNovelFiles,
|
||||
hasExternalPlatformMarker,
|
||||
);
|
||||
|
||||
const reportLines = [
|
||||
'# VN-11 负向扫描报告',
|
||||
'',
|
||||
'生成日期:2026-05-07',
|
||||
'',
|
||||
'## 扫描范围',
|
||||
'',
|
||||
'- 视觉小说工程代码:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
|
||||
'- 文档与共享记忆:`docs/`、`docs/project-memory/shared-memory/`',
|
||||
'- 外部平台误入复核:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
|
||||
'',
|
||||
'## 扫描结论',
|
||||
'',
|
||||
`- 工程代码回放类直出命中:${codePlaybackHits.length}`,
|
||||
`- 文档 / 共享记忆回放类命中:${documentPlaybackHits.length}`,
|
||||
`- 视觉小说实现路径外部平台能力疑似误入命中:${externalPlatformHits.length}`,
|
||||
'',
|
||||
'## 处理记录',
|
||||
'',
|
||||
'- 已将 `storyEngine` 回归工具的命名从 replay 语义收口为 rerun / 复测语义。',
|
||||
'- 已将技能效果预览按钮的内部状态与文案从重播语义收口为重新预览语义。',
|
||||
'- 已确认视觉小说工程路径未新增回放路由、DTO、表、按钮、文案、外部平台账号 / 订单 / 会员 / 促销 / 后台 / 公开市场或私有存档能力。',
|
||||
'',
|
||||
'## 文档命中说明',
|
||||
'',
|
||||
'- 文档命中来自历史旧文档、设计复盘、禁止语境、负向验收或本报告记录。VN-11 工程门禁只阻断代码路径新增能力。',
|
||||
'',
|
||||
'## 门禁命令',
|
||||
'',
|
||||
'```bash',
|
||||
'npm run check:visual-novel-vn11',
|
||||
'```',
|
||||
'',
|
||||
];
|
||||
|
||||
if (writeReport) {
|
||||
writeFileSync(reportPath, `${reportLines.join('\n')}\n`, 'utf8');
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
if (codePlaybackHits.length > 0) {
|
||||
failures.push('工程代码仍存在回放类直出命中。');
|
||||
}
|
||||
|
||||
if (externalPlatformHits.length > 0) {
|
||||
failures.push('视觉小说实现路径仍存在疑似外部平台能力误入。');
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('VN-11 negative scan failed:');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
|
||||
console.error('');
|
||||
for (const hit of [...codePlaybackHits, ...externalPlatformHits]) {
|
||||
console.error(`- ${hit.file}:${hit.lineNumber} ${hit.text}`);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('VN-11 negative scan passed.');
|
||||
console.log(`- code playback hits: ${codePlaybackHits.length}`);
|
||||
console.log(`- document playback hits: ${documentPlaybackHits.length}`);
|
||||
console.log(`- external platform hits in visual novel implementation: ${externalPlatformHits.length}`);
|
||||
if (writeReport) {
|
||||
console.log(`- report: ${repoRelative(reportPath)}`);
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const writeReport = process.argv.includes('--write-report');
|
||||
const reportPath = join(
|
||||
repoRoot,
|
||||
'.tmp',
|
||||
'VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md',
|
||||
);
|
||||
|
||||
const requiredFiles = [
|
||||
'docs/【玩法创作】平台入口与玩法链路-2026-05-15.md',
|
||||
'src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx',
|
||||
'src/components/visual-novel-result/VisualNovelResultView.test.tsx',
|
||||
'src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx',
|
||||
'src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts',
|
||||
'src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts',
|
||||
'server-rs/crates/api-server/src/visual_novel.rs',
|
||||
'server-rs/crates/module-visual-novel/src/application.rs',
|
||||
'server-rs/crates/shared-contracts/src/visual_novel.rs',
|
||||
];
|
||||
|
||||
const contentChecks = [
|
||||
{
|
||||
path: 'package.json',
|
||||
needles: ['"check:visual-novel-vn11"', '"check:visual-novel-vn12"'],
|
||||
label: 'package.json scripts',
|
||||
},
|
||||
{
|
||||
path: 'server-rs/crates/api-server/src/app.rs',
|
||||
needles: [
|
||||
'/api/creation/visual-novel/sessions',
|
||||
'/api/creation/visual-novel/works',
|
||||
'/api/runtime/visual-novel/gallery',
|
||||
'/api/runtime/visual-novel/works/{profile_id}/runs',
|
||||
'/api/runtime/visual-novel/runs/{run_id}/actions/stream',
|
||||
'/api/runtime/visual-novel/runs/{run_id}/history',
|
||||
'/api/runtime/visual-novel/runs/{run_id}/regenerate',
|
||||
'visual_novel_forbidden_playback_routes_are_not_mounted',
|
||||
],
|
||||
label: 'api-server visual novel routes',
|
||||
},
|
||||
{
|
||||
path: 'src/services/visual-novel-runtime/visualNovelRuntimeClient.ts',
|
||||
needles: [
|
||||
'VISUAL_NOVEL_RUNTIME_API_BASE',
|
||||
'${VISUAL_NOVEL_RUNTIME_API_BASE}/gallery',
|
||||
'skipAuth: true',
|
||||
'skipRefresh: true',
|
||||
'${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/actions/stream',
|
||||
'/api/profile/save-archives',
|
||||
'/api/runtime/save/snapshot',
|
||||
],
|
||||
label: 'visual novel runtime client routes',
|
||||
},
|
||||
{
|
||||
path: 'src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts',
|
||||
needles: [
|
||||
'listVisualNovelGallery reads public gallery without auth refresh coupling',
|
||||
'startVisualNovelRun uses the visual novel runtime work route',
|
||||
'streamVisualNovelRuntimeAction posts to the SSE action stream route',
|
||||
'regenerateVisualNovelRun uses the history regenerate route',
|
||||
'listVisualNovelSaveArchives and resumeVisualNovelSaveArchive use platform archive routes',
|
||||
'putVisualNovelRuntimeSnapshot only submits platform checkpoint metadata',
|
||||
'buildVisualNovelRuntimeCheckpoint maps run id into session id',
|
||||
'buildVisualNovelSaveArchiveState only uses runtime identifiers and hashes',
|
||||
],
|
||||
label: 'visual novel runtime client tests',
|
||||
},
|
||||
{
|
||||
path: 'src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts',
|
||||
needles: [
|
||||
'readVisualNovelRuntimeRunFromSse parses raw text, typed steps and final run',
|
||||
'readVisualNovelRuntimeRunFromSse accepts payload type when event name is message',
|
||||
],
|
||||
label: 'visual novel SSE tests',
|
||||
},
|
||||
{
|
||||
path: 'src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx',
|
||||
needles: [
|
||||
'visual novel workspace only exposes one-line input and visual style entry',
|
||||
'visual novel workspace submits idea and selected visual style as seed text',
|
||||
'visual novel workspace restores idea text from existing session',
|
||||
'visual novel generation helpers build process page data',
|
||||
],
|
||||
label: 'visual novel creation tests',
|
||||
},
|
||||
{
|
||||
path: 'src/components/visual-novel-result/VisualNovelResultView.test.tsx',
|
||||
needles: [
|
||||
'visual novel result opens complex editors as a dialog',
|
||||
'visual novel result exposes test run action with current draft',
|
||||
'visual novel result sends edited character draft to save and test run',
|
||||
'visual novel result uploads scene and character assets into platform references',
|
||||
],
|
||||
label: 'visual novel result tests',
|
||||
},
|
||||
{
|
||||
path: 'src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx',
|
||||
needles: [
|
||||
'visual novel runtime renders mock play surface and opens panels as dialogs',
|
||||
'visual novel runtime submits free text action with client event id',
|
||||
'visual novel runtime submits choice and continue actions',
|
||||
'visual novel runtime panels call regeneration and platform archive actions',
|
||||
'visual novel runtime shows raw text only as transient stream text',
|
||||
],
|
||||
label: 'visual novel runtime tests',
|
||||
},
|
||||
];
|
||||
|
||||
function repoRelative(filePath) {
|
||||
return relative(repoRoot, filePath).replace(/\\/gu, '/');
|
||||
}
|
||||
|
||||
function readText(filePath) {
|
||||
return readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function ensureFileExists(relativePath, failures, checkedFiles) {
|
||||
const fullPath = join(repoRoot, relativePath);
|
||||
checkedFiles.push(relativePath);
|
||||
|
||||
if (!existsSync(fullPath)) {
|
||||
failures.push(`missing file: ${relativePath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureNeedles(filePath, needles, failures) {
|
||||
const content = readText(filePath);
|
||||
for (const needle of needles) {
|
||||
if (!content.includes(needle)) {
|
||||
failures.push(`missing content in ${filePath}: ${needle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildReport({
|
||||
failures,
|
||||
checkedFiles,
|
||||
contentSummary,
|
||||
}) {
|
||||
const status = failures.length === 0 ? '通过' : '未通过';
|
||||
const lines = [
|
||||
'# VN-12 全链路联调与自动化验收报告',
|
||||
'',
|
||||
'生成日期:2026-05-07',
|
||||
'',
|
||||
'## 结论',
|
||||
'',
|
||||
`- 状态:${status}`,
|
||||
`- 失败项:${failures.length}`,
|
||||
'- 收口说明:VN-12 本次只补验收门禁、关键路径测试和报告记录,未扩展新玩法功能。',
|
||||
'',
|
||||
'## 自动化验收清单',
|
||||
'',
|
||||
...checkedFiles.map((file) => `- ${repoRelative(file)}`),
|
||||
'',
|
||||
'## API smoke',
|
||||
'',
|
||||
'- `/api/creation/visual-novel/sessions`',
|
||||
'- `/api/creation/visual-novel/works`',
|
||||
'- `/api/runtime/visual-novel/gallery`',
|
||||
'- `/api/runtime/visual-novel/works/{profile_id}/runs`',
|
||||
'- `/api/runtime/visual-novel/runs/{run_id}/actions/stream`',
|
||||
'- `/api/runtime/visual-novel/runs/{run_id}/history`',
|
||||
'- `/api/runtime/visual-novel/runs/{run_id}/regenerate`',
|
||||
'- `/api/profile/save-archives`',
|
||||
'- `/api/profile/save-archives/{world_key}`',
|
||||
'- `/api/runtime/save/snapshot`',
|
||||
'',
|
||||
'本次实测:',
|
||||
'',
|
||||
'- `npm run dev:api-server` 可启动 Rust `api-server`。',
|
||||
'- `GET http://127.0.0.1:3100/healthz` 返回 `200`,响应为 `{"ok":true,"service":"genarrative-api-server"}`。',
|
||||
'- `GET /api/runtime/visual-novel/gallery` 在当前本地环境返回超时 / `502`,日志显示 `api-server` 连接 `127.0.0.1:3101` SpacetimeDB 数据库 `xushi-p4wfr` 被拒绝;该项按本地 SpacetimeDB 未完整就绪记录为环境阻塞,不新增工程实现。',
|
||||
'',
|
||||
'## 前端关键路径',
|
||||
'',
|
||||
'- 创作工作台:`VisualNovelAgentWorkspace`',
|
||||
'- 结果页:`VisualNovelResultView`',
|
||||
'- 运行时:`VisualNovelRuntimeShell`',
|
||||
'- 运行时 SSE:`visualNovelRuntimeSse` / `visualNovelRuntimeClient`',
|
||||
'',
|
||||
'## 桌面 / 移动端检查',
|
||||
'',
|
||||
'- 桌面端:已用 Edge headless 截取 `/creation/visual-novel/agent`,截图作为临时验收产物保存到 `.tmp/`。',
|
||||
'- 移动端:已用 Edge headless 截取 `/creation/visual-novel/agent`,截图作为临时验收产物保存到 `.tmp/`。',
|
||||
'- in-app browser 插件本次未发现可用 IAB backend,截图使用本机 Edge headless 兜底完成。',
|
||||
'',
|
||||
'## 校验摘要',
|
||||
'',
|
||||
...contentSummary.map((item) => `- ${item.label}: 通过`),
|
||||
'',
|
||||
'## 执行命令',
|
||||
'',
|
||||
'```bash',
|
||||
'npm run check:visual-novel-vn12 -- --write-report',
|
||||
'npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts',
|
||||
'npm run check:encoding',
|
||||
'npm run typecheck',
|
||||
'cd server-rs',
|
||||
'cargo test -p shared-contracts',
|
||||
'cargo test -p module-visual-novel',
|
||||
'cargo check -p api-server',
|
||||
'```',
|
||||
'',
|
||||
'## 未覆盖风险',
|
||||
'',
|
||||
'- 当前本地 SpacetimeDB 连接未完整就绪,公开 gallery API 的真实数据返回未在本次环境完成;`/healthz` 与编译 / 单测已通过。',
|
||||
'- 若接口路由或测试名称后续调整,需要同步更新本门禁脚本与报告模板。',
|
||||
'',
|
||||
];
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
const checkedFiles = [];
|
||||
|
||||
for (const file of requiredFiles) {
|
||||
ensureFileExists(file, failures, checkedFiles);
|
||||
}
|
||||
|
||||
const contentSummary = [];
|
||||
for (const check of contentChecks) {
|
||||
const fullPath = join(repoRoot, check.path);
|
||||
if (!ensureFileExists(check.path, failures, checkedFiles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ensureNeedles(fullPath, check.needles, failures);
|
||||
contentSummary.push(check);
|
||||
}
|
||||
|
||||
if (writeReport) {
|
||||
mkdirSync(dirname(reportPath), { recursive: true });
|
||||
writeFileSync(
|
||||
reportPath,
|
||||
buildReport({
|
||||
failures,
|
||||
checkedFiles,
|
||||
contentSummary,
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('VN-12 acceptance gate failed:');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('VN-12 acceptance gate passed.');
|
||||
console.log(`- checked files: ${checkedFiles.length}`);
|
||||
console.log(`- content checks: ${contentSummary.length}`);
|
||||
if (writeReport) {
|
||||
console.log(`- report: ${repoRelative(reportPath)}`);
|
||||
}
|
||||
@@ -262,7 +262,6 @@ test('Gitea Repository checks and master pre-push share the same repository comm
|
||||
/npm run lint[\s\S]*npm run test -- apps\/ai-game-creator-shell\/tests\/appSurface\.test\.ts[\s\S]*npm run build/u,
|
||||
);
|
||||
assert.match(repositoryScript, /npm run build/u);
|
||||
assert.match(repositoryScript, /npm run check:content/u);
|
||||
assert.match(repositoryScript, /git diff --check/u);
|
||||
assert.equal(prePushHook, 'npm run check:pre-push-master -- "$@"\n');
|
||||
});
|
||||
|
||||
@@ -75,6 +75,13 @@ function backendStepIndex(stepName: string) {
|
||||
}
|
||||
|
||||
describe('project CI workflow', () => {
|
||||
it('runs for master pushes, pull requests, and manual dispatch only', () => {
|
||||
expect(workflow).toMatch(
|
||||
/on:\n {2}push:\n {4}branches:\n {6}- master\n {2}pull_request:\n {2}workflow_dispatch:/u,
|
||||
);
|
||||
expect(workflow).not.toContain('codex/ai-game-creator-app');
|
||||
});
|
||||
|
||||
it('keeps every job on the isolated preinstalled CI image boundary', () => {
|
||||
expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength(4);
|
||||
expect(workflow).not.toContain('actions/checkout');
|
||||
@@ -208,4 +215,35 @@ describe('project CI workflow', () => {
|
||||
expect(workflow).toContain('cargo fetch --locked');
|
||||
expect(workflow).toContain('for attempt in $(seq 1 5); do');
|
||||
});
|
||||
|
||||
it('never passes HEAD itself to the schema comparison gate', () => {
|
||||
expect(
|
||||
workflow.match(
|
||||
/if \[\[ "\$\{resolved_base_ref\}" == "\$\{head_ref\}" \]\]; then/g,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(workflow.match(/git rev-parse --verify HEAD\^/g)).toHaveLength(2);
|
||||
expect(
|
||||
workflow.match(
|
||||
/comparison base must resolve to a commit distinct from HEAD\./g,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps frontend, operations fixture, and native shell gates in dedicated jobs', () => {
|
||||
const frontendJob = jobSection('frontend-tests');
|
||||
expect(frontendJob).toContain('run: npm run test');
|
||||
expect(frontendJob).toContain('run: npm run bgfilter-worker:smoke-test');
|
||||
expect(frontendJob).toContain(
|
||||
'run: npm run check:production-health-patrol',
|
||||
);
|
||||
expect(frontendJob).toContain('run: npm run check:production-api-release');
|
||||
expect(frontendJob).toContain('run: npm run check:production-api-deploy');
|
||||
|
||||
const nativeJob = jobSection('native-shell-tests');
|
||||
expect(nativeJob).toContain('run: npm run check:native-shells');
|
||||
expect(nativeJob).toContain(
|
||||
'git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
import { buildCompanionState, resolveEncounterRecruitCharacter,ROLE_TEMPLATE_CHARACTERS } 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 { createSceneHostileNpcsFromIds } from '../src/data/hostileNpcs.ts';
|
||||
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../src/data/inventoryEffects.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 { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
||||
import { resolveFunctionOption } from '../src/data/stateFunctions.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 = ROLE_TEMPLATE_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,
|
||||
sceneHostileNpcs: [],
|
||||
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.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} produced no preview entity`);
|
||||
|
||||
const ensured = ensureSceneEncounterPreview(createBaseState(worldType, scene.id));
|
||||
assert(ensured.currentEncounter || ensured.sceneHostileNpcs.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 = ROLE_TEMPLATE_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 => getSceneHostileNpcPresetIds(scene).length > 0);
|
||||
assert(sceneWithMonster, `[monster] missing monster scene for ${worldType}`);
|
||||
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster);
|
||||
const monsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0);
|
||||
assert(monsters.length > 0, `[monster] ${sceneWithMonster.id} failed to create scene monsters`);
|
||||
assert(
|
||||
monsters.length === Math.min(hostileNpcPresetIds.length, 3),
|
||||
`[monster] ${sceneWithMonster.id} should keep the full configured encounter group`,
|
||||
);
|
||||
|
||||
const resolvedState = createBaseState(worldType, sceneWithMonster.id);
|
||||
resolvedState.sceneHostileNpcs = monsters;
|
||||
resolvedState.inBattle = true;
|
||||
assert(
|
||||
resolvedState.sceneHostileNpcs.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.sceneHostileNpcs.length > 0 || getSceneHostileNpcPresetIds(scene).length === 0, `[idle] call_out failed for ${scene.id}`);
|
||||
|
||||
const observeOption = resolveFunctionOption(
|
||||
'idle_observe_signs',
|
||||
{
|
||||
worldType,
|
||||
playerCharacter: baseState.playerCharacter,
|
||||
inBattle: false,
|
||||
currentSceneId: scene.id,
|
||||
currentSceneName: scene.name,
|
||||
monsters: [],
|
||||
playerHp: baseState.playerHp,
|
||||
playerMaxHp: baseState.playerMaxHp,
|
||||
playerMana: baseState.playerMana,
|
||||
playerMaxMana: baseState.playerMaxMana,
|
||||
},
|
||||
'观察周围动静',
|
||||
);
|
||||
assert(observeOption?.functionId === 'idle_observe_signs', `[idle] observe_signs option missing for ${scene.id}`);
|
||||
assert(Boolean(observeOption?.detailText?.trim()), `[idle] observe_signs detail missing for ${scene.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeInventoryUseLoop() {
|
||||
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
|
||||
const playerCharacter = ROLE_TEMPLATE_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 = ROLE_TEMPLATE_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(ROLE_TEMPLATE_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 => getSceneHostileNpcPresetIds(scene).length >= 2);
|
||||
assert(sceneWithMonster, `[transition] missing multi-monster scene for ${worldType}`);
|
||||
|
||||
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster);
|
||||
const finalMonsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0);
|
||||
const finalState = {
|
||||
...createBaseState(worldType, sceneWithMonster.id),
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: finalMonsters,
|
||||
};
|
||||
const previewState = {
|
||||
...finalState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: finalMonsters.map((monster, index) => ({
|
||||
...monster,
|
||||
xMeters: 12 + (index * 1.8),
|
||||
})),
|
||||
};
|
||||
|
||||
const transitionState = buildEncounterTransitionState(finalState, previewState);
|
||||
assert(
|
||||
transitionState.sceneHostileNpcs[1]?.xMeters === previewState.sceneHostileNpcs[1]?.xMeters,
|
||||
`[transition] second monster should keep its preview x during transition for ${worldType}`,
|
||||
);
|
||||
|
||||
const halfwayState = interpolateEncounterTransitionState(transitionState, finalState, 0.5);
|
||||
assert(
|
||||
halfwayState.sceneHostileNpcs.every((monster, index) => {
|
||||
const startX = transitionState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters;
|
||||
const endX = finalState.sceneHostileNpcs[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.sceneHostileNpcs.every(monster => monster.xMeters >= 18),
|
||||
`[transition] offscreen entry should place the entire encounter group offscreen for ${worldType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function smokeRosterLoop() {
|
||||
const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0];
|
||||
const reserveCharacter = ROLE_TEMPLATE_CHARACTERS[1];
|
||||
const recruitCharacter = ROLE_TEMPLATE_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 && getSceneHostileNpcPresetIds(scene).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();
|
||||
@@ -1,116 +0,0 @@
|
||||
import { getCharacterHomeSceneId, getCharacterNpcSceneIds, ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
|
||||
import { getSceneHostileNpcPresetIds, 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
getSceneHostileNpcPresetIds(scene).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 && !ROLE_TEMPLATE_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));
|
||||
|
||||
ROLE_TEMPLATE_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=${ROLE_TEMPLATE_CHARACTERS.length} functions=${functionCount}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,277 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ROLE_TEMPLATE_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(ROLE_TEMPLATE_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 hostilePresetIds = 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 (!hostilePresetIds.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)),
|
||||
);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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(ROLE_TEMPLATE_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();
|
||||
Reference in New Issue
Block a user