from typing import List, Optional from fastapi import APIRouter, HTTPException, Query, status from app.api.websocket import manager from app.game import game_engine from app.models import ( AiStepResponse, ApiResponse, AvailableMovesResponse, BattleRequest, BattleResult, BoardState, BotMemoryResponse, BotRadarResponse, GameConclusion, MoveCheckResult, MoveRequest, MoveResponse, Party, PartyDefeatResult, PartyDirectForm, PartyInvite, PartyInviteCreate, PartyInviteResponse, Player, PlayerCreate, TurnInfo, ) router = APIRouter() @router.get("/health", response_model=ApiResponse, tags=["System"]) async def health_check(): return ApiResponse( success=True, message="botWebWars backend service is healthy", data={"service": "FastAPI", "status": "running"}, ) @router.get( "/game/conclusion", response_model=GameConclusion, summary="Get the game conclusion state and final rankings when 1 party remains with all bots", tags=["System"], ) async def get_game_conclusion(): return await game_engine.get_game_conclusion() @router.get("/board", response_model=BoardState, tags=["Board"]) async def get_board(): return await game_engine.get_board_state() @router.post( "/reset", response_model=ApiResponse, summary="Reset game board, removing all players, parties, and turn history", tags=["Board"], ) async def reset_board(): await game_engine.reset() board_state = await game_engine.get_board_state() await manager.broadcast({ "event": "board_reset", "board": board_state.model_dump(), }) return ApiResponse( success=True, message="Game board has been successfully reset", ) # ========================================== # Player Endpoints # ========================================== @router.post( "/players", response_model=Player, status_code=status.HTTP_201_CREATED, summary="Register a new player/bot avatar on the 64x64 grid", tags=["Players"], ) async def register_player(player_in: PlayerCreate): player = await game_engine.register_player(player_in) board_state = await game_engine.get_board_state() await manager.broadcast({ "event": "player_registered", "player": player.model_dump(), "total_players": len(board_state.players), "turn": board_state.turn.model_dump(), }) return player @router.get( "/players", response_model=List[Player], summary="List all registered players/bots on the board", tags=["Players"], ) async def list_players(): return await game_engine.get_all_players() @router.get( "/players/{player_id}", response_model=Player, summary="Get details of a specific player/bot", tags=["Players"], ) async def get_player(player_id: str): player = await game_engine.get_player(player_id) if not player: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Player '{player_id}' not found", ) return player @router.delete( "/players/{player_id}", response_model=ApiResponse, summary="Remove a player/bot from the board", tags=["Players"], ) async def remove_player(player_id: str): removed = await game_engine.remove_player(player_id) if not removed: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Player '{player_id}' not found", ) board_state = await game_engine.get_board_state() await manager.broadcast({ "event": "player_removed", "player_id": player_id, "total_players": len(board_state.players), "turn": board_state.turn.model_dump(), }) return ApiResponse( success=True, message=f"Player '{player_id}' has been removed", ) # ========================================== # Bot Memory & Radar Awareness Endpoints # ========================================== @router.get( "/players/{player_id}/memory", response_model=BotMemoryResponse, summary="Get bot memory: coordinates visited and whether a specific location has been visited before", tags=["Bot AI & Awareness"], ) async def get_bot_memory( player_id: str, check_x: Optional[int] = Query(None, description="Optional X coordinate to check"), check_y: Optional[int] = Query(None, description="Optional Y coordinate to check"), ): try: return await game_engine.get_bot_memory(player_id, check_x, check_y) except KeyError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Player '{player_id}' not found", ) @router.get( "/players/{player_id}/radar", response_model=BotRadarResponse, summary="Scan radar for other bots/parties, calculating distance, ally/enemy status, recruit/battle actions", tags=["Bot AI & Awareness"], ) async def get_bot_radar(player_id: str): try: return await game_engine.get_bot_radar(player_id) except KeyError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Player '{player_id}' not found", ) # ========================================== # Party & Battle Endpoints # ========================================== @router.get( "/parties", response_model=List[Party], summary="List all active parties and their members", tags=["Parties"], ) async def list_parties(): return await game_engine.get_all_parties() @router.get( "/parties/{party_id}", response_model=Party, summary="Get details of a specific party", tags=["Parties"], ) async def get_party(party_id: str): party = await game_engine.get_party(party_id) if not party: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Party '{party_id}' not found", ) return party @router.post( "/parties", response_model=Party, status_code=status.HTTP_201_CREATED, summary="Directly form a party with adjacent linked members and an agreed leader", tags=["Parties"], ) async def form_party(form_req: PartyDirectForm): try: party = await game_engine.form_party( member_ids=form_req.member_ids, leader_id=form_req.leader_id, name=form_req.name, ) board_state = await game_engine.get_board_state() await manager.broadcast({ "event": "party_formed", "party": party.model_dump(), "players": [p.model_dump() for p in board_state.players], "turn": board_state.turn.model_dump(), }) if board_state.conclusion and board_state.conclusion.concluded: await manager.broadcast({ "event": "game_concluded", "conclusion": board_state.conclusion.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) return party except KeyError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.post( "/parties/invite", response_model=PartyInvite, summary="Invite an adjacent bot to join/form a party with an agreed leader", tags=["Parties"], ) async def invite_to_party(invite_req: PartyInviteCreate): try: invite = await game_engine.invite_to_party( inviter_id=invite_req.inviter_id, invitee_id=invite_req.invitee_id, proposed_leader_id=invite_req.proposed_leader_id, party_name=invite_req.party_name, ) return invite except KeyError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.post( "/parties/invites/{invite_id}/respond", response_model=Optional[Party], summary="Accept or reject a party invitation", tags=["Parties"], ) async def respond_to_invite(invite_id: str, response: PartyInviteResponse): try: party = await game_engine.respond_to_invite(invite_id, response.accept) board_state = await game_engine.get_board_state() if party: await manager.broadcast({ "event": "party_formed", "party": party.model_dump(), "players": [p.model_dump() for p in board_state.players], "turn": board_state.turn.model_dump(), }) if board_state.conclusion and board_state.conclusion.concluded: await manager.broadcast({ "event": "game_concluded", "conclusion": board_state.conclusion.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) return party except KeyError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.post( "/parties/{party_id}/defeat", response_model=PartyDefeatResult, summary="Trigger defeat for a party: leader killed, respawns, score -1, remainder assigns new leader", tags=["Parties"], ) async def defeat_party(party_id: str): try: result = await game_engine.defeat_party(party_id) board_state = await game_engine.get_board_state() await manager.broadcast({ "event": "party_defeated", "defeat_details": result.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) if board_state.conclusion and board_state.conclusion.concluded: await manager.broadcast({ "event": "game_concluded", "conclusion": board_state.conclusion.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) return result except KeyError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.post( "/battles/fight", response_model=BattleResult, summary="Initiate a 3-bout D20 battle between two adjacent parties/bots", tags=["Battles"], ) async def fight_battle(battle_req: BattleRequest): try: result = await game_engine.battle(battle_req.challenger_id, battle_req.defender_id) board_state = await game_engine.get_board_state() await manager.broadcast({ "event": "battle_resolved", "battle": result.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) if board_state.conclusion and board_state.conclusion.concluded: await manager.broadcast({ "event": "game_concluded", "conclusion": board_state.conclusion.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) return result except KeyError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) # ========================================== # Movement & Turn Endpoints # ========================================== @router.get( "/players/{player_id}/available-moves", response_model=AvailableMovesResponse, summary="Check availability of all 8 movement directions for a bot or their party", tags=["Movement"], ) async def get_available_moves(player_id: str): try: return await game_engine.get_available_moves(player_id) except KeyError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Player '{player_id}' not found", ) @router.get( "/players/{player_id}/check-move", response_model=MoveCheckResult, summary="Check if a specific direction is available for movement", tags=["Movement"], ) async def check_single_move( player_id: str, direction: str = Query( ..., description="Direction to check: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT", ), ): try: return await game_engine.check_single_move(player_id, direction) except KeyError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Player '{player_id}' not found", ) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) @router.post( "/players/{player_id}/move", response_model=MoveResponse, summary="Move player (or entire party if leader) in a cardinal or diagonal direction during turn", tags=["Movement"], ) async def move_player(player_id: str, move_req: MoveRequest): try: dx, dy, dir_name = move_req.get_delta() result = await game_engine.move_player(player_id, dx, dy, dir_name) board_state = await game_engine.get_board_state() await manager.broadcast({ "event": "player_moved", "player": result.player.model_dump(), "direction": result.direction, "party_moved": result.party_moved, "affected_players": [p.model_dump() for p in result.affected_players], "previous_position": result.previous_position, "new_position": result.new_position, "players": [p.model_dump() for p in board_state.players], "turn": result.turn.model_dump(), }) if result.party_formed_triggered and result.formed_party: await manager.broadcast({ "event": "party_formed", "party": result.formed_party.model_dump(), "players": [p.model_dump() for p in board_state.players], "turn": board_state.turn.model_dump(), }) if result.battle_triggered and result.battle_result: await manager.broadcast({ "event": "battle_resolved", "battle": result.battle_result.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) if result.game_concluded or (board_state.conclusion and board_state.conclusion.concluded): conclusion_obj = result.game_concluded or board_state.conclusion await manager.broadcast({ "event": "game_concluded", "conclusion": conclusion_obj.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) return result except KeyError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Player '{player_id}' not found", ) except PermissionError as e: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=str(e), ) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) @router.post( "/players/{player_id}/ai-step", response_model=AiStepResponse, summary="Autonomous AI turn: unpartied bots seek & form parties (stronger bot is leader); parties hunt & defeat all other parties", tags=["Movement"], ) async def step_bot_ai(player_id: str): try: result = await game_engine.step_bot_ai(player_id) board_state = await game_engine.get_board_state() if result.formed_party: await manager.broadcast({ "event": "party_formed", "party": result.formed_party.model_dump(), "players": [p.model_dump() for p in board_state.players], "turn": board_state.turn.model_dump(), }) elif result.battle_result: await manager.broadcast({ "event": "battle_resolved", "battle": result.battle_result.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) elif result.move_result: await manager.broadcast({ "event": "player_moved", "player": result.move_result.player.model_dump(), "direction": result.move_result.direction, "party_moved": result.move_result.party_moved, "affected_players": [p.model_dump() for p in result.move_result.affected_players], "previous_position": result.move_result.previous_position, "new_position": result.move_result.new_position, "players": [p.model_dump() for p in board_state.players], "turn": result.move_result.turn.model_dump(), }) if result.move_result.party_formed_triggered and result.move_result.formed_party: await manager.broadcast({ "event": "party_formed", "party": result.move_result.formed_party.model_dump(), "players": [p.model_dump() for p in board_state.players], "turn": board_state.turn.model_dump(), }) if result.move_result.battle_triggered and result.move_result.battle_result: await manager.broadcast({ "event": "battle_resolved", "battle": result.move_result.battle_result.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) else: await manager.broadcast({ "event": "turn_passed", "passed_by": player_id, "turn": result.turn.model_dump(), }) if result.game_concluded or (board_state.conclusion and board_state.conclusion.concluded): conclusion_obj = result.game_concluded or board_state.conclusion await manager.broadcast({ "event": "game_concluded", "conclusion": conclusion_obj.model_dump(), "players": [p.model_dump() for p in board_state.players], "parties": [p.model_dump() for p in board_state.parties], "turn": board_state.turn.model_dump(), }) return result except KeyError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Player '{player_id}' not found", ) except PermissionError as e: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=str(e), ) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) @router.post( "/players/{player_id}/pass", response_model=TurnInfo, summary="Pass the current turn to the next player", tags=["Movement"], ) async def pass_turn(player_id: str): try: next_turn = await game_engine.pass_turn(player_id) await manager.broadcast({ "event": "turn_passed", "passed_by": player_id, "turn": next_turn.model_dump(), }) return next_turn except KeyError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Player '{player_id}' not found", ) except PermissionError as e: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=str(e), ) @router.get( "/turn", response_model=TurnInfo, summary="Get current turn details, round number, active player, and turn order", tags=["Movement"], ) 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), )