docs(plan): platform 响应式 Store 第一切片实施计划(6 TDD 任务)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,689 @@
|
|||||||
|
# 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 交给子游戏。
|
||||||
Reference in New Issue
Block a user