From 000f87d98bff428330366731610b492b820a1468 Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 17 Jun 2026 22:53:38 -0700 Subject: [PATCH 01/14] =?UTF-8?q?feat(game-runtime):=20U1=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E7=BA=A6=E5=AE=9A=E5=9F=BA=E5=BA=A7=20create?= =?UTF-8?q?Runtime/rt(2D=20=E9=80=82=E9=85=8D=E5=99=A8=20behavior=20?= =?UTF-8?q?=E4=BE=A7=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan 2026-06-18-001 U1 · 引擎线 · 真结构化 gameDefinition 产线化地基。 新增 game-runtime/src/host/gd-runtime.js: - createRuntime(boot, gameDefinition) → 建可变世界(entities 实例)+ 产 rt 对象; rt 即 U2 装配器编译每 behavior 时 new Function('rt','self','dt',code) 注入的那个 rt。 - rt 面(落 plan KTD2):实体 getEntity/entities/query/spawn/destroy(.x.y.vx.vy.alive .components.tags+.get/set/destroy);输入轮询 input.isDown/justPressed/justTapped/pointer (包 ctx.getInput 受控事件);时间随机 time.now(相对 elapsed,非绝对钟)/dt/random/randRange /randInt(走 boot.ctx 受控种子,禁 Math.random/Date.now);分数胜负 score/addScore/setScore /win()/lose()(置 latch 终态 gameover,不可逆=H门);工具特效 clamp/dist/overlap(AABB)+ fx.burst/beep(经 ctx.getEngine 真调引擎粒子/合成核=满F门,无引擎优雅 no-op)。 - behaviors 各带逻辑 JS 经 new Function 编译(init 跑一次/其余每帧),抛错隔离+回灌质量信号; rules condition 编 JS 布尔(win/lose latch、score/advance 上升沿);物理组件 opt-in 积分; 声明式渲染器逐实体按 render 组件画 rect/circle/fill(behaviors 只逻辑不画)+极简 HUD。 受控面铁律:经 boot.ctx(PluginContext)注入,不触裸引擎/DOM;缺 ctx fail-loud。 迭代状态 v0(plan「别冻早」):契约 runtime-api-2d.d.ts 待收 3-5 类真实模型输出补 idiom 再正式化。 验收(lili-mac 快走查):node --test src/host/gd-runtime.test.mjs 10/10 过—— 三条必验(rt.random 同种子可复现/win latch 驻留不可逆/fx.burst 真触发 ctx.getEngine) + 集成(behavior 编译跑通/rule latch/spawn-destroy-回收/输入轮询/物理积分/渲染出图)+缺 ctx 守卫。 全 runtime 套件 41/41 无回归。测试文件就近 src/host/(贴 npm test 的 src/**/*.test.mjs 约定, 非 plan 所写 test/unit/——遵仓内既有 co-located 约定)。 Co-Authored-By: Claude Opus 4.8 --- game-runtime/src/host/gd-runtime.js | 352 ++++++++++++++++++++++ game-runtime/src/host/gd-runtime.test.mjs | 189 ++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 game-runtime/src/host/gd-runtime.js create mode 100644 game-runtime/src/host/gd-runtime.test.mjs diff --git a/game-runtime/src/host/gd-runtime.js b/game-runtime/src/host/gd-runtime.js new file mode 100644 index 00000000..253031bd --- /dev/null +++ b/game-runtime/src/host/gd-runtime.js @@ -0,0 +1,352 @@ +/** + * 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; + +/** 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); } + + /* ── 世界状态 ───────────────────────────────────────────────────────── */ + 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; }, + }; + return e; + } + function addEntity(e) { entities.push(e); byId.set(e.id, 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 重叠(中心点 + 半宽半高;缺省半尺寸 8px)。a/b = {x,y,w?,h?}。 */ + function overlap(a, b) { + if (!a || !b) return false; + const ahw = num(a.w || 16) / 2, ahh = num(a.h || 16) / 2, bhw = num(b.w || 16) / 2, bhh = num(b.h || 16) / 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 pointer; }, + }, + /* 时间/随机面(走 boot.ctx 受控种子,确定性;禁 Math.random/Date.now) */ + time: { now: () => elapsed, nowMs: () => ctx.time.nowMs() }, + 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 终态) */ + get score() { return score; }, + addScore: (n) => { score += (n == null ? 1 : 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 }, + }; + + /* ── 编译 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; + } + } + /** 回收死实体(帧末统一清,避免迭代中改数组)。 */ + function reap() { + let dirty = false; + for (const e of entities) { if (!e.alive) { byId.delete(e.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) { + if (phase !== 'playing') return; // latch 终态后停摆(H 门:终态驻留不可逆) + curDt = num(dt); + elapsed += curDt; + for (const slot of tickBehaviors) { if (phase !== 'playing') break; runBehavior(slot, curDt); } + integratePhysics(curDt); + evalRules(); + reap(); + justPressedKeys.clear(); // 帧末清每帧输入沿 + 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; + 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); + } + } + } + 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)。 */ + function state() { + const alive = entities.filter((e) => e.alive); + return { + phase, result, score, + elapsed, + remaining: alive.length, + progress: null, + entities: alive.map((e) => ({ id: e.id, x: e.x, y: e.y, tags: Array.from(e.tags) })), + errors: errors.slice(), // 生成质量信号(behavior/rule 抛错) + }; + } + + 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; diff --git a/game-runtime/src/host/gd-runtime.test.mjs b/game-runtime/src/host/gd-runtime.test.mjs new file mode 100644 index 00000000..123c7353 --- /dev/null +++ b/game-runtime/src/host/gd-runtime.test.mjs @@ -0,0 +1,189 @@ +/** + * 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 缺失/); +}); From b112f888cfb09d57ed8d0207d729aa0833296ef8 Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 17 Jun 2026 23:04:46 -0700 Subject: [PATCH 02/14] =?UTF-8?q?feat(game-runtime):=20U2=20build-from-sou?= =?UTF-8?q?rce=20=E8=A3=85=E9=85=8D=E5=99=A8(gameDefinition=20=E2=86=92=20?= =?UTF-8?q?=E5=8F=AF=E7=8E=A9=E5=B7=A5=E5=8E=82=E6=BA=90=E6=96=87=E6=9C=AC?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan 2026-06-18-001 U2 · 引擎线 · 解锁「源 → 可玩」装配链。 新增 game-runtime/src/host/build-from-source.mjs: - validateSourceProject(sp) → {ok,errors,gameDefinition}:轻量校验 + top-level strip (产线容错必做——剥模型塞的 schema 外键);引用完整性(entity.components→components[].id / scene.entityRefs→entities[].id)+ behavior 缺逻辑 code 揪错。守 game-runtime「本机零依赖」 不引 ajv(inner 键宽松保留运行时用的 vx/vy/tags,待 schema 迭代再收紧)。 - assembleFactoryFromSource(sp,{runtimeImportPath?}) → generated-factory.js 源文本: 对齐已冻装载契约(game-host.d.ts:default export 工厂 → GameInstance{init,update,render, destroy,_forensicsView}),五法委托 U1 createRuntime;gameDefinition 内联为 JS 字面量、 behavior.code 作数据串随之内联(运行期 new Function 编译)。校验失败 fail-loud(err.validationErrors 供 buildNode 回灌 repair feedback)。 接现有零改下游:产出文本 → scaffoldNode 落 generated-factory.js → scripts/build.mjs(esbuild, 不动)→ bundle.iife.js → 九门 harness(零改);entry-bundle.template.js 不动。 验收(lili-mac 快走查): - node --test src/host/build-from-source.test.mjs 5/5——校验+strip / 装配产物含装载契约要件 + node --check 过 / fail-loud / **端到端功能证**(装配文本经 createRuntime 真跑到 win + fx 真接线); 全 runtime 套件 46/46 无回归。 - 真 esbuild 链探针(探针目录已清,不入仓):金样 gameDefinition → assemble → scaffold(正确深度)→ 真 build.mjs → bundle.iife.js(215KB,语法过,__GameBundle 全局在);metafile 实证 gd-runtime.js + boot-game-host.js + littlejsengine 全内联。 范围说明:U2 的后端 Java 接线(SaaGenNodes.buildNode/scaffoldNode 从 K_SOURCE_PROJECT 装配、 SaaStudioNodes.modifyNode deterministic 解除 fail-loud)属后端线、需 mvn/mini-desktop 验证, 随 U3 后端线一并落(plan 允两线并行)。本提交 = U2 引擎线核心(本机可验的装配地基)。 Co-Authored-By: Claude Opus 4.8 --- game-runtime/src/host/build-from-source.mjs | 125 +++++++++++++++++ .../src/host/build-from-source.test.mjs | 128 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 game-runtime/src/host/build-from-source.mjs create mode 100644 game-runtime/src/host/build-from-source.test.mjs diff --git a/game-runtime/src/host/build-from-source.mjs b/game-runtime/src/host/build-from-source.mjs new file mode 100644 index 00000000..e559648e --- /dev/null +++ b/game-runtime/src/host/build-from-source.mjs @@ -0,0 +1,125 @@ +/** + * 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。 + * + * 【接现有·零改下游】产出文本 → scaffoldNode 落 generated-factory.js → scripts/build.mjs(esbuild, + * 唯一构建脚本不动)→ bundle.iife.js → serve-and-play.sh + play.cdp.cjs 九门(harness 零改)。 + * entry-bundle.template.js 不动(它 import './generated-factory.js',装配器只换该文件的来源)。 + * + * 【为何「behaviors 带 JS 经数据串内联」】gameDefinition 内联为 JS 对象字面量,behavior.code 作字符串 + * 随之内联;运行期 createRuntime 经 new Function 编译——故「逻辑代码」以数据形态过 JSON,运行时编译执行。 + * JSON.stringify 已对引号/换行/反斜杠完备转义,作模板插值是「值」非「模板语法」,无注入。 + * + * 【fail-loud】校验失败抛错(带 validationErrors)——buildNode 捕获回灌 repair feedback(生成质量信号), + * 不静默产坏工厂。strip 未知键是产线容错必做(spike 实证模型会塞 schema 外键)。 + * + * 【迭代状态 v0】轻量校验 + top-level strip(不引 ajv 依赖,守 game-runtime「本机零依赖只跑 node --test」); + * inner 实体/组件键宽松保留(运行时用得到的 vx/vy/tags 等),待 source-project.schema.json 迭代再收紧。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +/** gameDefinition 顶层白名单(对齐 schema#/properties/gameDefinition)。 */ +const GAMEDEF_KEYS = ['entities', 'components', 'behaviors', 'scenes', 'rules']; +/** 生成 game 目录中,generated-factory.js → src/host/gd-runtime.js 的相对 import 路径(games/_wg1-gen// 下三级回 game-runtime)。 */ +const DEFAULT_RUNTIME_IMPORT = '../../../src/host/gd-runtime.js'; + +/** + * 轻量校验 + 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') { + 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]; } + + // 结构必需(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 = []; + + // 引用完整性:entity.components → components[].id;scene.entityRefs → entities[].id。 + const compIds = new Set((cleaned.components || []).map((c) => c && String(c.id))); + const entIds = new Set((cleaned.entities || []).map((e) => e && String(e.id))); + for (const e of (cleaned.entities || [])) { + if (!e || e.id == null) { errors.push('实体缺 id'); continue; } + for (const ref of (Array.isArray(e.components) ? e.components : [])) { + if (typeof ref === 'string' && !compIds.has(ref)) errors.push('实体[' + e.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 (cleaned.behaviors || [])) { + if (b && b.code == null && b.js == null) errors.push('behavior[' + ((b && b.id) || '?') + '] 缺逻辑 code'); + } + 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; + const defJson = JSON.stringify(gameDefinition); + 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; diff --git a/game-runtime/src/host/build-from-source.test.mjs b/game-runtime/src/host/build-from-source.test.mjs new file mode 100644 index 00000000..55f715b2 --- /dev/null +++ b/game-runtime/src/host/build-from-source.test.mjs @@ -0,0 +1,128 @@ +/** + * 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); +}); + +/* ② 装配产物对齐装载契约 + 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 }); } +}); From 4292f4e79387fd8e3eac66efa6f54e68d4e21a7a Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 17 Jun 2026 23:08:51 -0700 Subject: [PATCH 03/14] =?UTF-8?q?test(game-runtime):=20U1=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E5=88=A4=E6=8D=AE=E2=80=94=E2=80=94rt=20=E7=BA=A6?= =?UTF-8?q?=E5=AE=9A=E8=B7=A8=204=20=E5=93=81=E7=B1=BB=E8=A1=A8=E8=BE=BE?= =?UTF-8?q?=E5=8A=9B=E8=A6=86=E7=9B=96(=E5=B9=B6=E4=BD=9C=20U3=20few-shot?= =?UTF-8?q?=20=E5=BA=93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan 2026-06-18-001 U1 验收「3-5 类游戏 behaviors 用约定全跑通、零约定外 helper 漂移」。 新增 game-runtime/src/host/gd-archetypes.test.mjs——4 类原型 gameDefinition 经 createRuntime 真驱动: - ① paddle-intercept(realtime+连续输入键/指针 + overlap 反弹 + lose latch) - ② event-clicker(event+离散 justTapped + addScore + rule 胜) - ③ dodge-spawn(realtime + rt.spawn/query/destroy + rt.random + overlap lose) - ④ runner(realtime + physics 组件重力积分 + justTapped/justPressed 跳 + spawn 障碍 + 碰撞 lose) headline 断言 = runtime.errors() 为空(零约定外 helper 漂移/零抛错)+ 各原型出合理状态转移 (移动/计分/终态 latch + 输入响应 + physics 重力 + spawn 真生成)。证 rt 面够表达目标品类谱。 定位:此为「约定表达力」证(手写金样,非真实模型输出)——真实模型 idiom 漂移收集随 U3 (GAMEDEF_SYSTEM prompt + NEWAPI_KEY)落;这 4 份金样并作 U3 prompt few-shot 库源。 验收:node --test src/host/gd-archetypes.test.mjs 4/4;全 runtime 套件 50/50 无回归。 Co-Authored-By: Claude Opus 4.8 --- game-runtime/src/host/gd-archetypes.test.mjs | 197 +++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 game-runtime/src/host/gd-archetypes.test.mjs diff --git a/game-runtime/src/host/gd-archetypes.test.mjs b/game-runtime/src/host/gd-archetypes.test.mjs new file mode 100644 index 00000000..8297ad86 --- /dev/null +++ b/game-runtime/src/host/gd-archetypes.test.mjs @@ -0,0 +1,197 @@ +/** + * 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'); +}); From c8526f1dd6aab9729ee06a9da2f1c45d26bf66f9 Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 17 Jun 2026 23:11:14 -0700 Subject: [PATCH 04/14] =?UTF-8?q?docs(game-runtime):=20U1=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E7=BA=A6=E5=AE=9A=E6=96=87=E6=9C=AC=20runtim?= =?UTF-8?q?e-api-2d.md(=E4=BA=A4=20U3=20prompt=20=E5=B5=8C=E5=85=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan 2026-06-18-001 U1 交付物「约定文字同步喂进 prompt → 交 U3 落 SaaPrompts 新 SYSTEM」。 新增 game-runtime/src/host/runtime-api-2d.md——写 gameDefinition behavior 逻辑时能用的全部 rt 面 的人/模型可读契约:gameDefinition 结构 / behavior 代码契约(new Function('rt','self','dt',code)+ trigger 语义+四条硬约束)/ rules / rt 面全 API 参考 / 组件 / 4 原型 few-shot / 常见坑(治真实模型 漂移:禁约定外 helper、禁 Math.random、终态置 latch、behavior 不画图、关键事件挂 fx)。 U3 的 GAMEDEF_SYSTEM prompt 直接嵌入本文。状态 v0(已过 4 类原型表达力证),保留补 idiom 余量。 落点同 game-host.d.ts 之理(内部宿主↔behavior 接线面 → game-runtime/src/host/); 是否升格 contracts/ 跨仓契约由 6c6g(contracts owner)定。 Co-Authored-By: Claude Opus 4.8 --- game-runtime/src/host/runtime-api-2d.md | 120 ++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 game-runtime/src/host/runtime-api-2d.md diff --git a/game-runtime/src/host/runtime-api-2d.md b/game-runtime/src/host/runtime-api-2d.md new file mode 100644 index 00000000..ffcab560 --- /dev/null +++ b/game-runtime/src/host/runtime-api-2d.md @@ -0,0 +1,120 @@ +# 运行时访问约定(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(...)`(真接引擎粒子/音频)。 + +--- + +## 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 控制。 +- **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`。 + +--- + +## 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`(满「真接线」门)。 From 160d9bc3e0ce807b9d09318e66a26bc3f2857bda Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 17 Jun 2026 23:39:57 -0700 Subject: [PATCH 05/14] =?UTF-8?q?fix(game-runtime):=20U1/U2=20=E5=8F=8C?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=95=B4=E6=94=B9(correctness=C3=97adversari?= =?UTF-8?q?al)=E2=80=94=E2=80=94driver=20=E5=91=BD=E5=90=8D=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=20+=20=E8=BE=B9=E7=95=8C=E9=9D=99=E6=80=81=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=20+=20rt=20=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 子代理双评审(ce-correctness + ce-adversarial)对引擎线 9+9 条发现,逐条核证为真后整改: gd-runtime.js: - [HIGH] state() 派生九门 harness driver 命名路径:逐实体按 id 投影顶层位(ball.x/paddle.x/bird.vy… +w/h/r/angle)+ tag='target' → targets[{x,y,idx,occupied,safe}](对齐 gatespec driver 契约)。 此前 driver 读 undefined→无法驱动→机制门必挂(plan U1 step5 要求,原缺)。 - [High] addScore 守 num(NaN/字符串不污染 score 共享契约);[High] 冻结 rt 纯 API 面 (input/fx/time/view+本体,防 behavior 改写共享 API 串味全局,实体仍可变)。 - [Med] 重复实体 id:addEntity 保先到者可寻址+记信号,reap 仅删 byId 指向的死亡实例(不误删存活者) + 实体上限 5000(防失控 spawn 静默膨胀)。 - 移除 rt.time.nowMs(墙钟=非确定性逃逸口);elapsed 帧末推进(首帧 now()=0);输入沿恒帧末清 (含 latch 后,杜绝滞留);overlap 显式 0 被尊重(不被 truthy 误当默认)。 build-from-source.mjs: - [keystone] 校验边界静态扫描 behavior.code/rule.condition:拦死循环(while(true)/for(;;))、沙箱逃逸 (process/require/eval/.constructor/Function/globalThis)、确定性逃逸(Math.random/Date)、DOM/网络、 condition 副作用(;/赋值/win-lose-spawn-destroy)→ 转 validationErrors 回灌 repair。同步 JS 不可进程内 硬中断+new Function 不沙箱,故在边界拦截(模型代码只在浏览器执行,esbuild 仅静态打包);信任模型写进 header。 - [Med] 非数组 components/entities 强制数组化(不抛裸 TypeError);behavior code 须非空字符串; id 唯一性+null/undefined id 不入引用集(杜绝 'null'/'undefined' 假命中);循环引用 JSON fail-loud (带 validationErrors);`<`→< 转义(inline-injection 安全,防 提前闭合宿主内联 script)。 runtime-api-2d.md:补「边界硬拦截」「取证/driver 命名约定」(实体命名 ball/paddle、目标打 target 标)——交 U3 prompt 教模型。 验收:node --test 全 runtime 套件 59/59(原 50 + 9 条修复验证:命名路径/addScore守卫/rt冻结/重复id/ nowMs移除/非数组/非串code/静态扫描/循环引用+转义);archetype few-shot 经核证 scan-clean(不教被拒模式)。 Co-Authored-By: Claude Opus 4.8 --- game-runtime/src/host/build-from-source.mjs | 125 +++++++++++++++--- .../src/host/build-from-source.test.mjs | 48 +++++++ game-runtime/src/host/gd-runtime.js | 93 ++++++++++--- game-runtime/src/host/gd-runtime.test.mjs | 62 +++++++++ game-runtime/src/host/runtime-api-2d.md | 15 +++ 5 files changed, 305 insertions(+), 38 deletions(-) diff --git a/game-runtime/src/host/build-from-source.mjs b/game-runtime/src/host/build-from-source.mjs index e559648e..0df75c22 100644 --- a/game-runtime/src/host/build-from-source.mjs +++ b/game-runtime/src/host/build-from-source.mjs @@ -7,19 +7,22 @@ * `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 零改)。 - * entry-bundle.template.js 不动(它 import './generated-factory.js',装配器只换该文件的来源)。 * - * 【为何「behaviors 带 JS 经数据串内联」】gameDefinition 内联为 JS 对象字面量,behavior.code 作字符串 - * 随之内联;运行期 createRuntime 经 new Function 编译——故「逻辑代码」以数据形态过 JSON,运行时编译执行。 - * JSON.stringify 已对引号/换行/反斜杠完备转义,作模板插值是「值」非「模板语法」,无注入。 + * 【为何「behaviors 带 JS 经数据串内联」】gameDefinition 内联为 JS 对象字面量,behavior.code 作字符串随之内联; + * 运行期 createRuntime 经 new Function 编译。JSON.stringify 完备转义;另把 `<` 转 `<`(保 inline-injection 安全, + * 防 提前闭合 host 页内联 /