fix(platform): preserve recoverable outcomes and terminal sessions
This commit is contained in:
@@ -32,6 +32,15 @@ SDK runtime 也不导入框架实现层,平台适配由 `platform/game-host-ad
|
||||
连接事件也提交到同一个 Store;重连只更换 app 连接状态,保留 room/players 引用。
|
||||
切服先发送原始 `connect_*server` 信封,进房成功再原子标记已登录;普通重连才重新发送已保存的登录信封。
|
||||
|
||||
登录、进房的非零 `state` 通过必需的 `ScenePort.showServerDenial({ rpc, state, data })`
|
||||
报告;`data` 保留服务器原对象,消费方依据 `showerror`、`error`、`roomcode` 等原字段处理。
|
||||
这类结果不提交成功状态、不关闭连接,调用方可重试;成功回包缺少必需字段仍是显式错误。
|
||||
|
||||
`GameEntry.resolveSeatCount` 只接收真实房间的 `roomtype`,预检只检查该方法存在;
|
||||
GameHost 在进房边界验证返回值是正整数并与服务器座位数组长度相同。
|
||||
`assertGameContract(entry, samples)` 的每个样本由游戏提供 `roomtype`、`expectedSeatCount`
|
||||
和对应的 `makeHost` 工厂,不要求游戏支持框架虚构的房间配置。
|
||||
|
||||
远程地址唯一取自 `data.urlserver`。配置请求使用空 body 的 POST,沿用无条件 `?` 缓存参数拼接。
|
||||
原生 settings 方法名称、调用次序和 WVJB 初始化/handler 名称由 `config/sources`、
|
||||
`adapters/native` 实现,`framework-tests/config` 与 `framework-tests/native` 验证。
|
||||
|
||||
@@ -19,10 +19,18 @@ export interface GameHostLeasePort {
|
||||
|
||||
export type GameHostLeaseFactory = () => GameHostLeasePort;
|
||||
|
||||
/** A valid nonzero server outcome, delivered without changing canonical state. */
|
||||
export interface ServerDenial {
|
||||
readonly rpc: 'player_login' | 'self_join_room';
|
||||
readonly state: number;
|
||||
readonly data: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface RuntimeSessionOptions {
|
||||
readonly store: PlatformStore;
|
||||
readonly gameSession: GameSessionHost;
|
||||
readonly createGameHostLease: GameHostLeaseFactory;
|
||||
readonly onServerDenial: (outcome: ServerDenial) => void;
|
||||
}
|
||||
|
||||
export type RuntimeSessionState = 'operational' | 'faulted' | 'kicked';
|
||||
@@ -66,6 +74,10 @@ export class RuntimeSession {
|
||||
this.requireOperational();
|
||||
this.loginIsPending = false;
|
||||
const parsed = parseLoginResponse(data);
|
||||
if (parsed.state !== 0) {
|
||||
this.options.onServerDenial({ rpc: 'player_login', state: parsed.state, data: parsed.raw });
|
||||
return;
|
||||
}
|
||||
const committed = this.options.store.applyLoginSuccess(parsed);
|
||||
if (parsed.room === null) {
|
||||
this.releaseOrFault('login lobby replacement');
|
||||
@@ -77,6 +89,10 @@ export class RuntimeSession {
|
||||
handleSelfJoin(data: unknown): void {
|
||||
this.requireOperational();
|
||||
const parsed = parseSelfJoinRoomResponse(data);
|
||||
if (parsed.state !== 0) {
|
||||
this.options.onServerDenial({ rpc: 'self_join_room', state: parsed.state, data: parsed.raw });
|
||||
return;
|
||||
}
|
||||
const committed = this.options.store.replaceRoom(parsed);
|
||||
this.openCommittedRoom(committed, parsed.reconnect, 'self join room open');
|
||||
}
|
||||
@@ -150,6 +166,7 @@ export class RuntimeSession {
|
||||
|
||||
const priorCleanupErrors = this.releaseGame();
|
||||
if (priorCleanupErrors.length > 0) this.throwFault(operation, priorCleanupErrors);
|
||||
if (this.gameWasDisposed()) return;
|
||||
|
||||
let lease: GameHostLeasePort;
|
||||
try {
|
||||
@@ -164,10 +181,12 @@ export class RuntimeSession {
|
||||
|
||||
try {
|
||||
this.options.gameSession.open(lease.host);
|
||||
if (this.gameWasDisposed()) return;
|
||||
this.options.gameSession.publish({
|
||||
type: 'room.entered',
|
||||
roomtype: state.room.roomtype,
|
||||
});
|
||||
if (this.gameWasDisposed()) return;
|
||||
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
|
||||
} catch (error) {
|
||||
const openErrors = extractGameSessionOpenFaultErrors(error);
|
||||
@@ -184,12 +203,16 @@ export class RuntimeSession {
|
||||
if (errors.length > 0) this.throwFault(operation, errors);
|
||||
}
|
||||
|
||||
private gameWasDisposed(): boolean {
|
||||
return this.options.gameSession.state === 'disposed';
|
||||
}
|
||||
|
||||
private releaseGame(): unknown[] {
|
||||
const errors: unknown[] = [];
|
||||
const lease = this.activeLease;
|
||||
this.activeLease = null;
|
||||
|
||||
if (this.options.gameSession.state !== 'idle') {
|
||||
if (this.options.gameSession.state !== 'idle' && !this.gameWasDisposed()) {
|
||||
try {
|
||||
this.options.gameSession.close();
|
||||
} catch (error) {
|
||||
|
||||
@@ -57,6 +57,7 @@ class SceneOrderedGameSessionHost extends GameSessionHost {
|
||||
|
||||
restore(deskinfo: unknown): void {
|
||||
this.showRoomAfterOpen();
|
||||
if (this.state === 'disposed') return;
|
||||
super.restore(deskinfo);
|
||||
}
|
||||
|
||||
@@ -183,6 +184,7 @@ export class PlatformRuntime {
|
||||
store: this.store,
|
||||
gameSession,
|
||||
createGameHostLease: () => this.createGameHostLease(commands, send),
|
||||
onServerDenial: outcome => { this.options.scene.showServerDenial(outcome); },
|
||||
});
|
||||
const handlers = new PlatformHandlers({
|
||||
store: this.store,
|
||||
@@ -341,6 +343,7 @@ export class PlatformRuntime {
|
||||
const stateBeforeDispatch = this.store.getState();
|
||||
if (
|
||||
this.requireRouter().dispatch(event.message) === 'handled-platform'
|
||||
&& this.lifecycle === 'running'
|
||||
&& this.store.getState() !== stateBeforeDispatch
|
||||
) {
|
||||
this.updateSceneAfterMessage(event.message.route, event.message.rpc);
|
||||
@@ -357,10 +360,17 @@ export class PlatformRuntime {
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// A lifecycle callback may stop and then throw. Preserve that error without
|
||||
// replacing the requested terminal stop with a fatal lifecycle transition.
|
||||
if (this.isStopped()) throw error;
|
||||
this.showFatalOnce(error);
|
||||
}
|
||||
}
|
||||
|
||||
private isStopped(): boolean {
|
||||
return this.lifecycle === 'stopped';
|
||||
}
|
||||
|
||||
private sendLoginEnvelope(envelope: OutboundEnvelope): void {
|
||||
const wire = this.requireOpenWire();
|
||||
const session = this.requireSession();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ServerDenial } from './runtime-session.ts';
|
||||
|
||||
/** Semantic scene intents; the headless runtime never imports Cocos. */
|
||||
export interface ScenePort {
|
||||
showLoading(): void;
|
||||
@@ -6,5 +8,7 @@ export interface ScenePort {
|
||||
showRoom(): void;
|
||||
showReconnect(): void;
|
||||
showKicked(data: unknown): void;
|
||||
/** Recoverable server result; the consumer interprets the original failure payload. */
|
||||
showServerDenial(outcome: ServerDenial): void;
|
||||
showFatal(error: Error): void;
|
||||
}
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ export function parseLoginResponse(value: unknown): ParsedLoginResponse {
|
||||
: optionalField(raw, 'playerid', '$.data', requireInteger);
|
||||
const hasRoomFields = ['roomcode', 'seat', 'roomtype', 'players']
|
||||
.some((field) => hasOwn(raw, field));
|
||||
const room = hasRoomFields ? parseRoomEntryFields(raw) : null;
|
||||
const room = state === 0 && hasRoomFields ? parseRoomEntryFields(raw) : null;
|
||||
|
||||
return {
|
||||
state,
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface GameEntry {
|
||||
readonly key: string;
|
||||
readonly gameId: string | number;
|
||||
readonly route: string;
|
||||
/** Interprets this game's actual server roomtype; no synthetic/default input is required. */
|
||||
resolveSeatCount(roomtype: readonly unknown[]): number;
|
||||
createModule(): GameModule;
|
||||
}
|
||||
|
||||
+29
-24
@@ -85,10 +85,6 @@ export function assertGameEntry(entry: GameEntry): void {
|
||||
if (typeof entry.resolveSeatCount !== 'function') {
|
||||
throw new Error('GameEntry resolveSeatCount must be a function');
|
||||
}
|
||||
const seatCount = entry.resolveSeatCount([]);
|
||||
if (!Number.isInteger(seatCount) || seatCount <= 0) {
|
||||
throw new Error(`GameEntry seat count must be a positive integer: ${seatCount}`);
|
||||
}
|
||||
if (typeof entry.createModule !== 'function') {
|
||||
throw new Error('GameEntry createModule must be a function');
|
||||
}
|
||||
@@ -130,24 +126,28 @@ export class GameSessionHost {
|
||||
}
|
||||
|
||||
this.currentState = 'attaching';
|
||||
let module: GameModule | undefined;
|
||||
try {
|
||||
module = this.entry.createModule();
|
||||
assertGameModule(module);
|
||||
const module = this.entry.createModule();
|
||||
this.module = module;
|
||||
// A factory may stop the application before returning its new instance.
|
||||
if (this.isDisposed()) {
|
||||
this.releaseModule();
|
||||
return;
|
||||
}
|
||||
assertGameModule(module);
|
||||
module.attach(host);
|
||||
this.currentState = 'active';
|
||||
if (this.currentState === 'attaching') this.currentState = 'active';
|
||||
} catch (primaryError) {
|
||||
this.module = undefined;
|
||||
if (!this.isDisposed()) this.currentState = 'disposing';
|
||||
let cleanupFailed = false;
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
disposeModuleIfPresent(module);
|
||||
this.releaseModule();
|
||||
} catch (error) {
|
||||
cleanupFailed = true;
|
||||
cleanupError = error;
|
||||
} finally {
|
||||
this.currentState = 'idle';
|
||||
if (!this.isDisposed()) this.currentState = 'idle';
|
||||
}
|
||||
if (cleanupFailed) throw new GameSessionOpenFault(primaryError, cleanupError);
|
||||
throw primaryError;
|
||||
@@ -171,7 +171,7 @@ export class GameSessionHost {
|
||||
try {
|
||||
module.restore(deskinfo);
|
||||
} finally {
|
||||
this.currentState = 'active';
|
||||
if (this.currentState === 'restoring') this.currentState = 'active';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,27 +184,32 @@ export class GameSessionHost {
|
||||
throw new Error(`Cannot close game session while ${this.currentState}`);
|
||||
}
|
||||
|
||||
const module = this.module;
|
||||
if (!module) {
|
||||
if (!this.module) {
|
||||
throw new Error('Active game session has no module');
|
||||
}
|
||||
this.currentState = 'disposing';
|
||||
this.module = undefined;
|
||||
try {
|
||||
module.dispose();
|
||||
this.releaseModule();
|
||||
} finally {
|
||||
this.currentState = 'idle';
|
||||
if (!this.isDisposed()) this.currentState = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.currentState === 'disposed') return;
|
||||
try {
|
||||
if (this.currentState !== 'idle') this.close();
|
||||
} finally {
|
||||
this.module = undefined;
|
||||
this.currentState = 'disposed';
|
||||
}
|
||||
if (this.isDisposed()) return;
|
||||
// Terminal state is visible before user cleanup, including reentrant dispose.
|
||||
this.currentState = 'disposed';
|
||||
this.releaseModule();
|
||||
}
|
||||
|
||||
private isDisposed(): boolean {
|
||||
return this.currentState === 'disposed';
|
||||
}
|
||||
|
||||
private releaseModule(): void {
|
||||
const module = this.module;
|
||||
this.module = undefined;
|
||||
disposeModuleIfPresent(module);
|
||||
}
|
||||
|
||||
private requireActive(operation: string): GameModule {
|
||||
|
||||
+27
-8
@@ -14,14 +14,26 @@ export interface GameContractHost {
|
||||
|
||||
export type GameContractHostFactory = () => GameContractHost;
|
||||
|
||||
/** The game owns each room configuration, its expected count and matching Host fixture. */
|
||||
export interface GameContractSample {
|
||||
readonly roomtype: readonly unknown[];
|
||||
readonly expectedSeatCount: number;
|
||||
readonly makeHost: GameContractHostFactory;
|
||||
}
|
||||
|
||||
/** Runs the shared lifecycle checks required of the app's sole GameEntry. */
|
||||
export function assertGameContract(entry: GameEntry, makeHost: GameContractHostFactory): void {
|
||||
export function assertGameContract(entry: GameEntry, samples: readonly GameContractSample[]): void {
|
||||
assert.ok(samples.length > 0, 'conformance requires game-owned roomtype samples');
|
||||
assertGameEntry(entry);
|
||||
for (const route of ['platform', 'agent', 'room']) {
|
||||
assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/);
|
||||
}
|
||||
for (const seatCount of [2, 4, 10]) {
|
||||
assert.equal(entry.resolveSeatCount([seatCount]), seatCount, `roomtype must resolve ${seatCount} seats`);
|
||||
for (const sample of samples) {
|
||||
assert.ok(
|
||||
Number.isInteger(sample.expectedSeatCount) && sample.expectedSeatCount > 0,
|
||||
'sample expectedSeatCount must be a positive integer',
|
||||
);
|
||||
assert.equal(entry.resolveSeatCount(sample.roomtype), sample.expectedSeatCount);
|
||||
}
|
||||
|
||||
const firstFactoryModule = entry.createModule();
|
||||
@@ -35,21 +47,28 @@ export function assertGameContract(entry: GameEntry, makeHost: GameContractHostF
|
||||
}
|
||||
|
||||
const sessions = new GameSessionHost(entry);
|
||||
runCycle(sessions, makeHost(), entry, 1);
|
||||
runCycle(sessions, makeHost(), entry, 2);
|
||||
for (const sample of samples) {
|
||||
runCycle(sessions, sample, entry, 1);
|
||||
runCycle(sessions, sample, entry, 2);
|
||||
}
|
||||
assert.equal(sessions.state, 'idle');
|
||||
}
|
||||
|
||||
function runCycle(
|
||||
sessions: GameSessionHost,
|
||||
contractHost: GameContractHost,
|
||||
sample: GameContractSample,
|
||||
entry: GameEntry,
|
||||
cycle: number,
|
||||
): void {
|
||||
const roomtype = [4] as const;
|
||||
const contractHost = sample.makeHost();
|
||||
const snapshot = contractHost.host.getSnapshot();
|
||||
assert.deepEqual(snapshot.room.roomtype, sample.roomtype, 'Host fixture must match the game sample');
|
||||
// Public seats contain occupied seats only; the mapper owns the full room range.
|
||||
assert.doesNotThrow(() => contractHost.host.seat.toView(sample.expectedSeatCount - 1));
|
||||
assert.throws(() => contractHost.host.seat.toView(sample.expectedSeatCount));
|
||||
const deskinfo = { cycle };
|
||||
sessions.open(contractHost.host);
|
||||
sessions.publish({ type: 'room.entered', roomtype });
|
||||
sessions.publish({ type: 'room.entered', roomtype: sample.roomtype });
|
||||
sessions.dispatchGameMessage(entry.route, { rpc: 'conformance.message', data: { cycle } });
|
||||
assert.throws(
|
||||
() => sessions.dispatchGameMessage(`${entry.route}.other`, { rpc: 'conformance.message', data: { cycle } }),
|
||||
|
||||
Reference in New Issue
Block a user