Files
youle_cocos/docs/superpowers/plans/2026-09-04-decoupled-contracts-platform-runtime.md
T

66 KiB
Raw Blame History

YouleNexus Decoupled Contracts and Platform Runtime 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: 建立纯净的单游戏 Game SDK 边界和一个无 Cocos 依赖的平台运行时,严格跑通远程配置、连接、登录、进房、准备、切服、断线重连与 deskinfo 恢复。

Architecture: 一个发布工程在 Composition Root 编译期注入唯一 GameEntry;framework 不使用 GameRegistry 或运行时发现。平台运行时通过永久 External Contract Adapters 对齐服务器、配置和原生 App,通过单一 Router、原子 PlatformStore、受限 GameHost 和每桌新建的 GameSessionHost 编排业务。

Tech Stack: TypeScript 6、Node.js 20 test runner、tsx、原生 WebSocket 抽象、Cocos Creator 3.8+(本计划不修改序列化 Cocos 资源)。

Spec: docs/superpowers/specs/2026-09-04-framework-subgame-zero-coupling-migration-design.md

Global Constraints

  • 服务器零改动:{app:"youle",route,rpc,data}、字段名、字段类型、数组结构、可选字段和可观察时序与 docs/protocol/ 一致。
  • 配置服务零改动:远程配置连接地址只取 data.urlserver;URL 构造和 GET 行为与原工程一致。
  • 原生 App 零改动:window.settings、WVJB 初始化、handler 名、payload 和 callback 约定逐字兼容。
  • roomtype 由所选游戏解释,平台只保存和透传原始嵌套数组;不得归一化位置含义。
  • deskinfo 不进入 PlatformStore;只有 Boolean(data.deskinfo) 为真时原样传给 GameModule.restore。
  • 每个工程编译期只有一个 GameEntry;禁止 GameRegistry、运行时发现、API 版本协商和多游戏同包。
  • framework/sdk 不得依赖 framework 内部模块或 cc;游戏实现只能依赖 SDK 公共契约、cc 和自身目录。
  • 配置、route 映射、平台状态和当前 GameSession 各自只有一个权威来源;下游不补默认值。
  • 新 PlatformRuntime 的每个入站业务 Envelope 只进入一个 Router 一次;现有 Cocos UI 仍引用的 RoomRPCBus/旧 NetClient 作为隔离的迁移期入口保留到阶段 3 接线计划,严禁被新运行时引用或与新运行时同时启动。
  • 本计划不实现具体子游戏、主题系统、公共 UI Presenter、Cocos 场景挂载、ZIP 或热更新。
  • 所有步骤测试先行;每个任务只提交列出的文件,不夹带工作区已有 UI 迁移改动。
  • 命令从 cocoscreator_projects/ 执行;Windows 上先使用 node --import tsx --test --test-concurrency=1 ...,避免当前环境并发启动 tsx 的 uv_os_get_passwd ENOMEM。

File Structure

本计划完成后的核心结构:

cocoscreator_projects/YouleNexus/assets/framework/
├── sdk/
│   ├── contracts/
│   │   ├── game-entry.ts
│   │   ├── game-host.ts
│   │   ├── game-module.ts
│   │   ├── game-seat-mapper.ts
│   │   ├── platform-events.ts
│   │   ├── snapshots.ts
│   │   └── index.ts
│   ├── runtime/game-session-host.ts
│   ├── testing/game-contract-harness.ts
│   └── index.ts
├── config/
│   ├── runtime-config.ts
│   ├── bootstrap.ts
│   ├── remote-config.ts
│   └── sources/native-settings.ts
├── adapters/native/
│   ├── native-contracts.ts
│   ├── native-settings-adapter.ts
│   └── webview-javascript-bridge.ts
├── protocol/
│   ├── contracts/
│   │   ├── validation.ts
│   │   ├── login-contract.ts
│   │   ├── room-contracts.ts
│   │   └── index.ts
│   ├── first-slice-routes.ts
│   ├── platform-handlers.ts
│   └── router.ts
├── platform/
│   ├── stores/platform-types.ts
│   ├── stores/platform-store.ts
│   ├── stores/selectors.ts
│   ├── connection-intent.ts
│   ├── commands.ts
│   ├── game-host-adapter.ts
│   ├── runtime-session.ts
│   ├── ready-gate.ts
│   ├── scene-port.ts
│   └── runtime.ts
└── net/
    ├── wire-client.ts
    ├── envelope-codec.ts
    └── transport.ts

cocoscreator_projects/scripts/
├── check-import-boundaries.mjs
└── lib/import-boundaries.mjs

cocoscreator_projects/framework-tests/
├── architecture/import-boundaries.test.mjs
├── fixtures/contracts/
├── sdk/
├── config/
├── native/
├── protocol/
├── platform/
├── net/
└── integration/platform-vertical-slice.test.ts

职责锁定:

  • sdk/contracts 只定义跨边界纯数据与接口。
  • sdk/runtime 只管理当前单款游戏的一桌实例生命周期。
  • config 和 adapters/native 只处理外部配置/原生契约。
  • protocol/contracts 只验证和构造 wire DTO,不写状态。
  • platform/stores 是平台状态唯一写入点。
  • protocol/router 是入站 Envelope 唯一分发点。
  • platform/runtime 是组合与启动/停止唯一所有者。

Task 1: 建立纯 Game SDK 契约和依赖门禁

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-entry.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-host.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-module.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-seat-mapper.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/platform-events.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/snapshots.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/index.ts
  • Modify: cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts
  • Create: cocoscreator_projects/scripts/lib/import-boundaries.mjs
  • Create: cocoscreator_projects/scripts/check-import-boundaries.mjs
  • Modify: cocoscreator_projects/package.json
  • Create: cocoscreator_projects/framework-tests/sdk/contracts.test.ts
  • Create: cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs

Interfaces:

  • Consumes: TypeScript standard types only.

  • Produces: GameEntry, GameModule, GameHost, GameSeatMapper, PlatformToGameEvent, GameServerMessage, PlatformGameSnapshot, GameHostCommand, scanImportBoundaries(options) and npm run check-boundaries.

  • Step 1: Write the failing public-contract test

