diff --git a/cocoscreator_projects/YouleNexus/assets/framework/presentation/page-model.ts b/cocoscreator_projects/YouleNexus/assets/framework/presentation/page-model.ts new file mode 100644 index 0000000..493fe85 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/presentation/page-model.ts @@ -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 { + 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}`); + } +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/presentation/ui-contracts.ts b/cocoscreator_projects/YouleNexus/assets/framework/presentation/ui-contracts.ts new file mode 100644 index 0000000..809fca5 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/presentation/ui-contracts.ts @@ -0,0 +1,54 @@ +import type { PlatformRuntime } from '../platform/runtime.ts'; +import type { ServerDenial } from '../platform/runtime-session.ts'; + +export type UiRuntime = Pick; + +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; +} diff --git a/cocoscreator_projects/framework-tests/presentation/page-model.test.ts b/cocoscreator_projects/framework-tests/presentation/page-model.test.ts new file mode 100644 index 0000000..70cddf7 --- /dev/null +++ b/cocoscreator_projects/framework-tests/presentation/page-model.test.ts @@ -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(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'); +});