refactor(platform): complete decoupled vertical runtime

This commit is contained in:
2026-09-05 10:21:28 +08:00
parent d177c52c3f
commit 1e3a21b476
10 changed files with 611 additions and 330 deletions
@@ -1,6 +1,71 @@
# framework — 框架真源(唯一权威副本)
各子游戏工程的 `assets/framework` 是指向**本目录**的 junction(见 `scripts/setup-links.mjs`)。
六层结构(依赖严格单向,详见 spec §3):core / net / protocol / platform / ui / sdk。
协议相关实现以 `docs/protocol/` 为权威,远程配置与原生接口也必须复刻原工程。服务器、配置服务和原生侧均零改动。
协议相关实现一律以 `docs/protocol/` 为唯一信息源(spec §0.1),不在代码或注释中内联复制协议字段。
## 现代运行时的唯一组合路径
```text
game CompositionRoot -> bootstrapPlatform(single GameEntry)
game implementation -> framework/sdk only
WireClient -> PlatformRuntime -> Router exactly once
PlatformStore -> selectors -> GameHost/UI read-only consumers
```
这里的 `bootstrapPlatform(single GameEntry)` 是组合设计记号,当前没有同名函数。
已实现的构造入口是 `new PlatformRuntime(options)`:`gameEntry` 由构建期固定提供一个,
`resolveRuntimeConfig` 使用 `config/runtime-config.ts` 的解析器,
`createWireClient(config)` 用最终 `config.servers` 构造 `WireClient`,注入实际 Transport 工厂。
组合方还提供 `ScenePort`、资源加载、最短展示等待和登录设备快照能力,然后调用 `start()`;
界面取得账号后显式调用 `login(account)`,socket open 本身不会发送登录。
`integration/platform-vertical-slice.test.ts` 展示了这一组合的完整无界面回放。
Runtime 内部创建唯一 Router、RuntimeSession、PlatformStore、平台命令和 GameSessionHost。
游戏实现只通过 `framework/sdk/index.ts` 导入公开契约;该入口只重导出 `sdk/contracts`,
不再公开旧 Store、EventBus 或任意路由发包能力。SDK contracts 不导入内部框架模块或 `cc`;
SDK runtime 也不导入框架实现层,平台适配由 `platform/game-host-adapter.ts` 承担。
不提供 GameRegistry、运行时 API 协商、多个游戏的包、游戏直接访问 Store/EventBus 的路径。
每次房间会话创建新的 GameModule 和 Host lease,旧 lease 不能读取或操纵新房间。
平台先原子提交房间和玩家快照,再创建游戏、发布平台事件;truthy `deskinfo` 在房间场景就绪后
按原对象引用交给 `restore`,不进入 PlatformStore,falsey/缺失值不触发恢复。
连接事件也提交到同一个 Store;重连只更换 app 连接状态,保留 room/players 引用。
切服先发送原始 `connect_*server` 信封,进房成功再原子标记已登录;普通重连才重新发送已保存的登录信封。
远程地址唯一取自 `data.urlserver`。配置请求使用空 body 的 POST,沿用无条件 `?` 缓存参数拼接。
原生 settings 方法名称、调用次序和 WVJB 初始化/handler 名称由 `config/sources`、
`adapters/native` 实现,`framework-tests/config` 与 `framework-tests/native` 验证。
## 仍被 Cocos 脚本使用的兼容隔离区
以下是 `LEGACY_RUNTIME` 的完整文件清单,路径相对本目录;现代框架文件禁止直接或传递依赖它们:
- `net/net-client.ts`
- `platform/session.ts`
- `platform/startup.ts`
- `platform/room-rpc-bus.ts`
- `platform/readonly.ts`
- `platform/stores/app-store.ts`
- `platform/stores/player-store.ts`
- `platform/stores/room-store.ts`
- `platform/stores/types.ts`
- `protocol/room-handlers.ts`
当前 `YouleNexus/assets/scripts/LoginFlow.ts`、`RoomEventProbe.ts`、`RoomSceneStart.ts`
仍直接引用上述兼容文件。因此它们继续保留;阶段 3 必须由 Presenter 和真正的游戏 Composition Root
接管这些脚本的调用后,阶段 6 才能删除兼容区。当前无界面现代回放通过,不表示 Cocos UI 已接入现代运行时。
保留的兼容逻辑测试(相对 `framework-tests/`)是 `net/net-client.test.ts`、
`platform/app-store.test.ts`、`platform/player-store.test.ts`、`platform/room-store.test.ts`、
`platform/session.test.ts`、`platform/startup.test.ts`、`platform/room-rpc-bus.test.ts`、
`protocol/room-handlers.test.ts`。它们测试旧脚本仍需使用的行为,不属于现代运行时的依赖图。
旧 SDK 的完整标识符盘点仅允许 `architecture/import-boundaries.test.mjs` 中的负例,
以及 `protocol/room-handlers.ts` 内一处历史说明。名称子串 `requireActiveGame` 是现代处理器的局部状态检查方法,
不是已移除的旧类型;自动盘点按完整标识符匹配。
`scripts/check-import-boundaries.mjs` 不再给 SDK 入口任何内部依赖豁免,也不给旧活动游戏文件名称检查豁免。
`framework-tests/architecture/import-boundaries.test.mjs` 对全部现代框架 TypeScript 文件检查传递依赖,
并验证重导出、别名、动态导入、CommonJS 和 import type 的负例;无法静态判断的传递加载直接拒绝。
阶段 3 的 Presenter/Composition Root 接管、theme/Prefab binding、首个真实子游戏迁移及 ZIP 发布均属于后续独立计划。
本次逻辑验收不改动 Cocos 序列化资源或现有 UI 迁移产物。
@@ -321,6 +321,7 @@ export class PlatformRuntime {
switch (event.type) {
case 'open':
this.wireIsOpen = true;
this.store.setConnectionPhase('connected');
this.markReady('socket');
if (this.connectionIntent.type === 'switch') {
const loginEnvelope = this.loginEnvelope;
@@ -348,6 +349,7 @@ export class PlatformRuntime {
case 'slow':
case 'reconnecting':
this.wireIsOpen = false;
this.store.setConnectionPhase(event.type);
this.options.scene.showReconnect();
break;
case 'close':
@@ -429,6 +431,7 @@ export class PlatformRuntime {
this.connectionIntent = { type: 'none' };
this.wireIsOpen = false;
const errors: unknown[] = [];
this.captureCleanupError(errors, () => { this.store.setConnectionPhase('kicked'); });
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
@@ -14,6 +14,7 @@ import {
} from '../../protocol/contracts/index.ts';
import type {
InsideRoomState,
PlatformConnectionPhase,
PlatformPlayer,
PlatformState,
} from './platform-types.ts';
@@ -422,6 +423,15 @@ export class PlatformStore {
return (): void => { this.listeners.delete(listener); };
}
setConnectionPhase(phase: Exclude<PlatformConnectionPhase, 'idle' | 'connecting' | 'logged-in'>): void {
if (phase !== 'connected' && phase !== 'reconnecting' && phase !== 'slow' && phase !== 'kicked') {
throw new TypeError('app.phase: expected a wire connection phase');
}
const previous = this.getState();
if (previous.app.phase === phase) return;
this.commit(Object.freeze({ ...previous, app: Object.freeze({ phase }) }));
}
private commit(state: PlatformState): PlatformState {
if (Object.is(state, this.state)) return state;
const previous = this.state;
@@ -543,7 +553,10 @@ export class PlatformStore {
}
const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
return this.commit(Object.freeze({
app: previous.app,
// A switch authenticates with connect_*server, then self_join_room instead of player_login.
app: previous.app.phase === 'logged-in'
? previous.app
: Object.freeze({ phase: 'logged-in' }),
players: roomResult.players,
room: roomResult.room,
}));
@@ -1,101 +1 @@
export * from './contracts/index.ts';
import type { EventBus } from '../core/events.ts';
import type { ReadonlyReactive } from '../core/reactive.ts';
import type { ReadonlyPlayerStore } from '../platform/readonly.ts';
import type { ReadonlyRoomStore } from '../platform/readonly.ts';
import type { ReadonlyAppStore } from '../platform/readonly.ts';
import type { PlayerState, RoomState, AppState } from '../platform/stores/types.ts';
/**
* 受限的发包通道(架构零耦合规则 §3)。
*
* 子游戏只能通过这个接口发包,**不能**直接拿到 transport/ws/start 等内部能力。
* 实现由 Router 在创建 GameContext 时注入(生产环境转发到 NetClient)。
*/
export interface GameNet {
send(route: string, rpc: string, data: unknown): void;
}
/**
* 座位↔视图工具(C §3 旧 ChangeToStatus 替代)。
*/
export interface GameSeat {
/** 绝对座位 → 视图位(自己=0,其余环形顺延)。 */
toView(mySeat: number, targetSeat: number, seatCount: number): number;
/** 视图位 → 绝对座位。 */
fromView(mySeat: number, viewSeat: number, seatCount: number): number;
}
/**
* @deprecated migration-only. Use GameHost from sdk/contracts instead.
*
* GameContext:子游戏调用框架能力的唯一入口。
*
* 只暴露只读 Store + 受限 net.send + 座位工具 + 事件总线。
* 子游戏**不能**通过本接口反改 Store;Store 的修改由平台层负责(第二准则:单一来源)。
*/
export interface GameContext {
/** 受限发包。 */
readonly net: GameNet;
/** 只读 PlayerStore。 */
readonly player: ReadonlyPlayerStore & { readonly state: ReadonlyReactive<PlayerState> };
/** 只读 RoomStore(含 deskinfo 透传引用)。 */
readonly room: ReadonlyRoomStore & { readonly state: ReadonlyReactive<RoomState> };
/** 只读 AppStore(连接相位 + 身份)。 */
readonly app: ReadonlyAppStore & { readonly state: ReadonlyReactive<AppState> };
/** 座位↔视图工具。 */
readonly seat: GameSeat;
/** 事件总线(子游戏可发自定义事件供自己订阅,框架不消费)。 */
readonly events: EventBus<Record<string, unknown[]>>;
}
/**
* @deprecated migration-only. Use GameModule from sdk/contracts instead.
*
* IGameModule:子游戏实现,被框架调用(框架 spec §4)。
*
* 子游戏实现这个接口并注册自己;框架 Router 据 route 分发对局包。
* 子游戏通过 GameContext 调用框架能力(不可 import platform/net 内部)。
*/
export interface IGameModule {
/** 本游戏的 game route(与 protocol/routes.ts 的 Route 同名)。 */
readonly route: string;
/** 进入牌桌场景,框架传入 GameContext。 */
onEnter(ctx: GameContext): void;
/** 离开牌桌场景。 */
onExit(): void;
/**
* 接收对局包(route = 本游戏 game route 的包)。
* 框架 router 已按 route 过滤;这里只按 rpc 分发。
*/
onReceive(rpc: string, data: unknown): void;
/**
* 登录回包带 deskinfo 时触发(C 规范 §5 边界判据 2:deskinfo 平台层不解析)。
* 子游戏负责反序列化对局态。
*/
onReconnect(deskinfo: unknown): void;
/**
* 可选:返回对局快照。框架在断线重连时收集 → 走 server player_login 响应 deskinfo。
* 若不实现则重连后从服务器拉取(默认行为)。
*/
serialize?(): unknown;
// ---- 平台事件钩子(全部可选,默认空实现)----
/** 玩家加入房间(seat = 对方座位)。 */
onPlayerJoin?(seat: number): void;
/** 玩家离开房间。 */
onPlayerLeave?(seat: number): void;
/** 玩家准备。 */
onReady?(seat: number): void;
/** 房间解散。 */
onDissolve?(): void;
/** 玩家离线/上线(在线由 onPlayerJoin 重复触发,离线由本钩子)。 */
onOffline?(seat: number): void;
}