feat(runtime): 删 gamedef 运行时核心(gd-runtime/build-from-source/2d-api)

gamedef 声明式 JSON + new Function 解释路线的 game-runtime 核心,随
gamedef 判废一并清除:
- 4 测试:src/host/{gd-runtime.test,gd-archetypes.test,build-from-source.test,gameplay-pitfalls.test}.mjs
- 4 被引文件:src/host/{gd-runtime.js,build-from-source.mjs,runtime-api-2d.d.ts,runtime-api-2d.md}

这 8 文件互相引用闭环、仅被彼此与那 4 个 gamedef 测试引用;A-model 全线
(src/core / src/plugins / host / _template / _generic)不依赖它们。
src/core/game-host.d.ts L25 一句史化石注释提及 gd-runtime(非 import),
属"绝不动"的 A-model 核心,保留,列入残留报告。

验证(必跑·删后绿基线):
- 核心 node --test src/core+src/plugins = 211 pass / 0 fail
- games/_generic 壳 = 5 pass / 0 fail(确认未误伤 _generic 标准插件壳)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-25 02:04:46 -07:00
parent d0626e1f1c
commit d55030bf4a
8 changed files with 0 additions and 2003 deletions

View File

@ -1,317 +0,0 @@
/**
* build-from-source.mjs —— build-from-source 装配器(gameDefinition → 可玩工厂源文本 · build-from-source-v0)
* owner:本 session(引擎线 · plan 2026-06-18-001 U2) | 消费方:SaaGenNodes.scaffoldNode(落 generated-factory.js)→ build.mjs(esbuild)→ 九门 harness
*
* ════════════════════════════════════════════════════════════════════════════
* 【职责】把后端线产出的 sourceProject(声明式 gameDefinition + behaviors 各带逻辑 JS)装配成
* `generated-factory.js` 源文本——对齐已冻装载契约(game-host.d.ts:default export 工厂 →
* GameInstance{init,update,render,destroy,_forensicsView}),GameInstance 五法委托 U1 的 createRuntime。
*
* 【信任模型(评审定论·铁律)】模型产出的 behavior.code / rule.condition 是**不可信代码**。
* - 它**只在浏览器**(Chrome via 九门 CDP harness)执行;esbuild 只**静态打包**(从不执行它);
* 生产链上 **Node 永不执行不可信模型代码**(本仓 Node 仅在单测里跑「自产金样」)。浏览器即安全边界。
* - 同步 JS 无法在进程内硬中断(死循环/逃逸),故**在校验边界静态拦截**({@link #scanLogic})——把
* 危险模式(禁用全局/确定性逃逸/死循环/condition 副作用)在编译前转成 validationErrors → repair 反馈,
* 而非靠 prompt 自觉。这是 new Function 不沙箱的现实缓解(对齐双评审 Critical 的边界化建议)。
*
* 【接现有·零改下游】产出文本 → scaffoldNode 落 generated-factory.js → scripts/build.mjs(esbuild,
* 唯一构建脚本不动)→ bundle.iife.js → serve-and-play.sh + play.cdp.cjs 九门(harness 零改)。
*
* 【为何「behaviors 带 JS 经数据串内联」】gameDefinition 内联为 JS 对象字面量,behavior.code 作字符串随之内联;
* 运行期 createRuntime 经 new Function 编译。JSON.stringify 完备转义;另把 `<` 转 `<`(保 inline-injection 安全,
* 防 </script> 提前闭合 host 页内联 <script>)。
*
* 【fail-loud】校验失败抛错(带 validationErrors)——buildNode 捕获回灌 repair feedback。strip 未知键是产线容错必做。
* 【迭代状态 v0】轻量校验 + top-level strip(不引 ajv 依赖,守 game-runtime「本机零依赖只跑 node --test」)。
* ════════════════════════════════════════════════════════════════════════════
*/
'use strict';
/** gameDefinition 顶层白名单(对齐 schema#/properties/gameDefinition)。
* ⚠️ assets **不在此**——schema 中 assets 位于 sourceProject 顶层(与 gameDefinition 平级,additionalProperties:false),
* 非 gameDefinition 属性;单独从顶层 sp.assets 抽取(见 validateSourceProject 末),误入本白名单会丢合规顶层 assets(评审 P0)。 */
const GAMEDEF_KEYS = ['entities', 'components', 'behaviors', 'scenes', 'rules'];
/** 六类资产类目(冻结,对齐 source-project.schema.json #/$defs/assetSpec + game-material V24 + studio.yaml)。 */
const ASSET_CATEGORIES = new Set(['sprite', 'character', 'effect', 'scene', 'ui', 'music']);
/** 生成 game 目录中,generated-factory.js → src/host/gd-runtime.js 的相对 import 路径(games/_wg1-gen/<name>/ 下三级回 game-runtime)。 */
const DEFAULT_RUNTIME_IMPORT = '../../../src/host/gd-runtime.js';
/* ── 静态扫描禁则(边界拦截不可信模型逻辑·转 repair 信号) ───────────────────── */
/** 通用禁则(behavior.code + rule.condition 都扫):确定性逃逸 + 沙箱逃逸 + DOM/网络 + 死循环。 */
const LOGIC_BANS = [
[/\bMath\s*\.\s*random\b/, '禁 Math.random(用 rt.random,否则取证不可复现)'],
[/\bDate\s*\.\s*now\b/, '禁 Date.now(用 rt.time.now)'],
[/\bnew\s+Date\b/, '禁 new Date(用 rt.time.now)'],
[/\bperformance\s*\.\s*now\b/, '禁 performance.now(用 rt.time.now)'],
[/\bprocess\b/, '禁 process(沙箱逃逸)'],
[/\brequire\s*\(/, '禁 require(沙箱逃逸)'],
[/\bimport\s*\(/, '禁动态 import(沙箱逃逸)'],
[/\bglobalThis\b/, '禁 globalThis(沙箱逃逸)'],
[/\beval\s*\(/, '禁 eval'],
[/\.\s*constructor\b/, '禁 .constructor(Function 构造逃逸)'],
[/\bFunction\s*\(/, '禁 Function 构造器(沙箱逃逸)'],
[/\baddEventListener\b/, '禁 addEventListener(用 rt.input)'],
[/\brequestAnimationFrame\b/, '禁 requestAnimationFrame(宿主驱动)'],
[/\bdocument\b/, '禁 document(用 render 组件/rt)'],
[/\bwindow\b/, '禁 window'],
[/\bfetch\s*\(/, '禁 fetch(无网络)'],
[/\bXMLHttpRequest\b/, '禁 XMLHttpRequest(无网络)'],
[/while\s*\(\s*true\s*\)/, '禁 while(true) 无界循环(同步死循环会冻帧/挂进程)'],
[/while\s*\(\s*1\s*\)/, '禁 while(1) 无界循环'],
[/for\s*\(\s*;\s*;\s*\)/, '禁 for(;;) 无界循环'],
];
/** rule.condition 专属禁则:必须是无副作用的布尔表达式(不得含语句分隔/赋值/世界变更)。 */
const CONDITION_BANS = [
[/;/, 'rule.condition 不得含 ; (须是单个布尔表达式,非语句)'],
[/(^|[^=!<>])=(?!=)/, 'rule.condition 不得含赋值(=)(须无副作用)'],
[/\b(addScore|setScore|spawn|destroy)\s*\(/, 'rule.condition 不得调世界变更方法(addScore/setScore/spawn/destroy)'],
[/\.\s*(win|lose)\s*\(/, 'rule.condition 不得调 win()/lose()(胜负经 outcome,非 condition 副作用)'],
];
/** 扫一段逻辑字符串;返回违规消息数组(空=过)。isCondition=true 时附加 condition 专属禁则。 */
function scanLogic(code, label, isCondition) {
const out = [];
if (typeof code !== 'string') return out;
for (const [re, msg] of LOGIC_BANS) { if (re.test(code)) out.push(label + ' ' + msg); }
if (isCondition) { for (const [re, msg] of CONDITION_BANS) { if (re.test(code)) out.push(label + ' ' + msg); } }
return out;
}
/** 取 behavior 的逻辑代码(code 规范名 / js 兼容别名)。 */
function behaviorCode(b) {
if (!b) return null;
if (typeof b.code === 'string') return b.code;
if (typeof b.js === 'string') return b.js;
return null;
}
/* ── 玩法哑火坑静态门(Plan A·Phase 3,2026-06-20;坑库码化:把 GAMEDEF_SYSTEM 的 ⚠️ 从「prompt 预防」升为「真玩前检测」)──
* 只码化【零误伤·高价值】两族:①.tags 是 Set 误用数组法/下标/length(必每帧抛)②漏 rt. 前缀的裸引擎调(必 ReferenceError;
* 补 LOGIC_BANS 不拦 getEngine/getInput/restart 的洞——含此坑的源装配能过却运行期夭折,gd-runner/whack/tetris 即此)。
* 命中→进 validationErrors→repair,真玩前就修、省一轮 CDP。零误伤铁律:对 gd-breakout/tictactoe/simon 三过门纯净源零命中(gameplay-pitfalls.test.mjs)。
* 【已评估但暂不作硬门】self 跨 behavior 泄漏(读 self.X 却本 behavior 从未赋值;self 确为 per-behavior,见 gd-runtime.js `self:{}`):
* 它是真 bug(§0 跨款最大失败簇),但 gd-tictactoe(self.board)/gd-simon(self.phase/seq) 等【带此 bug 却仍过九门】(AI/序列哑了但游戏可玩到过门),
* 硬门会误杀这些过门款(破零误伤)。正解=接「非阻塞 warning 通道」回喂 repair 改进、而非硬拒——留待 warning 通道落地后补(Phase 3 follow-up)。 */
const TAGS_SET_MISUSE = [
[/\.\s*tags\s*\.\s*(includes|indexOf|lastIndexOf|push|pop|shift|unshift|splice|concat|slice|filter|map|forEach|find|findIndex|some|every|reduce|join|sort|reverse)\s*\(/,
'.tags 是 Set 无数组方法(判 tag 用 e.tags.has(name) 或 rt.query(tag);调数组法每帧抛错冻屏)'],
[/\.\s*tags\s*\[\s*\d/, '.tags 是 Set 不可下标索引(用 e.tags.has(name))'],
[/\.\s*tags\s*\.\s*length\b/, '.tags 是 Set 无 .length(用 e.tags.size)'],
];
/** 漏 rt. 前缀的裸引擎调(限 gd-runtime 专有名、几无重名风险;负向后瞻排除 rt./obj. 方法调用)。 */
const BARE_RT_CALLS = [
[/(?<![\w.])getEntity\s*\(/, '裸调 getEntity()(漏 rt. 前缀)→每帧 ReferenceError 夭折;须 rt.getEntity(...)'],
[/(?<![\w.])(getEngine|getInput|restart)\s*\(/, '裸调引擎/宿主方法(漏 rt. 且 rt 面无此法)→ReferenceError;getEngine/getInput 不存在、重开由平台处理(游戏内勿写 restart)'],
[/(?<![\w.])(addScore|setScore|randRange|randInt)\s*\(/, '裸调 rt 方法(漏 rt. 前缀)→ReferenceError;须 rt.addScore/setScore/randRange/randInt'],
];
/** 扫一段 behavior.code 的玩法哑火坑;返回违规消息数组(空=过)。仅两族零误伤硬规则(self 泄漏见上方注释,暂不作硬门)。 */
function scanGameplayPitfalls(code, label) {
const out = [];
if (typeof code !== 'string') return out;
for (const [re, msg] of TAGS_SET_MISUSE) { if (re.test(code)) out.push(label + ' ' + msg); }
for (const [re, msg] of BARE_RT_CALLS) { if (re.test(code)) out.push(label + ' ' + msg); }
return out;
}
/**
* 轻量校验 + strip(产线容错)。
* @param {object} sourceProject 源项目(可为完整 SourceProject,或直接是 gameDefinition)。
* @returns {{ok:boolean, errors:string[], gameDefinition:(object|null)}} 校验结果 + 剥净的 gameDefinition。
*/
export function validateSourceProject(sourceProject) {
const errors = [];
const sp = sourceProject || {};
// 两路都收:完整 SourceProject{gameDefinition} 或直接传 gameDefinition。
const gd = sp.gameDefinition && typeof sp.gameDefinition === 'object' ? sp.gameDefinition : sp;
if (!gd || typeof gd !== 'object' || Array.isArray(gd)) {
errors.push('gameDefinition 缺失或非对象');
return { ok: false, errors, gameDefinition: null };
}
// strip top-level 未知键(模型常塞 schema 外键,如 title/instructions)。
const cleaned = {};
for (const k of GAMEDEF_KEYS) { if (gd[k] != null) cleaned[k] = gd[k]; }
// 数组类字段强制数组化(present-but-not-array=模型漂移:记错并降级为 [],杜绝后续 .map 裸 TypeError)。
// 复用 GAMEDEF_KEYS(成员皆数组类),免「白名单/数组化」两处字面量漂移 = 本次 P0(漏加一处)同类隐患(评审 P2)。
for (const k of GAMEDEF_KEYS) {
if (cleaned[k] != null && !Array.isArray(cleaned[k])) {
errors.push(k + ' 非数组(应为数组,已忽略)');
cleaned[k] = [];
}
}
// 结构必需(schema gameDefinition.required = entities/scenes/rules;scenes/rules 容缺→运行时兜底)。
if (!Array.isArray(cleaned.entities) || cleaned.entities.length === 0) errors.push('entities 缺失或为空');
if (!Array.isArray(cleaned.scenes)) cleaned.scenes = [];
if (!Array.isArray(cleaned.rules)) cleaned.rules = [];
// 引用完整性 + 唯一性(id 须非空;null/undefined id 不入集合,杜绝 'null'/'undefined' 假命中)。
const comps = Array.isArray(cleaned.components) ? cleaned.components : [];
const ents = Array.isArray(cleaned.entities) ? cleaned.entities : [];
const compIds = new Set(comps.filter((c) => c && c.id != null).map((c) => String(c.id)));
const entIds = new Set();
for (const e of ents) {
if (!e || e.id == null) { errors.push('实体缺 id'); continue; }
const id = String(e.id);
if (entIds.has(id)) errors.push('实体 id 重复: ' + id + '(须唯一)');
entIds.add(id);
for (const ref of (Array.isArray(e.components) ? e.components : [])) {
if (typeof ref === 'string' && !compIds.has(ref)) errors.push('实体[' + id + '] 引用不存在组件: ' + ref);
}
}
for (const s of (cleaned.scenes || [])) {
for (const ref of (Array.isArray(s && s.entityRefs) ? s.entityRefs : [])) {
if (!entIds.has(String(ref))) errors.push('场景[' + ((s && s.id) || '?') + '] 引用不存在实体: ' + ref);
}
}
// behaviors:须带非空字符串逻辑 code/js + 静态扫描(边界拦截危险逻辑)。
for (const b of (Array.isArray(cleaned.behaviors) ? cleaned.behaviors : [])) {
const code = behaviorCode(b);
const id = (b && b.id) || '?';
if (code == null || code.trim() === '') {
errors.push('behavior[' + id + '] 缺逻辑 code(须非空字符串)');
continue;
}
errors.push(...scanLogic(code, 'behavior[' + id + ']', false));
errors.push(...scanGameplayPitfalls(code, 'behavior[' + id + ']')); // Plan A·Phase 3:玩法哑火坑静态门
}
// rules:condition 须无副作用布尔表达式 + 静态扫描。
for (const r of (Array.isArray(cleaned.rules) ? cleaned.rules : [])) {
const cond = r && r.condition;
if (typeof cond === 'string' && cond.trim()) {
errors.push(...scanLogic(cond, 'rule[' + ((r && r.id) || '?') + '].condition', true));
}
}
// assets(U1 引擎资产):schema 中 assets 在 **sourceProject 顶层**(line 37,与 gameDefinition 平级),
// 非 gameDefinition 属性(gameDefinition additionalProperties:false)——评审 P0 修:从顶层 sp.assets 抽,
// 非误入 GAMEDEF_KEYS(那会丢合规顶层 assets)。容差:sp 直传 gameDefinition 时回退 gd.assets。
// 校验后并入 cleaned.assets(=运行时 GameDefinition,供 gdef.assets→drawSprite 消费;非源 gameDefinition,无 schema 冲突)。
let assetsSrc = Array.isArray(sp.assets) ? sp.assets : (Array.isArray(gd.assets) ? gd.assets : null);
if (sp.assets != null && !Array.isArray(sp.assets)) {
errors.push('assets 非数组(应为数组,已忽略)');
assetsSrc = null;
}
if (assetsSrc) {
for (const a of assetsSrc) {
if (!a || a.id == null) { errors.push('asset 缺 id'); continue; }
const id = String(a.id);
if (!ASSET_CATEGORIES.has(String(a.category))) {
errors.push('asset[' + id + '] category 非六类(sprite/character/effect/scene/ui/music): ' + a.category);
}
// ref 须**非空字符串**:String({}) 非空会让对象 ref 绕过校验 → 运行时永远解析不到图源(占位)且零 repair 信号、模型死循环(评审)。
if (typeof a.ref !== 'string' || a.ref.trim() === '') {
errors.push('asset[' + id + '] ref 须非空字符串(资产引用,如素材 id/URL)');
}
}
cleaned.assets = assetsSrc; // 并入运行时 GameDefinition(供 gdef.assets → drawSprite)
}
return { ok: errors.length === 0, errors, gameDefinition: cleaned };
}
/**
* 装配 sourceProject → generated-factory.js 源文本。
* @param {object} sourceProject 源项目。
* @param {{runtimeImportPath?:string}} [opts] runtimeImportPath:gd-runtime.js 的 import 路径(缺省=生成目录相对路径)。
* @returns {string} 对齐装载契约的工厂源文本。
* @throws {Error} 校验失败(err.validationErrors 携带逐条错误,供 repair 回灌)。
*/
export function assembleFactoryFromSource(sourceProject, opts) {
const o = opts || {};
const { ok, errors, gameDefinition } = validateSourceProject(sourceProject);
if (!ok) {
const err = new Error('[build-from-source] gameDefinition 校验失败:\n - ' + errors.join('\n - '));
err.validationErrors = errors;
throw err;
}
const importPath = o.runtimeImportPath || DEFAULT_RUNTIME_IMPORT;
// 序列化容错:循环引用/不可序列化 → fail-loud(带 validationErrors,供 repair),不抛裸 TypeError。
let defJson;
try {
defJson = JSON.stringify(gameDefinition);
} catch (e) {
const err = new Error('[build-from-source] gameDefinition 不可序列化(疑循环引用): ' + e.message);
err.validationErrors = ['gameDefinition 含循环引用/不可序列化结构'];
throw err;
}
// inline-injection 安全:把 `<` 转 `<`(JSON/JS 合法转义,运行期串值不变),防 </script>/<!-- 提前闭合宿主内联 script。
defJson = defJson.replace(/</g, '\\u003c');
return factoryTemplate(importPath, defJson);
}
/** 工厂源文本模板(对齐 game-host.d.ts 装载契约;GameInstance 委托 createRuntime)。 */
function factoryTemplate(importPath, defJson) {
return `'use strict';
// generated-factory.js —— build-from-source 装配产物(U2)。源 = gameDefinition(改源不改打包产物)。
// 工厂零引擎 import;一切经 boot.ctx(createRuntime 内部走受控面)。GameInstance 五法委托运行时基座。
import { createRuntime } from '${importPath}';
const GAME_DEFINITION = ${defJson};
export default function createGame(opts) {
const o = opts || {};
let runtime = null;
return {
init(boot) { runtime = createRuntime(boot, GAME_DEFINITION); runtime.init(); },
update(dt) { if (runtime) runtime.update(dt); },
render(g) { if (runtime) runtime.render(g); },
destroy() { if (runtime) { runtime.destroy(); runtime = null; } },
_forensicsView() {
return {
seed: o.seed,
state: () => (runtime ? runtime.state() : { phase: 'booting', result: null, score: 0, remaining: 0, progress: null }),
};
},
};
}
`;
}
export default assembleFactoryFromSource;
/* ════════════════════════════════════════════════════════════════════════════
* CLI(U2 Java 接缝调用点)——SaaGenNodes.validateNode(gamedef) 经 runProcess 子进程调本 CLI。
* node build-from-source.mjs <sourceJson> <outFactory> 校验+装配+写 generated-factory.js(失败 exit 2 + stderr 逐条错误)
* node build-from-source.mjs --validate <sourceJson> 只校验(失败 exit 2 + stderr 错误)
* 被 import 时不执行(保单测/装配器作纯函数用);仅作脚本直跑时执行 main。
* ════════════════════════════════════════════════════════════════════════════ */
async function cliMain(argv) {
const { readFileSync, writeFileSync } = await import('node:fs');
const args = argv.slice(2);
const validateOnly = args[0] === '--validate';
const files = validateOnly ? args.slice(1) : args;
const srcFile = files[0];
const outFile = files[1];
if (!srcFile) {
process.stderr.write('usage: build-from-source.mjs [--validate] <sourceJson> [outFactory]\n');
process.exit(64);
}
let sp;
try {
sp = JSON.parse(readFileSync(srcFile, 'utf8'));
} catch (e) {
process.stderr.write('source JSON 解析失败: ' + e.message + '\n');
process.exit(2);
}
if (validateOnly) {
const v = validateSourceProject(sp);
if (!v.ok) { process.stderr.write(v.errors.join('\n') + '\n'); process.exit(2); }
process.exit(0);
}
try {
const factory = assembleFactoryFromSource(sp);
if (outFile) { writeFileSync(outFile, factory, 'utf8'); } else { process.stdout.write(factory); }
process.exit(0);
} catch (err) {
process.stderr.write((err.validationErrors ? err.validationErrors.join('\n') : err.message) + '\n');
process.exit(2);
}
}
// 仅作为脚本直接运行时执行(import 时 process.argv[1] 非本文件 → 跳过)。
if (typeof process !== 'undefined' && process.argv && process.argv[1]) {
const here = new URL(import.meta.url).pathname;
if (process.argv[1] === here || process.argv[1].endsWith('/build-from-source.mjs')) {
cliMain(process.argv);
}
}

View File

@ -1,218 +0,0 @@
/**
* build-from-source.test.mjs —— U2 装配器单测(plan 2026-06-18-001 U2 验收 · lili-mac 快走查)
* 跑:cd game-runtime && node --test src/host/build-from-source.test.mjs(亦被 npm test 收)
*
* 钉死:① 校验+strip(产线容错);② 装配产物对齐装载契约 + node --check 过;
* ③ 端到端功能证——装配文本经 createRuntime 真跑到 win(不需 esbuild/Chrome,本机即证装配链可玩)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
import { validateSourceProject, assembleFactoryFromSource } from './build-from-source.mjs';
import { createHostDevContext } from '../core/plugin.js';
/** 金样 gameDefinition:确定性自动胜(behavior 0.5s 后加分 → rule score≥1 胜),无需输入,可复现。 */
function goldenSource() {
return {
schemaVersion: '1.0',
profile: { tickModel: 'realtime', inputModel: 'continuous', progressModel: 'metric' },
gameDefinition: {
components: [{ id: 'box', kind: 'render', shape: 'rect', color: '#00ffff', w: 30, h: 30 }],
entities: [{ id: 'hero', transform: { position: { x: 195, y: 422 } }, components: ['box'] }],
behaviors: [{
id: 'tick', trigger: 'update',
code: "self.t=(self.t||0)+dt; const h=rt.getEntity('hero'); if(h){ h.x = 195 + rt.clamp(self.t*50,0,80); } if(self.t>=0.5 && rt.score<1){ rt.addScore(1); rt.fx.burst(h?h.x:0, h?h.y:0, '#ff0'); }",
}],
scenes: [{ id: 's1', entityRefs: ['hero'] }],
rules: [{ id: 'win', condition: 'rt.score >= 1', outcome: 'win' }],
},
};
}
/* ① 校验 + strip ──────────────────────────────────────────────────────── */
test('U2-① validate 金样通过 + strip 顶层未知键', () => {
const r = validateSourceProject(goldenSource());
assert.equal(r.ok, true, '金样应通过: ' + r.errors.join('; '));
assert.deepEqual(r.errors, []);
// 顶层塞 schema 外键应被剥
const dirty = { gameDefinition: { ...goldenSource().gameDefinition, title: '塞的外键', _hack: 1 } };
const r2 = validateSourceProject(dirty);
assert.equal(r2.gameDefinition.title, undefined, 'title 应被 strip');
assert.equal(r2.gameDefinition._hack, undefined, '_hack 应被 strip');
assert.equal(r2.ok, true);
});
test('U2-① validate 揪结构/引用/缺逻辑错', () => {
assert.equal(validateSourceProject({ gameDefinition: { entities: [] } }).ok, false, '空 entities 应失败');
const badRef = validateSourceProject({ gameDefinition: { entities: [{ id: 'a', transform: { position: { x: 0, y: 0 } }, components: ['nope'] }] } });
assert.ok(badRef.errors.some((e) => e.includes('不存在组件')), '应揪组件引用错');
const badScene = validateSourceProject({ gameDefinition: { entities: [{ id: 'a', transform: { position: { x: 0, y: 0 } } }], scenes: [{ id: 's', entityRefs: ['ghost'] }] } });
assert.ok(badScene.errors.some((e) => e.includes('不存在实体')), '应揪场景引用错');
const noCode = validateSourceProject({ gameDefinition: { entities: [{ id: 'a', transform: { position: { x: 0, y: 0 } } }], behaviors: [{ id: 'b', trigger: 'update' }] } });
assert.ok(noCode.errors.some((e) => e.includes('缺逻辑')), '应揪 behavior 缺 code');
assert.equal(validateSourceProject(null).ok, false);
});
/* U1 引擎资产(评审 P0 修):assets 在 sourceProject **顶层**(schema 真位)被抽取并入运行时 gameDefinition;六类/非空串 ref/非数组校验。 */
test('U1 assets 顶层抽取 + 六类/非串ref/非数组 校验', () => {
// schema 真位:assets 在 sourceProject 顶层(与 gameDefinition 平级,非内嵌)
const top = validateSourceProject({
gameDefinition: goldenSource().gameDefinition,
assets: [{ id: 'hero-img', category: 'sprite', ref: 'mat-001' }],
});
assert.equal(top.ok, true, '顶层合法 assets 应过: ' + top.errors.join('; '));
assert.ok(Array.isArray(top.gameDefinition.assets), '顶层 assets 应抽入运行时 gameDefinition.assets(供 gdef.assets 消费)');
assert.equal(top.gameDefinition.assets[0].ref, 'mat-001', 'ref 透传保真');
assert.equal(top.gameDefinition.assets[0].category, 'sprite', 'category 透传保真');
// 容差:sp 直传时 assets 内嵌 gameDefinition 也回退抽取(模型漂移兜底)
const nested = validateSourceProject({ gameDefinition: { ...goldenSource().gameDefinition, assets: [{ id: 'x', category: 'ui', ref: 'r' }] } });
assert.equal(nested.ok, true, '内嵌 assets 容差应过');
assert.equal(nested.gameDefinition.assets[0].id, 'x');
// 错误转 repair 信号:非六类 / 非空串 ref(含对象 ref 绕过) / 缺 id
const bad = validateSourceProject({
gameDefinition: goldenSource().gameDefinition,
assets: [
{ id: 'a1', category: 'weapon', ref: 'x' }, // 非六类
{ id: 'a2', category: 'sprite' }, // 缺 ref
{ id: 'a3', category: 'sprite', ref: { mat: 1 } }, // 对象 ref(String({}) 非空,旧逻辑会绕过)
{ category: 'music', ref: 'm' }, // 缺 id
],
});
assert.equal(bad.ok, false);
assert.ok(bad.errors.some((e) => e.includes('非六类')), '应揪非六类 category');
assert.ok(bad.errors.filter((e) => e.includes('ref 须非空字符串')).length >= 2, '应揪缺 ref + 对象 ref 两条(非串防绕过)');
assert.ok(bad.errors.some((e) => e.includes('asset 缺 id')), '应揪缺 id');
// assets 非数组 → repair 信号
const notArr = validateSourceProject({ gameDefinition: goldenSource().gameDefinition, assets: { id: 'x' } });
assert.equal(notArr.ok, false);
assert.ok(notArr.errors.some((e) => e.includes('assets 非数组')), '应揪 assets 非数组');
// 端到端:合法顶层 assets 经 assemble 序列化进 GAME_DEFINITION(证 gd-runtime 可消费)
const factory = assembleFactoryFromSource({ gameDefinition: goldenSource().gameDefinition, assets: [{ id: 'hero-img', category: 'sprite', ref: 'mat-001' }] });
assert.ok(factory.includes('mat-001'), 'assemble 应把 assets.ref 序列化进 GAME_DEFINITION');
});
/* ② 装配产物对齐装载契约 + node --check 语法过 ────────────────────────────── */
test('U2-② assemble 产物含装载契约要件 + node --check 过', () => {
const src = assembleFactoryFromSource(goldenSource());
for (const marker of ['export default function createGame', 'createRuntime', 'init(boot)', 'update(dt)', 'render(g)', 'destroy()', '_forensicsView()', 'GAME_DEFINITION']) {
assert.ok(src.includes(marker), '产物应含: ' + marker);
}
assert.ok(src.includes("from '../../../src/host/gd-runtime.js'"), '缺省 import 路径应为生成目录相对路径');
// node --check:写临时文件 → 仅校语法(不解析 import)
const f = join(tmpdir(), 'gd-bfs-syntax-' + process.pid + '.mjs');
writeFileSync(f, src, 'utf8');
try {
const r = spawnSync(process.execPath, ['--check', f], { encoding: 'utf8' });
assert.equal(r.status, 0, 'node --check 应过: ' + (r.stderr || ''));
} finally { rmSync(f, { force: true }); }
});
test('U2-② assemble 校验失败 fail-loud(带 validationErrors)', () => {
try {
assembleFactoryFromSource({ gameDefinition: { entities: [] } });
assert.fail('应抛错');
} catch (err) {
assert.ok(Array.isArray(err.validationErrors), '错误应带 validationErrors 供 repair 回灌');
assert.ok(err.message.includes('校验失败'));
}
});
/* ③ 端到端功能证:装配文本经 createRuntime 真跑到 win(本机即证装配链可玩) ──────── */
test('U2-③ 装配产物经 createRuntime 真跑到 win(功能证)', async () => {
// runtimeImportPath 指向本仓真 gd-runtime.js(file:// 绝对),临时工厂可直接 import 运行。
const runtimePath = new URL('./gd-runtime.js', import.meta.url).href;
const src = assembleFactoryFromSource(goldenSource(), { runtimeImportPath: runtimePath });
const f = join(tmpdir(), 'gd-bfs-fn-' + process.pid + '.mjs');
writeFileSync(f, src, 'utf8');
try {
const mod = await import(pathToFileURL(f).href);
const createGame = mod.default;
// mock 引擎记 fx 调用(验 behavior 内 rt.fx.burst 真接线)
const emitterCalls = [];
const bundle = createHostDevContext({
seed: 7,
engineFactory: () => ({
particles: { spawnEmitter: (s) => { emitterCalls.push(s); return { isActive: () => false, stop() {} }; } },
audio: { synth: { synthSfx: () => [0], synthSong: () => null } },
math: { lerp: (a, b, p) => a + (b - a) * p, smoothStep: (p) => p },
}),
});
const game = createGame({ seed: 7 });
game.init({ ctx: bundle.context, mainContext: null, canvas: null, seed: 7 });
const fv = game._forensicsView();
assert.equal(typeof fv.state, 'function', '_forensicsView 应返 {state:fn}(取证契约)');
assert.equal(fv.state().phase, 'playing', 'init 后应 playing');
// 推进帧(dt=0.2)直至 win(0.5s 后加分 → rule 胜)
for (let i = 0; i < 10 && fv.state().phase === 'playing'; i++) game.update(0.2);
const st = fv.state();
assert.equal(st.phase, 'gameover', '应跑到终态');
assert.equal(st.result, 'win', '应胜(rule score>=1)');
assert.equal(st.score, 1);
assert.ok(emitterCalls.length >= 1, 'behavior 内 rt.fx.burst 应真触发引擎粒子');
// 渲染不抛(mock g 记账)
const calls = { fillRect: 0 };
const g = { fillStyle: '', font: '', textAlign: '', fillRect: () => { calls.fillRect++; }, beginPath() {}, arc() {}, fill() {}, fillText() {} };
assert.doesNotThrow(() => game.render(g));
assert.ok(calls.fillRect >= 1, '声明式渲染应出 rect');
assert.doesNotThrow(() => game.destroy());
} finally { rmSync(f, { force: true }); }
});
/* 工具:包一个最小合法 gameDefinition + 注入 behavior/rule 用于扫描测试。 */
function withBehavior(code) {
return { gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }], behaviors: [{ id: 'b', trigger: 'update', code }] } };
}
/* 评审修复(Med):非数组漂移转结构化 validationErrors(非裸 TypeError)。 */
test('U2-修 非数组 components/entities 转结构化错', () => {
const r = validateSourceProject({ gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }], components: { bad: 1 } } });
assert.equal(r.ok, false);
assert.ok(r.errors.some((e) => e.includes('非数组')));
try { assembleFactoryFromSource({ gameDefinition: { entities: {}, components: {} } }); assert.fail('应抛'); }
catch (err) { assert.ok(Array.isArray(err.validationErrors), '应带 validationErrors 非裸 TypeError'); }
});
/* 评审修复(Med):behavior 非字符串/空 code + 重复实体 id。 */
test('U2-修 非字符串/空 code + 重复 id 揪错', () => {
assert.ok(validateSourceProject(withBehavior(12345)).errors.some((e) => e.includes('缺逻辑')), '数字 code 应揪');
assert.ok(validateSourceProject(withBehavior(' ')).errors.some((e) => e.includes('缺逻辑')), '空白 code 应揪');
assert.ok(validateSourceProject({ gameDefinition: { entities: [
{ id: 'x', transform: { position: { x: 0, y: 0 } } }, { id: 'x', transform: { position: { x: 1, y: 1 } } },
] } }).errors.some((e) => e.includes('重复')), '重复 id 应揪');
});
/* 评审修复(keystone):静态扫描边界拦截危险逻辑 → repair 信号(治死循环/沙箱逃逸/确定性逃逸/condition 副作用)。 */
test('U2-修 静态扫描拦截危险逻辑', () => {
assert.ok(validateSourceProject(withBehavior('rt.addScore(Math.random())')).errors.some((e) => e.includes('Math.random')));
assert.ok(validateSourceProject(withBehavior('while(true){ rt.addScore(1); }')).errors.some((e) => e.includes('while(true)')));
assert.ok(validateSourceProject(withBehavior('process.exit(0)')).errors.some((e) => e.includes('process')));
assert.ok(validateSourceProject(withBehavior('const f=(0).constructor.constructor("x")()')).errors.some((e) => e.includes('constructor')));
// condition 副作用拦截 + 正常 condition 不误伤
const condWin = { gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }], rules: [{ id: 'r', condition: 'rt.win()', outcome: 'win' }] } };
assert.ok(validateSourceProject(condWin).errors.some((e) => e.includes('win()')), 'condition 调 win() 应拦');
const condStmt = { gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }], rules: [{ id: 'r', condition: 'rt.setScore(9); true', outcome: 'win' }] } };
assert.ok(validateSourceProject(condStmt).errors.length > 0, 'condition 含 ;/变更 应拦');
assert.equal(validateSourceProject({ gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }], rules: [{ id: 'r', condition: 'rt.score >= 3', outcome: 'win' }] } }).ok, true, 'rt.score>=3 不误伤');
});
/* 评审修复(Med):循环引用 fail-loud + </script> inline 安全转义。 */
test('U2-修 循环引用 fail-loud + </script> 转义', () => {
const cyc = { gameDefinition: { entities: [{ id: 'e', transform: { position: { x: 0, y: 0 } } }] } };
cyc.gameDefinition.entities[0].self = cyc.gameDefinition.entities[0]; // 制造循环
try { assembleFactoryFromSource(cyc); assert.fail('应抛'); }
catch (err) { assert.ok(Array.isArray(err.validationErrors) && err.validationErrors.some((e) => e.includes('循环')), '循环引用应结构化失败'); }
const src = assembleFactoryFromSource(withBehavior("rt.addScore(0); /* </script> */"));
assert.ok(!src.includes('</script>'), '不应含裸 </script>(防内联 script 提前闭合)');
assert.ok(src.includes('\\u003c/script>'), '应转义为 \\u003c');
});

