feat(platform): add atomic canonical platform store
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||
import {
|
||||
parseLoginResponse,
|
||||
parseOtherExitRoomPayload,
|
||||
parseOtherOfflinePayload,
|
||||
parseOtherJoinRoomPayload,
|
||||
parseOtherOnlinePayload,
|
||||
parsePlayerPreparePayload,
|
||||
parseSelfJoinRoomResponse,
|
||||
} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
||||
|
||||
function fixture(name: string): { data: Record<string, unknown> } {
|
||||
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||
}
|
||||
|
||||
test('PlatformStore starts unauthenticated and outside a room', () => {
|
||||
const state = new PlatformStore().getState();
|
||||
assert.equal(state.players.selfPlayerId, null);
|
||||
assert.deepEqual(state.room, { kind: 'outside' });
|
||||
});
|
||||
|
||||
test('applyLoginSuccess commits the complete login once and notifies once', () => {
|
||||
const store = new PlatformStore();
|
||||
const parsedLogin = parseLoginResponse(fixture('player-login-success.json').data);
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.applyLoginSuccess(parsedLogin);
|
||||
|
||||
const state = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(state.app.phase, 'logged-in');
|
||||
assert.equal(state.players.selfPlayerId, 430511);
|
||||
assert.deepEqual(state.players.entities[430511], {
|
||||
playerid: 430511,
|
||||
nickname: '测试号',
|
||||
avatar: 'http://a',
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
assert.deepEqual(state.room, { kind: 'outside' });
|
||||
});
|
||||
|
||||
test('room login stores one canonical player entity and recursively freezes roomtype', () => {
|
||||
const data = fixture('player-login-room.json').data;
|
||||
const parsedLogin = parseLoginResponse(data);
|
||||
const store = new PlatformStore();
|
||||
|
||||
store.applyLoginSuccess(parsedLogin);
|
||||
|
||||
const state = store.getState();
|
||||
assert.equal(state.players.selfPlayerId, 430511);
|
||||
assert.deepEqual(Object.keys(state.players.entities), ['430511']);
|
||||
assert.equal(state.players.entities[430511]?.nickname, '测试号');
|
||||
assert.equal(state.room.kind, 'inside');
|
||||
if (state.room.kind !== 'inside') return;
|
||||
const room = state.room;
|
||||
assert.deepEqual(room.seatPlayerIds, [null, 430511, null, null]);
|
||||
assert.equal(state.players.entities[430511]?.isprepare, 1);
|
||||
assert.equal(state.players.entities[430511]?.onstate, 1);
|
||||
assert.equal(typeof state.players.entities[430511]?.isprepare, 'number');
|
||||
assert.equal(typeof state.players.entities[430511]?.onstate, 'number');
|
||||
assert.equal(room.roomtype, data.roomtype);
|
||||
assert.equal(Object.isFrozen(room.roomtype), true);
|
||||
assert.equal(Object.isFrozen(room.roomtype[5]), true);
|
||||
assert.equal(Object.isFrozen((room.roomtype[5] as unknown[])[2]), true);
|
||||
assert.equal('deskinfo' in state, false);
|
||||
assert.equal('deskinfo' in state.room, false);
|
||||
assert.throws(() => {
|
||||
(room.roomtype[5] as unknown[])[0] = 99;
|
||||
}, TypeError);
|
||||
});
|
||||
|
||||
test('playerJoin atomically adds one canonical entity and its seat id', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
const joined = parseOtherJoinRoomPayload({
|
||||
seat: 2,
|
||||
playerid: 77,
|
||||
nickname: '对家',
|
||||
avatar: 'http://b',
|
||||
sex: 1,
|
||||
ip: '127.0.0.2',
|
||||
onstate: 0,
|
||||
bean: 90,
|
||||
isprepare: 0,
|
||||
});
|
||||
|
||||
store.playerJoin(joined);
|
||||
|
||||
const state = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(state.room.kind, 'inside');
|
||||
if (state.room.kind !== 'inside') return;
|
||||
assert.deepEqual(state.room.seatPlayerIds, [null, 430511, 77, null]);
|
||||
assert.deepEqual(state.players.entities[77], {
|
||||
playerid: 77,
|
||||
nickname: '对家',
|
||||
avatar: 'http://b',
|
||||
ip: '127.0.0.2',
|
||||
isprepare: 0,
|
||||
onstate: 0,
|
||||
});
|
||||
assert.equal(Object.isFrozen(state.players.entities[77]), true);
|
||||
});
|
||||
|
||||
test('playerReady replaces the affected player and room subtrees without prior mutation', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
store.playerJoin(parseOtherJoinRoomPayload({
|
||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
||||
ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
||||
}));
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.playerReady(parsePlayerPreparePayload({ seat: 2 }));
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.notEqual(after.players, before.players);
|
||||
assert.equal(after.players.entities[77]?.isprepare, 1);
|
||||
assert.notEqual(after.room, before.room);
|
||||
assert.equal(after.room.kind, 'inside');
|
||||
assert.equal(before.room.kind, 'inside');
|
||||
if (after.room.kind !== 'inside' || before.room.kind !== 'inside') return;
|
||||
assert.deepEqual(before.room.readySeats, [1]);
|
||||
assert.deepEqual(after.room.readySeats, [1, 2]);
|
||||
assert.equal(Object.isFrozen(after.room.readySeats), true);
|
||||
});
|
||||
|
||||
test('playerOffline records protocol and room status with one atomic commit', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
store.playerJoin(parseOtherJoinRoomPayload({
|
||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
||||
ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
||||
}));
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.playerOffline(parseOtherOfflinePayload({ seat: 2 }));
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.notEqual(after.players, before.players);
|
||||
assert.equal(after.players.entities[77]?.onstate, 1);
|
||||
assert.equal(after.room.kind, 'inside');
|
||||
assert.equal(before.room.kind, 'inside');
|
||||
if (after.room.kind !== 'inside' || before.room.kind !== 'inside') return;
|
||||
assert.deepEqual(before.room.offlineSeats, [1]);
|
||||
assert.deepEqual(after.room.offlineSeats, [1, 2]);
|
||||
});
|
||||
|
||||
test('playerOnline updates the canonical player ip and removes the offline seat atomically', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.playerOnline(parseOtherOnlinePayload({ seat: 1, ip: '127.0.0.9' }));
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.notEqual(after.players, before.players);
|
||||
assert.notEqual(after.room, before.room);
|
||||
assert.equal(after.players.entities[430511]?.ip, '127.0.0.9');
|
||||
assert.equal(before.players.entities[430511]?.ip, '127.0.0.1');
|
||||
assert.equal(after.room.kind, 'inside');
|
||||
if (after.room.kind !== 'inside') return;
|
||||
assert.deepEqual(after.room.offlineSeats, []);
|
||||
});
|
||||
|
||||
test('playerExit removes the seat entity and every seat-owned status in one commit', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
store.playerJoin(parseOtherJoinRoomPayload({
|
||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
||||
ip: '127.0.0.2', onstate: 1, isprepare: 1,
|
||||
}));
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.playerExit(parseOtherExitRoomPayload({ seat: 2 }));
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.equal(after.players.selfPlayerId, 430511);
|
||||
assert.equal(after.players.entities[430511], before.players.entities[430511]);
|
||||
assert.equal(after.players.entities[77], undefined);
|
||||
assert.equal(after.room.kind, 'inside');
|
||||
if (after.room.kind !== 'inside') return;
|
||||
assert.deepEqual(after.room.seatPlayerIds, [null, 430511, null, null]);
|
||||
assert.deepEqual(after.room.readySeats, [1]);
|
||||
assert.deepEqual(after.room.offlineSeats, [1]);
|
||||
});
|
||||
|
||||
test('replaceRoom atomically replaces the complete room from a parsed DTO', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
|
||||
const parsedRoom = parseSelfJoinRoomResponse(fixture('self-join-room.json').data);
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.replaceRoom(parsedRoom);
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.equal(after.room.kind, 'inside');
|
||||
if (after.room.kind !== 'inside') return;
|
||||
assert.equal(after.room.roomcode, '100001');
|
||||
assert.equal(after.room.selfSeat, 1);
|
||||
assert.equal(after.room.stage, 1);
|
||||
assert.equal(after.room.needprepare, 1);
|
||||
assert.equal(after.room.infinite, 0);
|
||||
assert.deepEqual(after.room.seatPlayerIds, [null, 430511, null, null]);
|
||||
assert.equal(after.room.roomtype, parsedRoom.room?.roomtype);
|
||||
assert.equal(after.players.entities[430511]?.ip, '127.0.0.1');
|
||||
});
|
||||
|
||||
test('clearRoom leaves no fabricated room fields and retains only the self entity', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
store.playerJoin(parseOtherJoinRoomPayload({
|
||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
||||
ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
||||
}));
|
||||
const before = store.getState();
|
||||
const self = before.players.entities[430511];
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
store.clearRoom();
|
||||
|
||||
const after = store.getState();
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(after.app, before.app);
|
||||
assert.deepEqual(after.room, { kind: 'outside' });
|
||||
assert.deepEqual(Object.keys(after.room), ['kind']);
|
||||
assert.deepEqual(Object.keys(after.players.entities), ['430511']);
|
||||
assert.equal(after.players.entities[430511], self);
|
||||
});
|
||||
|
||||
test('failed actions keep the exact root, notify nobody, and do not mutate inputs', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
const before = store.getState();
|
||||
let notifications = 0;
|
||||
store.subscribe(() => { notifications += 1; });
|
||||
|
||||
const tampered = parseSelfJoinRoomResponse(fixture('self-join-room.json').data);
|
||||
(tampered.room as { roomcode: string }).roomcode = 'tampered';
|
||||
assert.throws(() => store.replaceRoom(tampered), /roomcode/);
|
||||
assert.equal(store.getState(), before);
|
||||
assert.equal(notifications, 0);
|
||||
|
||||
assert.throws(
|
||||
() => store.playerReady(parsePlayerPreparePayload({ seat: 99 })),
|
||||
/seat/,
|
||||
);
|
||||
assert.equal(store.getState(), before);
|
||||
assert.equal(notifications, 0);
|
||||
|
||||
assert.throws(
|
||||
() => store.playerJoin(parseOtherJoinRoomPayload({
|
||||
seat: 2, playerid: 430511, nickname: '重复', avatar: 'http://duplicate',
|
||||
ip: '127.0.0.3', onstate: 0, isprepare: 0,
|
||||
})),
|
||||
/duplicate player seat/,
|
||||
);
|
||||
assert.equal(store.getState(), before);
|
||||
assert.equal(notifications, 0);
|
||||
|
||||
const duplicateData = fixture('self-join-room.json').data;
|
||||
const duplicatePlayers = duplicateData.players as Array<Record<string, unknown> | null>;
|
||||
duplicatePlayers[2] = { ...duplicatePlayers[1] as Record<string, unknown> };
|
||||
const mutableRoomtype = duplicateData.roomtype as unknown[];
|
||||
const duplicateRoom = parseSelfJoinRoomResponse(duplicateData);
|
||||
assert.throws(() => store.replaceRoom(duplicateRoom), /duplicate player seat/);
|
||||
assert.equal(Object.isFrozen(mutableRoomtype), false);
|
||||
assert.equal(store.getState(), before);
|
||||
assert.equal(notifications, 0);
|
||||
|
||||
assert.throws(() => {
|
||||
(before.players.entities[430511] as { nickname: string }).nickname = '篡改';
|
||||
}, TypeError);
|
||||
assert.throws(() => {
|
||||
(before.room as { kind: string }).kind = 'outside';
|
||||
}, TypeError);
|
||||
assert.equal(store.getState(), before);
|
||||
assert.equal(notifications, 0);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
selectGameSnapshot,
|
||||
selectRoomPlayers,
|
||||
selectSelfPlayer,
|
||||
} from '../../YouleNexus/assets/framework/platform/stores/selectors.ts';
|
||||
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||
import type { PlatformState } from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts';
|
||||
import { parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
||||
|
||||
function fixture(name: string): { data: Record<string, unknown> } {
|
||||
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||
}
|
||||
|
||||
test('selectSelfPlayer returns null for an unauthenticated state', () => {
|
||||
const state = {
|
||||
app: { phase: 'idle' },
|
||||
players: { selfPlayerId: null, entities: {} },
|
||||
room: { kind: 'outside' },
|
||||
} as PlatformState;
|
||||
assert.equal(selectSelfPlayer(state), null);
|
||||
});
|
||||
|
||||
test('selectRoomPlayers memoizes a frozen positional projection of canonical entities', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
const state = store.getState();
|
||||
|
||||
const first = selectRoomPlayers(state);
|
||||
const second = selectRoomPlayers(state);
|
||||
|
||||
assert.equal(first, second);
|
||||
assert.equal(Object.isFrozen(first), true);
|
||||
assert.deepEqual(
|
||||
first.map((player) => player === null ? null : player.playerid),
|
||||
[null, 430511, null, null],
|
||||
);
|
||||
assert.equal(first[1], selectSelfPlayer(state));
|
||||
assert.throws(() => {
|
||||
(first as unknown[])[1] = null;
|
||||
}, TypeError);
|
||||
});
|
||||
|
||||
test('selectGameSnapshot returns only recursively frozen SDK-owned DTOs', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
const state = store.getState();
|
||||
assert.equal(state.room.kind, 'inside');
|
||||
if (state.room.kind !== 'inside') return;
|
||||
|
||||
const snapshot = selectGameSnapshot(state);
|
||||
|
||||
assert.deepEqual(snapshot, {
|
||||
connection: { phase: 'logged-in' },
|
||||
self: { playerId: 430511, seat: 1 },
|
||||
room: {
|
||||
roomcode: '100001',
|
||||
roomtype: state.room.roomtype,
|
||||
stage: 1,
|
||||
needprepare: 1,
|
||||
infinite: 0,
|
||||
},
|
||||
seats: [{
|
||||
seat: 1,
|
||||
playerId: 430511,
|
||||
nickname: '测试号',
|
||||
avatar: 'http://a',
|
||||
online: false,
|
||||
ready: true,
|
||||
}],
|
||||
});
|
||||
assert.equal(Object.isFrozen(snapshot), true);
|
||||
assert.equal(Object.isFrozen(snapshot.connection), true);
|
||||
assert.equal(Object.isFrozen(snapshot.self), true);
|
||||
assert.equal(Object.isFrozen(snapshot.room), true);
|
||||
assert.equal(Object.isFrozen(snapshot.seats), true);
|
||||
assert.equal(Object.isFrozen(snapshot.seats[0]), true);
|
||||
assert.equal(snapshot.room.roomtype, state.room.roomtype);
|
||||
assert.deepEqual(Object.keys(snapshot).sort(), ['connection', 'room', 'seats', 'self']);
|
||||
});
|
||||
|
||||
test('selectGameSnapshot memoizes each SDK branch by its immutable source subtrees', () => {
|
||||
const store = new PlatformStore();
|
||||
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||
const base = store.getState();
|
||||
assert.equal(base.room.kind, 'inside');
|
||||
if (base.room.kind !== 'inside') return;
|
||||
|
||||
const first = selectGameSnapshot(base);
|
||||
const repeated = selectGameSnapshot(base);
|
||||
assert.equal(repeated, first);
|
||||
assert.equal(repeated.connection, first.connection);
|
||||
assert.equal(repeated.self, first.self);
|
||||
assert.equal(repeated.room, first.room);
|
||||
assert.equal(repeated.seats, first.seats);
|
||||
|
||||
const appChanged: PlatformState = Object.freeze({
|
||||
...base,
|
||||
app: Object.freeze({ phase: 'reconnecting' }),
|
||||
});
|
||||
const afterApp = selectGameSnapshot(appChanged);
|
||||
assert.notEqual(afterApp, first);
|
||||
assert.notEqual(afterApp.connection, first.connection);
|
||||
assert.equal(afterApp.self, first.self);
|
||||
assert.equal(afterApp.room, first.room);
|
||||
assert.equal(afterApp.seats, first.seats);
|
||||
|
||||
const playersChanged: PlatformState = Object.freeze({
|
||||
...base,
|
||||
players: Object.freeze({
|
||||
selfPlayerId: base.players.selfPlayerId,
|
||||
entities: Object.freeze({ ...base.players.entities }),
|
||||
}),
|
||||
});
|
||||
const afterPlayers = selectGameSnapshot(playersChanged);
|
||||
assert.equal(afterPlayers.connection, first.connection);
|
||||
assert.notEqual(afterPlayers.self, first.self);
|
||||
assert.equal(afterPlayers.room, first.room);
|
||||
assert.notEqual(afterPlayers.seats, first.seats);
|
||||
|
||||
const roomChanged: PlatformState = Object.freeze({
|
||||
...base,
|
||||
room: Object.freeze({ ...base.room, stage: 0 }),
|
||||
});
|
||||
const afterRoom = selectGameSnapshot(roomChanged);
|
||||
assert.equal(afterRoom.connection, first.connection);
|
||||
assert.notEqual(afterRoom.self, first.self);
|
||||
assert.notEqual(afterRoom.room, first.room);
|
||||
assert.notEqual(afterRoom.seats, first.seats);
|
||||
});
|
||||
Reference in New Issue
Block a user