diff --git a/docs/superpowers/specs/2026-06-28-platform-store-design.md b/docs/superpowers/specs/2026-06-28-platform-store-design.md new file mode 100644 index 0000000..a77d1f8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-platform-store-design.md @@ -0,0 +1,169 @@ +# 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` 事件路由分发。