diff --git a/src/application/application.module.ts b/src/application/application.module.ts index 774d77a..8a8f042 100644 --- a/src/application/application.module.ts +++ b/src/application/application.module.ts @@ -27,6 +27,8 @@ import { GetSessionsQueryHandler } from './queryHandlers/GetSessionQueryHandler' import { SessionSearchQueryHandler } from './queryHandlers/SessionSearchQueryHandler'; import { AggregateSessionCommandHandler } from './commandHandlers/AggregateSessionCommandHandler'; import { ProcessClientAddressCommandHandler } from './commandHandlers/ProcessClientAddressCommandHandler'; +import { GetPlayersQueryHandler } from './queryHandlers/GetPlayersQueryHandler'; +import { UpdatePlayerCommandHandler } from './commandHandlers/UpdatePlayerCommandHandler'; export const queryHandlers = [ GetSessionsQueryHandler, @@ -34,6 +36,8 @@ export const queryHandlers = [ FindPlayerQueryHandler, FindPlayerSessionQueryHandler, GetPlayerQueryHandler, + GetPlayersQueryHandler, + UpdatePlayerCommandHandler, FindLeaderboardsQueryHandler, ]; diff --git a/src/application/commandHandlers/UpdatePlayerCommandHandler.ts b/src/application/commandHandlers/UpdatePlayerCommandHandler.ts new file mode 100644 index 0000000..f54665e --- /dev/null +++ b/src/application/commandHandlers/UpdatePlayerCommandHandler.ts @@ -0,0 +1,27 @@ +import { Inject } from '@nestjs/common'; +import { ICommandHandler, CommandHandler } from '@nestjs/cqrs'; +import IPlayerRepository, { + IPlayerRepositorySymbol, +} from 'src/domain/repositories/IPlayerRepository'; +import { UpdatePlayerCommand } from '../commands/UpdatePlayerCommand'; + +@CommandHandler(UpdatePlayerCommand) +export class UpdatePlayerCommandHandler + implements ICommandHandler +{ + constructor( + @Inject(IPlayerRepositorySymbol) + private repository: IPlayerRepository, + ) {} + + async execute(command: UpdatePlayerCommand) { + const player = await this.repository.findByXuid(command.xuid); + + if (!player) { + return undefined; + } + + player.updatePlayer(command.player); + await this.repository.save(player); + } +} diff --git a/src/application/commands/UpdatePlayerCommand.ts b/src/application/commands/UpdatePlayerCommand.ts new file mode 100644 index 0000000..37c59b0 --- /dev/null +++ b/src/application/commands/UpdatePlayerCommand.ts @@ -0,0 +1,9 @@ +import Player from 'src/domain/aggregates/Player'; +import Xuid from 'src/domain/value-objects/Xuid'; + +export class UpdatePlayerCommand { + constructor( + public readonly xuid: Xuid, + public readonly player: Player, + ) {} +} diff --git a/src/application/queries/GetPlayersQuery.ts b/src/application/queries/GetPlayersQuery.ts new file mode 100644 index 0000000..70032ae --- /dev/null +++ b/src/application/queries/GetPlayersQuery.ts @@ -0,0 +1,5 @@ +import Xuid from 'src/domain/value-objects/Xuid'; + +export class GetPlayersQuery { + constructor(public readonly xuids: Array) {} +} diff --git a/src/application/queryHandlers/GetPlayersQueryHandler.ts b/src/application/queryHandlers/GetPlayersQueryHandler.ts new file mode 100644 index 0000000..7f0d728 --- /dev/null +++ b/src/application/queryHandlers/GetPlayersQueryHandler.ts @@ -0,0 +1,18 @@ +import { Inject } from '@nestjs/common'; +import { QueryHandler, IQueryHandler } from '@nestjs/cqrs'; +import IPlayerRepository, { + IPlayerRepositorySymbol, +} from 'src/domain/repositories/IPlayerRepository'; +import { GetPlayersQuery } from '../queries/GetPlayersQuery'; + +@QueryHandler(GetPlayersQuery) +export class GetPlayersQueryHandler implements IQueryHandler { + constructor( + @Inject(IPlayerRepositorySymbol) + private repository: IPlayerRepository, + ) {} + + async execute(query: GetPlayersQuery) { + return this.repository.findByXuids(query.xuids); + } +} diff --git a/src/domain/aggregates/Player.ts b/src/domain/aggregates/Player.ts index 8cab7ad..8879ebf 100644 --- a/src/domain/aggregates/Player.ts +++ b/src/domain/aggregates/Player.ts @@ -3,6 +3,8 @@ import MacAddress from '../value-objects/MacAddress'; import SessionId from '../value-objects/SessionId'; import Xuid from '../value-objects/Xuid'; import Gamertag from '../value-objects/Gamertag'; +import TitleId from '../value-objects/TitleId'; +import StateFlag, { StateFlags } from '../value-objects/StateFlag'; interface PlayerProps { xuid: Xuid; @@ -12,6 +14,8 @@ interface PlayerProps { machineId: Xuid; port: number; sessionId?: SessionId; + titleId?: TitleId; + state?: StateFlag; } interface CreateProps { @@ -33,13 +37,39 @@ export default class Player { return new Player({ ...props, port: 36000, // Port hard-coded? + state: new StateFlag( + StateFlags.ONLINE | StateFlags.JOINABLE | StateFlags.PLAYING, + ), + sessionId: new SessionId('0'), + titleId: new TitleId('0'), + // gamertag: new Gamertag(''), }); } + public updatePlayer(player: Player) { + this.props.xuid = player.xuid; + this.props.gamertag = player.gamertag; + this.props.hostAddress = player.hostAddress; + this.props.macAddress = player.macAddress; + this.props.machineId = player.machineId; + this.props.port = player.port; + this.props.sessionId = player.sessionId; + this.props.titleId = player.titleId; + this.props.state = player.state; + } + public setSession(sessionId: SessionId) { this.props.sessionId = sessionId; } + public setTitleId(titleId: TitleId) { + this.props.titleId = titleId; + } + + public setGamertag(gamertag: Gamertag) { + this.props.gamertag = gamertag; + } + get xuid() { return this.props.xuid; } @@ -67,4 +97,12 @@ export default class Player { get sessionId() { return this.props.sessionId; } + + get titleId() { + return this.props.titleId; + } + + get state() { + return this.props.state; + } } diff --git a/src/domain/repositories/IPlayerRepository.ts b/src/domain/repositories/IPlayerRepository.ts index c08727b..b87c2d8 100644 --- a/src/domain/repositories/IPlayerRepository.ts +++ b/src/domain/repositories/IPlayerRepository.ts @@ -4,6 +4,7 @@ import Xuid from '../value-objects/Xuid'; export default interface IPlayerRepository { findByXuid: (xuid: Xuid) => Promise; + findByXuids: (xuid: Xuid[]) => Promise; findByAddress: (hostAddress: IpAddress) => Promise; save: (player: Player) => Promise; } diff --git a/src/domain/value-objects/SessionId.ts b/src/domain/value-objects/SessionId.ts index 63301e8..3add96e 100644 --- a/src/domain/value-objects/SessionId.ts +++ b/src/domain/value-objects/SessionId.ts @@ -1,9 +1,13 @@ import { TinyTypeOf } from 'tiny-types'; +// Change base type to number/same as TitleId? + export default class SessionId extends TinyTypeOf() { public constructor(value: string) { - if (!/^[0-9A-Fa-f]+$/.test(value) || value.length != 16) { - throw new Error('Invalid SessionId ' + value); + if (value != '0') { + if (!/^[0-9A-Fa-f]+$/.test(value) || value.length != 16) { + throw new Error('Invalid SessionId ' + value); + } } super(value.toLowerCase()); diff --git a/src/domain/value-objects/StateFlag.ts b/src/domain/value-objects/StateFlag.ts new file mode 100644 index 0000000..8f48931 --- /dev/null +++ b/src/domain/value-objects/StateFlag.ts @@ -0,0 +1,48 @@ +import { TinyTypeOf } from 'tiny-types'; + +export enum StateFlags { + NONE = 0x0, + ONLINE = 0x1, + PLAYING = 0x2, + VOICE = 0x8, + JOINABLE = 1 << 4, + FRIENDS_ONLY = 1 << 8, +} + +export default class StateFlag extends TinyTypeOf() { + public constructor(value: number) { + super(Number(value)); + } + + private isFlagSet(flag: number) { + return (this.value & flag) == flag; + } + + public isOnline(): boolean { + return this.isFlagSet(StateFlags.ONLINE); + } + + public isJoinable(): boolean { + return this.isFlagSet(StateFlags.JOINABLE); + } + + public isPlaying(): boolean { + return this.isFlagSet(StateFlags.PLAYING); + } + + public setOnline() { + this.value = this.value | StateFlags.ONLINE; + } + + public setJoinable() { + this.value = this.value | StateFlags.JOINABLE; + } + + public setPlaying() { + this.value = this.value | StateFlags.PLAYING; + } + + public toString(): string { + return this.value.toString(16).toUpperCase(); + } +} diff --git a/src/infrastructure/persistance/mappers/PlayerDomainMapper.ts b/src/infrastructure/persistance/mappers/PlayerDomainMapper.ts index cb603fd..40d4411 100644 --- a/src/infrastructure/persistance/mappers/PlayerDomainMapper.ts +++ b/src/infrastructure/persistance/mappers/PlayerDomainMapper.ts @@ -6,20 +6,73 @@ import Xuid from 'src/domain/value-objects/Xuid'; import MacAddress from 'src/domain/value-objects/MacAddress'; import SessionId from 'src/domain/value-objects/SessionId'; import Gamertag from 'src/domain/value-objects/Gamertag'; +import TitleId from 'src/domain/value-objects/TitleId'; +import StateFlag, { StateFlags } from 'src/domain/value-objects/StateFlag'; @Injectable() export default class PlayerDomainMapper { constructor(private readonly logger: ConsoleLogger) {} public mapToDomainModel(player: PlayerModel): Player { + // Define default values for a player + + let xuid: Xuid = new Xuid('0000000000000000'); + let hostAddress: IpAddress = new IpAddress('0.0.0.0'); + let macAddress: MacAddress = new MacAddress('002212345678'); + let machineId: Xuid = new Xuid('FA00002212345678'); + let gamertag: Gamertag = new Gamertag('Xenia Gamertag'); + let sessionId: SessionId = new SessionId('0'); + let titleId: TitleId = new TitleId('0'); + let state: StateFlag = new StateFlag( + StateFlags.ONLINE | StateFlags.JOINABLE | StateFlags.PLAYING, + ); + + try { + if (player?.xuid) { + xuid = new Xuid(player.xuid); + } + + if (player?.gamertag) { + gamertag = new Gamertag(player.gamertag); + } + + if (player?.hostAddress) { + hostAddress = new IpAddress(player.hostAddress); + } + + if (player?.macAddress) { + macAddress = new MacAddress(player.macAddress); + } + + if (player?.machineId) { + machineId = new Xuid(player.machineId); + } + + if (player?.sessionId) { + sessionId = new SessionId(player.sessionId.toString()); + } + + if (player?.titleId) { + titleId = new TitleId(player.titleId.toString()); + } + + if (player?.state) { + state = new StateFlag(player.state); + } + } catch (error) { + this.logger.fatal(error); + } + return new Player({ - xuid: new Xuid(player.xuid), - gamertag: player.gamertag ? new Gamertag(player.gamertag) : undefined, - hostAddress: new IpAddress(player.hostAddress), - macAddress: new MacAddress(player.macAddress), - machineId: new Xuid(player.machineId), + xuid: xuid, + gamertag: gamertag, + hostAddress: hostAddress, + macAddress: macAddress, + machineId: machineId, port: player.port, - sessionId: player.sessionId ? new SessionId(player.sessionId) : undefined, + sessionId: sessionId, + titleId: titleId, + state: state, }); } } diff --git a/src/infrastructure/persistance/mappers/PlayerPersistanceMapper.ts b/src/infrastructure/persistance/mappers/PlayerPersistanceMapper.ts index 43a7e71..52b2be7 100644 --- a/src/infrastructure/persistance/mappers/PlayerPersistanceMapper.ts +++ b/src/infrastructure/persistance/mappers/PlayerPersistanceMapper.ts @@ -14,6 +14,8 @@ export default class PlayerPersistanceMapper { port: player.port, sessionId: player.sessionId?.value, updatedAt: new Date(), + titleId: player.titleId?.toString(), + state: player.state?.value, }; } } diff --git a/src/infrastructure/persistance/models/PlayerSchema.ts b/src/infrastructure/persistance/models/PlayerSchema.ts index 7a5523c..07ec764 100644 --- a/src/infrastructure/persistance/models/PlayerSchema.ts +++ b/src/infrastructure/persistance/models/PlayerSchema.ts @@ -21,6 +21,10 @@ export class Player { sessionId?: string; @Prop({ type: Date, expires: '1d', default: Date.now(), required: true }) updatedAt: Date; + @Prop() + titleId?: string; + @Prop() + state?: number; } export const PlayerSchema = SchemaFactory.createForClass(Player); diff --git a/src/infrastructure/persistance/repositories/PlayerRepository.ts b/src/infrastructure/persistance/repositories/PlayerRepository.ts index 6825dbd..d5c9d67 100644 --- a/src/infrastructure/persistance/repositories/PlayerRepository.ts +++ b/src/infrastructure/persistance/repositories/PlayerRepository.ts @@ -34,7 +34,23 @@ export default class PlayerRepository implements IPlayerRepository { ); } - public async findByXuid(xuid: Xuid) { + public async findByXuids(xuids: Xuid[]): Promise { + const player_xuids: string[] = xuids.map((_xuid: Xuid) => { + return _xuid.value; + }); + + const players_docs = await this.PlayerModel.find({ + xuid: player_xuids, + }); + + const players: Player[] = players_docs.map((document) => { + return this.playerDomainMapper.mapToDomainModel(document); + }); + + return players; + } + + public async findByXuid(xuid: Xuid): Promise { const player = await this.PlayerModel.findOne({ xuid: xuid.value, }); @@ -46,7 +62,7 @@ export default class PlayerRepository implements IPlayerRepository { return this.playerDomainMapper.mapToDomainModel(player); } - public async findByAddress(ip: IpAddress) { + public async findByAddress(ip: IpAddress): Promise { const player = await this.PlayerModel.findOne({ hostAddress: ip.value, }); diff --git a/src/infrastructure/presentation/controllers/player.controller.ts b/src/infrastructure/presentation/controllers/player.controller.ts index 2e26ff8..c7ba1ad 100644 --- a/src/infrastructure/presentation/controllers/player.controller.ts +++ b/src/infrastructure/presentation/controllers/player.controller.ts @@ -16,6 +16,11 @@ import MacAddress from 'src/domain/value-objects/MacAddress'; import { FindPlayerRequest } from '../requests/FindPlayerRequest'; import { FindPlayerQuery } from 'src/application/queries/FindPlayerQuery'; import type { PlayerResponse } from 'src/infrastructure/presentation/responses/PlayerResponse'; +import { GetPlayerPresence, PlayerPresence } from '../responses/PlayerPresence'; +import { PresenceRequest } from '../requests/PresenceRequest'; +import Player from 'src/domain/aggregates/Player'; +import { GetPlayersQuery } from 'src/application/queries/GetPlayersQuery'; +import _ from 'lodash'; @ApiTags('Player') @Controller('/players') @@ -60,12 +65,55 @@ export class PlayerController { return { xuid: player.xuid.value, - gamertag: player.gamertag ? player.gamertag.value : '', + gamertag: player.gamertag, hostAddress: player.hostAddress.value, machineId: player.machineId.value, port: player.port, macAddress: player.macAddress.value, - sessionId: player.sessionId ? player.sessionId.value : '0000000000000000', + sessionId: player.sessionId.value, }; } + + @Post('/presence') + async Presence(@Body() request: PresenceRequest): Promise { + const playerPresences: GetPlayerPresence = []; + + this.logger.debug(request); + + let xuids: Array = request.xuids.map((xuid: string) => { + let xuid_: Xuid = undefined; + + try { + xuid_ = new Xuid(xuid); + } catch (error) { + this.logger.error(`Invalid XUID: ${xuid}`); + } + + return xuid_; + }); + + // Remove undefined xuids from array + xuids = _.compact(xuids); + + const players: Array = await this.queryBus.execute( + new GetPlayersQuery(xuids), + ); + + players.forEach((player: Player) => { + const presence: PlayerPresence = { + xuid: player.xuid.value, + gamertag: player.gamertag.value, + state: player.state.value, + sessionId: player.sessionId.value, + titleId: player.titleId.toString(), + stateChangeTime: 0, + richPresenceStateSize: 0, + richPresence: 'Playing on Xenia', + }; + + playerPresences.push(presence); + }); + + return playerPresences; + } } diff --git a/src/infrastructure/presentation/controllers/session.controller.ts b/src/infrastructure/presentation/controllers/session.controller.ts index 7967ea1..6d5a086 100644 --- a/src/infrastructure/presentation/controllers/session.controller.ts +++ b/src/infrastructure/presentation/controllers/session.controller.ts @@ -39,7 +39,6 @@ import { SessionContextResponse } from '../responses/SessionContextResponse'; import Player from 'src/domain/aggregates/Player'; import { GetPlayerQuery } from 'src/application/queries/GetPlayerQuery'; import { FindPlayerQuery } from 'src/application/queries/FindPlayerQuery'; -import { SetPlayerSessionIdCommand } from 'src/application/commands/SetPlayerSessionIdCommand'; import { Request, Response } from 'express'; import { mkdir, stat, writeFile } from 'fs/promises'; import { join } from 'path'; @@ -54,6 +53,7 @@ import { MigrateSessionRequest } from '../requests/MigrateSessionRequest'; import { RealIP } from 'nestjs-real-ip'; import { ProcessClientAddressCommand } from 'src/application/commands/ProcessClientAddressCommand'; import Session from 'src/domain/aggregates/Session'; +import { UpdatePlayerCommand } from 'src/application/commands/UpdatePlayerCommand'; @ApiTags('Sessions') @Controller('/title/:titleId/sessions') @@ -99,17 +99,16 @@ export class SessionController { this.logger.debug('Updating Stats.'); } - const player = await this.queryBus.execute( + const player: Player = await this.queryBus.execute( new FindPlayerQuery(new IpAddress(request.hostAddress)), ); // If player doesn't exists add them to players table if (player) { + player.setSession(new SessionId(request.sessionId)); + await this.commandBus.execute( - new SetPlayerSessionIdCommand( - player.xuid, - new SessionId(request.sessionId), - ), + new UpdatePlayerCommand(player.xuid, player), ); } else { this.logger.debug(`Player not found: ${request.hostAddress}`); @@ -213,6 +212,9 @@ export class SessionController { new DeleteSessionCommand(new TitleId(titleId), new SessionId(sessionId)), ); + // Reset player's session id and title id when they delete a session. + // Problem is supporting multiple session instances + if (!result.deleted) { throw new NotFoundException( `Failed to soft delete session ${sessionId}.`, @@ -372,13 +374,16 @@ export class SessionController { const players_xuid = request.xuids.map((xuid) => new Xuid(xuid)); for (const player_xuid of players_xuid) { - const player = await this.queryBus.execute( + const player: Player = await this.queryBus.execute( new GetPlayerQuery(player_xuid), ); if (player) { + player.setSession(new SessionId(sessionId)); + player.setTitleId(new TitleId(titleId)); + await this.commandBus.execute( - new SetPlayerSessionIdCommand(player.xuid, new SessionId(sessionId)), + new UpdatePlayerCommand(player.xuid, player), ); } } @@ -406,6 +411,28 @@ export class SessionController { throw new NotFoundException(error_msg); } + + // Update leaving players + // Reset player's session id and title id when they leave a session. + // Problem is supporting multiple session instances + /* + const players_xuid = request.xuids.map((xuid) => new Xuid(xuid)); + + for (const player_xuid of players_xuid) { + const player: Player = await this.queryBus.execute( + new GetPlayerQuery(player_xuid), + ); + + if (player) { + player.setSession(new SessionId('0')); + // player.setTitleId(new TitleId('0')); + + await this.commandBus.execute( + new UpdatePlayerCommand(player.xuid, player), + ); + } + } + */ } @Post('/search') diff --git a/src/infrastructure/presentation/controllers/xnet.controller.ts b/src/infrastructure/presentation/controllers/xnet.controller.ts index fb819b6..1b7a6e8 100644 --- a/src/infrastructure/presentation/controllers/xnet.controller.ts +++ b/src/infrastructure/presentation/controllers/xnet.controller.ts @@ -7,6 +7,11 @@ import MacAddress from 'src/domain/value-objects/MacAddress'; import { DeleteSessionsCommand } from 'src/application/commands/DeleteSessionCommand'; import { RealIP } from 'nestjs-real-ip'; import { ProcessClientAddressCommand } from 'src/application/commands/ProcessClientAddressCommand'; +import { FindPlayerQuery } from 'src/application/queries/FindPlayerQuery'; +import Player from 'src/domain/aggregates/Player'; +import { UpdatePlayerCommand } from 'src/application/commands/UpdatePlayerCommand'; +import SessionId from 'src/domain/value-objects/SessionId'; +import TitleId from 'src/domain/value-objects/TitleId'; @ApiTags('XNet') @Controller() @@ -51,5 +56,18 @@ export class XNetController { await this.commandBus.execute( new DeleteSessionsCommand(new IpAddress(ipv4), mac), ); + + const player: Player = await this.queryBus.execute( + new FindPlayerQuery(new IpAddress(ipv4)), + ); + + if (player) { + player.setSession(new SessionId('0')); + player.setTitleId(new TitleId('0')); + + await this.commandBus.execute( + new UpdatePlayerCommand(player.xuid, player), + ); + } } } diff --git a/src/infrastructure/presentation/requests/PresenceRequest.ts b/src/infrastructure/presentation/requests/PresenceRequest.ts new file mode 100644 index 0000000..dea94a9 --- /dev/null +++ b/src/infrastructure/presentation/requests/PresenceRequest.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PresenceRequest { + @ApiProperty() + xuids: string[]; +} diff --git a/src/infrastructure/presentation/responses/PlayerPresence.ts b/src/infrastructure/presentation/responses/PlayerPresence.ts new file mode 100644 index 0000000..a7b822d --- /dev/null +++ b/src/infrastructure/presentation/responses/PlayerPresence.ts @@ -0,0 +1,17 @@ +export interface PlayerPresence { + xuid: string; + gamertag: string; + state: number; + sessionId: string; + titleId: string; + stateChangeTime: number; + richPresenceStateSize: number; + richPresence: string[64]; +} + +// TODO: +// ftUserTime; +// xnkidInvite; +// gameinviteTime; + +export type GetPlayerPresence = PlayerPresence[];