# platform 响应式 Store(第一切片)设计 > 状态:已与用户确认(brainstorming 通过),待转 writing-plans。 > 适用工程:`cocoscreator_projects/YouleNexus`。 > 关联:架构 spec `2026-06-28-cocos-framework-design.md` §3(分层)/§4(数据流+sdk 接口)/§6(技术栈)/§9.3(Store 选型);数据结构 `docs/protocol/04`;上游 `NetClient`(已完成,事件 `open/login/message/slow/reconnecting/kicked/serverSwitch/close`)。 ## 0. 目标与背景 Plan 3(platform 业务层)的**第一切片**:把旧 `04_Data`(GameData)/`06_Player`(C_Player)/`07_Desk`(Desk) 的状态,迁成**自研薄封装的响应式 Store**(状态变 → UI 自动刷新,告别手动刷 UI),并接上 `NetClient` 的 `login` 事件填充。后续切片(房间事件 handler、完整房间编排、UI 绑定)另立 spec。 **已确认决策**: - **Store 选型**:自研薄封装 signal/observable(零依赖、Node 可测、与 Cocos 解耦;spec §9.3「避免重依赖」)。 - **数据范围**:登录驱动子集(只建 login 响应实际填充的字段,其余按功能长,YAGNI)。 - **响应式粒度**:每个 Store 一个状态 signal(取向 A,最简够用;细粒度绑定留到 Plan 4 做 UI 时按需引入)。 **红线**:State 字段名直接由服务器 login 响应填充,故**逐字对齐 `docs/protocol/04`**(playerid/bean/roomcard/roomcode/isbattle…);内部基元/类/方法名走现代命名。见 [[naming-convention-modern-internal]] 等价原则。 ## 1. 模块结构 ``` framework/ ├─ core/ │ └─ reactive.ts # signal() → Reactive / ReadonlyReactive 基元(基座,类比 EventBus) └─ platform/ ├─ stores/ │ ├─ types.ts # PlayerState / AppState / RoomState(登录驱动子集) │ ├─ player-store.ts # PlayerStore(= C_Player) │ ├─ room-store.ts # RoomStore(= Desk) │ └─ app-store.ts # AppStore(= GameData:身份/服务器/连接相位) ├─ readonly.ts # ReadonlyPlayerStore / ReadonlyRoomStore / ReadonlyAppStore 只读视图接口 └─ session.ts # PlatformSession:订阅 NetClient 事件 → 填充三 Store ``` 依赖方向:`platform → core`(用 reactive + EventBus + protocol/login 的 parseLoginResponse)。不碰 net 内部,只消费其事件总线。 ## 2. 响应式基元(`core/reactive.ts`) ```ts export type Unsubscribe = () => void; export interface ReadonlyReactive { readonly value: T; subscribe(fn: (value: T, prev: T) => void): Unsubscribe; // 返回退订函数 } export interface Reactive extends ReadonlyReactive { value: T; // 可写 } /** 极小响应式信号:value 读写 + 订阅。Object.is 判等,值未变不通知;订阅者抛错隔离。 */ export function signal(initial: T): Reactive; ``` 实现:持有 `current` + 订阅者 `Set`;setter `if (!Object.is(v, current))` 才更新并通知;通知时 `try/catch` 每个订阅者(故障隔离,复用 EventBus 思路);subscribe 返回退订闭包。 ## 3. 三个 Store(取向 A:每 Store 一个状态 signal) 每个 Store:`private s = signal(初始)`;对外 `state: ReadonlyReactive`(= `this.s`)+ 类型化 getter + 领域更新方法(不可变 patch:`this.s.value = { ...this.s.value, ...partial }`)。更新方法仅供 session/后续 handler 调用。 ### State 类型(`stores/types.ts`,登录驱动子集,字段名对齐 protocol/04) ```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; } // 注:login 响应的 agentid/channelid 旧客户端写入 GameData(AppStore),非 C_Player(protocol/04:183-184),故归 AppState。 // AppState(GameData 子集:身份 + 服务器 + 代理/版本 + 连接相位) export type ConnectionPhase = | 'idle' | 'connecting' | 'connected' | 'loggedIn' | 'reconnecting' | 'slow' | 'kicked' | 'loginFailed'; export interface AppState { identity: ChannelIdentity | null; // 来自 bootstrap(agentid/gameid/channelid/marketid/version) servers: string[]; agentid: string | number; // login 响应回填(旧 GameData.AgentId,protocol/04:183) channelid: string | number; // login 响应回填(旧 GameData.ChannelId,protocol/04:184) agentname: string; // login 实测带 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 透传(见 protocol/05) } ``` > `players` 元素与子游戏对局态在后续切片细化;本切片只保留数组与 `deskinfo` 透传,供重连判断与下一步消费。 ### 更新方法(每 Store) - `PlayerStore.applyLogin(raw: LoginResponseData)`:从 login A 组 patch PlayerState。 - `AppStore.applyLogin(raw)`:patch `agentname/agentmode/gameversion/agentid/channelid`;`setIdentity(id)`/`setServers(s)`(启动时由 bootstrap 调);`setPhase(p)`。 - `RoomStore.applyRecovery(raw)`:login 含 roomcode 时 patch B 组、`inRoom=true`;`clear()`:复位 `inRoom=false`。 ## 4. 登录接入(`platform/session.ts`) ```ts export class PlatformSession { readonly app: AppStore; readonly player: PlayerStore; readonly room: RoomStore; constructor(bus: EventBus) { // 注入 NetClient.bus,便于测试 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); parsed.inRoom ? this.room.applyRecovery(parsed.raw) : this.room.clear(); this.app.setPhase('loggedIn'); } } ``` 只依赖 `NetClient` 的事件总线(`EventBus`),消费已联调通的真实 login 响应。`AppStore.setIdentity/setServers` 由启动编排在 `resolveBootstrap` 后调用(接线属后续启动切片,本切片只提供方法 + 测试)。 ## 5. 只读视图(`platform/readonly.ts`) ```ts export interface ReadonlyPlayerStore { readonly state: ReadonlyReactive; } export interface ReadonlyRoomStore { readonly state: ReadonlyReactive; } export interface ReadonlyAppStore { readonly state: ReadonlyReactive; } ``` 三个 Store `implements` 各自只读接口(`state` 用 `ReadonlyReactive` 类型暴露,更新方法不在接口里)。将来 `GameContext`(sdk Plan)只把只读视图交给子游戏,强制零耦合(spec §4「子游戏拿只读 Store + 受限 facade」)。 ## 6. 错误处理 - 基元订阅者抛错隔离(不影响其它订阅者,`console.error` 记录)。 - `onLogin` 中 `parseLoginResponse` 已处理 `state!=0`(`ok=false`)→ `loginFailed` 相位,不抛。 - Store patch 是纯内存操作,不涉 IO,无失败路径。 ## 7. 测试(tsx + node:test,`framework-tests/platform/`) - `reactive.test.ts`:value 读写;subscribe 收到 (value, prev);退订后不再收;`Object.is` 判等值未变不通知;一个订阅者抛错不影响其它。 - `player-store.test.ts` / `app-store.test.ts` / `room-store.test.ts`:`applyLogin/applyRecovery/clear/setPhase` 正确 patch;订阅者收到通知;getter 返回新值;`room.clear()` 复位 `inRoom`。 - `session.test.ts` 集成:注入 fake `EventBus`,emit `login`(**用 Task 12 联调实测的真实 login 响应 JSON 作 fixture**)→ 三 Store 正确填充、`phase==='loggedIn'`;`state!=0` → `loginFailed`;含 `roomcode` → `room.inRoom===true`;`emit kicked/slow/reconnecting` → 对应 phase。 验收:`npm run test:framework` 全绿、`npm run typecheck:framework` exit 0;`PlatformSession` 在 mock bus 下喂真实 login 响应能正确产出三个响应式 Store。 ## 8. 范围边界(YAGNI) - **做**:reactive 基元、三个 Store(登录驱动子集)、PlatformSession 登录/相位接入、只读视图、测试。 - **不做(后续切片/Plan)**:房间事件 handler(join/leave/ready/dissolve/offline…)、`players` 座位玩家完整建模、房间操作发包 API、启动编排把 bootstrap 接到 AppStore、UI 绑定(Plan 4)、GameContext/IGameModule(sdk Plan)、`message` 事件路由分发。