fix(platform): isolate game host leases
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
|
||||
function makeStore(location: 'outside' | 'inside', stage = 0): PlatformStore {
|
||||
function makeStore(location: 'outside' | 'inside', stage = 0, infinite = 0): PlatformStore {
|
||||
const store = new PlatformStore();
|
||||
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));
|
||||
return store;
|
||||
}
|
||||
@@ -127,6 +130,24 @@ test('joinRoom uses authenticated Store identity and requires every non-optional
|
||||
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', () => {
|
||||
const unauthenticated = makeCommands(new PlatformStore());
|
||||
assert.throws(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GameSessionDisposedError,
|
||||
} from '../../YouleNexus/assets/framework/platform/game-host-adapter.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 type {
|
||||
PlatformState,
|
||||
@@ -19,8 +20,13 @@ import {
|
||||
} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
||||
import type {
|
||||
GameEntry,
|
||||
GameHost,
|
||||
GameHostCommand,
|
||||
} 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({
|
||||
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', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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 {
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const { host, invalidate, store } = makeLease();
|
||||
const calls: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user