diff --git a/apps/server/src/application/coordinators/disconnectCoordinator.ts b/apps/server/src/application/coordinators/disconnectCoordinator.ts index dcb2d76..c76880a 100644 --- a/apps/server/src/application/coordinators/disconnectCoordinator.ts +++ b/apps/server/src/application/coordinators/disconnectCoordinator.ts @@ -3,11 +3,13 @@ * DISCONNECTイベントの調停を行い,ゲーム離脱処理とルーム離脱処理を順序実行する */ import { + type BombCleanupPort, type DisconnectPlayerPort, type GameOutputPort, } from "@server/domains/game/application/ports/gameUseCasePorts"; import type { DisconnectRoomPort, + FindRoomByIdPort, FindRoomByPlayerPort, RoomOutputPort, } from "@server/domains/room/application/ports/roomUseCasePorts"; @@ -17,8 +19,8 @@ /** 切断調停で利用する入力ポートと出力ポートの契約 */ export type DisconnectCoordinatorParams = { socketId: string; - gameManager: DisconnectPlayerPort; - roomManager: DisconnectRoomPort & FindRoomByPlayerPort; + gameManager: DisconnectPlayerPort & BombCleanupPort; + roomManager: DisconnectRoomPort & FindRoomByPlayerPort & FindRoomByIdPort; gameOutput: Pick; roomOutput: Pick; }; @@ -45,4 +47,8 @@ socketId, output: roomOutput, }); + + if (roomId && !roomManager.getRoomById(roomId)) { + gameManager.clearBombRoomState(roomId, "room-deleted"); + } }; diff --git a/apps/server/src/application/coordinators/readyForGameCoordinator.ts b/apps/server/src/application/coordinators/readyForGameCoordinator.ts index 407dc01..c87243b 100644 --- a/apps/server/src/application/coordinators/readyForGameCoordinator.ts +++ b/apps/server/src/application/coordinators/readyForGameCoordinator.ts @@ -4,15 +4,15 @@ */ import { type GameOutputPort, + type GameRoomLookupPort, type ReadyForGamePort, - type ReadyForGameRoomPort, } from "@server/domains/game/application/ports/gameUseCasePorts"; import { readyForGameUseCase } from "@server/domains/game/application/useCases/readyForGameUseCase"; type ReadyForGameCoordinatorParams = { socketId: string; gameManager: ReadyForGamePort; - roomManager: ReadyForGameRoomPort; + roomManager: GameRoomLookupPort; output: Pick; }; diff --git a/apps/server/src/application/coordinators/startGameCoordinator.ts b/apps/server/src/application/coordinators/startGameCoordinator.ts index 602e37b..766b626 100644 --- a/apps/server/src/application/coordinators/startGameCoordinator.ts +++ b/apps/server/src/application/coordinators/startGameCoordinator.ts @@ -3,6 +3,7 @@ * START_GAMEイベントの調停を行い,ルーム状態更新とゲーム開始処理を橋渡しする */ import { + type BombCleanupPort, type GameOutputPort, type StartGamePort, type StartGameRoomPort, @@ -14,9 +15,8 @@ type StartGameCoordinatorParams = { ownerId: string; - gameManager: StartGamePort; + gameManager: StartGamePort & BombCleanupPort; roomManager: StartGameRoomPort; - onRoomGameEnded?: (roomId: string) => void; output: Pick< GameOutputPort, | "publishUpdatePlayersToRoom" @@ -32,7 +32,6 @@ ownerId, gameManager, roomManager, - onRoomGameEnded, output, }: StartGameCoordinatorParams) => { const room = roomManager.getRoomByOwnerId(ownerId); @@ -82,7 +81,7 @@ gameManager, onGameEnd: () => { roomManager.markRoomWaiting(updatedRoom.roomId); - onRoomGameEnded?.(updatedRoom.roomId); + gameManager.clearBombRoomState(updatedRoom.roomId, "game-ended"); }, output, }); diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 389f85c..3f86bcf 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -3,6 +3,10 @@ * ゲームセッション集合の生成,更新,参照管理を統括する */ import type { gameTypes, GameResultPayload } from "@repo/shared"; +import { + BombRoomStateClearReason, + BombRoomStateStore, +} from "./entities/bomb/BombRoomStateStore"; import { Player } from "./entities/player/Player.js"; import { GameRoomSession } from "./application/services/GameRoomSession"; import { GameSessionLifecycleService } from "./application/services/GameSessionLifecycleService"; @@ -16,6 +20,7 @@ private roomToPlayers: Map>; private lifecycleService: GameSessionLifecycleService; private playerOperationService: GamePlayerOperationService; + private bombRoomStateStore: BombRoomStateStore; constructor() { this.sessions = new Map(); @@ -23,6 +28,7 @@ this.roomToPlayers = new Map(); this.lifecycleService = new GameSessionLifecycleService(this.sessions, this.playerToRoom, this.roomToPlayers); this.playerOperationService = new GamePlayerOperationService(this.sessions, this.playerToRoom, this.roomToPlayers); + this.bombRoomStateStore = new BombRoomStateStore(); } // 外部(GameHandlerなど)から開始時刻を取得できるようにする @@ -59,4 +65,19 @@ getRoomPlayers(roomId: string): Player[] { return this.lifecycleService.getRoomPlayers(roomId); } + + // 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する + shouldBroadcastBombPlaced(roomId: string, dedupeKey: string, nowMs: number): boolean { + return this.bombRoomStateStore.shouldBroadcastBombPlaced(roomId, dedupeKey, nowMs); + } + + // ルーム単位の連番からサーバー採番の爆弾IDを生成する + issueServerBombId(roomId: string): string { + return this.bombRoomStateStore.issueServerBombId(roomId); + } + + // 指定ルームの爆弾採番状態と重複排除状態を破棄する + clearBombRoomState(roomId: string, reason: BombRoomStateClearReason): void { + this.bombRoomStateStore.clearBombRoomState(roomId, reason); + } } \ No newline at end of file diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 637ded1..a269b40 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -2,9 +2,12 @@ * gameUseCasePorts * ゲーム系ユースケースが利用する入力ポートと出力ポートの契約を定義する */ +import type { BombRoomStateClearReason } from "@server/domains/game/entities/bomb/BombRoomStateStore"; import type { + BombPlacedPayload, gameTypes, playerTypes, + PlaceBombPayload, roomTypes, CurrentPlayersPayload, GameResultPayload, @@ -33,8 +36,8 @@ markRoomWaiting(roomId: string): roomTypes.Room | undefined; } -/** 準備完了調停で利用するルーム解決入力ポート */ -export interface ReadyForGameRoomPort { +/** ゲーム系調停で利用するプレイヤー所属ルーム解決入力ポート */ +export interface GameRoomLookupPort { getRoomByPlayerId(playerId: string): roomTypes.Room | undefined; } @@ -70,5 +73,27 @@ publishGameStartToRoom(roomId: roomTypes.Room["roomId"], payload: GameStartPayload): void; publishCurrentPlayersToSocket(players: CurrentPlayersPayload): void; publishGameStartToSocket(payload: GameStartPayload): void; + publishBombPlacedToRoom(roomId: roomTypes.Room["roomId"], payload: BombPlacedPayload): void; publishPlayerRemovedToRoom(roomId: roomTypes.Room["roomId"], removedPlayerId: RemovePlayerPayload): void; } + +/** 爆弾設置ユースケースが利用する爆弾状態入力ポート */ +export interface BombPlacementPort { + shouldBroadcastBombPlaced(roomId: string, dedupeKey: string, nowMs: number): boolean; + issueServerBombId(roomId: string): string; +} + +/** 爆弾状態破棄ユースケースが利用する入力ポート */ +export interface BombCleanupPort { + clearBombRoomState(roomId: string, reason: BombRoomStateClearReason): void; +} + +/** 爆弾状態の参照更新と破棄を扱う統合入力ポート */ +export interface BombStatePort extends BombPlacementPort, BombCleanupPort {} + +/** 爆弾設置ユースケースの入力値 */ +export type PlaceBombInput = { + socketId: string; + payload: PlaceBombPayload; + nowMs: number; +}; diff --git a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts new file mode 100644 index 0000000..a0e1290 --- /dev/null +++ b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts @@ -0,0 +1,41 @@ +/** + * placeBombUseCase + * 爆弾設置入力を検証済み前提で処理し,ルーム配信を実行する + */ +import type { + BombPlacementPort, + GameOutputPort, + GameRoomLookupPort, + PlaceBombInput, +} from "../ports/gameUseCasePorts"; + +type PlaceBombUseCaseParams = { + roomResolver: GameRoomLookupPort; + bombStore: BombPlacementPort; + input: PlaceBombInput; + output: Pick; +}; + +/** 爆弾設置入力を重複排除と採番付きでルームへ配信する */ +export const placeBombUseCase = ({ + roomResolver, + bombStore, + input, + output, +}: PlaceBombUseCaseParams): void => { + const roomId = roomResolver.getRoomByPlayerId(input.socketId)?.roomId; + if (!roomId) { + return; + } + + const dedupeKey = `${input.socketId}:${input.payload.requestId}`; + if (!bombStore.shouldBroadcastBombPlaced(roomId, dedupeKey, input.nowMs)) { + return; + } + + output.publishBombPlacedToRoom(roomId, { + ...input.payload, + bombId: bombStore.issueServerBombId(roomId), + ownerId: input.socketId, + }); +}; diff --git a/apps/server/src/domains/game/entities/bomb/BombRoomStateStore.ts b/apps/server/src/domains/game/entities/bomb/BombRoomStateStore.ts index d305563..cd4e006 100644 --- a/apps/server/src/domains/game/entities/bomb/BombRoomStateStore.ts +++ b/apps/server/src/domains/game/entities/bomb/BombRoomStateStore.ts @@ -4,59 +4,65 @@ */ import { config } from "@repo/shared"; -type BombRoomStateClearReason = "game-ended" | "room-deleted"; +/** 爆弾状態破棄理由の識別子 */ +export type BombRoomStateClearReason = "game-ended" | "room-deleted"; -const roomBombDedupTable = new Map>(); -const roomBombSerialTable = new Map(); -const isBombRoomStateDebugEnabled = process.env.NODE_ENV !== "production"; +/** ルーム単位の爆弾重複排除状態と採番状態を保持するストア */ +export class BombRoomStateStore { + private roomBombDedupTable = new Map>(); + private roomBombSerialTable = new Map(); + private isBombRoomStateDebugEnabled = process.env.NODE_ENV !== "production"; -const cleanupExpiredBombDedup = (roomId: string, nowMs: number) => { - const roomTable = roomBombDedupTable.get(roomId); - if (!roomTable) return; + /** 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ + public shouldBroadcastBombPlaced(roomId: string, dedupeKey: string, nowMs: number): boolean { + this.cleanupExpiredBombDedup(roomId, nowMs); - roomTable.forEach((expiresAtMs, dedupeKey) => { - if (expiresAtMs <= nowMs) { - roomTable.delete(dedupeKey); + const roomTable = this.roomBombDedupTable.get(roomId) ?? new Map(); + if (roomTable.has(dedupeKey)) { + return false; } - }); - if (roomTable.size === 0) { - roomBombDedupTable.delete(roomId); - } -}; - -/** 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ -export const shouldBroadcastBombPlaced = (roomId: string, dedupeKey: string, nowMs: number): boolean => { - cleanupExpiredBombDedup(roomId, nowMs); - - const roomTable = roomBombDedupTable.get(roomId) ?? new Map(); - if (roomTable.has(dedupeKey)) { - return false; + const ttlMs = config.GAME_CONFIG.BOMB_FUSE_MS + config.GAME_CONFIG.BOMB_DEDUP_EXTRA_TTL_MS; + roomTable.set(dedupeKey, nowMs + ttlMs); + this.roomBombDedupTable.set(roomId, roomTable); + return true; } - const ttlMs = config.GAME_CONFIG.BOMB_FUSE_MS + config.GAME_CONFIG.BOMB_DEDUP_EXTRA_TTL_MS; - roomTable.set(dedupeKey, nowMs + ttlMs); - roomBombDedupTable.set(roomId, roomTable); - return true; -}; - -/** ルーム単位の連番からサーバー採番の爆弾IDを生成する */ -export const issueServerBombId = (roomId: string): string => { - const serial = (roomBombSerialTable.get(roomId) ?? 0) + 1; - roomBombSerialTable.set(roomId, serial); - return `${roomId}:${serial}`; -}; - -/** 指定ルームの爆弾採番状態と重複排除状態を破棄する */ -export const clearBombRoomState = (roomId: string, reason: BombRoomStateClearReason): void => { - const hadDedupState = roomBombDedupTable.delete(roomId); - const hadSerialState = roomBombSerialTable.delete(roomId); - - if (!isBombRoomStateDebugEnabled) { - return; + /** ルーム単位の連番からサーバー採番の爆弾IDを生成する */ + public issueServerBombId(roomId: string): string { + const serial = (this.roomBombSerialTable.get(roomId) ?? 0) + 1; + this.roomBombSerialTable.set(roomId, serial); + return `${roomId}:${serial}`; } - console.debug( - `[BombState] cleared room=${roomId} reason=${reason} dedup=${hadDedupState} serial=${hadSerialState}` - ); -}; + /** 指定ルームの爆弾採番状態と重複排除状態を破棄する */ + public clearBombRoomState(roomId: string, reason: BombRoomStateClearReason): void { + const hadDedupState = this.roomBombDedupTable.delete(roomId); + const hadSerialState = this.roomBombSerialTable.delete(roomId); + + if (!this.isBombRoomStateDebugEnabled) { + return; + } + + console.debug( + `[BombState] cleared room=${roomId} reason=${reason} dedup=${hadDedupState} serial=${hadSerialState}` + ); + } + + private cleanupExpiredBombDedup(roomId: string, nowMs: number): void { + const roomTable = this.roomBombDedupTable.get(roomId); + if (!roomTable) { + return; + } + + roomTable.forEach((expiresAtMs, dedupeKey) => { + if (expiresAtMs <= nowMs) { + roomTable.delete(dedupeKey); + } + }); + + if (roomTable.size === 0) { + this.roomBombDedupTable.delete(roomId); + } + } +} diff --git a/apps/server/src/network/handlers/GameHandler.ts b/apps/server/src/network/handlers/GameHandler.ts index ac3b899..f317903 100644 --- a/apps/server/src/network/handlers/GameHandler.ts +++ b/apps/server/src/network/handlers/GameHandler.ts @@ -5,6 +5,3 @@ */ /** ゲームイベント受信ハンドラ登録関数を外部参照向けに再公開 */ export { registerGameHandlers } from "./game/registerGameHandlers"; - -/** ルーム終了時の爆弾状態掃除関数を外部参照向けに再公開 */ -export { clearBombRoomState } from "@server/domains/game/entities/bomb/BombRoomStateStore"; diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index bfa4b8f..3594a06 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -5,6 +5,7 @@ import { Server } from "socket.io"; import { protocol } from "@repo/shared"; import type { + BombPlacedPayload, GameStartPayload, GameResultPayload, PongPayload, @@ -53,6 +54,9 @@ publishGameStartToSocket: (payload: GameStartPayload) => { common.emitToSocket(protocol.SocketEvents.GAME_START, payload); }, + publishBombPlacedToRoom: (roomId: RoomId, payload: BombPlacedPayload) => { + common.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, payload); + }, }; }; diff --git a/apps/server/src/network/handlers/game/registerGameHandlers.ts b/apps/server/src/network/handlers/game/registerGameHandlers.ts index 75ea885..c9ca666 100644 --- a/apps/server/src/network/handlers/game/registerGameHandlers.ts +++ b/apps/server/src/network/handlers/game/registerGameHandlers.ts @@ -7,19 +7,20 @@ import { readyForGameCoordinator } from "@server/application/coordinators/readyForGameCoordinator"; import { startGameCoordinator } from "@server/application/coordinators/startGameCoordinator"; import type { + BombStatePort, + GameRoomLookupPort, MovePlayerPort, ReadyForGamePort, - ReadyForGameRoomPort, StartGamePort, StartGameRoomPort, } from "@server/domains/game/application/ports/gameUseCasePorts"; import { movePlayerUseCase } from "@server/domains/game/application/useCases/movePlayerUseCase"; +import { placeBombUseCase } from "@server/domains/game/application/useCases/placeBombUseCase"; import { pingUseCase } from "@server/domains/game/application/useCases/pingUseCase"; import { createCommonHandlerContext } from "@server/network/handlers/CommonHandler"; import { isMovePayload, isPingPayload, isPlaceBombPayload } from "@server/network/validation/socketPayloadValidators"; import { createServerSocketOnBridge } from "@server/network/handlers/socketEventBridge"; import { createPayloadGuard } from "@server/network/handlers/payloadGuard"; -import { clearBombRoomState, issueServerBombId, shouldBroadcastBombPlaced } from "@server/domains/game/entities/bomb/BombRoomStateStore"; import { createGameOutputAdapter } from "./createGameOutputAdapter"; /** ゲーム受信イベントごとの入力検証関数を保持するテーブル */ @@ -33,8 +34,8 @@ export const registerGameHandlers = ( io: Server, socket: Socket, - gameManager: StartGamePort & ReadyForGamePort & MovePlayerPort, - roomManager: StartGameRoomPort & ReadyForGameRoomPort + gameManager: StartGamePort & ReadyForGamePort & MovePlayerPort & BombStatePort, + roomManager: StartGameRoomPort & GameRoomLookupPort ) => { const common = createCommonHandlerContext(io, socket); const gameOutputAdapter = createGameOutputAdapter(common); @@ -52,7 +53,6 @@ protocol.SocketEvents.PLACE_BOMB, gamePayloadValidators[protocol.SocketEvents.PLACE_BOMB] ); - // 遅延計測用のPINGを検証しPONGを返す onEvent(protocol.SocketEvents.PING, (clientTime) => { if (!guardPingPayload(clientTime)) { @@ -71,9 +71,6 @@ ownerId: socket.id, gameManager, roomManager, - onRoomGameEnded: (roomId) => { - clearBombRoomState(roomId, "game-ended"); - }, output: gameOutputAdapter, }); }); @@ -107,23 +104,15 @@ return; } - const roomId = roomManager.getRoomByPlayerId(socket.id)?.roomId; - if (!roomId) { - return; - } - - const nowMs = Date.now(); - const dedupeKey = `${socket.id}:${data.requestId}`; - if (!shouldBroadcastBombPlaced(roomId, dedupeKey, nowMs)) { - return; - } - - const payload = { - ...data, - bombId: issueServerBombId(roomId), - ownerId: socket.id, - }; - - common.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, payload); + placeBombUseCase({ + roomResolver: roomManager, + bombStore: gameManager, + input: { + socketId: socket.id, + payload: data, + nowMs: Date.now(), + }, + output: gameOutputAdapter, + }); }); }; diff --git a/apps/server/src/network/handlers/registerConnectionHandlers.ts b/apps/server/src/network/handlers/registerConnectionHandlers.ts index 555dc51..66850a8 100644 --- a/apps/server/src/network/handlers/registerConnectionHandlers.ts +++ b/apps/server/src/network/handlers/registerConnectionHandlers.ts @@ -7,7 +7,7 @@ import { disconnectCoordinator } from "@server/application/coordinators/disconnectCoordinator"; import { logEvent } from "@server/logging/logger"; import { logResults, logScopes } from "@server/logging/index"; -import { clearBombRoomState, registerGameHandlers } from "./GameHandler"; +import { registerGameHandlers } from "./GameHandler"; import { registerRoomHandlers } from "./RoomHandler"; import { createGameDisconnectOutputAdapter } from "./game/createGameOutputAdapter"; import { createRoomDisconnectOutputAdapter } from "./room/createRoomOutputAdapter"; @@ -36,8 +36,6 @@ registerGameHandlers(io, socket, gameManager, roomManager); socket.on(protocol.SocketEvents.DISCONNECT, () => { - const roomIdBeforeDisconnect = roomManager.getRoomByPlayerId(socket.id)?.roomId; - // 切断ログ記録後にドメイン別の後処理を実行する logEvent(logScopes.NETWORK, { event: protocol.SocketEvents.DISCONNECT, @@ -52,10 +50,6 @@ gameOutput: gameDisconnectOutputAdapter, roomOutput: roomDisconnectOutputAdapter, }); - - if (roomIdBeforeDisconnect && !roomManager.getRoomById(roomIdBeforeDisconnect)) { - clearBombRoomState(roomIdBeforeDisconnect, "room-deleted"); - } }); }); }; diff --git a/apps/server/src/network/types/connectionPorts.ts b/apps/server/src/network/types/connectionPorts.ts index bd7a12a..6649026 100644 --- a/apps/server/src/network/types/connectionPorts.ts +++ b/apps/server/src/network/types/connectionPorts.ts @@ -4,10 +4,11 @@ */ import type { Server } from "socket.io"; import type { + BombStatePort, DisconnectPlayerPort, + GameRoomLookupPort, MovePlayerPort, ReadyForGamePort, - ReadyForGameRoomPort, StartGamePort, StartGameRoomPort, } from "@server/domains/game/application/ports/gameUseCasePorts"; @@ -23,13 +24,14 @@ export type ConnectionGamePort = & StartGamePort & ReadyForGamePort - & MovePlayerPort; + & MovePlayerPort + & BombStatePort; /** 接続時のルーム処理で利用する入力ポート集合 */ export type ConnectionRoomPort = & JoinRoomPort & StartGameRoomPort - & ReadyForGameRoomPort; + & GameRoomLookupPort; /** ソケット接続全体で利用するゲーム管理ポート集合 */ export type SocketConnectionGamePort = @@ -53,7 +55,7 @@ export type DisconnectGamePort = DisconnectPlayerPort; /** 切断時のルーム処理で利用する入力ポート集合 */ -export type DisconnectRoomHandlerPort = DisconnectRoomPort & FindRoomByPlayerPort; +export type DisconnectRoomHandlerPort = DisconnectRoomPort & FindRoomByPlayerPort & FindRoomByIdPort; /** 切断調停処理へ受け渡す依存集合 */ export type DisconnectCoordinatorPortBundle = Omit;