Files
youle_cocos/docs/superpowers/specs/2026-06-28-platform-store-design.md
T
joywayerandClaude Opus 4.8 241c9d447a docs(spec): platform 响应式 Store 第一切片设计
自研 signal 基元 + 三个 Store(Player/App/Room 登录驱动子集) + PlatformSession 登录接入 + 只读视图。取向A(每Store一状态signal)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:02:39 +08:00

170 lines
9.6 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(第一切片)设计
> 状态:已与用户确认(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<T>() → Reactive<T> / ReadonlyReactive<T> 基元(基座,类比 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<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>;
```
实现:持有 `current` + 订阅者 `Set`;setter `if (!Object.is(v, current))` 才更新并通知;通知时 `try/catch` 每个订阅者(故障隔离,复用 EventBus 思路);subscribe 返回退订闭包。
## 3. 三个 Store(取向 A:每 Store 一个状态 signal)
每个 Store:`private s = signal<XxxState>(初始)`;对外 `state: ReadonlyReactive<XxxState>`(= `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<NetClientEvents>) { // 注入 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<NetClientEvents>`),消费已联调通的真实 login 响应。`AppStore.setIdentity/setServers` 由启动编排在 `resolveBootstrap` 后调用(接线属后续启动切片,本切片只提供方法 + 测试)。
## 5. 只读视图(`platform/readonly.ts`)
```ts
export interface ReadonlyPlayerStore { readonly state: ReadonlyReactive<PlayerState>; }
export interface ReadonlyRoomStore { readonly state: ReadonlyReactive<RoomState>; }
export interface ReadonlyAppStore { readonly state: ReadonlyReactive<AppState>; }
```
三个 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<NetClientEvents>`,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` 事件路由分发。