View File

@ -1,53 +0,0 @@
// Plan A·Phase 3 坑门测试:验证 build-from-source.mjs 新增的「玩法哑火坑」静态门
// ① 正例:三族坑(.tags Set 误用 / 漏 rt. 前缀裸调 / self 跨 behavior 泄漏)被拦
// ② 零误伤铁律:对 3 个金标准过门纯净源(gd-breakout/tictactoe/simon)不得触任何坑门(否则会拦掉本可玩的游戏)
import { test } from 'node:test';
import assert from 'node:assert';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { validateSourceProject } from './build-from-source.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const WG1 = join(HERE, '../../games/_wg1-gen');
// 最小合法 gamedef + 注入一条待测 behavior code(隔离坑门,避免被无关结构错误干扰)。
function gdWith(code) {
return {
components: [{ id: 'bg', kind: 'render', shape: 'fill', color: '#101028' }],
entities: [{ id: 'p', transform: { position: { x: 0, y: 0 } }, components: ['bg'] }],
behaviors: [{ id: 'b', trigger: 'update', code }],
scenes: [{ id: 's', entityRefs: ['p'] }],
rules: [],
};
}
const errs = (code) => validateSourceProject(gdWith(code)).errors;
const hit = (code, marker) => errs(code).some((m) => m.includes(marker));
test('Phase3 坑门①: .tags 是 Set 误用数组法/length 被拦', () => {
assert.ok(hit("for(const e of rt.query('x')){ if(e.tags.includes('y')) rt.destroy(e); }", '.tags 是 Set'));
assert.ok(hit("const e=rt.getEntity('p'); if(e && e.tags.length>0){}", '.tags 是 Set'));
// 正例:e.tags.has(...) 是正确用法,不该被拦
assert.ok(!hit("const e=rt.getEntity('p'); if(e && e.tags.has('foe')){}", '.tags 是 Set'));
});
test('Phase3 坑门②: 漏 rt. 前缀的裸引擎调被拦', () => {
assert.ok(hit("const e=getEntity('p'); if(e) e.x+=1;", 'ReferenceError'));
assert.ok(hit("const eng=getEngine(); if(eng) eng.foo();", 'ReferenceError'));
assert.ok(hit("addScore(1);", 'ReferenceError'));
// 正例:rt.getEntity / rt.addScore 不该被拦
assert.ok(!hit("const e=rt.getEntity('p'); rt.addScore(1);", 'ReferenceError'));
});
// 坑门③ self 跨 behavior 泄漏:已评估但【暂不作硬门】——会误伤 gd-tictactoe(self.board)/gd-simon(self.phase/seq) 等
// 「带此 bug 却仍过九门」的款(破零误伤)。留待非阻塞 warning 通道落地后回喂 repair。详见 build-from-source.mjs 注释。
// 零误伤铁律:3 个金标准过门纯净源不得触任何坑门(误伤=拦掉本可玩的游戏,比漏报更糟)。
const PIT_MARKERS = ['.tags 是 Set', 'ReferenceError'];
for (const g of ['gd-breakout', 'gd-tictactoe', 'gd-simon']) {
test('Phase3 零误伤: ' + g + ' 不触任何坑门', () => {
const sp = JSON.parse(readFileSync(join(WG1, g, 'source.json'), 'utf8'));
const pit = validateSourceProject(sp).errors.filter((m) => PIT_MARKERS.some((k) => m.includes(k)));
assert.deepStrictEqual(pit, [], g + ' 被坑门误伤: ' + pit.join(' || '));
});
}

