diff --git a/apps/client/src/hooks/useSocketSubscriptions.ts b/apps/client/src/hooks/useSocketSubscriptions.ts index 19a2ef8..8d8009f 100644 --- a/apps/client/src/hooks/useSocketSubscriptions.ts +++ b/apps/client/src/hooks/useSocketSubscriptions.ts @@ -7,13 +7,14 @@ import { socketManager } from "@client/network/SocketManager"; import { domain } from "@repo/shared"; import type { GameResultPayload } from "@repo/shared"; +import type { Dispatch, SetStateAction } from "react"; type UseSocketSubscriptionsParams = { completeJoinRequest: () => void; setGameResult: (payload: GameResultPayload | null) => void; setMyId: (id: string | null) => void; setRoom: (room: domain.room.Room | null) => void; - setScenePhase: (phase: domain.app.ScenePhaseType) => void; + setScenePhase: Dispatch>; }; type AppSocketHandlers = { @@ -80,7 +81,16 @@ handleRoomUpdate: (updatedRoom: domain.room.Room) => { completeJoinRequest(); setRoom(updatedRoom); - setScenePhase(domain.app.ScenePhase.LOBBY); + setScenePhase((currentPhase) => { + if ( + currentPhase === domain.app.ScenePhase.PLAYING || + currentPhase === domain.app.ScenePhase.RESULT + ) { + return currentPhase; + } + + return domain.app.ScenePhase.LOBBY; + }); }, handleGameStart: () => { diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 128278b..4b0e499 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -48,6 +48,11 @@ this.playerOperationService.removePlayer(id); } + // 切断プレイヤーをBot制御へ引き継ぐ + replaceDisconnectedPlayerWithBot(id: string): boolean { + return this.playerOperationService.replaceDisconnectedPlayerWithBot(id); + } + // 指定プレイヤー座標更新処理 movePlayer(id: string, x: number, y: number) { this.playerOperationService.movePlayer(id, x, y); diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index a5bd40d..0e247fc 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -44,6 +44,7 @@ /** 切断ユースケースが利用するプレイヤー削除入力ポート */ export interface DisconnectPlayerPort { removePlayer(id: string): void; + replaceDisconnectedPlayerWithBot(id: string): boolean; } /** ゲーム系ユースケースが利用する送信出力ポート */ diff --git a/apps/server/src/domains/game/application/services/GamePlayerOperationService.ts b/apps/server/src/domains/game/application/services/GamePlayerOperationService.ts index 8367b1d..f2e3505 100644 --- a/apps/server/src/domains/game/application/services/GamePlayerOperationService.ts +++ b/apps/server/src/domains/game/application/services/GamePlayerOperationService.ts @@ -55,4 +55,18 @@ }); } } + + public replaceDisconnectedPlayerWithBot(id: string): boolean { + const session = this.sessionRef.current; + if (!session || !this.activePlayerIds.has(id)) { + logEvent(logScopes.GAME_PLAYER_OPERATION_SERVICE, { + event: gameDomainLogEvents.PLAYER_REMOVE, + result: logResults.IGNORED_PLAYER_NOT_IN_SESSION, + socketId: id, + }); + return false; + } + + return session.promotePlayerToBotControl(id); + } } \ No newline at end of file diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 6b52829..cd90ba7 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -135,9 +135,20 @@ } public removePlayer(id: string): boolean { + this.gameLoop?.releaseBotControl(id); return this.players.delete(id); } + /** 指定プレイヤーを切断後もBot制御で継続させる */ + public promotePlayerToBotControl(id: string): boolean { + if (!this.players.has(id) || !this.gameLoop) { + return false; + } + + this.gameLoop.promotePlayerToBotControl(id); + return true; + } + public getStartTime(): number | undefined { return this.startTime; } diff --git a/apps/server/src/domains/game/application/useCases/disconnectUseCase.ts b/apps/server/src/domains/game/application/useCases/disconnectUseCase.ts index c762160..a7def6d 100644 --- a/apps/server/src/domains/game/application/useCases/disconnectUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/disconnectUseCase.ts @@ -20,15 +20,20 @@ playerId, output, }: DisconnectUseCaseParams) => { - gameManager.removePlayer(playerId); + const replacedWithBot = gameManager.replaceDisconnectedPlayerWithBot(playerId); - if (roomId) { - output.publishPlayerRemovedToRoom(roomId, playerId); + if (!replacedWithBot) { + gameManager.removePlayer(playerId); + + if (roomId) { + output.publishPlayerRemovedToRoom(roomId, playerId); + } } logEvent(logScopes.GAME_USE_CASE, { event: gameUseCaseLogEvents.DISCONNECT, result: logResults.PLAYER_REMOVED, socketId: playerId, + replacedWithBot, }); }; diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index 35e3df8..403867b 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -13,7 +13,11 @@ logResults, logScopes, } from "@server/logging/index"; -import { BotTurnOrchestrator, isBotPlayerId } from "../application/services/bot/index.js"; +import { + BotTurnOrchestrator, + isBotPlayerId, + type BotPlayerId, +} from "../application/services/bot/index.js"; import { setPlayerPosition } from "../entities/player/playerMovement.js"; /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ @@ -26,6 +30,7 @@ private readonly maxCatchUpTicks: number = 3; private lastSentPlayers: Map = new Map(); + private disconnectedBotControlledPlayerIds: Set = new Set(); private botTurnOrchestrator: BotTurnOrchestrator = new BotTurnOrchestrator(); @@ -127,9 +132,12 @@ gridColorsSnapshot: number[], ): void { this.players.forEach((player) => { - if (isBotPlayerId(player.id)) { + if ( + isBotPlayerId(player.id) || + this.disconnectedBotControlledPlayerIds.has(player.id) + ) { const decision = this.botTurnOrchestrator.decide( - player.id, + player.id as BotPlayerId, player, gridColorsSnapshot, nowMs, @@ -147,14 +155,29 @@ /** 指定プレイヤーがBotなら被弾硬直を適用する */ public applyBotHitStun(playerId: string, nowMs: number): boolean { const player = this.players.get(playerId); - if (!player || !isBotPlayerId(player.id)) { + const isBotControlled = + !!player && + (isBotPlayerId(player.id) || + this.disconnectedBotControlledPlayerIds.has(player.id)); + + if (!isBotControlled) { return false; } - this.botTurnOrchestrator.applyHitStun(player.id, nowMs); + this.botTurnOrchestrator.applyHitStun(playerId as BotPlayerId, nowMs); return true; } + /** 切断プレイヤーをBot制御対象へ昇格する */ + public promotePlayerToBotControl(playerId: string): void { + this.disconnectedBotControlledPlayerIds.add(playerId); + } + + /** プレイヤー削除時にBot制御対象から除外する */ + public releaseBotControl(playerId: string): void { + this.disconnectedBotControlledPlayerIds.delete(playerId); + } + private buildTickData(): domain.game.TickData { const activePlayerIds = new Set(); const playerUpdates = this.collectChangedPlayerUpdates(activePlayerIds); @@ -213,6 +236,7 @@ this.isRunning = false; this.botTurnOrchestrator.clear(); + this.disconnectedBotControlledPlayerIds.clear(); this.lastSentPlayers.clear(); if (this.loopId) {