feat(adapter): 时间压力维度——新增 elapsed 相对秒,支撑 time-based win/lose 规则

修一处潜在语义错 + 补休闲玩法时间压力维度(限时/倒计时):
- 旧 state.t = nowMs/1000 是【绝对单调时】(受控面明禁跨设备比绝对值),用作 `t >= timeLimit` 规则会因时钟原点(performance.now/Date.now)立判或漂移——错
- 新增 state.elapsed = (now - startMs)/1000(init 记 startMs,相对秒),forensics 暴露 elapsed;规则 `elapsed >= config.timeLimit → win/lose` 正确(零新增 op,正交叠加任一族)
- 单测经注入时钟(createHostDevContext({clock}))验:init@1s 起点,clockMs=3000 时 elapsed=2(相对,非绝对 3)仍 playing,clockMs=4500 时 elapsed=3.5 触发 lose——证相对非绝对

验证:src 全量 197/197 绿(+1 时间压力);additive 改动经回归九门(tap-targets 重建探针 A–I 全绿,H score:0→3+latch)确认不破坏既有 6 族,探针已清场。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-17 15:10:52 -07:00
parent f97653e8ee
commit 33d9cd62ad
3 changed files with 33 additions and 3 deletions

View File

@ -23,7 +23,7 @@
- **input op**(behavior.trigger='input'):`hit-nearest-target`(点最近目标计分)· `control-x`(实体 x 跟手=连续控制)· `flap`(竖直上冲)· `move-lr`(点左/右半 → 实体左/右移一步=方向式 steer)· `tap-safe`(点 safe 目标计分、点 `.hazards` 列出的 hazard 即记 miss=辨别/规避)。
- **update op**(trigger='update'):`drift-targets`(目标慢漂移)· `fall-and-catch`(下落物+接住=控制类)· `flappy-gap`(重力+穿缝=flappy)· `reach-goal`(实体 x 抵达目标 x 容差内 → 计分+目标重生=导航/收集)。
- **rule**:`{ condition: "<state字段> <op> <数字|config.键>", outcome: win|lose }`(序前优先);胜负 → `phase=gameover` + **latch 驻留**
- **rule**:`{ condition: "<state字段> <op> <数字|config.键>", outcome: win|lose }`(序前优先);胜负 → `phase=gameover` + **latch 驻留**可引用 state 字段:`score`/`misses`/**`elapsed`**(相对秒,init 起算)。**时间压力维度**(限时/倒计时)= `elapsed >= config.timeLimit → lose`(零新增 op,复用 rule 机制);⚠ 勿用 `t`(绝对单调时,禁作时限判)。
- **命中/接住/穿缝/抵达/胜负事件**经 `ctx.getEngine()` 发粒子+音效(满足九门 F 真接线 + game-feel)。
## 六类资产消费(E3,对齐 `contracts/agent-loop/source-project.schema.json` 冻结 assetSpec)
@ -43,6 +43,7 @@ assetSpec = `{ id, category∈[sprite,character,effect,scene,ui,music], ref, url
## 已验证六族(均过真九门 AI + 首局门)
> 覆盖休闲玩法的主要机制维度:点击 / 连续控制 / 离散方向 steer / 物理 / 导航 / 辨别(负反馈)。
> **时间压力维度**(限时/倒计时)经 `elapsed >= config.timeLimit` 规则正交叠加于任一族(复用现有 driver,无新族),单测已验。
| fixture | 族 | driver | 关键门 |
|---|---|---|---|

View File

@ -181,6 +181,32 @@ test('win 优先于 lose先达 winScore 即 win(规则序)', () => {
assert.equal(game._forensicsView().state().result, 'win');
});
test('时间压力族elapsed >= config.timeLimit 规则 + 注入时钟 → 到时 lose(零新增 op,复用 rule 机制)', () => {
// 注入可控时钟,验 time-based 规则:elapsed 是【相对秒】(init 起点起算),非 state.t 的绝对单调值。
let clockMs = 0;
const { context } = createHostDevContext({ clock: () => clockMs });
const src = {
gameDefinition: {
entities: [{ id: 't', transform: { position: { x: 100, y: 200 } }, components: ['r'] }],
components: [{ id: 'r', kind: 'render', shape: 'rect', w: 50, h: 50, color: '#3cf' }],
behaviors: [{ id: 'b', trigger: 'input', op: 'hit-nearest-target' }],
scenes: [{ id: 's', entityRefs: ['t'] }],
rules: [{ id: 'timeout', condition: 'elapsed >= config.timeLimit', outcome: 'lose' }],
},
config: { timeLimit: 3 },
};
const game = adaptGameDefinition(src)();
clockMs = 1000; // init 在绝对 t=1s 记起点 → 验 elapsed 用相对差(否则绝对 1>... 立判,本例必假阴/假阳)
game.init({ ctx: context, mainContext: recordingCtx2d(), canvas: {}, seed: 1 });
clockMs = 3000; game.update(0.1); // elapsed=(3000-1000)/1000=2 < 3 → 未到时(若误用绝对 t=3 则会错误触发)
assert.equal(game._forensicsView().state().phase, 'playing', '未到时限应仍 playing');
assert.ok(Math.abs(game._forensicsView().state().elapsed - 2) < 1e-6, 'elapsed 应=相对 2 秒(非绝对 3)');
clockMs = 4500; game.update(0.1); // elapsed=3.5 >= 3 → 到时
const v = game._forensicsView().state();
assert.equal(v.phase, 'gameover');
assert.equal(v.result, 'lose');
});
test('E3assets[] 入 forensics(通道可观测)+ spec-only 几何兜底不崩', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot, g } = makeBoot();

View File

@ -97,16 +97,18 @@ export function adaptGameDefinition(source) {
let ctx = null;
let sub = null;
let nowMs = 0;
let startMs = null; // 时间压力族init 记游戏起点;state.t 是绝对单调时(禁作时限判),elapsed 才是相对秒
let bgImg = null; // E3scene 类背景图(有 url 时浏览器侧 new Image;node/无 url → null 走默认底色)
// 运行态:目标(可点击实体)+ 计分 + phasebooting|playing|gameover对齐 P0 latch 终态)。
/** @type {{id:string,x:number,y:number,w:number,h:number,color:string,hit:boolean}[]} */
let targets = [];
const state = { phase: 'booting', score: 0, misses: 0, result: null, t: 0 };
const state = { phase: 'booting', score: 0, misses: 0, result: null, t: 0, elapsed: 0 };
const entById = (id) => targets.find((t) => t && t.id === id); // 控制类按实体 id 取(paddle/faller)
/** init建目标、订阅受控输入、置 playing。 */
function init(boot) {
ctx = boot.ctx;
startMs = ctx.time.nowMs(); // 时间压力族:记游戏起点 → elapsed=(now-start)/1000(相对秒),正确支撑 `elapsed >= config.timeLimit` 规则
// E3预载 scene 背景图(有 url 才在浏览器侧 new Image;node 无 Image / 无 url → bgImg 保持 null,render 走默认底色)。
if (sceneAssetSpec && sceneAssetSpec.url && typeof Image !== 'undefined') {
try { bgImg = new Image(); bgImg.src = sceneAssetSpec.url; } catch (_) { bgImg = null; }
@ -221,7 +223,7 @@ export function adaptGameDefinition(source) {
/** update刷钟 + 评估 rules胜负 → phase=gameover + 终态驻留)。 */
function update(dt) {
if (ctx) { nowMs = ctx.time.nowMs(); state.t = nowMs / 1000; }
if (ctx) { nowMs = ctx.time.nowMs(); state.t = nowMs / 1000; state.elapsed = startMs != null ? (nowMs - startMs) / 1000 : 0; }
if (state.phase !== 'playing') return; // 已终态latch 驻留,不再评估
// trigger=update 行为本切片drift-targets 目标慢漂移,证 realtime/update 路)。
for (const b of behaviors) {
@ -372,6 +374,7 @@ export function adaptGameDefinition(source) {
result: state.result,
progress: ws ? state.score / ws : 0,
remaining: Math.max(0, ws - state.score), // 剩余到通关(对齐 prompt.py HUD 硬规则 + harness 读)
elapsed: state.elapsed || 0, // 时间压力族:相对秒(供 `elapsed >= config.timeLimit` 规则 + 倒计时 HUD;绝对 state.t 禁作时限)
tickModel: profile.tickModel || null,
assets: assets.map((a) => ({ id: a.id, category: a.category })), // E3 通道可观测(§10.2-3)
// tap-targets driver 读 targets[{idx,x,y,occupied,safe}] 自适应出招(逻辑坐标=中心点)。