fix(platform): serialize runtime session transitions
This commit is contained in:
@@ -3,21 +3,43 @@ import { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
|
|||||||
import {
|
import {
|
||||||
parseLoginResponse,
|
parseLoginResponse,
|
||||||
parseSelfJoinRoomResponse,
|
parseSelfJoinRoomResponse,
|
||||||
type ParsedSeatPayload,
|
type ParsedSelfExitPayload,
|
||||||
type ReconnectSnapshot,
|
type ReconnectSnapshot,
|
||||||
} from '../protocol/contracts/index.ts';
|
} from '../protocol/contracts/index.ts';
|
||||||
import { PlatformStore } from './stores/platform-store.ts';
|
import { PlatformStore } from './stores/platform-store.ts';
|
||||||
|
import type { PlatformState } from './stores/platform-types.ts';
|
||||||
|
|
||||||
|
export interface GameHostLeasePort {
|
||||||
|
readonly host: GameHost;
|
||||||
|
invalidate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameHostLeaseFactory = () => GameHostLeasePort;
|
||||||
|
|
||||||
export interface RuntimeSessionOptions {
|
export interface RuntimeSessionOptions {
|
||||||
readonly store: PlatformStore;
|
readonly store: PlatformStore;
|
||||||
readonly gameSession: GameSessionHost;
|
readonly gameSession: GameSessionHost;
|
||||||
readonly gameHost: GameHost;
|
readonly createGameHostLease: GameHostLeaseFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeSessionState = 'operational' | 'faulted' | 'kicked';
|
||||||
|
|
||||||
|
export class RuntimeSessionFault extends Error {
|
||||||
|
readonly errors: readonly unknown[];
|
||||||
|
|
||||||
|
constructor(operation: string, errors: readonly unknown[]) {
|
||||||
|
super(`RuntimeSession fault during ${operation}`);
|
||||||
|
this.name = 'RuntimeSessionFault';
|
||||||
|
this.errors = Object.freeze([...errors]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Coordinates the canonical platform snapshot with the one active game session. */
|
/** Coordinates the canonical platform snapshot with the one active game session. */
|
||||||
export class RuntimeSession {
|
export class RuntimeSession {
|
||||||
private loginIsPending = false;
|
private loginIsPending = false;
|
||||||
private wasKicked = false;
|
private wasKicked = false;
|
||||||
|
private terminalFault: RuntimeSessionFault | null = null;
|
||||||
|
private activeLease: GameHostLeasePort | null = null;
|
||||||
|
|
||||||
constructor(private readonly options: RuntimeSessionOptions) {}
|
constructor(private readonly options: RuntimeSessionOptions) {}
|
||||||
|
|
||||||
@@ -25,83 +47,166 @@ export class RuntimeSession {
|
|||||||
|
|
||||||
get kicked(): boolean { return this.wasKicked; }
|
get kicked(): boolean { return this.wasKicked; }
|
||||||
|
|
||||||
|
get state(): RuntimeSessionState {
|
||||||
|
if (this.wasKicked) return 'kicked';
|
||||||
|
return this.terminalFault !== null ? 'faulted' : 'operational';
|
||||||
|
}
|
||||||
|
|
||||||
|
get fault(): Error | null { return this.terminalFault; }
|
||||||
|
|
||||||
markLoginPending(): void {
|
markLoginPending(): void {
|
||||||
this.requireNotKicked('mark login pending');
|
this.requireOperational();
|
||||||
this.loginIsPending = true;
|
this.loginIsPending = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLogin(data: unknown): void {
|
handleLogin(data: unknown): void {
|
||||||
this.requireNotKicked('handle login');
|
this.requireOperational();
|
||||||
this.loginIsPending = false;
|
this.loginIsPending = false;
|
||||||
const parsed = parseLoginResponse(data);
|
const parsed = parseLoginResponse(data);
|
||||||
this.options.store.applyLoginSuccess(parsed);
|
const committed = this.options.store.applyLoginSuccess(parsed);
|
||||||
if (parsed.room === null) {
|
if (parsed.room === null) {
|
||||||
this.closeActiveGame();
|
this.releaseOrFault('login lobby replacement');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.openCommittedRoom(parsed.reconnect);
|
this.openCommittedRoom(committed, parsed.reconnect, 'login room open');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSelfJoin(data: unknown): void {
|
handleSelfJoin(data: unknown): void {
|
||||||
this.requireNotKicked('handle self join');
|
this.requireOperational();
|
||||||
const parsed = parseSelfJoinRoomResponse(data);
|
const parsed = parseSelfJoinRoomResponse(data);
|
||||||
this.options.store.replaceRoom(parsed);
|
const committed = this.options.store.replaceRoom(parsed);
|
||||||
this.openCommittedRoom(parsed.reconnect);
|
this.openCommittedRoom(committed, parsed.reconnect, 'self join room open');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSelfExit(parsed: ParsedSeatPayload): void {
|
handleSelfExit(parsed: ParsedSelfExitPayload): void {
|
||||||
this.requireNotKicked('handle self exit');
|
this.requireOperational();
|
||||||
const state = this.options.store.getState();
|
const state = this.options.store.getState();
|
||||||
if (state.room.kind !== 'inside') {
|
if (state.room.kind !== 'inside') {
|
||||||
throw new Error('self_exit_room requires an active room');
|
if (this.options.gameSession.state === 'idle' && this.activeLease === null) return;
|
||||||
|
const errors = [
|
||||||
|
new Error('self_exit_room found an active game without room state'),
|
||||||
|
...this.releaseGame(),
|
||||||
|
];
|
||||||
|
this.throwFault('self exit inconsistent state', errors);
|
||||||
}
|
}
|
||||||
if (parsed.seat !== state.room.selfSeat) {
|
if (parsed.seat !== undefined && parsed.seat !== state.room.selfSeat) {
|
||||||
throw new RangeError(
|
throw new RangeError(
|
||||||
`self_exit_room seat mismatch: expected ${state.room.selfSeat}, received ${parsed.seat}`,
|
`self_exit_room seat mismatch: expected ${state.room.selfSeat}, received ${parsed.seat}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (this.options.gameSession.state !== 'active') {
|
if (this.options.gameSession.state !== 'active' || this.activeLease === null) {
|
||||||
throw new Error('self_exit_room requires an active game session');
|
const errors = [
|
||||||
|
new Error('self_exit_room requires an active game session and host lease'),
|
||||||
|
...this.releaseGame(),
|
||||||
|
];
|
||||||
|
this.throwFault('self exit inconsistent state', errors);
|
||||||
}
|
}
|
||||||
this.options.store.clearRoom();
|
|
||||||
this.options.gameSession.close();
|
try {
|
||||||
|
this.options.store.clearRoom();
|
||||||
|
} catch (error) {
|
||||||
|
this.throwFault('self exit state commit', [error, ...this.releaseGame()]);
|
||||||
|
}
|
||||||
|
this.releaseOrFault('self exit cleanup');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleServerSwitch(): void {
|
handleServerSwitch(switchConnection: () => void): void {
|
||||||
this.requireNotKicked('handle server switch');
|
this.requireOperational();
|
||||||
this.closeActiveGame();
|
const errors = this.releaseGame();
|
||||||
|
try {
|
||||||
|
switchConnection();
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error);
|
||||||
|
}
|
||||||
|
if (errors.length > 0) this.throwFault('server switch', errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKick(_data: unknown): void {
|
handleKick(_data: unknown, stopConnection: () => void): void {
|
||||||
this.closeActiveGame();
|
if (this.wasKicked) return;
|
||||||
|
this.requireOperational();
|
||||||
this.loginIsPending = false;
|
this.loginIsPending = false;
|
||||||
this.wasKicked = true;
|
this.wasKicked = true;
|
||||||
|
|
||||||
|
const errors = this.releaseGame();
|
||||||
|
try {
|
||||||
|
stopConnection();
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error);
|
||||||
|
}
|
||||||
|
if (errors.length > 0) this.throwFault('kick terminal cleanup', errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
private openCommittedRoom(reconnect: ReconnectSnapshot): void {
|
private openCommittedRoom(
|
||||||
const state = this.options.store.getState();
|
state: PlatformState,
|
||||||
|
reconnect: ReconnectSnapshot,
|
||||||
|
operation: string,
|
||||||
|
): void {
|
||||||
if (state.room.kind !== 'inside') {
|
if (state.room.kind !== 'inside') {
|
||||||
throw new Error('Cannot open a game session without committed room state');
|
this.throwFault(operation, [new Error('Cannot open a game session without committed room state')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorCleanupErrors = this.releaseGame();
|
||||||
|
if (priorCleanupErrors.length > 0) this.throwFault(operation, priorCleanupErrors);
|
||||||
|
|
||||||
|
let lease: GameHostLeasePort;
|
||||||
|
try {
|
||||||
|
lease = this.options.createGameHostLease();
|
||||||
|
if (!lease || typeof lease !== 'object' || typeof lease.invalidate !== 'function') {
|
||||||
|
throw new TypeError('createGameHostLease must return { host, invalidate }');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.throwFault(operation, [error]);
|
||||||
|
}
|
||||||
|
this.activeLease = lease;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.options.gameSession.open(lease.host);
|
||||||
|
this.options.gameSession.publish({
|
||||||
|
type: 'room.entered',
|
||||||
|
roomtype: state.room.roomtype,
|
||||||
|
});
|
||||||
|
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
|
||||||
|
} catch (error) {
|
||||||
|
this.throwFault(operation, [error, ...this.releaseGame()]);
|
||||||
}
|
}
|
||||||
this.closeActiveGame();
|
|
||||||
this.options.gameSession.open(this.options.gameHost);
|
|
||||||
this.options.gameSession.publish({
|
|
||||||
type: 'room.entered',
|
|
||||||
roomtype: state.room.roomtype,
|
|
||||||
});
|
|
||||||
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private closeActiveGame(): void {
|
private releaseOrFault(operation: string): void {
|
||||||
const state = this.options.gameSession.state;
|
const errors = this.releaseGame();
|
||||||
if (state === 'idle') return;
|
if (errors.length > 0) this.throwFault(operation, errors);
|
||||||
if (state !== 'active') {
|
|
||||||
throw new Error(`Cannot close game session while ${state}`);
|
|
||||||
}
|
|
||||||
this.options.gameSession.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireNotKicked(operation: string): void {
|
private releaseGame(): unknown[] {
|
||||||
if (this.wasKicked) throw new Error(`Cannot ${operation} after kick_server`);
|
const errors: unknown[] = [];
|
||||||
|
const lease = this.activeLease;
|
||||||
|
this.activeLease = null;
|
||||||
|
|
||||||
|
if (this.options.gameSession.state !== 'idle') {
|
||||||
|
try {
|
||||||
|
this.options.gameSession.close();
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lease !== null) {
|
||||||
|
try {
|
||||||
|
lease.invalidate();
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwFault(operation: string, errors: readonly unknown[]): never {
|
||||||
|
const fault = new RuntimeSessionFault(operation, errors);
|
||||||
|
this.loginIsPending = false;
|
||||||
|
this.terminalFault = fault;
|
||||||
|
throw fault;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireOperational(): void {
|
||||||
|
if (this.terminalFault !== null) throw this.terminalFault;
|
||||||
|
if (this.wasKicked) throw new Error('RuntimeSession is kicked');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -422,8 +422,8 @@ export class PlatformStore {
|
|||||||
return (): void => { this.listeners.delete(listener); };
|
return (): void => { this.listeners.delete(listener); };
|
||||||
}
|
}
|
||||||
|
|
||||||
private commit(state: PlatformState): void {
|
private commit(state: PlatformState): PlatformState {
|
||||||
if (Object.is(state, this.state)) return;
|
if (Object.is(state, this.state)) return state;
|
||||||
const previous = this.state;
|
const previous = this.state;
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.transitions.push({
|
this.transitions.push({
|
||||||
@@ -431,7 +431,7 @@ export class PlatformStore {
|
|||||||
previous,
|
previous,
|
||||||
listeners: [...this.listeners],
|
listeners: [...this.listeners],
|
||||||
});
|
});
|
||||||
if (this.publishing) return;
|
if (this.publishing) return state;
|
||||||
|
|
||||||
this.publishing = true;
|
this.publishing = true;
|
||||||
try {
|
try {
|
||||||
@@ -449,9 +449,10 @@ export class PlatformStore {
|
|||||||
} finally {
|
} finally {
|
||||||
this.publishing = false;
|
this.publishing = false;
|
||||||
}
|
}
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyLoginSuccess(parsed: ParsedLoginResponse): void {
|
applyLoginSuccess(parsed: ParsedLoginResponse): PlatformState {
|
||||||
const input = requireRecord(parsed, '$.login');
|
const input = requireRecord(parsed, '$.login');
|
||||||
const raw = requireRecord(input.raw, '$.login.raw');
|
const raw = requireRecord(input.raw, '$.login.raw');
|
||||||
const state = requireMatchingState(input.state, raw, '$.login');
|
const state = requireMatchingState(input.state, raw, '$.login');
|
||||||
@@ -491,14 +492,14 @@ export class PlatformStore {
|
|||||||
player,
|
player,
|
||||||
requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'),
|
requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'),
|
||||||
);
|
);
|
||||||
this.commit(Object.freeze({
|
return this.commit(Object.freeze({
|
||||||
app: Object.freeze({ phase: 'logged-in' }),
|
app: Object.freeze({ phase: 'logged-in' }),
|
||||||
players: roomResult.players,
|
players: roomResult.players,
|
||||||
room: roomResult.room,
|
room: roomResult.room,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceRoom(parsed: ParsedRoomResponse): void {
|
replaceRoom(parsed: ParsedRoomResponse): PlatformState {
|
||||||
const input = requireRecord(parsed, '$.response');
|
const input = requireRecord(parsed, '$.response');
|
||||||
const raw = requireRecord(input.raw, '$.response.raw');
|
const raw = requireRecord(input.raw, '$.response.raw');
|
||||||
const state = requireMatchingState(input.state, raw, '$.response');
|
const state = requireMatchingState(input.state, raw, '$.response');
|
||||||
@@ -536,11 +537,12 @@ export class PlatformStore {
|
|||||||
'$.response.reconnect.value: expected canonical raw deskinfo reference',
|
'$.response.reconnect.value: expected canonical raw deskinfo reference',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const stage = reconnectPresent
|
const stage = requireProtocolFlag(input.stage, '$.response.stage');
|
||||||
? 1
|
if (reconnectPresent && stage !== 1) {
|
||||||
: requireProtocolFlag(raw.deskwar, '$.response.raw.deskwar');
|
throw new RangeError('$.response.stage: reconnect room must be in battle stage');
|
||||||
|
}
|
||||||
const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
|
const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
|
||||||
this.commit(Object.freeze({
|
return this.commit(Object.freeze({
|
||||||
app: previous.app,
|
app: previous.app,
|
||||||
players: roomResult.players,
|
players: roomResult.players,
|
||||||
room: roomResult.room,
|
room: roomResult.room,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export {
|
|||||||
parseOtherOfflinePayload,
|
parseOtherOfflinePayload,
|
||||||
parseOtherOnlinePayload,
|
parseOtherOnlinePayload,
|
||||||
parsePlayerPreparePayload,
|
parsePlayerPreparePayload,
|
||||||
|
parseSelfExitRoomPayload,
|
||||||
parseSelfJoinRoomResponse,
|
parseSelfJoinRoomResponse,
|
||||||
type JoinRoomEnvironment,
|
type JoinRoomEnvironment,
|
||||||
type JoinRoomPayload,
|
type JoinRoomPayload,
|
||||||
@@ -43,6 +44,7 @@ export {
|
|||||||
type ParsedRoomResponse,
|
type ParsedRoomResponse,
|
||||||
type ParsedRoomserverPayload,
|
type ParsedRoomserverPayload,
|
||||||
type ParsedRoomSnapshot,
|
type ParsedRoomSnapshot,
|
||||||
|
type ParsedSelfExitPayload,
|
||||||
type ParsedSeatPayload,
|
type ParsedSeatPayload,
|
||||||
type ReconnectSnapshot,
|
type ReconnectSnapshot,
|
||||||
type RoomActionPayload,
|
type RoomActionPayload,
|
||||||
|
|||||||
+22
-1
@@ -150,6 +150,7 @@ export interface ParsedRoomResponse {
|
|||||||
readonly state: number;
|
readonly state: number;
|
||||||
readonly room: ParsedRoomSnapshot | null;
|
readonly room: ParsedRoomSnapshot | null;
|
||||||
readonly reconnect: ReconnectSnapshot;
|
readonly reconnect: ReconnectSnapshot;
|
||||||
|
readonly stage: 0 | 1;
|
||||||
readonly raw: JsonRecord;
|
readonly raw: JsonRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +158,13 @@ export function parseSelfJoinRoomResponse(value: unknown): ParsedRoomResponse {
|
|||||||
const raw = requireRecord(value, '$.data');
|
const raw = requireRecord(value, '$.data');
|
||||||
const state = requireInteger(raw.state, '$.data.state');
|
const state = requireInteger(raw.state, '$.data.state');
|
||||||
const room = state === 0 ? parseRoomEntryFields(raw) : null;
|
const room = state === 0 ? parseRoomEntryFields(raw) : null;
|
||||||
return { state, room, reconnect: reconnectFrom(raw), raw };
|
const parsedDeskwar = optionalField(raw, 'deskwar', '$.data', requireInteger);
|
||||||
|
if (parsedDeskwar !== undefined && parsedDeskwar !== 0 && parsedDeskwar !== 1) {
|
||||||
|
throw new RangeError('$.data.deskwar: expected protocol flag 0 or 1');
|
||||||
|
}
|
||||||
|
const reconnect = reconnectFrom(raw);
|
||||||
|
const stage = reconnect.present ? 1 : parsedDeskwar ?? 0;
|
||||||
|
return { state, room, reconnect, stage, raw };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedSeatPayload {
|
export interface ParsedSeatPayload {
|
||||||
@@ -177,6 +184,20 @@ export const parseOtherJoinRoomPayload = parseSeatPayload;
|
|||||||
export const parseOtherExitRoomPayload = parseSeatPayload;
|
export const parseOtherExitRoomPayload = parseSeatPayload;
|
||||||
export const parseOtherOfflinePayload = parseSeatPayload;
|
export const parseOtherOfflinePayload = parseSeatPayload;
|
||||||
|
|
||||||
|
export interface ParsedSelfExitPayload {
|
||||||
|
readonly seat?: number;
|
||||||
|
readonly raw: JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSelfExitRoomPayload(value: unknown): ParsedSelfExitPayload {
|
||||||
|
const raw = requireRecord(value, '$.data');
|
||||||
|
const seat = optionalField(raw, 'seat', '$.data', requireInteger);
|
||||||
|
if (seat !== undefined && seat < 0) {
|
||||||
|
throw new RangeError('$.data.seat: expected non-negative server seat, received number');
|
||||||
|
}
|
||||||
|
return { ...(seat === undefined ? {} : { seat }), raw };
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParsedOnlinePayload extends ParsedSeatPayload {
|
export interface ParsedOnlinePayload extends ParsedSeatPayload {
|
||||||
readonly ip: string;
|
readonly ip: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
parseOtherOfflinePayload,
|
parseOtherOfflinePayload,
|
||||||
parseOtherOnlinePayload,
|
parseOtherOnlinePayload,
|
||||||
parsePlayerPreparePayload,
|
parsePlayerPreparePayload,
|
||||||
|
parseSelfExitRoomPayload,
|
||||||
} from './contracts/index.ts';
|
} from './contracts/index.ts';
|
||||||
import {
|
import {
|
||||||
buildFirstSliceEnvelope,
|
buildFirstSliceEnvelope,
|
||||||
@@ -67,7 +68,7 @@ export class PlatformHandlers {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'self_exit_room': {
|
case 'self_exit_room': {
|
||||||
const parsed = parseOtherExitRoomPayload(message.data);
|
const parsed = parseSelfExitRoomPayload(message.data);
|
||||||
this.options.session.handleSelfExit(parsed);
|
this.options.session.handleSelfExit(parsed);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -103,22 +104,26 @@ export class PlatformHandlers {
|
|||||||
const parsed = parseConnectRoomserverPayload(message.data);
|
const parsed = parseConnectRoomserverPayload(message.data);
|
||||||
const target = parseSwitchTarget(parsed.roomserver, '$.data.roomserver');
|
const target = parseSwitchTarget(parsed.roomserver, '$.data.roomserver');
|
||||||
const resend = buildFirstSliceEnvelope('connect_roomserver', parsed.raw);
|
const resend = buildFirstSliceEnvelope('connect_roomserver', parsed.raw);
|
||||||
this.options.session.handleServerSwitch();
|
this.options.session.handleServerSwitch(
|
||||||
this.options.connection.switchServer(target, resend);
|
() => this.options.connection.switchServer(target, resend),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'connect_agentserver': {
|
case 'connect_agentserver': {
|
||||||
const parsed = parseConnectAgentserverPayload(message.data);
|
const parsed = parseConnectAgentserverPayload(message.data);
|
||||||
const target = parseSwitchTarget(parsed.agentserver, '$.data.agentserver');
|
const target = parseSwitchTarget(parsed.agentserver, '$.data.agentserver');
|
||||||
const resend = buildFirstSliceEnvelope('connect_agentserver', parsed.raw);
|
const resend = buildFirstSliceEnvelope('connect_agentserver', parsed.raw);
|
||||||
this.options.session.handleServerSwitch();
|
this.options.session.handleServerSwitch(
|
||||||
this.options.connection.switchServer(target, resend);
|
() => this.options.connection.switchServer(target, resend),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'kick_server': {
|
case 'kick_server': {
|
||||||
const parsed = parseKickServerPayload(message.data);
|
const parsed = parseKickServerPayload(message.data);
|
||||||
this.options.session.handleKick(parsed.raw);
|
this.options.session.handleKick(
|
||||||
this.options.connection.stopForKick(parsed.raw);
|
parsed.raw,
|
||||||
|
() => this.options.connection.stopForKick(parsed.raw),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,14 +146,10 @@ function parseSwitchTarget(address: string, path: string): string {
|
|||||||
throw new TypeError(`${path}: expected a non-empty WebSocket server address`);
|
throw new TypeError(`${path}: expected a non-empty WebSocket server address`);
|
||||||
}
|
}
|
||||||
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(address);
|
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(address);
|
||||||
let target: string;
|
const target = scheme ? address : `ws://${address}`;
|
||||||
if (scheme) {
|
const authority = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/.exec(target)?.[1];
|
||||||
if (scheme[1] !== 'ws' && scheme[1] !== 'wss') {
|
if (authority === undefined || authority.length === 0) {
|
||||||
throw new TypeError(`${path}: unsupported WebSocket scheme ${scheme[1]}`);
|
throw new TypeError(`${path}: expected a WebSocket address with a non-empty host`);
|
||||||
}
|
|
||||||
target = address;
|
|
||||||
} else {
|
|
||||||
target = `ws://${address}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
@@ -157,7 +158,14 @@ function parseSwitchTarget(address: string, path: string): string {
|
|||||||
} catch {
|
} catch {
|
||||||
throw new TypeError(`${path}: expected a valid WebSocket server address`);
|
throw new TypeError(`${path}: expected a valid WebSocket server address`);
|
||||||
}
|
}
|
||||||
if ((parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') || parsed.hostname.length === 0) {
|
const protocol = parsed.protocol.toLowerCase();
|
||||||
|
if (
|
||||||
|
(protocol !== 'ws:' && protocol !== 'wss:')
|
||||||
|
|| parsed.hostname.length === 0
|
||||||
|
|| parsed.username.length > 0
|
||||||
|
|| parsed.password.length > 0
|
||||||
|
|| parsed.hash.length > 0
|
||||||
|
) {
|
||||||
throw new TypeError(`${path}: expected a valid WebSocket server address`);
|
throw new TypeError(`${path}: expected a valid WebSocket server address`);
|
||||||
}
|
}
|
||||||
return target;
|
return target;
|
||||||
|
|||||||
@@ -14,11 +14,43 @@ export interface RouterOptions {
|
|||||||
readonly gameSession: Pick<GameSessionHost, 'dispatchGameMessage'>;
|
readonly gameSession: Pick<GameSessionHost, 'dispatchGameMessage'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QueuedDispatch {
|
||||||
|
readonly message: InboundMessage;
|
||||||
|
result?: RouterDispatchResult;
|
||||||
|
}
|
||||||
|
|
||||||
/** Classifies each inbound business envelope exactly once. */
|
/** Classifies each inbound business envelope exactly once. */
|
||||||
export class Router {
|
export class Router {
|
||||||
|
private readonly queue: QueuedDispatch[] = [];
|
||||||
|
private draining = false;
|
||||||
|
|
||||||
constructor(private readonly options: RouterOptions) {}
|
constructor(private readonly options: RouterOptions) {}
|
||||||
|
|
||||||
dispatch(message: InboundMessage): RouterDispatchResult {
|
dispatch(message: InboundMessage): RouterDispatchResult {
|
||||||
|
const queued: QueuedDispatch = { message };
|
||||||
|
this.queue.push(queued);
|
||||||
|
if (this.draining) return this.resultWithoutSideEffects(message);
|
||||||
|
|
||||||
|
this.draining = true;
|
||||||
|
try {
|
||||||
|
while (this.queue.length > 0) {
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (next === undefined) continue;
|
||||||
|
next.result = this.dispatchOne(next.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.queue.length = 0;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.draining = false;
|
||||||
|
}
|
||||||
|
if (queued.result === undefined) {
|
||||||
|
throw new Error('Router failed to produce a dispatch result');
|
||||||
|
}
|
||||||
|
return queued.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchOne(message: InboundMessage): RouterDispatchResult {
|
||||||
if (this.options.session.loginPending && !isLoginGateControl(message)) {
|
if (this.options.session.loginPending && !isLoginGateControl(message)) {
|
||||||
return 'ignored-login-gate';
|
return 'ignored-login-gate';
|
||||||
}
|
}
|
||||||
@@ -31,6 +63,13 @@ export class Router {
|
|||||||
});
|
});
|
||||||
return 'handled-game';
|
return 'handled-game';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resultWithoutSideEffects(message: InboundMessage): RouterDispatchResult {
|
||||||
|
if (this.options.session.loginPending && !isLoginGateControl(message)) {
|
||||||
|
return 'ignored-login-gate';
|
||||||
|
}
|
||||||
|
return isPlatformRoute(message.route) ? 'handled-platform' : 'handled-game';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' {
|
function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { afterEach, test } from 'node:test';
|
import { afterEach, test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import ts from 'typescript';
|
import {
|
||||||
import { scanImportBoundaries } from '../../scripts/lib/import-boundaries.mjs';
|
findCompatibilityDependency,
|
||||||
|
scanImportBoundaries,
|
||||||
|
} from '../../scripts/lib/import-boundaries.mjs';
|
||||||
|
|
||||||
const roots = [];
|
const roots = [];
|
||||||
|
|
||||||
@@ -55,45 +57,13 @@ const legacyRuntimeFiles = new Set([
|
|||||||
'protocol/room-handlers.ts',
|
'protocol/room-handlers.ts',
|
||||||
].map((file) => resolve(frameworkRoot, file)));
|
].map((file) => resolve(frameworkRoot, file)));
|
||||||
|
|
||||||
function relativeImports(file) {
|
function scanCompatibilityFixture(root, entry = 'framework/protocol/router.ts', tsconfigPath) {
|
||||||
const source = readFileSync(file, 'utf8');
|
return findCompatibilityDependency({
|
||||||
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
|
entry: join(root, entry),
|
||||||
const imports = [];
|
forbiddenFiles: [join(root, 'framework/platform/session.ts')],
|
||||||
const visit = (node) => {
|
traversalRoot: join(root, 'framework'),
|
||||||
let specifier;
|
tsconfigPath: tsconfigPath ? join(root, tsconfigPath) : undefined,
|
||||||
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node))
|
});
|
||||||
&& node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
||||||
specifier = node.moduleSpecifier.text;
|
|
||||||
} else if (ts.isImportTypeNode(node)
|
|
||||||
&& ts.isLiteralTypeNode(node.argument)
|
|
||||||
&& ts.isStringLiteral(node.argument.literal)) {
|
|
||||||
specifier = node.argument.literal.text;
|
|
||||||
} else if (ts.isCallExpression(node)
|
|
||||||
&& node.expression.kind === ts.SyntaxKind.ImportKeyword
|
|
||||||
&& node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0])) {
|
|
||||||
specifier = node.arguments[0].text;
|
|
||||||
}
|
|
||||||
if (specifier?.startsWith('.')) {
|
|
||||||
const imported = resolve(dirname(file), specifier);
|
|
||||||
const candidates = [imported, `${imported}.ts`, join(imported, 'index.ts')];
|
|
||||||
const resolvedImport = candidates.find((candidate) => existsSync(candidate));
|
|
||||||
if (resolvedImport) imports.push(resolvedImport);
|
|
||||||
}
|
|
||||||
ts.forEachChild(node, visit);
|
|
||||||
};
|
|
||||||
ts.forEachChild(sourceFile, visit);
|
|
||||||
return imports;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLegacyDependency(entry, path = [], visited = new Set()) {
|
|
||||||
if (legacyRuntimeFiles.has(entry)) return [...path, entry];
|
|
||||||
if (visited.has(entry)) return null;
|
|
||||||
visited.add(entry);
|
|
||||||
for (const imported of relativeImports(entry)) {
|
|
||||||
const found = findLegacyDependency(imported, [...path, entry], visited);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test('every existing modern runtime entry is transitively isolated from the legacy compatibility island', () => {
|
test('every existing modern runtime entry is transitively isolated from the legacy compatibility island', () => {
|
||||||
@@ -103,15 +73,114 @@ test('every existing modern runtime entry is transitively isolated from the lega
|
|||||||
assert.ok(entries.some((file) => file.endsWith(`${join('protocol', 'router.ts')}`)));
|
assert.ok(entries.some((file) => file.endsWith(`${join('protocol', 'router.ts')}`)));
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const dependencyPath = findLegacyDependency(entry);
|
const dependency = findCompatibilityDependency({
|
||||||
|
entry,
|
||||||
|
forbiddenFiles: [...legacyRuntimeFiles],
|
||||||
|
traversalRoot: frameworkRoot,
|
||||||
|
tsconfigPath: fileURLToPath(new URL('../../tsconfig.framework.json', import.meta.url)),
|
||||||
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
dependencyPath,
|
dependency,
|
||||||
null,
|
null,
|
||||||
dependencyPath?.map((file) => file.slice(frameworkRoot.length)).join(' -> '),
|
dependency?.path.map((file) => file.slice(frameworkRoot.length)).join(' -> '),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('compatibility graph catches static imports, re-exports, and import types through barrels', async () => {
|
||||||
|
const cases = [
|
||||||
|
"import '../platform/session.ts'\n",
|
||||||
|
"export * from '../platform/session.ts'\n",
|
||||||
|
"export type { LegacySession } from '../platform/session.ts'\n",
|
||||||
|
"type Legacy = import('../platform/session.ts').LegacySession\n",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const source of cases) {
|
||||||
|
const root = await createFixtureRoot();
|
||||||
|
await writeFixture(root, 'framework/platform/session.ts', 'export interface LegacySession {}\n');
|
||||||
|
await writeFixture(root, 'framework/protocol/barrel.ts', source);
|
||||||
|
await writeFixture(root, 'framework/protocol/router.ts', "export * from './barrel.ts'\n");
|
||||||
|
|
||||||
|
const found = scanCompatibilityFixture(root);
|
||||||
|
assert.equal(found?.kind, 'forbidden', source);
|
||||||
|
assert.deepEqual(
|
||||||
|
found?.path.map((file) => file.replaceAll('\\', '/').split('/framework/')[1]),
|
||||||
|
['protocol/router.ts', 'protocol/barrel.ts', 'platform/session.ts'],
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compatibility graph reports direct and ordinary transitive dependency paths', async () => {
|
||||||
|
const directRoot = await createFixtureRoot();
|
||||||
|
await writeFixture(directRoot, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||||
|
await writeFixture(directRoot, 'framework/protocol/router.ts', "import '../platform/session.ts'\n");
|
||||||
|
assert.equal(scanCompatibilityFixture(directRoot)?.path.length, 2);
|
||||||
|
|
||||||
|
const transitiveRoot = await createFixtureRoot();
|
||||||
|
await writeFixture(transitiveRoot, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||||
|
await writeFixture(transitiveRoot, 'framework/protocol/helper.ts', "import '../platform/session.ts'\n");
|
||||||
|
await writeFixture(transitiveRoot, 'framework/protocol/router.ts', "import './helper.ts'\n");
|
||||||
|
assert.equal(scanCompatibilityFixture(transitiveRoot)?.path.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compatibility graph catches string and no-substitution-template dynamic imports', async () => {
|
||||||
|
for (const source of [
|
||||||
|
"await import('../platform/session.ts')\n",
|
||||||
|
'await import(`../platform/session.ts`)\n',
|
||||||
|
]) {
|
||||||
|
const root = await createFixtureRoot();
|
||||||
|
await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||||
|
await writeFixture(root, 'framework/protocol/router.ts', source);
|
||||||
|
|
||||||
|
assert.equal(scanCompatibilityFixture(root)?.kind, 'forbidden', source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compatibility graph resolves tsconfig path aliases to canonical files', async () => {
|
||||||
|
const root = await createFixtureRoot();
|
||||||
|
await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||||
|
await writeFixture(root, 'framework/protocol/router.ts', "import '@legacy/session'\n");
|
||||||
|
await writeFixture(root, 'tsconfig.json', JSON.stringify({
|
||||||
|
compilerOptions: {
|
||||||
|
baseUrl: '.',
|
||||||
|
paths: { '@legacy/*': ['framework/platform/*'] },
|
||||||
|
module: 'ESNext',
|
||||||
|
moduleResolution: 'Bundler',
|
||||||
|
allowImportingTsExtensions: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const found = scanCompatibilityFixture(root, undefined, 'tsconfig.json');
|
||||||
|
assert.equal(found?.kind, 'forbidden');
|
||||||
|
assert.ok(found?.path.at(-1)?.replaceAll('\\', '/').endsWith('/framework/platform/session.ts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compatibility graph fails closed on a reachable non-static dynamic import', async () => {
|
||||||
|
const root = await createFixtureRoot();
|
||||||
|
await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||||
|
await writeFixture(
|
||||||
|
root,
|
||||||
|
'framework/protocol/router.ts',
|
||||||
|
"const target = '../platform/session.ts'; void import(target)\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const found = scanCompatibilityFixture(root);
|
||||||
|
assert.equal(found?.kind, 'dynamic');
|
||||||
|
assert.ok(found?.path[0]?.replaceAll('\\', '/').endsWith('/framework/protocol/router.ts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compatibility graph applies the same isolation to a future platform/runtime.ts entry', async () => {
|
||||||
|
const root = await createFixtureRoot();
|
||||||
|
await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||||
|
await writeFixture(root, 'framework/application/barrel.ts', "export * from '../platform/session.ts'\n");
|
||||||
|
await writeFixture(root, 'framework/platform/runtime.ts', "import '../application/barrel.ts'\n");
|
||||||
|
|
||||||
|
const found = scanCompatibilityFixture(root, 'framework/platform/runtime.ts');
|
||||||
|
assert.equal(found?.kind, 'forbidden');
|
||||||
|
assert.equal(found?.path.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
test('scanner rejects sdk contracts importing a framework platform path', async () => {
|
test('scanner rejects sdk contracts importing a framework platform path', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n");
|
await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n");
|
||||||
|
|||||||
@@ -425,13 +425,24 @@ test('replaceRoom atomically replaces the complete room from a parsed DTO', () =
|
|||||||
assert.equal(after.players.entities[430511]?.ip, '127.0.0.1');
|
assert.equal(after.players.entities[430511]?.ip, '127.0.0.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('replaceRoom derives a reconnect stage only from deskinfo presence', () => {
|
test('replaceRoom consumes the parser-owned deskwar default and reconnect stage matrix', () => {
|
||||||
for (const deskwar of [undefined, 0] as const) {
|
const cases = [
|
||||||
|
{ deskwar: 'missing', deskinfo: false, expectedStage: 0 },
|
||||||
|
{ deskwar: 0, deskinfo: false, expectedStage: 0 },
|
||||||
|
{ deskwar: 1, deskinfo: false, expectedStage: 1 },
|
||||||
|
{ deskwar: 'missing', deskinfo: true, expectedStage: 1 },
|
||||||
|
{ deskwar: 0, deskinfo: true, expectedStage: 1 },
|
||||||
|
{ deskwar: 1, deskinfo: true, expectedStage: 1 },
|
||||||
|
] as const;
|
||||||
|
for (const row of cases) {
|
||||||
const store = new PlatformStore();
|
const store = new PlatformStore();
|
||||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
|
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
|
||||||
const data = fixture('self-join-room.json').data;
|
const data = fixture('self-join-room.json').data;
|
||||||
if (deskwar === undefined) delete data.deskwar;
|
if (row.deskwar === 'missing') delete data.deskwar;
|
||||||
else data.deskwar = deskwar;
|
else data.deskwar = row.deskwar;
|
||||||
|
if (row.deskinfo) data.deskinfo = { round: 7 };
|
||||||
|
else delete data.deskinfo;
|
||||||
|
data.isbattle = 1;
|
||||||
const parsed = parseSelfJoinRoomResponse(data);
|
const parsed = parseSelfJoinRoomResponse(data);
|
||||||
|
|
||||||
store.replaceRoom(parsed);
|
store.replaceRoom(parsed);
|
||||||
@@ -439,7 +450,7 @@ test('replaceRoom derives a reconnect stage only from deskinfo presence', () =>
|
|||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
assert.equal(state.room.kind, 'inside');
|
assert.equal(state.room.kind, 'inside');
|
||||||
if (state.room.kind !== 'inside') continue;
|
if (state.room.kind !== 'inside') continue;
|
||||||
assert.equal(state.room.stage, 1);
|
assert.equal(state.room.stage, row.expectedStage, JSON.stringify(row));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -458,21 +469,7 @@ test('applyLoginSuccess requires isbattle even when raw deskwar is fabricated',
|
|||||||
assert.equal(notifications, 0);
|
assert.equal(notifications, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('replaceRoom without reconnect requires deskwar and never substitutes isbattle', () => {
|
test('replaceRoom never substitutes self-join isbattle for missing deskwar or deskinfo', () => {
|
||||||
const successfulStore = new PlatformStore();
|
|
||||||
successfulStore.applyLoginSuccess(
|
|
||||||
parseLoginResponse(fixture('player-login-success.json').data),
|
|
||||||
);
|
|
||||||
const successfulData = fixture('self-join-room.json').data;
|
|
||||||
delete successfulData.deskinfo;
|
|
||||||
successfulData.deskwar = 0;
|
|
||||||
successfulStore.replaceRoom(parseSelfJoinRoomResponse(successfulData));
|
|
||||||
const successfulState = successfulStore.getState();
|
|
||||||
assert.equal(successfulState.room.kind, 'inside');
|
|
||||||
if (successfulState.room.kind === 'inside') {
|
|
||||||
assert.equal(successfulState.room.stage, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = new PlatformStore();
|
const store = new PlatformStore();
|
||||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
|
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
|
||||||
const data = fixture('self-join-room.json').data;
|
const data = fixture('self-join-room.json').data;
|
||||||
@@ -480,13 +477,12 @@ test('replaceRoom without reconnect requires deskwar and never substitutes isbat
|
|||||||
delete data.deskwar;
|
delete data.deskwar;
|
||||||
data.isbattle = 1;
|
data.isbattle = 1;
|
||||||
const parsed = parseSelfJoinRoomResponse(data);
|
const parsed = parseSelfJoinRoomResponse(data);
|
||||||
const before = store.getState();
|
|
||||||
let notifications = 0;
|
|
||||||
store.subscribe(() => { notifications += 1; });
|
|
||||||
|
|
||||||
assert.throws(() => store.replaceRoom(parsed), /deskwar/i);
|
store.replaceRoom(parsed);
|
||||||
assert.equal(store.getState(), before);
|
|
||||||
assert.equal(notifications, 0);
|
const state = store.getState();
|
||||||
|
assert.equal(state.room.kind, 'inside');
|
||||||
|
if (state.room.kind === 'inside') assert.equal(state.room.stage, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clearRoom leaves no fabricated room fields and retains only the self entity', () => {
|
test('clearRoom leaves no fabricated room fields and retains only the self entity', () => {
|
||||||
|
|||||||
@@ -48,14 +48,20 @@ function makeSession() {
|
|||||||
};
|
};
|
||||||
const gameSession = new GameSessionHost(entry);
|
const gameSession = new GameSessionHost(entry);
|
||||||
log.length = 0;
|
log.length = 0;
|
||||||
const gameHost: GameHost = {
|
const session = new RuntimeSession({
|
||||||
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
store,
|
||||||
getSnapshot: () => selectGameSnapshot(store.getState()),
|
gameSession,
|
||||||
subscribe: () => () => {},
|
createGameHostLease: () => ({
|
||||||
sendGameMessage() {},
|
host: {
|
||||||
execute() {},
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
||||||
};
|
getSnapshot: () => selectGameSnapshot(store.getState()),
|
||||||
const session = new RuntimeSession({ store, gameSession, gameHost });
|
subscribe: () => () => {},
|
||||||
|
sendGameMessage() {},
|
||||||
|
execute() {},
|
||||||
|
},
|
||||||
|
invalidate() {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
return { store, gameSession, session, log, restored, events };
|
return { store, gameSession, session, log, restored, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,3 +115,254 @@ test('RuntimeSession self join commits, opens a fresh session, and restores the
|
|||||||
assert.equal(setup.restored[0], join.deskinfo);
|
assert.equal(setup.restored[0], join.deskinfo);
|
||||||
assert.deepEqual(setup.log, ['store', 'attach', 'event:room.entered', 'restore']);
|
assert.deepEqual(setup.log, ['store', 'attach', 'event:room.entered', 'restore']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('RuntimeSession self join honors the parser-owned deskwar and deskinfo matrix', () => {
|
||||||
|
const cases = [
|
||||||
|
{ deskwar: 'missing', deskinfo: false, expectedStage: 0 },
|
||||||
|
{ deskwar: 0, deskinfo: false, expectedStage: 0 },
|
||||||
|
{ deskwar: 1, deskinfo: false, expectedStage: 1 },
|
||||||
|
{ deskwar: 'missing', deskinfo: true, expectedStage: 1 },
|
||||||
|
{ deskwar: 0, deskinfo: true, expectedStage: 1 },
|
||||||
|
{ deskwar: 1, deskinfo: true, expectedStage: 1 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const row of cases) {
|
||||||
|
const setup = makeSession();
|
||||||
|
setup.session.handleLogin(fixture('player-login-success.json'));
|
||||||
|
const data = fixture('self-join-room.json');
|
||||||
|
if (row.deskwar === 'missing') delete data.deskwar;
|
||||||
|
else data.deskwar = row.deskwar;
|
||||||
|
const deskinfo = { round: 7 };
|
||||||
|
if (row.deskinfo) data.deskinfo = deskinfo;
|
||||||
|
else delete data.deskinfo;
|
||||||
|
data.isbattle = 1;
|
||||||
|
|
||||||
|
setup.session.handleSelfJoin(data);
|
||||||
|
|
||||||
|
const state = setup.store.getState();
|
||||||
|
assert.equal(state.room.kind, 'inside');
|
||||||
|
if (state.room.kind === 'inside') {
|
||||||
|
assert.equal(state.room.stage, row.expectedStage, JSON.stringify(row));
|
||||||
|
}
|
||||||
|
assert.equal(setup.restored.length, row.deskinfo ? 1 : 0, JSON.stringify(row));
|
||||||
|
if (row.deskinfo) assert.equal(setup.restored[0], deskinfo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type LeaseFailurePoint =
|
||||||
|
| 'lease-factory'
|
||||||
|
| 'module-create'
|
||||||
|
| 'attach'
|
||||||
|
| 'event'
|
||||||
|
| 'restore'
|
||||||
|
| 'dispose'
|
||||||
|
| 'invalidate';
|
||||||
|
|
||||||
|
interface LeaseRecord {
|
||||||
|
readonly host: GameHost;
|
||||||
|
invalidations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLeaseSession() {
|
||||||
|
const store = new PlatformStore();
|
||||||
|
const leases: LeaseRecord[] = [];
|
||||||
|
const attachedHosts: GameHost[] = [];
|
||||||
|
const failures = new Set<LeaseFailurePoint>();
|
||||||
|
const errors = Object.fromEntries([
|
||||||
|
'lease-factory', 'module-create', 'attach', 'event', 'restore', 'dispose', 'invalidate',
|
||||||
|
].map((point) => [point, new Error(`failure:${point}`)])) as Record<LeaseFailurePoint, Error>;
|
||||||
|
let validatingEntry = true;
|
||||||
|
let moduleDisposals = 0;
|
||||||
|
|
||||||
|
function host(): GameHost {
|
||||||
|
return {
|
||||||
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
||||||
|
getSnapshot: () => selectGameSnapshot(store.getState()),
|
||||||
|
subscribe: () => () => {},
|
||||||
|
sendGameMessage() {},
|
||||||
|
execute() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: GameEntry = {
|
||||||
|
key: 'lease-fixture',
|
||||||
|
gameId: 41,
|
||||||
|
route: 'fixture-game',
|
||||||
|
resolveSeatCount: () => 4,
|
||||||
|
createModule: (): GameModule => {
|
||||||
|
if (!validatingEntry && failures.has('module-create')) throw errors['module-create'];
|
||||||
|
return {
|
||||||
|
attach(attachedHost) {
|
||||||
|
if (!validatingEntry) attachedHosts.push(attachedHost);
|
||||||
|
if (!validatingEntry && failures.has('attach')) throw errors.attach;
|
||||||
|
},
|
||||||
|
handlePlatformEvent() {
|
||||||
|
if (!validatingEntry && failures.has('event')) throw errors.event;
|
||||||
|
},
|
||||||
|
handleGameMessage() {},
|
||||||
|
restore() {
|
||||||
|
if (!validatingEntry && failures.has('restore')) throw errors.restore;
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (validatingEntry) return;
|
||||||
|
moduleDisposals += 1;
|
||||||
|
if (failures.has('dispose')) throw errors.dispose;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const gameSession = new GameSessionHost(entry);
|
||||||
|
validatingEntry = false;
|
||||||
|
const sessionOptions = {
|
||||||
|
store,
|
||||||
|
gameSession,
|
||||||
|
createGameHostLease() {
|
||||||
|
if (failures.has('lease-factory')) throw errors['lease-factory'];
|
||||||
|
const record: LeaseRecord = { host: host(), invalidations: 0 };
|
||||||
|
leases.push(record);
|
||||||
|
return {
|
||||||
|
host: record.host,
|
||||||
|
invalidate() {
|
||||||
|
record.invalidations += 1;
|
||||||
|
if (failures.has('invalidate')) throw errors.invalidate;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const session = new RuntimeSession(sessionOptions);
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
gameSession,
|
||||||
|
session,
|
||||||
|
leases,
|
||||||
|
attachedHosts,
|
||||||
|
errors,
|
||||||
|
setFailures(...points: LeaseFailurePoint[]) {
|
||||||
|
failures.clear();
|
||||||
|
for (const point of points) failures.add(point);
|
||||||
|
},
|
||||||
|
getModuleDisposals: () => moduleDisposals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function caughtError(operation: () => void): Error {
|
||||||
|
try {
|
||||||
|
operation();
|
||||||
|
} catch (error) {
|
||||||
|
assert.ok(error instanceof Error);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
assert.fail('expected operation to throw');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('RuntimeSession creates a fresh host lease per login/self-join replacement and invalidates on self exit', () => {
|
||||||
|
const setup = makeLeaseSession();
|
||||||
|
|
||||||
|
setup.session.handleLogin(fixture('player-login-room.json'));
|
||||||
|
setup.session.handleSelfJoin(fixture('self-join-room.json'));
|
||||||
|
setup.session.handleLogin(fixture('player-login-room.json'));
|
||||||
|
|
||||||
|
assert.equal(setup.leases.length, 3);
|
||||||
|
assert.equal(new Set(setup.attachedHosts).size, 3);
|
||||||
|
assert.deepEqual(setup.leases.map((lease) => lease.invalidations), [1, 1, 0]);
|
||||||
|
assert.equal(setup.attachedHosts[0], setup.leases[0]?.host);
|
||||||
|
assert.equal(setup.attachedHosts[1], setup.leases[1]?.host);
|
||||||
|
assert.equal(setup.attachedHosts[2], setup.leases[2]?.host);
|
||||||
|
|
||||||
|
setup.session.handleSelfExit({ raw: {} });
|
||||||
|
|
||||||
|
assert.deepEqual(setup.leases.map((lease) => lease.invalidations), [1, 1, 1]);
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
assert.equal(setup.session.state, 'operational');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RuntimeSession invalidates the active lease on a successful server switch', () => {
|
||||||
|
const setup = makeLeaseSession();
|
||||||
|
setup.session.handleLogin(fixture('player-login-room.json'));
|
||||||
|
|
||||||
|
setup.session.handleServerSwitch(() => {});
|
||||||
|
|
||||||
|
assert.equal(setup.leases[0]?.invalidations, 1);
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
assert.equal(setup.session.state, 'operational');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of [
|
||||||
|
'lease-factory', 'module-create', 'attach', 'event', 'restore',
|
||||||
|
] as const) {
|
||||||
|
test(`RuntimeSession cleans a failed room open and enters terminal fault at ${failure}`, () => {
|
||||||
|
const setup = makeLeaseSession();
|
||||||
|
setup.setFailures(failure);
|
||||||
|
|
||||||
|
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
|
||||||
|
|
||||||
|
assert.equal(setup.session.state, 'faulted');
|
||||||
|
assert.equal(setup.session.fault, error);
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
if (failure === 'lease-factory') {
|
||||||
|
assert.equal(setup.leases.length, 0);
|
||||||
|
} else {
|
||||||
|
assert.equal(setup.leases.length, 1);
|
||||||
|
assert.equal(setup.leases[0]?.invalidations, 1);
|
||||||
|
}
|
||||||
|
assert.throws(
|
||||||
|
() => setup.session.handleLogin(fixture('player-login-success.json')),
|
||||||
|
/fault/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const replacement of ['login', 'self-join'] as const) {
|
||||||
|
test(`RuntimeSession invalidates the old lease and faults when ${replacement} replacement disposal fails`, () => {
|
||||||
|
const setup = makeLeaseSession();
|
||||||
|
setup.session.handleLogin(fixture('player-login-room.json'));
|
||||||
|
setup.setFailures('dispose');
|
||||||
|
|
||||||
|
const error = caughtError(() => {
|
||||||
|
if (replacement === 'login') setup.session.handleLogin(fixture('player-login-room.json'));
|
||||||
|
else setup.session.handleSelfJoin(fixture('self-join-room.json'));
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(setup.session.state, 'faulted');
|
||||||
|
assert.equal(setup.session.fault, error);
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
assert.equal(setup.leases.length, 1, 'a failed old-session cleanup must not create a new lease');
|
||||||
|
assert.equal(setup.leases[0]?.invalidations, 1);
|
||||||
|
assert.equal(setup.getModuleDisposals(), 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const operation of ['self-exit', 'switch'] as const) {
|
||||||
|
test(`RuntimeSession invalidates once and faults when ${operation} disposal fails`, () => {
|
||||||
|
const setup = makeLeaseSession();
|
||||||
|
setup.session.handleLogin(fixture('player-login-room.json'));
|
||||||
|
setup.setFailures('dispose');
|
||||||
|
|
||||||
|
const error = caughtError(() => {
|
||||||
|
if (operation === 'self-exit') setup.session.handleSelfExit({ raw: {} });
|
||||||
|
else setup.session.handleServerSwitch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(setup.session.state, 'faulted');
|
||||||
|
assert.equal(setup.session.fault, error);
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
assert.equal(setup.leases[0]?.invalidations, 1);
|
||||||
|
assert.equal(setup.getModuleDisposals(), 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('RuntimeSession aggregates event, dispose, and invalidation failures after completing cleanup', () => {
|
||||||
|
const setup = makeLeaseSession();
|
||||||
|
setup.setFailures('event', 'dispose', 'invalidate');
|
||||||
|
|
||||||
|
const error = caughtError(() => setup.session.handleLogin(fixture('player-login-room.json')));
|
||||||
|
|
||||||
|
assert.equal(setup.session.state, 'faulted');
|
||||||
|
assert.equal(setup.session.fault, error);
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
assert.equal(setup.leases[0]?.invalidations, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
(error as Error & { errors?: readonly unknown[] }).errors,
|
||||||
|
[setup.errors.event, setup.errors.dispose, setup.errors.invalidate],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
parseOtherOfflinePayload,
|
parseOtherOfflinePayload,
|
||||||
parseOtherOnlinePayload,
|
parseOtherOnlinePayload,
|
||||||
parsePlayerPreparePayload,
|
parsePlayerPreparePayload,
|
||||||
|
parseSelfExitRoomPayload,
|
||||||
parseSelfJoinRoomResponse,
|
parseSelfJoinRoomResponse,
|
||||||
} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
||||||
|
|
||||||
@@ -214,6 +215,38 @@ test('parseSelfJoinRoomResponse 接受金样,不深验 roomtype/deskinfo 并
|
|||||||
assert.equal(parsed.reconnect.present && parsed.reconnect.value, opaqueDeskinfo);
|
assert.equal(parsed.reconnect.present && parsed.reconnect.value, opaqueDeskinfo);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseSelfJoinRoomResponse defines the optional deskwar default once and deskinfo wins stage', () => {
|
||||||
|
const cases = [
|
||||||
|
{ deskwar: 'missing', deskinfo: false, expectedStage: 0 },
|
||||||
|
{ deskwar: 0, deskinfo: false, expectedStage: 0 },
|
||||||
|
{ deskwar: 1, deskinfo: false, expectedStage: 1 },
|
||||||
|
{ deskwar: 'missing', deskinfo: true, expectedStage: 1 },
|
||||||
|
{ deskwar: 0, deskinfo: true, expectedStage: 1 },
|
||||||
|
{ deskwar: 1, deskinfo: true, expectedStage: 1 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const row of cases) {
|
||||||
|
const data = fixture('self-join-room.json').data;
|
||||||
|
if (row.deskwar === 'missing') delete data.deskwar;
|
||||||
|
else data.deskwar = row.deskwar;
|
||||||
|
if (row.deskinfo) data.deskinfo = { round: 7 };
|
||||||
|
else delete data.deskinfo;
|
||||||
|
data.isbattle = 1;
|
||||||
|
|
||||||
|
const parsed = parseSelfJoinRoomResponse(data);
|
||||||
|
|
||||||
|
assert.equal(parsed.stage, row.expectedStage, JSON.stringify(row));
|
||||||
|
assert.equal(parsed.reconnect.present, row.deskinfo, JSON.stringify(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const deskwar of [-1, 2, 1.5, '1', undefined]) {
|
||||||
|
assert.throws(
|
||||||
|
() => parseSelfJoinRoomResponse({ ...fixture('self-join-room.json').data, deskwar }),
|
||||||
|
/\$\.data\.deskwar/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('房间请求 builder 使用唯一 route,并仅发协议定义字段', () => {
|
test('房间请求 builder 使用唯一 route,并仅发协议定义字段', () => {
|
||||||
const room = { playerid: 430511, roomcode: '100001' };
|
const room = { playerid: 430511, roomcode: '100001' };
|
||||||
assert.deepEqual(buildPrepareRequest(runtimeConfig, room), {
|
assert.deepEqual(buildPrepareRequest(runtimeConfig, room), {
|
||||||
@@ -311,6 +344,23 @@ test('座位事件 parser 校验实际消费字段并保留 raw', () => {
|
|||||||
assert.throws(() => parseOtherOnlinePayload({ seat: 1 }), /\$\.data\.ip.*undefined/);
|
assert.throws(() => parseOtherOnlinePayload({ seat: 1 }), /\$\.data\.ip.*undefined/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('self_exit_room parser accepts an omitted seat but strictly validates it when present', () => {
|
||||||
|
const withoutSeat = { isowner: 1, roomcode: '100001', extension: 'kept' };
|
||||||
|
const parsedWithoutSeat = parseSelfExitRoomPayload(withoutSeat);
|
||||||
|
assert.equal(parsedWithoutSeat.raw, withoutSeat);
|
||||||
|
assert.equal(Object.prototype.hasOwnProperty.call(parsedWithoutSeat, 'seat'), false);
|
||||||
|
|
||||||
|
const withSeat = { seat: 1, extension: 'kept' };
|
||||||
|
const parsedWithSeat = parseSelfExitRoomPayload(withSeat);
|
||||||
|
assert.equal(parsedWithSeat.raw, withSeat);
|
||||||
|
assert.equal(parsedWithSeat.seat, 1);
|
||||||
|
|
||||||
|
for (const seat of [-1, 1.2, '1', undefined]) {
|
||||||
|
assert.throws(() => parseSelfExitRoomPayload({ seat }), /\$\.data\.seat/);
|
||||||
|
}
|
||||||
|
assert.throws(() => parseOtherExitRoomPayload({}), /\$\.data\.seat/);
|
||||||
|
});
|
||||||
|
|
||||||
test('切服与踢出 parser 拒绝畸形载荷并保留未知扩展', () => {
|
test('切服与踢出 parser 拒绝畸形载荷并保留未知扩展', () => {
|
||||||
const roomSwitch = { roomserver: '127.0.0.1:9001', epoch: 7 };
|
const roomSwitch = { roomserver: '127.0.0.1:9001', epoch: 7 };
|
||||||
assert.deepEqual(parseConnectRoomserverPayload(roomSwitch), {
|
assert.deepEqual(parseConnectRoomserverPayload(roomSwitch), {
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import type {
|
|||||||
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
||||||
import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
||||||
import type { ConnectionControlPort } from '../../YouleNexus/assets/framework/platform/connection-intent.ts';
|
import type { ConnectionControlPort } from '../../YouleNexus/assets/framework/platform/connection-intent.ts';
|
||||||
import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
|
import {
|
||||||
|
RuntimeSession,
|
||||||
|
RuntimeSessionFault,
|
||||||
|
} from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
|
||||||
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||||
import { selectGameSnapshot } from '../../YouleNexus/assets/framework/platform/stores/selectors.ts';
|
import { selectGameSnapshot } from '../../YouleNexus/assets/framework/platform/stores/selectors.ts';
|
||||||
import { PlatformHandlers } from '../../YouleNexus/assets/framework/protocol/platform-handlers.ts';
|
import { PlatformHandlers } from '../../YouleNexus/assets/framework/protocol/platform-handlers.ts';
|
||||||
@@ -21,15 +24,35 @@ function fixture(name: string): Record<string, unknown> {
|
|||||||
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')).data;
|
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function caughtError(operation: () => void): Error {
|
||||||
|
try {
|
||||||
|
operation();
|
||||||
|
} catch (error) {
|
||||||
|
assert.ok(error instanceof Error);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
assert.fail('expected operation to throw');
|
||||||
|
}
|
||||||
|
|
||||||
class ConnectionFixture implements ConnectionControlPort {
|
class ConnectionFixture implements ConnectionControlPort {
|
||||||
readonly switchCalls: Array<{ target: string; resend: OutboundEnvelope }> = [];
|
readonly switchCalls: Array<{ target: string; resend: OutboundEnvelope }> = [];
|
||||||
readonly stopCalls: unknown[] = [];
|
readonly stopCalls: unknown[] = [];
|
||||||
|
switchFailure: Error | undefined;
|
||||||
|
switchObserver: () => void = () => {};
|
||||||
|
stopFailure: Error | undefined;
|
||||||
|
stopObserver: () => void = () => {};
|
||||||
|
|
||||||
switchServer(target: string, resend: OutboundEnvelope): void {
|
switchServer(target: string, resend: OutboundEnvelope): void {
|
||||||
this.switchCalls.push({ target, resend });
|
this.switchCalls.push({ target, resend });
|
||||||
|
this.switchObserver();
|
||||||
|
if (this.switchFailure) throw this.switchFailure;
|
||||||
}
|
}
|
||||||
|
|
||||||
stopForKick(data: unknown): void { this.stopCalls.push(data); }
|
stopForKick(data: unknown): void {
|
||||||
|
this.stopCalls.push(data);
|
||||||
|
this.stopObserver();
|
||||||
|
if (this.stopFailure) throw this.stopFailure;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHandlers() {
|
function makeHandlers() {
|
||||||
@@ -40,6 +63,8 @@ function makeHandlers() {
|
|||||||
snapshot: PlatformGameSnapshot,
|
snapshot: PlatformGameSnapshot,
|
||||||
) => void = () => {};
|
) => void = () => {};
|
||||||
let disposalObserver: () => void = () => {};
|
let disposalObserver: () => void = () => {};
|
||||||
|
let invalidationObserver: () => void = () => {};
|
||||||
|
let invalidations = 0;
|
||||||
const entry: GameEntry = {
|
const entry: GameEntry = {
|
||||||
key: 'fixture', gameId: 41, route: 'fixture-game', resolveSeatCount: () => 4,
|
key: 'fixture', gameId: 41, route: 'fixture-game', resolveSeatCount: () => 4,
|
||||||
createModule: (): GameModule => ({
|
createModule: (): GameModule => ({
|
||||||
@@ -54,12 +79,21 @@ function makeHandlers() {
|
|||||||
};
|
};
|
||||||
const gameSession = new GameSessionHost(entry);
|
const gameSession = new GameSessionHost(entry);
|
||||||
disposals = 0;
|
disposals = 0;
|
||||||
const gameHost: GameHost = {
|
const session = new RuntimeSession({
|
||||||
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
store,
|
||||||
getSnapshot: () => selectGameSnapshot(store.getState()),
|
gameSession,
|
||||||
subscribe: () => () => {}, sendGameMessage() {}, execute() {},
|
createGameHostLease: () => ({
|
||||||
};
|
host: {
|
||||||
const session = new RuntimeSession({ store, gameSession, gameHost });
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
||||||
|
getSnapshot: () => selectGameSnapshot(store.getState()),
|
||||||
|
subscribe: () => () => {}, sendGameMessage() {}, execute() {},
|
||||||
|
},
|
||||||
|
invalidate() {
|
||||||
|
invalidations += 1;
|
||||||
|
invalidationObserver();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
const connection = new ConnectionFixture();
|
const connection = new ConnectionFixture();
|
||||||
const handlers = new PlatformHandlers({ store, gameSession, session, connection });
|
const handlers = new PlatformHandlers({ store, gameSession, session, connection });
|
||||||
const router = new Router({ session, platform: handlers, gameSession });
|
const router = new Router({ session, platform: handlers, gameSession });
|
||||||
@@ -71,11 +105,169 @@ function makeHandlers() {
|
|||||||
handlers,
|
handlers,
|
||||||
router,
|
router,
|
||||||
getDisposals: () => disposals,
|
getDisposals: () => disposals,
|
||||||
|
getInvalidations: () => invalidations,
|
||||||
observeEvent(observer: typeof eventObserver) { eventObserver = observer; },
|
observeEvent(observer: typeof eventObserver) { eventObserver = observer; },
|
||||||
observeDisposal(observer: () => void) { disposalObserver = observer; },
|
observeDisposal(observer: () => void) { disposalObserver = observer; },
|
||||||
|
observeInvalidation(observer: () => void) { invalidationObserver = observer; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeReentrantRouterFixture(log: string[]) {
|
||||||
|
const store = new PlatformStore();
|
||||||
|
let nextModuleId = 0;
|
||||||
|
const restores: Array<{ moduleId: number; deskinfo: unknown }> = [];
|
||||||
|
const entry: GameEntry = {
|
||||||
|
key: 'reentrant-fixture',
|
||||||
|
gameId: 41,
|
||||||
|
route: 'fixture-game',
|
||||||
|
resolveSeatCount: () => 4,
|
||||||
|
createModule: (): GameModule => {
|
||||||
|
const moduleId = ++nextModuleId;
|
||||||
|
let host: GameHost | undefined;
|
||||||
|
return {
|
||||||
|
attach(attachedHost) {
|
||||||
|
host = attachedHost;
|
||||||
|
log.push(`attach:${moduleId}:${attachedHost.getSnapshot().room.roomcode}`);
|
||||||
|
},
|
||||||
|
handlePlatformEvent(event) {
|
||||||
|
if (!host) throw new Error('fixture module has no host');
|
||||||
|
const snapshot = host.getSnapshot();
|
||||||
|
if (event.type === 'room.entered') {
|
||||||
|
log.push(`event:${moduleId}:room.entered:${snapshot.room.roomcode}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type === 'room.player-offline' || event.type === 'room.player-online') {
|
||||||
|
const online = snapshot.seats.find((seat) => seat.seat === event.seat)?.online;
|
||||||
|
log.push(`event:${moduleId}:${event.type}:${String(online)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleGameMessage() {},
|
||||||
|
restore(deskinfo) {
|
||||||
|
restores.push({ moduleId, deskinfo });
|
||||||
|
log.push(`restore:${moduleId}`);
|
||||||
|
},
|
||||||
|
dispose() { log.push(`dispose:${moduleId}`); },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const gameSession = new GameSessionHost(entry);
|
||||||
|
nextModuleId = 0;
|
||||||
|
log.length = 0;
|
||||||
|
const session = new RuntimeSession({
|
||||||
|
store,
|
||||||
|
gameSession,
|
||||||
|
createGameHostLease: () => ({
|
||||||
|
host: {
|
||||||
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
||||||
|
getSnapshot: () => selectGameSnapshot(store.getState()),
|
||||||
|
subscribe: () => () => {},
|
||||||
|
sendGameMessage() {},
|
||||||
|
execute() {},
|
||||||
|
},
|
||||||
|
invalidate() {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const connection = new ConnectionFixture();
|
||||||
|
const handlers = new PlatformHandlers({ store, gameSession, session, connection });
|
||||||
|
const router = new Router({ session, platform: handlers, gameSession });
|
||||||
|
return { store, session, gameSession, router, restores };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Router drains reentrant room entries FIFO so each module receives its own committed room and deskinfo', () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const setup = makeReentrantRouterFixture(log);
|
||||||
|
setup.router.dispatch({
|
||||||
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
|
||||||
|
});
|
||||||
|
const roomA = fixture('self-join-room.json');
|
||||||
|
const roomB = fixture('self-join-room.json');
|
||||||
|
roomA.roomcode = 'room-A';
|
||||||
|
roomB.roomcode = 'room-B';
|
||||||
|
roomA.deskinfo = { round: 'A' };
|
||||||
|
roomB.deskinfo = { round: 'B' };
|
||||||
|
let queuedResult: ReturnType<Router['dispatch']> | undefined;
|
||||||
|
|
||||||
|
setup.store.subscribe((state) => {
|
||||||
|
if (state.room.kind === 'inside' && state.room.roomcode === 'room-A') {
|
||||||
|
log.push('store:A');
|
||||||
|
queuedResult = setup.router.dispatch({
|
||||||
|
route: 'agent', rpc: 'self_join_room', data: roomB,
|
||||||
|
});
|
||||||
|
} else if (state.room.kind === 'inside' && state.room.roomcode === 'room-B') {
|
||||||
|
log.push('store:B');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = setup.router.dispatch({
|
||||||
|
route: 'agent', rpc: 'self_join_room', data: roomA,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, 'handled-platform');
|
||||||
|
assert.equal(queuedResult, 'handled-platform');
|
||||||
|
assert.deepEqual(log, [
|
||||||
|
'store:A',
|
||||||
|
'attach:1:room-A',
|
||||||
|
'event:1:room.entered:room-A',
|
||||||
|
'restore:1',
|
||||||
|
'store:B',
|
||||||
|
'dispose:1',
|
||||||
|
'attach:2:room-B',
|
||||||
|
'event:2:room.entered:room-B',
|
||||||
|
'restore:2',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(setup.restores, [
|
||||||
|
{ moduleId: 1, deskinfo: roomA.deskinfo },
|
||||||
|
{ moduleId: 2, deskinfo: roomB.deskinfo },
|
||||||
|
]);
|
||||||
|
assert.equal(setup.restores[0]?.deskinfo, roomA.deskinfo);
|
||||||
|
assert.equal(setup.restores[1]?.deskinfo, roomB.deskinfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Router finishes an offline state-first event before draining a reentrant online message', () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const setup = makeReentrantRouterFixture(log);
|
||||||
|
setup.router.dispatch({
|
||||||
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
|
||||||
|
});
|
||||||
|
setup.router.dispatch({
|
||||||
|
route: 'room',
|
||||||
|
rpc: 'other_join_room',
|
||||||
|
data: {
|
||||||
|
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b', sex: 1,
|
||||||
|
ip: '127.0.0.2', onstate: 0, bean: 90, isprepare: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log.length = 0;
|
||||||
|
let queuedResult: ReturnType<Router['dispatch']> | undefined;
|
||||||
|
|
||||||
|
setup.store.subscribe((state, previous) => {
|
||||||
|
if (state.room.kind !== 'inside' || previous.room.kind !== 'inside') return;
|
||||||
|
const wasOffline = previous.room.offlineSeats.includes(2);
|
||||||
|
const isOffline = state.room.offlineSeats.includes(2);
|
||||||
|
if (!wasOffline && isOffline) {
|
||||||
|
log.push('store:offline');
|
||||||
|
queuedResult = setup.router.dispatch({
|
||||||
|
route: 'room', rpc: 'other_online', data: { seat: 2, ip: '127.0.0.3' },
|
||||||
|
});
|
||||||
|
} else if (wasOffline && !isOffline) {
|
||||||
|
log.push('store:online');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = setup.router.dispatch({
|
||||||
|
route: 'room', rpc: 'other_offline', data: { seat: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, 'handled-platform');
|
||||||
|
assert.equal(queuedResult, 'handled-platform');
|
||||||
|
assert.deepEqual(log, [
|
||||||
|
'store:offline',
|
||||||
|
'event:1:room.player-offline:false',
|
||||||
|
'store:online',
|
||||||
|
'event:1:room.player-online:true',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('PlatformHandlers commits every room event before one typed game event reads the snapshot', () => {
|
test('PlatformHandlers commits every room event before one typed game event reads the snapshot', () => {
|
||||||
const setup = makeHandlers();
|
const setup = makeHandlers();
|
||||||
setup.handlers.dispatch({
|
setup.handlers.dispatch({
|
||||||
@@ -172,6 +364,41 @@ test('self_exit_room commits the outside snapshot before disposing the game sess
|
|||||||
assert.equal(setup.gameSession.state, 'idle');
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('self_exit_room accepts omitted seat, validates a present seat, and is idempotent outside', () => {
|
||||||
|
const setup = makeHandlers();
|
||||||
|
setup.router.dispatch({
|
||||||
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
|
||||||
|
});
|
||||||
|
const inside = setup.store.getState();
|
||||||
|
const disposalsBefore = setup.getDisposals();
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { seat: 0 } }),
|
||||||
|
/self_exit_room.*seat.*expected 1.*received 0/i,
|
||||||
|
);
|
||||||
|
assert.equal(setup.store.getState(), inside);
|
||||||
|
assert.equal(setup.gameSession.state, 'active');
|
||||||
|
assert.equal(setup.getDisposals(), disposalsBefore);
|
||||||
|
|
||||||
|
let notifications = 0;
|
||||||
|
setup.store.subscribe(() => { notifications += 1; });
|
||||||
|
assert.equal(
|
||||||
|
setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { reason: 'done' } }),
|
||||||
|
'handled-platform',
|
||||||
|
);
|
||||||
|
assert.equal(setup.store.getState().room.kind, 'outside');
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
assert.equal(notifications, 1);
|
||||||
|
assert.equal(setup.getDisposals(), disposalsBefore + 1);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { reason: 'duplicate' } }),
|
||||||
|
'handled-platform',
|
||||||
|
);
|
||||||
|
assert.equal(notifications, 1);
|
||||||
|
assert.equal(setup.getDisposals(), disposalsBefore + 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('PlatformHandlers preserves complete switch payloads and builds the exact resend envelopes', () => {
|
test('PlatformHandlers preserves complete switch payloads and builds the exact resend envelopes', () => {
|
||||||
const setup = makeHandlers();
|
const setup = makeHandlers();
|
||||||
const roomData = { roomserver: '10.0.0.2:3088', epoch: 7 };
|
const roomData = { roomserver: '10.0.0.2:3088', epoch: 7 };
|
||||||
@@ -227,6 +454,142 @@ test('PlatformHandlers rejects unknown, route-mismatched, missing, and invalid s
|
|||||||
assert.deepEqual(setup.connection.switchCalls, []);
|
assert.deepEqual(setup.connection.switchCalls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('unknown and every route-mismatched platform rpc leave all observable state unchanged', () => {
|
||||||
|
const setup = makeHandlers();
|
||||||
|
setup.router.dispatch({
|
||||||
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
|
||||||
|
});
|
||||||
|
const before = setup.store.getState();
|
||||||
|
let notifications = 0;
|
||||||
|
let gameEvents = 0;
|
||||||
|
setup.store.subscribe(() => { notifications += 1; });
|
||||||
|
setup.observeEvent(() => { gameEvents += 1; });
|
||||||
|
const rpcsByRoute = [
|
||||||
|
['player_login', 'agent'],
|
||||||
|
['self_join_room', 'agent'],
|
||||||
|
['player_prepare', 'room'],
|
||||||
|
['self_exit_room', 'room'],
|
||||||
|
['other_join_room', 'room'],
|
||||||
|
['other_exit_room', 'room'],
|
||||||
|
['other_offline', 'room'],
|
||||||
|
['other_online', 'room'],
|
||||||
|
['connect_roomserver', 'room'],
|
||||||
|
['connect_agentserver', 'agent'],
|
||||||
|
['kick_server', 'agent'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const route of ['platform', 'agent', 'room'] as const) {
|
||||||
|
assert.throws(
|
||||||
|
() => setup.router.dispatch({ route, rpc: 'unknown_rpc', data: {} }),
|
||||||
|
/unsupported.*unknown_rpc/i,
|
||||||
|
`${route}:unknown_rpc`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [rpc, expectedRoute] of rpcsByRoute) {
|
||||||
|
for (const route of ['platform', 'agent', 'room'] as const) {
|
||||||
|
if (route === expectedRoute) continue;
|
||||||
|
assert.throws(
|
||||||
|
() => setup.router.dispatch({ route, rpc, data: {} }),
|
||||||
|
/route mismatch/i,
|
||||||
|
`${route}:${rpc}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(setup.store.getState(), before);
|
||||||
|
assert.equal(setup.gameSession.state, 'active');
|
||||||
|
assert.equal(setup.session.state, 'operational');
|
||||||
|
assert.equal(notifications, 0);
|
||||||
|
assert.equal(gameEvents, 0);
|
||||||
|
assert.equal(setup.getDisposals(), 0);
|
||||||
|
assert.equal(setup.getInvalidations(), 0);
|
||||||
|
assert.deepEqual(setup.connection.switchCalls, []);
|
||||||
|
assert.deepEqual(setup.connection.stopCalls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PlatformHandlers validates every switch URL before closing the active game', () => {
|
||||||
|
const invalidTargets = [
|
||||||
|
'http://invalid.example',
|
||||||
|
'ws://user@example.com',
|
||||||
|
'ws://example.com/#fragment',
|
||||||
|
'ws:///path',
|
||||||
|
'ws://example.com:99999',
|
||||||
|
'ws://example.com:abc',
|
||||||
|
'ws://',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const roomserver of invalidTargets) {
|
||||||
|
const setup = makeHandlers();
|
||||||
|
setup.router.dispatch({
|
||||||
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
|
||||||
|
});
|
||||||
|
const before = setup.store.getState();
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => setup.router.dispatch({
|
||||||
|
route: 'room', rpc: 'connect_roomserver', data: { roomserver },
|
||||||
|
}),
|
||||||
|
/roomserver|WebSocket/i,
|
||||||
|
roomserver,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(setup.store.getState(), before, roomserver);
|
||||||
|
assert.equal(setup.gameSession.state, 'active', roomserver);
|
||||||
|
assert.equal(setup.session.state, 'operational', roomserver);
|
||||||
|
assert.equal(setup.getDisposals(), 0, roomserver);
|
||||||
|
assert.equal(setup.getInvalidations(), 0, roomserver);
|
||||||
|
assert.deepEqual(setup.connection.switchCalls, [], roomserver);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PlatformHandlers accepts case-insensitive WS/WSS schemes and preserves full payload identity', () => {
|
||||||
|
const setup = makeHandlers();
|
||||||
|
const roomData = { roomserver: 'WS://EXAMPLE.com:80/path?x=1', extra: { trace: 1 } };
|
||||||
|
const agentData = {
|
||||||
|
agentserver: 'WSS://secure.example:443/socket', opt: 'free_room', extra: { trace: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup.router.dispatch({ route: 'room', rpc: 'connect_roomserver', data: roomData });
|
||||||
|
setup.router.dispatch({ route: 'agent', rpc: 'connect_agentserver', data: agentData });
|
||||||
|
|
||||||
|
assert.equal(setup.connection.switchCalls[0]?.target, roomData.roomserver);
|
||||||
|
assert.equal(setup.connection.switchCalls[0]?.resend.data, roomData);
|
||||||
|
assert.equal(setup.connection.switchCalls[1]?.target, agentData.agentserver);
|
||||||
|
assert.equal(setup.connection.switchCalls[1]?.resend.data, agentData);
|
||||||
|
assert.equal(
|
||||||
|
(setup.connection.switchCalls[1]?.resend.data as typeof agentData).extra,
|
||||||
|
agentData.extra,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PlatformHandlers attempts a validated switch after cleanup errors and aggregates all failures', () => {
|
||||||
|
const setup = makeHandlers();
|
||||||
|
setup.router.dispatch({
|
||||||
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
|
||||||
|
});
|
||||||
|
const data = { roomserver: 'wss://switch.example/socket', epoch: 9 };
|
||||||
|
const disposeError = new Error('switch:dispose');
|
||||||
|
const invalidateError = new Error('switch:invalidate');
|
||||||
|
const switchError = new Error('switch:connection');
|
||||||
|
setup.observeDisposal(() => { throw disposeError; });
|
||||||
|
setup.observeInvalidation(() => { throw invalidateError; });
|
||||||
|
setup.connection.switchFailure = switchError;
|
||||||
|
|
||||||
|
const error = caughtError(() => setup.router.dispatch({
|
||||||
|
route: 'room', rpc: 'connect_roomserver', data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.ok(error instanceof RuntimeSessionFault);
|
||||||
|
assert.deepEqual(error.errors, [disposeError, invalidateError, switchError]);
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
assert.equal(setup.session.state, 'faulted');
|
||||||
|
assert.equal(setup.getDisposals(), 1);
|
||||||
|
assert.equal(setup.getInvalidations(), 1);
|
||||||
|
assert.equal(setup.connection.switchCalls.length, 1);
|
||||||
|
assert.equal(setup.connection.switchCalls[0]?.target, data.roomserver);
|
||||||
|
assert.equal(setup.connection.switchCalls[0]?.resend.data, data);
|
||||||
|
});
|
||||||
|
|
||||||
test('kick_server closes the game, marks the runtime session, and stops the connection once', () => {
|
test('kick_server closes the game, marks the runtime session, and stops the connection once', () => {
|
||||||
const setup = makeHandlers();
|
const setup = makeHandlers();
|
||||||
setup.handlers.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') });
|
setup.handlers.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') });
|
||||||
@@ -242,3 +605,64 @@ test('kick_server closes the game, marks the runtime session, and stops the conn
|
|||||||
assert.deepEqual(setup.connection.stopCalls, [kickData]);
|
assert.deepEqual(setup.connection.stopCalls, [kickData]);
|
||||||
assert.equal(setup.connection.stopCalls[0], kickData);
|
assert.equal(setup.connection.stopCalls[0], kickData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('kick_server latches before cleanup and makes a reentrant duplicate a no-op', () => {
|
||||||
|
const setup = makeHandlers();
|
||||||
|
setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') });
|
||||||
|
const kickData = { msg: 'same-reference' };
|
||||||
|
let reentrantResult: ReturnType<Router['dispatch']> | undefined;
|
||||||
|
|
||||||
|
setup.session.markLoginPending();
|
||||||
|
setup.observeDisposal(() => {
|
||||||
|
assert.equal(setup.session.kicked, true);
|
||||||
|
assert.equal(setup.session.loginPending, false);
|
||||||
|
reentrantResult = setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }),
|
||||||
|
'handled-platform',
|
||||||
|
);
|
||||||
|
assert.equal(reentrantResult, 'handled-platform');
|
||||||
|
assert.equal(setup.getDisposals(), 1);
|
||||||
|
assert.equal(setup.getInvalidations(), 1);
|
||||||
|
assert.equal(setup.connection.stopCalls.length, 1);
|
||||||
|
assert.equal(setup.connection.stopCalls[0], kickData);
|
||||||
|
assert.equal(setup.session.state, 'kicked');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kick_server attempts dispose, invalidate, mark, and stop once then aggregates failures', () => {
|
||||||
|
const setup = makeHandlers();
|
||||||
|
setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') });
|
||||||
|
const kickData = { msg: 'cleanup-failures' };
|
||||||
|
const disposeError = new Error('kick:dispose');
|
||||||
|
const invalidateError = new Error('kick:invalidate');
|
||||||
|
const stopError = new Error('kick:stop');
|
||||||
|
setup.observeDisposal(() => { throw disposeError; });
|
||||||
|
setup.observeInvalidation(() => { throw invalidateError; });
|
||||||
|
setup.connection.stopFailure = stopError;
|
||||||
|
setup.session.markLoginPending();
|
||||||
|
|
||||||
|
const error = caughtError(
|
||||||
|
() => setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(error instanceof RuntimeSessionFault);
|
||||||
|
assert.deepEqual(error.errors, [disposeError, invalidateError, stopError]);
|
||||||
|
assert.equal(setup.gameSession.state, 'idle');
|
||||||
|
assert.equal(setup.session.kicked, true);
|
||||||
|
assert.equal(setup.session.state, 'kicked');
|
||||||
|
assert.equal(setup.session.loginPending, false);
|
||||||
|
assert.equal(setup.getDisposals(), 1);
|
||||||
|
assert.equal(setup.getInvalidations(), 1);
|
||||||
|
assert.equal(setup.connection.stopCalls.length, 1);
|
||||||
|
assert.equal(setup.connection.stopCalls[0], kickData);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }),
|
||||||
|
'handled-platform',
|
||||||
|
);
|
||||||
|
assert.equal(setup.getDisposals(), 1);
|
||||||
|
assert.equal(setup.getInvalidations(), 1);
|
||||||
|
assert.equal(setup.connection.stopCalls.length, 1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
||||||
import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
||||||
import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts';
|
import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts';
|
||||||
|
import { FIRST_SLICE_RPC_ROUTE } from '../../YouleNexus/assets/framework/protocol/first-slice-routes.ts';
|
||||||
|
|
||||||
class LoginGateFixture {
|
class LoginGateFixture {
|
||||||
private pending = false;
|
private pending = false;
|
||||||
@@ -110,6 +111,41 @@ test('Router lets kick_server through the login gate but ignores every other rpc
|
|||||||
assert.deepEqual(calls, [kick]);
|
assert.deepEqual(calls, [kick]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Router login gate enforces the complete route by rpc cross matrix', () => {
|
||||||
|
const platformRoutes = ['platform', 'agent', 'room'] as const;
|
||||||
|
const routes = [...platformRoutes, 'fixture-game'] as const;
|
||||||
|
const rpcs = [...Object.keys(FIRST_SLICE_RPC_ROUTE), 'unknown_rpc'];
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
for (const rpc of rpcs) {
|
||||||
|
const gate = new LoginGateFixture();
|
||||||
|
const platformCalls: InboundMessage[] = [];
|
||||||
|
const gameCalls: InboundMessage[] = [];
|
||||||
|
const router = new Router({
|
||||||
|
session: gate,
|
||||||
|
platform: {
|
||||||
|
dispatch(message: InboundMessage) {
|
||||||
|
platformCalls.push(message);
|
||||||
|
return 'handled-platform' as const;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gameSession: makeGameSession(gameCalls),
|
||||||
|
});
|
||||||
|
const message: InboundMessage = { route, rpc, data: { marker: `${route}:${rpc}` } };
|
||||||
|
gate.markLoginPending();
|
||||||
|
|
||||||
|
const passes = route === 'agent' && (rpc === 'player_login' || rpc === 'kick_server');
|
||||||
|
assert.equal(
|
||||||
|
router.dispatch(message),
|
||||||
|
passes ? 'handled-platform' : 'ignored-login-gate',
|
||||||
|
`${route}:${rpc}`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(platformCalls, passes ? [message] : [], `${route}:${rpc}`);
|
||||||
|
assert.deepEqual(gameCalls, [], `${route}:${rpc}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('Router sends all platform routes to PlatformHandlers exactly once', () => {
|
test('Router sends all platform routes to PlatformHandlers exactly once', () => {
|
||||||
const gate = new LoginGateFixture();
|
const gate = new LoginGateFixture();
|
||||||
const calls: InboundMessage[] = [];
|
const calls: InboundMessage[] = [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
||||||
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
||||||
import ts from 'typescript';
|
import ts from 'typescript';
|
||||||
|
|
||||||
@@ -38,6 +38,137 @@ export function scanImportBoundaries(options) {
|
|||||||
return violations;
|
return violations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the first dependency path from one modern entry into a forbidden
|
||||||
|
* compatibility file. Reachable non-static dynamic imports fail closed.
|
||||||
|
*/
|
||||||
|
export function findCompatibilityDependency(options) {
|
||||||
|
const entry = canonicalPath(options.entry);
|
||||||
|
const traversalRoot = canonicalPath(options.traversalRoot);
|
||||||
|
const forbiddenFiles = new Set(options.forbiddenFiles.map((file) => pathKey(canonicalPath(file))));
|
||||||
|
const compilerOptions = readCompilerOptions(options.tsconfigPath);
|
||||||
|
const moduleCache = ts.createModuleResolutionCache(
|
||||||
|
options.tsconfigPath ? dirname(resolve(options.tsconfigPath)) : traversalRoot,
|
||||||
|
(file) => pathKey(canonicalPath(file)),
|
||||||
|
compilerOptions,
|
||||||
|
);
|
||||||
|
const visited = new Set();
|
||||||
|
|
||||||
|
const visit = (file, path) => {
|
||||||
|
const canonicalFile = canonicalPath(file);
|
||||||
|
const key = pathKey(canonicalFile);
|
||||||
|
const nextPath = [...path, canonicalFile];
|
||||||
|
if (forbiddenFiles.has(key)) {
|
||||||
|
return { kind: 'forbidden', path: nextPath, file: canonicalFile };
|
||||||
|
}
|
||||||
|
if (visited.has(key) || !isInsideOrEqual(canonicalFile, traversalRoot)) return null;
|
||||||
|
visited.add(key);
|
||||||
|
|
||||||
|
const source = readFileSync(canonicalFile, 'utf8');
|
||||||
|
for (const edge of extractDependencyEdges(source, canonicalFile)) {
|
||||||
|
if (edge.kind === 'dynamic') {
|
||||||
|
return { kind: 'dynamic', path: nextPath, file: canonicalFile };
|
||||||
|
}
|
||||||
|
const imported = resolveDependency(edge.specifier, canonicalFile, compilerOptions, moduleCache);
|
||||||
|
if (imported === null) continue;
|
||||||
|
const found = visit(imported, nextPath);
|
||||||
|
if (found !== null) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return visit(entry, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCompilerOptions(tsconfigPath) {
|
||||||
|
if (!tsconfigPath) {
|
||||||
|
return {
|
||||||
|
allowImportingTsExtensions: true,
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||||
|
target: ts.ScriptTarget.ES2020,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFile = resolve(tsconfigPath);
|
||||||
|
const loaded = ts.readConfigFile(configFile, ts.sys.readFile);
|
||||||
|
if (loaded.error) throw new Error(formatTsDiagnostics([loaded.error]));
|
||||||
|
const parsed = ts.parseJsonConfigFileContent(
|
||||||
|
loaded.config,
|
||||||
|
ts.sys,
|
||||||
|
dirname(configFile),
|
||||||
|
undefined,
|
||||||
|
configFile,
|
||||||
|
);
|
||||||
|
if (parsed.errors.length > 0) throw new Error(formatTsDiagnostics(parsed.errors));
|
||||||
|
return parsed.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTsDiagnostics(diagnostics) {
|
||||||
|
return ts.formatDiagnostics(diagnostics, {
|
||||||
|
getCanonicalFileName: (file) => file,
|
||||||
|
getCurrentDirectory: () => process.cwd(),
|
||||||
|
getNewLine: () => '\n',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDependencyEdges(source, file) {
|
||||||
|
const edges = [];
|
||||||
|
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
|
||||||
|
const visit = (node) => {
|
||||||
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node))
|
||||||
|
&& node.moduleSpecifier
|
||||||
|
&& ts.isStringLiteralLike(node.moduleSpecifier)) {
|
||||||
|
edges.push({ kind: 'static', specifier: node.moduleSpecifier.text });
|
||||||
|
} else if (ts.isImportTypeNode(node)
|
||||||
|
&& ts.isLiteralTypeNode(node.argument)
|
||||||
|
&& ts.isStringLiteralLike(node.argument.literal)) {
|
||||||
|
edges.push({ kind: 'static', specifier: node.argument.literal.text });
|
||||||
|
} else if (ts.isCallExpression(node)
|
||||||
|
&& node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||||
|
const argument = node.arguments[0];
|
||||||
|
if (node.arguments.length === 1 && argument && ts.isStringLiteralLike(argument)) {
|
||||||
|
edges.push({ kind: 'static', specifier: argument.text });
|
||||||
|
} else {
|
||||||
|
edges.push({ kind: 'dynamic' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
};
|
||||||
|
ts.forEachChild(sourceFile, visit);
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDependency(specifier, containingFile, compilerOptions, moduleCache) {
|
||||||
|
const result = ts.resolveModuleName(
|
||||||
|
specifier,
|
||||||
|
containingFile,
|
||||||
|
compilerOptions,
|
||||||
|
ts.sys,
|
||||||
|
moduleCache,
|
||||||
|
);
|
||||||
|
return result.resolvedModule ? canonicalPath(result.resolvedModule.resolvedFileName) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalPath(file) {
|
||||||
|
const absolute = resolve(file);
|
||||||
|
try {
|
||||||
|
return realpathSync.native(absolute);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') return absolute;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathKey(file) {
|
||||||
|
return process.platform === 'win32' ? file.toLowerCase() : file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideOrEqual(file, directory) {
|
||||||
|
if (pathKey(file) === pathKey(directory)) return true;
|
||||||
|
return isInside(file, directory);
|
||||||
|
}
|
||||||
|
|
||||||
function* typescriptFiles(directory) {
|
function* typescriptFiles(directory) {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user