View File

@ -1,197 +0,0 @@
/**
* gd-archetypes.test.mjs —— U1 收敛判据:rt 约定跨品类表达力覆盖(plan 2026-06-18-001 U1 验收)
* 跑:cd game-runtime && node --test src/host/gd-archetypes.test.mjs(亦被 npm test 收)
*
* 【为什么】plan U1 验收含「3-5 类游戏 behaviors 用约定全跑通、零约定外 helper 漂移(收敛判据)」。
* 本文件手写 4 类原型 gameDefinition(覆盖 profile 谱:realtime-continuous / event-discrete /
* realtime-spawn / realtime-physics),经 createRuntime 真驱动,钉死收敛判据:
* **headline 断言 = runtime.errors() 为空**(零约定外 helper 漂移 / 零运行时抛错)+ 各原型出
* 合理状态转移(移动 / 计分 / 终态 latch)。
*
* 【定位】此为「约定表达力」证(手写,非真实模型输出)——证 rt 面够表达目标品类谱;真实模型输出的
* idiom 漂移收集随 U3(GAMEDEF_SYSTEM prompt + NEWAPI_KEY)落。这 4 份金样并作 U3 prompt few-shot 库。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHostDevContext } from '../core/plugin.js';
import { createRuntime } from './gd-runtime.js';
/** 起受控运行时(注 mock 引擎记 fx)。 */
function boot(seed) {
const fx = [];
const bundle = createHostDevContext({
seed,
engineFactory: () => ({
particles: { spawnEmitter: (s) => { fx.push(s); return { isActive: () => false, stop() {} }; } },
audio: { synth: { synthSfx: () => [0], synthSong: () => null } },
math: { lerp: (a, b, p) => a + (b - a) * p, smoothStep: (p) => p },
}),
});
return { bundle, boot: { ctx: bundle.context, mainContext: null, canvas: null, seed }, fx };
}
/* ── 原型① paddle-intercept(打砖块/pong 谱:realtime + 连续输入 + 反弹 + lose latch) ── */
test('原型① paddle-intercept 约定跑通', () => {
const env = boot(11);
const gdef = {
profile: { tickModel: 'realtime', inputModel: 'continuous', progressModel: 'metric' },
components: [
{ id: 'r-paddle', kind: 'render', shape: 'rect', color: '#0ff', w: 80, h: 16 },
{ id: 'r-ball', kind: 'render', shape: 'circle', color: '#fff', r: 10 },
],
entities: [
{ id: 'paddle', transform: { position: { x: 195, y: 800 } }, components: ['r-paddle'] },
{ id: 'ball', transform: { position: { x: 60, y: 400 } }, vx: 0, vy: 260, components: ['r-ball'] },
],
behaviors: [{
id: 'play', trigger: 'update', code: `
const paddle = rt.getEntity('paddle'), ball = rt.getEntity('ball');
if (!paddle || !ball) return;
// 连续输入:键盘左右 / 指针 x 控板
if (rt.input.isDown('ArrowLeft')) paddle.x -= 300*dt;
if (rt.input.isDown('ArrowRight')) paddle.x += 300*dt;
if (rt.input.pointer.down) paddle.x = rt.input.pointer.x;
paddle.x = rt.clamp(paddle.x, 40, rt.view.w-40);
// 球积分 + 墙反弹
ball.x += ball.vx*dt; ball.y += ball.vy*dt;
if (ball.x < 10 || ball.x > rt.view.w-10) ball.vx = -ball.vx;
if (ball.y < 10) ball.vy = Math.abs(ball.vy);
// 板拦截
if (ball.vy > 0 && rt.overlap({x:ball.x,y:ball.y,w:20,h:20}, {x:paddle.x,y:paddle.y,w:80,h:16})) {
ball.vy = -Math.abs(ball.vy); rt.addScore(1); rt.fx.burst(ball.x, ball.y, '#0f0');
}
// 落底 → lose(置 latch 终态)
if (ball.y > rt.view.h) rt.lose();
`,
}],
scenes: [{ id: 's', entityRefs: ['paddle', 'ball'] }],
rules: [],
};
const r = createRuntime(env.boot, gdef);
r.init();
// 不输入 → 球落底 → lose
for (let i = 0; i < 60 && r.state().phase === 'playing'; i++) r.update(1 / 30);
assert.deepEqual(r.errors(), [], '零约定外 helper 漂移 / 零抛错');
assert.equal(r.state().phase, 'gameover');
assert.equal(r.state().result, 'lose', '无输入应落底 lose');
// 输入响应:另起一局,按右键 → 板右移
const env2 = boot(11);
const r2 = createRuntime(env2.boot, gdef);
r2.init();
const x0 = r2.rt.getEntity('paddle').x;
env2.bundle.inputBridge._emit('keydown', { key: 'ArrowRight' });
r2.update(0.1);
assert.ok(r2.rt.getEntity('paddle').x > x0, '按右键板应右移(连续输入接线)');
});
/* ── 原型② event-clicker(放置/点击器谱:event + 离散点击 + rule 胜) ── */
test('原型② event-clicker 约定跑通', () => {
const env = boot(2);
const gdef = {
profile: { tickModel: 'event', inputModel: 'discrete-choice', progressModel: 'metric' },
components: [{ id: 'r-btn', kind: 'render', shape: 'rect', color: '#fa0', w: 200, h: 80 }],
entities: [{ id: 'btn', transform: { position: { x: 195, y: 422 } }, components: ['r-btn'] }],
behaviors: [{
id: 'click', trigger: 'input', code: `
if (rt.input.justTapped()) { rt.addScore(1); rt.fx.beep('score'); }
`,
}],
scenes: [{ id: 's', entityRefs: ['btn'] }],
rules: [{ id: 'win', condition: 'rt.score >= 3', outcome: 'win' }],
};
const r = createRuntime(env.boot, gdef);
r.init();
for (let i = 0; i < 3; i++) { env.bundle.inputBridge._emit('pointerdown', { x: 195, y: 422 }); r.update(0.05); }
assert.deepEqual(r.errors(), []);
assert.equal(r.state().score, 3);
assert.equal(r.state().result, 'win', '点 3 次 → rule 胜');
});
/* ── 原型③ dodge-spawn(躲避谱:realtime + spawn/query/random + overlap lose) ── */
test('原型③ dodge-spawn 约定跑通', () => {
const env = boot(3);
const gdef = {
profile: { tickModel: 'realtime', inputModel: 'continuous', progressModel: 'metric' },
components: [
{ id: 'r-player', kind: 'render', shape: 'rect', color: '#0f0', w: 30, h: 30 },
{ id: 'r-enemy', kind: 'render', shape: 'circle', color: '#f33', r: 14 },
],
entities: [{ id: 'player', transform: { position: { x: 195, y: 700 } }, components: ['r-player'] }],
behaviors: [{
id: 'spawner', trigger: 'update', code: `
self.t = (self.t||0) + dt;
const player = rt.getEntity('player');
if (player) { if (rt.input.isDown('ArrowLeft')) player.x -= 240*dt; if (rt.input.isDown('ArrowRight')) player.x += 240*dt; }
// 定时随机生成下落敌人(rt.spawn + rt.random)
if (self.t >= 0.25) { self.t = 0; rt.spawn({ x: rt.randRange(20, rt.view.w-20), y: -20, vy: 320, tags: ['enemy'], components: [{ kind:'render', shape:'circle', color:'#f33', r:14 }] }); }
// 敌人下落 + 出界回收 + 撞玩家 lose(query/overlap)
for (const e of rt.query('enemy')) {
e.y += e.vy*dt;
if (e.y > rt.view.h+30) rt.destroy(e);
else if (player && rt.overlap({x:e.x,y:e.y,w:28,h:28}, {x:player.x,y:player.y,w:30,h:30})) { rt.fx.burst(e.x,e.y,'#f00'); rt.lose(); }
}
`,
}],
scenes: [{ id: 's', entityRefs: ['player'] }],
rules: [],
};
const r = createRuntime(env.boot, gdef);
r.init();
// 玩家不动停在敌人流里 → 早晚被撞 lose;先确认 spawn 真发生
let sawEnemies = false;
for (let i = 0; i < 200 && r.state().phase === 'playing'; i++) { r.update(1 / 30); if (r.rt.query('enemy').length > 0) sawEnemies = true; }
assert.deepEqual(r.errors(), [], '零约定外 helper 漂移 / 零抛错');
assert.ok(sawEnemies, 'spawn 应真生成敌人');
assert.equal(r.state().result, 'lose', '不动应被撞 lose');
});
/* ── 原型④ runner(跑酷谱:realtime + physics 组件重力 + 跳跃输入 + 障碍碰撞 lose) ── */
test('原型④ runner 约定跑通', () => {
const env = boot(4);
const gdef = {
profile: { tickModel: 'realtime', inputModel: 'continuous', progressModel: 'metric' },
components: [
{ id: 'phys', kind: 'physics', gravity: 1600 },
{ id: 'r-hero', kind: 'render', shape: 'rect', color: '#fff', w: 28, h: 28 },
{ id: 'r-obs', kind: 'render', shape: 'rect', color: '#f80', w: 24, h: 40 },
],
entities: [{ id: 'hero', transform: { position: { x: 90, y: 700 } }, vx: 0, vy: 0, components: ['phys', 'r-hero'] }],
behaviors: [{
id: 'run', trigger: 'update', code: `
const hero = rt.getEntity('hero');
const GROUND = 700;
if (hero) {
// 跳跃(justTapped/space 起跳;physics 组件自动积分重力)
if ((rt.input.justTapped() || rt.input.justPressed('Space')) && hero.y >= GROUND-1) hero.vy = -640;
if (hero.y > GROUND) { hero.y = GROUND; hero.vy = 0; } // 落地
}
// 障碍流(rt.spawn + 计分)
self.t = (self.t||0) + dt;
if (self.t >= 0.7) { self.t = 0; rt.spawn({ x: rt.view.w+20, y: GROUND, vx: -260, tags: ['obs'], components: [{ kind:'render', shape:'rect', color:'#f80', w:24, h:40 }] }); }
for (const o of rt.query('obs')) {
o.x += o.vx*dt;
if (o.x < -30) { rt.destroy(o); rt.addScore(1); }
else if (hero && rt.overlap({x:o.x,y:o.y,w:24,h:40}, {x:hero.x,y:hero.y,w:28,h:28})) { rt.fx.burst(o.x,o.y,'#f00'); rt.lose(); }
}
`,
}],
scenes: [{ id: 's', entityRefs: ['hero'] }],
rules: [],
};
const r = createRuntime(env.boot, gdef);
r.init();
const hero = r.rt.getEntity('hero');
// 验 physics 重力积分:从空中起,落到地面
hero.y = 600;
r.update(0.1);
assert.ok(hero.vy > 0, 'physics 组件应积分重力(vy 增)');
// 跳跃响应
hero.y = 700; hero.vy = 0;
env.bundle.inputBridge._emit('pointerdown', { x: 90, y: 700 });
r.update(1 / 60);
assert.ok(r.rt.getEntity('hero').vy < 0, 'tap 应起跳(vy 转负)');
// 长跑直至撞障碍 lose(不跳)
for (let i = 0; i < 400 && r.state().phase === 'playing'; i++) r.update(1 / 60);
assert.deepEqual(r.errors(), [], '零约定外 helper 漂移 / 零抛错');
assert.equal(r.state().result, 'lose', '不跳应撞障碍 lose');
});

