diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index e946f48..e7af619 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -123,6 +123,9 @@ callbacks: loopCallbacks, }); + // startDelayMs の待機中にJITとボット初期状態を準備する + this.gameLoop.warmUp(); + if (startDelayMs === 0) { this.gameLoop.start(); return; diff --git a/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts b/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts index 9550ec8..f7a47d1 100644 --- a/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts +++ b/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts @@ -37,7 +37,7 @@ public decide( botPlayerId: BotPlayerId, player: Player, - gridColors: number[], + gridColors: readonly number[], nowMs: number, elapsedMs: number, ): BotDecision { diff --git a/apps/server/src/domains/game/application/services/bot/policies/TargetSelectionPolicy.ts b/apps/server/src/domains/game/application/services/bot/policies/TargetSelectionPolicy.ts index 06788e5..683d0e4 100644 --- a/apps/server/src/domains/game/application/services/bot/policies/TargetSelectionPolicy.ts +++ b/apps/server/src/domains/game/application/services/bot/policies/TargetSelectionPolicy.ts @@ -21,7 +21,7 @@ }; const getCellTeamId = ( - gridColors: number[], + gridColors: readonly number[], col: number, row: number, cols: number, @@ -33,7 +33,7 @@ export const chooseNextTarget = ( col: number, row: number, - gridColors: number[], + gridColors: readonly number[], size: MapGridSize, ): BotTarget => { const { UNPAINTED_PRIORITY_STRENGTH } = config.BOT_AI_CONFIG; diff --git a/apps/server/src/domains/game/application/services/gameResultCalculator.ts b/apps/server/src/domains/game/application/services/gameResultCalculator.ts index c5287fb..0010088 100644 --- a/apps/server/src/domains/game/application/services/gameResultCalculator.ts +++ b/apps/server/src/domains/game/application/services/gameResultCalculator.ts @@ -17,7 +17,7 @@ /** グリッド色配列とプレイヤー情報からゲーム結果ペイロードを生成する */ export const buildGameResultPayload = ( - gridColors: number[], + gridColors: readonly number[], players?: PlayerStatsSource[], ): GameResultPayload => { const { TEAM_COUNT } = config.GAME_CONFIG; diff --git a/apps/server/src/domains/game/entities/map/MapStore.ts b/apps/server/src/domains/game/entities/map/MapStore.ts index 6742b1c..17bb588 100644 --- a/apps/server/src/domains/game/entities/map/MapStore.ts +++ b/apps/server/src/domains/game/entities/map/MapStore.ts @@ -47,8 +47,8 @@ return updates; } - /** 現在のマップ塗り状態をスナップショットとして返す */ - public getGridColorsSnapshot(): number[] { - return [...this.gridColors]; + /** 現在のマップ塗り状態を読み取り専用参照として返す(コピーなし) */ + public getGridColorsSnapshot(): readonly number[] { + return this.gridColors; } } \ No newline at end of file diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index d052cb9..42bb4c9 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -56,6 +56,15 @@ type DamageSource = "bomb" | "hurricane"; +/** 1秒間のパフォーマンス統計蓄積バッファ */ +type PerfAccumulator = { + windowStartMs: number; + tickCount: number; + totalTickMs: number; + maxTickMs: number; + totalPayloadBytes: number; +}; + /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ export class GameLoop { private loopId: NodeJS.Timeout | null = null; @@ -71,6 +80,13 @@ private botTurnOrchestrator: BotTurnOrchestrator; private readonly botReceivedHitCountById = new Map(); private readonly hurricaneSystem: HurricaneSystem; + private perfAccumulator: PerfAccumulator = { + windowStartMs: 0, + tickCount: 0, + totalTickMs: 0, + maxTickMs: 0, + totalPayloadBytes: 0, + }; private readonly roomId: string; private readonly tickRate: number; @@ -94,6 +110,32 @@ this.callbacks = options.callbacks; } + /** + * ゲーム開始前にJITコンパイルを誘発し,全ボットの初期目標を設定する + * startDelayMs の待機中に呼ぶことで tick1 のスパイクを抑制する + */ + public warmUp(): void { + const nowMs = Date.now(); + const gridColorsSnapshot = this.mapStore.getGridColorsSnapshot(); + + this.players.forEach((player) => { + if ( + isBotPlayerId(player.id) || + this.disconnectedBotControlledPlayerIds.has(player.id) + ) { + // decide() を呼んでJITコンパイルを誘発し初期目標を隣接セルに設定する + // 戻り値は使用しない(位置更新・爆弾設置コールバックは発火しない) + this.botTurnOrchestrator.decide( + player.id as BotPlayerId, + player, + gridColorsSnapshot, + nowMs, + 0, + ); + } + }); + } + start() { // 既にループが回っている場合は何もしない if (this.isRunning) return; @@ -104,6 +146,13 @@ nowMs + config.GAME_CONFIG.GAME_DURATION_SEC * 1000; this.nextTickAtMs = nowMs + this.tickRate; this.lastSentPlayers.clear(); + this.perfAccumulator = { + windowStartMs: nowMs, + tickCount: 0, + totalTickMs: 0, + maxTickMs: 0, + totalPayloadBytes: 0, + }; this.isRunning = true; this.scheduleNextTick(); @@ -161,11 +210,11 @@ } private processSingleTick(): void { - const monotonicNowMs = performance.now(); + const tickStartMs = performance.now(); const wallClockNowMs = Date.now(); const elapsedMs = Math.max( 0, - Math.round(monotonicNowMs - this.startMonotonicTimeMs), + Math.round(tickStartMs - this.startMonotonicTimeMs), ); this.hurricaneSystem.ensureSpawned(elapsedMs); this.hurricaneSystem.update(this.tickRate / 1000); @@ -175,12 +224,63 @@ this.detectBotBombHits(elapsedMs, wallClockNowMs); const tickData = this.buildTickData(elapsedMs); this.callbacks.onTick(tickData); + + // パフォーマンス統計を蓄積し,1秒ごとにログ出力する + const tickMs = performance.now() - tickStartMs; + const payloadBytes = JSON.stringify(tickData).length; + this.accumulatePerfStats(tickMs, payloadBytes); + } + + /** tick処理時間とペイロードサイズを蓄積し,1秒経過でログを出力する */ + private accumulatePerfStats(tickMs: number, payloadBytes: number): void { + const acc = this.perfAccumulator; + acc.tickCount += 1; + acc.totalTickMs += tickMs; + acc.maxTickMs = Math.max(acc.maxTickMs, tickMs); + acc.totalPayloadBytes += payloadBytes; + + const windowMs = performance.now() - acc.windowStartMs; + if (windowMs < 1000) return; + + const playerCount = this.players.size; + const avgTickMs = + acc.tickCount > 0 + ? Math.round((acc.totalTickMs / acc.tickCount) * 10) / 10 + : 0; + const maxTickMs = Math.round(acc.maxTickMs * 10) / 10; + const cpuUsagePct = + Math.round((acc.totalTickMs / windowMs) * 1000) / 10; + const avgPayloadBytesPerTick = + acc.tickCount > 0 ? Math.round(acc.totalPayloadBytes / acc.tickCount) : 0; + const outboundBytesPerSec = avgPayloadBytesPerTick * playerCount * acc.tickCount; + + logEvent(logScopes.GAME_LOOP, { + event: gameDomainLogEvents.PERF_STATS, + result: logResults.STATS, + roomId: this.roomId, + playerCount, + tickCount: acc.tickCount, + avgTickMs, + maxTickMs, + cpuUsagePct, + avgPayloadBytesPerTick, + outboundBytesPerSec, + }); + + // ウィンドウをリセット + this.perfAccumulator = { + windowStartMs: performance.now(), + tickCount: 0, + totalTickMs: 0, + maxTickMs: 0, + totalPayloadBytes: 0, + }; } private updateBotPlayers( nowMs: number, elapsedMs: number, - gridColorsSnapshot: number[], + gridColorsSnapshot: readonly number[], ): void { this.players.forEach((player) => { if ( diff --git a/apps/server/src/logging/constants/eventNames.ts b/apps/server/src/logging/constants/eventNames.ts index 0aed1a4..7a689f7 100644 --- a/apps/server/src/logging/constants/eventNames.ts +++ b/apps/server/src/logging/constants/eventNames.ts @@ -27,6 +27,7 @@ PLAYER_MOVE: "PLAYER_MOVE", PLAYER_REMOVE: "PLAYER_REMOVE", GAME_LOOP: "GAME_LOOP", + PERF_STATS: "PERF_STATS", } as const; /** Roomドメインサービスログで利用するイベント名定数 */ diff --git a/apps/server/src/logging/constants/results.ts b/apps/server/src/logging/constants/results.ts index 6ab8c75..03131c0 100644 --- a/apps/server/src/logging/constants/results.ts +++ b/apps/server/src/logging/constants/results.ts @@ -32,6 +32,7 @@ REMOVED: "removed", SESSION_DISPOSED_EMPTY_ROOM: "session_disposed_empty_room", STARTED: "started", + STATS: "stats", STOPPED: "stopped", TRANSFERRED: "transferred", } as const; diff --git a/apps/server/src/logging/contracts/payloadByScope.ts b/apps/server/src/logging/contracts/payloadByScope.ts index 6232381..f1dc1ee 100644 --- a/apps/server/src/logging/contracts/payloadByScope.ts +++ b/apps/server/src/logging/contracts/payloadByScope.ts @@ -199,8 +199,8 @@ | GamePlayerOperationServiceMoveLogPayload | GamePlayerOperationServiceRemoveLogPayload; -/** GameLoopスコープのログ契約 */ -type GameLoopLogPayload = { +/** GameLoopのライフサイクルログ契約 */ +type GameLoopLifecycleLogPayload = { event: typeof gameDomainLogEvents.GAME_LOOP; result: | typeof logResults.STARTED @@ -208,6 +208,32 @@ roomId: string; }; +/** GameLoopの1秒間パフォーマンス統計ログ契約 */ +type GameLoopPerfStatsLogPayload = { + event: typeof gameDomainLogEvents.PERF_STATS; + result: typeof logResults.STATS; + roomId: string; + /** ルーム内プレイヤー数 */ + playerCount: number; + /** 1秒間に処理したtick数(期待値: 20) */ + tickCount: number; + /** tick処理の平均時間(ms) */ + avgTickMs: number; + /** tick処理の最大時間(ms) */ + maxTickMs: number; + /** tick処理時間の合計 / 計測ウィンドウ × 100(%) */ + cpuUsagePct: number; + /** 1tickあたりの平均ペイロードサイズ(bytes,JSON推定値) */ + avgPayloadBytesPerTick: number; + /** 1秒間の送信バイト数推定(avgPayloadBytesPerTick × playerCount × tickCount) */ + outboundBytesPerSec: number; +}; + +/** GameLoopスコープのログ契約 */ +type GameLoopLogPayload = + | GameLoopLifecycleLogPayload + | GameLoopPerfStatsLogPayload; + /** GameRoomSessionスコープのログ契約 */ type GameRoomSessionLogPayload = { event: typeof gameDomainLogEvents.MOVE;