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

9.6 KiB
Raw Blame History

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)

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)

// 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)

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)

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 事件路由分发。