View File

@ -1,493 +0,0 @@
/**
* gd-runtime.js —— 运行时访问约定基座(2D 适配器的 behavior 侧 API · core-runtime-v0)
* owner:本 session(引擎线 · plan 2026-06-18-001 U1) | 消费方:U2 build-from-source 装配器 + U3 generate 真产 gameDefinition
*
* ════════════════════════════════════════════════════════════════════════════
* 【职责】把已冻结的受控面 `boot.ctx`(PluginContext,api.d.ts)包装成「声明式 gameDefinition」
* 友好的 2D 运行时:createRuntime(boot, gameDefinition) → 建可变世界(entities 实例)+ 产 `rt` 对象。
* `rt` 即 U2 装配器编译每个 behavior 时 `new Function('rt','self','dt', code)` 注入的那个 rt——
* behaviors 经 rt 操作世界(不写画、不触裸引擎/DOM),声明式渲染器逐实体按 render 组件出图。
*
* 【两阶段生命周期定位】gameDefinition = 开发态权威源(声明式 + behaviors 各带逻辑 JS);
* 本运行时是「源 → 可玩」的运行期装配核(改源不改打包产物,见 source-project.schema.json)。
*
* 【受控面铁律(对齐 api.d.ts / game-host.d.ts)】
* - 时间/随机一律走 boot.ctx(确定性可复现);rt 内部**禁 Math.random / Date.now**。
* - 引擎能力(粒子/音频)一律经 boot.ctx.getEngine()——满 F 门「真接线」;无引擎时优雅 no-op。
* - 输入一律经 boot.ctx.getInput() 订阅,rt 侧转成 behaviors 友好的**轮询面**(isDown/justTapped/pointer)。
*
* 【迭代状态·别冻早(plan U1)】本文件是运行时约定 **v0**;契约(runtime-api-2d.d.ts)待收 3-5 类真实
* 模型输出补 idiom、1-2 轮稳定后再正式化。当前以「能跑能测」为准,接口可能随真实输出微调。
* ════════════════════════════════════════════════════════════════════════════
*/
'use strict';
/** 视口常量(与 generic-host-config 的 390×844 移动竖屏基准同口径;behaviors 边界判断 / 渲染兜底用)。 */
const VIEW_W = 390;
const VIEW_H = 844;
/** sprite 缺省尺寸(U1·略大于 rect 缺省 20,sprite 通常占位更大;评审 P3 具名化免魔法数)。 */
const DEFAULT_SPRITE_SIZE = 32;
/** rt.fx.beep 音效参数包(ZzFX 参数;按 kind 取,缺省走 default)。仅为「真调引擎合成核」满 F 门,不强求好听。 */
const BEEP_PARAMS = {
default: [1, 0.05, 400, , , 0.1, , 1.2],
score: [1, 0.05, 800, , , 0.15, , 0.5],
hit: [1, 0.05, 300, , , 0.1, , 1.0],
lose: [1, 0.05, 150, 0.5, 0.3, 0.1, , 1.3],
win: [1, 0.05, 600, , 0.2, 0.2, , 0.6],
};
/* ── 小工具(纯函数,确定性) ───────────────────────────────────────────── */
function num(v) { return typeof v === 'number' && isFinite(v) ? v : 0; }
function toArray(v) { return Array.isArray(v) ? v : []; }
/** 归一化颜色:接受 '#rrggbb' / {r,g,b,a}(0..1);产出引擎归一化 {r,g,b,a}。缺省不透明白。 */
function toColor(c) {
if (c && typeof c === 'object') {
return { r: num(c.r), g: num(c.g), b: num(c.b), a: c.a == null ? 1 : num(c.a) };
}
if (typeof c === 'string' && c[0] === '#') {
const h = c.length === 4
? c.slice(1).split('').map(x => x + x).join('') // #abc → aabbcc
: c.slice(1);
const n = parseInt(h, 16);
if (isFinite(n)) return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255, a: 1 };
}
return { r: 1, g: 1, b: 1, a: 1 };
}
/**
* 建运行时:实例化 entities、编译 behaviors/rules、产 rt 对象。
* @param {{ctx:object, mainContext?:object, canvas?:object, seed?:number}} boot 宿主注入的受控启动上下文(game-host.d.ts GameHostBootContext)。
* @param {{entities?:Array, components?:Array, behaviors?:Array, scenes?:Array, rules?:Array}} gameDefinition 声明式领域模型(source-project.schema.json#/properties/gameDefinition)。
* @returns {{rt:object, init:Function, update:Function, render:Function, state:Function, destroy:Function, errors:Function}}
* 运行时句柄:U2 装配器产出的工厂经它实现 GameInstance 的 init/update/render/destroy/_forensicsView。
*/
export function createRuntime(boot, gameDefinition) {
const ctx = boot && boot.ctx;
// 错误路径必硬失败:绕过受控面即破「引擎可换」铁律,宁可 fail-loud 不静默降级。
if (!ctx || typeof ctx.getInput !== 'function') {
throw new Error('[gd-runtime] boot.ctx 缺失或非法:运行时必须经受控面(PluginContext)注入,不得绕过 ctx 触裸引擎/DOM');
}
const gdef = gameDefinition || {};
// 组件定义按 id 索引(entity.components 是 id 引用数组,schema#/$defs/entity)。
const componentDefs = new Map();
for (const c of toArray(gdef.components)) { if (c && c.id != null) componentDefs.set(String(c.id), c); }
// 资产定义按 id 索引(U1:render 组件 shape:'sprite' 经 asset/ref 引用六类资产;缺省空=无美术游戏)。
const assetDefs = new Map();
for (const a of toArray(gdef.assets)) { if (a && a.id != null) assetDefs.set(String(a.id), a); }
// 宿主预载图源(additive·game-host.d.ts boot.assets,可空):assetId → {image} 受控面图源。
// 受控面铁律:sprite 图源**只取宿主 init 预载**,运行时绝不触 new Image()/DOM(保「引擎可换」+ node 单测可跑)。
// 缺图源(宿主预载未 wire / 资产未载)→ drawSprite 降级类目色占位(前向兼容,不崩不空屏)。
const bootAssets = (boot && boot.assets && typeof boot.assets === 'object') ? boot.assets : null;
/* ── 世界状态 ───────────────────────────────────────────────────────── */
let entities = []; // 当前世界实体(含已 spawn;reap 清 alive=false)
const byId = new Map(); // id → entity
let spawnSeq = 0; // 匿名 spawn 实体的自增序号
let score = 0;
let phase = 'booting'; // booting → playing → gameover(gameover 为 latch 终态,不可逆)
let result = null; // 'win' | 'lose' | null
let elapsed = 0; // 相对游戏时间(秒,累加 dt)——禁用绝对钟比较(取证可复现)
let curDt = 0; // 本帧 dt(rt.dt 经 getter 读它)
const subs = []; // 输入订阅句柄(destroy 时注销,防泄漏)
const errors = []; // behavior/rule 运行错误(回灌为生成质量信号,供 repair 读)
/* ── 输入轮询态(订阅受控事件 → 维护,behaviors 经 rt.input 轮询) ──────── */
const downKeys = new Set(); // 当前按住的键
const justPressedKeys = new Set(); // 本帧刚按下的键(帧末清)
let pointer = { x: 0, y: 0, down: false };
let tappedThisFrame = false; // 本帧是否发生 pointerdown(帧末清)
subs.push(ctx.getInput().on('keydown', (e) => { downKeys.add(e.key); justPressedKeys.add(e.key); }));
subs.push(ctx.getInput().on('keyup', (e) => { downKeys.delete(e.key); }));
subs.push(ctx.getInput().on('pointerdown', (e) => { pointer = { x: e.x, y: e.y, down: true }; tappedThisFrame = true; }));
subs.push(ctx.getInput().on('pointermove', (e) => { pointer.x = e.x; pointer.y = e.y; }));
subs.push(ctx.getInput().on('pointerup', () => { pointer.down = false; }));
/* ── 实体工厂 ───────────────────────────────────────────────────────── */
/** 把组件引用/内联组件解析为组件对象数组(字符串 → 查 componentDefs;对象 → 内联用,spawn 走此路)。 */
function resolveComponents(refs) {
const out = [];
for (const r of toArray(refs)) {
if (typeof r === 'string') { const def = componentDefs.get(r); if (def) out.push(def); }
else if (r && typeof r === 'object') { out.push(r); }
}
return out;
}
/** 取实体首个指定 kind 的组件(无则 null)。 */
function componentOf(e, kind) {
for (const c of e.components) { if (c && c.kind === kind) return c; }
return null;
}
/** 建实体实例:transform.position → x/y;vx/vy/tags/components 归一化;带 get/set/destroy。 */
function makeEntity(spec) {
const pos = (spec.transform && spec.transform.position) || { x: spec.x, y: spec.y };
const e = {
id: spec.id != null ? String(spec.id) : ('_e' + (spawnSeq++)),
x: num(pos && pos.x), y: num(pos && pos.y),
vx: num(spec.vx), vy: num(spec.vy),
alive: true,
tags: new Set(toArray(spec.tags).map(String)),
components: resolveComponents(spec.components),
get(k) { return this[k]; },
set(k, v) { this[k] = v; return v; },
destroy() { this.alive = false; },
};
// 去脚枪(Plan A·S2):把 spec/entities 字面量上的自定义标量字段(gridX/colorIdx/scored/idx/hp 等)
// 拷到实体,使「spec 带自定义字段」的模型自然假设成真(原仅留 id/x/y/vx/vy/tags/components → §0
// 跨款最大失败簇:自定义状态读到 undefined→逻辑空转/冻屏)。仅标量(num/str/bool)、不覆盖保留键/已设键;
// 取证 entityView 白名单投影,故自定义字段不外泄取证。对象/数组不拷(避免共享引用意外)。
// M3 修(CodeRabbit 2026-06-20):w/h/r 列入保留键【不拷顶层】——它们是 rt.overlap 的碰撞尺寸(读实体顶层、缺省 16),
// 拷上去会静默改存量游戏命中框;且违反 prompt 契约"碰撞尺寸只写 render 组件、顶层 e.w 永 undefined"。
const RESERVED = { id: 1, transform: 1, x: 1, y: 1, vx: 1, vy: 1, tags: 1, components: 1, alive: 1, get: 1, set: 1, destroy: 1, w: 1, h: 1, r: 1 };
for (const k in spec) {
if (RESERVED[k] || e[k] !== undefined) continue;
const t = typeof spec[k];
if (t === 'number' || t === 'string' || t === 'boolean') e[k] = spec[k];
}
return e;
}
/** 实体上限(防失控 spawn 静默膨胀;超限记错误信号并拒新增,转成 repair 可读信号)。 */
const ENTITY_CAP = 5000;
function addEntity(e) {
if (entities.length >= ENTITY_CAP) {
if (errors.length < 50) errors.push('实体数超上限 ' + ENTITY_CAP + '(疑失控 spawn),拒新增: ' + e.id);
return e;
}
// 重复 id:保留先到者可寻址(byId 仅在无映射时写),记质量信号;杜绝「后者覆盖 byId、reap 误删致先到者失联」。
if (byId.has(e.id)) { if (errors.length < 50) errors.push('实体 id 重复: ' + e.id + '(先到者保持可寻址)'); }
else { byId.set(e.id, e); }
entities.push(e);
return e;
}
/* ── rt 对象(behavior 侧 API · 注入 new Function('rt','self','dt',code)) ── */
/** latch 终态(win/lose 一旦置定不可逆——H 门「终态驻留」硬约束)。 */
function latch(r) { if (phase === 'gameover') return; phase = 'gameover'; result = r; }
/** 经 ctx.getEngine() 真调引擎粒子(满 F 门「真接线」;无引擎优雅 no-op,不留 sim 降级)。 */
function fxBurst(x, y, color) {
const eng = ctx.getEngine && ctx.getEngine();
if (!eng || !eng.particles) return;
const c = toColor(color);
try {
eng.particles.spawnEmitter({
pos: { x: num(x), y: num(y) }, count: 20, speed: 120, particleTime: 0.4,
colorStart: c, colorEnd: { r: c.r, g: c.g, b: c.b, a: 0 },
});
} catch (_) { /* 引擎调用失败不连坐游戏帧 */ }
}
/** 经 ctx.getEngine() 真调引擎合成核(满 F 门;无引擎 no-op)。 */
function fxBeep(kind) {
const eng = ctx.getEngine && ctx.getEngine();
if (!eng || !eng.audio || !eng.audio.synth) return;
try { eng.audio.synth.synthSfx(BEEP_PARAMS[kind] || BEEP_PARAMS.default); } catch (_) { /* no-op */ }
}
/** AABB 重叠(中心点 + 半宽半高;缺省全尺寸 16=半 8)。a/b = {x,y,w?,h?}。显式 0 须被尊重(不被 truthy 误当默认)。 */
function overlap(a, b) {
if (!a || !b) return false;
const sz = (v) => (v == null ? 16 : num(v));
const ahw = sz(a.w) / 2, ahh = sz(a.h) / 2, bhw = sz(b.w) / 2, bhh = sz(b.h) / 2;
return Math.abs(num(a.x) - num(b.x)) <= ahw + bhw && Math.abs(num(a.y) - num(b.y)) <= ahh + bhh;
}
const rt = {
/* 实体面 */
getEntity(id) { const e = byId.get(String(id)); return e && e.alive ? e : null; },
entities() { return entities.filter((e) => e.alive); },
/** 按 tag 或组件 id/kind 查询活实体(gameplay 主用 tag)。 */
query(name) {
return entities.filter((e) => e.alive && (e.tags.has(name) || e.components.some((c) => c && (c.id === name || c.kind === name))));
},
/** 生成实体:{x,y,vx,vy,tags,components}(components 可内联对象)。返回实体引用。 */
spawn(spec) { return addEntity(makeEntity(spec || {})); },
destroy(e) { if (e) e.alive = false; },
/* 输入面(轮询) */
input: {
isDown: (k) => downKeys.has(k),
justPressed: (k) => justPressedKeys.has(k),
justTapped: () => tappedThisFrame,
get pointer() { return { x: pointer.x, y: pointer.y, down: pointer.down }; }, // 返副本:防 behavior 改内部 pointer 态
},
/* 时间/随机面(走 boot.ctx 受控种子,确定性;禁 Math.random/Date.now)。
不暴露 nowMs(墙钟=非确定性)——behaviors 一律 now()(相对 elapsed)/dt,保取证可复现。 */
time: { now: () => elapsed },
get dt() { return curDt; },
random: () => ctx.random.next(),
randRange: (a, b) => ctx.random.range(a, b),
randInt: (a, b) => Math.floor(ctx.random.range(a, b + 1)),
/* 分数胜负面(win/lose 置 latch 终态;addScore 守 num 防 NaN/字符串污染 score 契约——score 是 behaviors/rules/取证共享面)。 */
get score() { return score; },
addScore: (n) => { score = num(score) + (n == null ? 1 : num(n)); return score; },
setScore: (n) => { score = num(n); return score; },
win: () => latch('win'),
lose: () => latch('lose'),
/* 工具/特效面 */
clamp: (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v),
dist: (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by),
overlap,
fx: { burst: (x, y, color) => fxBurst(x, y, color), beep: (kind) => fxBeep(kind) },
/* 视口(behaviors 边界判断用) */
view: { w: VIEW_W, h: VIEW_H },
};
// 冻结 rt 纯 API 面(input/fx/time/view + rt 本体)——防某 behavior 改写共享 API(如 rt.input.isDown)串味全局;
// 实体仍可变(behaviors 合法写 e.x),只冻 API 命名空间。getter(dt/score)/方法仍可调;写入静默失效或抛,不腐蚀内部态。
Object.freeze(rt.input);
Object.freeze(rt.fx);
Object.freeze(rt.time);
Object.freeze(rt.view);
Object.freeze(rt);
/* ── 编译 behaviors(各带逻辑 JS:behavior.code 规范名 / behavior.js 兼容别名) ── */
// self = 本 behavior 的持久局部态(跨帧保留,存计时器/累加器等);dt = 本帧秒步。
const initBehaviors = []; // trigger==='init':世界建好后跑一次
const tickBehaviors = []; // 其余(update/input/collision/timer):每帧跑(behaviors 经 rt.input 轮询)
for (const b of toArray(gdef.behaviors)) {
const code = b && (b.code != null ? b.code : (b.js != null ? b.js : ''));
if (!code || typeof code !== 'string') continue;
let fn;
try { fn = new Function('rt', 'self', 'dt', code); }
catch (err) { errors.push('behavior[' + (b.id || '?') + '] 编译失败: ' + err.message); continue; }
const slot = { id: b.id, trigger: b.trigger, fn, self: {} };
(b.trigger === 'init' ? initBehaviors : tickBehaviors).push(slot);
}
function runBehavior(slot, dt) {
// behavior 抛错隔离 + 回灌质量信号(plan U2 step6:喂 repair feedback);单 behavior 错不连坐整帧。
try { slot.fn(rt, slot.self, dt); }
catch (err) { errors.push('behavior[' + (slot.id || '?') + '] 运行抛错: ' + err.message); }
}
/* ── 编译 rules(condition = JS 布尔表达式串,rt/self 在作用域内) ──────── */
const compiledRules = [];
for (const r of toArray(gdef.rules)) {
let cond;
try { cond = new Function('rt', 'self', 'return (' + (r.condition || 'false') + ');'); }
catch (err) { errors.push('rule[' + (r.id || '?') + '] 编译失败: ' + err.message); continue; }
compiledRules.push({ id: r.id, cond, outcome: r.outcome, self: {}, lastTrue: false });
}
function evalRules() {
for (const cr of compiledRules) {
let t = false;
try { t = !!cr.cond(rt, cr.self); } catch (err) { errors.push('rule[' + (cr.id || '?') + '] 运行抛错: ' + err.message); }
const rising = t && !cr.lastTrue; // 上升沿(score/advance 用,防每帧重复触发)
cr.lastTrue = t;
if (!t) continue;
if (cr.outcome === 'win') latch('win');
else if (cr.outcome === 'lose') latch('lose');
else if (cr.outcome === 'score') { if (rising) score += 1; }
// 'advance':多场景推进,v0 占位(单场景为主,后续迭代)。
}
}
/* ── 物理积分(opt-in:含 kind=physics 组件的实体每帧按 vx/vy(+gravity) 推进) ── */
function integratePhysics(dt) {
for (const e of entities) {
if (!e.alive) continue;
const phys = componentOf(e, 'physics');
if (!phys) continue;
if (phys.gravity) e.vy += num(phys.gravity) * dt;
e.x += e.vx * dt;
e.y += e.vy * dt;
}
}
/**
* 内置点击派发(plan U3 tap-targets 救场基元):本帧有 tap 时,点中**带 clickable 组件且未 occupied** 的实体 →
* 翻 occupied + spawn 标记(可见变化) + 计分 + fx。让离散点击类「声明 clickable 即可」,免模型写易错 tap-handler
* (实测便宜/强模型均常把 tap 门控在分离 state/计时器上→点不中)。clickable 组件:{kind:'clickable',score?,mark?:{shape,color,r/w/h},hitW?,hitH?}。
*/
function dispatchClicks() {
if (!tappedThisFrame) return;
for (const e of entities) {
if (!e.alive || e.occupied) continue;
const clk = componentOf(e, 'clickable');
if (!clk) continue;
const rc = componentOf(e, 'render');
const hw = num(clk.hitW != null ? clk.hitW : (rc && rc.w != null ? rc.w : (rc && rc.r != null ? rc.r * 2 : 80))) / 2;
const hh = num(clk.hitH != null ? clk.hitH : (rc && rc.h != null ? rc.h : (rc && rc.r != null ? rc.r * 2 : 80))) / 2;
if (Math.abs(pointer.x - e.x) <= hw && Math.abs(pointer.y - e.y) <= hh) {
e.occupied = true;
const mk = clk.mark;
if (mk && typeof mk === 'object') {
rt.spawn({ x: e.x, y: e.y, components: [{ kind: 'render', shape: mk.shape || 'circle', color: mk.color || '#ffcc00', r: num(mk.r != null ? mk.r : 28), w: mk.w, h: mk.h }] });
}
score = num(score) + num(clk.score != null ? clk.score : 1);
fxBurst(e.x, e.y, (mk && mk.color) || '#ffcc00');
fxBeep('score');
break; // 一次 tap 只触一个目标
}
}
}
/** 回收死实体(帧末统一清,避免迭代中改数组)。 */
function reap() {
let dirty = false;
for (const e of entities) {
if (!e.alive) {
if (byId.get(e.id) === e) byId.delete(e.id); // 仅当 byId 指向该死亡实例才删(保同 id 存活者可寻址)
dirty = true;
}
}
if (dirty) entities = entities.filter((e) => e.alive);
}
/* ── 场景:v0 取 scenes[0].entityRefs 过滤初始实体(缺省全量实例化) ──────── */
function sceneRefs() {
const scenes = toArray(gdef.scenes);
if (!scenes.length || !Array.isArray(scenes[0].entityRefs)) return null;
return new Set(scenes[0].entityRefs.map(String));
}
/* ── 生命周期:init / update / render / state / destroy ─────────────────── */
function init() {
const refs = sceneRefs();
for (const espec of toArray(gdef.entities)) {
if (refs && espec.id != null && !refs.has(String(espec.id))) continue;
addEntity(makeEntity(espec));
}
for (const slot of initBehaviors) runBehavior(slot, 0);
if (phase === 'booting') phase = 'playing';
}
function update(dt) {
curDt = num(dt);
if (phase === 'playing') { // latch 终态后停摆(H 门:终态驻留);但输入沿仍每帧清(见下)
for (const slot of tickBehaviors) { if (phase !== 'playing') break; runBehavior(slot, curDt); }
dispatchClicks(); // 内置 clickable 点击派发(tap-targets 声明式基元,免模型写 tap-handler)
integratePhysics(curDt);
evalRules();
reap();
elapsed += curDt; // 帧末推进(首帧 behaviors 见 now()=0;latch 后不推进=时间冻结)
}
justPressedKeys.clear(); // 输入沿恒帧末清(含 latch 后,杜绝事件滞留——防未来 resume 路读到幻触)
tappedThisFrame = false;
}
/** 声明式渲染器:逐实体按 render 组件画 rect/circle/fill(behaviors 只逻辑不画)+ 极简 HUD。 */
function render(g) {
if (!g) return;
// 背景 fill 实体先画(kind=render 且 shape=fill 铺满视口)。
for (const e of entities) {
if (!e.alive) continue;
for (const c of e.components) {
if (c && c.kind === 'render' && c.shape === 'fill') { g.fillStyle = c.color || '#000'; g.fillRect(0, 0, VIEW_W, VIEW_H); }
}
}
for (const e of entities) { if (e.alive) drawEntity(g, e); }
drawHud(g);
}
function drawEntity(g, e) {
for (const c of e.components) {
if (!c || c.kind !== 'render' || c.shape === 'fill') continue;
if (c.shape === 'sprite') { drawSprite(g, e, c); continue; } // U1 资产渲染(图源/降级见 drawSprite)
g.fillStyle = c.color || '#ffffff';
if (c.shape === 'circle') {
const r = num(c.r != null ? c.r : (c.radius != null ? c.radius : 10));
g.beginPath(); g.arc(e.x, e.y, r, 0, Math.PI * 2); g.fill();
} else { // rect(缺省):以 transform 为中心
const w = num(c.w != null ? c.w : (c.width != null ? c.width : 20));
const h = num(c.h != null ? c.h : (c.height != null ? c.height : 20));
g.fillRect(e.x - w / 2, e.y - h / 2, w, h);
}
}
}
/**
* U1 sprite 渲染:render 组件 shape:'sprite' 引用资产 → 经 asset.ref 取宿主预载图源 drawImage(居中)。
* 解析链(评审:消 id/ref key 空间歧义):c.asset(canonical;c.ref 容差)= 资产 id → assetDefs.get(id) → def.ref(装载坐标=素材id/URL)
* → boot.assets[def.ref].image。**boot.assets 按 asset.ref 键**(宿主装载坐标),非 asset.id。
* 受控面铁律:图源只取 boot.assets(宿主 init 预载,game-host.d.ts additive);运行时不触 new Image/DOM。
* 无图源(宿主预载未 wire / 资产未载 / 悬空 ref)→ 降级类目色占位 rect(保可见、不崩、不空屏;宿主预载落地后自动转真图)。
*/
function drawSprite(g, e, c) {
const w = num(c.w != null ? c.w : (c.width != null ? c.width : DEFAULT_SPRITE_SIZE));
const h = num(c.h != null ? c.h : (c.height != null ? c.height : DEFAULT_SPRITE_SIZE));
const assetId = c.asset != null ? c.asset : (c.ref != null ? c.ref : null); // 引用资产 id(canonical=asset,ref 为容差别名)
const def = assetId != null ? assetDefs.get(String(assetId)) : null;
// 图源键 = asset.ref(宿主按装载坐标键 boot.assets);无 def(悬空 ref)时退回 assetId 容差。
const loadKey = (def && def.ref != null) ? String(def.ref) : (assetId != null ? String(assetId) : null);
const slot = (bootAssets && loadKey != null) ? bootAssets[loadKey] : null;
const img = slot && slot.image;
if (img && typeof g.drawImage === 'function') {
try { g.drawImage(img, e.x - w / 2, e.y - h / 2, w, h); return; }
catch (_) { /* drawImage 失败不连坐游戏帧,落占位 */ }
}
// 降级占位(宿主预载未 wire / 悬空 ref 时):类目色 rect,保可见。
g.fillStyle = c.color || placeholderColor(def && def.category);
g.fillRect(e.x - w / 2, e.y - h / 2, w, h);
}
/** 占位类目色(宿主图源缺时用;六类各一色,便于人眼辨「待载资产/误用」)。 */
function placeholderColor(cat) {
switch (cat) {
case 'character': return '#88ccff';
case 'effect': return '#ffcc44';
case 'scene': return '#446644';
case 'ui': return '#cccccc';
case 'music': return '#aa66aa'; // music 本不应入 render 组件;给独立色便于辨误用(评审 P2)
default: return '#cc66cc'; // sprite / 未知类目
}
}
function drawHud(g) {
if (typeof g.fillText !== 'function') return; // mock ctx 容错
try {
g.fillStyle = '#ffffff';
g.font = '24px monospace';
g.textAlign = 'center';
g.fillText('Score: ' + score, VIEW_W / 2, 36);
if (phase === 'gameover') {
g.font = '36px monospace';
g.fillStyle = result === 'win' ? '#33dd66' : '#ff4444';
g.fillText(result === 'win' ? 'YOU WIN' : 'GAME OVER', VIEW_W / 2, VIEW_H / 2);
}
} catch (_) { /* HUD 失败不连坐 */ }
}
/**
* 取证快照(喂 _forensicsView().state())。除九门读的 phase/score/result/remaining 外,
* 还**派生 harness driver 形状**(plan U1 step5,对齐 SaaPrompts gatespec driver 契约):
* ① 逐实体按 id 投影为顶层命名位(ball.x/paddle.x/bird.vy… 让 driver 命名路径解析);
* ② tag='target' 的实体 → targets[{x,y,idx,occupied,safe}](tap-targets driver 读)。
* 故 gatespec 的 driver.ballPath='ball.x' 等须与 gameDefinition 实体 id 对齐(U3 prompt 喂此约定)。
*/
function state() {
const alive = entities.filter((e) => e.alive);
const snap = {
phase, result, score,
elapsed,
remaining: alive.length,
progress: null,
errors: errors.slice(), // 生成质量信号(behavior/rule 抛错)
};
// ① 逐实体按 id 投影顶层命名位(不覆盖上面的保留键)。
for (const e of alive) {
if (e.id && !(e.id in snap)) snap[e.id] = entityView(e);
}
// ② tap-targets driver:tag='target' 的实体导出 targets 数组(idx/occupied/safe 供确定性逐格驱动)。
const tnodes = alive.filter((e) => e.tags.has('target'));
if (tnodes.length) {
snap.targets = tnodes.map((e, i) => ({
x: e.x, y: e.y, idx: (e.idx != null ? e.idx : i),
occupied: !!e.occupied, safe: e.safe !== false,
}));
}
snap.entities = alive.map((e) => ({ id: e.id, x: e.x, y: e.y, tags: Array.from(e.tags) }));
return snap;
}
/** 单实体取证投影:位置/速度 + 常见数值字段(w/h/r/angle/radius)供 gatespec 命名路径解析。 */
function entityView(e) {
const v = { x: e.x, y: e.y, vx: e.vx, vy: e.vy };
for (const k of ['w', 'h', 'r', 'angle', 'radius']) {
if (typeof e[k] === 'number') v[k] = e[k];
}
return v;
}
function destroy() {
for (const s of subs) { try { s && s.cancel && s.cancel(); } catch (_) { /* no-op */ } }
subs.length = 0;
entities = [];
byId.clear();
}
return { rt, init, update, render, state, destroy, errors: () => errors.slice() };
}
export default createRuntime;