import { test } from 'node:test';
import assert from 'node:assert/strict';
import type {
  GameEntry, GameHost, GameModule, GameServerMessage,
  PlatformGameSnapshot, PlatformToGameEvent,
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';

test('single GameEntry contract has no registry or runtime version member', () => {
  const module: GameModule = {
    attach(_host: GameHost) {},
    handlePlatformEvent(_event: PlatformToGameEvent) {},
    handleGameMessage(_message: GameServerMessage) {},
    restore(_deskinfo: unknown) {},
    dispose() {},
  };
  const entry: GameEntry = {
    key: 'fixture', gameId: 41, route: 'fixture-route',
    resolveSeatCount: () => 4,
    createModule: () => module,
  };
  assert.equal(entry.route, 'fixture-route');
  assert.equal('apiVersion' in entry, false);
});

test('public snapshot is plain readonly data', () => {
  const value = {} as PlatformGameSnapshot;
  assert.equal('state' in value, false);
});
  • Step 2: Write failing import-boundary tests

Create temporary fixtures proving the scanner rejects:

await writeFixture('framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n");
assert.match(scanImportBoundaries(root)[0].message, /sdk.*platform/);

await writeFixture('games/a/assets/game/bad.ts', "import '../../../framework/net/net-client.ts'\n");
assert.match(scanImportBoundaries(root)[0].message, /game.*framework\/net/);

Also assert it permits a game import ending in framework/sdk/index.ts, rejects from 'cc' inside sdk/contracts, permits cc in a game fixture, and rejects any framework source import resolving under /games/.

  • Step 3: Run targeted tests and verify failure

Run:

node --import tsx --test --test-concurrency=1 framework-tests/sdk/contracts.test.ts framework-tests/architecture/import-boundaries.test.mjs

Expected: FAIL because sdk/contracts and the scanner do not exist.

  • Step 4: Add exact public contract types
export interface GameEntry {
  readonly key: string;
  readonly gameId: string | number;
  readonly route: string;
  resolveSeatCount(roomtype: readonly unknown[]): number;
  createModule(): GameModule;
}

export interface GameModule {
  attach(host: GameHost): void;
  handlePlatformEvent(event: PlatformToGameEvent): void;
  handleGameMessage(message: GameServerMessage): void;
  restore(deskinfo: unknown): void;
  dispose(): void;
}

export interface GameHost {
  readonly seat: GameSeatMapper;
  getSnapshot(): PlatformGameSnapshot;
  subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void;
  sendGameMessage(rpc: string, data: unknown): void;
  execute(command: GameHostCommand): void;
}

export type GameHostCommand =
  | { readonly type: 'room.prepare' }
  | { readonly type: 'room.exit' };

Define GameServerMessage as {readonly rpc:string; readonly data:unknown}. Define PlatformToGameEvent as a discriminated union for room.entered, room.player-joined, room.player-left, room.player-ready, room.player-offline, room.player-online, room.dissolved. Define plain readonly player/app/room snapshot DTOs locally; do not import Store, Reactive, EventBus, NetClient, protocol or cc types.

  • Step 5: Implement the static scanner and command

scanImportBoundaries({ frameworkDir, gamesDir }) recursively reads .ts files, extracts static and string-literal dynamic import specifiers, resolves every relative specifier against the importing file, normalizes it to an absolute path, returns {file,specifier,message}[], and applies these rules to the resolved path (not to the raw import text):

const SDK_ALLOWED = /framework[\\/]sdk(?:[\\/]|$)/;
const FRAMEWORK_INTERNAL = /framework[\\/](?:net|protocol|platform|application|domain|presentation|ui|core|compat)(?:[\\/]|$)/;
const LEGACY_RUNTIME = /framework[\\/](?:net[\\/]net-client|platform[\\/](?:session|startup|room-rpc-bus|readonly|stores[\\/](?:app-store|player-store|room-store|types))|protocol[\\/]room-handlers)\.ts$/;

Game code may import framework only when SDK_ALLOWED matches. sdk/contracts may import only sibling contract files and may not import cc or anything outside its own directory. Framework code may not import a path under games. Files created by this plan may not import a path matching LEGACY_RUNTIME; existing legacy files may reference each other only inside their quarantined path until the UI-composition plan replaces their Cocos callers. Add "check-boundaries": "node scripts/check-import-boundaries.mjs" to package.json.

  • Step 6: Keep one public barrel during migration

Prepend export * from './contracts/index.ts'; to sdk/index.ts. Retain the existing direct GameContext/IGameModule declarations temporarily, mark them @deprecated migration-only, and prove with the scanner that no newly created production file imports those names. Task 11 removes the declarations after ActiveGame is removed; do not invent a legacy namespace or another public barrel.

  • Step 7: Run tests, boundary check and typecheck
node --import tsx --test --test-concurrency=1 framework-tests/sdk/contracts.test.ts framework-tests/architecture/import-boundaries.test.mjs
npm run check-boundaries
npm run typecheck:framework

Expected: PASS with zero forbidden imports in the new public contract tree.

  • Step 8: Commit
git add -- YouleNexus/assets/framework/sdk scripts/lib/import-boundaries.mjs scripts/check-import-boundaries.mjs package.json framework-tests/sdk/contracts.test.ts framework-tests/architecture/import-boundaries.test.mjs
git commit -m "feat(sdk): define single-game public contracts"

Task 2: 实现单 GameEntry 会话生命周期和 conformance harness

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-session-host.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts
  • Create: cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts
  • Create: cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts

Interfaces:

  • Consumes: Task 1 GameEntry, GameModule, GameHost, events and messages.

  • Produces: assertGameEntry(entry), GameSessionHost.open/publish/dispatchGameMessage/restore/close/dispose, assertGameContract(entry, makeHost).

  • Step 1: Write failing entry validation tests

assert.throws(() => assertGameEntry({ ...entry, key: '' }), /key/);
assert.throws(() => assertGameEntry({ ...entry, route: 'room' }), /reserved.*route/);
assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 0 }), /seat count/);

Test reserved routes platform, agent, and room; reject an empty gameId string, non-integer seat counts, and a factory returning no module.

  • Step 2: Write failing lifecycle tests
const sessions = new GameSessionHost(entry);
sessions.open(host);
sessions.publish({ type: 'room.entered', roomtype: rawRoomtype });
sessions.restore(deskinfo);
sessions.close();
assert.deepEqual(log, ['attach', 'event:room.entered', 'restore', 'dispose']);
assert.equal(restoredValue, deskinfo);

Also cover illegal double open, publish/restore without an active module, route mismatch, dispose exactly once, close() returning to idle, dispose() being terminal, and open() creating a fresh module after a previous close.

  • Step 3: Run tests and verify failure
node --import tsx --test --test-concurrency=1 framework-tests/sdk/game-session-host.test.ts framework-tests/sdk/game-contract-harness.test.ts

Expected: FAIL because runtime and harness files do not exist.

  • Step 4: Implement the explicit state machine
export type GameSessionState = 'idle' | 'attaching' | 'active' | 'restoring' | 'disposing' | 'disposed';

export class GameSessionHost {
  constructor(readonly entry: GameEntry) { assertGameEntry(entry); }
  open(host: GameHost): void;
  publish(event: PlatformToGameEvent): void;
  dispatchGameMessage(route: string, message: GameServerMessage): void;
  restore(deskinfo: unknown): void;
  close(): void;
  dispose(): void;
}

open() must call entry.createModule() each time and transition through attaching. restore() preserves object identity and returns to active in finally. close() transitions through disposing, clears the module before invoking user disposal, and always returns to idle. dispose() calls close() if needed and ends in terminal disposed.

  • Step 5: Implement reusable conformance

assertGameContract(entry, makeHost) validates entry values, reserved route rejection, 2/4/10-seat mapping, fresh modules across two open/close cycles, event ordering, deskinfo identity, route binding, host invalidation supplied by the fake host, and state isolation between the two module instances.

  • Step 6: Run tests and typecheck
node --import tsx --test --test-concurrency=1 framework-tests/sdk/game-session-host.test.ts framework-tests/sdk/game-contract-harness.test.ts
npm run typecheck:framework

Expected: PASS.

  • Step 7: Commit
git add -- YouleNexus/assets/framework/sdk/runtime YouleNexus/assets/framework/sdk/testing framework-tests/sdk/game-session-host.test.ts framework-tests/sdk/game-contract-harness.test.ts
git commit -m "feat(sdk): add isolated single-game sessions"

Task 3: 修正 gameserver、原生身份和 data.urlserver 单一来源

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/config/runtime-config.ts
  • Modify: cocoscreator_projects/YouleNexus/assets/framework/config/bootstrap.ts
  • Modify: cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts
  • Modify: cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts
  • Modify: cocoscreator_projects/YouleNexus/assets/framework/config/profiles.ts
  • Modify: cocoscreator_projects/YouleNexus/assets/framework/config/sources/native-settings.ts
  • Modify: cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts
  • Create: cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-single.json
  • Create: cocoscreator_projects/framework-tests/fixtures/contracts/remote-config-array.json
  • Modify: cocoscreator_projects/framework-tests/config/bootstrap.test.ts
  • Modify: cocoscreator_projects/framework-tests/config/remote-config.test.ts
  • Create: cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts
  • Modify: cocoscreator_projects/framework-tests/config/profiles.test.ts
  • Modify: cocoscreator_projects/framework-tests/config/sources/native-settings.test.ts
  • Create: cocoscreator_projects/framework-tests/config/runtime-config.test.ts

