feat(framework): AppStore + PlatformSession(登录接入)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<NetClientEvents>) {
|
||||
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<RoomStore['applyRecovery']>[0]);
|
||||
} else {
|
||||
this.room.clear();
|
||||
}
|
||||
this.app.setPhase('loggedIn');
|
||||
}
|
||||
}
|
||||
@@ -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<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 };
|
||||
}
|
||||
|
||||
/** 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 ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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<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 + deskinfo 透传', () => {
|
||||
const bus = new EventBus<NetClientEvents>();
|
||||
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<NetClientEvents>();
|
||||
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<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');
|
||||
});
|
||||
|
||||
test('session: 二次 login 应清空旧 room 状态再填充', () => {
|
||||
const bus = new EventBus<NetClientEvents>();
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user