diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index 8927232..ea9f08e 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -9,6 +9,8 @@ BombHitReportPayload, BombPlacedAckPayload, BombPlacedPayload, + ClientToServerEventPayloadMap, + ServerToClientEventPayloadMap, CurrentPlayersPayload, GameResultPayload, GameStartPayload, @@ -53,37 +55,96 @@ /** ソケットインスタンスからゲーム向けハンドラを生成する */ export const createGameHandler = (socket: Socket): GameHandler => { const { onEvent, onceEvent, offEvent, emitEvent } = createClientSocketEventBridge(socket); + type ReceiveEventName = Extract; + type SendEventName = Extract; + + const createSubscriptionPair = (event: TEvent) => { + return { + on: (callback: (payload: ServerToClientEventPayloadMap[TEvent]) => void) => { + onEvent(event, callback); + }, + off: (callback: (payload: ServerToClientEventPayloadMap[TEvent]) => void) => { + offEvent(event, callback); + }, + }; + }; + + const createPayloadSender = (event: TEvent) => { + return (payload: ClientToServerEventPayloadMap[TEvent]) => { + emitEvent(event, payload); + }; + }; + + const createVoidSender = (event: TEvent) => { + return () => { + emitEvent(event); + }; + }; + + const currentPlayersSubscription = createSubscriptionPair( + protocol.SocketEvents.CURRENT_PLAYERS + ); + const newPlayerSubscription = createSubscriptionPair( + protocol.SocketEvents.NEW_PLAYER + ); + const updatePlayersSubscription = createSubscriptionPair( + protocol.SocketEvents.UPDATE_PLAYERS + ); + const removePlayerSubscription = createSubscriptionPair( + protocol.SocketEvents.REMOVE_PLAYER + ); + const updateMapCellsSubscription = createSubscriptionPair( + protocol.SocketEvents.UPDATE_MAP_CELLS + ); + const gameEndSubscription = createSubscriptionPair( + protocol.SocketEvents.GAME_END + ); + const gameResultSubscription = createSubscriptionPair( + protocol.SocketEvents.GAME_RESULT + ); + const bombPlacedSubscription = createSubscriptionPair( + protocol.SocketEvents.BOMB_PLACED + ); + const bombPlacedAckSubscription = createSubscriptionPair( + protocol.SocketEvents.BOMB_PLACED_ACK + ); + const sendMovePayload = createPayloadSender(protocol.SocketEvents.MOVE); + const sendPlaceBombPayload = createPayloadSender(protocol.SocketEvents.PLACE_BOMB); + const sendBombHitReportPayload = createPayloadSender( + protocol.SocketEvents.BOMB_HIT_REPORT + ); + const sendReadyForGame = createVoidSender(protocol.SocketEvents.READY_FOR_GAME); return { onCurrentPlayers: (callback) => { - onEvent(protocol.SocketEvents.CURRENT_PLAYERS, callback); + currentPlayersSubscription.on(callback); }, offCurrentPlayers: (callback) => { - offEvent(protocol.SocketEvents.CURRENT_PLAYERS, callback); + currentPlayersSubscription.off(callback); }, onNewPlayer: (callback) => { - onEvent(protocol.SocketEvents.NEW_PLAYER, callback); + newPlayerSubscription.on(callback); }, offNewPlayer: (callback) => { - offEvent(protocol.SocketEvents.NEW_PLAYER, callback); + newPlayerSubscription.off(callback); }, onUpdatePlayers: (callback) => { - onEvent(protocol.SocketEvents.UPDATE_PLAYERS, callback); + updatePlayersSubscription.on(callback); }, offUpdatePlayers: (callback) => { - offEvent(protocol.SocketEvents.UPDATE_PLAYERS, callback); + updatePlayersSubscription.off(callback); }, onRemovePlayer: (callback) => { - onEvent(protocol.SocketEvents.REMOVE_PLAYER, callback); + removePlayerSubscription.on(callback); }, offRemovePlayer: (callback) => { - offEvent(protocol.SocketEvents.REMOVE_PLAYER, callback); + removePlayerSubscription.off(callback); }, onUpdateMapCells: (callback) => { - onEvent(protocol.SocketEvents.UPDATE_MAP_CELLS, callback); + updateMapCellsSubscription.on(callback); }, offUpdateMapCells: (callback) => { - offEvent(protocol.SocketEvents.UPDATE_MAP_CELLS, callback); + updateMapCellsSubscription.off(callback); }, onGameStart: (callback) => { onEvent(protocol.SocketEvents.GAME_START, callback); @@ -95,41 +156,41 @@ offEvent(protocol.SocketEvents.GAME_START, callback); }, onGameEnd: (callback) => { - onEvent(protocol.SocketEvents.GAME_END, callback); + gameEndSubscription.on(callback); }, offGameEnd: (callback) => { - offEvent(protocol.SocketEvents.GAME_END, callback); + gameEndSubscription.off(callback); }, onGameResult: (callback) => { - onEvent(protocol.SocketEvents.GAME_RESULT, callback); + gameResultSubscription.on(callback); }, offGameResult: (callback) => { - offEvent(protocol.SocketEvents.GAME_RESULT, callback); + gameResultSubscription.off(callback); }, onBombPlaced: (callback) => { - onEvent(protocol.SocketEvents.BOMB_PLACED, callback); + bombPlacedSubscription.on(callback); }, offBombPlaced: (callback) => { - offEvent(protocol.SocketEvents.BOMB_PLACED, callback); + bombPlacedSubscription.off(callback); }, onBombPlacedAck: (callback) => { - onEvent(protocol.SocketEvents.BOMB_PLACED_ACK, callback); + bombPlacedAckSubscription.on(callback); }, offBombPlacedAck: (callback) => { - offEvent(protocol.SocketEvents.BOMB_PLACED_ACK, callback); + bombPlacedAckSubscription.off(callback); }, sendMove: (x, y) => { const payload: MovePayload = { x, y }; - emitEvent(protocol.SocketEvents.MOVE, payload); + sendMovePayload(payload); }, sendPlaceBomb: (payload) => { - emitEvent(protocol.SocketEvents.PLACE_BOMB, payload); + sendPlaceBombPayload(payload); }, sendBombHitReport: (payload) => { - emitEvent(protocol.SocketEvents.BOMB_HIT_REPORT, payload); + sendBombHitReportPayload(payload); }, readyForGame: () => { - emitEvent(protocol.SocketEvents.READY_FOR_GAME); + sendReadyForGame(); } }; }; diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 734cb76..498b49c 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -31,6 +31,7 @@ private bombHitOrchestrator: BombHitOrchestrator | null = null; private networkSync: GameNetworkSync | null = null; private gameLoop: GameLoop | null = null; + private reportedBombHitIds = new Set(); // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ public setGameStart(startTime: number) { @@ -209,13 +210,29 @@ result: BombHitEvaluationResult | undefined, bombId: string, ): void { - if (result !== "hit") { + if (!this.shouldSendBombHitReport(result, bombId)) { return; } socketManager.game.sendBombHitReport({ bombId }); } + private shouldSendBombHitReport( + result: BombHitEvaluationResult | undefined, + bombId: string, + ): boolean { + if (result !== "hit") { + return false; + } + + if (this.reportedBombHitIds.has(bombId)) { + return false; + } + + this.reportedBombHitIds.add(bombId); + return true; + } + /** * クリーンアップ処理(コンポーネントアンマウント時) */ @@ -228,6 +245,7 @@ this.bombManager = null; this.bombHitOrchestrator?.clear(); this.bombHitOrchestrator = null; + this.reportedBombHitIds.clear(); this.players = {}; this.isInputLocked = false; diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 7bf53c8..d7e1845 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -3,8 +3,10 @@ * ゲーム系ユースケースが利用する入力ポートと出力ポートの契約を定義する */ import type { + BombHitReportPayload, BombPlacedAckPayload, BombPlacedPayload, + PlayerDeadPayload, gameTypes, playerTypes, PlaceBombPayload, @@ -84,8 +86,19 @@ socketId: string, payload: BombPlacedAckPayload, ): void; + publishPlayerDeadToOthersInRoom( + roomId: roomTypes.Room["roomId"], + deadPlayerId: string, + payload: PlayerDeadPayload, + ): void; } +/** 爆弾設置ユースケースが利用する出力ポート */ +export type PlaceBombOutputPort = Pick< + BombOutputPort, + "publishBombPlacedToOthersInRoom" | "publishBombPlacedAckToSocket" +>; + /** start-game 系フローで利用する送信出力ポート */ export type StartGameOutputPort = Pick< GameOutputPort, @@ -112,3 +125,12 @@ payload: PlaceBombPayload; nowMs: number; }; + +/** 被弾報告ユースケースの入力値 */ +export type ReportBombHitInput = { + socketId: string; + payload: BombHitReportPayload; +}; + +/** 被弾報告ユースケースが利用する出力ポート */ +export type BombHitOutputPort = Pick; diff --git a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts index de21ec8..28c3017 100644 --- a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts @@ -4,7 +4,7 @@ */ import type { BombPlacementPort, - BombOutputPort, + PlaceBombOutputPort, PlaceBombInput, } from "../ports/gameUseCasePorts"; import { @@ -17,7 +17,7 @@ roomId: string; bombStore: BombPlacementPort; input: PlaceBombInput; - output: BombOutputPort; + output: PlaceBombOutputPort; }; /** 爆弾設置入力を重複排除と採番付きでルームへ配信する */ diff --git a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts new file mode 100644 index 0000000..5194609 --- /dev/null +++ b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts @@ -0,0 +1,25 @@ +/** + * reportBombHitUseCase + * 被弾報告を受け取り,死亡通知の配信処理へ橋渡しする + */ +import type { + BombHitOutputPort, + ReportBombHitInput, +} from "../ports/gameUseCasePorts"; + +type ReportBombHitUseCaseParams = { + roomId: string; + input: ReportBombHitInput; + output: BombHitOutputPort; +}; + +/** 被弾報告を受け取り,死亡通知を同一ルームへ配信する */ +export const reportBombHitUseCase = ({ + roomId, + input, + output, +}: ReportBombHitUseCaseParams): void => { + output.publishPlayerDeadToOthersInRoom(roomId, input.socketId, { + playerId: input.socketId, + }); +}; diff --git a/apps/server/src/logging/contracts/payloadByScope.ts b/apps/server/src/logging/contracts/payloadByScope.ts index f0ec8b1..598845b 100644 --- a/apps/server/src/logging/contracts/payloadByScope.ts +++ b/apps/server/src/logging/contracts/payloadByScope.ts @@ -53,6 +53,13 @@ socketId: string; }; +/** NetworkのBOMB_HIT_REPORT不正ペイロードログ契約 */ +type NetworkBombHitReportLogPayload = { + event: typeof protocol.SocketEvents.BOMB_HIT_REPORT; + result: typeof logResults.IGNORED_INVALID_PAYLOAD; + socketId: string; +}; + /** Networkスコープのログ契約ユニオン */ type NetworkLogPayload = | NetworkConnectLogPayload @@ -60,7 +67,8 @@ | NetworkJoinRoomLogPayload | NetworkPingLogPayload | NetworkMoveLogPayload - | NetworkPlaceBombLogPayload; + | NetworkPlaceBombLogPayload + | NetworkBombHitReportLogPayload; /** GameUseCaseのSTART_GAMEログ契約 */ type GameUseCaseStartGameLogPayload = { diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index a0416cf..bad3082 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -9,6 +9,7 @@ BombPlacedPayload, GameStartPayload, GameResultPayload, + PlayerDeadPayload, PongPayload, roomTypes, CurrentPlayersPayload, @@ -66,6 +67,9 @@ publishBombPlacedAckToSocket: (socketId: string, payload: BombPlacedAckPayload) => { common.emitToSocketById(socketId, protocol.SocketEvents.BOMB_PLACED_ACK, payload); }, + publishPlayerDeadToOthersInRoom: (roomId: RoomId, deadPlayerId: string, payload: PlayerDeadPayload) => { + common.emitToRoomExceptSocket(roomId, deadPlayerId, protocol.SocketEvents.PLAYER_DEAD, payload); + }, }; }; diff --git a/apps/server/src/network/handlers/game/registerGameHandlers.ts b/apps/server/src/network/handlers/game/registerGameHandlers.ts index 1d85a7c..f848eec 100644 --- a/apps/server/src/network/handlers/game/registerGameHandlers.ts +++ b/apps/server/src/network/handlers/game/registerGameHandlers.ts @@ -16,6 +16,7 @@ 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 { reportBombHitUseCase } from "@server/domains/game/application/useCases/reportBombHitUseCase"; import { resolveRuntimeByPlayerId } from "@server/domains/room/application/services/RoomRuntimeResolver"; import { createCommonHandlerContext } from "@server/network/handlers/CommonHandler"; import { @@ -146,5 +147,14 @@ if (!runtime) { return; } + + reportBombHitUseCase({ + roomId: runtime.roomId, + input: { + socketId: socket.id, + payload: data, + }, + output: gameOutputAdapter, + }); }); }; diff --git a/apps/server/src/network/handlers/payloadGuard.ts b/apps/server/src/network/handlers/payloadGuard.ts index 9cdaabb..dd1ab88 100644 --- a/apps/server/src/network/handlers/payloadGuard.ts +++ b/apps/server/src/network/handlers/payloadGuard.ts @@ -7,14 +7,34 @@ import { logResults, logScopes } from "@server/logging/index"; type PayloadValidator = (value: unknown) => value is TPayload; -type PayloadGuardEventName = - | typeof protocol.SocketEvents.JOIN_ROOM - | typeof protocol.SocketEvents.PING - | typeof protocol.SocketEvents.MOVE - | typeof protocol.SocketEvents.PLACE_BOMB - | typeof protocol.SocketEvents.BOMB_HIT_REPORT; type EventBoundPayloadGuard = (payload: unknown) => payload is TPayload; +/** 不正ペイロード時に記録するログ契約をイベント単位で一元管理する */ +const invalidPayloadLogByEvent = { + [protocol.SocketEvents.JOIN_ROOM]: { + event: protocol.SocketEvents.JOIN_ROOM, + result: logResults.IGNORED_INVALID_PAYLOAD, + }, + [protocol.SocketEvents.PING]: { + event: protocol.SocketEvents.PING, + result: logResults.IGNORED_INVALID_PAYLOAD, + }, + [protocol.SocketEvents.MOVE]: { + event: protocol.SocketEvents.MOVE, + result: logResults.IGNORED_INVALID_PAYLOAD, + }, + [protocol.SocketEvents.PLACE_BOMB]: { + event: protocol.SocketEvents.PLACE_BOMB, + result: logResults.IGNORED_INVALID_PAYLOAD, + }, + [protocol.SocketEvents.BOMB_HIT_REPORT]: { + event: protocol.SocketEvents.BOMB_HIT_REPORT, + result: logResults.IGNORED_INVALID_PAYLOAD, + }, +} as const; + +type PayloadGuardEventName = keyof typeof invalidPayloadLogByEvent; + /** * 受信ペイロードを検証し,不正時は共通ログを記録するガード関数を生成する */ @@ -28,42 +48,11 @@ return true; } - switch (event) { - case protocol.SocketEvents.JOIN_ROOM: - logEvent(logScopes.NETWORK, { - event: protocol.SocketEvents.JOIN_ROOM, - result: logResults.IGNORED_INVALID_PAYLOAD, - socketId, - }); - break; - - case protocol.SocketEvents.PING: - logEvent(logScopes.NETWORK, { - event: protocol.SocketEvents.PING, - result: logResults.IGNORED_INVALID_PAYLOAD, - socketId, - }); - break; - - case protocol.SocketEvents.MOVE: - logEvent(logScopes.NETWORK, { - event: protocol.SocketEvents.MOVE, - result: logResults.IGNORED_INVALID_PAYLOAD, - socketId, - }); - break; - - case protocol.SocketEvents.PLACE_BOMB: - logEvent(logScopes.NETWORK, { - event: protocol.SocketEvents.PLACE_BOMB, - result: logResults.IGNORED_INVALID_PAYLOAD, - socketId, - }); - break; - - case protocol.SocketEvents.BOMB_HIT_REPORT: - break; - } + const logContract = invalidPayloadLogByEvent[event]; + logEvent(logScopes.NETWORK, { + ...logContract, + socketId, + }); return false; }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8ec8528..8eb0070 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -22,33 +22,7 @@ /** ソケットイベント定義を再公開 */ export * as protocol from "./protocol/events"; /** ソケットイベント関連の共有型を再公開 */ -export type { - ConnectionLifecycleEventPayloadMap, - ConnectionLifecyclePayloadOf, - ClientToServerEventPayloadMap, - ClientToServerPayloadOf, - ServerToClientEventPayloadMap, - ServerToClientPayloadOf, - InitialPlayerSyncPayload, - DeltaPlayerSyncPayload, - CurrentPlayersPayload, - BombPlacedPayload, - BombPlacedAckPayload, - BombHitReportPayload, - PlayerDeadPayload, - PlaceBombPayload, - BombNetworkPayload, - GameStartPayload, - GameResultPayload, - GameResultRanking, - MovePayload, - NewPlayerPayload, - PingPayload, - PongPayload, - RemovePlayerPayload, - UpdateMapCellsPayload, - UpdatePlayersPayload, -} from "./protocol/events"; +export type * from "./protocol/events"; /** ソケットイベントブリッジ生成関数を再公開 */ export { createSocketEventBridge } from "./protocol/socketEventBridge"; /** 爆弾ペイロードから同期用IDを生成するユーティリティを再公開 */ diff --git a/packages/shared/src/protocol/events.ts b/packages/shared/src/protocol/events.ts index 3de5e3c..e075f0b 100644 --- a/packages/shared/src/protocol/events.ts +++ b/packages/shared/src/protocol/events.ts @@ -14,36 +14,7 @@ export type { BombNetworkPayload } from "./bombIdentity"; /** 基本ペイロード型を再公開する */ -export type { - InitialPlayerSyncPayload, - DeltaPlayerSyncPayload, - UpdatePlayersPayload, - CurrentPlayersPayload, - UpdateMapCellsPayload, - NewPlayerPayload, - RemovePlayerPayload, - GameStartPayload, - MovePayload, - PlaceBombPayload, - BombPlacedPayload, - BombPlacedAckPayload, - BombHitReportPayload, - PlayerDeadPayload, - PingPayload, - PongPayload, - JoinRoomPayload, - RoomJoinRejectedPayload, - RoomUpdatePayload, - GameResultPayload, - GameResultRanking, -} from "./eventPayloads"; +export type * from "./eventPayloads"; /** イベント方向ごとのペイロード対応表とユーティリティ型を再公開する */ -export type { - ConnectionLifecycleEventPayloadMap, - ConnectionLifecyclePayloadOf, - ClientToServerEventPayloadMap, - ClientToServerPayloadOf, - ServerToClientEventPayloadMap, - ServerToClientPayloadOf, -} from "./eventPayloadMaps"; +export type * from "./eventPayloadMaps";