fix(saa): 复审整改 C2 客观门必达集 + M3 碰撞尺寸不拷顶层 + Phase 3 玩法坑离线门

CodeRabbit/Codex 双复审整改(针对 4e5a7bbf):

- C2(critical, SaaGenNodes.objectiveGatesPass):改「A-E 五门全在场且 pass,缺任一即失败」。旧「出现才查」会被 harness 中途崩(runDriver 在 D/E 前抛、未 try/catch)钻空子——verdict 只剩 A_boot/C_frame 即 sawObjective=true 放行=崩掉的游戏判过。缺门=未跑完=失败才安全。
- M3(major, gd-runtime RESERVED += w/h/r):碰撞尺寸 w/h/r 不拷实体顶层——它们是 rt.overlap 读顶层的命中框(缺省16),拷上去静默改存量游戏命中框且违反 prompt 契约「顶层 e.w 永 undefined」。
- Phase 3(OpenGame +6.9 离线 linter):build-from-source 加两族【零误伤】玩法哑火坑门——①.tags 是 Set 误用数组法/下标/length ②漏 rt. 前缀裸引擎调(补 LOGIC_BANS 不拦 getEngine/getInput/restart 的洞,含此坑源装配过却运行期夭折)。命中→repair,真玩前修省一轮 CDP。self 跨 behavior 泄漏因误伤过门款(gd-tictactoe/simon 带 bug 却过门,self 确 per-behavior)降级待非阻塞 warning 通道。新增 gameplay-pitfalls.test.mjs:正例拦坑 + gd-breakout/tictactoe/simon 零误伤,32/32 全绿(含既有回归)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-20 12:28:41 -07:00
parent fba7f3e030
commit 5bc0afb4bb
4 changed files with 97 additions and 13 deletions

View File

@ -561,19 +561,18 @@ final class SaaGenNodes {
if (!guards.isObject() || guards.size() == 0) {
return verdict.path("pass").asBoolean(false);
}
boolean sawObjective = false;
Iterator<Map.Entry<String, JsonNode>> it = guards.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> e = it.next();
if (!OBJECTIVE_GATES.contains(e.getKey())) {
continue; // driver 依赖门(G/H/I)等:不参与生成硬门。
}
sawObjective = true;
if (!e.getValue().path("pass").asBoolean(false)) {
return false;
// C2 修(CodeRabbit/Codex 双报,2026-06-20):客观门按【必达集】校验——5 门必须【全部存在且 pass】,缺任一即判失败。
// 旧"遍历出现的门、出现才查"会被 harness 中途崩钻空子误判 PASS:play.cdp.cjs 的 runDriver(含 seek-food)在
// D_render/E_live 之前执行且未 try/catch,conc=12 下 CDP 抖动抛错→主 catch 只补 A_boot→verdict 只剩
// A_boot/C_frame/I_control→旧逻辑 sawObjective=true 放行=【崩掉、根本没测到渲染/活性的游戏被判过】。
// 缺门=harness 未跑完=失败,才安全(旧 rc==0 口径反不会误判;本修补回该安全性、又不重新绑 driver 门)。
for (String gate : OBJECTIVE_GATES) {
JsonNode g = guards.path(gate);
if (g.isMissingNode() || !g.path("pass").asBoolean(false)) {
return false; // 客观门缺失或未过 → 生成硬门判失败。
}
}
return sawObjective || verdict.path("pass").asBoolean(false);
return true;
}
/**

View File

@ -86,6 +86,35 @@ function behaviorCode(b) {
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)。
@ -145,6 +174,7 @@ export function validateSourceProject(sourceProject) {
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 : [])) {

View File

@ -0,0 +1,53 @@
// 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

@ -133,11 +133,13 @@ export function createRuntime(boot, gameDefinition) {
set(k, v) { this[k] = v; return v; },
destroy() { this.alive = false; },
};
// 去脚枪(Plan A·S2):把 spec/entities 字面量上的自定义标量字段(gridX/colorIdx/scored/idx/hp/w/h 等)
// 去脚枪(Plan A·S2):把 spec/entities 字面量上的自定义标量字段(gridX/colorIdx/scored/idx/hp 等)
// 拷到实体,使「spec 带自定义字段」的模型自然假设成真(原仅留 id/x/y/vx/vy/tags/components → §0
// 跨款最大失败簇:自定义状态读到 undefined→逻辑空转/冻屏)。仅标量(num/str/bool)、不覆盖保留键/已设键;
// 取证 entityView 白名单投影,故自定义字段不外泄取证。对象/数组不拷(避免共享引用意外)。
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 };
// 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];