diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 2f0c8cf..c6aa198 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -101,6 +101,11 @@ this.lifecycleService.registerActiveBomb(registration); } + /** 指定爆弾の所有者の bombHitCount を加算する */ + recordBombHitForOwner(bombId: string): void { + this.lifecycleService.recordBombHitForOwner(bombId); + } + dispose(): void { this.lifecycleService.dispose(); } diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index ebfd42f..990f18e 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -128,6 +128,11 @@ shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean; } +/** 被弾時に爆弾所有者のスタッツを更新するポート */ +export interface BombHitStatsPort { + recordBombHitForOwner(bombId: string): void; +} + /** 爆弾設置ユースケースの入力値 */ export type PlaceBombInput = { socketId: string; diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index f89c7da..6598066 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -84,6 +84,7 @@ onGameEnd: () => { const resultPayload = buildGameResultPayload( this.mapStore.getGridColorsSnapshot(), + Array.from(this.players.values()), ); this.dispose(); callbacks.onGameEnd(resultPayload); @@ -195,11 +196,26 @@ const ownerTeamId = player?.teamId ?? -1; this.bombStateStore.activeBombRegistry.registerBomb({ bombId: registration.bombId, + ownerPlayerId: registration.ownerPlayerId, x: registration.x, y: registration.y, explodeAtElapsedMs: registration.explodeAtElapsedMs, ownerTeamId, }); + this.bombStateStore.registerBombOwner( + registration.bombId, + registration.ownerPlayerId, + ); + } + + /** 指定爆弾の所有者の bombHitCount を加算する */ + public recordBombHitForOwner(bombId: string): void { + const ownerPlayerId = this.bombStateStore.getBombOwnerPlayerId(bombId); + if (!ownerPlayerId) return; + const owner = this.players.get(ownerPlayerId); + if (owner) { + owner.bombHitCount += 1; + } } public dispose(): void { diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index 6a66cdc..f6e826c 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -66,6 +66,11 @@ this.sessionRef.current?.registerActiveBomb(registration); } + /** 指定爆弾の所有者の bombHitCount を加算する */ + public recordBombHitForOwner(bombId: string): void { + this.sessionRef.current?.recordBombHitForOwner(bombId); + } + public startRoomSession( playerIds: string[], playerNamesById: Record, diff --git a/apps/server/src/domains/game/application/services/gameResultCalculator.ts b/apps/server/src/domains/game/application/services/gameResultCalculator.ts index 090cf71..c5287fb 100644 --- a/apps/server/src/domains/game/application/services/gameResultCalculator.ts +++ b/apps/server/src/domains/game/application/services/gameResultCalculator.ts @@ -4,11 +4,21 @@ * 塗り率計算,同率順位付け,チーム名解決を一箇所で扱う */ import { config } from "@server/config"; -import type { GameResultPayload } from "@repo/shared"; +import type { GameResultPayload, PlayerGameStats } from "@repo/shared"; -/** グリッド色配列からゲーム結果ペイロードを生成する */ +/** プレイヤースタッツ構築に必要な最小情報 */ +type PlayerStatsSource = { + id: string; + name: string; + teamId: number; + paintCount: number; + bombHitCount: number; +}; + +/** グリッド色配列とプレイヤー情報からゲーム結果ペイロードを生成する */ export const buildGameResultPayload = ( gridColors: number[], + players?: PlayerStatsSource[], ): GameResultPayload => { const { TEAM_COUNT } = config.GAME_CONFIG; const totalCells = gridColors.length; @@ -53,8 +63,17 @@ item.rank = currentRank; }); + const playerStats: PlayerGameStats[] | undefined = players?.map((p) => ({ + playerId: p.id, + playerName: p.name, + teamId: p.teamId, + paintCount: p.paintCount, + bombHitCount: p.bombHitCount, + })); + return { rankings, finalGridColors: [...gridColors], + ...(playerStats ? { playerStats } : {}), }; }; diff --git a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts index e47b9eb..5f393de 100644 --- a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts @@ -6,6 +6,7 @@ import type { PlayerDeadOutputPort, BombHitReportValidationPort, + BombHitStatsPort, ReportBombHitInput, } from "../ports/gameUseCasePorts"; import { shouldPublishPlayerDeadFromBombHit } from "./reportBombHitValidation"; @@ -13,6 +14,7 @@ type ReportBombHitUseCaseParams = { roomId: string; validation: BombHitReportValidationPort; + stats: BombHitStatsPort; input: ReportBombHitInput; output: PlayerDeadOutputPort; }; @@ -21,6 +23,7 @@ export const reportBombHitUseCase = ({ roomId, validation, + stats, input, output, }: ReportBombHitUseCaseParams): void => { @@ -28,6 +31,8 @@ return; } + stats.recordBombHitForOwner(input.payload.bombId); + const deadPlayerId = input.socketId; output.publishPlayerDeadToOthersInRoom(roomId, deadPlayerId, { diff --git a/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts b/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts index 446b082..a4c38f2 100644 --- a/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts +++ b/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts @@ -7,6 +7,7 @@ /** アクティブ爆弾の状態表現 */ export type ActiveBomb = { bombId: string; + ownerPlayerId: string; x: number; y: number; explodeAtElapsedMs: number; diff --git a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts index 19610bd..e36bb1a 100644 --- a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts +++ b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts @@ -18,6 +18,9 @@ /** アクティブ爆弾のライフサイクルを追跡するレジストリ */ public readonly activeBombRegistry = new ActiveBombRegistry(); + /** 爆弾IDから設置者プレイヤーIDを引くためのマップ(爆発後も保持する) */ + private bombOwnerMap = new Map(); + /** 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ public shouldBroadcastBombPlaced(dedupeKey: string, nowMs: number): boolean { return shouldBroadcastBombPlaced({ @@ -44,4 +47,14 @@ this.bombSerial = nextSerial; return bombId; } + + /** 爆弾IDと設置者プレイヤーIDを紐づけて記録する */ + public registerBombOwner(bombId: string, ownerPlayerId: string): void { + this.bombOwnerMap.set(bombId, ownerPlayerId); + } + + /** 爆弾IDから設置者プレイヤーIDを取得する */ + public getBombOwnerPlayerId(bombId: string): string | undefined { + return this.bombOwnerMap.get(bombId); + } } diff --git a/apps/server/src/domains/game/entities/map/MapStore.ts b/apps/server/src/domains/game/entities/map/MapStore.ts index 191417a..ec06150 100644 --- a/apps/server/src/domains/game/entities/map/MapStore.ts +++ b/apps/server/src/domains/game/entities/map/MapStore.ts @@ -21,9 +21,10 @@ /** * マスを塗り,色が変化した場合のみ差分キューに追加する + * @returns 色が実際に変わった場合 true */ - public paintCell(index: number, teamId: number): void { - paintCellIfChanged({ + public paintCell(index: number, teamId: number): boolean { + return paintCellIfChanged({ gridColors: this.gridColors, pendingUpdates: this.pendingUpdates, index, diff --git a/apps/server/src/domains/game/entities/map/mapPainting.ts b/apps/server/src/domains/game/entities/map/mapPainting.ts index 6135b8f..d30c0a5 100644 --- a/apps/server/src/domains/game/entities/map/mapPainting.ts +++ b/apps/server/src/domains/game/entities/map/mapPainting.ts @@ -11,17 +11,18 @@ teamId: number; }; -/** マップセルの色が変わった場合のみ差分へ追加する */ +/** マップセルの色が変わった場合のみ差分へ追加し,変更の有無を返す */ export const paintCellIfChanged = ({ gridColors, pendingUpdates, index, teamId, -}: PaintCellParams): void => { +}: PaintCellParams): boolean => { if (gridColors[index] === teamId) { - return; + return false; } gridColors[index] = teamId; pendingUpdates.push({ index, teamId }); + return true; }; diff --git a/apps/server/src/domains/game/entities/player/Player.ts b/apps/server/src/domains/game/entities/player/Player.ts index 2c658c8..79a4a3e 100644 --- a/apps/server/src/domains/game/entities/player/Player.ts +++ b/apps/server/src/domains/game/entities/player/Player.ts @@ -11,6 +11,11 @@ public y: number = 0; public teamId: number; + /** セルの色を塗り替えた回数 */ + public paintCount: number = 0; + /** 爆弾を敵に当てた回数 */ + public bombHitCount: number = 0; + // 💡 コンストラクタで teamId を受け取るように変更 constructor(id: string, name: string, teamId: number) { this.id = id; diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index a9c21ad..cb2b7d9 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -222,6 +222,13 @@ player.id as BotPlayerId, nowMs, ); + + // 爆弾所有者の bombHitCount を加算する + const owner = this.players.get(bomb.ownerPlayerId); + if (owner) { + owner.bombHitCount += 1; + } + onBotBombHit(player.id, bomb.bombId); } } @@ -299,7 +306,10 @@ for (const { gridIndex, player } of gridEntries) { if (gridIndex !== null && isCellPaintable(cellTeamMap, gridIndex)) { - this.mapStore.paintCell(gridIndex, player.teamId); + const changed = this.mapStore.paintCell(gridIndex, player.teamId); + if (changed) { + player.paintCount += 1; + } } } } diff --git a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts index af90407..3de357d 100644 --- a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts +++ b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts @@ -5,6 +5,7 @@ import { domain } from "@repo/shared"; import type { BombHitReportValidationPort, + BombHitStatsPort, BombPlacementPort, DisconnectPlayerPort, MovePlayerPort, @@ -19,6 +20,7 @@ & MovePlayerPort & BombPlacementPort & BombHitReportValidationPort + & BombHitStatsPort & DisconnectPlayerPort; /** ルーム参加処理の実行結果 */ diff --git a/apps/server/src/network/handlers/game/gameEventOrchestrators.ts b/apps/server/src/network/handlers/game/gameEventOrchestrators.ts index 206ac49..bb6b4ae 100644 --- a/apps/server/src/network/handlers/game/gameEventOrchestrators.ts +++ b/apps/server/src/network/handlers/game/gameEventOrchestrators.ts @@ -143,6 +143,7 @@ reportBombHitUseCase({ roomId, validation: gameManager, + stats: gameManager, input: { socketId: deps.socketId, payload, diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index 3276144..dc59f39 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -35,6 +35,7 @@ PlayerDeadPayload, GameResultPayload, GameResultRanking, + PlayerGameStats, } from "./payloads/gamePayloads"; /** 被弾演出イベントのペイロード型を再公開する */ diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index 80752ad..48b115a 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -18,11 +18,24 @@ paintRate: number; }; +/** game-result イベントで送受信するプレイヤー個人スタッツ */ +export type PlayerGameStats = { + playerId: string; + playerName: string; + teamId: number; + /** セルの色を塗り替えた回数 */ + paintCount: number; + /** 爆弾を敵に当てた回数 */ + bombHitCount: number; +}; + /** game-result イベントで送受信する最終結果 */ export type GameResultPayload = { rankings: GameResultRanking[]; /** ゲーム終了時点のマップ色配列,index はセル位置に対応する */ finalGridColors?: number[]; + /** プレイヤー個人スタッツ一覧 */ + playerStats?: PlayerGameStats[]; }; /** current-players で配信するプレイヤー全体スナップショット */