diff --git a/apps/server/src/application/coordinators/startGameCoordinator.ts b/apps/server/src/application/coordinators/startGameCoordinator.ts index 6df458b..c265c9e 100644 --- a/apps/server/src/application/coordinators/startGameCoordinator.ts +++ b/apps/server/src/application/coordinators/startGameCoordinator.ts @@ -8,7 +8,7 @@ import { createBalancedSessionPlayerIds, isBotPlayerId, -} from "@server/domains/game/application/services/BotRosterService"; +} from "@server/domains/game/application/services/bot/roster/BotRosterService"; import { logEvent } from "@server/logging/logger"; import { gameUseCaseLogEvents, diff --git a/apps/server/src/domains/game/application/services/BotAiService.ts b/apps/server/src/domains/game/application/services/BotAiService.ts deleted file mode 100644 index 0818cba..0000000 --- a/apps/server/src/domains/game/application/services/BotAiService.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * BotAiService - * Botの移動・爆弾行動を決定する - */ -import type { PlaceBombPayload } from "@repo/shared"; -import { config } from "@server/config"; -import type { BotPlayerId } from "./BotRosterService"; -import type { Player } from "../../entities/player/Player"; - -type BotState = { - targetCol: number; - targetRow: number; - lastBombPlacedAtMs: number; - bombSeq: number; -}; - -type BotDecision = { - nextX: number; - nextY: number; - placeBombPayload: PlaceBombPayload | null; -}; - -const UNPAINTED_TEAM_ID = -1; - -const clamp = (value: number, min: number, max: number): number => { - return Math.max(min, Math.min(max, value)); -}; - -const toGridIndex = (col: number, row: number, cols: number): number => { - return row * cols + col; -}; - -const getCellTeamId = ( - gridColors: number[], - col: number, - row: number, - cols: number, -): number => { - return gridColors[toGridIndex(col, row, cols)] ?? UNPAINTED_TEAM_ID; -}; - -const chooseNextTarget = ( - col: number, - row: number, - gridColors: number[], -): { col: number; row: number } => { - const { UNPAINTED_PRIORITY_STRENGTH } = config.BOT_AI_CONFIG; - const { GRID_COLS, GRID_ROWS } = config.GAME_CONFIG; - const candidates = [ - { col: col + 1, row }, - { col: col - 1, row }, - { col, row: row + 1 }, - { col, row: row - 1 }, - ].filter((candidate) => { - return ( - candidate.col >= 0 && - candidate.col < GRID_COLS && - candidate.row >= 0 && - candidate.row < GRID_ROWS - ); - }); - - if (candidates.length === 0) { - return { col, row }; - } - - const unpaintedCandidates = candidates.filter((candidate) => { - return ( - getCellTeamId(gridColors, candidate.col, candidate.row, GRID_COLS) === - UNPAINTED_TEAM_ID - ); - }); - - if ( - unpaintedCandidates.length > 0 && - Math.random() < clamp(UNPAINTED_PRIORITY_STRENGTH, 0, 1) - ) { - return ( - unpaintedCandidates[ - Math.floor(Math.random() * unpaintedCandidates.length) - ] ?? { col, row } - ); - } - - return ( - candidates[Math.floor(Math.random() * candidates.length)] ?? { col, row } - ); -}; - -const moveTowardsTarget = ( - x: number, - y: number, - targetCol: number, - targetRow: number, -): { nextX: number; nextY: number } => { - const targetX = targetCol + 0.5; - const targetY = targetRow + 0.5; - const diffX = targetX - x; - const diffY = targetY - y; - const distance = Math.hypot(diffX, diffY); - - const maxStep = - config.GAME_CONFIG.PLAYER_SPEED * - (config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS / 1000) * - clamp(config.BOT_AI_CONFIG.MOVE_SMOOTHNESS, 0.1, 2); - - if (distance <= maxStep || distance === 0) { - return { nextX: targetX, nextY: targetY }; - } - - const ratio = maxStep / distance; - const nextX = x + diffX * ratio; - const nextY = y + diffY * ratio; - - return { - nextX: clamp(nextX, 0, config.GAME_CONFIG.GRID_COLS - 0.001), - nextY: clamp(nextY, 0, config.GAME_CONFIG.GRID_ROWS - 0.001), - }; -}; - -/** Botの移動・爆弾行動を管理するサービス */ -export class BotAiService { - private states = new Map(); - - public decide( - botPlayerId: BotPlayerId, - player: Player, - gridColors: number[], - nowMs: number, - elapsedMs: number, - ): BotDecision { - const { GRID_COLS, GRID_ROWS, BOMB_COOLDOWN_MS, BOMB_FUSE_MS } = - config.GAME_CONFIG; - const currentCol = clamp(Math.floor(player.x), 0, GRID_COLS - 1); - const currentRow = clamp(Math.floor(player.y), 0, GRID_ROWS - 1); - - const currentState = this.states.get(botPlayerId) ?? { - targetCol: currentCol, - targetRow: currentRow, - lastBombPlacedAtMs: Number.NEGATIVE_INFINITY, - bombSeq: 0, - }; - - const targetCenterX = currentState.targetCol + 0.5; - const targetCenterY = currentState.targetRow + 0.5; - const reachedTarget = - Math.hypot(targetCenterX - player.x, targetCenterY - player.y) <= - config.BOT_AI_CONFIG.TARGET_REACHED_EPSILON; - - const nextTarget = reachedTarget - ? chooseNextTarget(currentCol, currentRow, gridColors) - : { col: currentState.targetCol, row: currentState.targetRow }; - - const moved = moveTowardsTarget( - player.x, - player.y, - nextTarget.col, - nextTarget.row, - ); - - let placeBombPayload: PlaceBombPayload | null = null; - const canPlaceBomb = - nowMs - currentState.lastBombPlacedAtMs >= BOMB_COOLDOWN_MS; - if ( - canPlaceBomb && - Math.random() < - clamp(config.BOT_AI_CONFIG.BOMB_PLACE_PROBABILITY_PER_TICK, 0, 1) - ) { - const nextBombSeq = currentState.bombSeq + 1; - placeBombPayload = { - requestId: `bot-${botPlayerId}-${nextBombSeq}`, - x: moved.nextX, - y: moved.nextY, - explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, - }; - - this.states.set(botPlayerId, { - targetCol: nextTarget.col, - targetRow: nextTarget.row, - bombSeq: nextBombSeq, - lastBombPlacedAtMs: nowMs, - }); - - return { - nextX: moved.nextX, - nextY: moved.nextY, - placeBombPayload, - }; - } - - this.states.set(botPlayerId, { - targetCol: nextTarget.col, - targetRow: nextTarget.row, - bombSeq: currentState.bombSeq, - lastBombPlacedAtMs: currentState.lastBombPlacedAtMs, - }); - - return { - nextX: moved.nextX, - nextY: moved.nextY, - placeBombPayload, - }; - } - - public clear(): void { - this.states.clear(); - } -} diff --git a/apps/server/src/domains/game/application/services/BotBombActionService.ts b/apps/server/src/domains/game/application/services/BotBombActionService.ts deleted file mode 100644 index 26d8c14..0000000 --- a/apps/server/src/domains/game/application/services/BotBombActionService.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * BotBombActionService - * Bot由来の爆弾設置アクションを既存ユースケースへ橋渡しする - */ -import type { PlaceBombPayload } from "@repo/shared"; -import type { - BombPlacementPort, - StartGameOutputPort, -} from "../ports/gameUseCasePorts"; -import { placeBombUseCase } from "../useCases/placeBombUseCase"; - -type CreateBotBombActionHandlerParams = { - roomId: string; - bombStore: BombPlacementPort; - output: StartGameOutputPort; -}; - -/** Bot爆弾アクションを処理するコールバックを生成する */ -export const createBotBombActionHandler = ({ - roomId, - bombStore, - output, -}: CreateBotBombActionHandlerParams) => { - return (ownerId: string, payload: PlaceBombPayload): void => { - placeBombUseCase({ - roomId, - bombStore, - input: { - socketId: ownerId, - payload, - nowMs: Date.now(), - }, - output, - }); - }; -}; diff --git a/apps/server/src/domains/game/application/services/BotRosterService.ts b/apps/server/src/domains/game/application/services/BotRosterService.ts deleted file mode 100644 index 3aca566..0000000 --- a/apps/server/src/domains/game/application/services/BotRosterService.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * BotRosterService - * 4チームの人数差をなくすためのBot ID補充ロジックを提供する - */ -import { config } from "@server/config"; - -const BOT_PLAYER_ID_PREFIX = "bot:"; -declare const botPlayerIdBrand: unique symbol; - -export type BotPlayerId = string & { readonly [botPlayerIdBrand]: true }; - -/** BotプレイヤーIDを生成する */ -const createBotPlayerId = ( - roomId: string, - serialNumber: number, -): BotPlayerId => { - return `${BOT_PLAYER_ID_PREFIX}${roomId}:${serialNumber}` as BotPlayerId; -}; - -/** BotプレイヤーIDかどうかを判定する */ -export const isBotPlayerId = (playerId: string): playerId is BotPlayerId => { - return playerId.startsWith(BOT_PLAYER_ID_PREFIX); -}; - -const getMinimumTotalPlayers = (humanPlayerCount: number): number => { - const teamCount = config.GAME_CONFIG.TEAM_COUNT; - if (teamCount <= 0) { - return humanPlayerCount; - } - - const remainder = humanPlayerCount % teamCount; - if (remainder === 0) { - return humanPlayerCount; - } - - return humanPlayerCount + (teamCount - remainder); -}; - -const resolveTargetTotalPlayers = ( - humanPlayerCount: number, - requestedPlayerCount: number | undefined, -): number => { - const minimumTotal = getMinimumTotalPlayers(humanPlayerCount); - const teamCount = config.GAME_CONFIG.TEAM_COUNT; - const maxTotal = config.GAME_CONFIG.MAX_PLAYERS_PER_ROOM; - - if ( - requestedPlayerCount === undefined || - teamCount <= 0 || - requestedPlayerCount > maxTotal || - requestedPlayerCount < minimumTotal || - requestedPlayerCount % teamCount !== 0 - ) { - return minimumTotal; - } - - return requestedPlayerCount; -}; - -const getRequiredBotCount = ( - humanPlayerCount: number, - requestedPlayerCount: number | undefined, -): number => { - const totalPlayers = resolveTargetTotalPlayers( - humanPlayerCount, - requestedPlayerCount, - ); - return Math.max(0, totalPlayers - humanPlayerCount); -}; - -/** - * 人間プレイヤーIDに必要数のBot IDを補充し,チーム人数差が0になる構成を返す - */ -export const createBalancedSessionPlayerIds = ( - roomId: string, - humanPlayerIds: string[], - requestedPlayerCount?: number, -): string[] => { - const requiredBotCount = getRequiredBotCount( - humanPlayerIds.length, - requestedPlayerCount, - ); - if (requiredBotCount === 0) { - return [...humanPlayerIds]; - } - - const botIds = Array.from({ length: requiredBotCount }, (_, index) => { - return createBotPlayerId(roomId, index + 1); - }); - - return [...humanPlayerIds, ...botIds]; -}; diff --git a/apps/server/src/domains/game/application/services/bot/adapters/BotBombActionAdapter.ts b/apps/server/src/domains/game/application/services/bot/adapters/BotBombActionAdapter.ts new file mode 100644 index 0000000..c77b492 --- /dev/null +++ b/apps/server/src/domains/game/application/services/bot/adapters/BotBombActionAdapter.ts @@ -0,0 +1,36 @@ +/** + * BotBombActionAdapter + * Bot由来の爆弾設置アクションをユースケースへ橋渡しする + */ +import type { PlaceBombPayload } from "@repo/shared"; +import type { + BombPlacementPort, + StartGameOutputPort, +} from "../../../ports/gameUseCasePorts"; +import { placeBombUseCase } from "../../../useCases/placeBombUseCase"; + +type CreateBotBombActionHandlerParams = { + roomId: string; + bombStore: BombPlacementPort; + output: StartGameOutputPort; +}; + +/** Bot爆弾アクションを処理するコールバックを生成する */ +export const createBotBombActionHandler = ({ + roomId, + bombStore, + output, +}: CreateBotBombActionHandlerParams) => { + return (ownerId: string, payload: PlaceBombPayload): void => { + placeBombUseCase({ + roomId, + bombStore, + input: { + socketId: ownerId, + payload, + nowMs: Date.now(), + }, + output, + }); + }; +}; diff --git a/apps/server/src/domains/game/application/services/bot/movement/MovePlanner.ts b/apps/server/src/domains/game/application/services/bot/movement/MovePlanner.ts new file mode 100644 index 0000000..49d8cdb --- /dev/null +++ b/apps/server/src/domains/game/application/services/bot/movement/MovePlanner.ts @@ -0,0 +1,41 @@ +/** + * MovePlanner + * 目標セルに向かう次フレーム座標を計算する + */ +import { config } from "@server/config"; + +const clamp = (value: number, min: number, max: number): number => { + return Math.max(min, Math.min(max, value)); +}; + +/** 現在座標から目標セル中心へ向かう次座標を計算する */ +export const moveTowardsTarget = ( + x: number, + y: number, + targetCol: number, + targetRow: number, +): { nextX: number; nextY: number } => { + const targetX = targetCol + 0.5; + const targetY = targetRow + 0.5; + const diffX = targetX - x; + const diffY = targetY - y; + const distance = Math.hypot(diffX, diffY); + + const maxStep = + config.GAME_CONFIG.PLAYER_SPEED * + (config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS / 1000) * + clamp(config.BOT_AI_CONFIG.MOVE_SMOOTHNESS, 0.1, 2); + + if (distance <= maxStep || distance === 0) { + return { nextX: targetX, nextY: targetY }; + } + + const ratio = maxStep / distance; + const nextX = x + diffX * ratio; + const nextY = y + diffY * ratio; + + return { + nextX: clamp(nextX, 0, config.GAME_CONFIG.GRID_COLS - 0.001), + nextY: clamp(nextY, 0, config.GAME_CONFIG.GRID_ROWS - 0.001), + }; +}; 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 new file mode 100644 index 0000000..aa432dd --- /dev/null +++ b/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts @@ -0,0 +1,84 @@ +/** + * BotTurnOrchestrator + * Botの移動目標選択,移動計算,爆弾設置判断を統合して実行する + */ +import { config } from "@server/config"; +import type { Player } from "../../../../entities/player/Player"; +import type { BotPlayerId } from "../roster/BotRosterService.js"; +import { moveTowardsTarget } from "../movement/MovePlanner.js"; +import { chooseNextTarget } from "../policies/TargetSelectionPolicy.js"; +import { decideBombPlacement } from "../policies/BombPlacementPolicy.js"; +import { BotStateStore } from "../state/BotStateStore.js"; +import type { BotDecision } from "../types/BotTypes.js"; + +const clamp = (value: number, min: number, max: number): number => { + return Math.max(min, Math.min(max, value)); +}; + +/** Botの1tick分の意思決定を提供するオーケストレータ */ +export class BotTurnOrchestrator { + private stateStore = new BotStateStore(); + + public decide( + botPlayerId: BotPlayerId, + player: Player, + gridColors: number[], + nowMs: number, + elapsedMs: number, + ): BotDecision { + const { GRID_COLS, GRID_ROWS } = config.GAME_CONFIG; + const currentCol = clamp(Math.floor(player.x), 0, GRID_COLS - 1); + const currentRow = clamp(Math.floor(player.y), 0, GRID_ROWS - 1); + + const currentState = this.stateStore.getOrCreate(botPlayerId, { + targetCol: currentCol, + targetRow: currentRow, + lastBombPlacedAtMs: Number.NEGATIVE_INFINITY, + bombSeq: 0, + }); + + const targetCenterX = currentState.targetCol + 0.5; + const targetCenterY = currentState.targetRow + 0.5; + const reachedTarget = + Math.hypot(targetCenterX - player.x, targetCenterY - player.y) <= + config.BOT_AI_CONFIG.TARGET_REACHED_EPSILON; + + const nextTarget = reachedTarget + ? chooseNextTarget(currentCol, currentRow, gridColors) + : { col: currentState.targetCol, row: currentState.targetRow }; + + const moved = moveTowardsTarget( + player.x, + player.y, + nextTarget.col, + nextTarget.row, + ); + + const bombDecision = decideBombPlacement( + botPlayerId, + nowMs, + elapsedMs, + currentState.lastBombPlacedAtMs, + currentState.bombSeq, + moved.nextX, + moved.nextY, + ); + + this.stateStore.set(botPlayerId, { + targetCol: nextTarget.col, + targetRow: nextTarget.row, + bombSeq: bombDecision.nextBombSeq, + lastBombPlacedAtMs: bombDecision.nextLastBombPlacedAtMs, + }); + + return { + nextX: moved.nextX, + nextY: moved.nextY, + placeBombPayload: bombDecision.placeBombPayload, + }; + } + + public clear(): void { + this.stateStore.clear(); + } +} diff --git a/apps/server/src/domains/game/application/services/bot/policies/BombPlacementPolicy.ts b/apps/server/src/domains/game/application/services/bot/policies/BombPlacementPolicy.ts new file mode 100644 index 0000000..23eba34 --- /dev/null +++ b/apps/server/src/domains/game/application/services/bot/policies/BombPlacementPolicy.ts @@ -0,0 +1,53 @@ +/** + * BombPlacementPolicy + * 爆弾設置可否の判定とペイロード生成を提供する + */ +import type { PlaceBombPayload } from "@repo/shared"; +import { config } from "@server/config"; +import type { BotPlayerId } from "../roster/BotRosterService.js"; + +const clamp = (value: number, min: number, max: number): number => { + return Math.max(min, Math.min(max, value)); +}; + +/** 爆弾設置可否を判定して設置時の情報を返す */ +export const decideBombPlacement = ( + botPlayerId: BotPlayerId, + nowMs: number, + elapsedMs: number, + lastBombPlacedAtMs: number, + bombSeq: number, + x: number, + y: number, +): { + placeBombPayload: PlaceBombPayload | null; + nextBombSeq: number; + nextLastBombPlacedAtMs: number; +} => { + const { BOMB_COOLDOWN_MS, BOMB_FUSE_MS } = config.GAME_CONFIG; + const canPlaceBomb = nowMs - lastBombPlacedAtMs >= BOMB_COOLDOWN_MS; + + if ( + !canPlaceBomb || + Math.random() >= + clamp(config.BOT_AI_CONFIG.BOMB_PLACE_PROBABILITY_PER_TICK, 0, 1) + ) { + return { + placeBombPayload: null, + nextBombSeq: bombSeq, + nextLastBombPlacedAtMs: lastBombPlacedAtMs, + }; + } + + const nextBombSeq = bombSeq + 1; + return { + placeBombPayload: { + requestId: `bot-${botPlayerId}-${nextBombSeq}`, + x, + y, + explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, + }, + nextBombSeq, + nextLastBombPlacedAtMs: nowMs, + }; +}; 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 new file mode 100644 index 0000000..1024753 --- /dev/null +++ b/apps/server/src/domains/game/application/services/bot/policies/TargetSelectionPolicy.ts @@ -0,0 +1,74 @@ +/** + * TargetSelectionPolicy + * グリッド状況を考慮して次の移動目標セルを選択する + */ +import { config } from "@server/config"; +import type { BotTarget } from "../types/BotTypes.js"; + +const UNPAINTED_TEAM_ID = -1; + +const clamp = (value: number, min: number, max: number): number => { + return Math.max(min, Math.min(max, value)); +}; + +const toGridIndex = (col: number, row: number, cols: number): number => { + return row * cols + col; +}; + +const getCellTeamId = ( + gridColors: number[], + col: number, + row: number, + cols: number, +): number => { + return gridColors[toGridIndex(col, row, cols)] ?? UNPAINTED_TEAM_ID; +}; + +/** 隣接候補から優先度付きで次の目標セルを選択する */ +export const chooseNextTarget = ( + col: number, + row: number, + gridColors: number[], +): BotTarget => { + const { UNPAINTED_PRIORITY_STRENGTH } = config.BOT_AI_CONFIG; + const { GRID_COLS, GRID_ROWS } = config.GAME_CONFIG; + const candidates = [ + { col: col + 1, row }, + { col: col - 1, row }, + { col, row: row + 1 }, + { col, row: row - 1 }, + ].filter((candidate) => { + return ( + candidate.col >= 0 && + candidate.col < GRID_COLS && + candidate.row >= 0 && + candidate.row < GRID_ROWS + ); + }); + + if (candidates.length === 0) { + return { col, row }; + } + + const unpaintedCandidates = candidates.filter((candidate) => { + return ( + getCellTeamId(gridColors, candidate.col, candidate.row, GRID_COLS) === + UNPAINTED_TEAM_ID + ); + }); + + if ( + unpaintedCandidates.length > 0 && + Math.random() < clamp(UNPAINTED_PRIORITY_STRENGTH, 0, 1) + ) { + return ( + unpaintedCandidates[ + Math.floor(Math.random() * unpaintedCandidates.length) + ] ?? { col, row } + ); + } + + return ( + candidates[Math.floor(Math.random() * candidates.length)] ?? { col, row } + ); +}; diff --git a/apps/server/src/domains/game/application/services/bot/roster/BotRosterService.ts b/apps/server/src/domains/game/application/services/bot/roster/BotRosterService.ts new file mode 100644 index 0000000..648717d --- /dev/null +++ b/apps/server/src/domains/game/application/services/bot/roster/BotRosterService.ts @@ -0,0 +1,91 @@ +/** + * BotRosterService + * チーム人数差を埋めるためのBotプレイヤーID補充ロジックを提供する + */ +import { config } from "@server/config"; + +const BOT_PLAYER_ID_PREFIX = "bot:"; +declare const botPlayerIdBrand: unique symbol; + +/** BotプレイヤーIDのブランド型 */ +export type BotPlayerId = string & { readonly [botPlayerIdBrand]: true }; + +/** BotプレイヤーIDを生成する */ +const createBotPlayerId = ( + roomId: string, + serialNumber: number, +): BotPlayerId => { + return `${BOT_PLAYER_ID_PREFIX}${roomId}:${serialNumber}` as BotPlayerId; +}; + +/** BotプレイヤーIDかどうかを判定する */ +export const isBotPlayerId = (playerId: string): playerId is BotPlayerId => { + return playerId.startsWith(BOT_PLAYER_ID_PREFIX); +}; + +const getMinimumTotalPlayers = (humanPlayerCount: number): number => { + const teamCount = config.GAME_CONFIG.TEAM_COUNT; + if (teamCount <= 0) { + return humanPlayerCount; + } + + const remainder = humanPlayerCount % teamCount; + if (remainder === 0) { + return humanPlayerCount; + } + + return humanPlayerCount + (teamCount - remainder); +}; + +const resolveTargetTotalPlayers = ( + humanPlayerCount: number, + requestedPlayerCount: number | undefined, +): number => { + const minimumTotal = getMinimumTotalPlayers(humanPlayerCount); + const teamCount = config.GAME_CONFIG.TEAM_COUNT; + const maxTotal = config.GAME_CONFIG.MAX_PLAYERS_PER_ROOM; + + if ( + requestedPlayerCount === undefined || + teamCount <= 0 || + requestedPlayerCount > maxTotal || + requestedPlayerCount < minimumTotal || + requestedPlayerCount % teamCount !== 0 + ) { + return minimumTotal; + } + + return requestedPlayerCount; +}; + +const getRequiredBotCount = ( + humanPlayerCount: number, + requestedPlayerCount: number | undefined, +): number => { + const totalPlayers = resolveTargetTotalPlayers( + humanPlayerCount, + requestedPlayerCount, + ); + return Math.max(0, totalPlayers - humanPlayerCount); +}; + +/** 人間プレイヤーIDへ必要数のBot IDを補充した配列を返す */ +export const createBalancedSessionPlayerIds = ( + roomId: string, + humanPlayerIds: string[], + requestedPlayerCount?: number, +): string[] => { + const requiredBotCount = getRequiredBotCount( + humanPlayerIds.length, + requestedPlayerCount, + ); + if (requiredBotCount === 0) { + return [...humanPlayerIds]; + } + + const botIds = Array.from({ length: requiredBotCount }, (_, index) => { + return createBotPlayerId(roomId, index + 1); + }); + + return [...humanPlayerIds, ...botIds]; +}; diff --git a/apps/server/src/domains/game/application/services/bot/state/BotStateStore.ts b/apps/server/src/domains/game/application/services/bot/state/BotStateStore.ts new file mode 100644 index 0000000..802f4cf --- /dev/null +++ b/apps/server/src/domains/game/application/services/bot/state/BotStateStore.ts @@ -0,0 +1,23 @@ +/** + * BotStateStore + * Botごとの行動状態を保持し,取得と更新を提供する + */ +import type { BotPlayerId } from "../roster/BotRosterService.js"; +import type { BotState } from "../types/BotTypes.js"; + +/** Bot状態の保持と更新を提供するストア */ +export class BotStateStore { + private states = new Map(); + + public getOrCreate(botPlayerId: BotPlayerId, initialState: BotState): BotState { + return this.states.get(botPlayerId) ?? initialState; + } + + public set(botPlayerId: BotPlayerId, state: BotState): void { + this.states.set(botPlayerId, state); + } + + public clear(): void { + this.states.clear(); + } +} diff --git a/apps/server/src/domains/game/application/services/bot/types/BotTypes.ts b/apps/server/src/domains/game/application/services/bot/types/BotTypes.ts new file mode 100644 index 0000000..fb3ebf3 --- /dev/null +++ b/apps/server/src/domains/game/application/services/bot/types/BotTypes.ts @@ -0,0 +1,26 @@ +/** + * BotTypes + * Bot行動決定で利用する型定義を提供する + */ +import type { PlaceBombPayload } from "@repo/shared"; + +/** Botの内部状態 */ +export type BotState = { + targetCol: number; + targetRow: number; + lastBombPlacedAtMs: number; + bombSeq: number; +}; + +/** 移動先のグリッド座標 */ +export type BotTarget = { + col: number; + row: number; +}; + +/** 1tick分のBot行動決定結果 */ +export type BotDecision = { + nextX: number; + nextY: number; + placeBombPayload: PlaceBombPayload | null; +}; diff --git a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index d386c8c..384badc 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -13,7 +13,7 @@ logResults, logScopes, } from "@server/logging/index"; -import { createBotBombActionHandler } from "../services/BotBombActionService"; +import { createBotBombActionHandler } from "../services/bot/adapters/BotBombActionAdapter.js"; const excludeRecipientFromPlayerUpdates = < TPlayerUpdate extends { id: string }, diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index 7497a75..2f55962 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -13,8 +13,8 @@ logResults, logScopes, } from "@server/logging/index"; -import { BotAiService } from "../application/services/BotAiService"; -import { isBotPlayerId } from "../application/services/BotRosterService"; +import { BotTurnOrchestrator } from "../application/services/bot/orchestrators/BotTurnOrchestrator.js"; +import { isBotPlayerId } from "../application/services/bot/roster/BotRosterService"; import { setPlayerPosition } from "../entities/player/playerMovement.js"; /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ @@ -27,7 +27,8 @@ private readonly maxCatchUpTicks: number = 3; private lastSentPlayers: Map = new Map(); - private botAiService: BotAiService = new BotAiService(); + private botTurnOrchestrator: BotTurnOrchestrator = + new BotTurnOrchestrator(); constructor( private roomId: string, @@ -127,7 +128,7 @@ ): void { this.players.forEach((player) => { if (isBotPlayerId(player.id)) { - const decision = this.botAiService.decide( + const decision = this.botTurnOrchestrator.decide( player.id, player, gridColorsSnapshot, @@ -200,7 +201,7 @@ if (!this.isRunning) return; this.isRunning = false; - this.botAiService.clear(); + this.botTurnOrchestrator.clear(); this.lastSentPlayers.clear(); if (this.loopId) {