fix(platform): isolate game host leases
This commit is contained in:
@@ -47,8 +47,8 @@ export class PlatformCommands {
|
|||||||
exitRoom(): void {
|
exitRoom(): void {
|
||||||
const state = this.options.store.getState();
|
const state = this.options.store.getState();
|
||||||
const room = this.requireInsideRoom(state);
|
const room = this.requireInsideRoom(state);
|
||||||
if (room.stage !== 0) {
|
if (room.stage !== 0 && room.infinite !== 1) {
|
||||||
throw new UnsupportedGameHostCommandError('room.exit requires stage=0');
|
throw new UnsupportedGameHostCommandError('room.exit requires stage=0 unless infinite=1');
|
||||||
}
|
}
|
||||||
this.options.send.send(buildExitRoomRequest(
|
this.options.send.send(buildExitRoomRequest(
|
||||||
this.options.runtimeConfig,
|
this.options.runtimeConfig,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
GameSeatMapper,
|
GameSeatMapper,
|
||||||
PlatformGameSnapshot,
|
PlatformGameSnapshot,
|
||||||
} from '../sdk/contracts/index.ts';
|
} from '../sdk/contracts/index.ts';
|
||||||
|
import { assertGameRoute } from '../sdk/runtime/game-session-host.ts';
|
||||||
import {
|
import {
|
||||||
PlatformCommands,
|
PlatformCommands,
|
||||||
UnsupportedGameHostCommandError,
|
UnsupportedGameHostCommandError,
|
||||||
@@ -16,8 +17,6 @@ import { PlatformStore } from './stores/platform-store.ts';
|
|||||||
import { selectGameSnapshot } from './stores/selectors.ts';
|
import { selectGameSnapshot } from './stores/selectors.ts';
|
||||||
import type { PlatformState } from './stores/platform-types.ts';
|
import type { PlatformState } from './stores/platform-types.ts';
|
||||||
|
|
||||||
const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']);
|
|
||||||
|
|
||||||
export class GameSessionDisposedError extends Error {
|
export class GameSessionDisposedError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('GameHost session lease is disposed');
|
super('GameHost session lease is disposed');
|
||||||
@@ -47,6 +46,13 @@ type SubscriptionReleaseResult =
|
|||||||
| { readonly threw: false }
|
| { readonly threw: false }
|
||||||
| { readonly threw: true; readonly error: unknown };
|
| { readonly threw: true; readonly error: unknown };
|
||||||
|
|
||||||
|
interface BoundRoomGeneration {
|
||||||
|
readonly roomcode: string;
|
||||||
|
readonly roomtype: readonly unknown[];
|
||||||
|
readonly selfSeat: number;
|
||||||
|
readonly seatCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
class GameHostInvalidationError extends Error {
|
class GameHostInvalidationError extends Error {
|
||||||
readonly errors: readonly unknown[];
|
readonly errors: readonly unknown[];
|
||||||
|
|
||||||
@@ -64,11 +70,14 @@ class SessionBoundGameHost {
|
|||||||
private commands: PlatformCommands | null;
|
private commands: PlatformCommands | null;
|
||||||
private sendPort: OutboundEnvelopeSendPort | null;
|
private sendPort: OutboundEnvelopeSendPort | null;
|
||||||
private route: string | null;
|
private route: string | null;
|
||||||
|
private roomGeneration: BoundRoomGeneration | null;
|
||||||
private readonly subscriptions = new Set<SubscriptionRecord>();
|
private readonly subscriptions = new Set<SubscriptionRecord>();
|
||||||
|
private invalidationErrors: unknown[] | null = null;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
|
||||||
constructor(options: CreateGameHostAdapterOptions) {
|
constructor(options: CreateGameHostAdapterOptions) {
|
||||||
const route = requireGameRoute(options.gameEntry.route);
|
const route = options.gameEntry.route;
|
||||||
|
assertGameRoute(route);
|
||||||
const state = options.store.getState();
|
const state = options.store.getState();
|
||||||
if (state.room.kind !== 'inside') {
|
if (state.room.kind !== 'inside') {
|
||||||
throw new Error('GameHost requires player to be inside a room');
|
throw new Error('GameHost requires player to be inside a room');
|
||||||
@@ -81,15 +90,21 @@ class SessionBoundGameHost {
|
|||||||
this.commands = options.commands;
|
this.commands = options.commands;
|
||||||
this.sendPort = options.send;
|
this.sendPort = options.send;
|
||||||
this.route = route;
|
this.route = route;
|
||||||
|
this.roomGeneration = {
|
||||||
|
roomcode: state.room.roomcode,
|
||||||
|
roomtype: state.room.roomtype,
|
||||||
|
selfSeat,
|
||||||
|
seatCount,
|
||||||
|
};
|
||||||
|
|
||||||
const seat: GameSeatMapper = Object.freeze({
|
const seat: GameSeatMapper = Object.freeze({
|
||||||
toView: (serverSeat: number): number => {
|
toView: (serverSeat: number): number => {
|
||||||
this.requireActive();
|
this.requireCurrentRoom();
|
||||||
const validated = requireSeat(serverSeat, seatCount, 'server seat');
|
const validated = requireSeat(serverSeat, seatCount, 'server seat');
|
||||||
return (validated - selfSeat + seatCount) % seatCount;
|
return (validated - selfSeat + seatCount) % seatCount;
|
||||||
},
|
},
|
||||||
toServer: (viewSeat: number): number => {
|
toServer: (viewSeat: number): number => {
|
||||||
this.requireActive();
|
this.requireCurrentRoom();
|
||||||
const validated = requireSeat(viewSeat, seatCount, 'view seat');
|
const validated = requireSeat(viewSeat, seatCount, 'view seat');
|
||||||
return (validated + selfSeat) % seatCount;
|
return (validated + selfSeat) % seatCount;
|
||||||
},
|
},
|
||||||
@@ -116,18 +131,24 @@ class SessionBoundGameHost {
|
|||||||
this.commands = null;
|
this.commands = null;
|
||||||
this.sendPort = null;
|
this.sendPort = null;
|
||||||
this.route = null;
|
this.route = null;
|
||||||
|
this.roomGeneration = null;
|
||||||
|
|
||||||
const errors: unknown[] = [];
|
const errors: unknown[] = [];
|
||||||
|
this.invalidationErrors = errors;
|
||||||
|
try {
|
||||||
for (const record of subscriptions) {
|
for (const record of subscriptions) {
|
||||||
const result = this.releaseSubscription(record);
|
const result = this.releaseSubscription(record);
|
||||||
if (result.threw) errors.push(result.error);
|
if (result.threw) errors.push(result.error);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
this.invalidationErrors = null;
|
||||||
|
}
|
||||||
if (errors.length === 1) throw errors[0];
|
if (errors.length === 1) throw errors[0];
|
||||||
if (errors.length > 1) throw new GameHostInvalidationError(errors);
|
if (errors.length > 1) throw new GameHostInvalidationError(errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSnapshot(): PlatformGameSnapshot {
|
private getSnapshot(): PlatformGameSnapshot {
|
||||||
return selectGameSnapshot(this.requireStore().getState());
|
return selectGameSnapshot(this.requireCurrentRoom());
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void {
|
private subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void {
|
||||||
@@ -136,10 +157,10 @@ class SessionBoundGameHost {
|
|||||||
throw new TypeError('GameHost subscribe listener must be a function');
|
throw new TypeError('GameHost subscribe listener must be a function');
|
||||||
}
|
}
|
||||||
const store = this.requireStore();
|
const store = this.requireStore();
|
||||||
let current = selectGameSnapshot(store.getState());
|
let current = selectGameSnapshot(this.requireCurrentRoom());
|
||||||
const record: SubscriptionRecord = { active: true, unsubscribeStore: null };
|
const record: SubscriptionRecord = { active: true, unsubscribeStore: null };
|
||||||
const bridge = (state: PlatformState): void => {
|
const bridge = (state: PlatformState): void => {
|
||||||
if (this.disposed || !record.active || state.room.kind !== 'inside') return;
|
if (this.disposed || !record.active || !this.isCurrentRoom(state)) return;
|
||||||
const next = selectGameSnapshot(state);
|
const next = selectGameSnapshot(state);
|
||||||
if (samePublicSnapshot(current, next)) return;
|
if (samePublicSnapshot(current, next)) return;
|
||||||
current = next;
|
current = next;
|
||||||
@@ -157,7 +178,10 @@ class SessionBoundGameHost {
|
|||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
try {
|
try {
|
||||||
this.releaseSubscription(record);
|
const result = this.releaseSubscription(record);
|
||||||
|
if (result.threw && this.invalidationErrors !== null) {
|
||||||
|
this.invalidationErrors.push(result.error);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// The public unsubscribe contract remains non-throwing, including after invalidation.
|
// The public unsubscribe contract remains non-throwing, including after invalidation.
|
||||||
}
|
}
|
||||||
@@ -165,7 +189,7 @@ class SessionBoundGameHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sendGameMessage(rpc: string, data: unknown): void {
|
private sendGameMessage(rpc: string, data: unknown): void {
|
||||||
this.requireActive();
|
this.requireCurrentRoom();
|
||||||
if (typeof rpc !== 'string' || rpc.trim() === '') {
|
if (typeof rpc !== 'string' || rpc.trim() === '') {
|
||||||
throw new TypeError('GameHost rpc must be a non-empty string');
|
throw new TypeError('GameHost rpc must be a non-empty string');
|
||||||
}
|
}
|
||||||
@@ -177,7 +201,7 @@ class SessionBoundGameHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private execute(command: GameHostCommand): void {
|
private execute(command: GameHostCommand): void {
|
||||||
this.requireActive();
|
this.requireCurrentRoom();
|
||||||
const commands = this.commands;
|
const commands = this.commands;
|
||||||
if (commands === null) throw new GameSessionDisposedError();
|
if (commands === null) throw new GameSessionDisposedError();
|
||||||
if (command !== null && typeof command === 'object') {
|
if (command !== null && typeof command === 'object') {
|
||||||
@@ -200,6 +224,22 @@ class SessionBoundGameHost {
|
|||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private requireCurrentRoom(): PlatformState {
|
||||||
|
const state = this.requireStore().getState();
|
||||||
|
if (!this.isCurrentRoom(state)) throw new GameSessionDisposedError();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCurrentRoom(state: PlatformState): boolean {
|
||||||
|
const generation = this.roomGeneration;
|
||||||
|
return generation !== null
|
||||||
|
&& state.room.kind === 'inside'
|
||||||
|
&& state.room.roomcode === generation.roomcode
|
||||||
|
&& state.room.roomtype === generation.roomtype
|
||||||
|
&& state.room.selfSeat === generation.selfSeat
|
||||||
|
&& state.room.seatPlayerIds.length === generation.seatCount;
|
||||||
|
}
|
||||||
|
|
||||||
private requireActive(): void {
|
private requireActive(): void {
|
||||||
if (this.disposed) throw new GameSessionDisposedError();
|
if (this.disposed) throw new GameSessionDisposedError();
|
||||||
}
|
}
|
||||||
@@ -228,16 +268,6 @@ export function createGameHostAdapter(options: CreateGameHostAdapterOptions): Ga
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireGameRoute(route: unknown): string {
|
|
||||||
if (typeof route !== 'string' || route.trim() === '') {
|
|
||||||
throw new TypeError('GameEntry route must be a non-empty string');
|
|
||||||
}
|
|
||||||
if (RESERVED_ROUTES.has(route)) {
|
|
||||||
throw new Error(`GameEntry reserved route: ${route}`);
|
|
||||||
}
|
|
||||||
return route;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireSeatCount(seatCount: unknown, roomSeatCount: number): asserts seatCount is number {
|
function requireSeatCount(seatCount: unknown, roomSeatCount: number): asserts seatCount is number {
|
||||||
if (!Number.isInteger(seatCount) || (seatCount as number) <= 0) {
|
if (!Number.isInteger(seatCount) || (seatCount as number) <= 0) {
|
||||||
throw new RangeError(`GameEntry seat count must be a positive integer: ${String(seatCount)}`);
|
throw new RangeError(`GameEntry seat count must be a positive integer: ${String(seatCount)}`);
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import type {
|
|||||||
GameServerMessage,
|
GameServerMessage,
|
||||||
PlatformToGameEvent,
|
PlatformToGameEvent,
|
||||||
} from '../contracts/index.ts';
|
} from '../contracts/index.ts';
|
||||||
|
import { Route } from '../../core/constants.ts';
|
||||||
|
|
||||||
const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']);
|
const RESERVED_ROUTES = new Set<string>(Object.values(Route));
|
||||||
const GAME_SESSION_OPEN_FAULT_BRAND = Symbol.for(
|
const GAME_SESSION_OPEN_FAULT_BRAND = Symbol.for(
|
||||||
'youle.framework.sdk.GameSessionOpenFault',
|
'youle.framework.sdk.GameSessionOpenFault',
|
||||||
);
|
);
|
||||||
@@ -81,10 +82,7 @@ export function isGameSessionOpenFault(value: unknown): value is GameSessionOpen
|
|||||||
export function assertGameEntry(entry: GameEntry): void {
|
export function assertGameEntry(entry: GameEntry): void {
|
||||||
assertNonEmptyText(entry.key, 'key');
|
assertNonEmptyText(entry.key, 'key');
|
||||||
assertGameId(entry.gameId);
|
assertGameId(entry.gameId);
|
||||||
assertNonEmptyText(entry.route, 'route');
|
assertGameRoute(entry.route);
|
||||||
if (RESERVED_ROUTES.has(entry.route)) {
|
|
||||||
throw new Error(`GameEntry reserved route: ${entry.route}`);
|
|
||||||
}
|
|
||||||
if (typeof entry.resolveSeatCount !== 'function') {
|
if (typeof entry.resolveSeatCount !== 'function') {
|
||||||
throw new Error('GameEntry resolveSeatCount must be a function');
|
throw new Error('GameEntry resolveSeatCount must be a function');
|
||||||
}
|
}
|
||||||
@@ -103,6 +101,14 @@ export function assertGameEntry(entry: GameEntry): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The single GameEntry route admission check shared by runtime adapters. */
|
||||||
|
export function assertGameRoute(route: unknown): asserts route is string {
|
||||||
|
assertNonEmptyText(route, 'route');
|
||||||
|
if (RESERVED_ROUTES.has(route)) {
|
||||||
|
throw new Error(`GameEntry reserved route: ${route}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Owns one isolated game module for one room at a time. */
|
/** Owns one isolated game module for one room at a time. */
|
||||||
export class GameSessionHost {
|
export class GameSessionHost {
|
||||||
private module: GameModule | undefined;
|
private module: GameModule | undefined;
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ function fixture(name: string): { data: Record<string, unknown> } {
|
|||||||
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStore(location: 'outside' | 'inside', stage = 0): PlatformStore {
|
function makeStore(location: 'outside' | 'inside', stage = 0, infinite = 0): PlatformStore {
|
||||||
const store = new PlatformStore();
|
const store = new PlatformStore();
|
||||||
const source = fixture(location === 'inside' ? 'player-login-room.json' : 'player-login-success.json');
|
const source = fixture(location === 'inside' ? 'player-login-room.json' : 'player-login-success.json');
|
||||||
if (location === 'inside') source.data.isbattle = stage;
|
if (location === 'inside') {
|
||||||
|
source.data.isbattle = stage;
|
||||||
|
source.data.infinite = infinite;
|
||||||
|
}
|
||||||
store.applyLoginSuccess(parseLoginResponse(source.data));
|
store.applyLoginSuccess(parseLoginResponse(source.data));
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
@@ -127,6 +130,24 @@ test('joinRoom uses authenticated Store identity and requires every non-optional
|
|||||||
assert.equal(sent.length, 1);
|
assert.equal(sent.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('exitRoom sends self_exit_room while an infinite room is in battle', () => {
|
||||||
|
const { commands, sent } = makeCommands(makeStore('inside', 1, 1));
|
||||||
|
|
||||||
|
commands.exitRoom();
|
||||||
|
|
||||||
|
assert.deepEqual(sent, [{
|
||||||
|
app: 'youle',
|
||||||
|
route: 'room',
|
||||||
|
rpc: 'self_exit_room',
|
||||||
|
data: {
|
||||||
|
agentid: 'agent-from-config',
|
||||||
|
playerid: 430511,
|
||||||
|
gameid: 'game-from-config',
|
||||||
|
roomcode: '100001',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
test('command preconditions reject missing authentication, wrong room location, and battle exit without sending', () => {
|
test('command preconditions reject missing authentication, wrong room location, and battle exit without sending', () => {
|
||||||
const unauthenticated = makeCommands(new PlatformStore());
|
const unauthenticated = makeCommands(new PlatformStore());
|
||||||
assert.throws(
|
assert.throws(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GameSessionDisposedError,
|
GameSessionDisposedError,
|
||||||
} from '../../YouleNexus/assets/framework/platform/game-host-adapter.ts';
|
} from '../../YouleNexus/assets/framework/platform/game-host-adapter.ts';
|
||||||
import { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.ts';
|
import { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.ts';
|
||||||
|
import { RuntimeSession } 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 type {
|
import type {
|
||||||
PlatformState,
|
PlatformState,
|
||||||
@@ -19,8 +20,13 @@ import {
|
|||||||
} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
||||||
import type {
|
import type {
|
||||||
GameEntry,
|
GameEntry,
|
||||||
|
GameHost,
|
||||||
GameHostCommand,
|
GameHostCommand,
|
||||||
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
||||||
|
import {
|
||||||
|
assertGameRoute,
|
||||||
|
GameSessionHost,
|
||||||
|
} from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
|
||||||
|
|
||||||
const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||||
mode: 'release',
|
mode: 'release',
|
||||||
@@ -110,6 +116,22 @@ test('adapter rejects empty and reserved GameEntry routes before exposing a Host
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Task 1 route validator is the shared authority for game-route admission', () => {
|
||||||
|
assert.doesNotThrow(() => assertGameRoute('fixture-route'));
|
||||||
|
assert.throws(() => assertGameRoute(''), /route.*non-empty/i);
|
||||||
|
for (const route of ['platform', 'agent', 'room']) {
|
||||||
|
assert.throws(() => assertGameRoute(route), /reserved.*route/i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatorSource = readFileSync(new URL(
|
||||||
|
'../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts',
|
||||||
|
import.meta.url,
|
||||||
|
), 'utf8');
|
||||||
|
assert.match(validatorSource, /import \{ Route \} from '\.\.\/\.\.\/core\/constants\.ts';/);
|
||||||
|
assert.match(validatorSource, /new Set<string>\(Object\.values\(Route\)\)/);
|
||||||
|
assert.doesNotMatch(validatorSource, /new Set\(\[['"]platform['"]/);
|
||||||
|
});
|
||||||
|
|
||||||
test('execute exposes only the GameHost command union and delegates semantic commands', () => {
|
test('execute exposes only the GameHost command union and delegates semantic commands', () => {
|
||||||
const { host, sent } = makeLease({ store: roomStore(4, 1, 0) });
|
const { host, sent } = makeLease({ store: roomStore(4, 1, 0) });
|
||||||
|
|
||||||
@@ -179,6 +201,74 @@ test('getSnapshot exposes only recursively frozen SDK DTOs and the canonical roo
|
|||||||
assert.equal('state' in snapshot, false);
|
assert.equal('state' in snapshot, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('RuntimeSession room replacement keeps the old lease isolated until release and exposes only the fresh lease', () => {
|
||||||
|
const store = new PlatformStore();
|
||||||
|
const sent: OutboundEnvelope[] = [];
|
||||||
|
const send = { send: (envelope: OutboundEnvelope) => { sent.push(envelope); } };
|
||||||
|
const commands = new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send });
|
||||||
|
const attachedHosts: GameHost[] = [];
|
||||||
|
const staleDisposeResults: unknown[] = [];
|
||||||
|
const gameEntry: GameEntry = {
|
||||||
|
...entry(),
|
||||||
|
resolveSeatCount: (roomtype) => roomtype.length === 0 ? 4 : roomtype[0] as number,
|
||||||
|
createModule: () => {
|
||||||
|
let host: GameHost | null = null;
|
||||||
|
return {
|
||||||
|
attach(value) {
|
||||||
|
host = value;
|
||||||
|
attachedHosts.push(value);
|
||||||
|
},
|
||||||
|
handlePlatformEvent() {},
|
||||||
|
handleGameMessage() {},
|
||||||
|
restore() {},
|
||||||
|
dispose() {
|
||||||
|
if (host === null) return;
|
||||||
|
for (const call of [
|
||||||
|
() => host!.getSnapshot(),
|
||||||
|
() => host!.sendGameMessage('stale-play', { room: 'old' }),
|
||||||
|
() => host!.execute({ type: 'room.prepare' }),
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
staleDisposeResults.push(call());
|
||||||
|
} catch (error) {
|
||||||
|
staleDisposeResults.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const gameSession = new GameSessionHost(gameEntry);
|
||||||
|
const runtime = new RuntimeSession({
|
||||||
|
store,
|
||||||
|
gameSession,
|
||||||
|
createGameHostLease: () => createGameHostAdapter({ gameEntry, store, commands, send }),
|
||||||
|
});
|
||||||
|
const login = fixture('player-login-room.json');
|
||||||
|
login.data.roomtype = [4, { generation: 'A' }];
|
||||||
|
login.data.isbattle = 0;
|
||||||
|
runtime.handleLogin(login.data);
|
||||||
|
const oldHost = attachedHosts[0]!;
|
||||||
|
const oldRoomcodes: string[] = [];
|
||||||
|
oldHost.subscribe((snapshot) => { oldRoomcodes.push(snapshot.room.roomcode); });
|
||||||
|
|
||||||
|
const replacement = fixture('self-join-room.json');
|
||||||
|
replacement.data.roomcode = '200002';
|
||||||
|
replacement.data.roomtype = [4, { generation: 'B' }];
|
||||||
|
replacement.data.deskwar = 0;
|
||||||
|
delete replacement.data.deskinfo;
|
||||||
|
runtime.handleSelfJoin(replacement.data);
|
||||||
|
|
||||||
|
assert.deepEqual(oldRoomcodes, ['100001']);
|
||||||
|
assert.equal(staleDisposeResults.length, 3);
|
||||||
|
for (const result of staleDisposeResults) {
|
||||||
|
assert.ok(result instanceof GameSessionDisposedError);
|
||||||
|
}
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
assert.equal(attachedHosts.length, 2);
|
||||||
|
assert.equal(attachedHosts[1]!.getSnapshot().room.roomcode, '200002');
|
||||||
|
});
|
||||||
|
|
||||||
class ObservableStore extends PlatformStore {
|
class ObservableStore extends PlatformStore {
|
||||||
private readonly observed = new Set<(state: PlatformState, previous: PlatformState) => void>();
|
private readonly observed = new Set<(state: PlatformState, previous: PlatformState) => void>();
|
||||||
|
|
||||||
@@ -307,6 +397,63 @@ test('invalidate clears every subscription and reference before reporting all cl
|
|||||||
assert.equal(callbacks, 2);
|
assert.equal(callbacks, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
class ReentrantThrowingUnsubscribeStore extends PlatformStore {
|
||||||
|
readonly nestedFailure = { source: 'subscription-B' };
|
||||||
|
readonly cleanupCalls: string[] = [];
|
||||||
|
secondPublicUnsubscribe: (() => void) | null = null;
|
||||||
|
private subscriptionIndex = 0;
|
||||||
|
|
||||||
|
override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void {
|
||||||
|
const index = this.subscriptionIndex++;
|
||||||
|
const unsubscribeStore = super.subscribe(listener);
|
||||||
|
if (index === 0) {
|
||||||
|
return () => {
|
||||||
|
this.cleanupCalls.push('A');
|
||||||
|
this.secondPublicUnsubscribe!();
|
||||||
|
unsubscribeStore();
|
||||||
|
throw undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
this.cleanupCalls.push('B');
|
||||||
|
unsubscribeStore();
|
||||||
|
throw this.nestedFailure;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('invalidate collects a reentrant public-unsubscribe failure in occurrence order exactly once', () => {
|
||||||
|
const store = new ReentrantThrowingUnsubscribeStore();
|
||||||
|
const source = fixture('player-login-room.json');
|
||||||
|
source.data.isbattle = 0;
|
||||||
|
source.data.roomtype = [4];
|
||||||
|
store.applyLoginSuccess(parseLoginResponse(source.data));
|
||||||
|
const { host, invalidate } = makeLease({ store });
|
||||||
|
const firstPublicUnsubscribe = host.subscribe(() => {});
|
||||||
|
const secondPublicUnsubscribe = host.subscribe(() => {});
|
||||||
|
store.secondPublicUnsubscribe = secondPublicUnsubscribe;
|
||||||
|
|
||||||
|
let didThrow = false;
|
||||||
|
let cleanupError: unknown;
|
||||||
|
try {
|
||||||
|
invalidate();
|
||||||
|
} catch (error) {
|
||||||
|
didThrow = true;
|
||||||
|
cleanupError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(didThrow, true);
|
||||||
|
assert.deepEqual(store.cleanupCalls, ['A', 'B']);
|
||||||
|
const errors = (cleanupError as { errors?: readonly unknown[] })?.errors;
|
||||||
|
assert.equal(errors?.[0], store.nestedFailure);
|
||||||
|
assert.equal(errors?.[1], undefined);
|
||||||
|
assert.equal(errors?.length, 2);
|
||||||
|
assert.doesNotThrow(() => firstPublicUnsubscribe());
|
||||||
|
assert.doesNotThrow(() => secondPublicUnsubscribe());
|
||||||
|
assert.deepEqual(store.cleanupCalls, ['A', 'B']);
|
||||||
|
assert.doesNotThrow(() => invalidate());
|
||||||
|
});
|
||||||
|
|
||||||
test('unsubscribe and invalidate are reentry-safe and prevent callbacks already snapshotted by Store', () => {
|
test('unsubscribe and invalidate are reentry-safe and prevent callbacks already snapshotted by Store', () => {
|
||||||
const { host, invalidate, store } = makeLease();
|
const { host, invalidate, store } = makeLease();
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user