View File

@ -1,358 +0,0 @@
/**
* gd-runtime.test.mjs —— U1 运行时约定基座单测(plan 2026-06-18-001 U1 验收)
* 跑:cd game-runtime && node --test src/host/gd-runtime.test.mjs(亦被 `npm test` 的 src/**\/*.test.mjs 收)
*
* 钉死 plan U1 三条必验 + 集成验证(证 behaviors/rules/spawn 真跑通):
* ① 确定性:rt.random 同种子可复现;② win latch 驻留不可逆;③ rt.fx.burst 真触发 ctx.getEngine。
* 受控 ctx 经 src/core/plugin.js 的 createHostDevContext(带 SeededRandom + 可注 engineFactory)建。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHostDevContext } from '../core/plugin.js';
import { createRuntime } from './gd-runtime.js';
/** 建一个记账 mock 引擎(记 particles/audio 调用,验「真接线」),交 engineFactory。 */
function makeMockEngine() {
const emitterCalls = [];
const synthCalls = [];
const engine = {
particles: { spawnEmitter: (spec) => { emitterCalls.push(spec); return { isActive: () => false, stop() {} }; } },
audio: { synth: { synthSfx: (p) => { synthCalls.push(p); return [0]; }, synthSong: () => null } },
math: { lerp: (a, b, p) => a + (b - a) * p, smoothStep: (p) => p },
};
return { engine, emitterCalls, synthCalls };
}
/** 起一个受控 boot(注 seed + 可选 mock 引擎)。返回 {boot, bundle, mock}。 */
function makeBoot(seed, withEngine) {
const mock = withEngine ? makeMockEngine() : null;
const bundle = createHostDevContext({ seed, engineFactory: mock ? () => mock.engine : null });
const boot = { ctx: bundle.context, mainContext: null, canvas: null, seed };
return { boot, bundle, mock };
}
/* ① 确定性:同种子两运行时 rt.random 序列逐位相等(禁 Math.random 的硬证)。 */
test('U1-① rt.random 同种子可复现(确定性)', () => {
const a = createRuntime(makeBoot(20260618, false).boot, {});
const b = createRuntime(makeBoot(20260618, false).boot, {});
const seqA = Array.from({ length: 6 }, () => a.rt.random());
const seqB = Array.from({ length: 6 }, () => b.rt.random());
assert.deepEqual(seqA, seqB, 'rt.random 同种子序列应逐位相等');
// randRange/randInt 同样确定
assert.equal(a.rt.randInt(0, 100), b.rt.randInt(0, 100));
// 不同种子应大概率不同(弱证非常数)
const c = createRuntime(makeBoot(999, false).boot, {});
assert.notDeepEqual(Array.from({ length: 6 }, () => c.rt.random()), seqA);
});
/* ② win/lose latch:置定终态后不可逆(H 门「终态驻留」硬约束)。 */
test('U1-② rt.win() 后 phase latch 驻留不可逆', () => {
const { boot } = makeBoot(1, false);
const r = createRuntime(boot, {});
r.init();
assert.equal(r.state().phase, 'playing');
r.rt.win();
assert.equal(r.state().phase, 'gameover');
assert.equal(r.state().result, 'win');
// latch 不可逆:再调 lose 不应翻盘
r.rt.lose();
assert.equal(r.state().result, 'win', 'latch 后 result 不可被 lose 覆盖');
// latch 后 update 停摆(不再推进 elapsed)
const before = r.state().elapsed;
r.update(0.5);
assert.equal(r.state().elapsed, before, 'latch 后 update 应停摆');
});
/* ③ rt.fx.burst / beep 真触发 ctx.getEngine() 调用(满 F 门「真接线」)。 */
test('U1-③ rt.fx.burst/beep 真调引擎(满 F 门)', () => {
const { boot, mock } = makeBoot(1, true);
const r = createRuntime(boot, {});
r.rt.fx.burst(100, 200, '#ff0000');
assert.equal(mock.emitterCalls.length, 1, 'fx.burst 应触发一次 particles.spawnEmitter');
assert.deepEqual(mock.emitterCalls[0].pos, { x: 100, y: 200 }, '发射原点应为受控面像素 {x,y}');
assert.ok(Math.abs(mock.emitterCalls[0].colorStart.r - 1) < 1e-6, '#ff0000 → colorStart.r≈1');
r.rt.fx.beep('score');
assert.equal(mock.synthCalls.length, 1, 'fx.beep 应触发一次 audio.synth.synthSfx');
// 无引擎时优雅 no-op(不抛)
const noEng = createRuntime(makeBoot(1, false).boot, {});
assert.doesNotThrow(() => { noEng.rt.fx.burst(0, 0, '#fff'); noEng.rt.fx.beep('hit'); });
});
/* 集成①:behavior 带逻辑 JS 经 new Function 编译 + 每帧跑 + 操作世界(实体移动)。 */
test('U1-集成 behavior 编译并每帧操作世界', () => {
const { boot } = makeBoot(1, false);
const gdef = {
entities: [{ id: 'p', transform: { position: { x: 10, y: 10 } } }],
behaviors: [{ id: 'mv', trigger: 'update', code: "const p=rt.getEntity('p'); if(p){ p.x += 100*dt; }" }],
};
const r = createRuntime(boot, gdef);
r.init();
assert.equal(r.rt.getEntity('p').x, 10);
r.update(0.5);
assert.ok(Math.abs(r.rt.getEntity('p').x - 60) < 1e-6, '0.5s @100px/s → x≈60');
assert.deepEqual(r.errors(), [], '正常 behavior 不应产错误信号');
});
/* 集成②:rule 布尔表达式编译 + 上升沿/latch(score≥3 → win)。 */
test('U1-集成 rule 评估并 latch', () => {
const { boot } = makeBoot(1, false);
const r = createRuntime(boot, { rules: [{ id: 'w', condition: 'rt.score >= 3', outcome: 'win' }] });
r.init();
r.rt.setScore(2);
r.update(0.016);
assert.equal(r.state().phase, 'playing', 'score<3 不应胜');
r.rt.setScore(3);
r.update(0.016);
assert.equal(r.state().phase, 'gameover');
assert.equal(r.state().result, 'win');
});
/* 集成③:rt.spawn/destroy/query + 死实体回收。 */
test('U1-集成 spawn/destroy/query 与回收', () => {
const { boot } = makeBoot(1, false);
const r = createRuntime(boot, {});
r.init();
const b = r.rt.spawn({ x: 5, y: 5, tags: ['bullet'] });
assert.equal(r.rt.query('bullet').length, 1);
r.rt.destroy(b);
r.update(0.016); // reap 在帧末
assert.equal(r.rt.query('bullet').length, 0, 'destroy 后下一帧应回收');
assert.equal(r.rt.getEntity(b.id), null);
});
/* 集成④:输入轮询面(受控事件桥 _emit → rt.input 轮询)。 */
test('U1-集成 输入轮询(isDown/justTapped)', () => {
const { boot, bundle } = makeBoot(1, false);
const r = createRuntime(boot, {});
r.init();
bundle.inputBridge._emit('keydown', { key: 'ArrowLeft' });
assert.equal(r.rt.input.isDown('ArrowLeft'), true);
assert.equal(r.rt.input.justPressed('ArrowLeft'), true);
bundle.inputBridge._emit('pointerdown', { x: 42, y: 99 });
assert.equal(r.rt.input.justTapped(), true);
assert.deepEqual({ x: r.rt.input.pointer.x, y: r.rt.input.pointer.y, down: r.rt.input.pointer.down }, { x: 42, y: 99, down: true });
r.update(0.016); // 帧末清沿
assert.equal(r.rt.input.justPressed('ArrowLeft'), false, '帧末应清 justPressed');
assert.equal(r.rt.input.justTapped(), false, '帧末应清 justTapped');
assert.equal(r.rt.input.isDown('ArrowLeft'), true, 'isDown 不随帧清(仍按住)');
bundle.inputBridge._emit('keyup', { key: 'ArrowLeft' });
assert.equal(r.rt.input.isDown('ArrowLeft'), false);
});
/* 集成⑤:物理组件 opt-in 积分(gravity + vx/vy)。 */
test('U1-集成 物理组件积分', () => {
const { boot } = makeBoot(1, false);
const gdef = {
components: [{ id: 'phys', kind: 'physics', gravity: 100 }],
entities: [{ id: 'ball', transform: { position: { x: 0, y: 0 } }, vx: 10, vy: 0, components: ['phys'] }],
};
const r = createRuntime(boot, gdef);
r.init();
r.update(1.0);
const ball = r.rt.getEntity('ball');
assert.ok(Math.abs(ball.vy - 100) < 1e-6, 'gravity 100 @1s → vy≈100');
assert.ok(Math.abs(ball.x - 10) < 1e-6, 'vx 10 @1s → x≈10');
});
/* 集成⑥:声明式渲染器不抛 + 真出绘制调用(mock g 记账)。 */
test('U1-集成 声明式渲染器出图', () => {
const { boot } = makeBoot(1, false);
const calls = { fillRect: 0, arc: 0 };
const g = {
fillStyle: '', font: '', textAlign: '',
fillRect: () => { calls.fillRect++; }, beginPath: () => {}, arc: () => { calls.arc++; }, fill: () => {},
fillText: () => {},
};
const gdef = {
components: [
{ id: 'bg', kind: 'render', shape: 'fill', color: '#101020' },
{ id: 'box', kind: 'render', shape: 'rect', color: '#fff', w: 20, h: 20 },
{ id: 'dot', kind: 'render', shape: 'circle', color: '#0f0', r: 8 },
],
entities: [
{ id: 'b', transform: { position: { x: 0, y: 0 } }, components: ['bg'] },
{ id: 'box1', transform: { position: { x: 100, y: 100 } }, components: ['box'] },
{ id: 'd1', transform: { position: { x: 50, y: 50 } }, components: ['dot'] },
],
};
const r = createRuntime(boot, gdef);
r.init();
assert.doesNotThrow(() => r.render(g));
assert.ok(calls.fillRect >= 2, 'fill 背景 + rect 实体应出 fillRect');
assert.equal(calls.arc, 1, 'circle 实体应出 arc');
});
/* 守卫:缺 boot.ctx 必 fail-loud(错误路径不静默)。 */
test('U1-守卫 缺 ctx 硬失败', () => {
assert.throws(() => createRuntime({}, {}), /boot\.ctx 缺失/);
assert.throws(() => createRuntime(null, {}), /boot\.ctx 缺失/);
});
/* 评审修复①(HIGH):state() 派生 harness driver 命名路径(ball.x/paddle.x/targets[])。 */
test('U1-修 state 派生 driver 命名路径', () => {
const { boot } = makeBoot(1, false);
const gdef = {
components: [{ id: 'rb', kind: 'render', shape: 'circle', color: '#fff', r: 8 }],
entities: [
{ id: 'ball', transform: { position: { x: 100, y: 200 } }, vx: 5, vy: -3, components: ['rb'] },
{ id: 'paddle', transform: { position: { x: 195, y: 800 } } },
{ id: 't0', transform: { position: { x: 50, y: 50 } }, tags: ['target'] },
{ id: 't1', transform: { position: { x: 90, y: 50 } }, tags: ['target'] },
],
};
const r = createRuntime(boot, gdef); r.init();
const st = r.state();
assert.equal(st.ball.x, 100); assert.equal(st.ball.vy, -3); // paddle-intercept driver 读 ball.x
assert.equal(st.paddle.x, 195); // controlCheck 读 paddle.x
assert.equal(st.targets.length, 2); // tap-targets driver 读 targets[]
assert.equal(st.targets[0].idx, 0); assert.equal(st.targets[0].occupied, false);
assert.equal(st.phase, 'playing'); // 保留键不被实体投影覆盖
});
/* 评审修复②(High):addScore 守 num(NaN/字符串不污染 score 契约)。 */
test('U1-修 addScore num 守卫', () => {
const { boot } = makeBoot(1, false);
const r = createRuntime(boot, {}); r.init();
r.rt.addScore(NaN); assert.equal(r.state().score, 0, 'NaN→0 不污染');
r.rt.addScore('5'); assert.equal(r.state().score, 0, '非数值串→0(忽略,不拼接)');
r.rt.addScore(3); assert.equal(r.state().score, 3, '数值正常 +3');
r.rt.addScore(); assert.equal(r.state().score, 4, '缺省 +1');
});
/* 评审修复③(High):rt 纯 API 面冻结(behavior 改写共享 API 不腐蚀全局)。 */
test('U1-修 rt API 面冻结', () => {
const { boot } = makeBoot(1, false);
const r = createRuntime(boot, {}); r.init();
assert.throws(() => { r.rt.input.isDown = () => true; }); // ESM 严格模式写冻结对象抛
assert.equal(r.rt.input.isDown('X'), false, 'isDown 未被改写');
assert.throws(() => { r.rt.view.w = 0; });
assert.equal(r.rt.view.w, 390);
});
/* 评审修复(Med):重复实体 id——先到者保持可寻址 + 记错误信号(reap 不误删存活者)。 */
test('U1-修 重复实体 id 先到者可寻址', () => {
const { boot } = makeBoot(1, false);
const gdef = { entities: [
{ id: 'd', transform: { position: { x: 1, y: 1 } }, tags: ['first'] },
{ id: 'd', transform: { position: { x: 2, y: 2 } }, tags: ['second'] },
] };
const r = createRuntime(boot, gdef); r.init();
assert.ok(r.errors().some((e) => e.includes('重复')), '应记 id 重复信号');
assert.equal(r.rt.entities().length, 2, '两实体都在世界');
assert.equal(r.rt.getEntity('d').tags.has('first'), true, 'getEntity 返先到者');
});
/* 评审修复(残留):rt.time 不暴露 nowMs(墙钟=非确定性已移除)。 */
test('U1-修 rt.time 不暴露 nowMs', () => {
const { boot } = makeBoot(1, false);
const r = createRuntime(boot, {});
assert.equal(typeof r.rt.time.now, 'function');
assert.equal(r.rt.time.nowMs, undefined, 'nowMs 已移除(防非确定性逃逸)');
});
/* U3 救场基元:clickable 内置点击派发(声明式 tap-targets,免模型写 tap-handler)。 */
test('U3 clickable 内置点击派发', () => {
const { boot, bundle, mock } = makeBoot(1, true);
const gdef = {
components: [
{ id: 'cell', kind: 'render', shape: 'rect', color: '#333', w: 100, h: 100 },
{ id: 'clk', kind: 'clickable', score: 1, mark: { shape: 'circle', color: '#ff0', r: 30 } },
],
entities: [{ id: 'c0', transform: { position: { x: 100, y: 300 } }, tags: ['target'], components: ['cell', 'clk'] }],
};
const r = createRuntime(boot, gdef); r.init();
bundle.inputBridge._emit('pointerdown', { x: 100, y: 300 }); // 点中 c0
r.update(0.016);
assert.equal(r.state().score, 1, '点中 clickable +1 分(无需 tap-handler)');
assert.equal(r.state().targets[0].occupied, true, '点中后 occupied 翻 true(driver 会跳过它)');
assert.ok(r.state().entities.length >= 2, '应 spawn 标记实体(可见变化→E_live)');
assert.ok(mock.emitterCalls.length >= 1, 'fx.burst 真触发引擎(F_wiring)');
bundle.inputBridge._emit('pointerdown', { x: 100, y: 300 }); // 再点同一(已 occupied)
r.update(0.016);
assert.equal(r.state().score, 1, 'occupied 后再点不重复计分');
bundle.inputBridge._emit('pointerdown', { x: 5, y: 5 }); // 点空白
r.update(0.016);
assert.equal(r.state().score, 1, '点空白不计分');
});
/* U1 sprite 资产渲染:经 asset.ref 取 boot.assets 图源 drawImage;无图源/中间态/抛错/悬空ref → 降级类目色占位 rect。 */
test('U1 sprite 渲染(ref 键图源 drawImage / 多路降级占位)', () => {
const fakeImg = { __img: true };
// mock g:记 drawImage 入参 + fillRect 次数 + 每次 fillRect 时的 fillStyle(验占位类目色)。
const mkG = (rec) => {
let fill = '';
return {
font: '', textAlign: '',
get fillStyle() { return fill; }, set fillStyle(v) { fill = v; },
drawImage: (img, x, y, w, h) => { rec.drawImage.push({ img, x, y, w, h }); },
fillRect: () => { rec.fillRect++; rec.fillAt.push(fill); },
beginPath() {}, arc() {}, fill() {}, fillText() {},
};
};
const rec = () => ({ drawImage: [], fillRect: 0, fillAt: [] });
const gdef = {
components: [{ id: 'spr', kind: 'render', shape: 'sprite', asset: 'hero-img', w: 40, h: 40 }],
entities: [{ id: 'h', transform: { position: { x: 100, y: 200 } }, components: ['spr'] }],
assets: [{ id: 'hero-img', category: 'sprite', ref: 'mat-001' }],
};
// (a) 有图源(**按 asset.ref='mat-001' 键**,非 id)→ drawImage 居中一次
const recHit = rec();
const bootA = makeBoot(1, false).boot; bootA.assets = { 'mat-001': { image: fakeImg } };
const rA = createRuntime(bootA, gdef); rA.init();
assert.doesNotThrow(() => rA.render(mkG(recHit)));
assert.equal(recHit.drawImage.length, 1, '有图源 sprite 应 drawImage 一次');
assert.equal(recHit.drawImage[0].img, fakeImg);
assert.deepEqual({ x: recHit.drawImage[0].x, y: recHit.drawImage[0].y, w: recHit.drawImage[0].w, h: recHit.drawImage[0].h }, { x: 80, y: 180, w: 40, h: 40 }, '居中 drawImage');
// (b) 无 boot.assets → 占位、零 drawImage
const recNone = rec();
const rB = createRuntime(makeBoot(1, false).boot, gdef); rB.init();
assert.doesNotThrow(() => rB.render(mkG(recNone)));
assert.equal(recNone.drawImage.length, 0); assert.ok(recNone.fillRect >= 1, '无图源降级占位');
// (c) slot 存在但 image=null(中间态)→ 占位、零 drawImage
const recNull = rec();
const bootC = makeBoot(1, false).boot; bootC.assets = { 'mat-001': { image: null } };
const rC = createRuntime(bootC, gdef); rC.init();
assert.doesNotThrow(() => rC.render(mkG(recNull)));
assert.equal(recNull.drawImage.length, 0, 'image=null 不 drawImage'); assert.ok(recNull.fillRect >= 1, 'image=null 降级占位');
// (d) drawImage 抛错 → catch 兜底落占位、不连坐帧
const recThrow = rec();
const gD = mkG(recThrow); gD.drawImage = () => { throw new Error('decode fail'); };
const bootD = makeBoot(1, false).boot; bootD.assets = { 'mat-001': { image: fakeImg } };
const rD = createRuntime(bootD, gdef); rD.init();
assert.doesNotThrow(() => rD.render(gD), 'drawImage 抛错不应连坐渲染帧');
assert.ok(recThrow.fillRect >= 1, 'drawImage 抛错后落占位 fillRect');
// (e) 悬空 ref(render.asset 引用不存在的 asset id)→ 默认占位色 #cc66cc、不崩
const recDangling = rec();
const gdefDangling = { ...gdef, components: [{ id: 'spr', kind: 'render', shape: 'sprite', asset: 'nope', w: 40, h: 40 }] };
const rE = createRuntime(makeBoot(1, false).boot, gdefDangling); rE.init();
assert.doesNotThrow(() => rE.render(mkG(recDangling)));
assert.equal(recDangling.drawImage.length, 0); assert.ok(recDangling.fillAt.includes('#cc66cc'), '悬空 ref 占位用默认色 #cc66cc');
// (f) music 类目占位色独立(评审 P2):sprite render 引 music 资产、无图源 → 占位 #aa66aa
const recMusic = rec();
const gdefMusic = {
components: [{ id: 'sm', kind: 'render', shape: 'sprite', asset: 'bgm', w: 20, h: 20 }],
entities: [{ id: 'hm', transform: { position: { x: 50, y: 50 } }, components: ['sm'] }],
assets: [{ id: 'bgm', category: 'music', ref: 'mat-music' }],
};
const rF = createRuntime(makeBoot(1, false).boot, gdefMusic); rF.init();
assert.doesNotThrow(() => rF.render(mkG(recMusic)));
assert.ok(recMusic.fillAt.includes('#aa66aa'), 'music 类目占位色 #aa66aa(与 sprite default 区分)');
// (g) asset:0 falsy 边界(!= null 应解析,非当缺省)→ 经 ref 命中图源 drawImage
const recFalsy = rec();
const gdef0 = {
components: [{ id: 's0', kind: 'render', shape: 'sprite', asset: 0, w: 20, h: 20 }],
entities: [{ id: 'h0', transform: { position: { x: 50, y: 50 } }, components: ['s0'] }],
assets: [{ id: 0, category: 'sprite', ref: 'r0' }],
};
const boot0 = makeBoot(1, false).boot; boot0.assets = { r0: { image: fakeImg } };
const r0 = createRuntime(boot0, gdef0); r0.init();
assert.doesNotThrow(() => r0.render(mkG(recFalsy)));
assert.equal(recFalsy.drawImage.length, 1, 'asset:0 应解析(!= null)并经 ref 命中图源');
});

