From 77be6c0d197398d458078d9b003aadccbfb3b6dd Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 31 Aug 2026 19:46:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(framework):=20AppStore=20+=20PlatformSessi?= =?UTF-8?q?on(=E7=99=BB=E5=BD=95=E6=8E=A5=E5=85=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppStore(=旧 GameData 子集): setIdentity/setServers/setPhase/applyLogin - applyLogin 区分两类字段语义: * 身份字段(agentid/channelid/agentmode/gameversion)缺省保留旧值 * 显示字段(agentname)缺省重置为空字符串 - phase 仅由 session 驱动,applyLogin 不写 phase PlatformSession: 订阅 NetClient 事件总线 → 扇出到三 Store - 'open' → phase=connected - 'login'(state==0) → 三 Store 填充 + phase=loggedIn - 'login'(state!=0) → phase=loginFailed, 不污染 playerid/roomcode - 'reconnecting' → phase=reconnecting - 'slow' → phase=slow - 'kicked' → phase=kicked 110/110 tests pass, typecheck exit 0。 Co-Authored-By: Claude Opus 5 (1M context) --- .../assets/framework/platform/session.ts | 50 ++++++++++++ .../framework/platform/stores/app-store.ts | 67 ++++++++++++++++ .../platform/app-store.test.ts | 78 +++++++++++++++++++ .../framework-tests/platform/session.test.ts | 74 ++++++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/platform/session.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/platform/stores/app-store.ts create mode 100644 cocoscreator_projects/framework-tests/platform/app-store.test.ts create mode 100644 cocoscreator_projects/framework-tests/platform/session.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/session.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/session.ts new file mode 100644 index 0000000..a8f42bf --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/session.ts @@ -0,0 +1,50 @@ +import type { EventBus } from '../core/events.ts'; +import type { NetClientEvents } from '../net/net-client.ts'; +import type { LoginResponseData } from '../core/types/login.ts'; +import { AppStore } from './stores/app-store.ts'; +import { PlayerStore } from './stores/player-store.ts'; +import { RoomStore } from './stores/room-store.ts'; + +/** + * PlatformSession:平台会话编排(C 规范 §6.1 启动流程)。 + * + * 订阅 NetClient 事件总线,把 login/连接事件落到三个响应式 Store: + * - 'open' → phase = 'connected' + * - 'login'(data) → 解析 state:state==0 → 三 Store 填充 + phase=loggedIn; + * state!=0 → phase=loginFailed(不写入 playerid/roomcode) + * - 'reconnecting' → phase = 'reconnecting' + * - 'slow' → phase = 'slow' + * - 'kicked' → phase = 'kicked'(不再重连,由 NetClient 自身决定) + * + * 不持有业务态——只做事件→Store 的扇出。Store 是状态唯一来源。 + */ +export class PlatformSession { + readonly app = new AppStore(); + readonly player = new PlayerStore(); + readonly room = new RoomStore(); + + constructor(bus: EventBus) { + bus.on('open', () => this.app.setPhase('connected')); + bus.on('login', (data) => this.onLogin(data)); + 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 d = data as LoginResponseData & { state?: number }; + if (d.state !== 0) { + // 登录失败:仅记 phase,不污染 playerid/roomcode 等敏感字段 + this.app.setPhase('loginFailed'); + return; + } + this.player.applyLogin(d); + this.app.applyLogin(d); + if (d.roomcode) { + this.room.applyRecovery(d as unknown as Parameters[0]); + } else { + this.room.clear(); + } + this.app.setPhase('loggedIn'); + } +} \ No newline at end of file diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/app-store.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/app-store.ts new file mode 100644 index 0000000..038019d --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/app-store.ts @@ -0,0 +1,67 @@ +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'; + +/** AppState 初始值。 */ +const INITIAL: AppState = { + identity: null, + servers: [], + agentid: '', + channelid: '', + agentname: '', + agentmode: 0, + gameversion: 0, + phase: 'idle', +}; + +/** applyLogin 入参契约:A 组公共字段(agentid/channelid/agentname/agentmode/gameversion)。 */ +export interface AppLoginData { + agentid?: string | number; + channelid?: string | number; + agentname?: string; + agentmode?: number; + gameversion?: number; +} + +/** + * AppStore:应用级状态(= 旧 GameData 子集)。 + * - identity/servers 由 config/bootstrap 启动编排时写入 + * - phase 由 NetClient 事件总线经 PlatformSession 驱动 + * - agentid/channelid/agentname/agentmode/gameversion 由 login 回包回填 + */ +export class AppStore implements ReadonlyAppStore { + private s = signal({ ...INITIAL }); + get state(): ReadonlyReactive { 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 }; + } + + /** phase 由 PlatformSession 从 NetClient 事件总线驱动,session.ts 唯一调用方。 */ + setPhase(phase: ConnectionPhase): void { + this.s.value = { ...this.s.value, phase }; + } + + /** + * login 回填 A 组字段。语义分两类: + * - 身份字段(agentid/channelid/agentmode/gameversion):缺省时保留旧值(避免身份错乱) + * - 显示字段(agentname):缺省时重置为空(避免显示旧值) + */ + applyLogin(raw: LoginResponseData | AppLoginData): void { + const r = raw as AppLoginData; + this.s.value = { + ...this.s.value, + agentid: r.agentid ?? this.s.value.agentid, + channelid: r.channelid ?? this.s.value.channelid, + agentmode: r.agentmode ?? this.s.value.agentmode, + gameversion: r.gameversion ?? this.s.value.gameversion, + agentname: r.agentname ?? '', + }; + } +} \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/platform/app-store.test.ts b/cocoscreator_projects/framework-tests/platform/app-store.test.ts new file mode 100644 index 0000000..3655706 --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/app-store.test.ts @@ -0,0 +1,78 @@ +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, identity=null, servers=[]', () => { + const v = new AppStore().state.value; + assert.equal(v.phase, 'idle'); + assert.equal(v.identity, null); + assert.deepEqual(v.servers, []); +}); + +test('setPhase: 每次都触发订阅(setter 即响应入口)', () => { + const store = new AppStore(); + let n = 0; + store.state.subscribe(() => { n++; }); + store.setPhase('connecting'); + store.setPhase('connected'); + store.setPhase('loggedIn'); + assert.equal(n, 3); +}); + +test('setServers: 替换数组(非 mutate)', () => { + const store = new AppStore(); + store.setServers(['ws://a']); + store.setServers(['ws://a', 'ws://b']); + assert.deepEqual(store.state.value.servers, ['ws://a', 'ws://b']); +}); + +test('setIdentity: 写入后再次读回一致', () => { + const store = new AppStore(); + store.setIdentity({ + agentid: 'A', channelid: 'C', gameid: 'G', marketid: 4, version: 10000, + }); + assert.equal(store.state.value.identity?.agentid, 'A'); + assert.equal(store.state.value.identity?.version, 10000); +}); + +test('applyLogin: 回填 agentid/channelid/agentname/agentmode/gameversion', () => { + const store = new AppStore(); + store.applyLogin({ + 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); +}); + +test('applyLogin: 身份字段缺省保留旧值,显示字段重置', () => { + const store = new AppStore(); + store.applyLogin({ agentid: 'A', channelid: 'C', agentname: '旧', agentmode: 1, gameversion: 41 } as any); + store.applyLogin({ agentname: '新' } as any); // 仅下发 agentname + const v = store.state.value; + // 身份字段保留 + assert.equal(v.agentid, 'A'); + assert.equal(v.channelid, 'C'); + assert.equal(v.agentmode, 1); + assert.equal(v.gameversion, 41); + // 显示字段更新 + assert.equal(v.agentname, '新'); +}); + +test('applyLogin: agentname 缺省时重置为空字符串', () => { + const store = new AppStore(); + store.applyLogin({ agentname: '旧名' } as any); + store.applyLogin({ agentid: 'A' } as any); // 不下发 agentname + assert.equal(store.state.value.agentname, '', '显示字段缺省重置(避免显示旧值)'); +}); + +test('applyLogin 不写 phase(phase 由 NetClient 事件驱动,session.setPhase 调用)', () => { + const store = new AppStore(); + store.setPhase('connected'); + store.applyLogin({ agentid: 'A' } as any); + assert.equal(store.state.value.phase, 'connected', 'applyLogin 不应覆盖 phase'); +}); \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/platform/session.test.ts b/cocoscreator_projects/framework-tests/platform/session.test.ts new file mode 100644 index 0000000..9479d73 --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/session.test.ts @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventBus } from '../../YouleNexus/assets/framework/core/events.ts'; +import { PlatformSession } from '../../YouleNexus/assets/framework/platform/session.ts'; +import type { NetClientEvents } from '../../YouleNexus/assets/framework/net/net-client.ts'; + +// 真实联调实测的 login 响应(取自 plan Task 12 fixture) +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, +} as any; + +test('login(真实响应): 三 Store 填充 + phase=loggedIn', () => { + const bus = new EventBus(); + 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 + deskinfo 透传', () => { + const bus = new EventBus(); + const session = new PlatformSession(bus); + const deskinfo = { round: 3 }; + bus.emit('login', { ...REAL_LOGIN, roomcode: 'ROOM1', isbattle: 1, seat: 2, deskinfo }); + + assert.equal(session.room.state.value.inRoom, true); + assert.equal(session.room.state.value.roomcode, 'ROOM1'); + assert.equal(session.room.state.value.isbattle, 1); + assert.equal(session.room.state.value.seat, 2); + assert.equal(session.room.state.value.deskinfo, deskinfo, 'deskinfo 引用相等(不深拷贝)'); +}); + +test('login 失败(state!=0): phase=loginFailed, 三 Store 不填充敏感字段', () => { + const bus = new EventBus(); + const session = new PlatformSession(bus); + bus.emit('login', { state: 1, playerid: -1 }); + + assert.equal(session.app.state.value.phase, 'loginFailed'); + assert.equal(session.player.state.value.playerid, 0, '失败时 playerid 走初始,不写入 -1'); + assert.equal(session.room.state.value.inRoom, false); +}); + +test('open/reconnecting/slow/kicked 驱动 phase 流转', () => { + const bus = new EventBus(); + 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'); +}); + +test('session: 二次 login 应清空旧 room 状态再填充', () => { + const bus = new EventBus(); + const session = new PlatformSession(bus); + bus.emit('login', { ...REAL_LOGIN, roomcode: 'ROOM1', seat: 1 }); + assert.equal(session.room.state.value.inRoom, true); + bus.emit('login', REAL_LOGIN); // 第二次无 roomcode + assert.equal(session.room.state.value.inRoom, false, '无 roomcode → room.clear()'); + assert.equal(session.room.state.value.roomcode, null); +}); \ No newline at end of file