docs: design and plan platform UI wiring core
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
# Platform UI Wiring Core Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 完成启动、登录、大厅、进房的无引擎 UI 接线核心,并用真实 PlatformRuntime 回放验证,不修改 Cocos 序列化资源。
|
||||
|
||||
**Architecture:** Runtime 保持唯一状态源;presentation 投影 ViewModel、转发命令并实现同步 ScenePort。实际节点绑定留给单独批准的 3B 编辑器批次;3A 使用记录型 ViewPort 验证。
|
||||
|
||||
**Tech Stack:** 现有 TypeScript、node:test、tsx;不新增依赖,不导入 cc。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-05-platform-ui-wiring-design.md`,并继承 `2026-09-04-framework-subgame-zero-coupling-migration-design.md`。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 服务器包、route/rpc、roomtype 与 deskinfo 全部沿用已验证 contracts。不得为 UI 新增字段。
|
||||
- 远程配置与原生读取/WVJB 不在本批修改。
|
||||
- 任何 `.scene/.prefab/.anim/.meta` 变更均使 3A 验收失败。
|
||||
- 3A 测试夹具只留在 framework-tests,不导入 assets,不声明生产 GameEntry。
|
||||
- 不扩充 SDK 出口,不依赖旧 PlatformSession/RoomRPCBus,不引入下游兜底。
|
||||
- 每个 Task 独立执行 RED → GREEN → 规格/实现审查 → 代码质量审查 → 修复复验 → scoped commit。审查不通过不得推进下一 Task。
|
||||
- 执行使用新的隔离 worktree;先按 using-git-worktrees 核实,不重建已删除的旧工作树来冒充继续旧任务。本文件不授权编辑器资源写入。
|
||||
|
||||
## 执行环境与文件地图
|
||||
|
||||
所有代码路径在本计划中相对 `cocoscreator_projects/`;文档路径相对仓库根。所有命令在 `cocoscreator_projects` 执行,git 路径也据此指定。
|
||||
|
||||
先完整阅读仓库 AGENTS.md、上述两个设计及本计划。基线为 `8eae325`,执行时检查分支状态并记录新的 base SHA;保留无关修改。不需要重新执行已完成的 decoupled-runtime Tasks 1–11。
|
||||
|
||||
新增 `YouleNexus/assets/framework/presentation/`:`ui-contracts.ts` 管端口和模型;`page-model.ts` 管纯投影;`frame-renderer.ts` 管合帧与取消;`platform-ui-controller.ts` 管 ScenePort 和交互。不建第二个 Store 或 barrel SDK 出口。
|
||||
|
||||
修改 `platform/runtime.ts` 只增加只读观察及其终止清理;测试放在 `framework-tests/presentation/`,真实运行时联合回放追加到已有 `framework-tests/platform/runtime.test.ts`,复用其中 makeRuntime、RecordingWireClient、ManualClock、makeGameEntry、fixture,不把测试帮助函数搬进产品代码。
|
||||
|
||||
通用验证命令:
|
||||
|
||||
```powershell
|
||||
node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit
|
||||
node scripts/check-import-boundaries.mjs
|
||||
node --test framework-tests/architecture/import-boundaries.test.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
npm 不可解析时使用 `C:\Users\Joywayer\AppData\Local\Author Software\nvm\.nodejs\npm.exe`。tsx 启动的 `uv_os_get_passwd` ENOMEM 属于待核实环境失败,不是有效 RED;申请在允许的环境运行同一只读测试命令,不改依赖、不写个人环境绕过脚本。不要运行含旧资源迁移生成器的全量 npm test:framework。
|
||||
|
||||
## Task 1: 给 Runtime 增加有明确错误通道的只读观察
|
||||
|
||||
**Files:** Modify `YouleNexus/assets/framework/platform/runtime.ts`; Test `framework-tests/platform/runtime.test.ts`。
|
||||
|
||||
**Interfaces:** Consumes `PlatformStore.subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void` 和 Runtime 既有清理路径。Produces `PlatformRuntime.subscribeState(listener: (state: PlatformState) => void, onError: (error: unknown) => void): () => void`,不立即通知,初值仍取 state。
|
||||
|
||||
- [ ] 在 runtime.test.ts 现有 helpers 下增加实际观察测试:
|
||||
|
||||
```ts
|
||||
test('UI observes canonical commits and unsubscribe is idempotent', async () => {
|
||||
const { runtime, wire } = makeRuntime();
|
||||
const seen: unknown[] = [];
|
||||
const off = runtime.subscribeState(s => seen.push(s), e => { throw e; });
|
||||
assert.equal(seen.length, 0);
|
||||
await runtime.start();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
assert.equal(seen[seen.length - 1], runtime.state);
|
||||
off(); off();
|
||||
const count = seen.length;
|
||||
runtime.login(ACCOUNT);
|
||||
wire.emit({ type: 'message', message: {
|
||||
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
|
||||
} });
|
||||
assert.equal(seen.length, count);
|
||||
runtime.stop();
|
||||
assert.throws(() => runtime.subscribeState(() => {}, () => {}), /stopped/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Run `node --import tsx --test framework-tests/platform/runtime.test.ts`,确认因缺 subscribeState 失败。
|
||||
- [ ] 增加每观察者 active 标志、取消集合及终止清理;Store 回调交付提交值,不改为读取可能被重入更新的最新值。核心正常取消结构:
|
||||
|
||||
```ts
|
||||
let active = true;
|
||||
const off = (): void => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
unsubscribeStore();
|
||||
this.stateObservers.delete(off);
|
||||
};
|
||||
const unsubscribeStore = this.store.subscribe(state => {
|
||||
if (!active) return;
|
||||
try { listener(state); }
|
||||
catch (error) {
|
||||
off();
|
||||
try { onError(error); }
|
||||
catch (callbackError) {
|
||||
this.showFatalOnce(this.failureError('UI observer', error, [callbackError]));
|
||||
}
|
||||
}
|
||||
});
|
||||
this.stateObservers.add(off);
|
||||
return off;
|
||||
```
|
||||
|
||||
`stateObservers` 是 Runtime 新增 `Set<() => void>`;现有 failureError 签名为 `(operation: string, primary: unknown, cleanupErrors: readonly unknown[]): Error`。入口拒绝 `kicked`、`fatal`、`stopped`,错误携带当前生命周期;不新增生命周期。stop 与 fatal 在外部清理回调前使观察者失效。不得将原始错误改成默认数据。
|
||||
- [ ] 追加 RED/GREEN:监听器抛错原对象只交付一次、其他监听继续;onError 抛错保留两错误;stop/fatal 后迟到 Store 回调无效;监听中 stop/取消其他监听/再订阅不会复活。测试使用现有 runtime helpers 和 Store 行为,不用私有字段强转写状态。
|
||||
- [ ] 重跑上述测试和通用验证。规格审查核对原引用、初值与取消语义,质量审查核对异常/重入清理。提交只含本 Task 两个文件,message `feat(platform): expose lifecycle-safe UI observation`。
|
||||
|
||||
## Task 2: 定义 UI 端口并实现唯一快照投影
|
||||
|
||||
**Files:** Create `YouleNexus/assets/framework/presentation/ui-contracts.ts`, `YouleNexus/assets/framework/presentation/page-model.ts`, `framework-tests/presentation/page-model.test.ts`。
|
||||
|
||||
**Interfaces:** Consumes Task 1 的 PlatformRuntime 只读观察和既有 `PlatformState`、`selectSelfPlayer`、`selectRoomPlayers`。Produces 下列完整端口与 `selectPageModel(page: PageId, state: PlatformState, ready: boolean): PageModel`:
|
||||
|
||||
```ts
|
||||
import type { PlatformRuntime } from '../platform/runtime.ts';
|
||||
import type { ServerDenial } from '../platform/runtime-session.ts';
|
||||
export type UiRuntime = Pick<PlatformRuntime,
|
||||
'state' | 'ready' | 'subscribeState' | 'login' | 'joinRoom' | 'prepare' | 'exitRoom'>;
|
||||
export type PageId = 'loading' | 'login' | 'lobby' | 'room';
|
||||
export interface PlayerModel {
|
||||
readonly playerid: number; readonly nickname: string;
|
||||
readonly avatar: string; readonly bean: number;
|
||||
}
|
||||
export type SeatModel =
|
||||
| { readonly kind: 'empty'; readonly seat: number }
|
||||
| { readonly kind: 'occupied'; readonly seat: number;
|
||||
readonly player: PlayerModel; readonly ready: boolean; readonly offline: boolean };
|
||||
export type PageModel =
|
||||
| { readonly page: 'loading' }
|
||||
| { readonly page: 'login'; readonly canLogin: boolean }
|
||||
| { readonly page: 'lobby'; readonly self: PlayerModel }
|
||||
| { readonly page: 'room'; readonly roomcode: string; readonly selfSeat: number;
|
||||
readonly seats: readonly SeatModel[]; readonly canPrepare: boolean; readonly canExit: boolean };
|
||||
export type OverlayModel =
|
||||
| { readonly kind: 'none' }
|
||||
| { readonly kind: 'reconnect' }
|
||||
| { readonly kind: 'denial'; readonly outcome: ServerDenial }
|
||||
| { readonly kind: 'kicked'; readonly data: unknown }
|
||||
| { readonly kind: 'fatal'; readonly error: Error };
|
||||
export interface FrameScheduler { request(callback: () => void): () => void; }
|
||||
export interface PlatformViewPort {
|
||||
render(model: PageModel): void;
|
||||
activate(page: PageId): void;
|
||||
showOverlay(model: OverlayModel): void;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] 编写投影 RED;测试文件按相对路径 `../../YouleNexus/assets/framework/...` 导入类型/函数,使用 node:test 和 strict assert:
|
||||
|
||||
```ts
|
||||
test('empty seats keep authoritative positions and count', () => {
|
||||
for (const count of [2, 4, 10]) {
|
||||
const state: PlatformState = {
|
||||
app: { phase: 'logged-in' },
|
||||
players: { selfPlayerId: 7, entities: {
|
||||
7: { playerid: 7, nickname: 'self', avatar: '', bean: 0, sex: 0,
|
||||
isprepare: 0, onstate: 0 },
|
||||
} },
|
||||
room: { kind: 'inside', roomcode: '000123', selfSeat: 0,
|
||||
seatPlayerIds: [7, ...Array<null>(count - 1).fill(null)],
|
||||
readySeats: [], offlineSeats: [], roomtype: [], stage: 0,
|
||||
needprepare: 1, infinite: 0 },
|
||||
};
|
||||
const model = selectPageModel('room', state, true);
|
||||
assert.equal(model.page, 'room');
|
||||
if (model.page !== 'room') throw new Error('expected room');
|
||||
assert.equal(model.roomcode, '000123');
|
||||
assert.equal(model.seats.length, count);
|
||||
assert.deepEqual(model.seats[1], { kind: 'empty', seat: 1 });
|
||||
assert.equal(model.canPrepare, true);
|
||||
assert.equal(model.canExit, true);
|
||||
assert.ok(Object.isFrozen(model));
|
||||
assert.ok(Object.isFrozen(model.seats));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Run `node --import tsx --test framework-tests/presentation/page-model.test.ts`,缺模块/函数为预期 RED。
|
||||
- [ ] 实现 switch(page),所有输出及嵌套模型 freeze;不直接泄露 PlatformPlayer 扩展字段。empty 来源仅允许 null。room occupied 的 onstate/isprepare 缺失抛带 seat/player 路径错误;selfSeat 必须指向 self。核心投影:
|
||||
|
||||
```ts
|
||||
const playerModel = (player: PlatformPlayer): PlayerModel => Object.freeze({
|
||||
playerid: player.playerid, nickname: player.nickname,
|
||||
avatar: player.avatar, bean: player.bean,
|
||||
});
|
||||
const canExit = state.app.phase === 'logged-in'
|
||||
&& (room.stage === 0 || room.infinite === 1);
|
||||
```
|
||||
|
||||
PlatformPlayer 从既有 platform-types type-only 导入;room 是 inside 判别收窄后的 state.room。完整 canPrepare 条件按设计第 5 节,不扩展服务器规则。
|
||||
- [ ] 追加逐项 RED/GREEN:lobby 缺 self、occupied 缺实体、selfSeat 不匹配必报错;bean=0/空 avatar 不变;已有准备/离线状态;战斗普通房禁退出、infinite 房可退出;reconnecting 禁命令;输入 roomtype 完全不解析。
|
||||
- [ ] 重跑本测试和通用验证;规格审查查空位及服务器原值,质量审查查冻结/判别联合/职责。scoped commit 三文件,message `feat(presentation): project immutable public page models`。
|
||||
|
||||
## Task 3: 实现可取消、抗重入的合帧渲染
|
||||
|
||||
**Files:** Create `YouleNexus/assets/framework/presentation/frame-renderer.ts`, `framework-tests/presentation/frame-renderer.test.ts`。
|
||||
|
||||
**Interfaces:** Consumes Task 2 `FrameScheduler`。Produces `class FrameRenderer`,constructor `(scheduler: FrameScheduler, render: () => void, onFault: (error: unknown) => void)`;方法 `request(): void`, `flush(): void`, `dispose(): void`。
|
||||
|
||||
- [ ] 写入可直接运行的 RED(导入 FrameRenderer、test、assert):
|
||||
|
||||
```ts
|
||||
test('coalesces frames and invalidates callbacks even if scheduler delivers late', () => {
|
||||
const callbacks: (() => void)[] = [];
|
||||
let cancelled = 0;
|
||||
let rendered = 0;
|
||||
const renderer = new FrameRenderer({
|
||||
request(cb) { callbacks.push(cb); return () => { cancelled++; }; },
|
||||
}, () => { rendered++; }, e => { throw e; });
|
||||
renderer.request(); renderer.request();
|
||||
assert.equal(callbacks.length, 1);
|
||||
renderer.flush();
|
||||
assert.equal(rendered, 1);
|
||||
callbacks[0]!();
|
||||
assert.equal(rendered, 1);
|
||||
renderer.request();
|
||||
renderer.dispose(); renderer.dispose();
|
||||
callbacks[1]!();
|
||||
assert.equal(rendered, 1);
|
||||
assert.equal(cancelled, 2);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Run `node --import tsx --test framework-tests/presentation/frame-renderer.test.ts`,确认缺类 RED。
|
||||
- [ ] 实现 pending cancel、generation、disposed。flush 先失效旧 token、取消旧任务再同步 render;同步异常直接抛出。request 的异步回调先释放 pending,再 try render,失败 dispose 并调用 onFault 原错误。dispose 先置终止位再调用外部 cancel;即使 cancel 抛错,迟到任务仍无效。核心失效顺序:
|
||||
|
||||
```ts
|
||||
const cancel = this.pending;
|
||||
this.pending = null;
|
||||
this.generation += 1;
|
||||
if (cancel !== null) cancel();
|
||||
```
|
||||
|
||||
pending 初始为 null,generation 初始为 0。request/dispose 终止后的请求不排队;flush 终止后不渲染。取消错误和原 render 错误同时存在时用包含原对象的显式聚合错误,不吞掉任何一个。
|
||||
- [ ] 追加 RED/GREEN:render 内 request 只排下一帧;render 内 dispose 后不得重排;取消抛错仍失效;render 异步抛错只报一次;flush 同步抛错不吞。测试实现手动 scheduler,不用真实计时。
|
||||
- [ ] 重跑本测试与通用验证;规格审查查同帧最新/首帧,质量审查查 token 与回调重入。scoped commit 两文件,message `feat(presentation): add cancellable frame renderer`。
|
||||
|
||||
## Task 4: 实现 ScenePort Presenter 和原样命令入口
|
||||
|
||||
**Files:** Create `YouleNexus/assets/framework/presentation/platform-ui-controller.ts`, `framework-tests/presentation/platform-ui-controller.test.ts`。
|
||||
|
||||
**Interfaces:** Consumes Tasks 1–3。Produces `class PlatformUiController implements ScenePort`;constructor `(view: PlatformViewPort, scheduler: FrameScheduler, onFault: (error: unknown) => void)`;`connect(runtime: UiRuntime): void`;全部既有 ScenePort 方法;`login(account: LoginAccountIdentity): void`, `joinRoom(command: JoinRoomCommand): void`, `prepare(): void`, `exitRoom(): void`, `dismissDenial(): void`, `dispose(): void`。账号/命令/ScenePort 类型均 type-only 导入既有定义。
|
||||
|
||||
- [ ] 先写纯端口 RED,测试 helper 也留在本测试文件:
|
||||
|
||||
```ts
|
||||
test('login forwards the exact account only after the runtime opens login', () => {
|
||||
const sent: unknown[] = [];
|
||||
const runtime: UiRuntime = {
|
||||
state: { app: { phase: 'connected' },
|
||||
players: { selfPlayerId: null, entities: {} }, room: { kind: 'outside' } },
|
||||
ready: true, subscribeState: () => () => {},
|
||||
login: account => { sent.push(account); }, joinRoom: () => {},
|
||||
prepare: () => {}, exitRoom: () => {},
|
||||
};
|
||||
const events: string[] = [];
|
||||
const controller = new PlatformUiController({
|
||||
render: model => { events.push(`render:${model.page}`); },
|
||||
activate: page => { events.push(`activate:${page}`); }, showOverlay: () => {},
|
||||
}, { request: () => () => {} }, error => { throw error; });
|
||||
controller.connect(runtime);
|
||||
const account = { openid: 'o1', nickname: 'name', avatar: '', sex: 0,
|
||||
province: '', city: '', unionid: 'u1' };
|
||||
assert.throws(() => controller.login(account), /login/);
|
||||
controller.showLogin();
|
||||
controller.login(account);
|
||||
assert.equal(sent[0], account);
|
||||
assert.deepEqual(events.slice(-2), ['render:login', 'activate:login']);
|
||||
controller.dispose();
|
||||
assert.throws(() => controller.login(account), /disposed/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Run `node --import tsx --test framework-tests/presentation/platform-ui-controller.test.ts`,缺类为 RED。
|
||||
- [ ] 实现一次 connect;订阅之后读取 runtime.state。尚未收到 ScenePort 页面请求时不画业务页。切页保存 page、flush 最新模型、检查未被 render 重入终止后 activate;不调用 director/loadScene。核心模型读取:
|
||||
|
||||
```ts
|
||||
private renderCurrent(): void {
|
||||
if (this.page === null) return;
|
||||
const runtime = this.requireRuntime();
|
||||
this.view.render(selectPageModel(this.page, runtime.state, runtime.ready));
|
||||
}
|
||||
```
|
||||
|
||||
page 初始 null;requireRuntime 对未 connect/已 dispose 显式抛错。用 FrameRenderer 承担合帧,controller 保留 terminal/disposed 生命周期及当前 OverlayModel,不保留玩家/房间镜像。每个外部 View 调用后检查终止位再继续。connect 重复拒绝;dispose 幂等,取消 frame 和 subscription 均尝试执行。
|
||||
- [ ] 实现命令 guards,直接 `runtime.login(account)`/`runtime.joinRoom(command)`,不 spread 或加工业务输入。prepare/exit 使用最新 page model 决定 UI 可用性。恢复/denial/kick/fatal 规则严格按设计第 6 节:terminal 禁命令但仍允许第一次 fatal 画 overlay,fatal 优先于 kicked。
|
||||
- [ ] 每个行为先加 RED 再实现:join 原引用;ready=false 禁登录;每帧最新 state;showRoom 同步 render→activate;denial 保留 data 身份且重试成功;connected/logged-in 清 reconnect 但不清 denial;kick/fatal 后迟到回调不绘制;render/activate/showOverlay 中 dispose 不再调用后续 View;订阅/frame 错误调用 onFault,同步错误向 Runtime 传播。
|
||||
- [ ] 重跑三个 presentation 测试和通用验证;规格审查查导航唯一来源/拒绝重试,质量审查查 controller 未引入第二状态源、循环清理。scoped commit 两文件,message `feat(presentation): wire platform scenes and UI commands`。
|
||||
|
||||
## Task 5: 用真实 Runtime 验证整条接线并锁定边界
|
||||
|
||||
**Files:** Modify `framework-tests/platform/runtime.test.ts`; Create `framework-tests/architecture/presentation-boundaries.test.mjs`; Modify 本计划仅勾选已验证任务并记录结果。
|
||||
|
||||
**Interfaces:** Consumes `PlatformUiController`、真实 `PlatformRuntime` 及 runtime.test.ts 现有 helpers。Produces 无新增生产 API;只读集成回放与静态边界门禁。
|
||||
|
||||
- [ ] 将下面集成 RED 加入 runtime.test.ts,导入 controller;使用现有 ACCOUNT、DEVICE、runtimeConfig、RecordingWireClient、ManualClock、fixture、makeGameEntry:
|
||||
|
||||
```ts
|
||||
test('real runtime renders the room before restoring canonical deskinfo', async () => {
|
||||
const events: string[] = [];
|
||||
const wire = new RecordingWireClient();
|
||||
const controller = new PlatformUiController({
|
||||
render: model => { events.push(`render:${model.page}`); },
|
||||
activate: page => { events.push(`activate:${page}`); },
|
||||
showOverlay: model => { events.push(`overlay:${model.kind}`); },
|
||||
}, { request: () => () => {} }, error => { throw error; });
|
||||
const deskinfo = { fixture: 'opaque' };
|
||||
const entry = makeGameEntry({ createModule: () => ({
|
||||
attach() { events.push('attach'); },
|
||||
handlePlatformEvent() {}, handleGameMessage() {}, dispose() {},
|
||||
restore(value) { assert.equal(value, deskinfo); events.push('restore'); },
|
||||
}) });
|
||||
const runtime = new PlatformRuntime({
|
||||
gameEntry: entry, resolveRuntimeConfig: async () => runtimeConfig(),
|
||||
createWireClient: () => wire, scene: controller,
|
||||
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
|
||||
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
|
||||
});
|
||||
controller.connect(runtime);
|
||||
await runtime.start();
|
||||
wire.emit({ type: 'open', server: 'ws://agent' });
|
||||
controller.login(ACCOUNT);
|
||||
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login',
|
||||
data: fixture('player-login-success.json') } });
|
||||
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
|
||||
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room',
|
||||
data: { ...fixture('self-join-room.json'), deskinfo } } });
|
||||
assert.ok(events.includes('render:lobby'));
|
||||
assert.ok(events.indexOf('attach') < events.indexOf('render:room'));
|
||||
assert.ok(events.indexOf('render:room') < events.indexOf('activate:room'));
|
||||
assert.ok(events.indexOf('activate:room') < events.indexOf('restore'));
|
||||
controller.dispose(); runtime.stop();
|
||||
assert.equal(wire.stopCalls, 1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Run `node --import tsx --test framework-tests/platform/runtime.test.ts`。联合测试若初次已绿,不制造伪失败;通过以下定向负例验证测试能抓回归:临时在 controller 中把 showRoom 首帧排到 scheduler,确认该用例因缺 room render/activate 失败,然后恢复正确代码。该受控变异只涉及本批 TS 并不得提交。
|
||||
- [ ] 追加真实 Runtime 回放:非零登录、非零进房保持原 Store root/原 data,重试能成功;断线重连 overlay 可恢复;kick/fatal 无后续 UI 命令;延迟 loadResources 时不得由 controller 登录;view 回调销毁 Runtime/Presenter 不得 restore;stop 后旧 scheduler 回调无效。夹具数据仅在测试中具备明确来源,不改服务器 fixture 文件来让错误行为通过。
|
||||
- [ ] 编写 architecture 测试,用 TypeScript AST 遍历 presentation 的 import/export/call expression,type-only 合同引用允许,值导入 protocol/net/cc 和动态导入同样禁止。扫描使用 `readFileSync/readdirSync`,不启动 Creator、不写资源。基础 RED 断言先覆盖禁止真实值导入:
|
||||
|
||||
```js
|
||||
import ts from 'typescript';
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
function forbiddenSpecifier(specifier) {
|
||||
return specifier === 'cc' || /(?:^|\/)(?:net|protocol)(?:\/|$)/.test(specifier)
|
||||
|| /(?:platform-session|room-rpc-bus)(?:\.|$)/.test(specifier);
|
||||
}
|
||||
|
||||
test('presentation boundary recognizes forbidden production imports', () => {
|
||||
assert.equal(forbiddenSpecifier('../net/wire-client.ts'), true);
|
||||
assert.equal(forbiddenSpecifier('../protocol/contracts/index.ts'), true);
|
||||
assert.equal(forbiddenSpecifier('../platform/runtime.ts'), false);
|
||||
});
|
||||
```
|
||||
|
||||
完成 AST 取值与 type-only 判别后,增加内联正反样本(普通/混合 type import、export from、import()、require()),再扫描真实目录;同文件核对 SDK index 仍仅导出 contracts。禁止整个 presentation 被添加到子游戏例外白名单。不要用单个字符串搜索冒充完整边界检查。
|
||||
- [ ] Run `node --test framework-tests/architecture/presentation-boundaries.test.mjs`;暂时注入禁止 import 到新增 presentation 文件验证真实扫描失败,立即恢复;GREEN 后运行所有纯 TS 测试:
|
||||
|
||||
```powershell
|
||||
$uiCoreTests = @(rg --files framework-tests -g '*.test.ts')
|
||||
if ($uiCoreTests.Count -eq 0) { throw 'No TypeScript tests selected' }
|
||||
node --import tsx --test --test-concurrency=1 @uiCoreTests
|
||||
node --test framework-tests/architecture/import-boundaries.test.mjs framework-tests/architecture/presentation-boundaries.test.mjs
|
||||
node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit
|
||||
node scripts/check-import-boundaries.mjs
|
||||
git diff --check
|
||||
git diff --name-only HEAD
|
||||
git status --short
|
||||
```
|
||||
|
||||
运行前检查所选 .test.ts 没有生成资源副作用;不混入 legacy-layer-migration 的 .mjs 生成器。核对从执行 base SHA 到 HEAD 的 committed diff 以及工作区/untracked,确认没有 `.scene/.prefab/.anim/.meta`;不仅检查最后一个 commit。
|
||||
- [ ] 规格审查把设计第 8 节每项映射到测试;质量审查核对 UI 生命周期重入和静态检查漏口。只提交本 Task 测试和计划记录,message `test(presentation): verify runtime UI wiring and boundaries`。
|
||||
|
||||
## 完成与后续门槛
|
||||
|
||||
报告实际测试命令、数量、退出码、base/head SHA 与资源零变更检查;不沿用过去的 408/408 数字当本批结果。3A 完成可表述为“UI 接线核心已通过无引擎回放”。
|
||||
|
||||
不自动进入 3B。进入实际 Cocos 接管前,须另行确认允许修改的序列化文件、真实 GameEntry/目标宿主/账号来源,再通过 MCP 核实控件绑定并制定编辑器实施任务。主题扩展与真实子游戏继续各自独立计划。
|
||||
@@ -0,0 +1,110 @@
|
||||
# 公共 UI 接线设计:启动 → 登录 → 大厅 → 进房
|
||||
|
||||
日期:2026-09-05。基线:`master@8eae325`。
|
||||
|
||||
## 1. 目标与实施边界
|
||||
|
||||
这是总设计 `2026-09-04-framework-subgame-zero-coupling-migration-design.md` 第三阶段的第一个子项目:把已完成的 PlatformRuntime 接到可测试的公共 UI Presenter。先交付不依赖 Cocos 的接线核心,再进行编辑器接管。
|
||||
|
||||
本轮产物为设计与实施计划,不运行实施任务。下一实施批次称为 **3A:UI 接线核心**,包括 Runtime 只读观察、ViewModel 投影、命令入口、同步 ScenePort 适配与真实 Runtime 回放测试。
|
||||
|
||||
**3B:Cocos 实际接管**单独实施:需要编辑器确认节点、挂载与持久化,且须先取得修改指定序列化资源的明确授权。用户现有“不修改任何 `.scene/.prefab/.anim/.meta`”限制继续有效,不能因为有 MCP 就绕过。3A 不启动编辑器导入,不写这些文件,也不声称实际界面已经可用。
|
||||
|
||||
主题扩展、SemanticAssetKey/ExtensionSlot 完整实现、创房规则页、聊天/商城/设置、首个真实子游戏均不在 3A 内。现有主题模块不复制、不重构。
|
||||
|
||||
## 2. 已核对的现状
|
||||
|
||||
- `assets/framework/platform/runtime.ts` 已拥有唯一 Store、WireClient、Router、GameSessionHost,公开 `state`、`ready`、登录/进房/准备/退出命令,但没有 UI 订阅入口。
|
||||
- Store 为不可变快照,订阅不立即回调;其通用监听异常处理只记录日志。UI 需要显式错误通道,不能让渲染故障只留日志。
|
||||
- `ScenePort` 是同步接口。当前房间时序为 module attach、`room.entered`、`showRoom()`、可选 `restore(deskinfo)`。不得将 `showRoom()` 实现为未等待的异步场景切换。
|
||||
- 当前 `assets/scenes` 只有 Login 场景;旧 `LoginFlow.ts` 跳转 MainMenu,且自行建立旧网络栈、内置调试账号。`LaunchFlow.ts`、`SceneStart.ts`、`RoomEventProbe.ts` 也包含演示流程,不是新 Runtime 入口。
|
||||
- 已迁移 Login_Layer、Layer4_MainMenu、Layer15_JoinRoom、Layer50_MainScene、Layer202_PlayerHeadScore 及 loading/reconnect/kick 等 Prefab。存在资源不等于已确认按钮语义或绑定。
|
||||
- 两个旧 PlayerInfoView 使用相同 ccclass 名,且含下游默认值。新核心不依赖它们;是否阻碍实际编辑器接管由 3B 预检决定,不能随手修改旧资源引用。
|
||||
- 旧 `net/cocos-transport.ts` 含可选发送、字符串强转和吞异常,不能不经契约测试就作为现代生产适配器。
|
||||
|
||||
以上路径均相对 `cocoscreator_projects/YouleNexus`。已废止的 `2026-09-04-platform-vertical-slice.md` 不得执行。
|
||||
|
||||
## 3. 方案选择
|
||||
|
||||
选择单场景常驻根、页面预加载、同步页面激活。3A 用严格 ViewPort 验证该语义,不创建 Cocos 节点;3B 用组件引用实现同一端口。
|
||||
|
||||
另外两个选项不采用:
|
||||
|
||||
- 多场景异步导航需要修改 Runtime/Host 的进房恢复时序,扩大已验证生命周期契约的变更面。
|
||||
- 直接给旧 LoginFlow/RoomSceneStart 接新网络对象会保留双 Store/双网络栈及下游默认值,无法满足唯一来源。
|
||||
|
||||
3B 的所有基础页面、公共座位容器和必需绑定须在 `loadResources()` 完成前准备好。加载画面本身由启动根预先具备,不依赖这次异步加载。`showRoom()` 只投影最新快照、同步渲染并激活已就绪页面;不加载资源、不发包、不初始化第二个 GameSessionHost。
|
||||
|
||||
## 4. 核心接口与所有权
|
||||
|
||||
新增 `framework/presentation/`,允许依赖 platform 的只读类型、现有 selector 与 ScenePort;不导出到 SDK,不允许子游戏反向导入。View 不得到 Store、WireClient、Router 或协议包构造器。
|
||||
|
||||
Runtime 增加:
|
||||
|
||||
```ts
|
||||
subscribeState(
|
||||
listener: (state: PlatformState) => void,
|
||||
onError: (error: unknown) => void,
|
||||
): () => void;
|
||||
```
|
||||
|
||||
订阅不立即通知;通知交付 Store 提交的原快照,不复制、修补或另建响应式状态。调用者订阅后同步读取 `state` 获取初值。取消幂等;stop/fatal 清理观察者;终止后新订阅显式拒绝。某监听器抛错时先取消该监听器,再将原错误交给必需 onError,其他监听器仍可接收。onError 也是可抛的外部回调:复用现有清理/错误聚合路径,原错误不可丢失;不得修改通用 Store 的订阅语义来满足 UI。
|
||||
|
||||
Presenter 接口:
|
||||
|
||||
```ts
|
||||
type UiRuntime = Pick<PlatformRuntime,
|
||||
'state' | 'ready' | 'subscribeState' | 'login' | 'joinRoom' | 'prepare' | 'exitRoom'>;
|
||||
interface FrameScheduler { request(callback: () => void): () => void; }
|
||||
interface PlatformViewPort {
|
||||
render(model: PageModel): void;
|
||||
activate(page: PageId): void;
|
||||
showOverlay(model: OverlayModel): void;
|
||||
}
|
||||
```
|
||||
|
||||
FrameScheduler 必须异步、至多调用一次;返回取消函数。测试用手动帧,3B 才使用 Creator 更新周期。Presenter 拥有订阅与帧取消;外层应用拥有 Runtime.stop。清理与错误处理不得相互递归复活。
|
||||
|
||||
`PlatformUiController` 实现 ScenePort,构造参数为 ViewPort、FrameScheduler、必需 `onFault(error: unknown): void`,随后 `connect(runtime: UiRuntime): void` 一次。外层顺序:构造 controller → 用 controller 作为 ScenePort 构造 Runtime → connect → Runtime.start。controller 不调用 Runtime.start/stop、不选择 GameEntry/渠道/账号来源。
|
||||
|
||||
## 5. ViewModel 与页面状态
|
||||
|
||||
`PageId = 'loading' | 'login' | 'lobby' | 'room'`。PageModel 是对应判别联合:loading 无业务字段;login 携带 canLogin;lobby 携带必需 self;room 携带 roomcode、selfSeat、动态 seats、canPrepare、canExit。玩家公共显示字段仅 playerid/nickname/avatar/bean。
|
||||
|
||||
SeatModel 为 `empty` 或 `occupied`,都携带服务器 seat 索引,occupied 额外携带 player、ready、offline。数组长度严格等于权威 `seatPlayerIds.length`;不固定为 4/8,不压缩空位,不解析 roomtype 推测布局。2/4/10 座位都须验证。
|
||||
|
||||
已登录页缺少 self 或 occupied 缺实体必须报错;空座位是来源明确的 null 语义,不是伪造匿名玩家。头像 URL 原样交给 View,资源失败的视觉策略不在核心中猜测。
|
||||
|
||||
canPrepare 在 room.needprepare 为 1、自身未准备、stage 为 0 且连接 phase 为 logged-in 时为真;canExit 在 stage 为 0 或 infinite 为 1,且 phase 为 logged-in 时为真。这是本批 UI 的可用性策略,不是新增服务器规则。Runtime/Commands 仍为命令合法性的最终执行入口。
|
||||
|
||||
页面只由 ScenePort 决定,不能同时通过 app.phase 推断第二套导航。快照通知只触发当前页面更新。页面切换取消待处理旧帧、同步生成最新模型并 render,然后 activate;render 抛错不得激活半成品页面。room 首帧必须在 showRoom 返回前完成,保证既有 restore 时序。
|
||||
|
||||
同一帧多次状态变化只渲染最新值。dispose、fatal、kick 后旧帧/旧订阅无效。生命周期检查必须覆盖 View 回调重入 dispose,不能仅在入口检查一次。
|
||||
|
||||
## 6. 操作、拒绝与错误
|
||||
|
||||
controller 暴露 `login(account: LoginAccountIdentity)`、`joinRoom(command: JoinRoomCommand)`、`prepare()`、`exitRoom()`、`dismissDenial()` 与 `dispose()`,均返回 void。
|
||||
|
||||
- 登录只在 login 页且 runtime.ready 时转发;大厅进房只在 lobby 页;prepare/exit 只在 room 且模型对应能力为真时转发。界面本地误用显式抛出,不偷偷缓存请求。
|
||||
- account/JoinRoomCommand 原引用转发;不填 mock_openid、ip、location、gameid,不裁剪/重写 roomcode,不擅加正则或长度协议。账号/设备/进房环境由外层权威适配器完整供给。
|
||||
- 本批不新增超时、重连策略或通用请求 pending 状态机;网络策略沿用 Runtime。按钮节流/账号异步获取另有来源契约后再实施。
|
||||
- OverlayModel 为 none/reconnect/denial/kicked/fatal 判别联合。denial 保存完整 ServerDenial 引用,包括原 data;不把合法非零结果升级 fatal,不修改 Store。dismissDenial 仅清除当前 denial;登录/进房成功页面切换清除非终止 overlay。
|
||||
- 重连保留当前页面。Store phase 从 reconnecting/slow 恢复 connected/logged-in 时只清 reconnect overlay,不清 denial,不自行跳大厅。
|
||||
- kicked 保存原 data,fatal 保存原 Error。两者终止交互并取消观察/帧,但保留底图用于展示;fatal 可覆盖 kicked,其他回调不得覆盖终止画面。
|
||||
- 同步 ScenePort/View 异常向 Runtime 原样传播;帧/订阅异步异常必须取消更新并调用 onFault。外层应展示 fatal 并停止 Runtime;两项操作均须尝试,清理异常聚合保留。3A 不把错误转换为默认模型。
|
||||
|
||||
## 7. 外部兼容与后续实际接管
|
||||
|
||||
服务器包、route/rpc、roomtype 与 deskinfo 全部沿用已验证 contracts。不得为 UI 新增字段。远程配置与原生读取/WVJB 不在本批修改;后续适配前须完整读 native-bridge-contract,并用原工程核实精确契约,不能根据摘要重新发明接口。已核定远程配置 POST 空 body、URL 拼接、gameconfig 等行为保持现状。
|
||||
|
||||
3A 测试夹具只留在 framework-tests,不导入 assets,不声明生产 GameEntry。3B 启动前须明确真实 GameEntry 与 config.identity.gameid 一致、账号来源及目标宿主。尚未选定真实游戏不能用夹具顶替生产入口。
|
||||
|
||||
3B 单独批准后需完成:MCP 枚举 Login 活跃组件及 Prefab 控件语义;形成唯一绑定清单;移除活跃路径上的旧模拟驱动;以一个应用根持有新 Runtime;严格 WebSocket adapter 契约测试;保存并重开确认挂载;浏览器回放截图与真实宿主验收分别记录。没有这些证据,3A 完成不能称为“UI 接管完成”。
|
||||
|
||||
## 8. 验收
|
||||
|
||||
3A 必须通过:观察生命周期、2/4/10 座位、同帧合并/切页首帧、命令原样转发、非零拒绝可重试、重连 overlay 恢复、kick/fatal 终止、回调重入销毁、真实 PlatformRuntime 登录/进房/deskinfo 恢复顺序回放、现有非 UI 回归、类型与架构检查。
|
||||
|
||||
代码边界测试禁止 presentation 导入 cc/net/protocol 实现、旧 PlatformSession/RoomRPCBus,以及 SDK 导出 presentation。允许 type-only 引用现有 LoginAccountIdentity、JoinRoomCommand、ServerDenial;协议 parser/builder 不进入 Presenter。
|
||||
|
||||
任何 `.scene/.prefab/.anim/.meta` 变更均使 3A 验收失败。本计划完成后交付测试证据与明确 3B 门槛,不自动启动编辑器、选择真实游戏或删除旧资源。
|
||||
Reference in New Issue
Block a user