diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 28b542b..a4062dd 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -618,3 +618,26 @@ async def pass_turn(player_id: str): async def get_turn(): board_state = await game_engine.get_board_state() return board_state.turn + + +@router.post( + "/game/start", + response_model=TurnInfo, + summary="Start the game, activating turns and permitting bot moves", + tags=["Game"], +) +async def start_game(): + try: + turn_info = await game_engine.start_game() + board_state = await game_engine.get_board_state() + await manager.broadcast({ + "event": "game_started", + "turn": turn_info.model_dump(), + "board": board_state.model_dump(), + }) + return turn_info + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) diff --git a/backend/app/game.py b/backend/app/game.py index cb8b63a..441a160 100644 --- a/backend/app/game.py +++ b/backend/app/game.py @@ -48,6 +48,7 @@ class GameEngine: self.current_turn_index: int = 0 self.round_number: int = 1 self.turn_number: int = 0 + self.game_started: bool = False self.config = BoardConfig( min_x=settings.GRID_MIN_X, @@ -250,6 +251,8 @@ class GameEngine: ) def _get_active_turn_actors(self) -> List[str]: + if not self.game_started: + return [] actors = [] for pid in self.turn_order: p = self.players.get(pid) @@ -268,9 +271,19 @@ class GameEngine: return self.players.get(player_id) def _get_turn_info(self) -> TurnInfo: + if not self.game_started: + return TurnInfo( + game_started=False, + current_player_id=None, + current_player_name=None, + round_number=0, + turn_number=0, + turn_order=[], + ) current = self._get_current_player() actors = self._get_active_turn_actors() return TurnInfo( + game_started=True, current_player_id=current.id if current else None, current_player_name=current.name if current else None, round_number=self.round_number, @@ -329,6 +342,10 @@ class GameEngine: async with self._lock: return list(self.players.values()) + # Alias remove_player to delete_player + async def remove_player(self, player_id: str) -> bool: + return await self.delete_player(player_id) + async def delete_player(self, player_id: str) -> bool: async with self._lock: if player_id not in self.players: @@ -370,6 +387,22 @@ class GameEngine: return True + async def start_game(self) -> TurnInfo: + async with self._lock: + if self.game_started: + return self._get_turn_info() + + if len(self.players) == 0: + raise ValueError("Cannot start game: no bots have joined yet. Spawn or register bots first.") + + self.game_started = True + # Build turn order from registered players + self.turn_order = list(self.players.keys()) + self.current_turn_index = 0 + self.round_number = 1 + self.turn_number = 1 + return self._get_turn_info() + async def reset(self) -> None: async with self._lock: self.players.clear() @@ -379,6 +412,7 @@ class GameEngine: self.current_turn_index = 0 self.round_number = 1 self.turn_number = 0 + self.game_started = False # Regenerate fresh procedural mountain ranges and valley trenches on reset self.obstacles = self._generate_terrain() @@ -394,6 +428,7 @@ class GameEngine: parties=parties_list, obstacles=obstacles_list, turn=self._get_turn_info(), + game_started=self.game_started, conclusion=self._check_game_concluded(), ) @@ -572,6 +607,8 @@ class GameEngine: self, member_ids: List[str], leader_id: str, name: Optional[str] = None ) -> Party: async with self._lock: + if not self.game_started: + raise ValueError("Game has not started yet. Waiting for Start Game button in UI.") if len(member_ids) < 2: raise ValueError("A party must have at least 2 members") if leader_id not in member_ids: @@ -857,7 +894,18 @@ class GameEngine: moves: Dict[str, MoveCheckResult] = {} for name, dx, dy in STANDARD_DIRECTIONS: - moves[name] = self._check_move_internal(player, dx, dy, name, occupied) + if not self.game_started: + moves[name] = MoveCheckResult( + direction=name, + dx=dx, + dy=dy, + target_x=player.x + dx, + target_y=player.y + dy, + available=False, + reason="Game has not started yet. Waiting for Start Game button in UI.", + ) + else: + moves[name] = self._check_move_internal(player, dx, dy, name, occupied) member_count = 1 if player.party_id and player.party_id in self.parties: @@ -1015,6 +1063,8 @@ class GameEngine: async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult: async with self._lock: + if not self.game_started: + raise ValueError("Game has not started yet. Waiting for Start Game button in UI.") p1 = self.players.get(challenger_id) p2 = self.players.get(defender_id) if not p1 or not p2: @@ -1203,6 +1253,8 @@ class GameEngine: self, player_id: str, dx: int, dy: int, direction_name: str ) -> MoveResponse: async with self._lock: + if not self.game_started: + raise ValueError("Game has not started yet. Waiting for Start Game button in UI.") player = self.players.get(player_id) if not player: raise KeyError(f"Player '{player_id}' not found") @@ -1274,6 +1326,8 @@ class GameEngine: async def pass_turn(self, player_id: str) -> TurnInfo: async with self._lock: + if not self.game_started: + raise ValueError("Game has not started yet. Waiting for Start Game button in UI.") player = self.players.get(player_id) if not player: raise KeyError(f"Player '{player_id}' not found") @@ -1291,6 +1345,8 @@ class GameEngine: async def step_bot_ai(self, player_id: str) -> AiStepResponse: async with self._lock: + if not self.game_started: + raise ValueError("Game has not started yet. Waiting for Start Game button in UI.") player = self.players.get(player_id) if not player: raise KeyError(f"Player '{player_id}' not found") diff --git a/backend/app/models.py b/backend/app/models.py index 347ab4d..bcfb34b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -298,6 +298,7 @@ class BoardConfig(BaseModel): class TurnInfo(BaseModel): + game_started: bool = False current_player_id: Optional[str] = None current_player_name: Optional[str] = None round_number: int = 1 @@ -326,6 +327,7 @@ class BoardState(BaseModel): parties: List[Party] = [] obstacles: List[Obstacle] = Field(default_factory=list) turn: TurnInfo + game_started: bool = False conclusion: Optional[GameConclusion] = None diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0e5393d..fd4d49d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -11,6 +11,59 @@ def test_health_check(): assert response.json()["success"] is True +def test_lobby_and_start_game_lifecycle(): + client = TestClient(app) + client.post("/api/reset") + + # 1. Before bots register: not started, no current player + board = client.get("/api/board").json() + assert board["turn"]["game_started"] is False + assert board["turn"]["current_player_id"] is None + + # 2. Bots register (Lobby phase) + b1 = client.post("/api/players", json={"name": "LobbyBot1", "color": "#111111", "strength": 2}).json() + b2 = client.post("/api/players", json={"name": "LobbyBot2", "color": "#222222", "strength": 3}).json() + + # Still not started, no one's turn + turn_res = client.get("/api/turn").json() + assert turn_res["game_started"] is False + assert turn_res["current_player_id"] is None + + # 3. Attempting to move or take turns before start is rejected + move_res = client.post(f"/api/players/{b1['id']}/move", json={"direction": "UP"}) + assert move_res.status_code == 400 + assert "Game has not started yet" in move_res.json()["detail"] + + step_res = client.post(f"/api/players/{b1['id']}/ai-step") + assert step_res.status_code == 400 + assert "Game has not started yet" in step_res.json()["detail"] + + # 4. Bots can depart freely during lobby + del_res = client.delete(f"/api/players/{b2['id']}") + assert del_res.status_code == 200 + + board = client.get("/api/board").json() + assert board["player_count"] == 1 + + # Add another bot back + b3 = client.post("/api/players", json={"name": "LobbyBot3", "color": "#333333", "strength": 4}).json() + + # 5. Start Game button pressed! + start_res = client.post("/api/game/start") + assert start_res.status_code == 200 + start_data = start_res.json() + assert start_data["game_started"] is True + assert start_data["current_player_id"] in [b1["id"], b3["id"]] + assert start_data["round_number"] == 1 + assert start_data["turn_number"] == 1 + + # 6. Now bots can take their turns + acting_id = start_data["current_player_id"] + pass_res = client.post(f"/api/players/{acting_id}/pass") + assert pass_res.status_code == 200 + assert pass_res.json()["turn_number"] == 2 + + def test_register_player_and_get_board(): client = TestClient(app) # Reset board first @@ -33,6 +86,8 @@ def test_register_player_and_get_board(): assert board_data["player_count"] == 1 assert board_data["players"][0]["name"] == "TestBot" assert len(board_data["obstacles"]) > 0 + assert board_data["turn"]["game_started"] is False + assert board_data["turn"]["current_player_id"] is None def test_bot_memory_and_radar_endpoints(): @@ -78,6 +133,8 @@ def test_bot_ai_step_forms_parties_and_prioritizes_stronger_leader(): p2.x, p2.y = 5, 6 asyncio.run(set_positions()) + client.post("/api/game/start") + # Step WeakBot turn step_res = client.post(f"/api/players/{b1['id']}/ai-step") assert step_res.status_code == 200 @@ -111,6 +168,8 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner(): b4.x, b4.y = 11, 11 asyncio.run(setup_combat_positions()) + client.post("/api/game/start") + # Form Party 1 (Strength 10) client.post("/api/parties", json={ "member_ids": [p1["id"], p2["id"]], @@ -125,7 +184,7 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner(): "name": "BetaSquad", }) - # Fight battle between AlphaLead and BetaLead + # Trigger explicit 3-bout D20 battle between AlphaLeader and BetaLeader battle_res = client.post("/api/battles/fight", json={ "challenger_id": p1["id"], "defender_id": p3["id"], @@ -164,6 +223,8 @@ def test_game_conclusion_and_scoreboard(): b4.score = 0 # 4th place asyncio.run(setup_positions()) + client.post("/api/game/start") + # Before joining all into 1 party, conclusion should be false conc_before = client.get("/api/game/conclusion").json() assert conc_before["concluded"] is False @@ -206,6 +267,8 @@ def test_solo_bot_refuses_weaker_leader_party_responds_with_battle(): b3.x, b3.y = 21, 20 asyncio.run(setup_positions()) + client.post("/api/game/start") + client.post("/api/parties", json={ "member_ids": [p1["id"], p2["id"]], "leader_id": p1["id"], @@ -224,7 +287,7 @@ def test_solo_bot_refuses_weaker_leader_party_responds_with_battle(): def test_map_obstacles_mountains_valleys_and_connectivity(): """Verify obstacle constraints: - 1. Obstacles include both mountains and valleys. + 1. Obstacles include both mountains and forests. 2. Obstacles cover <= 50% of total map tiles. 3. All passable areas form a SINGLE connected component (no isolated bodies). 4. Spawned bots never spawn on an obstacle. diff --git a/botagent/bot_agent.py b/botagent/bot_agent.py index 60add15..a7561a3 100644 --- a/botagent/bot_agent.py +++ b/botagent/bot_agent.py @@ -230,6 +230,11 @@ class SmartBotAgent: try: while True: turn_info = requests.get(f"{BASE_URL}/turn").json() + if not turn_info.get("game_started", False): + print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI...", end=" ") + time.sleep(0.5) + continue + curr_player_id = turn_info.get("current_player_id") if curr_player_id == self.bot_id: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 311cf12..25d4177 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,6 +40,7 @@ export function App() { movePlayer, passTurn, stepActiveBotTurn, + startGame, resetBoard, } = useGameSocket(); @@ -80,6 +81,17 @@ export function App() { } }; + const handleStartGame = async () => { + try { + const turnInfo = await startGame(); + showNotification("🚀 Game started! First turn: " + (turnInfo.current_player_name || "Active Bot")); + } catch (err: unknown) { + if (err instanceof Error) { + showNotification("Failed to start game: " + err.message); + } + } + }; + const handleReset = async () => { try { await resetBoard(); @@ -125,6 +137,8 @@ export function App() { {/* Top Navigation Bar */}
setIsRegisterOpen(true)} onQuickSpawn={handleQuickSpawn} onResetBoard={handleReset} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index b8998e3..24ddc71 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -2,6 +2,8 @@ import React from 'react'; interface HeaderProps { isConnected: boolean; + gameStarted: boolean; + onStartGame: () => void; onOpenRegister: () => void; onQuickSpawn: () => void; onResetBoard: () => void; @@ -12,6 +14,8 @@ interface HeaderProps { export const Header: React.FC = ({ isConnected, + gameStarted, + onStartGame, onOpenRegister, onQuickSpawn, onResetBoard, @@ -52,6 +56,23 @@ export const Header: React.FC = ({ + {/* Game Phase Badge */} + {!isConcluded && ( +
+ {gameStarted ? ( + + + GAME IN PROGRESS + + ) : ( + + + LOBBY (WAITING TO START) + + )} +
+ )} + {/* Game concluded banner tag */} {isConcluded && (
@@ -62,6 +83,22 @@ export const Header: React.FC = ({ {/* Action Buttons */}
+ {/* Start Game Button (Visible during Lobby) */} + {!gameStarted && !isConcluded && ( + + )} + {isConcluded && onOpenScoreboard && (
- WASD / Arrows / Numpad to move + {isStarted ? 'WASD / Arrows / Numpad to move' : 'Bots can join & depart in Lobby'}
); diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts index 1209449..cd192b2 100644 --- a/frontend/src/hooks/useGameSocket.ts +++ b/frontend/src/hooks/useGameSocket.ts @@ -26,12 +26,14 @@ const INITIAL_BOARD: BoardState = { parties: [], obstacles: [], turn: { + game_started: false, current_player_id: null, current_player_name: null, round_number: 1, turn_number: 0, turn_order: [], }, + game_started: false, conclusion: null, }; @@ -191,6 +193,17 @@ export function useGameSocket() { setLastEventMessage( `🏆 VICTORY! Game concluded! All bots united under "${conc.winning_party_name}" led by ${conc.winning_leader_name}!` ); + } else if (data.event === 'game_started') { + if (data.board) { + setBoardState(data.board); + } else { + setBoardState((prev) => ({ + ...prev, + turn: data.turn ?? { ...prev.turn, game_started: true }, + game_started: true, + })); + } + setLastEventMessage('🚀 Game Started! Turns are now active.'); } else if (data.event === 'turn_passed') { setBoardState((prev) => ({ ...prev, @@ -367,10 +380,24 @@ export function useGameSocket() { setShowScoreboard(false); }; + const startGame = async (): Promise => { + const res = await fetch('/api/game/start', { + method: 'POST', + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || 'Failed to start game'); + } + const data: TurnInfo = await res.json(); + return data; + }; + // Step active bot turn according to its explicit autonomous goal: // - Bot without party: seeks other bots to form a party (stronger bot insists on being leader) // - Party leader: seeks other parties to find and defeat all other parties const stepActiveBotTurn = useCallback(async () => { + // Cannot step if game hasn't started yet + if (!boardState.turn.game_started) return; // If a battle modal is currently open, pause turn stepping until battle modal acknowledges/closes if (activeBattleRef.current) return; @@ -441,6 +468,7 @@ export function useGameSocket() { movePlayer, passTurn, stepActiveBotTurn, + startGame, resetBoard, }; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d999d61..2dc0b00 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -38,6 +38,7 @@ export interface Party { } export interface TurnInfo { + game_started?: boolean; current_player_id: string | null; current_player_name: string | null; round_number: number; @@ -62,6 +63,7 @@ export interface BoardState { parties: Party[]; obstacles: Obstacle[]; turn: TurnInfo; + game_started?: boolean; conclusion?: GameConclusion | null; }