diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 4e72924..a27851a 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -7,7 +7,6 @@ import type { BombPlacedAckPayload, BombPlacedPayload, - PlayerDeadPayload, } from "@repo/shared"; import { socketManager } from "@client/network/SocketManager"; import { AppearanceResolver } from "./application/AppearanceResolver"; @@ -18,6 +17,7 @@ import { GameLoop } from "./application/GameLoop"; import { BombHitContextProvider } from "./application/BombHitContextProvider"; import { BombHitOrchestrator } from "./application/BombHitOrchestrator"; +import { PlayerDeathPolicy } from "./application/PlayerDeathPolicy"; import type { BombHitEvaluationResult } from "./application/BombHitOrchestrator"; import type { GamePlayers } from "./application/game.types"; @@ -35,6 +35,7 @@ private bombHitOrchestrator: BombHitOrchestrator | null = null; private networkSync: GameNetworkSync | null = null; private gameLoop: GameLoop | null = null; + private playerDeathPolicy: PlayerDeathPolicy; private reportedBombHitIds = new Set(); // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ @@ -90,6 +91,10 @@ this.app = new Application(); this.worldContainer = new Container(); this.worldContainer.sortableChildren = true; + this.playerDeathPolicy = new PlayerDeathPolicy({ + myId: this.myId, + lockInput: this.lockInput.bind(this), + }); } /** @@ -167,7 +172,7 @@ this.applyPlacedBombAck(payload); }, onPlayerDeadFromNetwork: (payload) => { - this.handlePlayerDeadFromNetwork(payload); + this.playerDeathPolicy.applyPlayerDeadEvent(payload); }, }); this.networkSync.bind(); @@ -240,14 +245,6 @@ return true; } - private handlePlayerDeadFromNetwork(payload: PlayerDeadPayload): void { - if (payload.playerId !== this.myId) { - return; - } - - this.lockInput(); - } - /** * クリーンアップ処理(コンポーネントアンマウント時) */ diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index 1a910e1..578f5ec 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -36,6 +36,11 @@ onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; }; +type SocketSubscription = { + bind: () => void; + unbind: () => void; +}; + /** ゲーム中のネットワークイベント購読と同期処理を管理する */ export class GameNetworkSync { private worldContainer: Container; @@ -48,6 +53,7 @@ private onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; private onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; private onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; + private socketSubscriptions: SocketSubscription[]; private isBound = false; private debugLog = (message: string) => { @@ -142,21 +148,56 @@ this.onBombPlacedFromOthers = onBombPlacedFromOthers; this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; + this.socketSubscriptions = [ + { + bind: () => socketManager.game.onCurrentPlayers(this.handleCurrentPlayers), + unbind: () => socketManager.game.offCurrentPlayers(this.handleCurrentPlayers), + }, + { + bind: () => socketManager.game.onNewPlayer(this.handleNewPlayer), + unbind: () => socketManager.game.offNewPlayer(this.handleNewPlayer), + }, + { + bind: () => socketManager.game.onGameStart(this.handleGameStart), + unbind: () => socketManager.game.offGameStart(this.handleGameStart), + }, + { + bind: () => socketManager.game.onUpdatePlayers(this.handlePlayerUpdates), + unbind: () => socketManager.game.offUpdatePlayers(this.handlePlayerUpdates), + }, + { + bind: () => socketManager.game.onRemovePlayer(this.handleRemovePlayer), + unbind: () => socketManager.game.offRemovePlayer(this.handleRemovePlayer), + }, + { + bind: () => socketManager.game.onUpdateMapCells(this.handleUpdateMapCells), + unbind: () => socketManager.game.offUpdateMapCells(this.handleUpdateMapCells), + }, + { + bind: () => socketManager.game.onGameEnd(this.handleGameEnd), + unbind: () => socketManager.game.offGameEnd(this.handleGameEnd), + }, + { + bind: () => socketManager.game.onBombPlaced(this.handleBombPlaced), + unbind: () => socketManager.game.offBombPlaced(this.handleBombPlaced), + }, + { + bind: () => socketManager.game.onBombPlacedAck(this.handleBombPlacedAck), + unbind: () => socketManager.game.offBombPlacedAck(this.handleBombPlacedAck), + }, + { + bind: () => socketManager.game.onPlayerDead(this.handlePlayerDead), + unbind: () => socketManager.game.offPlayerDead(this.handlePlayerDead), + }, + ]; } public bind() { if (this.isBound) return; - socketManager.game.onCurrentPlayers(this.handleCurrentPlayers); - socketManager.game.onNewPlayer(this.handleNewPlayer); - socketManager.game.onGameStart(this.handleGameStart); - socketManager.game.onUpdatePlayers(this.handlePlayerUpdates); - socketManager.game.onRemovePlayer(this.handleRemovePlayer); - socketManager.game.onUpdateMapCells(this.handleUpdateMapCells); - socketManager.game.onGameEnd(this.handleGameEnd); - socketManager.game.onBombPlaced(this.handleBombPlaced); - socketManager.game.onBombPlacedAck(this.handleBombPlacedAck); - socketManager.game.onPlayerDead(this.handlePlayerDead); + this.socketSubscriptions.forEach((subscription) => { + subscription.bind(); + }); this.isBound = true; } @@ -164,16 +205,9 @@ public unbind() { if (!this.isBound) return; - socketManager.game.offCurrentPlayers(this.handleCurrentPlayers); - socketManager.game.offNewPlayer(this.handleNewPlayer); - socketManager.game.offGameStart(this.handleGameStart); - socketManager.game.offUpdatePlayers(this.handlePlayerUpdates); - socketManager.game.offRemovePlayer(this.handleRemovePlayer); - socketManager.game.offUpdateMapCells(this.handleUpdateMapCells); - socketManager.game.offGameEnd(this.handleGameEnd); - socketManager.game.offBombPlaced(this.handleBombPlaced); - socketManager.game.offBombPlacedAck(this.handleBombPlacedAck); - socketManager.game.offPlayerDead(this.handlePlayerDead); + this.socketSubscriptions.forEach((subscription) => { + subscription.unbind(); + }); this.isBound = false; } diff --git a/apps/client/src/scenes/game/application/PlayerDeathPolicy.ts b/apps/client/src/scenes/game/application/PlayerDeathPolicy.ts new file mode 100644 index 0000000..207d74f --- /dev/null +++ b/apps/client/src/scenes/game/application/PlayerDeathPolicy.ts @@ -0,0 +1,26 @@ +import type { PlayerDeadPayload } from "@repo/shared"; + +type PlayerDeathPolicyParams = { + myId: string; + lockInput: () => void; +}; + +/** 被弾後のローカルプレイヤー処理ポリシーを管理する */ +export class PlayerDeathPolicy { + private myId: string; + private lockInput: () => void; + + constructor({ myId, lockInput }: PlayerDeathPolicyParams) { + this.myId = myId; + this.lockInput = lockInput; + } + + /** サーバーからの死亡通知に応じてローカル被弾後処理を適用する */ + public applyPlayerDeadEvent(payload: PlayerDeadPayload): void { + if (payload.playerId !== this.myId) { + return; + } + + this.lockInput(); + } +} diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index b495a92..d97598c 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -82,6 +82,11 @@ return this.lifecycleService.shouldBroadcastBombPlaced(dedupeKey, nowMs); } + // 被弾報告イベントを配信すべきか判定し,配信時は重複排除状態を更新する + shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean { + return this.lifecycleService.shouldBroadcastBombHitReport(dedupeKey, nowMs); + } + // サーバー採番の爆弾IDを生成する issueServerBombId(): string { return this.lifecycleService.issueServerBombId(); diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index d7e1845..7cf0308 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -119,6 +119,11 @@ issueServerBombId(): string; } +/** 被弾報告ユースケースが利用する重複排除入力ポート */ +export interface BombHitReportValidationPort { + shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean; +} + /** 爆弾設置ユースケースの入力値 */ export type PlaceBombInput = { socketId: string; @@ -130,6 +135,7 @@ export type ReportBombHitInput = { socketId: string; payload: BombHitReportPayload; + nowMs: number; }; /** 被弾報告ユースケースが利用する出力ポート */ diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 107f3ab..fff0b67 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -152,6 +152,10 @@ return this.bombStateStore.shouldBroadcastBombPlaced(dedupeKey, nowMs); } + public shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean { + return this.bombStateStore.shouldBroadcastBombHitReport(dedupeKey, nowMs); + } + public issueServerBombId(): string { return this.bombStateStore.issueServerBombId(); } diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index 3fb9d09..a72d78a 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -42,6 +42,13 @@ ); } + public shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean { + return ( + this.sessionRef.current?.shouldBroadcastBombHitReport(dedupeKey, nowMs) ?? + false + ); + } + public issueServerBombId(): string { const session = this.sessionRef.current; if (!session) { diff --git a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts index 5194609..f65ccdf 100644 --- a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts @@ -4,22 +4,51 @@ */ import type { BombHitOutputPort, + BombHitReportValidationPort, ReportBombHitInput, } from "../ports/gameUseCasePorts"; +import { createBombHitReportDedupeKey } from "@server/domains/game/entities/bomb/bombHitReport"; type ReportBombHitUseCaseParams = { roomId: string; + validation: BombHitReportValidationPort; input: ReportBombHitInput; output: BombHitOutputPort; }; +/** 受信した被弾報告を処理対象にすべきか判定する */ +const shouldPublishPlayerDeadFromBombHit = ( + validation: BombHitReportValidationPort, + input: ReportBombHitInput, +): boolean => { + const dedupeKey = createBombHitReportDedupeKey( + input.socketId, + input.payload.bombId, + ); + return validation.shouldBroadcastBombHitReport(dedupeKey, input.nowMs); +}; + +/** 被弾報告を死亡通知へ変換して配信する */ +const publishPlayerDeadFromBombHit = ( + roomId: string, + input: ReportBombHitInput, + output: BombHitOutputPort, +): void => { + output.publishPlayerDeadToOthersInRoom(roomId, input.socketId, { + playerId: input.socketId, + }); +}; + /** 被弾報告を受け取り,死亡通知を同一ルームへ配信する */ export const reportBombHitUseCase = ({ roomId, + validation, input, output, }: ReportBombHitUseCaseParams): void => { - output.publishPlayerDeadToOthersInRoom(roomId, input.socketId, { - playerId: input.socketId, - }); + if (!shouldPublishPlayerDeadFromBombHit(validation, input)) { + return; + } + + publishPlayerDeadFromBombHit(roomId, input, output); }; diff --git a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts index 92a2ea4..ecb645d 100644 --- a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts +++ b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts @@ -3,11 +3,15 @@ * セッション単位の爆弾重複排除状態と採番状態を管理する */ import { issueServerBombId } from "./bombIdentity.js"; -import { shouldBroadcastBombPlaced } from "./bombDedup.js"; +import { + shouldBroadcastBombHitReport, + shouldBroadcastBombPlaced, +} from "./bombDedup.js"; /** セッション単位の爆弾重複排除状態と採番状態を保持するストア */ export class BombStateStore { private bombDedupTable = new Map(); + private bombHitReportDedupTable = new Map(); private bombSerial = 0; /** 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ @@ -19,6 +23,15 @@ }); } + /** 被弾報告イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ + public shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean { + return shouldBroadcastBombHitReport({ + dedupTable: this.bombHitReportDedupTable, + dedupeKey, + nowMs, + }); + } + /** セッション単位の連番からサーバー採番の爆弾IDを生成する */ public issueServerBombId(): string { const { bombId, nextSerial } = issueServerBombId({ diff --git a/apps/server/src/domains/game/entities/bomb/bombDedup.ts b/apps/server/src/domains/game/entities/bomb/bombDedup.ts index ff57c39..d28ba33 100644 --- a/apps/server/src/domains/game/entities/bomb/bombDedup.ts +++ b/apps/server/src/domains/game/entities/bomb/bombDedup.ts @@ -10,6 +10,12 @@ nowMs: number; }; +type ShouldBroadcastBombHitReportParams = { + dedupTable: Map; + dedupeKey: string; + nowMs: number; +}; + /** 重複排除テーブルの期限切れエントリを削除する */ export const cleanupExpiredBombDedup = ( dedupTable: Map, @@ -38,3 +44,20 @@ dedupTable.set(dedupeKey, nowMs + ttlMs); return true; }; + +/** 被弾報告イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ +export const shouldBroadcastBombHitReport = ({ + dedupTable, + dedupeKey, + nowMs, +}: ShouldBroadcastBombHitReportParams): boolean => { + cleanupExpiredBombDedup(dedupTable, nowMs); + + if (dedupTable.has(dedupeKey)) { + return false; + } + + const ttlMs = config.GAME_CONFIG.BOMB_FUSE_MS + config.GAME_CONFIG.BOMB_DEDUP_EXTRA_TTL_MS; + dedupTable.set(dedupeKey, nowMs + ttlMs); + return true; +}; diff --git a/apps/server/src/domains/game/entities/bomb/bombHitReport.ts b/apps/server/src/domains/game/entities/bomb/bombHitReport.ts new file mode 100644 index 0000000..208d342 --- /dev/null +++ b/apps/server/src/domains/game/entities/bomb/bombHitReport.ts @@ -0,0 +1,9 @@ +/** + * bombHitReport + * 被弾報告イベントの重複排除キー生成を提供する + */ + +/** 被弾報告の重複排除に利用するキーを生成する */ +export const createBombHitReportDedupeKey = (reporterSocketId: string, bombId: string): string => { + return `${reporterSocketId}:${bombId}`; +}; diff --git a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts index f727fcb..d72a13c 100644 --- a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts +++ b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts @@ -4,6 +4,7 @@ */ import type { roomTypes } from "@repo/shared"; import type { + BombHitReportValidationPort, BombPlacementPort, DisconnectPlayerPort, MovePlayerPort, @@ -17,6 +18,7 @@ & ReadyForGamePort & MovePlayerPort & BombPlacementPort + & BombHitReportValidationPort & DisconnectPlayerPort; /** ルーム参加処理の実行結果 */ diff --git a/apps/server/src/network/handlers/game/registerGameHandlers.ts b/apps/server/src/network/handlers/game/registerGameHandlers.ts index f848eec..bcc0631 100644 --- a/apps/server/src/network/handlers/game/registerGameHandlers.ts +++ b/apps/server/src/network/handlers/game/registerGameHandlers.ts @@ -150,9 +150,11 @@ reportBombHitUseCase({ roomId: runtime.roomId, + validation: runtime.gameManager, input: { socketId: socket.id, payload: data, + nowMs: Date.now(), }, output: gameOutputAdapter, });