diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index e45b0dc..f9a3bce 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -7,49 +7,28 @@ import type { BombPlacedAckPayload, BombPlacedPayload, - HurricaneStatePayload, domain, GameStartPayload, GameResultPayload, HurricaneHitPayload, PlayerHitPayload, PongPayload, - CurrentHurricanesPayload, CurrentPlayersPayload, RemovePlayerPayload, - UpdateHurricanesPayload, - UpdatePlayersPayload, } from "@repo/shared"; import type { - ActiveBombSnapshot, BombPlacementOutputPort, PlayerHitOutputPort, GameOutputPort, } from "@server/domains/game/application/ports/gameUseCasePorts"; -import { isBotPlayerId } from "@server/domains/game/application/services/bot/index.js"; -import { - collectChangedUpdatePlayersPayload, - quantizeUpdatePlayersPayload, -} from "@server/network/adapters/gamePayloadSanitizers"; import { createRealtimeRoomSyncStateStore } from "@server/network/adapters/realtimeRoomSyncState"; import { createEmitToRoom } from "@server/network/adapters/socketEmitters"; import type { CommonHandlerContext } from "../CommonHandler"; -import type { - FindGameByRoomPort, - FindRoomByIdPort, -} from "@server/domains/room/application/ports/roomUseCasePorts"; -import { - isTargetInAoiWindow, - resolveViewerAoiCell, - resolveViewerAoiWindow, - type AoiWindow, -} from "./aoi/aoiVisibility"; -import { - getActiveBombSnapshotsInRoom, - getConnectedSocketIdsInRoom, - getRoomPlayers, - type RuntimeResolverDeps, -} from "./runtime/gameRuntimeResolvers"; +import { resolveViewerAoiCell } from "./aoi/aoiVisibility"; +import type { RuntimeResolverDeps } from "./runtime/gameRuntimeResolvers"; +import { createBombSyncService } from "./services/bombSyncService"; +import { createHurricaneSyncService } from "./services/hurricaneSyncService"; +import { createPlayerSyncService } from "./services/playerSyncService"; type RoomId = domain.room.Room["roomId"]; type SocketId = string; @@ -84,27 +63,7 @@ deps: RuntimeResolverDeps, ): GameOutputAdapter => { const { reliable } = common; - const { roomManager, runtimeRegistry } = deps; const realtimeRoomSyncState = createRealtimeRoomSyncStateStore(); - const hurricaneSnapshotByRoomId = new Map< - RoomId, - Map - >(); - - const getPlayersInRoom = (roomId: RoomId): domain.game.player.PlayerData[] => { - return getRoomPlayers(deps, roomId); - }; - - const getActiveBombsInRoom = (roomId: RoomId): ActiveBombSnapshot[] => { - return getActiveBombSnapshotsInRoom(deps, roomId); - }; - - const isInViewerAoi = ( - target: { x: number; y: number }, - aoiWindow: AoiWindow, - ): boolean => { - return isTargetInAoiWindow(target, aoiWindow); - }; const updateViewerAoiCellCache = ( roomId: RoomId, @@ -121,154 +80,25 @@ realtimeRoomSyncState.setLastAoiCell(roomId, viewerId, nextCell); }; - const syncVisiblePlayersByViewer = ( - roomId: RoomId, - viewerId: SocketId, - visiblePlayers: domain.game.player.PlayerData[], - ): void => { - const previousVisibleIds = realtimeRoomSyncState.getVisiblePlayerIdsSnapshot( - roomId, - viewerId, - ); - const viewerPositionCache = realtimeRoomSyncState.getPlayerPositionCache( - roomId, - viewerId, - ); - const nextVisibleIds = new Set(visiblePlayers.map((player) => player.id)); - - visiblePlayers.forEach((player) => { - if (!previousVisibleIds.has(player.id)) { - reliable.emitToSocketById(viewerId, protocol.SocketEvents.NEW_PLAYER, player); - } - }); - - previousVisibleIds.forEach((playerId) => { - if (nextVisibleIds.has(playerId)) { - return; - } - - viewerPositionCache.delete(playerId); - reliable.emitToSocketById(viewerId, protocol.SocketEvents.REMOVE_PLAYER, playerId); - }); - - realtimeRoomSyncState.replaceVisiblePlayerIds(roomId, viewerId, nextVisibleIds); - }; - - const syncVisibleBombsByViewer = ( - roomId: RoomId, - viewerId: SocketId, - viewer: domain.game.player.PlayerData, - bombs: ActiveBombSnapshot[], - ): void => { - updateViewerAoiCellCache(roomId, viewerId, viewer); - const aoiWindow = resolveViewerAoiWindow(viewer); - const previousVisibleBombIds = realtimeRoomSyncState.getVisibleBombIdsSnapshot( - roomId, - viewerId, - ); - const nextVisibleBombIds = new Set(); - - bombs.forEach((bomb) => { - if (!isInViewerAoi(bomb, aoiWindow)) { - return; - } - - nextVisibleBombIds.add(bomb.bombId); - if (previousVisibleBombIds.has(bomb.bombId)) { - return; - } - - if (viewerId === bomb.ownerPlayerId && !isBotPlayerId(bomb.ownerPlayerId)) { - return; - } - - const payload: BombPlacedPayload = { - bombId: bomb.bombId, - ownerTeamId: bomb.ownerTeamId, - x: bomb.x, - y: bomb.y, - explodeAtElapsedMs: bomb.explodeAtElapsedMs, - }; - reliable.emitToSocketById(viewerId, protocol.SocketEvents.BOMB_PLACED, payload); - }); - - realtimeRoomSyncState.replaceVisibleBombIds( - roomId, - viewerId, - nextVisibleBombIds, - ); - }; - - const replaceRoomHurricaneSnapshot = ( - roomId: RoomId, - hurricanes: HurricaneStatePayload[], - ): void => { - const snapshotMap = new Map(); - hurricanes.forEach((hurricane) => { - snapshotMap.set(hurricane.id, hurricane); - }); - hurricaneSnapshotByRoomId.set(roomId, snapshotMap); - }; - - const upsertRoomHurricaneSnapshot = ( - roomId: RoomId, - hurricanes: HurricaneStatePayload[], - ): void => { - const snapshotMap = hurricaneSnapshotByRoomId.get(roomId) ?? new Map(); - hurricanes.forEach((hurricane) => { - snapshotMap.set(hurricane.id, hurricane); - }); - hurricaneSnapshotByRoomId.set(roomId, snapshotMap); - }; - - const collectVisibleHurricanesByViewer = ( - roomId: RoomId, - viewerId: SocketId, - viewer: domain.game.player.PlayerData, - hurricanes: Iterable, - ): HurricaneStatePayload[] => { - updateViewerAoiCellCache(roomId, viewerId, viewer); - const aoiWindow = resolveViewerAoiWindow(viewer); - const visibleHurricanes: HurricaneStatePayload[] = []; - - for (const hurricane of hurricanes) { - if (isInViewerAoi(hurricane, aoiWindow)) { - visibleHurricanes.push(hurricane); - } - } - - return visibleHurricanes; - }; - - const syncVisibleHurricaneIdsByViewer = ( - roomId: RoomId, - viewerId: SocketId, - hurricanes: HurricaneStatePayload[], - ): void => { - const nextVisibleIds = hurricanes.map((hurricane) => hurricane.id); - realtimeRoomSyncState.replaceVisibleHurricaneIds( - roomId, - viewerId, - nextVisibleIds, - ); - }; - - const hasChangedVisibleHurricaneIds = ( - previousVisibleIds: Set, - nextVisibleHurricanes: HurricaneStatePayload[], - ): boolean => { - if (previousVisibleIds.size !== nextVisibleHurricanes.length) { - return true; - } - - for (const hurricane of nextVisibleHurricanes) { - if (!previousVisibleIds.has(hurricane.id)) { - return true; - } - } - - return false; - }; + const bombSyncService = createBombSyncService({ + reliable, + runtimeDeps: deps, + realtimeRoomSyncState, + updateViewerAoiCellCache, + }); + const playerSyncService = createPlayerSyncService({ + reliable, + runtimeDeps: deps, + realtimeRoomSyncState, + bombSyncService, + updateViewerAoiCellCache, + }); + const hurricaneSyncService = createHurricaneSyncService({ + reliable, + runtimeDeps: deps, + realtimeRoomSyncState, + updateViewerAoiCellCache, + }); const emitReliableToRoom = ( roomId: RoomId, @@ -287,67 +117,8 @@ publishPongToSocket: (payload: PongPayload) => { reliable.emitToSocket(protocol.SocketEvents.PONG, payload); }, - publishUpdatePlayersToRoom: ( - roomId: RoomId, - players: UpdatePlayersPayload, - ) => { - const roomPlayers = getPlayersInRoom(roomId); - const activeBombs = getActiveBombsInRoom(roomId); - if (roomPlayers.length === 0) { - return; - } - - const quantizedPlayers = quantizeUpdatePlayersPayload(players); - const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); - const recipientSocketIds = getConnectedSocketIdsInRoom(deps, roomId); - - recipientSocketIds.forEach((viewerId) => { - const viewer = roomPlayerById.get(viewerId); - if (!viewer) { - return; - } - - syncVisibleBombsByViewer(roomId, viewerId, viewer, activeBombs); - - updateViewerAoiCellCache(roomId, viewerId, viewer); - const aoiWindow = resolveViewerAoiWindow(viewer); - - const visibleSnapshotPlayers = roomPlayers.filter((player) => { - if (player.id === viewerId) { - return false; - } - - return isInViewerAoi(player, aoiWindow); - }); - syncVisiblePlayersByViewer(roomId, viewerId, visibleSnapshotPlayers); - - const visibleDeltaPlayers = quantizedPlayers.filter((player) => { - if (player.id === viewerId) { - return false; - } - - return isInViewerAoi(player, aoiWindow); - }); - - const viewerPositionCache = realtimeRoomSyncState.getPlayerPositionCache( - roomId, - viewerId, - ); - const changedPlayers = collectChangedUpdatePlayersPayload( - visibleDeltaPlayers, - viewerPositionCache, - ); - - if (changedPlayers.length === 0) { - return; - } - - reliable.emitToSocketById( - viewerId, - protocol.SocketEvents.UPDATE_PLAYERS, - changedPlayers, - ); - }); + publishUpdatePlayersToRoom: (roomId, players) => { + playerSyncService.publishUpdatePlayersToRoom(roomId, players); }, publishMapCellUpdatesToRoom: ( @@ -357,105 +128,15 @@ const grouped = domainNs.game.gridMap.groupCellUpdates(cellUpdates); emitReliableToRoom(roomId, protocol.SocketEvents.UPDATE_MAP_CELLS, grouped); }, - publishCurrentHurricanesToRoom: ( - roomId: RoomId, - hurricanes: CurrentHurricanesPayload, - ) => { - replaceRoomHurricaneSnapshot(roomId, hurricanes); - - const roomPlayers = getPlayersInRoom(roomId); - if (roomPlayers.length === 0) { - return; - } - - const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); - const recipientSocketIds = getConnectedSocketIdsInRoom(deps, roomId); - - recipientSocketIds.forEach((viewerId) => { - const viewer = roomPlayerById.get(viewerId); - if (!viewer) { - return; - } - - const visibleHurricanes = collectVisibleHurricanesByViewer( - roomId, - viewerId, - viewer, - hurricanes, - ); - syncVisibleHurricaneIdsByViewer(roomId, viewerId, visibleHurricanes); - - reliable.emitToSocketById( - viewerId, - protocol.SocketEvents.CURRENT_HURRICANES, - visibleHurricanes, - ); - }); + publishCurrentHurricanesToRoom: (roomId, hurricanes) => { + hurricaneSyncService.publishCurrentHurricanesToRoom(roomId, hurricanes); }, - publishUpdateHurricanesToRoom: ( - roomId: RoomId, - hurricanes: UpdateHurricanesPayload, - ) => { - upsertRoomHurricaneSnapshot(roomId, hurricanes); - - const roomPlayers = getPlayersInRoom(roomId); - if (roomPlayers.length === 0) { - return; - } - - const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); - const recipientSocketIds = getConnectedSocketIdsInRoom(deps, roomId); - const roomSnapshot = hurricaneSnapshotByRoomId.get(roomId); - - recipientSocketIds.forEach((viewerId) => { - const viewer = roomPlayerById.get(viewerId); - if (!viewer) { - return; - } - - const nextVisibleHurricanes = collectVisibleHurricanesByViewer( - roomId, - viewerId, - viewer, - roomSnapshot?.values() ?? [], - ); - const previousVisibleIds = new Set( - realtimeRoomSyncState.getVisibleHurricaneIdsSnapshot(roomId, viewerId), - ); - const hasMembershipChanged = hasChangedVisibleHurricaneIds( - previousVisibleIds, - nextVisibleHurricanes, - ); - - if (hasMembershipChanged) { - reliable.emitToSocketById( - viewerId, - protocol.SocketEvents.CURRENT_HURRICANES, - nextVisibleHurricanes, - ); - syncVisibleHurricaneIdsByViewer(roomId, viewerId, nextVisibleHurricanes); - return; - } - - const nextVisibleIdSet = new Set(nextVisibleHurricanes.map((hurricane) => hurricane.id)); - const visibleUpdateHurricanes = hurricanes.filter((hurricane) => { - return nextVisibleIdSet.has(hurricane.id); - }); - if (visibleUpdateHurricanes.length === 0) { - return; - } - - reliable.emitToSocketById( - viewerId, - protocol.SocketEvents.UPDATE_HURRICANES, - visibleUpdateHurricanes, - ); - syncVisibleHurricaneIdsByViewer(roomId, viewerId, nextVisibleHurricanes); - }); + publishUpdateHurricanesToRoom: (roomId, hurricanes) => { + hurricaneSyncService.publishUpdateHurricanesToRoom(roomId, hurricanes); }, publishGameEndToRoom: (roomId: RoomId) => { realtimeRoomSyncState.resetRoom(roomId); - hurricaneSnapshotByRoomId.delete(roomId); + hurricaneSyncService.clearRoomSnapshot(roomId); emitReliableToRoom(roomId, protocol.SocketEvents.GAME_END); }, publishGameResultToRoom: (roomId: RoomId, payload: GameResultPayload) => { @@ -463,7 +144,7 @@ }, publishGameStartToRoom: (roomId: RoomId, payload: GameStartPayload) => { realtimeRoomSyncState.resetRoom(roomId); - hurricaneSnapshotByRoomId.delete(roomId); + hurricaneSyncService.clearRoomSnapshot(roomId); emitReliableToRoom(roomId, protocol.SocketEvents.GAME_START, payload); }, publishCurrentPlayersToSocket: (players: CurrentPlayersPayload) => { @@ -477,32 +158,11 @@ excludedSocketId: string, payload: BombPlacedPayload, ) => { - const roomPlayers = getPlayersInRoom(roomId); - if (roomPlayers.length === 0) { - return; - } - - const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); - const recipientSocketIds = getConnectedSocketIdsInRoom(deps, roomId); - - recipientSocketIds.forEach((viewerId) => { - if (viewerId === excludedSocketId && !isBotPlayerId(excludedSocketId)) { - return; - } - - const viewer = roomPlayerById.get(viewerId); - if (!viewer) { - return; - } - - updateViewerAoiCellCache(roomId, viewerId, viewer); - const aoiWindow = resolveViewerAoiWindow(viewer); - if (!isInViewerAoi(payload, aoiWindow)) { - return; - } - - reliable.emitToSocketById(viewerId, protocol.SocketEvents.BOMB_PLACED, payload); - }); + bombSyncService.publishBombPlacedToOthersInRoom( + roomId, + excludedSocketId, + payload, + ); }, publishBombPlacedAckToSocket: ( socketId: string, diff --git a/apps/server/src/network/handlers/game/services/bombSyncService.ts b/apps/server/src/network/handlers/game/services/bombSyncService.ts new file mode 100644 index 0000000..69ef797 --- /dev/null +++ b/apps/server/src/network/handlers/game/services/bombSyncService.ts @@ -0,0 +1,137 @@ +/** + * bombSyncService + * 爆弾のAOI同期と設置通知送信を提供する + * 可視集合キャッシュ更新とAOI内配信を集約する + */ +import { contracts as protocol, domain } from "@repo/shared"; +import type { BombPlacedPayload } from "@repo/shared"; +import type { ActiveBombSnapshot } from "@server/domains/game/application/ports/gameUseCasePorts"; +import { isBotPlayerId } from "@server/domains/game/application/services/bot/index.js"; +import type { RealtimeRoomSyncStateStore } from "@server/network/adapters/realtimeRoomSyncState"; +import type { ReliableEmitters } from "../../CommonHandler"; +import { + isTargetInAoiWindow, + resolveViewerAoiWindow, + type AoiWindow, +} from "../aoi/aoiVisibility"; +import { + getConnectedSocketIdsInRoom, + getRoomPlayers, + type RuntimeResolverDeps, +} from "../runtime/gameRuntimeResolvers"; + +type RoomId = domain.room.Room["roomId"]; +type SocketId = string; + +/** 爆弾同期サービスが提供する操作契約 */ +export type BombSyncService = { + syncVisibleBombsByViewer: ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + bombs: ActiveBombSnapshot[], + ) => void; + publishBombPlacedToOthersInRoom: ( + roomId: RoomId, + excludedSocketId: string, + payload: BombPlacedPayload, + ) => void; +}; + +/** 爆弾同期サービス生成時の依存集合 */ +export type CreateBombSyncServiceDeps = { + reliable: ReliableEmitters; + runtimeDeps: RuntimeResolverDeps; + realtimeRoomSyncState: RealtimeRoomSyncStateStore; + updateViewerAoiCellCache: ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + ) => void; +}; + +/** 爆弾のAOI同期サービスを生成する */ +export const createBombSyncService = ( + deps: CreateBombSyncServiceDeps, +): BombSyncService => { + const isInViewerAoi = ( + target: { x: number; y: number }, + aoiWindow: AoiWindow, + ): boolean => { + return isTargetInAoiWindow(target, aoiWindow); + }; + + return { + syncVisibleBombsByViewer: (roomId, viewerId, viewer, bombs) => { + deps.updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = resolveViewerAoiWindow(viewer); + const previousVisibleBombIds = deps.realtimeRoomSyncState.getVisibleBombIdsSnapshot( + roomId, + viewerId, + ); + const nextVisibleBombIds = new Set(); + + bombs.forEach((bomb) => { + if (!isInViewerAoi(bomb, aoiWindow)) { + return; + } + + nextVisibleBombIds.add(bomb.bombId); + if (previousVisibleBombIds.has(bomb.bombId)) { + return; + } + + if (viewerId === bomb.ownerPlayerId && !isBotPlayerId(bomb.ownerPlayerId)) { + return; + } + + const syncPayload: BombPlacedPayload = { + bombId: bomb.bombId, + ownerTeamId: bomb.ownerTeamId, + x: bomb.x, + y: bomb.y, + explodeAtElapsedMs: bomb.explodeAtElapsedMs, + }; + deps.reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.BOMB_PLACED, + syncPayload, + ); + }); + + deps.realtimeRoomSyncState.replaceVisibleBombIds( + roomId, + viewerId, + nextVisibleBombIds, + ); + }, + publishBombPlacedToOthersInRoom: (roomId, excludedSocketId, payload) => { + const roomPlayers = getRoomPlayers(deps.runtimeDeps, roomId); + if (roomPlayers.length === 0) { + return; + } + + const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); + const recipientSocketIds = getConnectedSocketIdsInRoom(deps.runtimeDeps, roomId); + + recipientSocketIds.forEach((viewerId) => { + if (viewerId === excludedSocketId && !isBotPlayerId(excludedSocketId)) { + return; + } + + const viewer = roomPlayerById.get(viewerId); + if (!viewer) { + return; + } + + deps.updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = resolveViewerAoiWindow(viewer); + if (!isInViewerAoi(payload, aoiWindow)) { + return; + } + + deps.reliable.emitToSocketById(viewerId, protocol.SocketEvents.BOMB_PLACED, payload); + }); + }, + }; +}; diff --git a/apps/server/src/network/handlers/game/services/hurricaneSyncService.ts b/apps/server/src/network/handlers/game/services/hurricaneSyncService.ts new file mode 100644 index 0000000..e745955 --- /dev/null +++ b/apps/server/src/network/handlers/game/services/hurricaneSyncService.ts @@ -0,0 +1,228 @@ +/** + * hurricaneSyncService + * ハリケーンのAOI同期送信を提供する + * 受信者ごとの可視集合差分とルーム内最新スナップショットを管理する + */ +import { contracts as protocol, domain } from "@repo/shared"; +import type { + CurrentHurricanesPayload, + HurricaneStatePayload, + UpdateHurricanesPayload, +} from "@repo/shared"; +import type { RealtimeRoomSyncStateStore } from "@server/network/adapters/realtimeRoomSyncState"; +import type { ReliableEmitters } from "../../CommonHandler"; +import { isTargetInAoiWindow, resolveViewerAoiWindow, type AoiWindow } from "../aoi/aoiVisibility"; +import { + getConnectedSocketIdsInRoom, + getRoomPlayers, + type RuntimeResolverDeps, +} from "../runtime/gameRuntimeResolvers"; + +type RoomId = domain.room.Room["roomId"]; +type SocketId = string; + +/** ハリケーン同期サービスが提供する操作契約 */ +export type HurricaneSyncService = { + publishCurrentHurricanesToRoom: ( + roomId: RoomId, + hurricanes: CurrentHurricanesPayload, + ) => void; + publishUpdateHurricanesToRoom: ( + roomId: RoomId, + hurricanes: UpdateHurricanesPayload, + ) => void; + clearRoomSnapshot: (roomId: RoomId) => void; +}; + +/** ハリケーン同期サービス生成時の依存集合 */ +export type CreateHurricaneSyncServiceDeps = { + reliable: ReliableEmitters; + runtimeDeps: RuntimeResolverDeps; + realtimeRoomSyncState: RealtimeRoomSyncStateStore; + updateViewerAoiCellCache: ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + ) => void; +}; + +/** ハリケーンAOI同期サービスを生成する */ +export const createHurricaneSyncService = ( + deps: CreateHurricaneSyncServiceDeps, +): HurricaneSyncService => { + const hurricaneSnapshotByRoomId = new Map>(); + + const isInViewerAoi = ( + target: { x: number; y: number }, + aoiWindow: AoiWindow, + ): boolean => { + return isTargetInAoiWindow(target, aoiWindow); + }; + + const replaceRoomHurricaneSnapshot = ( + roomId: RoomId, + hurricanes: HurricaneStatePayload[], + ): void => { + const snapshotMap = new Map(); + hurricanes.forEach((hurricane) => { + snapshotMap.set(hurricane.id, hurricane); + }); + hurricaneSnapshotByRoomId.set(roomId, snapshotMap); + }; + + const upsertRoomHurricaneSnapshot = ( + roomId: RoomId, + hurricanes: HurricaneStatePayload[], + ): void => { + const snapshotMap = hurricaneSnapshotByRoomId.get(roomId) ?? new Map(); + hurricanes.forEach((hurricane) => { + snapshotMap.set(hurricane.id, hurricane); + }); + hurricaneSnapshotByRoomId.set(roomId, snapshotMap); + }; + + const collectVisibleHurricanesByViewer = ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + hurricanes: Iterable, + ): HurricaneStatePayload[] => { + deps.updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = resolveViewerAoiWindow(viewer); + const visibleHurricanes: HurricaneStatePayload[] = []; + + for (const hurricane of hurricanes) { + if (isInViewerAoi(hurricane, aoiWindow)) { + visibleHurricanes.push(hurricane); + } + } + + return visibleHurricanes; + }; + + const syncVisibleHurricaneIdsByViewer = ( + roomId: RoomId, + viewerId: SocketId, + hurricanes: HurricaneStatePayload[], + ): void => { + const nextVisibleIds = hurricanes.map((hurricane) => hurricane.id); + deps.realtimeRoomSyncState.replaceVisibleHurricaneIds( + roomId, + viewerId, + nextVisibleIds, + ); + }; + + const hasChangedVisibleHurricaneIds = ( + previousVisibleIds: Set, + nextVisibleHurricanes: HurricaneStatePayload[], + ): boolean => { + if (previousVisibleIds.size !== nextVisibleHurricanes.length) { + return true; + } + + for (const hurricane of nextVisibleHurricanes) { + if (!previousVisibleIds.has(hurricane.id)) { + return true; + } + } + + return false; + }; + + return { + publishCurrentHurricanesToRoom: (roomId, hurricanes) => { + replaceRoomHurricaneSnapshot(roomId, hurricanes); + + const roomPlayers = getRoomPlayers(deps.runtimeDeps, roomId); + if (roomPlayers.length === 0) { + return; + } + + const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); + const recipientSocketIds = getConnectedSocketIdsInRoom(deps.runtimeDeps, roomId); + + recipientSocketIds.forEach((viewerId) => { + const viewer = roomPlayerById.get(viewerId); + if (!viewer) { + return; + } + + const visibleHurricanes = collectVisibleHurricanesByViewer( + roomId, + viewerId, + viewer, + hurricanes, + ); + syncVisibleHurricaneIdsByViewer(roomId, viewerId, visibleHurricanes); + + deps.reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.CURRENT_HURRICANES, + visibleHurricanes, + ); + }); + }, + publishUpdateHurricanesToRoom: (roomId, hurricanes) => { + upsertRoomHurricaneSnapshot(roomId, hurricanes); + + const roomPlayers = getRoomPlayers(deps.runtimeDeps, roomId); + if (roomPlayers.length === 0) { + return; + } + + const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); + const recipientSocketIds = getConnectedSocketIdsInRoom(deps.runtimeDeps, roomId); + const roomSnapshot = hurricaneSnapshotByRoomId.get(roomId); + + recipientSocketIds.forEach((viewerId) => { + const viewer = roomPlayerById.get(viewerId); + if (!viewer) { + return; + } + + const nextVisibleHurricanes = collectVisibleHurricanesByViewer( + roomId, + viewerId, + viewer, + roomSnapshot?.values() ?? [], + ); + const previousVisibleIds = new Set( + deps.realtimeRoomSyncState.getVisibleHurricaneIdsSnapshot(roomId, viewerId), + ); + const hasMembershipChanged = hasChangedVisibleHurricaneIds( + previousVisibleIds, + nextVisibleHurricanes, + ); + + if (hasMembershipChanged) { + deps.reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.CURRENT_HURRICANES, + nextVisibleHurricanes, + ); + syncVisibleHurricaneIdsByViewer(roomId, viewerId, nextVisibleHurricanes); + return; + } + + const nextVisibleIdSet = new Set(nextVisibleHurricanes.map((hurricane) => hurricane.id)); + const visibleUpdateHurricanes = hurricanes.filter((hurricane) => { + return nextVisibleIdSet.has(hurricane.id); + }); + if (visibleUpdateHurricanes.length === 0) { + return; + } + + deps.reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.UPDATE_HURRICANES, + visibleUpdateHurricanes, + ); + syncVisibleHurricaneIdsByViewer(roomId, viewerId, nextVisibleHurricanes); + }); + }, + clearRoomSnapshot: (roomId) => { + hurricaneSnapshotByRoomId.delete(roomId); + }, + }; +}; diff --git a/apps/server/src/network/handlers/game/services/playerSyncService.ts b/apps/server/src/network/handlers/game/services/playerSyncService.ts new file mode 100644 index 0000000..8437fae --- /dev/null +++ b/apps/server/src/network/handlers/game/services/playerSyncService.ts @@ -0,0 +1,154 @@ +/** + * playerSyncService + * プレイヤー差分同期と可視プレイヤー管理を提供する + * AOI可視判定と前回送信位置キャッシュを利用して更新量を最小化する + */ +import { contracts as protocol, domain } from "@repo/shared"; +import type { UpdatePlayersPayload } from "@repo/shared"; +import { collectChangedUpdatePlayersPayload, quantizeUpdatePlayersPayload } from "@server/network/adapters/gamePayloadSanitizers"; +import type { RealtimeRoomSyncStateStore } from "@server/network/adapters/realtimeRoomSyncState"; +import type { ReliableEmitters } from "../../CommonHandler"; +import { isTargetInAoiWindow, resolveViewerAoiWindow, type AoiWindow } from "../aoi/aoiVisibility"; +import { + getActiveBombSnapshotsInRoom, + getConnectedSocketIdsInRoom, + getRoomPlayers, + type RuntimeResolverDeps, +} from "../runtime/gameRuntimeResolvers"; +import type { BombSyncService } from "./bombSyncService"; + +type RoomId = domain.room.Room["roomId"]; +type SocketId = string; + +/** プレイヤー同期サービスが提供する操作契約 */ +export type PlayerSyncService = { + publishUpdatePlayersToRoom: ( + roomId: RoomId, + players: UpdatePlayersPayload, + ) => void; +}; + +/** プレイヤー同期サービス生成時の依存集合 */ +export type CreatePlayerSyncServiceDeps = { + reliable: ReliableEmitters; + runtimeDeps: RuntimeResolverDeps; + realtimeRoomSyncState: RealtimeRoomSyncStateStore; + bombSyncService: Pick; + updateViewerAoiCellCache: ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + ) => void; +}; + +/** プレイヤー差分同期サービスを生成する */ +export const createPlayerSyncService = ( + deps: CreatePlayerSyncServiceDeps, +): PlayerSyncService => { + const isInViewerAoi = ( + target: { x: number; y: number }, + aoiWindow: AoiWindow, + ): boolean => { + return isTargetInAoiWindow(target, aoiWindow); + }; + + const syncVisiblePlayersByViewer = ( + roomId: RoomId, + viewerId: SocketId, + visiblePlayers: domain.game.player.PlayerData[], + ): void => { + const previousVisibleIds = deps.realtimeRoomSyncState.getVisiblePlayerIdsSnapshot( + roomId, + viewerId, + ); + const viewerPositionCache = deps.realtimeRoomSyncState.getPlayerPositionCache( + roomId, + viewerId, + ); + const nextVisibleIds = new Set(visiblePlayers.map((player) => player.id)); + + visiblePlayers.forEach((player) => { + if (!previousVisibleIds.has(player.id)) { + deps.reliable.emitToSocketById(viewerId, protocol.SocketEvents.NEW_PLAYER, player); + } + }); + + previousVisibleIds.forEach((playerId) => { + if (nextVisibleIds.has(playerId)) { + return; + } + + viewerPositionCache.delete(playerId); + deps.reliable.emitToSocketById(viewerId, protocol.SocketEvents.REMOVE_PLAYER, playerId); + }); + + deps.realtimeRoomSyncState.replaceVisiblePlayerIds(roomId, viewerId, nextVisibleIds); + }; + + return { + publishUpdatePlayersToRoom: (roomId, players) => { + const roomPlayers = getRoomPlayers(deps.runtimeDeps, roomId); + const activeBombs = getActiveBombSnapshotsInRoom(deps.runtimeDeps, roomId); + if (roomPlayers.length === 0) { + return; + } + + const quantizedPlayers = quantizeUpdatePlayersPayload(players); + const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); + const recipientSocketIds = getConnectedSocketIdsInRoom(deps.runtimeDeps, roomId); + + recipientSocketIds.forEach((viewerId) => { + const viewer = roomPlayerById.get(viewerId); + if (!viewer) { + return; + } + + deps.bombSyncService.syncVisibleBombsByViewer( + roomId, + viewerId, + viewer, + activeBombs, + ); + + deps.updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = resolveViewerAoiWindow(viewer); + + const visibleSnapshotPlayers = roomPlayers.filter((player) => { + if (player.id === viewerId) { + return false; + } + + return isInViewerAoi(player, aoiWindow); + }); + syncVisiblePlayersByViewer(roomId, viewerId, visibleSnapshotPlayers); + + const visibleDeltaPlayers = quantizedPlayers.filter((player) => { + if (player.id === viewerId) { + return false; + } + + return isInViewerAoi(player, aoiWindow); + }); + + const viewerPositionCache = deps.realtimeRoomSyncState.getPlayerPositionCache( + roomId, + viewerId, + ); + const changedPlayers = collectChangedUpdatePlayersPayload( + visibleDeltaPlayers, + viewerPositionCache, + ); + + if (changedPlayers.length === 0) { + return; + } + + deps.reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.UPDATE_PLAYERS, + changedPlayers, + ); + }); + }, + }; +};