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:
2026-08-31 19:46:14 +08:00
co-authored by Claude Opus 5
parent b54de5bbda
commit 77be6c0d19
4 changed files with 269 additions and 0 deletions
@@ -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 ?? '',
};
}
}