botWebWars/backend/app/api/routes.py

621 lines
21 KiB
Python
Raw Normal View History

2026-09-05 21:20:39 +00:00
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from app.api.websocket import manager
from app.game import game_engine
2026-09-05 21:20:39 +00:00
from app.models import (
2026-09-05 23:20:54 +00:00
AiStepResponse,
2026-09-05 21:20:39 +00:00
ApiResponse,
AvailableMovesResponse,
BattleRequest,
BattleResult,
2026-09-05 21:20:39 +00:00
BoardState,
BotMemoryResponse,
BotRadarResponse,
2026-09-05 23:50:27 +00:00
GameConclusion,
2026-09-05 21:20:39 +00:00
MoveCheckResult,
MoveRequest,
MoveResponse,
Party,
PartyDefeatResult,
PartyDirectForm,
PartyInvite,
PartyInviteCreate,
PartyInviteResponse,
2026-09-05 21:20:39 +00:00
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"},
)
2026-09-05 23:50:27 +00:00
@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",
)
# ==========================================
2026-09-05 23:50:27 +00:00
# Player Endpoints
# ==========================================
@router.post(
"/players",
response_model=Player,
status_code=status.HTTP_201_CREATED,
2026-09-05 23:50:27 +00:00
summary="Register a new player/bot avatar on the 64x64 grid",
tags=["Players"],
)
async def register_player(player_in: PlayerCreate):
2026-09-05 23:50:27 +00:00
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],
2026-09-05 23:50:27 +00:00
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,
2026-09-05 23:50:27 +00:00
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,
2026-09-05 23:50:27 +00:00
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",
)
2026-09-05 21:20:39 +00:00
board_state = await game_engine.get_board_state()
await manager.broadcast({
2026-09-05 23:50:27 +00:00
"event": "player_removed",
"player_id": player_id,
2026-09-05 23:50:27 +00:00
"total_players": len(board_state.players),
2026-09-05 21:20:39 +00:00
"turn": board_state.turn.model_dump(),
})
return ApiResponse(
success=True,
2026-09-05 23:50:27 +00:00
message=f"Player '{player_id}' has been removed",
)
# ==========================================
2026-09-05 23:50:27 +00:00
# Bot Memory & Radar Awareness Endpoints
# ==========================================
@router.get(
"/players/{player_id}/memory",
response_model=BotMemoryResponse,
2026-09-05 23:50:27 +00:00
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,
2026-09-05 23:50:27 +00:00
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,
2026-09-05 23:50:27 +00:00
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",
)
# ==========================================
2026-09-05 23:50:27 +00:00
# Party & Battle Endpoints
# ==========================================
@router.get(
"/parties",
response_model=List[Party],
2026-09-05 23:50:27 +00:00
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(),
})
2026-09-05 23:50:27 +00:00
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"],
)
2026-09-05 23:50:27 +00:00
async def respond_to_invite(invite_id: str, response: PartyInviteResponse):
try:
2026-09-05 23:50:27 +00:00
party = await game_engine.respond_to_invite(invite_id, response.accept)
board_state = await game_engine.get_board_state()
if party:
await manager.broadcast({
2026-09-05 23:50:27 +00:00
"event": "party_formed",
"party": party.model_dump(),
"players": [p.model_dump() for p in board_state.players],
"turn": board_state.turn.model_dump(),
})
2026-09-05 23:50:27 +00:00
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(),
})
2026-09-05 23:50:27 +00:00
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(),
})
2026-09-05 23:50:27 +00:00
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))
2026-09-05 21:20:39 +00:00
# ==========================================
# 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",
2026-09-05 21:20:39 +00:00
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",
2026-09-05 21:20:39 +00:00
),
):
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",
2026-09-05 21:20:39 +00:00
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()
2026-09-05 21:20:39 +00:00
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],
2026-09-05 21:20:39 +00:00
"previous_position": result.previous_position,
"new_position": result.new_position,
"players": [p.model_dump() for p in board_state.players],
2026-09-05 21:20:39 +00:00
"turn": result.turn.model_dump(),
})
2026-09-05 23:20:54 +00:00
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(),
})
2026-09-05 23:50:27 +00:00
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(),
})
2026-09-05 21:20:39 +00:00
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,
2026-09-05 23:20:54 +00:00
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(),
})
2026-09-05 23:50:27 +00:00
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(),
})
2026-09-05 23:20:54 +00:00
else:
await manager.broadcast({
"event": "turn_passed",
"passed_by": player_id,
"turn": result.turn.model_dump(),
})
2026-09-05 23:50:27 +00:00
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(),
})
2026-09-05 23:20:54 +00:00
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,
2026-09-05 21:20:39 +00:00
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,
2026-09-05 23:50:27 +00:00
summary="Get current turn details, round number, active player, and turn order",
tags=["Movement"],
2026-09-05 21:20:39 +00:00
)
async def get_turn():
2026-09-05 23:50:27 +00:00
board_state = await game_engine.get_board_state()
return board_state.turn