Interfaces:

  • Consumes: existing RuntimeMode; original 12_Logic.js:521,533-550,1215-1281 and 05_Func.js:2454-2466.

  • Produces: RuntimeConfig, resolveRuntimeConfig(options), decodeGameConfig(value), resolveGameServer(options), parseUrlServers(input).

  • Step 1: Add exact remote-config fixtures and failing tests

remote-config-single.json:

{"data":{"urlserver":"10.0.0.1:3088"}}

remote-config-array.json:

{"data":{"urlserver":["10.0.0.1:3088","wss://backup.example/ws"]}}

Tests:

assert.deepEqual(parseUrlServers(single), ['ws://10.0.0.1:3088']);
assert.deepEqual(parseUrlServers(array), ['ws://10.0.0.1:3088', 'wss://backup.example/ws']);
assert.throws(() => parseUrlServers({ data: { player_server_tcp: 'wrong:1' } }), /data\.urlserver/);
  • Step 2: Add failing gameconfig and native source tests

Use the actual original name gameconfig from Logic.setGameServer, not gameserver:

assert.equal(decodeGameConfig('host-path#8080-config'), 'http://host/path:8080/config.txt');
assert.equal(resolveGameServer({ configured: 'https://default/config.txt', injectedGameConfig: '' }), 'https://default/config.txt');
assert.equal(resolveGameServer({ configured: 'https://default/config.txt', injectedGameConfig: 'host-path#80-a' }), 'http://host/path:80/a.txt');

Freeze the original source semantics in tests instead of spreading fallbacks downstream:

  • non-uAgent_3 getothername(name) calls window.settings.getothername(name) and converts a thrown call to '' at this source boundary;
  • getchannelName() returns the settings result when truthy, otherwise ''; on error it returns '' for non-uAgent_3 and window.app_channel for uAgent_3;
  • getmarketname() returns the settings result; on error it returns the legacy source-defined '4' for non-uAgent_3 and window.app_market for uAgent_3;
  • explicit uAgent_3 getothername(name) reads window['app_' + name] without calling settings, including window.app_agent and window.app_gameconfig.

These defaults exist only in the native source adapter because the old external contract defines them. resolveRuntimeConfig must still reject an incomplete final identity; no downstream consumer may repeat or reinterpret the fallback.

  • Step 3: Run config tests and verify failure
