feat(presentation): project immutable public page models
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
import { selectRoomPlayers, selectSelfPlayer } from '../platform/stores/selectors.ts';
|
||||||
|
import type { PlatformPlayer, PlatformState } from '../platform/stores/platform-types.ts';
|
||||||
|
import type { PageId, PageModel, PlayerModel, SeatModel } from './ui-contracts.ts';
|
||||||
|
|
||||||
|
const playerModel = (player: PlatformPlayer): PlayerModel => Object.freeze({
|
||||||
|
playerid: player.playerid,
|
||||||
|
nickname: player.nickname,
|
||||||
|
avatar: player.avatar,
|
||||||
|
bean: player.bean,
|
||||||
|
});
|
||||||
|
|
||||||
|
function requireSelf(state: PlatformState): PlatformPlayer {
|
||||||
|
const self = selectSelfPlayer(state);
|
||||||
|
if (self === null) {
|
||||||
|
throw new Error('players.selfPlayerId: expected authenticated player');
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRoomModel(state: PlatformState): Extract<PageModel, { page: 'room' }> {
|
||||||
|
if (state.room.kind !== 'inside') {
|
||||||
|
throw new Error('room: expected player to be inside a room');
|
||||||
|
}
|
||||||
|
const room = state.room;
|
||||||
|
const self = requireSelf(state);
|
||||||
|
if (room.seatPlayerIds[room.selfSeat] !== self.playerid) {
|
||||||
|
throw new RangeError('room.selfSeat: expected authenticated player seat');
|
||||||
|
}
|
||||||
|
|
||||||
|
const seats: SeatModel[] = [];
|
||||||
|
const roomPlayers = selectRoomPlayers(state);
|
||||||
|
for (let seat = 0; seat < roomPlayers.length; seat += 1) {
|
||||||
|
const player = roomPlayers[seat];
|
||||||
|
if (player === null) {
|
||||||
|
seats.push(Object.freeze({ kind: 'empty', seat }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (player.onstate === undefined) {
|
||||||
|
throw new TypeError(
|
||||||
|
`room.seatPlayerIds[${seat}] -> players.entities[${player.playerid}].onstate: expected protocol number`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (player.isprepare === undefined) {
|
||||||
|
throw new TypeError(
|
||||||
|
`room.seatPlayerIds[${seat}] -> players.entities[${player.playerid}].isprepare: expected protocol number`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seats.push(Object.freeze({
|
||||||
|
kind: 'occupied',
|
||||||
|
seat,
|
||||||
|
player: playerModel(player),
|
||||||
|
ready: player.isprepare === 1,
|
||||||
|
offline: player.onstate === 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
page: 'room',
|
||||||
|
roomcode: room.roomcode,
|
||||||
|
selfSeat: room.selfSeat,
|
||||||
|
seats: Object.freeze(seats),
|
||||||
|
canPrepare: state.app.phase === 'logged-in'
|
||||||
|
&& room.needprepare === 1
|
||||||
|
&& self.isprepare !== 1
|
||||||
|
&& room.stage === 0,
|
||||||
|
canExit: state.app.phase === 'logged-in'
|
||||||
|
&& (room.stage === 0 || room.infinite === 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectPageModel(
|
||||||
|
page: PageId,
|
||||||
|
state: PlatformState,
|
||||||
|
ready: boolean,
|
||||||
|
): PageModel {
|
||||||
|
switch (page) {
|
||||||
|
case 'loading':
|
||||||
|
return Object.freeze({ page: 'loading' });
|
||||||
|
case 'login':
|
||||||
|
return Object.freeze({
|
||||||
|
page: 'login',
|
||||||
|
canLogin: ready,
|
||||||
|
});
|
||||||
|
case 'lobby':
|
||||||
|
return Object.freeze({ page: 'lobby', self: playerModel(requireSelf(state)) });
|
||||||
|
case 'room':
|
||||||
|
return selectRoomModel(state);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported page: ${page}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { PlatformRuntime } from '../platform/runtime.ts';
|
||||||
|
import type { ServerDenial } from '../platform/runtime-session.ts';
|
||||||
|
|
||||||
|
export type UiRuntime = Pick<PlatformRuntime,
|
||||||
|
'state' | 'ready' | 'subscribeState' | 'login' | 'joinRoom' | 'prepare' | 'exitRoom'>;
|
||||||
|
|
||||||
|
export type PageId = 'loading' | 'login' | 'lobby' | 'room';
|
||||||
|
|
||||||
|
export interface PlayerModel {
|
||||||
|
readonly playerid: number;
|
||||||
|
readonly nickname: string;
|
||||||
|
readonly avatar: string;
|
||||||
|
readonly bean: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeatModel =
|
||||||
|
| { readonly kind: 'empty'; readonly seat: number }
|
||||||
|
| {
|
||||||
|
readonly kind: 'occupied';
|
||||||
|
readonly seat: number;
|
||||||
|
readonly player: PlayerModel;
|
||||||
|
readonly ready: boolean;
|
||||||
|
readonly offline: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageModel =
|
||||||
|
| { readonly page: 'loading' }
|
||||||
|
| { readonly page: 'login'; readonly canLogin: boolean }
|
||||||
|
| { readonly page: 'lobby'; readonly self: PlayerModel }
|
||||||
|
| {
|
||||||
|
readonly page: 'room';
|
||||||
|
readonly roomcode: string;
|
||||||
|
readonly selfSeat: number;
|
||||||
|
readonly seats: readonly SeatModel[];
|
||||||
|
readonly canPrepare: boolean;
|
||||||
|
readonly canExit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OverlayModel =
|
||||||
|
| { readonly kind: 'none' }
|
||||||
|
| { readonly kind: 'reconnect' }
|
||||||
|
| { readonly kind: 'denial'; readonly outcome: ServerDenial }
|
||||||
|
| { readonly kind: 'kicked'; readonly data: unknown }
|
||||||
|
| { readonly kind: 'fatal'; readonly error: Error };
|
||||||
|
|
||||||
|
export interface FrameScheduler {
|
||||||
|
request(callback: () => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformViewPort {
|
||||||
|
render(model: PageModel): void;
|
||||||
|
activate(page: PageId): void;
|
||||||
|
showOverlay(model: OverlayModel): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { selectPageModel } from '../../YouleNexus/assets/framework/presentation/page-model.ts';
|
||||||
|
import type { PlatformState } from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts';
|
||||||
|
|
||||||
|
test('loading is an immutable page without business data', () => {
|
||||||
|
const model = selectPageModel('loading', {
|
||||||
|
app: { phase: 'idle' },
|
||||||
|
players: { selfPlayerId: null, entities: {} },
|
||||||
|
room: { kind: 'outside' },
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
assert.deepEqual(model, { page: 'loading' });
|
||||||
|
assert.ok(Object.isFrozen(model));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty seats keep authoritative positions and count', () => {
|
||||||
|
for (const count of [2, 4, 10]) {
|
||||||
|
const state: PlatformState = {
|
||||||
|
app: { phase: 'logged-in' },
|
||||||
|
players: { selfPlayerId: 7, entities: {
|
||||||
|
7: { playerid: 7, nickname: 'self', avatar: '', bean: 0, sex: 0,
|
||||||
|
isprepare: 0, onstate: 0 },
|
||||||
|
} },
|
||||||
|
room: { kind: 'inside', roomcode: '000123', selfSeat: 0,
|
||||||
|
seatPlayerIds: [7, ...Array<null>(count - 1).fill(null)],
|
||||||
|
readySeats: [], offlineSeats: [], roomtype: [], stage: 0,
|
||||||
|
needprepare: 1, infinite: 0 },
|
||||||
|
};
|
||||||
|
const model = selectPageModel('room', state, true);
|
||||||
|
assert.equal(model.page, 'room');
|
||||||
|
if (model.page !== 'room') throw new Error('expected room');
|
||||||
|
assert.equal(model.roomcode, '000123');
|
||||||
|
assert.equal(model.seats.length, count);
|
||||||
|
assert.deepEqual(model.seats[1], { kind: 'empty', seat: 1 });
|
||||||
|
assert.equal(model.canPrepare, true);
|
||||||
|
assert.equal(model.canExit, true);
|
||||||
|
assert.ok(Object.isFrozen(model));
|
||||||
|
assert.ok(Object.isFrozen(model.seats));
|
||||||
|
assert.ok(Object.isFrozen(model.seats[1]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login permits commands when the runtime is ready', () => {
|
||||||
|
const state: PlatformState = {
|
||||||
|
app: { phase: 'connected' },
|
||||||
|
players: { selfPlayerId: null, entities: {} },
|
||||||
|
room: { kind: 'outside' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const readyModel = selectPageModel('login', state, true);
|
||||||
|
assert.deepEqual(readyModel, {
|
||||||
|
page: 'login',
|
||||||
|
canLogin: true,
|
||||||
|
});
|
||||||
|
assert.ok(Object.isFrozen(readyModel));
|
||||||
|
assert.deepEqual(selectPageModel('login', state, false), {
|
||||||
|
page: 'login',
|
||||||
|
canLogin: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(selectPageModel('login', { ...state, app: { phase: 'reconnecting' } }, true), {
|
||||||
|
page: 'login',
|
||||||
|
canLogin: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lobby projects only the public self fields without normalizing their values', () => {
|
||||||
|
const state: PlatformState = {
|
||||||
|
app: { phase: 'logged-in' },
|
||||||
|
players: { selfPlayerId: 7, entities: {
|
||||||
|
7: { playerid: 7, nickname: 'self', avatar: '', bean: 0, sex: 1, ip: '127.0.0.1' },
|
||||||
|
} },
|
||||||
|
room: { kind: 'outside' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const model = selectPageModel('lobby', state, true);
|
||||||
|
assert.deepEqual(model, {
|
||||||
|
page: 'lobby',
|
||||||
|
self: { playerid: 7, nickname: 'self', avatar: '', bean: 0 },
|
||||||
|
});
|
||||||
|
assert.ok(Object.isFrozen(model));
|
||||||
|
if (model.page !== 'lobby') throw new Error('expected lobby');
|
||||||
|
assert.ok(Object.isFrozen(model.self));
|
||||||
|
assert.equal('sex' in model.self, false);
|
||||||
|
assert.equal('ip' in model.self, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lobby rejects a missing authenticated player', () => {
|
||||||
|
const state: PlatformState = {
|
||||||
|
app: { phase: 'logged-in' },
|
||||||
|
players: { selfPlayerId: null, entities: {} },
|
||||||
|
room: { kind: 'outside' },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => selectPageModel('lobby', state, true),
|
||||||
|
/players\.selfPlayerId: expected authenticated player/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room rejects an occupied seat whose player entity is absent', () => {
|
||||||
|
const state: PlatformState = {
|
||||||
|
app: { phase: 'logged-in' },
|
||||||
|
players: { selfPlayerId: 7, entities: {
|
||||||
|
7: { playerid: 7, nickname: 'self', avatar: '', bean: 0, sex: 0,
|
||||||
|
isprepare: 0, onstate: 0 },
|
||||||
|
} },
|
||||||
|
room: { kind: 'inside', roomcode: '000123', selfSeat: 0,
|
||||||
|
seatPlayerIds: [7, 8], readySeats: [], offlineSeats: [], roomtype: [], stage: 0,
|
||||||
|
needprepare: 1, infinite: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => selectPageModel('room', state, true),
|
||||||
|
/players\.entities: missing player for occupied seat 1/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room requires selfSeat to identify the authenticated player', () => {
|
||||||
|
const state: PlatformState = {
|
||||||
|
app: { phase: 'logged-in' },
|
||||||
|
players: { selfPlayerId: 7, entities: {
|
||||||
|
7: { playerid: 7, nickname: 'self', avatar: '', bean: 0, sex: 0,
|
||||||
|
isprepare: 0, onstate: 0 },
|
||||||
|
} },
|
||||||
|
room: { kind: 'inside', roomcode: '000123', selfSeat: 1,
|
||||||
|
seatPlayerIds: [7, null], readySeats: [], offlineSeats: [], roomtype: [], stage: 0,
|
||||||
|
needprepare: 1, infinite: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => selectPageModel('room', state, true),
|
||||||
|
/room\.selfSeat: expected authenticated player seat/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room rejects missing protocol status for an occupied seat', () => {
|
||||||
|
const state: PlatformState = {
|
||||||
|
app: { phase: 'logged-in' },
|
||||||
|
players: { selfPlayerId: 7, entities: {
|
||||||
|
7: { playerid: 7, nickname: 'self', avatar: '', bean: 0, sex: 0,
|
||||||
|
isprepare: 0, onstate: 0 },
|
||||||
|
8: { playerid: 8, nickname: 'guest', avatar: '', bean: 5, sex: 1,
|
||||||
|
isprepare: 0 },
|
||||||
|
} },
|
||||||
|
room: { kind: 'inside', roomcode: '000123', selfSeat: 0,
|
||||||
|
seatPlayerIds: [7, 8], readySeats: [], offlineSeats: [], roomtype: [], stage: 0,
|
||||||
|
needprepare: 1, infinite: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => selectPageModel('room', state, true),
|
||||||
|
/room\.seatPlayerIds\[1\].*players\.entities\[8\]\.onstate/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingPrepare: PlatformState = {
|
||||||
|
...state,
|
||||||
|
players: { ...state.players, entities: {
|
||||||
|
...state.players.entities,
|
||||||
|
8: { playerid: 8, nickname: 'guest', avatar: '', bean: 5, sex: 1, onstate: 0 },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() => selectPageModel('room', missingPrepare, true),
|
||||||
|
/room\.seatPlayerIds\[1\].*players\.entities\[8\]\.isprepare/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room exposes ready and offline status from the occupied player', () => {
|
||||||
|
const state: PlatformState = {
|
||||||
|
app: { phase: 'logged-in' },
|
||||||
|
players: { selfPlayerId: 7, entities: {
|
||||||
|
7: { playerid: 7, nickname: 'self', avatar: '', bean: 0, sex: 0,
|
||||||
|
isprepare: 0, onstate: 0 },
|
||||||
|
8: { playerid: 8, nickname: 'guest', avatar: 'avatar.png', bean: 5, sex: 1,
|
||||||
|
isprepare: 1, onstate: 1 },
|
||||||
|
} },
|
||||||
|
room: { kind: 'inside', roomcode: '000123', selfSeat: 0,
|
||||||
|
seatPlayerIds: [7, 8], readySeats: [], offlineSeats: [], roomtype: [], stage: 0,
|
||||||
|
needprepare: 1, infinite: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const model = selectPageModel('room', state, true);
|
||||||
|
if (model.page !== 'room') throw new Error('expected room');
|
||||||
|
assert.deepEqual(model.seats[1], {
|
||||||
|
kind: 'occupied', seat: 1,
|
||||||
|
player: { playerid: 8, nickname: 'guest', avatar: 'avatar.png', bean: 5 },
|
||||||
|
ready: true, offline: true,
|
||||||
|
});
|
||||||
|
assert.ok(Object.isFrozen(model.seats[1]));
|
||||||
|
if (model.seats[1]?.kind !== 'occupied') throw new Error('expected occupied seat');
|
||||||
|
assert.ok(Object.isFrozen(model.seats[1].player));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room command availability follows the authoritative room and connection state', () => {
|
||||||
|
const room = { kind: 'inside' as const, roomcode: '000123', selfSeat: 0,
|
||||||
|
seatPlayerIds: [7], readySeats: [], offlineSeats: [], roomtype: [], stage: 1,
|
||||||
|
needprepare: 1, infinite: 0 };
|
||||||
|
const players = { selfPlayerId: 7, entities: {
|
||||||
|
7: { playerid: 7, nickname: 'self', avatar: '', bean: 0, sex: 0,
|
||||||
|
isprepare: 0, onstate: 0 },
|
||||||
|
} };
|
||||||
|
|
||||||
|
const battle = selectPageModel('room', { app: { phase: 'logged-in' }, players, room }, true);
|
||||||
|
assert.equal(battle.page, 'room');
|
||||||
|
if (battle.page !== 'room') throw new Error('expected room');
|
||||||
|
assert.equal(battle.canPrepare, false);
|
||||||
|
assert.equal(battle.canExit, false);
|
||||||
|
|
||||||
|
const infinite = selectPageModel('room', {
|
||||||
|
app: { phase: 'logged-in' }, players, room: { ...room, infinite: 1 },
|
||||||
|
}, true);
|
||||||
|
assert.equal(infinite.page, 'room');
|
||||||
|
if (infinite.page !== 'room') throw new Error('expected room');
|
||||||
|
assert.equal(infinite.canExit, true);
|
||||||
|
|
||||||
|
const reconnecting = selectPageModel('room', {
|
||||||
|
app: { phase: 'reconnecting' }, players, room: { ...room, stage: 0 },
|
||||||
|
}, true);
|
||||||
|
assert.equal(reconnecting.page, 'room');
|
||||||
|
if (reconnecting.page !== 'room') throw new Error('expected room');
|
||||||
|
assert.equal(reconnecting.canPrepare, false);
|
||||||
|
assert.equal(reconnecting.canExit, false);
|
||||||
|
|
||||||
|
const prepared = selectPageModel('room', {
|
||||||
|
app: { phase: 'logged-in' },
|
||||||
|
players: { selfPlayerId: 7, entities: {
|
||||||
|
7: { ...players.entities[7]!, isprepare: 1 },
|
||||||
|
} },
|
||||||
|
room: { ...room, stage: 0 },
|
||||||
|
}, true);
|
||||||
|
assert.equal(prepared.page, 'room');
|
||||||
|
if (prepared.page !== 'room') throw new Error('expected room');
|
||||||
|
assert.equal(prepared.canPrepare, false);
|
||||||
|
|
||||||
|
const optionalPrepare = selectPageModel('room', {
|
||||||
|
app: { phase: 'logged-in' }, players, room: { ...room, stage: 0, needprepare: 0 },
|
||||||
|
}, true);
|
||||||
|
assert.equal(optionalPrepare.page, 'room');
|
||||||
|
if (optionalPrepare.page !== 'room') throw new Error('expected room');
|
||||||
|
assert.equal(optionalPrepare.canPrepare, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room does not inspect roomtype while projecting seats', () => {
|
||||||
|
const roomtype = new Proxy([], {
|
||||||
|
get: () => { throw new Error('roomtype must not be parsed by page projection'); },
|
||||||
|
});
|
||||||
|
const state: PlatformState = {
|
||||||
|
app: { phase: 'logged-in' },
|
||||||
|
players: { selfPlayerId: 7, entities: {
|
||||||
|
7: { playerid: 7, nickname: 'self', avatar: '', bean: 0, sex: 0,
|
||||||
|
isprepare: 0, onstate: 0 },
|
||||||
|
} },
|
||||||
|
room: { kind: 'inside', roomcode: '000123', selfSeat: 0,
|
||||||
|
seatPlayerIds: [7, null], readySeats: [], offlineSeats: [], roomtype, stage: 0,
|
||||||
|
needprepare: 1, infinite: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(selectPageModel('room', state, true).page, 'room');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user