# 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 本计划完成后的核心结构: ```text 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** ```ts 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: ```js 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: ```powershell 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** ```ts 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): ```js 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** ```powershell 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** ```powershell 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** ```ts 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** ```ts 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** ```powershell 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** ```ts 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** ```powershell 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** ```powershell 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`: ```json {"data":{"urlserver":"10.0.0.1:3088"}} ``` `remote-config-array.json`: ```json {"data":{"urlserver":["10.0.0.1:3088","wss://backup.example/ws"]}} ``` Tests: ```ts 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`: ```ts 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** ```powershell 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** ```ts 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; 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: ```ts 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_` 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** ```powershell 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** ```powershell 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: ```ts 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`: ```ts [ '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: ```ts [ '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** ```powershell 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** ```ts 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** ```powershell node --import tsx --test --test-concurrency=1 framework-tests/native/*.test.ts npm run typecheck:framework ``` Expected: PASS. - [ ] **Step 7: Commit** ```powershell 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** ```ts 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`; no caller-supplied route map is accepted. - [ ] **Step 2: Add verified golden fixtures** `player-login-success.json` uses the existing verified response: ```json {"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): ```json {"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** ```ts 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: ```ts 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** ```powershell 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: ```ts export interface ParsedRoomEntry { readonly room: ParsedRoomSnapshot; readonly reconnect: { readonly present: false } | { readonly present: true; readonly value: unknown }; readonly raw: Readonly>; } ``` Set `reconnect.present` using `Boolean(raw.deskinfo)` and keep `value` as the same reference. - [ ] **Step 7: Run protocol tests and typecheck** ```powershell 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** ```powershell 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** ```ts 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. ```ts const before = store.getState(); assert.throws(() => store.playerReady({ seat: 99 }), /seat/); assert.equal(store.getState(), before); ``` - [ ] **Step 3: Run tests and verify failure** ```powershell 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** ```ts export interface PlayerDirectoryState { readonly selfPlayerId: number | null; readonly entities: Readonly>; } 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** ```powershell 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** ```powershell 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** ```ts 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. ```ts assert.throws(() => client.send(envelope), /connection.*open/); client.switchServer('ws://room'); assert.equal(currentTransport.closeCalls, 1); ``` - [ ] **Step 3: Run net tests and verify failure** ```powershell 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** ```ts 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** ```powershell node --import tsx --test --test-concurrency=1 framework-tests/net/*.test.ts npm run typecheck:framework ``` Expected: PASS. - [ ] **Step 7: Commit** ```powershell 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** ```ts 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: ```ts 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** ```ts 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** ```powershell 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** ```ts 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** ```ts 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** ```powershell 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** ```powershell 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** ```ts 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** ```ts 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** ```powershell 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** ```ts 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** ```powershell 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** ```powershell 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. ```ts 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. ```ts 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`. ```ts 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** ```powershell 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** ```ts 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** ```powershell 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** ```powershell 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: ```text 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** ```powershell 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: ```ts 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: ```powershell 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: ```text 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** ```powershell 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** ```powershell 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** ```powershell 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.