node --import tsx --test --test-concurrency=1 framework-tests/config/*.test.ts framework-tests/config/sources/native-settings.test.ts

Expected: FAIL because current code reads *_server_tcp, catches arbitrary settings errors, and allows incomplete identity casts.

  • Step 4: Implement strict config types
export interface RemoteConfig {
  readonly data: {
    readonly urlserver: string | readonly string[];
    readonly [key: string]: unknown;
  };
}

export interface RuntimeConfig {
  readonly mode: RuntimeMode;
  readonly isDebugger: boolean;
  readonly identity: Readonly<ChannelIdentity>;
  readonly servers: readonly string[];
  readonly source: 'remote' | 'direct';
  readonly gameserver: string | null;
  readonly rawConfig: RemoteConfig | null;
}

parseUrlServers accepts only a nonempty string or nonempty string array under data.urlserver, preserves order and duplicate entries exactly as supplied, normalizes missing schemes to ws://, keeps existing ws:///wss://, and freezes the returned array. Remote mode requires non-null gameserver and rawConfig; explicitly selected local-debug direct mode requires both to be null and receives its complete server list from the selected profile. No consumer infers one mode from missing fields.

  • Step 5: Make source selection explicit

resolveRuntimeConfig receives an explicit host kind:

export type HostKind = 'h5' | 'native-settings' | 'uAgent_3';
  • h5: channel/agent query behavior remains exactly as documented; no native access.
  • native-settings: uses settings.getothername('agent'), getchannelName(), getmarketname(), and getothername('gameconfig').
  • uAgent_3: uses the original window.app_<name> branch only.

Validate every required identity field before freezing RuntimeConfig. Remove REPLACE_ME from any profile reachable by a normal build; debug credentials must be injected explicitly by tests or local launch configuration.

  • Step 6: Fetch exactly the resolved gameserver URL

Keep cache busting in one function. The test injects cacheBust: () => '123' and asserts the original observable request exactly: method POST, empty-string body, and URL ${gameserver}?123. Preserve the old unconditional ? concatenation even when gameserver already contains a query; do not modernize it to &, because the configuration service must observe the same URL construction as 12_Logic.js:1330-1338.

  • Step 7: Run tests and typecheck
node --import tsx --test --test-concurrency=1 framework-tests/config/*.test.ts framework-tests/config/sources/native-settings.test.ts
npm run typecheck:framework

Expected: PASS.

  • Step 8: Commit
git add -- YouleNexus/assets/framework/config framework-tests/config framework-tests/fixtures/contracts/remote-config-single.json framework-tests/fixtures/contracts/remote-config-array.json
git commit -m "fix(config): restore legacy gameserver and urlserver contract"

Task 4: 建立可测试且逐名兼容的原生桥适配器

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/adapters/native/native-contracts.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/adapters/native/native-settings-adapter.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/adapters/native/webview-javascript-bridge.ts
  • Create: cocoscreator_projects/framework-tests/native/native-settings-adapter.test.ts
  • Create: cocoscreator_projects/framework-tests/native/webview-javascript-bridge.test.ts
  • Delete: cocoscreator_projects/YouleNexus/assets/framework/platform/native-bridge.ts
  • Delete: cocoscreator_projects/framework-tests/platform/native-bridge.test.ts

Interfaces:

  • Consumes: exact initialization flow in projects/Game_Surface_3/js/00_Surface/05_Func.js:2615-2634 and handler names listed by native-bridge-contract.

  • Produces: NativeSettingsPort.getOtherName/getChannelName/getMarketName, NativeBridgePort.register/call/isReady/dispose, setupWebViewJavascriptBridge(environment, callback).

  • Step 1: Write failing initialization branch tests

Cover all three original branches with the recording fake environment:

test('existing WebViewJavascriptBridge calls back immediately', () => {
  const env = makeBridgeEnvironment({ bridge });
  const seen: unknown[] = [];
  setupWebViewJavascriptBridge(env, value => seen.push(value));
  assert.deepEqual(seen, [bridge]);
});

test('without bridge registers the exact ready listener', () => {
  const env = makeBridgeEnvironment();
  setupWebViewJavascriptBridge(env, () => undefined);
  assert.deepEqual(env.events, [{ name: 'WebViewJavascriptBridgeReady', capture: false }]);
});

test('first WVJB callback creates and removes the loader iframe', () => {
  const env = makeBridgeEnvironment();
  setupWebViewJavascriptBridge(env, () => undefined);
  assert.equal(env.created[0].tagName, 'iframe');
  assert.equal(env.created[0].style.display, 'none');
  assert.equal(env.created[0].src, 'wvjbscheme://__BRIDGE_LOADED__');
  assert.deepEqual(env.domOperations, ['append:iframe']);
  env.runZeroDelayTimers();
  assert.deepEqual(env.domOperations, ['append:iframe', 'remove:iframe']);
});

The fake environment records document.addEventListener, createElement('iframe'), documentElement.appendChild/removeChild, setTimeout(...,0), and WVJBCallbacks mutations. Assertions use the exact strings WebViewJavascriptBridgeReady and wvjbscheme://__BRIDGE_LOADED__.

  • Step 2: Write complete registered-handler/called-method name and ownership tests

Assert the complete active registerHandler tuple from 05_Func.js:2645-3248:

[
  'getVideoinfo', 'sharelogin', 'gameui_play_voice', 'gameui_stop_voice',
  'sharesuccess', 'getphoneinfo', 'getAddressBook', 'phonestate',
  'appservice', 'getaudiourl', 'getBattery', 'getwifiLevel', 'getnetwork',
  'shakeEnd', 'yPaytype', 'getlocationinfo', 'getWebdata',
  'PayuserPaytypePaystate', 'setPostUrl', 'getphoto',
]

Assert the complete active callHandler tuple from 05_Func.js:3265-4034 separately:

[
  'getphoto', 'h5Webpay', 'getVideoinfo', 'getphoneInfo', 'getAddressBook',
  'createRoom', 'exitRoom', 'accreditlogin', 'cancellogin',
  'OpenurlTitleData', 'getOther', 'voicePlaying', 'gameCopytext',
  'opencamera', 'gamepastetext', 'getGameinstall', 'getGameplay',
  'getphonestate', 'ypayType', 'SwitchOverGameData', 'backgameData',
  'startlocation', 'getlocationinfo', 'srcIsloop', 'opensaoma',
  'startshake', 'stopshake', 'SwitchShake', 'sharephotourl', 'browser',
  'mediaTypeAudio', 'prepareaudio', 'vibrator', 'repeatvibrator',
  'canclevibrator', 'orientation', 'notification',
  'friendsSharetypeUrlToptitleDescript',
]

Verify register and call preserve payload identity and callback argument order/count; unknown names throw. Verify two adapter instances do not share globalThis.__activeChannel. dispose() removes listeners owned by that adapter and makes later calls fail. The tuples freeze spelling/casing only; handler-specific payload DTOs are added with the feature slice that consumes them, never guessed here.

  • Step 3: Run native tests and verify failure
node --import tsx --test --test-concurrency=1 framework-tests/native/*.test.ts

Expected: FAIL because the new adapters do not exist and current implementation omits the immediate bridge/event/iframe paths.

  • Step 4: Define exact minimal ports
export interface NativeSettingsPort {
  getOtherName(name: 'agent' | 'gameconfig' | 'gameserver' | 'servertype'): string | number;
  getChannelName(): string | number;
  getMarketName(): string | number;
}

export interface NativeBridgePort {
  isReady(): boolean;
  register(name: RegisteredNativeHandlerName, handler: NativeHandler): void;
  call(name: NativeCallName, data: unknown, callback?: NativeResponseCallback): void;
  dispose(): void;
}

The adapter owns its bridge channel in a private field. Do not store it on global state. NativeSettingsAdapter invokes the external methods with their exact original spelling—settings.getothername(name), settings.getchannelName(), and settings.getmarketname()—and preserves each return value. Required/optional meaning is decided once by the calling config source.

  • Step 5: Implement the exact WVJB bootstrap

Implement the immediate window.WebViewJavascriptBridge callback and still continue through the original WVJBCallbacks/iframe block; the old function does not return after the immediate branch. Otherwise register the DOM ready listener. Then push into an existing callback queue and return, or create the queue, hidden iframe, scheme assignment, append, and zero-delay removal in the same observable order as the original. The low-level bootstrap preserves every callback invocation that the original environment would make; the higher-level NativeBridgePort alone guards its own readiness transition and handler registration from running twice.

  • Step 6: Run native tests and typecheck
node --import tsx --test --test-concurrency=1 framework-tests/native/*.test.ts
npm run typecheck:framework

Expected: PASS.

  • Step 7: Commit
git add -A -- YouleNexus/assets/framework/adapters/native YouleNexus/assets/framework/platform/native-bridge.ts framework-tests/native framework-tests/platform/native-bridge.test.ts
git commit -m "refactor(native): isolate exact WVJB compatibility adapter"

Task 5: 建立第一纵向切片的 route 单源与严格协议解析器

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/protocol/first-slice-routes.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/validation.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts
  • Create: cocoscreator_projects/framework-tests/fixtures/contracts/player-login-success.json
  • Create: cocoscreator_projects/framework-tests/fixtures/contracts/player-login-room.json
  • Create: cocoscreator_projects/framework-tests/fixtures/contracts/self-join-room.json
  • Create: cocoscreator_projects/framework-tests/protocol/first-slice-routes.test.ts
  • Create: cocoscreator_projects/framework-tests/protocol/contracts.test.ts
  • Modify: cocoscreator_projects/framework-tests/net/envelope-codec.test.ts

Interfaces:

  • Consumes: docs/protocol/01-传输层与架构.md through 05-游戏内协议与桥接.md.

  • Produces: FirstSliceRpc, FIRST_SLICE_RPC_ROUTE, request builders and response parsers for login/join/prepare/join/exit/online/offline/switch/kick.

  • Step 1: Write the exact route-map test

assert.deepEqual(FIRST_SLICE_RPC_ROUTE, {
  player_login: 'agent',
  self_join_room: 'agent',
  player_prepare: 'room',
  self_exit_room: 'room',
  other_join_room: 'room',
  other_exit_room: 'room',
  other_offline: 'room',
  other_online: 'room',
  connect_roomserver: 'room',
  connect_agentserver: 'agent',
  kick_server: 'agent',
});

Use satisfies Record<FirstSliceRpc, 'platform'|'agent'|'room'>; no caller-supplied route map is accepted.

  • Step 2: Add verified golden fixtures

player-login-success.json uses the existing verified response:

{"route":"agent","rpc":"player_login","data":{"state":0,"playerid":430511,"agentid":"veRa0qrBf","channelid":"FtJf073aa","nickname":"测试号","avatar":"http://a","openid":"o1","sex":0,"unionid":"test_unionid","roomcard":3,"bean":0,"score":0,"invitecode":null,"advanced":0,"taskstate":1,"ip":"127.0.0.1","bankpower":1,"bank":0,"sign":null,"tel":null,"initCard":"3","initBean":"0","bankpwd":0,"agentname":"进贤","agentmode":2,"gameversion":41}}

player-login-room.json reuses the A-group fields above and adds this exact verified-by-document shape (the player-array index is the server seat; no synthetic seat field is added to each player):

{"route":"agent","rpc":"player_login","data":{"state":0,"playerid":430511,"agentid":"veRa0qrBf","channelid":"FtJf073aa","nickname":"测试号","avatar":"http://a","openid":"o1","sex":0,"unionid":"test_unionid","roomcard":3,"bean":0,"score":0,"invitecode":null,"advanced":0,"taskstate":1,"ip":"127.0.0.1","bankpower":1,"bank":0,"sign":null,"tel":null,"initCard":"3","initBean":"0","bankpwd":0,"agentname":"进贤","agentmode":2,"gameversion":41,"roomcode":"100001","seat":1,"isowner":1,"roomtype":[1,4,1,2,2,[1,1,[1,2000,10],null,null,1],[1,0,5]],"asetcount":8,"isbattle":1,"makewar":4,"players":[null,{"playerid":430511,"nickname":"测试号","avatar":"http://a","sex":0,"bean":0,"isprepare":1,"onstate":1},null,null],"roommode":0,"beanlimit":0,"needprepare":1,"infinite":0,"rebateNumber":0,"rebateMode":0,"rebateType":0,"deskinfo":{"round":3,"cards":[1,2,3]}}}

self-join-room.json uses the same room fields under route:"agent", rpc:"self_join_room", adds deskwar:1, and omits the login-only A-group identity/assets. Keep this fixture minimal to fields proven in docs/protocol/04-数据结构.md; do not add convenience fields.

  • Step 3: Write failing positive and negative parser tests
const parsed = parseLoginResponse(roomFixture.data);
assert.equal(parsed.reconnect.present, true);
assert.equal(parsed.reconnect.value, roomFixture.data.deskinfo);

const { deskinfo: _drop, ...withoutDeskinfo } = roomFixture.data;
assert.equal(parseLoginResponse({ ...withoutDeskinfo, isbattle: 1 }).reconnect.present, false);
assert.equal(parseLoginResponse({ ...withoutDeskinfo, deskinfo: null }).reconnect.present, false);

Reject missing state, successful login without integer playerid, room response without roomcode/seat/roomtype/players, invalid seat, and malformed switch payloads. Preserve unknown documented extension fields in raw; never validate inside roomtype or deskinfo.

Add an outbound login golden assertion. buildLoginRequest must produce exactly these always-present keys, with numeric version, and append conditional keys only when their authoritative source supplies them:

assert.deepEqual(buildLoginRequest(runtimeConfig, account, device), {
  app: 'youle', route: 'agent', rpc: 'player_login', data: {
    agentid: 'veRa0qrBf', gameid: 41, openid: 'o1', nickname: '测试号',
    avatar: 'http://a', sex: 0, province: '江西', city: '南昌',
    unionid: 'test_unionid', version: 10000, channelid: 'FtJf073aa',
    marketid: 4, ip: '127.0.0.1', location: { latitude: 1, longitude: 2 },
    machineid: 'machine-1', machineroom: 'room-install-1',
  },
});

Separate tests add telphone and telphoneAuto:true only for the explicit device-login mode, and cached playerid only when login-player-id mode is enabled and the cache source returns a valid ID. Omitting optional IP source must omit ip; location remains present and may be null, matching Net.Send_login.

  • Step 4: Run tests and verify failure
node --import tsx --test --test-concurrency=1 framework-tests/protocol/first-slice-routes.test.ts framework-tests/protocol/contracts.test.ts framework-tests/net/envelope-codec.test.ts

Expected: FAIL because the strict contracts do not exist and current login parser treats isbattle as a reconnect signal.

  • Step 5: Implement JSON-path validation helpers

Export requireRecord, requireString, requireNumber, requireInteger, requireArray, optionalField, and hasOwn. Errors include the exact JSON path and received value type. Parsers validate only fields consumed by this slice and retain the raw object for untouched server fields.

  • Step 6: Implement builders and parsers

Builders return fresh data objects with protocol field names unchanged. buildLoginRequest composes RuntimeConfig identity, complete account identity, and one validated device/environment snapshot; it follows the exact conditional fields above and never mutates the account object. buildPrepareRequest and buildExitRoomRequest contain agentid, playerid, gameid, and roomcode. buildJoinRoomRequest adds roomcode, location, ip, and only explicitly supplied vipMatch/match_id fields.

parseLoginResponse and parseSelfJoinRoomResponse return:

export interface ParsedRoomEntry {
  readonly room: ParsedRoomSnapshot;
  readonly reconnect: { readonly present: false } |
    { readonly present: true; readonly value: unknown };
  readonly raw: Readonly<Record<string, unknown>>;
}

Set reconnect.present using Boolean(raw.deskinfo) and keep value as the same reference.

  • Step 7: Run protocol tests and typecheck
node --import tsx --test --test-concurrency=1 framework-tests/protocol/first-slice-routes.test.ts framework-tests/protocol/contracts.test.ts framework-tests/net/envelope-codec.test.ts
npm run typecheck:framework

Expected: PASS.

  • Step 8: Commit
git add -- YouleNexus/assets/framework/protocol/contracts YouleNexus/assets/framework/protocol/first-slice-routes.ts framework-tests/protocol framework-tests/net/envelope-codec.test.ts framework-tests/fixtures/contracts
git commit -m "feat(protocol): define strict first-slice wire contracts"

Task 6: 建立无镜像实体的原子 PlatformStore

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-types.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/platform/stores/selectors.ts
  • Create: cocoscreator_projects/framework-tests/platform/platform-store.test.ts
  • Create: cocoscreator_projects/framework-tests/platform/selectors.test.ts

Interfaces:

  • Consumes: Task 5 parsed DTOs; never consumes raw unvalidated Envelope data.

  • Produces: PlatformState, PlatformStore, atomic room/player actions, selectGameSnapshot, selectSelfPlayer, selectRoomPlayers.

  • Step 1: Write failing atomic notification tests

const store = new PlatformStore();
let notifications = 0;
store.subscribe(() => notifications++);
store.applyLoginSuccess(parsedLogin);
assert.equal(notifications, 1);
assert.equal(store.getState().players.selfPlayerId, 430511);

Add a fixture where self appears in the room players list; assert entities[430511] is the single canonical player and seats store player IDs, not duplicate objects.

  • Step 2: Write failing invariant tests

Cover join, exit, ready, offline, online, full room replacement, room clear, invalid seat, duplicate player seat, and mutation attempts. A failed action keeps the exact prior state reference and emits no notification.

const before = store.getState();
assert.throws(() => store.playerReady({ seat: 99 }), /seat/);
assert.equal(store.getState(), before);
  • Step 3: Run tests and verify failure
node --import tsx --test --test-concurrency=1 framework-tests/platform/platform-store.test.ts framework-tests/platform/selectors.test.ts

Expected: FAIL because PlatformStore does not exist.

  • Step 4: Define state without sentinel identities
export interface PlayerDirectoryState {
  readonly selfPlayerId: number | null;
  readonly entities: Readonly<Record<number, PlatformPlayer>>;
}

export type RoomState = { readonly kind: 'outside' } | {
  readonly kind: 'inside';
  readonly roomcode: string;
  readonly selfSeat: number;
  readonly seatPlayerIds: readonly (number | null)[];
  readonly readySeats: readonly number[];
  readonly offlineSeats: readonly number[];
  readonly roomtype: readonly unknown[];
  readonly stage: number;
  readonly needprepare: number;
  readonly infinite: number;
};

PlatformState groups app, players, and room. outside carries no fabricated room defaults; callers must narrow kind before room access. selfPlayerId:null is the sole explicit unauthenticated state, not a guessed player ID. The state does not contain deskinfo, a GameModule, transport, bridge, or Cocos object.

  • Step 5: Implement one-commit actions

Own one private signal/state cell. Validate all inputs first, build structurally shared immutable subtrees, then replace the root exactly once. Recursively freeze roomtype without changing array positions. Do not mutate nested arrays in place and do not use ?? to invent missing server data.

  • Step 6: Implement frozen public selectors

selectGameSnapshot(state) returns SDK-owned DTOs and no Store reference. Implement it as a memoized selector keyed by the immutable app/players/room subtree references: an unchanged subtree reuses its already frozen DTO and arrays, while a changed subtree allocates once. Expose the already frozen canonical roomtype reference. Translate protocol numeric ready/online values only in selector output; retain original protocol values internally where they are part of server state. Tests assert referential equality for unchanged snapshot branches and new identity only for the branch affected by an action.

  • Step 7: Run tests and typecheck
node --import tsx --test --test-concurrency=1 framework-tests/platform/platform-store.test.ts framework-tests/platform/selectors.test.ts
npm run typecheck:framework

Expected: PASS while legacy stores remain temporarily untouched.

  • Step 8: Commit
git add -- YouleNexus/assets/framework/platform/stores/platform-types.ts YouleNexus/assets/framework/platform/stores/platform-store.ts YouleNexus/assets/framework/platform/stores/selectors.ts framework-tests/platform/platform-store.test.ts framework-tests/platform/selectors.test.ts
git commit -m "feat(platform): add atomic canonical platform store"

Task 7: 建立单一职责 WireClient,隔离现有 UI 的迁移期网络入口

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/net/wire-client.ts
  • Create: cocoscreator_projects/framework-tests/net/wire-client.test.ts
  • Modify: cocoscreator_projects/framework-tests/helpers/fake-transport.ts

Interfaces:

  • Consumes: Task 3 validated server list; existing envelope codec, heartbeat and reconnect policy.

  • Produces: WireEvent, WireClient.start/send/switchServer/reconnectCurrent/stop/subscribe; no login identity or platform RPC logic.

  • Migration boundary: existing net-client.ts remains untouched because assets/scripts/LoginFlow.ts still imports it; no file created by this plan may import it.

  • Step 1: Write failing WireClient ownership tests

const events: WireEvent[] = [];
const unsubscribe = client.subscribe((event) => events.push(event));
client.start();
await transport.flush();
assert.deepEqual(events[0], { type: 'open', server: 'ws://a' });
assert.equal(transport.sent.length, 0, 'open 不应由 WireClient 自动发 login');

Assert decoded player_login, kick_server, and connect_*server are all emitted as {type:'message', message} without interception. Assert only one subscriber receives each message once.

  • Step 2: Add lifecycle failure tests

Cover empty servers, start twice, send before open, send after stop, malformed switch URL, duplicate transport close/error, unsubscribe, stale TCP generation messages, timer cleanup, and stop idempotency.

assert.throws(() => client.send(envelope), /connection.*open/);
client.switchServer('ws://room');
assert.equal(currentTransport.closeCalls, 1);
  • Step 3: Run net tests and verify failure
node --import tsx --test --test-concurrency=1 framework-tests/net/*.test.ts

Expected: FAIL because wire-client.ts does not exist.

  • Step 4: Define the narrow event stream
export type WireEvent =
  | { readonly type: 'open'; readonly server: string }
  | { readonly type: 'message'; readonly message: InboundMessage }
  | { readonly type: 'slow' }
  | { readonly type: 'reconnecting'; readonly server: string }
  | { readonly type: 'close' };

Use a private Set<(event: WireEvent) => void> rather than exposing the generic EventBus. subscribe returns an unsubscribe function.

  • Step 5: Implement transport-only lifecycle

start() connects to the current validated server. send() accepts a completed outbound envelope and requires open state. switchServer(url) changes the reconnect policy target and closes the current connection; the next open event is observable by PlatformRuntime. reconnectCurrent() closes and schedules the documented reconnect delay. Preserve handshake/heartbeat handling, receive watchdog, server rotation and TCP generation filtering.

  • Step 6: Run net tests and typecheck
node --import tsx --test --test-concurrency=1 framework-tests/net/*.test.ts
npm run typecheck:framework

Expected: PASS.

  • Step 7: Commit
git add -- YouleNexus/assets/framework/net/wire-client.ts framework-tests/net/wire-client.test.ts framework-tests/helpers/fake-transport.ts
git commit -m "refactor(net): isolate transport and wire lifecycle"

Task 8: 为新运行时合并 Router、平台 Handler 与 RuntimeSession 编排

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/platform/connection-intent.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts
  • Modify: cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts
  • Delete: cocoscreator_projects/YouleNexus/assets/framework/protocol/active-game.ts
  • Modify: cocoscreator_projects/framework-tests/protocol/router.test.ts
  • Create: cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts
  • Create: cocoscreator_projects/framework-tests/platform/runtime-session.test.ts
  • Delete: cocoscreator_projects/framework-tests/protocol/active-game.test.ts

Interfaces:

  • Consumes: Task 2 GameSessionHost, Task 5 parsers/routes, Task 6 PlatformStore.

  • Produces: ConnectionIntent, ConnectionControlPort, RuntimeSession.handleLogin/handleSelfJoin, PlatformHandlers.dispatch, one Router.dispatch.

  • Migration boundary: existing platform/session.ts, room-rpc-bus.ts, protocol/room-handlers.ts and their tests remain unchanged for the current Cocos UI until the phase-3 Presenter/Composition Root plan; the modern path cannot import them.

  • Step 1: Write the exact login-gate router tests

session.markLoginPending();
assert.equal(router.dispatch(updateBeanMessage), 'ignored-login-gate');
assert.equal(router.dispatch(playerLoginMessage), 'handled-platform');
assert.equal(router.dispatch(gameMessage), 'handled-game');

While login is pending, only player_login and kick_server may pass; other messages are deliberately ignored to match the old client. After login, platform/agent/room routes go to PlatformHandlers and the single game route goes to GameSessionHost. Unknown platform RPC and mismatched game routes throw without side effects.

  • Step 2: Write ordering and deskinfo tests

For each room event, log Store notification and GameModule event; Store commit must occur first so the callback reads the new snapshot. For login/self-join:

session.handleLogin(roomLogin);
assert.equal(store.getState().room.kind, 'inside');
assert.equal(game.restored[0], roomLogin.deskinfo);
assert.equal('deskinfo' in store.getState().room, false);

isbattle:1 without truthy deskinfo does not restore. Truthy deskinfo restores exactly once after room state commit and game session open.

  • Step 3: Write connection-control tests
router.dispatch({ route: 'room', rpc: 'connect_roomserver', data: { roomserver: '10.0.0.2:3088' } });
assert.deepEqual(connection.switchCalls[0], {
  target: 'ws://10.0.0.2:3088',
  resend: { app: 'youle', route: 'room', rpc: 'connect_roomserver', data: { roomserver: '10.0.0.2:3088' } },
});

Do the same for connect_agentserver, preserving its complete data including opt. Missing target fields throw. kick_server closes GameSession, marks kicked, and invokes connection.stopForKick(data) once.

  • Step 4: Run tests and verify failure
node --import tsx --test --test-concurrency=1 framework-tests/protocol/router.test.ts framework-tests/protocol/platform-handlers.test.ts framework-tests/platform/session.test.ts

Expected: FAIL because RuntimeSession/PlatformHandlers do not exist and Router still depends on ActiveGame.

  • Step 5: Implement ConnectionIntent and control port
export type ConnectionIntent =
  | { readonly type: 'none' }
  | { readonly type: 'login'; readonly envelope: OutboundMessage }
  | { readonly type: 'switch'; readonly target: string; readonly resend: OutboundMessage };

export interface ConnectionControlPort {
  switchServer(target: string, resend: OutboundMessage): void;
  stopForKick(data: unknown): void;
}

The complete switch data object is preserved; no handler rebuilds or drops opt or extension fields.

  • Step 6: Implement one parsed platform dispatch path

PlatformHandlers parse once, call exactly one PlatformStore action, then publish at most one typed game event. Implement this slice: login, self join, prepare, self/other exit, other join, offline, online, connect room/agent, and kick.

  • Step 7: Replace ActiveGame with the injected single session
if (isPlatformRoute(message.route)) return this.platform.dispatch(message);
this.gameSession.dispatchGameMessage(message.route, { rpc: message.rpc, data: message.data });
return 'handled-game';

No other module in the modern runtime subscribes to WireClient messages.

  • Step 8: Remove ActiveGame and enforce legacy isolation

Delete ActiveGame and its test because the public SDK can no longer expose IGameModule. Keep RoomRPCBus, old roomHandlers, old PlatformSession and old Store files unchanged as one quarantined compatibility island for existing assets/scripts imports. Add architecture assertions that wire-client.ts, runtime-session.ts, platform-handlers.ts, the new Router and runtime.ts have no dependency path into that island. Their removal belongs to the phase-3 UI takeover/phase-6 cleanup, not this headless plan.

  • Step 9: Run tests and typecheck
node --import tsx --test --test-concurrency=1 framework-tests/protocol/*.test.ts framework-tests/platform/runtime-session.test.ts framework-tests/platform/platform-store.test.ts framework-tests/sdk/*.test.ts
npm run typecheck:framework

Expected: PASS.

  • Step 10: Commit
git add -A -- YouleNexus/assets/framework/protocol/router.ts YouleNexus/assets/framework/protocol/platform-handlers.ts YouleNexus/assets/framework/protocol/active-game.ts YouleNexus/assets/framework/platform/connection-intent.ts YouleNexus/assets/framework/platform/runtime-session.ts framework-tests/protocol/router.test.ts framework-tests/protocol/platform-handlers.test.ts framework-tests/protocol/active-game.test.ts framework-tests/platform/runtime-session.test.ts framework-tests/architecture/import-boundaries.test.mjs
git commit -m "refactor(platform): unify routing and session dispatch"

Task 9: 实现受限 GameHost 和平台 Command

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/platform/game-host-adapter.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/platform/commands.ts
  • Create: cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts
  • Create: cocoscreator_projects/framework-tests/platform/commands.test.ts

Interfaces:

  • Consumes: Task 1 GameHost contracts, Task 5 request builders, Task 6 selectors, Task 7 WireClient send port.

  • Produces: PlatformCommands.prepare/exitRoom/joinRoom, createGameHostAdapter(options) and GameHostLease.invalidate().

  • Step 1: Write failing route-binding tests

host.sendGameMessage('play-card', { card: 7 });
assert.deepEqual(sent[0], {
  app: 'youle', route: 'fixture-route', rpc: 'play-card', data: { card: 7 },
});
assert.equal(host.sendGameMessage.length, 2, 'Host API 不接受 route');

Reject empty rpc and reserved game route. Preserve data identity through the encoder boundary.

  • Step 2: Write command and snapshot tests
host.execute({ type: 'room.prepare' });
assert.equal(sent[1].rpc, 'player_prepare');
host.execute({ type: 'room.exit' });
assert.equal(sent[2].rpc, 'self_exit_room');

Assert exact common fields from RuntimeConfig/PlatformStore, reject commands outside the union, reject prepare when not in room, and apply the documented exit precondition. Snapshot and nested arrays are frozen; no Store method/state cell is exposed.

  • Step 3: Write seat and lease invalidation tests

Cover all server seats for 2, 4, and 10 seats. Self maps to view 0, mapping round-trips, and invalid values throw. The initial subscription callback is immediate. After lease invalidation, every Host method and old unsubscribe path is safe and no new callback fires.

  • Step 4: Run tests and verify failure
node --import tsx --test --test-concurrency=1 framework-tests/platform/game-host-adapter.test.ts framework-tests/platform/commands.test.ts

Expected: FAIL because the adapter and commands do not exist.

  • Step 5: Implement commands from single-source builders
prepare(): void {
  this.send(buildPrepareRequest(this.requireRoomContext()));
}

exitRoom(): void {
  this.send(buildExitRoomRequest(this.requireExitContext()));
}

send receives a completed typed envelope. Commands never accept route/rpc from UI or game callers.

  • Step 6: Implement session-bound GameHostLease

Create the seat mapper once from validated selfSeat and seatCount. Build frozen snapshots through selectors. Track unsubscribe functions in the lease. invalidate() clears subscriptions once and makes subsequent public calls throw GameSessionDisposedError.

  • Step 7: Run tests, conformance and typecheck
node --import tsx --test --test-concurrency=1 framework-tests/platform/game-host-adapter.test.ts framework-tests/platform/commands.test.ts framework-tests/sdk/game-contract-harness.test.ts
npm run typecheck:framework

Expected: PASS.

  • Step 8: Commit
git add -- YouleNexus/assets/framework/platform/game-host-adapter.ts YouleNexus/assets/framework/platform/commands.ts framework-tests/platform/game-host-adapter.test.ts framework-tests/platform/commands.test.ts framework-tests/sdk/game-contract-harness.test.ts
git commit -m "feat(platform): expose restricted game host and commands"

Task 10: 建立 PlatformRuntime、连接意图和四门闩

Files:

  • Create: cocoscreator_projects/YouleNexus/assets/framework/platform/ready-gate.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/platform/scene-port.ts
  • Create: cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts
  • Create: cocoscreator_projects/framework-tests/platform/ready-gate.test.ts
  • Create: cocoscreator_projects/framework-tests/platform/runtime.test.ts

Interfaces:

  • Consumes: one compile-time GameEntry, RuntimeConfig resolver, WireClient factory, Router, RuntimeSession, PlatformStore, GameHost factory and ScenePort.

  • Produces: PlatformRuntime.start/login/joinRoom/prepare/exitRoom/stop, PlatformRuntime.state, four-gate readiness, exact reconnect/switch resend behavior.

  • Step 1: Write all readiness permutation tests

Test all 16 subsets of resources, config, socket, minimum-display; ready is true only when all four are marked. Duplicate marks are idempotent.

gate.mark('resources'); gate.mark('config'); gate.mark('socket');
assert.equal(gate.ready, false);
gate.mark('minimum-display');
assert.equal(gate.ready, true);
  • Step 2: Write initial-login and normal-reconnect tests

start() resolves RuntimeConfig, creates WireClient and connects but sends no login before login(account) supplies a complete request. After login has been sent successfully, an ordinary reconnect open resends the same player_login data and starts the four-second login guard.

await runtime.start();
net.emit({ type: 'open', server: 'ws://agent' });
assert.equal(sent.length, 0);
runtime.login(account);
assert.equal(sent[0].rpc, 'player_login');

Before creating WireClient, assert runtimeConfig.identity.gameid === gameEntry.gameId with strict type-and-value equality. A mismatch is a build/composition error: call showFatal(error) once and do not open a socket. Cover both a different value and a string/number type mismatch so a package cannot accidentally run against another game's server identity.

  • Step 3: Write exact switch-intent tests

When PlatformHandlers requests a room switch, runtime stores the complete switch envelope, calls net.switchServer(target), and on the next open sends connect_roomserver with identical data before returning the steady intent to login. Repeat for agent switch and preserve opt.

connection.switchServer('ws://room', switchEnvelope);
net.emit({ type: 'open', server: 'ws://room' });
assert.equal(sent.at(-1), switchEnvelope);

No automatic player_login may be sent instead of the switch packet. A later ordinary reconnect sends login.

  • Step 4: Write scene and shutdown ordering tests

  • successful login without room -> showLobby();

  • room login/self join -> commit state, open game session, showRoom(), then restore deskinfo;

  • reconnecting/slow -> showReconnect() without clearing room state;

  • kick -> close game, showKicked(data), stop network;

  • fatal contract/config error -> showFatal(error) once;

  • stop -> unsubscribe WireClient, dispose game session, invalidate GameHost, then stop WireClient.

  • Step 5: Run tests and verify failure

node --import tsx --test --test-concurrency=1 framework-tests/platform/ready-gate.test.ts framework-tests/platform/runtime.test.ts

Expected: FAIL because PlatformRuntime and ReadyGate do not exist.

  • Step 6: Implement narrow ports and runtime composition
export interface ScenePort {
  showLoading(): void;
  showLogin(): void;
  showLobby(): void;
  showRoom(): void;
  showReconnect(): void;
  showKicked(data: unknown): void;
  showFatal(error: Error): void;
}

Runtime imports no cc. Its constructor requires one GameEntry; there is no array and no registration method. Wire the sole WireClient subscription before start(). Own login guard and ConnectionIntent in runtime, while WireClient owns only transport reconnect/watchdog.

  • Step 7: Keep the old StartupOrchestrator outside the modern path

Do not import or modify startup.ts: it is still referenced by old framework tests and belongs to the quarantined UI compatibility island. Extend the boundary test to prove runtime.ts cannot import StartupOrchestrator, old NetClient, PlatformSession or RoomRPCBus. Inside the modern path, runtime.ts is the only startup owner.

  • Step 8: Run tests and typecheck
node --import tsx --test --test-concurrency=1 framework-tests/platform/*.test.ts framework-tests/net/*.test.ts framework-tests/sdk/*.test.ts
npm run typecheck:framework

Expected: PASS.

  • Step 9: Commit
git add -- YouleNexus/assets/framework/platform/ready-gate.ts YouleNexus/assets/framework/platform/scene-port.ts YouleNexus/assets/framework/platform/runtime.ts framework-tests/platform/ready-gate.test.ts framework-tests/platform/runtime.test.ts framework-tests/architecture/import-boundaries.test.mjs
git commit -m "feat(platform): add single-entry platform runtime"

Task 11: 完整纵向回放、收紧 SDK,并审计迁移期隔离

Files:

  • Create: cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts
  • Replace: cocoscreator_projects/framework-tests/integration/login-flow.test.ts
  • Modify: cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts
  • Delete: cocoscreator_projects/framework-tests/sdk/sdk.test.ts
  • Modify: cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs
  • Modify: cocoscreator_projects/YouleNexus/assets/framework/README.md

Interfaces:

  • Consumes: Tasks 1-10 complete runtime.

  • Produces: one headless verified modern vertical slice, a contracts-only public SDK, and a machine-checked wall preventing modern code from entering the still-required Cocos UI compatibility island.

  • Step 1: Write the end-to-end replay test before deleting old code

Use fake config fetcher, fake native environment, fake transports, fake ScenePort and one fixture GameEntry. Drive this exact sequence:

resolve gameserver -> POST empty body -> data.urlserver
-> socket open, no premature login
-> login(account) -> exact player_login envelope
-> login success -> lobby
-> joinRoom -> exact self_join_room envelope
-> connect_roomserver push -> close -> target open -> resend exact switch packet
-> self_join_room success -> room state + fresh GameModule
-> player_prepare -> store commit before game event
-> disconnect -> delayed reconnect -> player_login
-> login response with truthy deskinfo -> fresh room state -> restore same object
-> kick_server -> session disposal -> no reconnect

Assert every outbound JSON string after parsing equals the expected envelope and contains no added/renamed field. Assert every inbound Envelope reaches one Router call. Assert other game routes throw.

  • Step 2: Run the replay and verify the first failure
node --import tsx --test --test-concurrency=1 framework-tests/integration/platform-vertical-slice.test.ts

Expected: FAIL at the first concrete wiring or ordering mismatch. Use the failure path to identify the owning Task 1-10 module, change that module only, and rerun this single test after each correction; do not add alternate dispatch or fallback behavior to make the replay pass.

  • Step 3: Remove the legacy public SDK surface and prove runtime isolation

Make sdk/index.ts exactly:

export * from './contracts/index.ts';

Delete the obsolete sdk.test.ts that tests GameContext/IGameModule. Keep legacy Store/PlatformSession/NetClient/RoomRPCBus files because the current Cocos scripts still import them and phase 3 has not yet introduced Presenters or a real game Composition Root. Extend the boundary test so every modern runtime file is transitively checked against LEGACY_RUNTIME. Use rg to inventory the compatibility island and fail if a match appears outside the explicit allowlist:

rg -n "GameContext|IGameModule|ActiveGame" YouleNexus/assets/framework framework-tests
rg -n "NetClient|PlatformSession|RoomRPCBus|ReadonlyPlayerStore|ReadonlyRoomStore|ReadonlyAppStore|AppStore|PlayerStore|RoomStore" YouleNexus/assets/framework/net/wire-client.ts YouleNexus/assets/framework/platform/runtime-session.ts YouleNexus/assets/framework/platform/runtime.ts YouleNexus/assets/framework/protocol/platform-handlers.ts YouleNexus/assets/framework/protocol/router.ts

Expected: the first command has no matches except explicit negative assertions in architecture tests; the second command has no matches. Existing matches under old compatibility files/tests are documented in README and are not part of the modern import graph.

  • Step 4: Update framework README with the one supported composition path

Document:

game CompositionRoot -> bootstrapPlatform(single GameEntry)
game implementation -> framework/sdk only
WireClient -> PlatformRuntime -> Router exactly once
PlatformStore -> selectors -> GameHost/UI read-only consumers

Explicitly state no GameRegistry, no runtime API negotiation, no multi-game package and no direct Store/EventBus access. Also list the exact quarantined files (net/net-client.ts, platform/session.ts, platform/startup.ts, platform/room-rpc-bus.ts, platform/readonly.ts, legacy Store files, protocol/room-handlers.ts) and state that phase 3 must replace their assets/scripts callers before phase 6 deletes them.

  • Step 5: Run the complete non-UI verification suite
npm run check-boundaries
npm run typecheck:framework
node --import tsx --test --test-concurrency=1 framework-tests/config/*.test.ts framework-tests/config/sources/*.test.ts framework-tests/native/*.test.ts framework-tests/core/*.test.ts framework-tests/net/*.test.ts framework-tests/protocol/*.test.ts framework-tests/platform/*.test.ts framework-tests/sdk/*.test.ts framework-tests/integration/*.test.ts

Expected: all listed tests PASS with zero type errors and zero forbidden imports. Legacy UI migration .mjs tests are outside this logic batch and remain untouched.

  • Step 6: Run a changed-file audit
git status --short
git diff --check
git diff --name-only -- YouleNexus/assets/framework framework-tests scripts package.json

Expected: only plan-owned TypeScript/tests/tooling files are changed; no .scene, .prefab, .anim, .meta, PNG or existing UI migration artifact appears.

  • Step 7: Commit
git add -A -- YouleNexus/assets/framework/sdk/index.ts YouleNexus/assets/framework/README.md framework-tests/sdk/sdk.test.ts framework-tests/integration framework-tests/architecture/import-boundaries.test.mjs
git commit -m "refactor(platform): complete decoupled vertical runtime"

Final Acceptance Checklist

  • framework/sdk/contracts has no imports from internal framework modules or cc.
  • PlatformRuntime receives exactly one compile-time GameEntry and has no registry/discovery/version negotiation API.
  • A fresh GameModule and GameHost lease are created for each room session and disposed exactly once.
  • data.urlserver is the only remote source of WebSocket server addresses.
  • native settings and WVJB initialization match the original names and observable order.
  • first-slice outbound route/rpc/data structures match protocol golden fixtures.
  • login gate, four-second login guard, heartbeat timeout, server rotation and stale connection filtering pass.
  • room/agent server switch reconnects and first resends the exact connect_*server packet, not an invented login sequence.
  • each inbound business Envelope enters one Router once.
  • PlatformStore commits atomically and contains no deskinfo, transport, bridge, GameModule or Cocos object.
  • truthy deskinfo reaches GameModule.restore by identity after room state commit; falsey/missing values do not.
  • game custom sends cannot choose a platform route; platform commands cannot accept arbitrary rpc.
  • obsolete ActiveGame 和旧 SDK 暴露已删除;RoomRPCBus、legacy Store、旧 NetClient/PlatformSession 只存在于 README 列明的隔离兼容岛,现代运行时对其零依赖。
  • all targeted tests, boundary check and framework typecheck pass.
  • no Cocos serialized resource or existing UI migration artifact is modified by this plan.
  • Presenter/Composition Root 接管后删除兼容岛、theme/Prefab binding、first real game migration and ZIP publishing remain separate subsequent plans.