diff --git a/packages/app-api/src/controllers/PlayerOnGameserverController.ts b/packages/app-api/src/controllers/PlayerOnGameserverController.ts index c892dfca7b..051016b590 100644 --- a/packages/app-api/src/controllers/PlayerOnGameserverController.ts +++ b/packages/app-api/src/controllers/PlayerOnGameserverController.ts @@ -1,4 +1,4 @@ -import { IsNumber, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { IsBoolean, IsNumber, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; import { ITakaroQuery } from '@takaro/db'; import { APIOutput, apiResponse } from '@takaro/http'; import { AuthenticatedRequest, AuthService } from '../service/AuthService.js'; @@ -42,6 +42,10 @@ class PlayerOnGameServerSearchInputAllowedFilters { @IsUUID(4, { each: true }) @IsOptional() playerId!: string; + + @IsOptional() + @IsBoolean({ each: true }) + online!: boolean; } class PlayerOnGameServerSearchInputDTO extends ITakaroQuery { diff --git a/packages/app-api/src/db/playerOnGameserver.ts b/packages/app-api/src/db/playerOnGameserver.ts index 7552f566cf..929ec8bcd1 100644 --- a/packages/app-api/src/db/playerOnGameserver.ts +++ b/packages/app-api/src/db/playerOnGameserver.ts @@ -13,6 +13,7 @@ import { } from '../service/PlayerOnGameserverService.js'; import { PlayerRoleAssignmentOutputDTO } from '../service/RoleService.js'; import { ItemRepo } from './items.js'; +import { IGamePlayer } from '@takaro/modules'; export const PLAYER_ON_GAMESERVER_TABLE_NAME = 'playerOnGameServer'; const PLAYER_INVENTORY_TABLE_NAME = 'playerInventory'; @@ -34,6 +35,8 @@ export class PlayerOnGameServerModel extends TakaroModel { currency: number; + online: boolean; + static get relationMappings() { return { gameServer: { @@ -354,4 +357,16 @@ export class PlayerOnGameServerRepo extends ITakaroRepo< throw error; } } + + async setOnlinePlayers(gameServerId: string, players: IGamePlayer[]) { + const { query: query1 } = await this.getModel(); + const { query: query2 } = await this.getModel(); + const gameIds = players.map((player) => player.gameId); + + await Promise.all([ + query1.whereNotIn('gameId', gameIds).andWhere({ gameServerId }).update({ online: false }), + + query2.whereIn('gameId', gameIds).andWhere({ gameServerId }).update({ online: true }), + ]); + } } diff --git a/packages/app-api/src/service/PlayerOnGameserverService.ts b/packages/app-api/src/service/PlayerOnGameserverService.ts index 7b41e8912b..ca23814eae 100644 --- a/packages/app-api/src/service/PlayerOnGameserverService.ts +++ b/packages/app-api/src/service/PlayerOnGameserverService.ts @@ -1,6 +1,6 @@ import { TakaroService } from './Base.js'; -import { IsIP, IsNumber, IsOptional, IsString, Min, ValidateNested } from 'class-validator'; +import { IsBoolean, IsIP, IsNumber, IsOptional, IsString, Min, ValidateNested } from 'class-validator'; import { TakaroDTO, TakaroModelDTO, ctx, errors, traceableClass } from '@takaro/util'; import { ITakaroQuery } from '@takaro/db'; import { PaginatedOutput } from '../db/base.js'; @@ -9,6 +9,7 @@ import { IItemDTO, IPlayerReferenceDTO } from '@takaro/gameserver'; import { Type } from 'class-transformer'; import { PlayerRoleAssignmentOutputDTO, RoleService } from './RoleService.js'; import { EVENT_TYPES, EventCreateDTO, EventService } from './EventService.js'; +import { IGamePlayer } from '@takaro/modules'; export class PlayerOnGameserverOutputDTO extends TakaroModelDTO { @IsString() @@ -43,6 +44,9 @@ export class PlayerOnGameserverOutputDTO extends TakaroModelDTO IItemDTO) inventory: IItemDTO[]; @@ -252,4 +256,7 @@ export class PlayerOnGameServerService extends TakaroService< }) ); } + async setOnlinePlayers(gameServerId: string, players: IGamePlayer[]) { + await this.repo.setOnlinePlayers(gameServerId, players); + } } diff --git a/packages/app-api/src/workers/playerSyncWorker.ts b/packages/app-api/src/workers/playerSyncWorker.ts index a32efc3a0b..fa62100ce7 100644 --- a/packages/app-api/src/workers/playerSyncWorker.ts +++ b/packages/app-api/src/workers/playerSyncWorker.ts @@ -48,7 +48,7 @@ export async function processJob(job: Job) { const gameserverService = new GameServerService(domain.id); const gameServers = await gameserverService.find({}); promises.push( - gameServers.results.map(async (gs) => { + ...gameServers.results.map(async (gs) => { const reachable = await gameserverService.testReachability(gs.id); if (reachable.connectable) { await queueService.queues.playerSync.queue.add( @@ -67,32 +67,38 @@ export async function processJob(job: Job) { if (job.data.gameServerId) { const { domainId, gameServerId } = job.data; + log.info(`Processing playerSync job for domain: ${domainId} and game server: ${gameServerId}`); const gameServerService = new GameServerService(domainId); const playerService = new PlayerService(domainId); const playerOnGameServerService = new PlayerOnGameServerService(domainId); const onlinePlayers = await gameServerService.getPlayers(gameServerId); - const promises = onlinePlayers.map(async (player) => { - log.debug(`Syncing player ${player.gameId} on game server ${gameServerId}`); - await playerService.sync(player, gameServerId); - const resolvedPlayer = await playerService.resolveRef(player, gameServerId); - await gameServerService.getPlayerLocation(gameServerId, resolvedPlayer.id); - - await playerOnGameServerService.addInfo( - player, - gameServerId, - await new PlayerOnGameServerUpdateDTO().construct({ - ip: player.ip, - ping: player.ping, - }) - ); - }); + const promises = []; + + promises.push( + ...onlinePlayers.map(async (player) => { + log.debug(`Syncing player ${player.gameId} on game server ${gameServerId}`); + await playerService.sync(player, gameServerId); + const resolvedPlayer = await playerService.resolveRef(player, gameServerId); + await gameServerService.getPlayerLocation(gameServerId, resolvedPlayer.id); + + await playerOnGameServerService.addInfo( + player, + gameServerId, + await new PlayerOnGameServerUpdateDTO().construct({ + ip: player.ip, + ping: player.ping, + }) + ); + }) + ); + + promises.push(playerOnGameServerService.setOnlinePlayers(gameServerId, onlinePlayers)); - await Promise.allSettled(promises); + await Promise.all(promises); // Processing for a specific game server - log.info(`Processing playerSync job for domain: ${domainId} and game server: ${gameServerId}`); await gameServerService.syncInventories(gameServerId); return; diff --git a/packages/lib-apiclient/src/generated/api.ts b/packages/lib-apiclient/src/generated/api.ts index 485a6f70ae..782081ce4d 100644 --- a/packages/lib-apiclient/src/generated/api.ts +++ b/packages/lib-apiclient/src/generated/api.ts @@ -4558,6 +4558,12 @@ export interface PlayerOnGameServerSearchInputAllowedFilters { * @memberof PlayerOnGameServerSearchInputAllowedFilters */ playerId?: Array; + /** + * + * @type {Array} + * @memberof PlayerOnGameServerSearchInputAllowedFilters + */ + online?: Array; } /** * @@ -4684,6 +4690,12 @@ export interface PlayerOnGameServerUpdateDTO { * @memberof PlayerOnGameServerUpdateDTO */ currency?: number; + /** + * + * @type {boolean} + * @memberof PlayerOnGameServerUpdateDTO + */ + online?: boolean; } /** * @@ -4764,6 +4776,12 @@ export interface PlayerOnGameserverOutputDTO { * @memberof PlayerOnGameserverOutputDTO */ currency: number; + /** + * + * @type {boolean} + * @memberof PlayerOnGameserverOutputDTO + */ + online: boolean; /** * * @type {Array} @@ -4892,6 +4910,12 @@ export interface PlayerOnGameserverOutputWithRolesDTO { * @memberof PlayerOnGameserverOutputWithRolesDTO */ currency: number; + /** + * + * @type {boolean} + * @memberof PlayerOnGameserverOutputWithRolesDTO + */ + online: boolean; /** * * @type {Array} diff --git a/packages/lib-db/src/migrations/sql/20231223132631-pog-online.ts b/packages/lib-db/src/migrations/sql/20231223132631-pog-online.ts new file mode 100644 index 0000000000..d92f631497 --- /dev/null +++ b/packages/lib-db/src/migrations/sql/20231223132631-pog-online.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('playerOnGameServer', (table) => { + table.boolean('online').defaultTo(false); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('playerOnGameServer', (table) => { + table.dropColumn('online'); + }); +} diff --git a/packages/lib-gameserver/src/gameservers/rust/__tests__/RustEventDetection.unit.test.ts b/packages/lib-gameserver/src/gameservers/rust/__tests__/RustEventDetection.unit.test.ts index ba51cf7644..328167e1eb 100644 --- a/packages/lib-gameserver/src/gameservers/rust/__tests__/RustEventDetection.unit.test.ts +++ b/packages/lib-gameserver/src/gameservers/rust/__tests__/RustEventDetection.unit.test.ts @@ -119,7 +119,7 @@ describe('rust event detection', () => { expect(location).to.deep.equal({ x: -770.0, y: 1.0, - z: -1090.7, + z: -1090, }); }); @@ -142,7 +142,7 @@ describe('rust event detection', () => { expect(location).to.deep.equal({ x: -780.0, y: 2.0, - z: -1100.7, + z: -1100, }); }); }); diff --git a/packages/web-main/src/pages/gameserver/GameServerDashboard.tsx b/packages/web-main/src/pages/gameserver/GameServerDashboard.tsx index 833440622d..e48e2c7ad3 100644 --- a/packages/web-main/src/pages/gameserver/GameServerDashboard.tsx +++ b/packages/web-main/src/pages/gameserver/GameServerDashboard.tsx @@ -5,11 +5,32 @@ import { useSocket } from 'hooks/useSocket'; import { useGameServer } from 'queries/gameservers'; import { useSelectedGameServer } from 'hooks/useSelectedGameServerContext'; import { useGameServerDocumentTitle } from 'hooks/useDocumentTitle'; +import { ChatMessagesCard } from './cards/ChatMessages'; +import { OnlinePlayersCard } from './cards/OnlinePlayers'; + +const GridContainer = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 1rem; + min-height: 25vh; + max-height: 25vh; +`; + +const DashboardCard = styled.div` + background-color: ${({ theme }) => theme.colors.backgroundAccent}; + padding: 1rem; + border-radius: 1rem; +`; const ConsoleContainer = styled.div` height: 80vh; `; +const OnlinePlayerContainer = styled(DashboardCard)``; + +const ChatContainer = styled(DashboardCard)``; + const GameServerDashboard: FC = () => { const { selectedGameServerId } = useSelectedGameServer(); useGameServerDocumentTitle('dashboard'); @@ -88,6 +109,14 @@ const GameServerDashboard: FC = () => { return ( + + + + + + + + theme.colors.backgroundAlt}; + height: 2rem; + & > * { + margin-right: 1rem; + } +`; + +const PlayerName = styled.span` + font-weight: bold; + margin-right: 10px; +`; + +const ChatMessage: FC<{ chatMessage: EventOutputDTO }> = ({ chatMessage }) => { + if (!chatMessage.meta || !('message' in chatMessage.meta)) return null; + let avatarUrl = '/favicon.ico'; + + if (chatMessage.player?.steamAvatar) avatarUrl = chatMessage.player?.steamAvatar; + + const friendlyTimeStamp = new Date(chatMessage.createdAt).toLocaleTimeString(); + + return ( + + {friendlyTimeStamp} + + {chatMessage.player?.name} + {chatMessage.meta.message as string} + + ); +}; + +export const ChatMessagesCard: FC = () => { + const { selectedGameServerId } = useSelectedGameServer(); + const { socket } = useSocket(); + + const { data, isLoading, refetch } = useEvents({ + filters: { + gameserverId: [selectedGameServerId], + eventName: [EventSearchInputAllowedFiltersEventNameEnum.ChatMessage], + }, + sortBy: 'createdAt', + sortDirection: EventSearchInputDTOSortDirectionEnum.Desc, + extend: ['player'], + }); + + useEffect(() => { + socket.on('event', (event: EventOutputDTO) => { + if (event.eventName === 'chat-message') refetch(); + }); + + return () => { + socket.off('event'); + }; + }, []); + + if (isLoading) return ; + + const components = data?.pages[0].data?.map((event) => ); + + return {components}; +}; diff --git a/packages/web-main/src/pages/gameserver/cards/OnlinePlayers.tsx b/packages/web-main/src/pages/gameserver/cards/OnlinePlayers.tsx new file mode 100644 index 0000000000..5e6bd43e95 --- /dev/null +++ b/packages/web-main/src/pages/gameserver/cards/OnlinePlayers.tsx @@ -0,0 +1,79 @@ +import { PlayerOnGameserverOutputDTO, PlayerOutputDTO } from '@takaro/apiclient'; +import { Loading, styled } from '@takaro/lib-components'; +import { useSelectedGameServer } from 'hooks/useSelectedGameServerContext'; +import { PATHS } from 'paths'; +import { usePlayerOnGameServers, usePlayers } from 'queries/players/queries'; +import { FC } from 'react'; + +const SteamAvatar = styled.img` + height: 100%; + border-radius: 50%; + margin: auto; +`; + +const PlayerCards = styled.div` + display: grid; + grid-template-columns: 1fr 1fr 1fr; + overflow-y: scroll; +`; + +const PlayerCard = styled.div` + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + background-color: ${(props) => props.theme.colors.background}; + border-radius: ${(props) => props.theme.borderRadius.large}; + padding: ${(props) => props.theme.spacing[1]}; + margin: ${(props) => props.theme.spacing[1]}; + height: 50px; +`; + +const OnlinePlayer: FC<{ player: PlayerOutputDTO; pog: PlayerOnGameserverOutputDTO }> = ({ player }) => { + let avatarUrl = '/favicon.ico'; + + if (player.steamAvatar) avatarUrl = player.steamAvatar; + + return ( + + + + {player.name} + + + ); +}; + +export const OnlinePlayersCard: FC = () => { + const { selectedGameServerId } = useSelectedGameServer(); + + const { data, isLoading } = usePlayerOnGameServers({ + filters: { + online: [true], + gameServerId: [selectedGameServerId], + }, + extend: ['player'], + }); + + const { data: players, isLoading: isLoadingPlayers } = usePlayers({ + filters: { + id: data?.data.map((playerOnGameServer) => playerOnGameServer.playerId), + }, + }); + + if (isLoading || isLoadingPlayers) return ; + + return ( + <> +

Online Players

+ + {data?.data.map((playerOnGameServer) => { + const player = players?.data.find((player) => player.id === playerOnGameServer.playerId); + if (!player) return null; + + return ; + })} + + + ); +};