diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index d5850f5..7c46ac3 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -60,10 +60,14 @@ 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 BombOutputPort { + publishBombPlacedToRoom(roomId: roomTypes.Room["roomId"], payload: BombPlacedPayload): void; +} + /** 爆弾設置ユースケースが利用する爆弾状態入力ポート */ export interface BombPlacementPort { shouldBroadcastBombPlaced(roomId: string, dedupeKey: string, nowMs: number): boolean; diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 959fe41..54a5abf 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -12,7 +12,7 @@ import { GameLoop } from "../../loop/GameLoop"; import { Player } from "../../entities/player/Player.js"; import { MapStore } from "../../entities/map/MapStore"; -import { BombRoomStateStore } from "../../entities/bomb/BombRoomStateStore"; +import { BombStateStore } from "../../entities/bomb/BombStateStore"; import { createSpawnedPlayer } from "../../entities/player/playerSpawn.js"; import { isValidPosition, @@ -25,7 +25,7 @@ export class GameRoomSession { private players: Map; private mapStore: MapStore; - private bombStateStore: BombRoomStateStore; + private bombStateStore: BombStateStore; private gameLoop: GameLoop | null = null; private startTime: number | undefined; @@ -35,7 +35,7 @@ ) { this.players = new Map(); this.mapStore = new MapStore(); - this.bombStateStore = new BombRoomStateStore(); + this.bombStateStore = new BombStateStore(); playerIds.forEach((playerId) => { // 現在のプレイヤー構成から人数が最も少ないチームを算出する diff --git a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts index 0e9fde1..adbf947 100644 --- a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts @@ -4,16 +4,21 @@ */ import type { BombPlacementPort, - GameOutputPort, + BombOutputPort, PlaceBombInput, } from "../ports/gameUseCasePorts"; +import { + createBombDedupeKey, + createBombPlacedPayload, +} from "@server/domains/game/entities/bomb/bombPlacement"; +import { resolveRoomIdBySocketId } from "./useCaseRoomResolver"; import type { FindRoomByPlayerPort } from "@server/domains/room/application/ports/roomUseCasePorts"; type PlaceBombUseCaseParams = { roomResolver: FindRoomByPlayerPort; bombStore: BombPlacementPort; input: PlaceBombInput; - output: Pick; + output: BombOutputPort; }; /** 爆弾設置入力を重複排除と採番付きでルームへ配信する */ @@ -23,19 +28,22 @@ input, output, }: PlaceBombUseCaseParams): void => { - const roomId = roomResolver.getRoomByPlayerId(input.socketId)?.roomId; + const roomId = resolveRoomIdBySocketId(roomResolver, input.socketId); if (!roomId) { return; } - const dedupeKey = `${input.socketId}:${input.payload.requestId}`; + const dedupeKey = createBombDedupeKey(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, - }); + output.publishBombPlacedToRoom( + roomId, + createBombPlacedPayload({ + payload: input.payload, + bombId: bombStore.issueServerBombId(roomId), + ownerId: input.socketId, + }) + ); }; diff --git a/apps/server/src/domains/game/application/useCases/useCaseRoomResolver.ts b/apps/server/src/domains/game/application/useCases/useCaseRoomResolver.ts new file mode 100644 index 0000000..9096941 --- /dev/null +++ b/apps/server/src/domains/game/application/useCases/useCaseRoomResolver.ts @@ -0,0 +1,13 @@ +/** + * useCaseRoomResolver + * ゲーム系ユースケースで利用するルーム解決処理を提供する + */ +import type { FindRoomByPlayerPort } from "@server/domains/room/application/ports/roomUseCasePorts"; + +/** ソケットIDから所属ルームIDを解決して返す */ +export const resolveRoomIdBySocketId = ( + roomResolver: FindRoomByPlayerPort, + socketId: string +): string | undefined => { + return roomResolver.getRoomByPlayerId(socketId)?.roomId; +}; diff --git a/apps/server/src/domains/game/entities/bomb/BombRoomStateStore.ts b/apps/server/src/domains/game/entities/bomb/BombRoomStateStore.ts deleted file mode 100644 index b0a7aaf..0000000 --- a/apps/server/src/domains/game/entities/bomb/BombRoomStateStore.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * BombRoomStateStore - * ルーム単位の爆弾重複排除状態と採番状態を管理する - */ -import { issueServerBombId } from "./bombIdentity.js"; -import { shouldBroadcastBombPlaced } from "./bombDedup.js"; - -/** ルーム単位の爆弾重複排除状態と採番状態を保持するストア */ -export class BombRoomStateStore { - private bombDedupTable = new Map(); - private bombSerial = 0; - - /** 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ - public shouldBroadcastBombPlaced(dedupeKey: string, nowMs: number): boolean { - return shouldBroadcastBombPlaced({ - dedupTable: this.bombDedupTable, - dedupeKey, - nowMs, - }); - } - - /** セッション単位の連番からサーバー採番の爆弾IDを生成する */ - public issueServerBombId(roomId: string): string { - const { bombId, nextSerial } = issueServerBombId({ - roomId, - currentSerial: this.bombSerial, - }); - this.bombSerial = nextSerial; - return bombId; - } -} diff --git a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts new file mode 100644 index 0000000..28a0719 --- /dev/null +++ b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts @@ -0,0 +1,32 @@ +/** + * BombStateStore + * セッション単位の爆弾重複排除状態と採番状態を管理する + */ +import { issueServerBombId } from "./bombIdentity.js"; +import { shouldBroadcastBombPlaced } from "./bombDedup.js"; + +/** セッション単位の爆弾重複排除状態と採番状態を保持するストア */ +export class BombStateStore { + private bombDedupTable = new Map(); + private bombSerial = 0; + + /** 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ + public shouldBroadcastBombPlaced(dedupeKey: string, nowMs: number): boolean { + return shouldBroadcastBombPlaced({ + dedupTable: this.bombDedupTable, + dedupeKey, + nowMs, + }); + } + + /** セッション単位の連番からサーバー採番の爆弾IDを生成する */ + public issueServerBombId(roomId: string): string { + // roomId はセッションを外部参照するためのID名前空間として利用する + const { bombId, nextSerial } = issueServerBombId({ + roomId, + currentSerial: this.bombSerial, + }); + this.bombSerial = nextSerial; + return bombId; + } +} diff --git a/apps/server/src/domains/game/entities/bomb/bombIdentity.ts b/apps/server/src/domains/game/entities/bomb/bombIdentity.ts index 634cf62..eaa9ba2 100644 --- a/apps/server/src/domains/game/entities/bomb/bombIdentity.ts +++ b/apps/server/src/domains/game/entities/bomb/bombIdentity.ts @@ -4,6 +4,7 @@ */ type IssueServerBombIdParams = { + // セッションに紐づく外部公開用のID名前空間 roomId: string; currentSerial: number; }; diff --git a/apps/server/src/domains/game/entities/bomb/bombPayloadValidation.ts b/apps/server/src/domains/game/entities/bomb/bombPayloadValidation.ts new file mode 100644 index 0000000..02f7209 --- /dev/null +++ b/apps/server/src/domains/game/entities/bomb/bombPayloadValidation.ts @@ -0,0 +1,28 @@ +/** + * bombPayloadValidation + * 爆弾設置ペイロードの妥当性検証ロジックを提供する + */ +import type { PlaceBombPayload } from "@repo/shared"; + +const isFiniteNumber = (value: unknown): value is number => { + return typeof value === "number" && Number.isFinite(value); +}; + +const isNonEmptyString = (value: unknown): value is string => { + return typeof value === "string" && value.trim().length > 0; +}; + +/** PLACE_BOMBイベントのペイロードが爆弾設置要求であるか判定する */ +export const isPlaceBombPayload = (value: unknown): value is PlaceBombPayload => { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + return ( + isNonEmptyString(candidate.requestId) + && isFiniteNumber(candidate.x) + && isFiniteNumber(candidate.y) + && isFiniteNumber(candidate.explodeAtElapsedMs) + ); +}; diff --git a/apps/server/src/domains/game/entities/bomb/bombPlacement.ts b/apps/server/src/domains/game/entities/bomb/bombPlacement.ts new file mode 100644 index 0000000..98492a0 --- /dev/null +++ b/apps/server/src/domains/game/entities/bomb/bombPlacement.ts @@ -0,0 +1,29 @@ +/** + * bombPlacement + * 爆弾設置時の識別キー生成と確定ペイロード組み立てを提供する + */ +import type { BombPlacedPayload, PlaceBombPayload } from "@repo/shared"; + +/** 重複排除に利用する爆弾設置要求キーを生成する */ +export const createBombDedupeKey = (ownerId: string, requestId: string): string => { + return `${ownerId}:${requestId}`; +}; + +type CreateBombPlacedPayloadParams = { + payload: PlaceBombPayload; + bombId: string; + ownerId: string; +}; + +/** 爆弾確定通知で配信するペイロードを生成する */ +export const createBombPlacedPayload = ({ + payload, + bombId, + ownerId, +}: CreateBombPlacedPayloadParams): BombPlacedPayload => { + return { + ...payload, + bombId, + ownerId, + }; +}; diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index 3594a06..1e3e779 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -15,14 +15,17 @@ UpdateMapCellsPayload, UpdatePlayersPayload, } from "@repo/shared"; -import type { GameOutputPort } from "@server/domains/game/application/ports/gameUseCasePorts"; +import type { + BombOutputPort, + GameOutputPort, +} from "@server/domains/game/application/ports/gameUseCasePorts"; import { createEmitToRoom } from "@server/network/adapters/socketEmitters"; import type { CommonHandlerContext } from "../CommonHandler"; type RoomId = roomTypes.Room["roomId"]; /** ゲーム出力アダプターのインターフェース */ -export type GameOutputAdapter = Omit; +export type GameOutputAdapter = Omit & BombOutputPort; /** ゲーム切断時の出力アダプターのインターフェース */ export type GameDisconnectOutputAdapter = Pick; diff --git a/apps/server/src/network/validation/socketPayloadValidators.ts b/apps/server/src/network/validation/socketPayloadValidators.ts index 1f950cc..5b62965 100644 --- a/apps/server/src/network/validation/socketPayloadValidators.ts +++ b/apps/server/src/network/validation/socketPayloadValidators.ts @@ -4,6 +4,7 @@ */ import type { playerTypes, roomTypes, PlaceBombPayload } from "@repo/shared"; import type { PingPayload } from "@repo/shared"; +import { isPlaceBombPayload as isValidPlaceBombPayload } from "@server/domains/game/entities/bomb/bombPayloadValidation"; const isFiniteNumber = (value: unknown): value is number => { return typeof value === "number" && Number.isFinite(value); @@ -30,18 +31,7 @@ /** PLACE_BOMBイベントのペイロードが爆弾設置要求であるか判定する */ export const isPlaceBombPayload = (value: unknown): value is PlaceBombPayload => { - if (typeof value !== "object" || value === null) { - return false; - } - - const candidate = value as Record; - return ( - isNonEmptyString(candidate.requestId) - && - isFiniteNumber(candidate.x) - && isFiniteNumber(candidate.y) - && isFiniteNumber(candidate.explodeAtElapsedMs) - ); + return isValidPlaceBombPayload(value); }; /** JOIN_ROOMイベントのペイロードが参加情報であるか判定する */