Files
youle_cocos/docs/superpowers/plans/2026-06-28-platform-store.md
T
2026-06-28 21:09:33 +08:00

690 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# platform 响应式 Store(第一切片)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:** 实现 `framework/platform` 第一切片——自研响应式 `signal` 基元 + 三个 Store(PlayerStore/AppStore/RoomStore,登录驱动子集)+ `PlatformSession`(订阅 `NetClient` 事件 → 填充 Store)+ 只读视图,纯逻辑、Node 全测,接上真机联调通过的真实 login 响应。
**Architecture:** 取向 A——每个 Store 内部一个 `Reactive<State>` 状态 signal,不可变 patch 更新,对外暴露 `state: ReadonlyReactive` + 领域更新方法。`PlatformSession` 只依赖 `NetClient` 的事件总线(不碰 net 内部),复用 `protocol/login` 的 `parseLoginResponse`。State 字段名逐字对齐 `docs/protocol/04`(直接由服务器 login 响应填充)。
**Tech Stack:** TypeScript(Cocos 3.8.8);测试 Node 20 + `tsx` 跑 `node:test`,沿用 `npm run test:framework` / `npm run typecheck:framework`(cwd `cocoscreator_projects/`)。
> 依据 spec `docs/superpowers/specs/2026-06-28-platform-store-design.md`。当前分支 `feat/platform-store`。
> 导入须带 `.ts` 后缀(tsconfig `allowImportingTsExtensions`)。`git commit` 的 CRLF 警告可忽略。npm 命令 cwd `G:/Works/YouleGamesCocosCreator/cocoscreator_projects`;git 命令 cwd `G:/Works/YouleGamesCocosCreator`。
---
## 文件结构(本计划建立)
```
cocoscreator_projects/
├─ framework-tests/platform/
│ ├─ reactive.test.ts
│ ├─ player-store.test.ts
│ ├─ app-store.test.ts
│ ├─ room-store.test.ts
│ └─ session.test.ts
└─ YouleNexus/assets/framework/
├─ core/reactive.ts # signal 基元
└─ platform/
├─ stores/types.ts # PlayerState/AppState/RoomState/ConnectionPhase
├─ readonly.ts # 只读视图接口
├─ stores/player-store.ts
├─ stores/app-store.ts
├─ stores/room-store.ts
└─ session.ts # PlatformSession
```
---
### Task 1: core/reactive.ts — 响应式 signal 基元
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/core/reactive.ts`
- Create: `cocoscreator_projects/framework-tests/platform/reactive.test.ts`
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/platform/reactive.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { signal } from '../../YouleNexus/assets/framework/core/reactive.ts';
test('signal: value 读写', () => {
const s = signal(1);
assert.equal(s.value, 1);
s.value = 2;
assert.equal(s.value, 2);
});
test('signal: subscribe 收到 (value, prev)', () => {
const s = signal(1);
let got: [number, number] | null = null;
s.subscribe((v, p) => { got = [v, p]; });
s.value = 5;
assert.deepEqual(got, [5, 1]);
});
test('signal: 退订后不再收到', () => {
const s = signal(0);
let count = 0;
const un = s.subscribe(() => { count++; });
s.value = 1;
un();
s.value = 2;
assert.equal(count, 1);
});
test('signal: Object.is 判等,值未变不通知', () => {
const s = signal(7);
let count = 0;
s.subscribe(() => { count++; });
s.value = 7;
assert.equal(count, 0);
});
test('signal: 一个订阅者抛错不影响其它', () => {
const s = signal(0);
let reached = false;
s.subscribe(() => { throw new Error('boom'); });
s.subscribe(() => { reached = true; });
s.value = 1;
assert.equal(reached, true);
});
```
- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 reactive.ts)
- [ ] **Step 3: 实现 reactive.ts**
创建 `core/reactive.ts`:
```ts
export type Unsubscribe = () => void;
export interface ReadonlyReactive<T> {
readonly value: T;
subscribe(fn: (value: T, prev: T) => void): Unsubscribe; // 返回退订函数
}
export interface Reactive<T> extends ReadonlyReactive<T> {
value: T; // 可写
}
/** 极小响应式信号:value 读写 + 订阅。Object.is 判等(值未变不通知);订阅者抛错隔离。 */
export function signal<T>(initial: T): Reactive<T> {
let current = initial;
const subs = new Set<(value: T, prev: T) => void>();
return {
get value(): T { return current; },
set value(v: T) {
if (Object.is(v, current)) return;
const prev = current;
current = v;
for (const fn of [...subs]) {
try { fn(current, prev); } catch (e) { console.error('[reactive] subscriber error:', e); }
}
},
subscribe(fn: (value: T, prev: T) => void): Unsubscribe {
subs.add(fn);
return () => { subs.delete(fn); };
},
};
}
```
- [ ] **Step 4: 跑测试 + 类型检查** — `npm run test:framework`(含 reactive 5 用例 PASS)+ `npm run typecheck:framework`(exit 0)
- [ ] **Step 5: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/core/reactive.ts cocoscreator_projects/framework-tests/platform/reactive.test.ts
git commit -m "feat(framework): core 响应式 signal 基元(自研薄封装)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: platform/stores/types.ts + platform/readonly.ts — State 类型与只读视图
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/types.ts`
- Create: `cocoscreator_projects/YouleNexus/assets/framework/platform/readonly.ts`
> 类型层无运行时行为,本任务以 `typecheck:framework` 通过为验收。
- [ ] **Step 1: 实现 stores/types.ts**
创建 `platform/stores/types.ts`(字段名对齐 protocol/04;`agentid/channelid` 归 AppState,见 spec §3 注):
```ts
import type { ChannelIdentity } from '../../config/identity.ts';
/** PlayerState(C_Player A 组,login 始终下发;openid 等仅 deviceLogin 下发,做可选)。 */
export interface PlayerState {
playerid: number;
nickname: string; avatar: string; sex: number;
openid?: string; unionid?: string | number; province?: string; city?: string;
bean: number; roomcard: number; score: number; charm: number;
taskstate: number; advanced: number;
bankpower: number; bank: number; bankpwd: number;
ip: string; sign: string | null; tel: string | null;
invitecode: string | null; initCard: number | string; initBean: number | string;
}
export type ConnectionPhase =
| 'idle' | 'connecting' | 'connected' | 'loggedIn'
| 'reconnecting' | 'slow' | 'kicked' | 'loginFailed';
/** AppState(GameData 子集:身份 + 服务器 + 代理/版本 + 连接相位)。 */
export interface AppState {
identity: ChannelIdentity | null; // 来自 bootstrap
servers: string[];
agentid: string | number; // login 回填(旧 GameData.AgentId,protocol/04:183)
channelid: string | number; // login 回填(旧 GameData.ChannelId,protocol/04:184)
agentname: string;
agentmode: number;
gameversion: number;
phase: ConnectionPhase;
}
/** RoomState(Desk B 组,仅在房/重连时下发)。 */
export interface RoomState {
inRoom: boolean;
roomcode: string | null;
isbattle: number; // 0未开局/1已开局
roommode: number; // 0普通/1星星场
seat: number; // 自己的座位(-1 未入座)
isowner: number; // 1房主/0非
asetcount: number; makewar: number; needprepare: number; infinite: number;
players: unknown[]; // 按座位下标(可含 null);完整建模留后续切片
deskinfo: unknown; // 子游戏对局快照,opaque 透传
}
```
- [ ] **Step 2: 实现 readonly.ts**
创建 `platform/readonly.ts`:
```ts
import type { ReadonlyReactive } from '../core/reactive.ts';
import type { PlayerState, AppState, RoomState } from './stores/types.ts';
export interface ReadonlyPlayerStore { readonly state: ReadonlyReactive<PlayerState>; }
export interface ReadonlyRoomStore { readonly state: ReadonlyReactive<RoomState>; }
export interface ReadonlyAppStore { readonly state: ReadonlyReactive<AppState>; }
```
- [ ] **Step 3: 类型检查** — `npm run typecheck:framework`(exit 0)
- [ ] **Step 4: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/platform/stores/types.ts cocoscreator_projects/YouleNexus/assets/framework/platform/readonly.ts
git commit -m "feat(framework): platform State 类型(登录驱动子集) + 只读视图接口
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: platform/stores/player-store.ts — PlayerStore
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/player-store.ts`
- Create: `cocoscreator_projects/framework-tests/platform/player-store.test.ts`
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/platform/player-store.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PlayerStore } from '../../YouleNexus/assets/framework/platform/stores/player-store.ts';
const RAW = { state: 0, playerid: 430511, nickname: '测试号', avatar: 'http://a', sex: 0,
bean: 0, roomcard: 3, score: 0, taskstate: 1, advanced: 0, bankpower: 1, bank: 0, bankpwd: 0,
ip: '127.0.0.1', sign: null, tel: null, invitecode: null, initCard: '3', initBean: '0',
openid: 'o1', unionid: 'u1', province: 'p', city: 'c' };
test('applyLogin 填充 PlayerState 并通知订阅者', () => {
const store = new PlayerStore();
let notified = 0;
store.state.subscribe(() => { notified++; });
store.applyLogin(RAW as any);
const v = store.state.value;
assert.equal(v.playerid, 430511);
assert.equal(v.nickname, '测试号');
assert.equal(v.roomcard, 3);
assert.equal(v.taskstate, 1);
assert.equal(v.openid, 'o1');
assert.equal(v.initCard, '3');
assert.equal(notified, 1);
});
test('charm 等缺省字段补默认 0', () => {
const store = new PlayerStore();
store.applyLogin(RAW as any);
assert.equal(store.state.value.charm, 0);
});
test('未下发的可选字段(openid 等)缺省时不写入', () => {
const store = new PlayerStore();
store.applyLogin({ state: 0, playerid: 1 } as any);
assert.equal(store.state.value.openid, undefined);
assert.equal(store.state.value.playerid, 1);
});
```
- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 player-store.ts)
- [ ] **Step 3: 实现 player-store.ts**
创建 `platform/stores/player-store.ts`:
```ts
import { signal, type ReadonlyReactive } from '../../core/reactive.ts';
import type { PlayerState } from './types.ts';
import type { ReadonlyPlayerStore } from '../readonly.ts';
import type { LoginResponseData } from '../../core/types/login.ts';
const INITIAL: PlayerState = {
playerid: 0, nickname: '', avatar: '', sex: 0,
bean: 0, roomcard: 0, score: 0, charm: 0,
taskstate: 0, advanced: 0,
bankpower: 0, bank: 0, bankpwd: 0,
ip: '', sign: null, tel: null,
invitecode: null, initCard: 0, initBean: 0,
};
/** 玩家自身状态(= 旧 C_Player)。 */
export class PlayerStore implements ReadonlyPlayerStore {
private s = signal<PlayerState>({ ...INITIAL });
get state(): ReadonlyReactive<PlayerState> { return this.s; }
/** 从 login 响应 A 组填充。字段名对齐 protocol/04(直接取服务器下发值)。 */
applyLogin(raw: LoginResponseData): void {
const r = raw as any;
this.s.value = {
...this.s.value,
playerid: r.playerid ?? 0,
nickname: r.nickname ?? '', avatar: r.avatar ?? '', sex: r.sex ?? 0,
bean: r.bean ?? 0, roomcard: r.roomcard ?? 0, score: r.score ?? 0, charm: r.charm ?? 0,
taskstate: r.taskstate ?? 0, advanced: r.advanced ?? 0,
bankpower: r.bankpower ?? 0, bank: r.bank ?? 0, bankpwd: r.bankpwd ?? 0,
ip: r.ip ?? '', sign: r.sign ?? null, tel: r.tel ?? null,
invitecode: r.invitecode ?? null, initCard: r.initCard ?? 0, initBean: r.initBean ?? 0,
...(r.openid != null ? { openid: String(r.openid) } : {}),
...(r.unionid != null ? { unionid: r.unionid } : {}),
...(r.province != null ? { province: String(r.province) } : {}),
...(r.city != null ? { city: String(r.city) } : {}),
};
}
}
```
- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 player-store 3 用例)+ `npm run typecheck:framework`
- [ ] **Step 5: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/platform/stores/player-store.ts cocoscreator_projects/framework-tests/platform/player-store.test.ts
git commit -m "feat(framework): platform PlayerStore(=C_Player) applyLogin
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: platform/stores/app-store.ts — AppStore
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/app-store.ts`
- Create: `cocoscreator_projects/framework-tests/platform/app-store.test.ts`
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/platform/app-store.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { AppStore } from '../../YouleNexus/assets/framework/platform/stores/app-store.ts';
test('初始 phase 为 idle', () => {
assert.equal(new AppStore().state.value.phase, 'idle');
});
test('setPhase/setServers/setIdentity 更新并通知', () => {
const store = new AppStore();
let n = 0;
store.state.subscribe(() => { n++; });
store.setPhase('connected');
store.setServers(['ws://a']);
store.setIdentity({ agentid: 'A', channelid: 'C', gameid: 'G', marketid: 4, version: 10000 });
const v = store.state.value;
assert.equal(v.phase, 'connected');
assert.deepEqual(v.servers, ['ws://a']);
assert.equal(v.identity?.agentid, 'A');
assert.equal(n, 3);
});
test('applyLogin 回填 agentid/channelid/agentname/agentmode/gameversion', () => {
const store = new AppStore();
store.applyLogin({ state: 0, playerid: 1, agentid: 'AG', channelid: 'CH', agentname: '进贤', agentmode: 2, gameversion: 41 } as any);
const v = store.state.value;
assert.equal(v.agentid, 'AG');
assert.equal(v.channelid, 'CH');
assert.equal(v.agentname, '进贤');
assert.equal(v.agentmode, 2);
assert.equal(v.gameversion, 41);
});
```
- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 app-store.ts)
- [ ] **Step 3: 实现 app-store.ts**
创建 `platform/stores/app-store.ts`:
```ts
import { signal, type ReadonlyReactive } from '../../core/reactive.ts';
import type { AppState, ConnectionPhase } from './types.ts';
import type { ReadonlyAppStore } from '../readonly.ts';
import type { ChannelIdentity } from '../../config/identity.ts';
import type { LoginResponseData } from '../../core/types/login.ts';
const INITIAL: AppState = {
identity: null, servers: [],
agentid: '', channelid: '',
agentname: '', agentmode: 0, gameversion: 0,
phase: 'idle',
};
/** 应用级状态(= 旧 GameData 子集:身份/服务器/连接相位)。 */
export class AppStore implements ReadonlyAppStore {
private s = signal<AppState>({ ...INITIAL });
get state(): ReadonlyReactive<AppState> { return this.s; }
setIdentity(identity: ChannelIdentity): void { this.s.value = { ...this.s.value, identity }; }
setServers(servers: string[]): void { this.s.value = { ...this.s.value, servers }; }
setPhase(phase: ConnectionPhase): void { this.s.value = { ...this.s.value, phase }; }
applyLogin(raw: LoginResponseData): void {
const r = raw as any;
this.s.value = {
...this.s.value,
agentid: r.agentid ?? this.s.value.agentid,
channelid: r.channelid ?? this.s.value.channelid,
agentname: r.agentname ?? '',
agentmode: r.agentmode ?? 0,
gameversion: r.gameversion ?? 0,
};
}
}
```
- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 app-store 3 用例)+ `npm run typecheck:framework`
- [ ] **Step 5: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/platform/stores/app-store.ts cocoscreator_projects/framework-tests/platform/app-store.test.ts
git commit -m "feat(framework): platform AppStore(=GameData) 身份/服务器/相位
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: platform/stores/room-store.ts — RoomStore
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/platform/stores/room-store.ts`
- Create: `cocoscreator_projects/framework-tests/platform/room-store.test.ts`
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/platform/room-store.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RoomStore } from '../../YouleNexus/assets/framework/platform/stores/room-store.ts';
test('初始 inRoom 为 false、seat 为 -1', () => {
const v = new RoomStore().state.value;
assert.equal(v.inRoom, false);
assert.equal(v.seat, -1);
});
test('applyRecovery 填充 B 组并置 inRoom=true', () => {
const store = new RoomStore();
store.applyRecovery({ state: 0, playerid: 1, roomcode: 'ROOM1', isbattle: 1, roommode: 1,
seat: 2, isowner: 1, players: [null, {}, {}], deskinfo: { round: 3 } } as any);
const v = store.state.value;
assert.equal(v.inRoom, true);
assert.equal(v.roomcode, 'ROOM1');
assert.equal(v.isbattle, 1);
assert.equal(v.seat, 2);
assert.equal(v.players.length, 3);
assert.deepEqual(v.deskinfo, { round: 3 });
});
test('clear 复位 inRoom 与字段', () => {
const store = new RoomStore();
store.applyRecovery({ state: 0, playerid: 1, roomcode: 'R', seat: 1 } as any);
store.clear();
const v = store.state.value;
assert.equal(v.inRoom, false);
assert.equal(v.roomcode, null);
assert.equal(v.seat, -1);
});
```
- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 room-store.ts)
- [ ] **Step 3: 实现 room-store.ts**
创建 `platform/stores/room-store.ts`:
```ts
import { signal, type ReadonlyReactive } from '../../core/reactive.ts';
import type { RoomState } from './types.ts';
import type { ReadonlyRoomStore } from '../readonly.ts';
import type { LoginResponseData } from '../../core/types/login.ts';
const INITIAL: RoomState = {
inRoom: false, roomcode: null, isbattle: 0, roommode: 0, seat: -1, isowner: 0,
asetcount: 0, makewar: 0, needprepare: 0, infinite: 0, players: [], deskinfo: null,
};
/** 房间/牌桌状态(= 旧 Desk)。仅 login 含 roomcode(在房/重连)时填充。 */
export class RoomStore implements ReadonlyRoomStore {
private s = signal<RoomState>({ ...INITIAL });
get state(): ReadonlyReactive<RoomState> { return this.s; }
/** login 含 roomcode 时恢复房间(B 组)。 */
applyRecovery(raw: LoginResponseData): void {
const r = raw as any;
this.s.value = {
...this.s.value,
inRoom: true,
roomcode: r.roomcode ?? null,
isbattle: r.isbattle ?? 0,
roommode: r.roommode ?? 0,
seat: r.seat ?? -1,
isowner: r.isowner ?? 0,
asetcount: r.asetcount ?? 0,
makewar: r.makewar ?? 0,
needprepare: r.needprepare ?? 0,
infinite: r.infinite ?? 0,
players: Array.isArray(r.players) ? r.players : [],
deskinfo: r.deskinfo ?? null,
};
}
/** 离开房间/未在房,复位。 */
clear(): void { this.s.value = { ...INITIAL }; }
}
```
- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 room-store 3 用例)+ `npm run typecheck:framework`
- [ ] **Step 5: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/platform/stores/room-store.ts cocoscreator_projects/framework-tests/platform/room-store.test.ts
git commit -m "feat(framework): platform RoomStore(=Desk) applyRecovery/clear
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: platform/session.ts — PlatformSession(登录接入,集成)
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/framework/platform/session.ts`
- Create: `cocoscreator_projects/framework-tests/platform/session.test.ts`
> 集成:注入 `EventBus<NetClientEvents>`,emit `login`(**用 Task 12 联调实测的真实 login 响应**作 fixture)→ 三 Store 正确填充。
- [ ] **Step 1: 写失败测试**
创建 `framework-tests/platform/session.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EventBus } from '../../YouleNexus/assets/framework/core/events.ts';
import type { NetClientEvents } from '../../YouleNexus/assets/framework/net/net-client.ts';
import { PlatformSession } from '../../YouleNexus/assets/framework/platform/session.ts';
// Task 12 真机联调实测的 login 响应(本地服)
const REAL_LOGIN = { 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 };
test('login(真实响应) → 三 Store 填充 + phase=loggedIn', () => {
const bus = new EventBus<NetClientEvents>();
const session = new PlatformSession(bus);
bus.emit('login', REAL_LOGIN);
assert.equal(session.player.state.value.playerid, 430511);
assert.equal(session.player.state.value.nickname, '测试号');
assert.equal(session.player.state.value.roomcard, 3);
assert.equal(session.app.state.value.agentname, '进贤');
assert.equal(session.app.state.value.gameversion, 41);
assert.equal(session.app.state.value.phase, 'loggedIn');
assert.equal(session.room.state.value.inRoom, false); // 无 roomcode
});
test('login 含 roomcode → room.inRoom=true', () => {
const bus = new EventBus<NetClientEvents>();
const session = new PlatformSession(bus);
bus.emit('login', { ...REAL_LOGIN, roomcode: 'ROOM1', isbattle: 1, seat: 2 });
assert.equal(session.room.state.value.inRoom, true);
assert.equal(session.room.state.value.roomcode, 'ROOM1');
});
test('login 失败(state!=0) → phase=loginFailed', () => {
const bus = new EventBus<NetClientEvents>();
const session = new PlatformSession(bus);
bus.emit('login', { state: 1, playerid: -1 });
assert.equal(session.app.state.value.phase, 'loginFailed');
});
test('open/reconnecting/slow/kicked 驱动 phase', () => {
const bus = new EventBus<NetClientEvents>();
const session = new PlatformSession(bus);
bus.emit('open');
assert.equal(session.app.state.value.phase, 'connected');
bus.emit('reconnecting', 'ws://x');
assert.equal(session.app.state.value.phase, 'reconnecting');
bus.emit('slow');
assert.equal(session.app.state.value.phase, 'slow');
bus.emit('kicked', { msg: 'x' });
assert.equal(session.app.state.value.phase, 'kicked');
});
```
- [ ] **Step 2: 跑测试确认失败** — `npm run test:framework` → FAIL(找不到 session.ts)
- [ ] **Step 3: 实现 session.ts**
创建 `platform/session.ts`:
```ts
import type { EventBus } from '../core/events.ts';
import type { NetClientEvents } from '../net/net-client.ts';
import type { LoginResponseData } from '../core/types/login.ts';
import { parseLoginResponse } from '../protocol/login.ts';
import { AppStore } from './stores/app-store.ts';
import { PlayerStore } from './stores/player-store.ts';
import { RoomStore } from './stores/room-store.ts';
/** 平台会话:订阅 NetClient 事件总线,把 login/连接事件落到三个响应式 Store。 */
export class PlatformSession {
readonly app = new AppStore();
readonly player = new PlayerStore();
readonly room = new RoomStore();
constructor(bus: EventBus<NetClientEvents>) {
bus.on('open', () => this.app.setPhase('connected'));
bus.on('login', (d) => this.onLogin(d));
bus.on('reconnecting', () => this.app.setPhase('reconnecting'));
bus.on('slow', () => this.app.setPhase('slow'));
bus.on('kicked', () => this.app.setPhase('kicked'));
}
private onLogin(data: unknown): void {
const parsed = parseLoginResponse(data as LoginResponseData); // 复用 protocol/login
if (!parsed.ok) { this.app.setPhase('loginFailed'); return; }
this.player.applyLogin(parsed.raw);
this.app.applyLogin(parsed.raw);
if (parsed.inRoom) this.room.applyRecovery(parsed.raw);
else this.room.clear();
this.app.setPhase('loggedIn');
}
}
```
- [ ] **Step 4: 跑测试确认通过** — `npm run test:framework`(含 session 4 用例,全部框架测试全绿)+ `npm run typecheck:framework`(exit 0)
- [ ] **Step 5: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/framework/platform/session.ts cocoscreator_projects/framework-tests/platform/session.test.ts
git commit -m "feat(framework): platform PlatformSession 登录接入(NetClient 事件→三 Store)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## 验收标准(完成定义)
1. `npm run test:framework` 全绿;`npm run typecheck:framework` exit 0。
2. `core/reactive.ts`:signal 基元(读写/订阅/退订/判等/故障隔离)。
3. 三个 Store(登录驱动子集):`PlayerStore.applyLogin`、`AppStore.setIdentity/setServers/setPhase/applyLogin`、`RoomStore.applyRecovery/clear`,均响应式 + 类型化。
4. `PlatformSession`:注入 `NetClient.bus`,真实 login 响应 → 三 Store 正确填充、相位流转(connected/loggedIn/reconnecting/slow/kicked/loginFailed),含 roomcode 进房恢复。
5. 只读视图接口齐备(三 Store `implements`),为后续 `GameContext` 只读暴露铺路。
## 后续衔接
- **启动接线切片**:把 `resolveBootstrap` 结果接到 `AppStore.setIdentity/setServers`,并 `new PlatformSession(net.bus)` + `net.start()`,跑通启动全流程。
- **房间事件切片**:`message` 事件路由分发 → 房间 handler(join/leave/ready/dissolve/offline…)更新 RoomStore;`players` 座位玩家完整建模。
- Plan 4(ui):Cocos 组件 `subscribe` 各 Store → UI 自动刷新。
- sdk Plan:`GameContext` 把只读 Store + 受限 facade 交给子游戏。