diff --git a/docs/superpowers/plans/2026-09-04-platform-vertical-slice.md b/docs/superpowers/plans/2026-09-04-platform-vertical-slice.md new file mode 100644 index 0000000..48e8310 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-platform-vertical-slice.md @@ -0,0 +1,1210 @@ +# YouleNexus Platform Vertical Slice 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:** 建立可复用、可测试、与具体子游戏解耦的平台运行时,并跑通配置、登录、大厅、进房、准备和断线重连的第一条纵向链路。 + +**Architecture:** 平台核心通过单一 `PlatformRuntime` 装配配置、网络、协议、状态和场景端口;所有业务消息只经过一个 Router。子游戏通过版本化 `sdk/contracts` 接入,平台通过 `GameSessionHost` 调用游戏,游戏只能通过受限 `GameHost` 调用平台,不得导入任何平台内部实现。 + +**Tech Stack:** TypeScript 6、Node.js test runner、tsx、Cocos Creator 3.8+、原生 WebSocket、funplay-cocos MCP。 + +**Spec:** `docs/superpowers/specs/2026-09-04-platform-vertical-slice-design.md` + +## Global Constraints + +- 服务器零改动:信封固定为 `{app:"youle",route,rpc,data}`,route/rpc/字段名/类型/时序与 `docs/protocol/` 一致。 +- 配置服务和原生 App 零改动:`gameserver → data.urlserver`、`window.settings` 名称和数据格式逐字兼容。 +- 配置、RPC route、玩家实体和房间座位各自只有一个权威来源;下游不补默认值。 +- 平台不解析 `roomtype` 和 `deskinfo`;`deskinfo` 严格按原工程 `if (deskinfo)` 的真值语义触发 `GameModule.restore`,不进入 PlatformState。 +- `sdk/contracts` 不得依赖 platform/net/protocol/ui/core 内部模块、Cocos `cc` 或具体子游戏。 +- 具体子游戏不得导入 framework 的 platform/net/protocol/ui/core 内部模块。 +- 所有 `.scene`、`.prefab`、`.anim`、`.meta` 修改必须通过 funplay-cocos MCP,禁止文本编辑。 +- 每个任务遵循测试先行;提交时只暂存本任务文件,不夹带工作区已有 UI 迁移改动。 +- 测试命令均从 `cocoscreator_projects/` 目录执行。 + +--- + +## File Structure + +本计划完成后的核心结构: + +```text +cocoscreator_projects/YouleNexus/assets/framework/ +├── config/ +│ ├── runtime-config.ts +│ ├── bootstrap.ts +│ ├── remote-config.ts +│ └── sources/native-settings.ts +├── net/ +│ ├── net-client.ts +│ └── ... +├── protocol/ +│ ├── contracts/ +│ │ ├── validation.ts +│ │ ├── login-contract.ts +│ │ ├── join-room-contract.ts +│ │ └── room-event-contracts.ts +│ ├── rpc-route.ts +│ ├── platform-handlers.ts +│ └── router.ts +├── platform/ +│ ├── runtime.ts +│ ├── ready-gate.ts +│ ├── session.ts +│ ├── commands.ts +│ ├── game-host-adapter.ts +│ ├── scene-port.ts +│ └── stores/ +│ ├── platform-store.ts +│ ├── platform-types.ts +│ ├── selectors.ts +│ └── types.ts # legacy types, removed after migration +├── sdk/ +│ ├── contracts/ +│ │ ├── api-version.ts +│ │ ├── game-descriptor.ts +│ │ ├── game-host.ts +│ │ ├── game-module.ts +│ │ ├── game-seat-mapper.ts +│ │ ├── platform-events.ts +│ │ ├── snapshots.ts +│ │ └── index.ts +│ ├── runtime/ +│ │ ├── game-registry.ts +│ │ └── game-session-host.ts +│ └── testing/game-contract-harness.ts +└── ui/controllers/ + ├── connection-overlay-controller.ts + ├── join-room-controller.ts + └── room-shell-controller.ts +``` + +具体子游戏后续统一放在 `YouleNexus/assets/games//`,本计划只创建测试游戏,不创建真实玩法。 + +--- + +### Task 1: 建立纯净、版本化的子游戏 SDK 契约 + +**Files:** +- Create: `cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/api-version.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-descriptor.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/framework-tests/sdk/contracts.test.ts` +- Create: `cocoscreator_projects/framework-tests/architecture/import-boundaries.test.mjs` + +**Interfaces:** +- Produces: `PLATFORM_GAME_API_VERSION = 1`, `GameDescriptor`, `GameModule`, `GameHost`, `GameSeatMapper`, `PlatformToGameEvent`, `GameServerMessage`, `PlatformGameSnapshot`, `GameHostCommand`。 +- Consumes: only TypeScript standard types. + +- [ ] **Step 1: Write the failing public-contract test** + +```ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PLATFORM_GAME_API_VERSION } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; + +test('platform game API version is exact', () => { + assert.equal(PLATFORM_GAME_API_VERSION, 1); +}); +``` + +- [ ] **Step 2: Write the failing import-boundary test** + +The test recursively reads `assets/framework/sdk/contracts/**/*.ts` and rejects imports containing `/platform/`, `/net/`, `/protocol/`, `/ui/`, `/core/`, `from 'cc'`, or `from "cc"`. + +```js +assert.equal( + forbidden, + undefined, + `${file} imports forbidden dependency: ${forbidden}`, +); +``` + +- [ ] **Step 3: Run the tests and verify failure** + +Run: + +```powershell +node --import tsx --test framework-tests/sdk/contracts.test.ts framework-tests/architecture/import-boundaries.test.mjs +``` + +Expected: FAIL because `sdk/contracts/index.ts` does not exist. + +- [ ] **Step 4: Add the exact contract types** + +```ts +export const PLATFORM_GAME_API_VERSION = 1 as const; +export type PlatformGameApiVersion = typeof PLATFORM_GAME_API_VERSION; + +export interface GameDescriptor { + readonly apiVersion: PlatformGameApiVersion; + readonly gameId: string | number; + readonly route: string; + readonly key: string; + resolveSeatCount(roomtype: readonly unknown[]): number; + create(): GameModule; +} + +export interface GameModule { + attach(host: GameHost): void; + handlePlatformEvent(event: PlatformToGameEvent): void; + handleGameMessage(message: GameServerMessage): void; + restore(deskinfo: unknown): void; + dispose(): void; +} +``` + +Define `GameHost` exactly as the approved spec: bound `seat: GameSeatMapper`, `getSnapshot`, immediate `subscribe`, route-bound `sendGameMessage`, and `execute` with only `room.prepare|room.exit`. `GameSeatMapper` exposes `toView(serverSeat)` and `toServer(viewSeat)`. Define snapshot DTOs locally in `sdk/contracts`; do not import Store types. + +- [ ] **Step 5: Add the new contracts without breaking legacy callers** + +```ts +export * from './contracts/index.ts'; +``` + +Add this export to the existing `sdk/index.ts` while temporarily retaining its legacy interfaces. Only `sdk/contracts` is the new public boundary; Task 7 migrates callers and removes the legacy definitions in the same green commit. + +- [ ] **Step 6: Run targeted tests and typecheck** + +```powershell +node --import tsx --test framework-tests/sdk/contracts.test.ts framework-tests/architecture/import-boundaries.test.mjs +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: SDK tests PASS and typecheck reports zero errors. + +- [ ] **Step 7: Commit** + +```powershell +git add -- YouleNexus/assets/framework/sdk framework-tests/sdk/contracts.test.ts framework-tests/architecture/import-boundaries.test.mjs +git commit -m "feat(sdk): define decoupled game contracts" +``` + +--- + +### Task 2: 实现游戏注册表、会话生命周期与接入测试工具 + +**Files:** +- Create: `cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-registry.ts` +- 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-registry.test.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 `GameDescriptor`, `GameModule`, `GameHost`, event/message contracts. +- Produces: `GameRegistry.register/getByRoute/getByGameId`, `GameSessionHost.attach/enter/handleGameMessage/restore/dispose`, `assertGameContract`。 + +- [ ] **Step 1: Write registry failure tests** + +```ts +test('registry rejects duplicate route and API mismatch', () => { + const registry = new GameRegistry(); + registry.register(descriptor({ key: 'a', gameId: 1, route: 'g1' })); + assert.throws(() => registry.register(descriptor({ key: 'b', gameId: 2, route: 'g1' })), /route.*g1/); + assert.throws(() => registry.register({ ...descriptor({ key: 'c', gameId: 3, route: 'g3' }), apiVersion: 2 as never }), /apiVersion/); +}); +``` + +Also reject a missing `resolveSeatCount` contract. The conformance harness calls it with fixture `roomtype` values and rejects zero, negative, fractional, or non-number results. + +- [ ] **Step 2: Write lifecycle and generation tests** + +Cover `detached → attached → entered → disposed`, illegal double attach, event before enter, dispose exactly once, and calls through an old Host after a new generation begins. + +```ts +session.attach(host); +session.enter({ type: 'room-entered', roomtype: rawRoomtype }); +session.dispose(); +assert.throws(() => oldHost.sendGameMessage('play', {}), /disposed/); +``` + +- [ ] **Step 3: Run tests and verify failure** + +```powershell +node --import tsx --test framework-tests/sdk/game-registry.test.ts framework-tests/sdk/game-session-host.test.ts +``` + +Expected: FAIL because runtime classes do not exist. + +- [ ] **Step 4: Implement strict registry** + +Use three maps keyed by `key`, normalized `String(gameId)`, and `route`. Reject empty values, duplicates, API mismatch, and factories that return the same object twice during conformance testing. + +```ts +register(value: GameDescriptor): void { + assertDescriptor(value); + if (this.byRoute.has(value.route)) throw new Error(`duplicate game route: ${value.route}`); + this.byRoute.set(value.route, value); + this.byGameId.set(String(value.gameId), value); + this.byKey.set(value.key, value); +} +``` + +- [ ] **Step 5: Implement serialized session dispatch** + +`GameSessionHost` owns one module and one Host. Every public method checks state; `fail(error)` records the first failure, disposes once, and rejects later dispatch. Store commit ordering remains the platform adapter's responsibility. + +- [ ] **Step 6: Implement the reusable conformance harness** + +`assertGameContract(descriptor, makeHost)` runs descriptor validation, seat-count resolution, fresh factory, lifecycle, route-bound send, snapshot immutability, subscription cleanup, deskinfo identity, 2/4/10-seat mapper round trips, and two-session isolation. + +- [ ] **Step 7: Run tests and typecheck** + +```powershell +node --import tsx --test framework-tests/sdk/game-registry.test.ts framework-tests/sdk/game-session-host.test.ts framework-tests/sdk/game-contract-harness.test.ts +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```powershell +git add -- YouleNexus/assets/framework/sdk/runtime YouleNexus/assets/framework/sdk/testing framework-tests/sdk +git commit -m "feat(sdk): add game registry and isolated sessions" +``` + +--- + +### Task 3: 修正运行配置、原生身份与 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/profiles.ts` +- Modify: `cocoscreator_projects/YouleNexus/assets/framework/config/sources/native-settings.ts` +- Modify: `cocoscreator_projects/framework-tests/config/bootstrap.test.ts` +- Modify: `cocoscreator_projects/framework-tests/config/remote-config.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:** +- Produces: `RuntimeConfig`, `resolveRuntimeConfig(options)`, `decodeNativeGameConfig(value)`, `parseUrlServers(config)`. +- Consumes: existing runtime mode and identity source abstractions. + +- [ ] **Step 1: Add failing urlserver tests** + +Before changing configuration or identity code, read `.agents/skills/native-bridge-contract/SKILL.md` completely and follow its referenced source map for `gameserver`, `gameconfig`, `urlserver`, and `window.settings`. Record the exact legacy source lines beside each fixture assertion; do not infer bridge names or transforms from current YouleNexus code. + +```ts +test('remote config uses data.urlserver as the only WS source', () => { + assert.deepEqual(parseUrlServers({ data: { urlserver: ['10.0.0.1:3088', 'wss://b'] } }), [ + 'ws://10.0.0.1:3088', + 'wss://b', + ]); + assert.throws(() => parseUrlServers({ data: { player_server_tcp: 'wrong:1' } }), /data\.urlserver/); +}); +``` + +- [ ] **Step 2: Add failing native branch tests** + +```ts +assert.equal(decodeNativeGameConfig('host-path#8080-config'), 'http://host/path:8080/config.txt'); +assert.throws(() => nativeSettingsSource(nonUAgentHostWithoutSettings)(), /getothername/); +assert.equal(nativeSettingsSource(uAgent3Host)().agentid, 'legacy-agent'); +``` + +The uAgent_3 case must be selected by an explicit environment discriminator, not by catch-all exception handling. + +- [ ] **Step 3: Run config tests and verify failure** + +```powershell +node --import tsx --test framework-tests/config/*.test.ts framework-tests/config/sources/native-settings.test.ts +``` + +Expected: FAIL on `urlserver`, gameconfig decoding, and current fallback behavior. + +- [ ] **Step 4: Implement strict remote config parsing** + +```ts +export interface RemoteConfig { + data: { urlserver: string | readonly string[]; [key: string]: unknown }; +} + +export function parseUrlServers(input: unknown): string[] { + const value = requireRecord(input, '$').data; + const raw = requireRecord(value, '$.data').urlserver; + const entries = typeof raw === 'string' ? [raw] : requireStringArray(raw, '$.data.urlserver'); + if (entries.length === 0) throw new ConfigParseError('$.data.urlserver must not be empty'); + return entries.map(toWebSocketUrl); +} +``` + +Do not read `player_server_tcp`, `visitor_server_tcp`, or `game_server_tcp` for the first WebSocket path. + +- [ ] **Step 5: Correct profiles and bootstrap** + +Set the release default to the exact original source value from `Game_Surface_3/js/01_SubGame/00_SubGame_Config.js:11`. Keep explicit local/staging profiles, but remove `REPLACE_ME` data from production paths and prevent unknown profile names from silently selecting prod; unknown names throw in debug. + +- [ ] **Step 6: Build immutable RuntimeConfig** + +Validate channel identity and login identity before constructing NetClient. Return a frozen object and frozen server list. No controller may call `resolveBootstrap` directly after this task. + +- [ ] **Step 7: Run tests and typecheck** + +```powershell +node --import tsx --test framework-tests/config/*.test.ts framework-tests/config/sources/native-settings.test.ts +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```powershell +git add -- YouleNexus/assets/framework/config framework-tests/config +git commit -m "fix(config): align runtime configuration with legacy contract" +``` + +--- + +### Task 4: 建立首批 RPC 的唯一 route 表、解析器与黄金样本 + +**Files:** +- Create: `cocoscreator_projects/YouleNexus/assets/framework/protocol/rpc-route.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/join-room-contract.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-event-contracts.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts` +- Modify: `cocoscreator_projects/YouleNexus/assets/framework/protocol/routes.ts` +- Modify: `cocoscreator_projects/YouleNexus/assets/framework/protocol/typed-send.ts` +- Replace: `cocoscreator_projects/YouleNexus/assets/framework/core/types/login.ts` +- Create: `cocoscreator_projects/framework-tests/fixtures/protocol/player-login-success.json` +- Create: `cocoscreator_projects/framework-tests/fixtures/protocol/player-login-room.json` +- Create: `cocoscreator_projects/framework-tests/fixtures/protocol/self-join-room.json` +- Create: `cocoscreator_projects/framework-tests/fixtures/protocol/room-events.json` +- Create: `cocoscreator_projects/framework-tests/protocol/contracts.test.ts` +- Modify: `cocoscreator_projects/framework-tests/protocol/routes.test.ts` +- Modify: `cocoscreator_projects/framework-tests/protocol/net-typed-send.test.ts` +- Modify: `cocoscreator_projects/framework-tests/protocol/login.test.ts` + +**Interfaces:** +- Produces: exhaustive `RPC_ROUTE satisfies Record`, request/response parsers for login, join, prepare, join/exit/online/offline, switch-server, kick. +- Consumes: `docs/protocol/01-05` and the contract inventory; no Store imports. + +- [ ] **Step 1: Add failing exhaustive route test** + +Before writing or changing a network contract, re-read `docs/protocol/01-网络通信与生命周期.md` through `05-游戏内协议与桥接.md` sections covering that RPC and verify every fixture field against the cited source. The contract inventory is an index, not a substitute for the protocol chapters. + +```ts +for (const rpc of Object.values(Rpc)) { + assert.ok(RPC_ROUTE[rpc], `missing route for ${rpc}`); +} +assert.equal(RPC_ROUTE.self_join_room, Route.agent); +assert.equal(RPC_ROUTE.player_prepare, Route.room); +``` + +- [ ] **Step 2: Add golden fixture tests** + +Copy the verified `player_login` response from `docs/protocol/04-数据结构.md`; give other fixtures only fields justified by protocol tables. Tests assert exact encoded envelope and parser output. Do not add values for undocumented fields. + +```ts +const deskinfo = roomFixture.data.deskinfo; +const parsed = parseLoginResponse(roomFixture.data); +assert.equal(parsed.reconnect.present, true); +assert.equal(parsed.reconnect.value, deskinfo); +``` + +- [ ] **Step 3: Add failing negative tests** + +Cover numeric `version`, missing `playerid`, missing room `seat`, invalid players array, invalid `urlserver`, and the critical cases where `isbattle` is set but `deskinfo` is absent or falsey. + +```ts +const { deskinfo: _ignored, ...withoutDeskinfo } = room; +assert.equal(parseLoginResponse({ ...withoutDeskinfo, isbattle: 1 }).reconnect.present, false); +assert.equal(parseLoginResponse({ ...withoutDeskinfo, isbattle: 1, deskinfo: null }).reconnect.present, false); +assert.equal(parseLoginResponse({ ...withoutDeskinfo, isbattle: 1, deskinfo: { round: 2 } }).reconnect.present, true); +``` + +Use `Boolean(data.deskinfo)` for this trigger because the original source is exactly `if (_msg.data.deskinfo)`. Keep the truthy value opaque and preserve its identity; do not validate or interpret the snapshot structure in platform code. + +- [ ] **Step 4: Run tests and verify failure** + +```powershell +node --import tsx --test framework-tests/protocol/contracts.test.ts framework-tests/protocol/routes.test.ts framework-tests/protocol/net-typed-send.test.ts framework-tests/protocol/login.test.ts +``` + +Expected: FAIL because the production route table and strict parsers do not exist. + +- [ ] **Step 5: Implement reusable validation helpers** + +Provide `requireRecord`, `requireString`, `requireNumber`, `requireInteger`, `requireArray`, `optionalField`, and `hasOwn`. Every thrown message includes the JSON path. + +- [ ] **Step 6: Implement exact parsers and request builders** + +Request builders return fresh objects containing only protocol-defined fields. Response parsers preserve optional protocol values and keep `roomtype/deskinfo` opaque; the reconnect projection uses the original client's truthiness semantics. + +- [ ] **Step 7: Replace caller-supplied route maps** + +```ts +export function typedSend(rpc: RpcName, data: T): TypedEnvelope { + return { route: RPC_ROUTE[rpc], rpc, data }; +} +``` + +Delete the `map` parameter so controllers and games cannot provide a second route source. + +- [ ] **Step 8: Run protocol tests and typecheck** + +```powershell +node --import tsx --test framework-tests/protocol/*.test.ts +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```powershell +git add -- YouleNexus/assets/framework/protocol YouleNexus/assets/framework/core/types/login.ts framework-tests/protocol framework-tests/fixtures/protocol +git commit -m "feat(protocol): add strict vertical-slice contracts" +``` + +--- + +### Task 5: 用单一 PlatformStore 替换玩家与房间镜像状态 + +**Files:** +- Create: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/selectors.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-types.ts` +- Create: `cocoscreator_projects/framework-tests/platform/platform-store.test.ts` +- Create: `cocoscreator_projects/framework-tests/platform/selectors.test.ts` + +**Interfaces:** +- Produces: `PlatformStore`, `PlatformState`, `PlayerDirectoryState`, `RoomState`, atomic actions and read-only selectors. +- Consumes: Task 4 parsed DTOs; does not consume raw unknown server payloads. + +- [ ] **Step 1: Write failing atomic-state tests** + +```ts +const store = new PlatformStore(); +let notifications = 0; +store.state.subscribe(() => notifications++); +store.applyLoginSuccess(parsedLogin); +assert.equal(notifications, 1); +assert.equal(store.state.value.players.selfPlayerId, parsedLogin.player.playerid); +``` + +Add a room fixture whose self player also appears in `players`; assert only one entity object exists and the room stores only player IDs. + +- [ ] **Step 2: Write failing push/update tests** + +Cover other join, exit, ready, offline, online, full reconnect replacement, and failure operations that leave the prior state reference unchanged. + +```ts +const before = store.state.value; +assert.throws(() => store.playerReady({ seat: 99 }), /seat/); +assert.equal(store.state.value, before); +``` + +- [ ] **Step 3: Run tests and verify failure** + +```powershell +node --import tsx --test framework-tests/platform/platform-store.test.ts framework-tests/platform/selectors.test.ts +``` + +Expected: FAIL because PlatformStore does not exist. + +- [ ] **Step 4: Define internal state without sentinel identities** + +```ts +export interface PlayerDirectoryState { + readonly selfPlayerId: number | null; + readonly entities: Readonly>; +} + +export interface RoomState { + readonly inRoom: boolean; + readonly roomcode: string | null; + readonly selfSeat: number | null; + readonly seatPlayerIds: readonly (number | null)[]; + readonly roomtype: readonly unknown[] | null; + readonly stage: number; + readonly needprepare: number; + readonly infinite: number; +} +``` + +There is no fake `playerid:0`, `seat:-1`, or empty server-derived player object. Keep the existing `stores/types.ts` unchanged until all legacy stores are removed in Task 12, so this task remains independently green. + +- [ ] **Step 5: Implement one-signal atomic actions** + +`PlatformStore` owns one `signal`. Every action validates preconditions, builds all changed subtrees, then assigns `this.s.value` exactly once. It never mutates `this.s.value` or nested arrays in place. On room entry/recovery, recursively freeze the received JSON `roomtype` tree without interpreting positions, then keep that one canonical reference; tests assert nested mutation throws and no normalized copy is introduced. + +- [ ] **Step 6: Implement read-only selectors** + +Provide `selectSelfPlayer`, `selectSeat`, `selectRoomPlayers`, and `selectGameSnapshot`. Snapshot creation returns a frozen DTO and maps protocol `onstate/isprepare` to public booleans without changing the stored protocol values. + +- [ ] **Step 7: Run tests and typecheck** + +```powershell +node --import tsx --test framework-tests/platform/platform-store.test.ts framework-tests/platform/selectors.test.ts +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: PASS while legacy stores remain temporarily available to old callers. + +- [ ] **Step 8: Commit** + +```powershell +git add -- YouleNexus/assets/framework/platform/stores framework-tests/platform/platform-store.test.ts framework-tests/platform/selectors.test.ts +git commit -m "feat(platform): add atomic platform state store" +``` + +--- + +### Task 6: 收紧 NetClient 前置条件和控制包行为 + +**Files:** +- Modify: `cocoscreator_projects/YouleNexus/assets/framework/net/net-client.ts` +- Modify: `cocoscreator_projects/YouleNexus/assets/framework/net/transport.ts` +- Modify: `cocoscreator_projects/YouleNexus/assets/framework/net/cocos-transport.ts` +- Modify: `cocoscreator_projects/framework-tests/net/net-client.test.ts` +- Modify: `cocoscreator_projects/framework-tests/helpers/fake-transport.ts` + +**Interfaces:** +- Produces: fail-fast `NetClient.start/send/stop`, one message stream, exact login gate, switch/kick events. +- Consumes: Task 3 `RuntimeConfig`, Task 4 request builders/envelope codec. + +- [ ] **Step 1: Add failing precondition tests** + +Before changing NetClient, re-read the lifecycle, control-packet, heartbeat, timeout, and reconnect sections in `docs/protocol/01-网络通信与生命周期.md` and `02-客户端发包.md`; record any discrepancy between current code and the documented original behavior in the test name. + +```ts +assert.throws(() => clientWithoutIdentity.start(), /login identity/); +assert.throws(() => client.send('room', 'player_prepare', {}), /not connected/); +``` + +Also test transport `send` after close, duplicate `onerror/onclose`, and stop idempotency. + +- [ ] **Step 2: Add control packet negative tests** + +Missing `roomserver/agentserver` must emit a contract error or throw through the test boundary, not be ignored. `kick_server` must cancel login/reconnect/watchdog timers and close once. + +- [ ] **Step 3: Run tests and verify failure** + +```powershell +node --import tsx --test framework-tests/net/net-client.test.ts framework-tests/net/transport.test.ts +``` + +Expected: FAIL on silent `sendLogin`, optional transport send, and malformed switch packets. + +- [ ] **Step 4: Implement explicit connection states** + +Use an internal union `idle|connecting|open|stopped`. `start()` requires identity and at least one validated server. `send()` requires `open`. `stop()` transitions once and clears all timers before closing. + +- [ ] **Step 5: Keep the verified legacy timing** + +Retain four-second login guard, ten-second reconnect delay, receive timeout, TCP generation filtering, login-period packet gate, and server rotation. Update tests rather than changing these values. + +- [ ] **Step 6: Run all net tests and typecheck** + +```powershell +node --import tsx --test framework-tests/net/*.test.ts +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```powershell +git add -- YouleNexus/assets/framework/net framework-tests/net framework-tests/helpers/fake-transport.ts +git commit -m "fix(net): fail fast on invalid client lifecycle" +``` + +--- + +### Task 7: 统一平台 Handler、Router 与 Session + +**Files:** +- Create: `cocoscreator_projects/YouleNexus/assets/framework/protocol/platform-handlers.ts` +- Modify: `cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts` +- Modify: `cocoscreator_projects/YouleNexus/assets/framework/platform/session.ts` +- Modify: `cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts` +- Delete: `cocoscreator_projects/YouleNexus/assets/framework/platform/room-rpc-bus.ts` +- Delete: `cocoscreator_projects/YouleNexus/assets/framework/protocol/room-handlers.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` +- Replace: `cocoscreator_projects/framework-tests/platform/session.test.ts` +- Delete: `cocoscreator_projects/framework-tests/platform/room-rpc-bus.test.ts` +- Delete: `cocoscreator_projects/framework-tests/protocol/room-handlers.test.ts` +- Delete: `cocoscreator_projects/framework-tests/protocol/active-game.test.ts` + +**Interfaces:** +- Consumes: strict parsers, `PlatformStore`, `GameSessionHost`, and an injected `GameHostFactory` port. +- Produces: one `Router.dispatch`, one `PlatformSession.handleLogin`, `GameHostFactory.create({ descriptor, selfSeat, seatCount })`, and the platform handler table for the first slice. + +- [ ] **Step 1: Rewrite router tests around one dispatch owner** + +```ts +router.dispatch({ route: 'room', rpc: 'player_prepare', data: { seat: 2 } }); +assert.deepEqual(platformCalls, ['player_prepare']); + +router.dispatch({ route: activeDescriptor.route, rpc: 'deal', data: { card: 1 } }); +assert.deepEqual(gameMessages, [{ rpc: 'deal', data: { card: 1 } }]); +``` + +Assert an unknown platform RPC throws `UnsupportedPlatformRpcError`; a route different from the active session descriptor throws and never reaches either side. + +- [ ] **Step 2: Write handler ordering tests** + +For each room push, subscribe to PlatformStore and log GameModule events. Assert store notification occurs before the event and that the snapshot read inside the event contains the committed state. + +- [ ] **Step 3: Write login/reconnect tests** + +```ts +session.handleLogin(roomLogin); +assert.equal(store.state.value.room.inRoom, true); +assert.equal(fakeGame.restoreCalls[0], roomLogin.deskinfo); +assert.equal('deskinfo' in store.state.value.room, false); +``` + +Also assert `{ isbattle:1 }` with absent or `null` `deskinfo` does not call restore, while a truthy opaque object calls it exactly once. + +- [ ] **Step 4: Run tests and verify failure** + +```powershell +node --import tsx --test framework-tests/protocol/router.test.ts framework-tests/protocol/platform-handlers.test.ts framework-tests/platform/session.test.ts +``` + +Expected: FAIL against the current parallel Router/RoomRPCBus implementation. + +- [ ] **Step 5: Implement PlatformHandlers** + +Handlers accept parsed data only. Implement `self_join_room`, `other_join_room`, `self_exit_room`, `other_exit_room`, `player_prepare`, `other_offline`, and `other_online`. Each calls one PlatformStore action, then one GameSession event where defined. + +- [ ] **Step 6: Replace ActiveGame with GameSessionHost in Router** + +```ts +if (isPlatformRoute(message.route)) { + return this.platform.dispatch(message); +} +return this.gameSessions.dispatchGameMessage(message.route, { + rpc: message.rpc, + data: message.data, +}); +``` + +No other module subscribes separately to `NetClient.message`. + +- [ ] **Step 7: Make PlatformSession a parsed orchestration boundary** + +`handleLogin(unknown)` parses once, rejects failure without partial Store writes, resolves the descriptor with `GameRegistry.getByGameId(RuntimeConfig.identity.gameid)`, obtains the seat count only through `descriptor.resolveSeatCount(roomtype)`, asks the injected `GameHostFactory` for a route/seat-bound Host, opens or replaces the correct game session, atomically replaces recovery state, then calls `restore` if and only if `Boolean(deskinfo)`. Task 7 tests use a strict fake factory; the production adapter arrives in Task 8, so there is no forward implementation dependency. + +- [ ] **Step 8: Remove obsolete buses/stores from this path** + +Delete the parallel bus/handler/active-game files and all imports. Replace `sdk/index.ts` with a pure re-export of `sdk/contracts/index.ts`. Do not leave forwarding shims that could become a second runtime path. + +- [ ] **Step 9: Run platform/protocol tests and typecheck** + +```powershell +node --import tsx --test framework-tests/protocol/*.test.ts framework-tests/platform/*.test.ts framework-tests/sdk/*.test.ts +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 10: Commit** + +```powershell +git add -A -- YouleNexus/assets/framework/protocol YouleNexus/assets/framework/platform YouleNexus/assets/framework/sdk/index.ts framework-tests/protocol framework-tests/platform +git commit -m "refactor(platform): unify message routing and session state" +``` + +--- + +### Task 8: 实现受限 GameHost 适配器与平台命令 + +**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: GameHost contract, Task 7 `GameHostFactory` port, descriptor route, PlatformStore selectors, typed send, NetClient. +- Produces: the production `createGameHostAdapter(options)` implementation of `GameHostFactory` and `PlatformCommands.prepare/exitRoom`. + +- [ ] **Step 1: Write failing route-binding and command tests** + +```ts +host.sendGameMessage('play-card', { card: 7 }); +assert.deepEqual(sent[0], { route: 'mock-game', rpc: 'play-card', data: { card: 7 } }); + +host.execute({ type: 'room.prepare' }); +assert.equal(sent[1].rpc, 'player_prepare'); +``` + +Assert no Host method accepts a route argument. Assert `room.exit` rejects when `room.stage !== 0`. + +For seat mapping, cover 2-, 4-, and 10-seat sessions. Assert `host.seat.toView(selfSeat) === 0`, every server seat round-trips through `toView`/`toServer`, and an out-of-range or non-integer seat throws with the offending value. + +- [ ] **Step 2: Write subscription/disposal tests** + +The first callback is immediate. Reassigning internal state with no public snapshot change does not notify. Disposing removes all listeners and every subsequent Host method throws `GameSessionDisposedError`. + +- [ ] **Step 3: Run tests and verify failure** + +```powershell +node --import tsx --test framework-tests/platform/game-host-adapter.test.ts framework-tests/platform/commands.test.ts +``` + +Expected: FAIL because adapter and commands do not exist. + +- [ ] **Step 4: Implement commands through typedSend** + +```ts +prepare(): void { + this.net.sendTyped(typedSend(Rpc.player_prepare, {})); +} + +exitRoom(): void { + if (this.store.state.value.room.stage !== 0) { + throw new UnsupportedGameHostCommandError('room.exit requires stage=0'); + } + this.net.sendTyped(typedSend(Rpc.self_exit_room, {})); +} +``` + +If NetClient does not expose `sendTyped`, add a narrow method accepting `TypedEnvelope`; do not expose raw transport. + +- [ ] **Step 5: Implement snapshot and seat-mapper isolation** + +Build DTOs with selectors, freeze every newly created public object/array, and expose the Store's already-recursively-frozen canonical `roomtype` reference without interpreting its contents. Construct `GameSeatMapper` once from the current session's validated `selfSeat` and descriptor-derived `seatCount`; the adapter must not import the legacy `core/seat` implementation or read changing Store values inside the formulas. Do not expose `PlatformStore.state`. + +- [ ] **Step 6: Run tests, conformance harness, and typecheck** + +```powershell +node --import tsx --test framework-tests/platform/game-host-adapter.test.ts framework-tests/platform/commands.test.ts framework-tests/sdk/game-contract-harness.test.ts +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```powershell +git add -- YouleNexus/assets/framework/platform/game-host-adapter.ts YouleNexus/assets/framework/platform/commands.ts YouleNexus/assets/framework/net/net-client.ts framework-tests/platform framework-tests/sdk +git commit -m "feat(platform): expose restricted game host adapter" +``` + +--- + +### Task 9: 建立 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` +- Delete: `cocoscreator_projects/YouleNexus/assets/framework/platform/startup.ts` +- Create: `cocoscreator_projects/framework-tests/platform/ready-gate.test.ts` +- Create: `cocoscreator_projects/framework-tests/platform/runtime.test.ts` +- Replace: `cocoscreator_projects/framework-tests/platform/startup.test.ts` + +**Interfaces:** +- Consumes: RuntimeConfig resolver, NetClient factory, Router, PlatformSession, PlatformStore, GameRegistry, ScenePort. +- Produces: single `PlatformRuntime.start/stop/joinRoom/prepare/exitRoom`, read-only state, four-gate readiness. + +- [ ] **Step 1: Write all 16 readiness permutation tests** + +For each subset of the four gates, assert ready is false unless all four are true. Assert each gate can be marked once and duplicate marks are harmless. + +```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 runtime construction-order tests** + +Assert config resolves before NetClient creation, identity is set before `start`, the application composition root registers descriptors explicitly, `RuntimeConfig.identity.gameid` resolves exactly one descriptor before a room session can open, Router subscribes before the first post-login business message, and stop disposes game session before stores/network. + +- [ ] **Step 3: Write scene decision tests** + +No room login calls `scene.showLobby()`. Room login calls `scene.showRoom()` before game restore. Kick calls `scene.showKicked()`. Reconnecting/slow calls `scene.showReconnect()` without clearing room state. + +- [ ] **Step 4: Run tests and verify failure** + +```powershell +node --import tsx --test framework-tests/platform/ready-gate.test.ts framework-tests/platform/runtime.test.ts +``` + +Expected: FAIL because runtime and gate do not exist. + +- [ ] **Step 5: Implement Cocos-free runtime** + +The runtime constructor accepts factories/ports; it never imports `cc`. `start()` launches resource/minimum-display promises plus config resolution, then creates and starts NetClient. All NetClient events are wired in one private method and unwired in `stop()`. + +- [ ] **Step 6: Define narrow ScenePort** + +```ts +export interface ScenePort { + showLoading(): void; + showLogin(): void; + showLobby(): void; + showRoom(): void; + showReconnect(): void; + showKicked(data: unknown): void; + showFatal(error: Error): void; +} +``` + +The port describes intent, not Cocos scene or node names. + +- [ ] **Step 7: Delete StartupOrchestrator and adapt tests** + +There must be only one startup owner. Remove all `RoomRPCBus` factory concepts and the old “login 后构造 NetClient” flow. + +- [ ] **Step 8: Run tests and typecheck** + +```powershell +node --import tsx --test framework-tests/platform/*.test.ts framework-tests/integration/login-flow.test.ts +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: PASS after the integration test is adapted to PlatformRuntime. + +- [ ] **Step 9: Commit** + +```powershell +git add -A -- YouleNexus/assets/framework/platform framework-tests/platform framework-tests/integration/login-flow.test.ts +git commit -m "feat(platform): add single runtime and readiness gate" +``` + +--- + +### Task 10: 编写不依赖 Cocos 的首批 Layer 控制器 + +**Files:** +- Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/controllers/connection-overlay-controller.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/controllers/join-room-controller.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/controllers/room-shell-controller.ts` +- Create: `cocoscreator_projects/framework-tests/ui/connection-overlay-controller.test.ts` +- Create: `cocoscreator_projects/framework-tests/ui/join-room-controller.test.ts` +- Create: `cocoscreator_projects/framework-tests/ui/room-shell-controller.test.ts` + +**Interfaces:** +- Consumes: narrow RuntimeCommands and read-only selector/subscription ports. +- Produces: presentation models and user-intent methods; no Cocos imports. + +- [ ] **Step 1: Write JoinRoom input-state tests** + +```ts +const controller = new JoinRoomController(commands, view); +for (const digit of [1, 2, 3, 4, 5, 6]) controller.inputDigit(digit); +assert.equal(view.last.roomcode, '123456'); +controller.confirm(); +assert.deepEqual(commands.calls, [{ type: 'join-room', roomcode: '123456' }]); +``` + +Cover 0-9 only, six-digit maximum, delete, clear, confirm before six digits, double confirm while pending, success close, and failure retaining input. + +- [ ] **Step 2: Write connection overlay tests** + +Map `reconnecting/slow` to Layer 615, pending commands to Layer 614, kicked to Layer 616, and normal states to all hidden. Kicked has precedence over reconnect/loading. + +- [ ] **Step 3: Write room shell tests** + +Feed snapshots and assert roomcode, seats, online/ready state, prepare visibility, and exit command. The controller stores no PlayerState objects after render. + +- [ ] **Step 4: Run tests and verify failure** + +```powershell +node --import tsx --test framework-tests/ui/connection-overlay-controller.test.ts framework-tests/ui/join-room-controller.test.ts framework-tests/ui/room-shell-controller.test.ts +``` + +Expected: FAIL because controllers do not exist. + +- [ ] **Step 5: Implement view-port-driven controllers** + +Each controller receives a view interface and commands. It owns only UI transient state such as roomcode digits and pending flag. All server state comes from snapshots/selectors. + +- [ ] **Step 6: Run UI tests and typecheck** + +```powershell +node --import tsx --test framework-tests/ui/connection-overlay-controller.test.ts framework-tests/ui/join-room-controller.test.ts framework-tests/ui/room-shell-controller.test.ts +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```powershell +git add -- YouleNexus/assets/framework/ui/controllers framework-tests/ui +git commit -m "feat(ui): add vertical-slice presentation controllers" +``` + +--- + +### Task 11: 添加 Cocos 适配器并通过 MCP 挂接首批 prefab + +**Files:** +- Create: `cocoscreator_projects/YouleNexus/assets/scripts/platform/PlatformRootComponent.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/scripts/platform/CocosScenePort.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/scripts/platform/JoinRoomLayerComponent.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/scripts/platform/RoomShellComponent.ts` +- Create: `cocoscreator_projects/YouleNexus/assets/scripts/platform/ConnectionOverlayComponent.ts` +- Replace: `cocoscreator_projects/YouleNexus/assets/scripts/LaunchFlow.ts` +- Replace: `cocoscreator_projects/YouleNexus/assets/scripts/LoginFlow.ts` +- Modify via funplay-cocos MCP: `cocoscreator_projects/YouleNexus/assets/scenes/Login.scene` +- Modify via funplay-cocos MCP: Layer 1、4、15、50、403、411、202、416、614、615、616 prefab resources +- Create: `cocoscreator_projects/framework-tests/cocos-adapters/no-demo-fallback.test.mjs` + +**Interfaces:** +- Consumes: Task 9 PlatformRuntime, Task 10 controllers. +- Produces: thin Cocos Components and ScenePort adapter; no duplicated business logic. + +- [ ] **Step 1: Write the static failure test first** + +Scan production Cocos adapters for forbidden literals/patterns: + +```js +const forbidden = [ + 'mock_openid', + "search: '?profile=local'", + 'fallback to ws://127.0.0.1:3088', + 'setIdentity({ agentid:', +]; +``` + +Expected current `LoginFlow.ts` to fail. + +- [ ] **Step 2: Run the static test and verify failure** + +```powershell +node --test framework-tests/cocos-adapters/no-demo-fallback.test.mjs +``` + +Expected: FAIL on current demo code. + +- [ ] **Step 3: Implement thin Cocos adapters** + +`PlatformRootComponent` creates exactly one runtime and persists it across scene changes. `CocosScenePort` maps semantic scene intents to root Layer activation. Other components only translate Button/Label/Node events to/from the pure controllers. + +- [ ] **Step 4: Remove demo networking from LaunchFlow/LoginFlow** + +Delete local `WSTransport`, mock identity, bootstrap catch fallback, direct RoomRPCBus construction, and fixed one-second scene jump. Use `CocosTransport` and the root runtime. + +- [ ] **Step 5: Use the cocos-mcp skill before editor changes** + +Read `.agents/skills/cocos-mcp/SKILL.md`, verify the editor service is healthy, and use the injected `mcp__funplay_cocos__*` tools. If unavailable, stop this task and ask the user to start the editor service; never edit serialized files manually. + +Before the first editor mutation, record `git status --short` for every listed scene/prefab and its `.meta`. They must already be tracked or separately checkpointed as the completed UI-migration baseline. If any is still an untracked pre-existing UI artifact, stop Task 11 and ask the user to checkpoint that UI work first; an untracked serialized file cannot separate earlier UI content from this task's bindings. + +- [ ] **Step 6: Inspect exact prefab node paths via MCP** + +For each Layer, record the queried node/component path in the task log before binding. Do not infer paths from prefab text. Verify which existing Sprite nodes are intended as buttons and which Labels display room/player state. + +- [ ] **Step 7: Attach components and serialized references via MCP** + +Attach only the five adapter components, wire Buttons/Labels/Nodes through editor properties, and keep business state out of prefab components. Save each prefab/scene through the editor. + +- [ ] **Step 8: Preview the vertical UI states** + +Using injected fake runtime states, capture or visually inspect: loading, login, lobby, six-digit join input, room with two seats, ready, reconnect, and kicked. Verify inactive overlays do not block input. + +- [ ] **Step 9: Run static and framework checks** + +```powershell +node --test framework-tests/cocos-adapters/no-demo-fallback.test.mjs +node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit +``` + +Expected: PASS. Also run the Cocos editor's script compilation check through MCP and require zero component/UUID errors. + +- [ ] **Step 10: Commit** + +```powershell +git add -- YouleNexus/assets/scripts/platform YouleNexus/assets/scripts/LaunchFlow.ts YouleNexus/assets/scripts/LoginFlow.ts framework-tests/cocos-adapters +git add -- YouleNexus/assets/scenes/Login.scene YouleNexus/assets/scenes/Login.scene.meta +git add -- YouleNexus/assets/framework/ui/prefabs/Login_Layer.prefab YouleNexus/assets/framework/ui/prefabs/Login_Layer.prefab.meta +git add -- YouleNexus/assets/framework/ui/prefabs/widgets/Layer1_Logo.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer1_Logo.prefab.meta YouleNexus/assets/framework/ui/prefabs/widgets/Layer4_MainMenu.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer4_MainMenu.prefab.meta YouleNexus/assets/framework/ui/prefabs/widgets/Layer15_JoinRoom.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer15_JoinRoom.prefab.meta +git add -- YouleNexus/assets/framework/ui/prefabs/widgets/Layer50_MainScene.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer50_MainScene.prefab.meta YouleNexus/assets/framework/ui/prefabs/widgets/Layer403.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer403.prefab.meta YouleNexus/assets/framework/ui/prefabs/widgets/Layer411.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer411.prefab.meta +git add -- YouleNexus/assets/framework/ui/prefabs/widgets/Layer202_PlayerHeadScore.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer202_PlayerHeadScore.prefab.meta YouleNexus/assets/framework/ui/prefabs/widgets/Layer416_PlayerInfo.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer416_PlayerInfo.prefab.meta +git add -- YouleNexus/assets/framework/ui/prefabs/widgets/Layer614_Loading.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer614_Loading.prefab.meta YouleNexus/assets/framework/ui/prefabs/widgets/Layer615_Reconnect.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer615_Reconnect.prefab.meta YouleNexus/assets/framework/ui/prefabs/widgets/Layer616_Kick.prefab YouleNexus/assets/framework/ui/prefabs/widgets/Layer616_Kick.prefab.meta +git commit -m "feat(cocos): connect platform runtime to migrated layers" +``` + +Before committing, inspect `git diff --cached --name-only` and unstage any prefab unrelated to the listed first-batch Layers. + +--- + +### Task 12: 建立完整纵向回放测试并删除旧状态路径 + +**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/framework-tests/architecture/import-boundaries.test.mjs` +- Delete: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/app-store.ts` +- Delete: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/player-store.ts` +- Delete: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/room-store.ts` +- Delete: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/types.ts` +- Delete: `cocoscreator_projects/YouleNexus/assets/framework/platform/readonly.ts` +- Modify: `cocoscreator_projects/scripts/run-framework-tests.mjs` +- Delete or rewrite affected legacy tests under: `cocoscreator_projects/framework-tests/platform/` + +**Interfaces:** +- Consumes: completed runtime and fake transport/clock/scene/game adapters. +- Produces: one deterministic end-to-end regression test and final dependency guard. + +- [ ] **Step 1: Write the full replay test** + +Replay this exact sequence with fixtures: + +```text +runtime.start +→ config resolved +→ WS open / player_login sent +→ login success without room / lobby shown +→ joinRoom('123456') / self_join_room sent +→ self_join_room success / room shown / game session entered +→ other_join_room +→ player_prepare +→ socket close / reconnect overlay +→ reconnect open / player_login resent +→ login success with room + deskinfo / room state replaced / restore called once +``` + +At every arrow assert outbound envelope, PlatformState, ScenePort call, GameModule event, and notification count. + +- [ ] **Step 2: Add two-game isolation to the architecture guard** + +Register two descriptors, enter/leave both in sequence, and assert imports under `assets/games/` cannot reach framework internals. Use temporary in-memory source strings in the test; do not create fake production games. + +- [ ] **Step 3: Run the test and verify any remaining legacy path fails** + +```powershell +node --import tsx --test framework-tests/integration/platform-vertical-slice.test.ts framework-tests/architecture/import-boundaries.test.mjs +``` + +Expected before cleanup: FAIL if any runtime still constructs old AppStore/PlayerStore/RoomStore or RoomRPCBus. + +- [ ] **Step 4: Delete legacy Store facades and update remaining tests** + +All production state callers must use PlatformStore/selectors. Do not leave deprecated aliases, because they would recreate a second source of truth. + +Also add `--test-concurrency=1` to `run-framework-tests.mjs` so the complete suite is deterministic in the managed Windows environment: + +```js +const r = spawnSync( + process.execPath, + ['--import', 'tsx', '--test', '--test-concurrency=1', ...files], + { stdio: 'inherit' }, +); +``` + +- [ ] **Step 5: Run the complete framework suite** + +```powershell +npm run typecheck:framework +npm run test:framework +``` + +Expected: every framework test PASS with zero skipped tests and zero TypeScript errors. + +- [ ] **Step 6: Audit forbidden patterns** + +```powershell +rg -n "RoomRPCBus|new AppStore|new PlayerStore|new RoomStore|mock_openid|fallback to ws://|transport\?\.send|if \(!this\.identity\) return|hasBattle.*isbattle" YouleNexus/assets +``` + +Expected: no production matches. Mentions in migration documentation are allowed outside `YouleNexus/assets`. + +- [ ] **Step 7: Commit** + +```powershell +git add -A -- YouleNexus/assets/framework/platform/stores/app-store.ts YouleNexus/assets/framework/platform/stores/player-store.ts YouleNexus/assets/framework/platform/stores/room-store.ts YouleNexus/assets/framework/platform/stores/types.ts YouleNexus/assets/framework/platform/readonly.ts +git add -- scripts/run-framework-tests.mjs framework-tests/integration/platform-vertical-slice.test.ts framework-tests/integration/login-flow.test.ts framework-tests/architecture/import-boundaries.test.mjs framework-tests/platform +git commit -m "test(platform): cover complete vertical slice replay" +``` + +--- + +### Task 13: 真服与编辑器验收 + +**Files:** +- Modify only if evidence changes: `docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md` +- Create: `docs/superpowers/verification/2026-09-04-platform-vertical-slice.md` + +**Interfaces:** +- Consumes: a user-authorized test account, the original configuration service, Cocos preview/device environment. +- Produces: sanitized verification record with packet shapes, state transitions, screenshots/observations, and remaining out-of-scope items. + +- [ ] **Step 1: Run preflight verification** + +```powershell +npm run typecheck:framework +npm run test:framework +``` + +Expected: PASS before opening a real connection. + +- [ ] **Step 2: Select an explicit debug profile** + +Put the authorized account only in the existing debug profile mechanism or injected runtime data. Do not commit openid, phone, token, machine ID, real IP, or location. + +- [ ] **Step 3: Run the original and Cocos clients against the same test service** + +Capture and sanitize these envelopes: first `player_login`, successful `self_join_room`, `player_prepare`, reconnect `player_login`, and one room push. Compare route/rpc, field presence, primitive types, and nested structure. + +- [ ] **Step 4: Verify UI/state behavior through Cocos MCP preview** + +Confirm lobby entry, join-room input, room player rendering, ready state, reconnect overlay, room recovery, and kicked overlay. Capture screenshots through MCP if the tool supports it. + +- [ ] **Step 5: Record evidence and discrepancies** + +The verification document must list each acceptance item as PASS or FAIL with the observed packet/state evidence. A discrepancy is a failed acceptance item; do not add a fallback to make the run continue. + +- [ ] **Step 6: Re-run complete automated verification after any correction** + +```powershell +npm run typecheck:framework +npm run test:framework +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the verification record** + +```powershell +git add -- docs/superpowers/verification/2026-09-04-platform-vertical-slice.md +git commit -m "docs(verification): record platform vertical-slice results" +``` + +--- + +## Final Acceptance Checklist + +- [ ] `sdk/contracts` has no framework implementation or Cocos dependencies. +- [ ] Adding a test game changes only its own directory and application registration. +- [ ] Game messages are route-bound and cannot impersonate platform RPCs. +- [ ] Game sessions release subscriptions and reject stale calls after disposal. +- [ ] Remote WebSocket addresses come only from `data.urlserver` or an explicit debug direct profile. +- [ ] All first-batch server payloads pass strict boundary parsers. +- [ ] There is one Router and one PlatformStore. +- [ ] Login, room recovery, join, prepare, offline/online, reconnect and kick match source behavior. +- [ ] `deskinfo` uses the original client's truthiness trigger, is never stored by the platform, and is passed unchanged to `restore`. +- [ ] Descriptor-owned seat-count resolution and the Host's 2/4/10-seat round-trip mapping tests pass without importing framework internals into a game. +- [ ] Layer adapters contain no server fallback, mock identity, network code, or writable business state. +- [ ] All serialized Cocos changes were made and verified through funplay-cocos MCP. +- [ ] `npm run typecheck:framework` passes. +- [ ] `npm run test:framework` passes with zero skipped tests. +- [ ] Real test-server packet comparison passes without server or native-side changes. diff --git a/docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md b/docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md index 83d5959..57163a4 100644 --- a/docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md +++ b/docs/superpowers/specs/2026-09-04-platform-vertical-slice-contract-inventory.md @@ -129,7 +129,7 @@ videoConfig, shortcode, match, matchid, agreefree, isbet, deskinfo - `roomcode` 存在决定是否恢复房间; - `isbattle` 只写房间 stage; -- `deskinfo` 字段存在决定是否调用 `onReconnect(deskinfo)`; +- `deskinfo` 值为真决定是否调用 `onReconnect(deskinfo)`,严格复刻原工程 `if (deskinfo)`; - `deskinfo` 值不被平台复制、补字段或解析; - `roomtype` 保留完整嵌套数组结构。 @@ -171,6 +171,8 @@ videoConfig, shortcode, match, matchid, agreefree, isbet, deskinfo | GAME-13 | snapshot 是冻结 DTO,不暴露内部 Store 类型 | mutation failure/type test | | GAME-14 | dispose 后 Host 失效并释放全部订阅 | lifecycle/leak test | | GAME-15 | 不同游戏、不同房间会话之间无状态泄漏 | two-game isolation test | +| GAME-16 | `resolveSeatCount(roomtype)` 是座位总数的唯一游戏侧解释入口,且必须返回正整数 | descriptor validation test | +| GAME-17 | Host 座位映射绑定当前 self seat/seat count,子游戏不导入内部 seat 实现 | 2/4/10-seat round-trip test | 第一版 `GameHostCommand` 只包含: diff --git a/docs/superpowers/specs/2026-09-04-platform-vertical-slice-design.md b/docs/superpowers/specs/2026-09-04-platform-vertical-slice-design.md index 96f8800..8ad2529 100644 --- a/docs/superpowers/specs/2026-09-04-platform-vertical-slice-design.md +++ b/docs/superpowers/specs/2026-09-04-platform-vertical-slice-design.md @@ -1,6 +1,6 @@ # YouleNexus 平台纵向链路逻辑迁移设计 -> 状态:已确认平台纵向链路方向;已纳入子游戏解耦架构,待用户复审。 +> 状态:已确认平台纵向链路与子游戏解耦架构。 > > 适用工程:`cocoscreator_projects/YouleNexus`。 > @@ -30,7 +30,7 @@ 2. **唯一数据源**:配置、协议映射、玩家实体和房间座位各有唯一权威来源。 3. **下游不兜底**:边界数据缺失或非法时显式报错;Store、控制器和视图不得猜默认值。 4. **roomtype 不解析**:平台只保存和透传具体子游戏的嵌套数组。 -5. **deskinfo 不解析**:平台只按“响应中存在 deskinfo”判断并原样传给 `GameModule.restore(deskinfo)`;不得改用 `isbattle` 作为触发条件。 +5. **deskinfo 不解析**:平台严格复刻原工程 `if (deskinfo)`,仅当响应中的 `deskinfo` 值为真时原样传给 `GameModule.restore(deskinfo)`;不得改用 `isbattle` 或单纯字段存在作为触发条件。 6. **Cocos 资源只经编辑器 MCP 修改**:本设计涉及 prefab 挂载或场景操作时,执行阶段必须使用 funplay-cocos MCP。 7. **旧引擎机制不复刻**:渲染循环、命中检测、对象表和逐精灵定时器交给 Cocos;只迁移业务状态机和协议行为。 8. **依赖方向单向**:`game implementation → sdk contracts ← platform adapters`;SDK contracts 不反向依赖 platform/net/ui 实现。 @@ -87,7 +87,7 @@ - `roomHandlers` 直接修改 `signal.value` 内部对象,不能保证订阅者收到更新;部分 handler 是空实现。 - `PlayerStore` 和 `RoomStore.players` 同时保存自己的玩家信息,存在状态镜像。 - `PlayerStore`、`RoomStore` 和多个 handler 用 `??` 构造服务器未提供的数据。 -- `parseLoginResponse.hasBattle` 同时判断 `isbattle` 和 `deskinfo`,与源码“deskinfo 存在才调用 Reconnect”不一致。 +- `parseLoginResponse.hasBattle` 同时判断 `isbattle` 和 `deskinfo`,与源码“`if (deskinfo)` 为真才调用 Reconnect”不一致。 - `native-bridge.ts` 把入站注册名和出站调用名合并成同一份 14 项白名单;原工程两者并不相同。 - 现有 `sdk/index.ts` 直接导入 platform Store 类型和内部 EventBus,使 SDK 与平台实现耦合。 - 现有 `GameNet.send(route,rpc,data)` 允许子游戏任意指定 route,能够绕过平台协议边界。 @@ -267,6 +267,7 @@ interface GameDescriptor { readonly gameId: string | number; readonly route: string; readonly key: string; + resolveSeatCount(roomtype: readonly unknown[]): number; create(): GameModule; } ``` @@ -276,6 +277,7 @@ interface GameDescriptor { - `key`、`gameId`、`route` 均非空且分别唯一; - `apiVersion` 与平台支持版本完全相等; - `create()` 每次返回新的 GameModule 实例; +- `resolveSeatCount(roomtype)` 是平台获得该游戏座位总数的唯一入口,结果必须是正整数;具体 `roomtype` 索引含义只存在于游戏实现; - 重复注册或版本不匹配显式抛错,不选择“最接近版本”。 进入房间时依据权威 gameId/route 激活 descriptor。`GameSessionHost` 使用 `detached → attached → entered → disposed|failed` 状态机;非法次序显式抛错。离房或切换游戏时先 dispose 当前会话,下一次进房创建新实例,禁止把上一桌状态带入下一桌。每个 Host 带内部 generation,旧会话的异步回调在新会话建立后继续发包时必须失败。 @@ -320,7 +322,7 @@ interface GameModule { - `attach`、每个房间进入事件、`restore` 和 `dispose` 的调用次数有状态机校验; - 游戏 route 的服务端消息只形成 `GameServerMessage`,平台不解析 data; -- `restore` 的唯一触发条件是响应对象拥有 `deskinfo` 字段; +- `restore` 的唯一触发条件是响应中的 `deskinfo` 值为真,与原工程 `if (deskinfo)` 完全一致;缺失、`null`、`false`、`0` 或空字符串均不触发; - 对同一会话的状态提交、PlatformToGameEvent 和游戏消息严格串行派发;平台先提交 Store,再发对应事件,游戏读取 snapshot 时一定看到事件后的权威状态; - snapshot 表达“现在是什么”,event 表达“刚发生什么”,不得用 event 重建平台状态,也不得用 snapshot 猜测一次性动画事件; - 平台不得吞掉 GameModule 抛出的契约错误;运行时转入明确 failed 状态并停止本局继续派发。 @@ -332,6 +334,7 @@ interface GameModule { ```ts interface GameHost { readonly apiVersion: PlatformGameApiVersion; + readonly seat: GameSeatMapper; getSnapshot(): PlatformGameSnapshot; subscribe(listener: (snapshot: PlatformGameSnapshot) => void): Unsubscribe; sendGameMessage(rpc: string, data: unknown): void; @@ -340,6 +343,11 @@ interface GameHost { type Unsubscribe = () => void; +interface GameSeatMapper { + toView(serverSeat: number): number; + toServer(viewSeat: number): number; +} + type PublicConnectionPhase = | 'connected' | 'logged-in' @@ -377,7 +385,8 @@ type GameHostCommand = | { readonly type: 'room.exit' }; ``` -- `PlatformGameSnapshot` 是冻结的只读 DTO,只包含公开身份、房间、座位和连接状态,不暴露 Store 类型或 reactive 实现。 +- `PlatformGameSnapshot` 是冻结的只读 DTO,只包含公开身份、房间、座位和连接状态,不暴露 Store 类型或 reactive 实现;`roomtype` 在入 Store 边界仅做通用递归冻结、不解释任何位置,并以同一权威引用透传到 snapshot。 +- `GameSeatMapper` 在会话建立时绑定当前 `self.seat` 和 descriptor 解析出的 `seatCount`;公式分别为 `(serverSeat-selfSeat+seatCount)%seatCount` 与 `(viewSeat+selfSeat)%seatCount`,子游戏不导入平台 `core/seat`,也不重复保存自己的换算参数。 - `sendGameMessage` 自动绑定 descriptor 的 `route` 与固定 app,子游戏不能指定 platform/agent/room route。 - `GameHostCommand` 是受控判别联合。第一批只开放 `room.prepare` 和 `room.exit`;命令只负责发起协议动作,成功状态由服务器响应/推送驱动。增加命令必须同步契约、适配器和一致性测试。 - `room.exit` 在第一批仅允许未开局房间;已开局时显式抛出 unsupported command,不能误发 `self_exit_room` 代替尚未迁移的开局退出流程。 @@ -427,7 +436,7 @@ one game → another game 4. `state === 0` 时一次 action 提交 A 组状态。 5. 无 `roomcode`:清理房间域并显示大厅。 6. 有 `roomcode`:一次 action 提交 B 组状态,进入房间壳。 -7. 响应存在 `deskinfo`:进入房间后调用一次 `onReconnect(deskinfo)`;没有则不调用。 +7. 响应 `deskinfo` 值为真:进入房间后调用一次 `onReconnect(deskinfo)`;缺失或假值不调用。 ### 6.3 主动加入房间 @@ -435,7 +444,7 @@ one game → another game 2. 输入完成并确认后构造 `self_join_room` 请求,字段严格来自登录态和输入值。 3. 失败响应只更新命令结果和提示,不污染 RoomState。 4. 成功响应原子提交玩家目录和房间座位状态。 -5. `deskwar` 为真时调用 `onStartWar`;否则若存在 `deskinfo`,调用 `DeskInfo` 等价入口;普通进房不调用对局恢复。 +5. `deskwar` 为真时调用 `onStartWar`;否则若 `deskinfo` 值为真,调用 `DeskInfo` 等价入口;普通进房不调用对局恢复。 ### 6.4 准备和房内推送 @@ -450,7 +459,7 @@ one game → another game 2. 按旧策略延时并轮换候选服务器。 3. open 后用同一份登录身份重发 `player_login`。 4. 登录成功后用服务器完整快照覆盖房间态,不在旧 RoomState 上打补丁。 -5. 响应存在 `deskinfo` 时原样调用 `onReconnect`。 +5. 响应 `deskinfo` 值为真时原样调用 `onReconnect`。 6. `kick_server` 停止重连并显示 Layer 616。 ## 7. 错误处理 @@ -508,6 +517,7 @@ bootstrap → open → login(no room) → join → other join - snapshot 只读且不泄露 Store/Reactive/EventBus; - dispose 自动释放订阅并使旧 Host 失效; - 两个测试游戏顺序进入时,状态、订阅和消息不串局; +- 2、4、10 人座位的双向换算可逆,且服务端座位到自己的视图位置恒为 0; - `deskinfo` 以同一引用和值交给 `restore`,平台不解析。 ## 9. 交付与验收 @@ -520,7 +530,7 @@ bootstrap → open → login(no room) → join → other join 4. 玩家实体只有一个权威存储; 5. 第一批 RPC 均有请求/响应解析器和黄金样本; 6. 四门闩、登录、大厅、进房、准备和断线重连集成测试通过; -7. `deskinfo` 仅以“字段存在”为触发条件并原样传给游戏模块; +7. `deskinfo` 仅以原工程 `if (deskinfo)` 的真值语义触发,并原样传给游戏模块; 8. Layer 控制器只依赖 command 与只读 selector; 9. TypeScript 类型检查与全部 framework tests 通过; 10. 新子游戏只需提供 descriptor/module 并在应用组合根注册,不修改 framework 核心;