View File

@ -1,231 +0,0 @@
// runtime-api-2d.d.ts —— 运行时访问约定(2D 适配器 · behavior 侧 API)TS 契约 · core-runtime-v0
// owner:本 session(引擎线 · plan 2026-06-18-001 U1 正式化产物) | 消费方:U2 build-from-source 装配器 + U3 GAMEDEF_SYSTEM prompt
//
// 【为什么手写 .d.ts、落 game-runtime/src/host/】
// 与 game-host.d.ts(第 9 类契约)同理:发行版源 = ESM JS + JSDoc,对外「类型契约」手写。本约定是 game-runtime
// 内部「宿主↔游戏 behavior」接线面(behaviors 经 rt 操作世界),与 api.d.ts(插件↔引擎面)正交、与 game-host.d.ts
// (游戏工厂↔宿主面)相邻;故落 src/host/,不落 contracts/(避免跨仓 SSOT 反向 import)。是否升格 contracts/ 由 6c6g 定。
//
// 【与实现同形】本文件与 gd-runtime.js 的 createRuntime/rt 必须同形同步;人/模型可读版见同目录 runtime-api-2d.md。
// 【状态】已过 4 轮迭代 + 跨品类 9/9 实证(含 clickable 基元),约定稳定,正式化为契约。后续演进只增不破已冻签名。
import type { PluginContext } from '../core/api.d.ts';
import type { GameHostBootContext } from './game-host.d.ts';
/* ──────────────────────────────────────────────────────────────────────────
* gameDefinition 声明式领域模型(source-project.schema.json#/properties/gameDefinition 的运行时消费视图)
* ────────────────────────────────────────────────────────────────────────── */
/** 归一化颜色:'#rgb'/'#rrggbb' 或 {r,g,b,a}(0..1)。 */
export type Color = string | { r: number; g: number; b: number; a?: number };
/** 渲染组件:声明式出图(behaviors 只逻辑不画,渲染器据此画)。 */
export interface RenderComponent {
id?: string;
kind: 'render';
/** rect(缺省,以 transform 为中心)/ circle / fill(铺满 390×844 视口作背景)/ sprite(U1:引 asset 经 boot.assets 图源 drawImage,缺图降级占位)。 */
shape?: 'rect' | 'circle' | 'fill' | 'sprite';
color?: Color;
w?: number; h?: number; // rect / sprite(缺省 32)
r?: number; // circle
/** U1 sprite:引用 GameDefinition.assets[].id(canonical=asset;ref 为容差别名)。运行时经 asset.ref 查 boot.assets 图源。 */
asset?: string;
ref?: string;
}
/** 物理组件(opt-in):挂此组件的实体每帧 x+=vx*dt; y+=vy*dt(有 gravity 则先 vy+=gravity*dt)。 */
export interface PhysicsComponent {
id?: string;
kind: 'physics';
gravity?: number;
}
/**
* 点击组件(plan U3 离散点击声明式基元):运行时内置——本帧点中带此组件且 occupied!==true 的实体
* → 自动翻 occupied=true + spawn mark 标记实体(可见变化) + 加 score + fx(粒子/音效)。
* 离散点击类(井字棋/打地鼠/翻牌)声明它即可,免写易错 tap-handler。配 tags:["target"] 让九门 driver 逐个点。
*/
export interface ClickableComponent {
id?: string;
kind: 'clickable';
/** 命中加分(缺省 1)。 */
score?: number;
/** 命中时 spawn 的标记实体的 render 形态(缺省黄色小圆)。 */
mark?: { shape?: 'rect' | 'circle'; color?: Color; r?: number; w?: number; h?: number };
/** 命中区半尺寸来源(缺省取 render 尺寸 / 80)。 */
hitW?: number; hitH?: number;
}
/** 碰撞/自定义组件:声明式标记,由 behavior 自行 rt.overlap 判定 / 读取。 */
export interface MarkerComponent {
id?: string;
kind: 'collision' | 'custom';
[k: string]: unknown;
}
export type Component = RenderComponent | PhysicsComponent | ClickableComponent | MarkerComponent;
/** 实体声明(schema entity):transform.position → 运行时 x/y;可带 vx/vy/tags + 任意数值字段(idx/occupied 等)。 */
export interface EntityDef {
id: string;
transform: { position: { x: number; y: number; z?: number }; rotation?: number; scale?: number };
/** 组件引用(components[].id 字符串)或内联组件对象(spawn 用)。 */
components?: Array<string | Component>;
vx?: number; vy?: number;
tags?: string[];
[k: string]: unknown; // 运行时可用的自定义数值/布尔字段(idx/occupied/…)
}
/** 行为模块:code 是一段 JS 逻辑串,运行期编译为 function(rt,self,dt)。init 跑一次,其余每帧。 */
export interface BehaviorDef {
id: string;
trigger: 'init' | 'update' | 'input' | 'collision' | 'timer';
/** 逻辑代码(规范字段 code;js 为兼容别名)。受校验边界静态扫描(禁 Math.random/Date/process/while(true)…)。 */
code?: string;
js?: string;
}
/** 规则:condition 是无副作用 JS 布尔表达式(rt/self 在作用域),为真按 outcome。 */
export interface RuleDef {
id: string;
condition: string;
outcome: 'win' | 'lose' | 'score' | 'advance';
}
/** 场景:scenes[0].entityRefs 决定初始实例化哪些实体(缺省全量)。 */
export interface SceneDef {
id: string;
entityRefs: string[];
}
/** 资产规格(U1·对齐 source-project.schema.json #/$defs/assetSpec)。schema 中 assets 位于 **sourceProject 顶层**(与 gameDefinition 平级);assembler 校验后抽入运行时 GameDefinition.assets 供渲染。 */
export interface AssetSpec {
id: string;
category: 'sprite' | 'character' | 'effect' | 'scene' | 'ui' | 'music';
ref: string; // 资产引用(素材 id / URL)= 宿主 boot.assets 装载键
url?: string;
provider?: string;
}
export interface GameDefinition {
entities: EntityDef[];
components?: Component[];
behaviors?: BehaviorDef[];
scenes?: SceneDef[];
rules?: RuleDef[];
/** U1 引擎资产:assembler 从 sourceProject 顶层 assets 抽入此处供运行时 gdef.assets 消费(非 schema gameDefinition 属性)。 */
assets?: AssetSpec[];
}
/* ──────────────────────────────────────────────────────────────────────────
* rt —— behavior 侧 API(注入 new Function('rt','self','dt',code) 的那个 rt)
* 受控面铁律:时间/随机走 boot.ctx 受控种子(确定性,禁 Math.random/Date.now);引擎能力经 ctx.getEngine()(满 F 门)。
* ────────────────────────────────────────────────────────────────────────── */
/** 运行时实体实例(rt.getEntity/entities/query/spawn 返回)。 */
export interface Entity {
id: string;
x: number; y: number; vx: number; vy: number;
alive: boolean;
tags: Set<string>;
/** 已解析的组件对象数组。 */
components: Component[];
get(k: string): unknown;
set(k: string, v: unknown): unknown;
destroy(): void;
[k: string]: unknown; // behaviors 可直接读写自定义属性(e.hp=3、e.occupied 等)
}
/** 受控输入轮询面(包 ctx.getInput() 受控事件为 behaviors 友好的轮询)。 */
export interface RtInput {
isDown(key: string): boolean;
justPressed(key: string): boolean;
justTapped(): boolean;
/** 当前指针(副本,写它不腐蚀内部态)。 */
readonly pointer: { x: number; y: number; down: boolean };
}
/** 受控时间面(now=相对游戏时间秒,从 0;不暴露墙钟 nowMs,保确定性)。 */
export interface RtTime {
now(): number;
}
export interface RtFx {
/** 经 ctx.getEngine().particles 真喷粒子(满 F 门;无引擎优雅 no-op)。 */
burst(x: number, y: number, color?: Color): void;
/** 经 ctx.getEngine().audio.synth 真合成音效(kind ∈ score/hit/lose/win/default)。 */
beep(kind?: string): void;
}
/** behavior/rule 注入的运行时访问面(全部能力的唯一入口;其纯 API 面被 Object.freeze 防改写)。 */
export interface Rt {
// 实体
getEntity(id: string): Entity | null;
entities(): Entity[];
/** 按 tag 或组件 id/kind 查询活实体(gameplay 主用 tag)。 */
query(name: string): Entity[];
spawn(spec: Partial<EntityDef> & { x?: number; y?: number; vx?: number; vy?: number; tags?: string[]; components?: Array<string | Component> }): Entity;
destroy(e: Entity): void;
// 输入 / 时间 / 随机(确定性)
input: RtInput;
time: RtTime;
readonly dt: number;
random(): number;
randRange(a: number, b: number): number;
randInt(a: number, b: number): number;
// 分数胜负(win/lose 置不可逆 latch 终态)
readonly score: number;
addScore(n?: number): number;
setScore(n: number): number;
win(): void;
lose(): void;
// 工具 / 特效 / 视口
clamp(v: number, lo: number, hi: number): number;
dist(ax: number, ay: number, bx: number, by: number): number;
/** AABB 重叠({x,y,w?,h?},缺省全尺寸 16;显式 0 被尊重)。 */
overlap(a: { x: number; y: number; w?: number; h?: number }, b: { x: number; y: number; w?: number; h?: number }): boolean;
fx: RtFx;
view: { w: number; h: number };
}
/* ──────────────────────────────────────────────────────────────────────────
* 取证快照(_forensicsView().state();九门据此判进展 + 派生 driver 命名路径)
* ────────────────────────────────────────────────────────────────────────── */
/** state() 快照:固定取证键 + 逐实体按 id 投影命名位(ball.x/paddle.x…)+ tag='target' → targets[]。 */
export interface ForensicsState {
phase: 'booting' | 'playing' | 'gameover';
result: 'win' | 'lose' | null;
score: number;
elapsed: number;
remaining: number;
progress: number | null;
errors: string[];
entities: Array<{ id: string; x: number; y: number; tags: string[] }>;
/** tap-targets driver 读:tag='target' 实体派生。 */
targets?: Array<{ x: number; y: number; idx: number; occupied: boolean; safe: boolean }>;
/** 逐实体 id 投影位(如 ball:{x,y,vx,vy,w?,h?,r?,angle?}),供 gatespec 命名路径解析。 */
[entityId: string]: unknown;
}
/* ──────────────────────────────────────────────────────────────────────────
* createRuntime —— 建可变世界 + 产 rt(U2 装配器产出的工厂 init 调它;GameInstance 五法委托其 init/update/render/state/destroy)
* ────────────────────────────────────────────────────────────────────────── */
export interface Runtime {
rt: Rt;
init(): void;
update(dt: number): void;
render(g: CanvasRenderingContext2D): void;
state(): ForensicsState;
destroy(): void;
errors(): string[];
}
/**
* 建运行时:实例化 entities、编译 behaviors(new Function)/rules、内置 clickable 点击派发 + 声明式渲染器,产 rt。
* @param boot 宿主注入的受控启动上下文(含 ctx:PluginContext 受控面)。
* @param gameDefinition 声明式领域模型。
*/
export declare function createRuntime(boot: GameHostBootContext | { ctx: PluginContext; seed?: number; mainContext?: CanvasRenderingContext2D | null; canvas?: HTMLCanvasElement | null; assets?: Record<string, { image?: CanvasImageSource }> }, gameDefinition: GameDefinition): Runtime;
export default createRuntime;

View File

@ -1,136 +0,0 @@
# 运行时访问约定(2D 适配器 · behavior 侧 API)—— runtime-api-2d v0
> plan 2026-06-18-001 U1 交付物。本文件是「写 gameDefinition 的 behavior 逻辑时能用的全部 `rt` 面」的**人/模型可读契约**,
> U3 的 `GAMEDEF_SYSTEM` prompt 直接嵌入本文(+ 末尾 few-shot)。实现见 `gd-runtime.js`,类型雏形随后正式化为 `runtime-api-2d.d.ts`。
>
> **状态 v0(plan「别冻早」)**:已过 4 类原型表达力证(见 `gd-archetypes.test.mjs`),接口稳定但保留随真实模型输出补 idiom 的余量。
> **落点说明**:与 `game-host.d.ts`(第 9 类契约)同理,本约定是 game-runtime 内部「宿主↔游戏 behavior」接线面,故落 `game-runtime/src/host/`;
> 是否升格为 `contracts/` 跨仓契约由 6c6g(大脑线/contracts owner)定。
---
## 1. 游戏 = 声明式 gameDefinition(改源不改打包产物)
一个游戏 = 一份 `gameDefinition` JSON(结构见 `contracts/agent-loop/source-project.schema.json`):
| 字段 | 含义 |
|---|---|
| `entities[]` | 实体:`{id, transform:{position:{x,y}}, components:[组件id...]}`(可带 `vx/vy/tags` 供运行时用) |
| `components[]` | 组件定义:`{id, kind, ...}`,`kind ∈ render/collision/physics/custom` |
| `behaviors[]` | 行为模块:`{id, trigger, code}`——`code` 是一段 **JS 逻辑字符串**,运行期编译执行 |
| `scenes[]` | 场景:`{id, entityRefs:[实体id...]}`(v0 取 `scenes[0]` 决定初始实例化哪些实体;缺省全量) |
| `rules[]` | 胜负规则:`{id, condition, outcome}`——`condition` 是 JS 布尔表达式串,`outcome ∈ win/lose/score/advance` |
**核心范式**:实体/组件/场景/规则是**声明式数据**;唯一的「逻辑代码」是 `behaviors[].code` 和 `rules[].condition`,
它们经受控的 `rt` 面操作世界。**behaviors 只写逻辑、不写画**(出图由声明式渲染器据 render 组件自动完成)。
---
## 2. behavior 代码契约
每个 behavior 的 `code` 被编译为 `new Function('rt', 'self', 'dt', code)`,每次调用注入三参:
- **`rt`** — 运行时访问面(见 §4),操作世界的唯一入口。
- **`self`** — 本 behavior 的**持久局部态**(普通对象 `{}`,跨帧保留)。存计时器/累加器:`self.t = (self.t||0) + dt;`
- **`dt`** — 本帧时间步(秒)。
**trigger 语义**:
- `init`:世界建好后**跑一次**(布初始实体/初值)。
- `update` / `input` / `collision` / `timer`:**每帧跑一次**(输入经 `rt.input` 轮询;碰撞/计时在 behavior 内自查)。
**硬约束(违反 = 生成缺陷)**:
1. **确定性**:随机一律 `rt.random/rt.randRange/rt.randInt`,时间一律 `rt.time.now()`/`dt`——**禁 `Math.random` / `Date.now` / `performance.now`**。
2. **只用 `rt` 面**:禁自造约定外 helper(如 `rt._spawnFood`)、禁 `document`/`window`/裸引擎/`requestAnimationFrame`/`addEventListener`。要生成实体用 `rt.spawn`。
3. **终态置 latch**:胜负用 `rt.win()`/`rt.lose()`(置不可逆终态),**不要**用计分或自定义 flag 表达「游戏结束」。
4. **特效经 `rt.fx`**:碰撞/得分等关键事件调 `rt.fx.burst(...)` / `rt.fx.beep(...)`(真接引擎粒子/音频)。
> **边界硬拦截(非靠自觉)**:以上禁则在**校验边界静态扫描**——`behavior.code`/`rule.condition` 命中
> `Math.random`/`Date`/`process`/`require`/`eval`/`.constructor`/`while(true)`/`document`/`window` 等,或
> condition 含 `;`/赋值/世界变更调用,会被**拒绝并回灌 repair**(不进编译)。模型代码**只在浏览器执行**
> (esbuild 仅静态打包),故确定性/安全靠此扫描兜底——别用约定外手段,会被打回。
---
## 3. 规则(rules)
`condition` 是 JS 布尔表达式串,作用域内有 `rt`(与 `self`)。每帧求值;为真时按 `outcome`:
- `win` / `lose` → 置 latch 终态(一次性,不可逆)。
- `score` → 上升沿 +1 分(防每帧重复加)。
- `advance` → 场景推进(v0 占位)。
例:`{ "id":"win", "condition":"rt.score >= 10", "outcome":"win" }`
---
## 4. `rt` 面参考(全部可用 API)
### 实体
- `rt.getEntity(id)` → 实体或 null(仅活实体)
- `rt.entities()` → 全部活实体数组
- `rt.query(name)` → 按 tag 或组件 id/kind 过滤的活实体数组
- `rt.spawn({x, y, vx?, vy?, tags?, components?})` → 新实体(`components` 可内联组件对象);返回实体引用
- `rt.destroy(entity)` → 标记死亡(帧末回收)
- 实体字段:`.x .y .vx .vy .alive .tags(Set) .components(数组)`;方法 `.get(k)` / `.set(k,v)` / `.destroy()`;可直接读写任意属性(`e.hp = 3`)
### 输入(轮询)
- `rt.input.isDown(key)` → 是否按住(如 `'ArrowLeft'`/`'Space'`)
- `rt.input.justPressed(key)` → 本帧是否刚按下
- `rt.input.justTapped()` → 本帧是否发生指针按下
- `rt.input.pointer` → `{x, y, down}`
### 时间 / 随机(确定性)
- `rt.time.now()` → 相对游戏时间(秒,从 0 累加;**用这个**,非绝对钟)
- `rt.dt` → 本帧步长(秒,= 注入的 `dt`)
- `rt.random()` → `[0,1)`;`rt.randRange(a,b)` → `[a,b)`;`rt.randInt(a,b)` → `[a,b]` 整数
### 分数 / 胜负
- `rt.score`(读)/ `rt.addScore(n=1)` / `rt.setScore(n)`
- `rt.win()` / `rt.lose()` → 置 latch 终态
### 工具 / 特效
- `rt.clamp(v,lo,hi)` / `rt.dist(ax,ay,bx,by)` / `rt.overlap(a,b)`(AABB,`a/b={x,y,w?,h?}`,缺省半尺寸 16)
- `rt.fx.burst(x,y,color)` → 引擎粒子(color 接 `'#rrggbb'` 或 `{r,g,b,a}`)
- `rt.fx.beep(kind)` → 引擎音效(kind ∈ `score/hit/lose/win/default`)
- `rt.view` → `{w:390, h:844}`(视口;边界判断用)
---
## 5. 组件(components)
- **render**:`{id, kind:'render', shape, color, ...}`
- `shape:'rect'` + `w,h`(以实体 transform 为中心)
- `shape:'circle'` + `r`
- `shape:'fill'` + `color`(铺满视口,作背景)
- **physics**:`{id, kind:'physics', gravity?}`——挂此组件的实体每帧自动 `x+=vx*dt; y+=vy*dt`(有 `gravity` 则先 `vy+=gravity*dt`)。不挂则位置全由 behavior 控制。
- **clickable**(离散点击声明式基元):`{id, kind:'clickable', score?:1, mark?:{shape,color,r/w/h}, hitW?, hitH?}`——运行时**内置**:本帧点中带此组件且 `occupied!==true` 的实体(命中区取 hitW/hitH,缺省取 render 尺寸/80)→ 自动翻 `occupied=true` + spawn `mark` 标记实体(可见变化) + `+score` + fx 粒子/音效。**离散点击类(井字棋/打地鼠/翻牌)用它即可,无需写 tap-handler**(免模型把命中误门控在分离 state/计时器上)。配 `tags:["target"]` 让九门 driver 能逐个点。
- **collision / custom**:声明式标记,由 behavior 自行 `rt.overlap` 判定 / 读取。
---
## 6. 原型范式(few-shot · 见 `gd-archetypes.test.mjs` 完整可跑版)
- **paddle-intercept(打砖块/pong)**:板 entity + 球 entity(`vx/vy`);update behavior 里键/指针控板、球积分+墙反弹、`rt.overlap` 板拦截 `rt.addScore`+`rt.fx.burst`、落底 `rt.lose()`。
- **event-clicker(点击器/放置)**:按钮 entity;input behavior 里 `if(rt.input.justTapped()) rt.addScore(1)`;rule `rt.score>=N → win`。
- **dodge-spawn(躲避)**:玩家 entity;update behavior 里键控玩家、定时 `rt.spawn` 随机位敌人(`rt.randRange`)、遍历 `rt.query('enemy')` 下落+出界 `rt.destroy`+`rt.overlap` 撞玩家 `rt.lose()`。
- **runner(跑酷)**:玩家挂 `physics{gravity}`;update behavior 里 `rt.input.justTapped()` 起跳(置 `vy`)、落地复位、定时 `rt.spawn` 障碍(`vx`)、`rt.overlap` 撞障碍 `rt.lose()` / 出界 `rt.addScore`。
---
## 6.1 取证/driver 命名约定(让九门能驱动你的游戏 · 关键)
九门 harness 据 gatespec 的 driver 用**命名路径**读取证状态来真玩你的游戏。运行时自动把世界投影成取证形状,**但你必须命名/打标对齐**,否则 driver 读不到 → 机制门必挂:
- **实体按 id 投影为顶层取证位**:实体 id=`ball` → 取证可读 `ball.x/ball.y/ball.vx/ball.vy`(含 `w/h/r/angle`);id=`paddle` → `paddle.x`。
故 **paddle-intercept 类**:把球实体命名 `ball`、挡板命名 `paddle`(与设计 agent 的 gatespec `driver.ballPath="ball.x"`/`controlCheck.paddlePath="paddle.x"` 对齐)。
- **离散点击类(tap-targets)**:给每个可点目标实体打 `tags:["target"]`,并(可选)设 `idx`(序号)/`occupied`(是否已占)/`safe`(是否非致负);
运行时自动汇成 `targets:[{x,y,idx,occupied,safe}]` 供 driver 逐个点。**不打 target 标 → driver 点不到 → H_progress 必挂。**
- 保留取证键(`phase/score/result/remaining/progress/errors/entities/targets`)不会被实体投影覆盖(同名实体 id 不投影)。
## 7. 常见坑(治真实模型漂移)
- ❌ 自造 `rt._spawnFood` 等约定外 helper → ✅ 用 `rt.spawn(...)`。
- ❌ `Math.random()` / `Date.now()` → ✅ `rt.random()` / `rt.time.now()`(确定性,否则取证不可复现 + 静态门拒)。
- ❌ 用分数/flag 表达「结束」 → ✅ `rt.win()`/`rt.lose()` 置 latch。
- ❌ 在 behavior 里画图(`ctx.fillRect`)→ ✅ 给实体加 render 组件,渲染器自动出图。
- ❌ 关键事件无特效 → ✅ 碰撞/得分调 `rt.fx.burst`/`rt.fx.beep`(满「真接线」门)。