2026-09-05 21:20:39 +00:00
|
|
|
from typing import List, Optional
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, status
|
2026-09-05 21:10:18 +00:00
|
|
|
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,
|
2026-09-05 21:55:07 +00:00
|
|
|
BattleRequest,
|
|
|
|
|
BattleResult,
|
2026-09-05 21:20:39 +00:00
|
|
|
BoardState,
|
2026-09-05 22:09:18 +00:00
|
|
|
BotMemoryResponse,
|
|
|
|
|
BotRadarResponse,
|
2026-09-05 23:50:27 +00:00
|
|
|
GameConclusion,
|
2026-09-05 21:20:39 +00:00
|
|
|
MoveCheckResult,
|
|
|
|
|
MoveRequest,
|
|
|
|
|
MoveResponse,
|
2026-09-05 21:55:07 +00:00
|
|
|
Party,
|
|
|
|
|
PartyDefeatResult,
|
|
|
|
|
PartyDirectForm,
|
|
|
|
|
PartyInvite,
|
|
|
|
|
PartyInviteCreate,
|
|
|
|
|
PartyInviteResponse,
|
2026-09-05 21:20:39 +00:00
|
|
|
Player,
|
|
|
|
|
PlayerCreate,
|
|
|
|
|
TurnInfo,
|
2026-09-09 22:11:54 +00:00
|
|
|
WizardChallengeRequest,
|
|
|
|
|
WizardChallengeResult,
|
|
|
|
|
WizardNPC,
|
2026-09-05 21:20:39 +00:00
|
|
|
)
|
2026-09-05 21:10:18 +00:00
|
|
|
|
|
|
|
|
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 21:55:07 +00:00
|
|
|
# ==========================================
|
2026-09-05 23:50:27 +00:00
|
|
|
# Player Endpoints
|
2026-09-05 21:55:07 +00:00
|
|
|
# ==========================================
|
|
|
|
|
|
2026-09-05 21:10:18 +00:00
|
|
|
@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",
|
2026-09-05 21:10:18 +00:00
|
|
|
tags=["Players"],
|
|
|
|
|
)
|
|
|
|
|
async def register_player(player_in: PlayerCreate):
|
2026-09-06 13:29:31 +00:00
|
|
|
try:
|
|
|
|
|
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
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(e),
|
|
|
|
|
)
|
2026-09-05 21:10:18 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/players",
|
|
|
|
|
response_model=List[Player],
|
2026-09-05 23:50:27 +00:00
|
|
|
summary="List all registered players/bots on the board",
|
2026-09-05 21:10:18 +00:00
|
|
|
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",
|
2026-09-05 21:10:18 +00:00
|
|
|
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",
|
2026-09-05 21:10:18 +00:00
|
|
|
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()
|
2026-09-05 21:10:18 +00:00
|
|
|
await manager.broadcast({
|
2026-09-05 23:50:27 +00:00
|
|
|
"event": "player_removed",
|
2026-09-05 21:10:18 +00:00
|
|
|
"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(),
|
2026-09-05 21:10:18 +00:00
|
|
|
})
|
|
|
|
|
return ApiResponse(
|
|
|
|
|
success=True,
|
2026-09-05 23:50:27 +00:00
|
|
|
message=f"Player '{player_id}' has been removed",
|
2026-09-05 21:10:18 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 22:09:18 +00:00
|
|
|
# ==========================================
|
2026-09-05 23:50:27 +00:00
|
|
|
# Bot Memory & Radar Awareness Endpoints
|
2026-09-05 22:09:18 +00:00
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
@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"],
|
2026-09-05 22:09:18 +00:00
|
|
|
)
|
|
|
|
|
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"),
|
2026-09-05 22:09:18 +00:00
|
|
|
):
|
|
|
|
|
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"],
|
2026-09-05 22:09:18 +00:00
|
|
|
)
|
|
|
|
|
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 21:55:07 +00:00
|
|
|
# ==========================================
|
2026-09-05 23:50:27 +00:00
|
|
|
# Party & Battle Endpoints
|
2026-09-05 21:55:07 +00:00
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/parties",
|
|
|
|
|
response_model=List[Party],
|
2026-09-05 23:50:27 +00:00
|
|
|
summary="List all active parties and their members",
|
2026-09-05 21:55:07 +00:00
|
|
|
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(),
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-05 21:55:07 +00:00
|
|
|
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):
|
2026-09-05 21:55:07 +00:00
|
|
|
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()
|
|
|
|
|
|
2026-09-05 21:55:07 +00:00
|
|
|
if party:
|
|
|
|
|
await manager.broadcast({
|
2026-09-05 23:50:27 +00:00
|
|
|
"event": "party_formed",
|
2026-09-05 21:55:07 +00:00
|
|
|
"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(),
|
|
|
|
|
})
|
2026-09-05 21:55:07 +00:00
|
|
|
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(),
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-05 21:55:07 +00:00
|
|
|
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,
|
2026-09-05 22:09:18 +00:00
|
|
|
summary="Initiate a 3-bout D20 battle between two adjacent parties/bots",
|
2026-09-05 21:55:07 +00:00
|
|
|
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(),
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-05 21:55:07 +00:00
|
|
|
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-09 22:11:54 +00:00
|
|
|
# ==========================================
|
|
|
|
|
# Wizard NPC Endpoints
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/wizard",
|
|
|
|
|
response_model=WizardNPC,
|
|
|
|
|
summary="Get current position and details of the wandering Wizard NPC",
|
|
|
|
|
tags=["Wizard NPC"],
|
|
|
|
|
)
|
|
|
|
|
async def get_wizard():
|
|
|
|
|
return game_engine.wizard
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/wizard/challenge",
|
|
|
|
|
response_model=WizardChallengeResult,
|
2026-09-10 00:32:22 +00:00
|
|
|
summary="Challenge the Wizard NPC to a 3-bout D20 duel (awards choice of +2 score, +2 strength, or +2 health on win; -2 health/points on loss)",
|
2026-09-09 22:11:54 +00:00
|
|
|
tags=["Wizard NPC"],
|
|
|
|
|
)
|
|
|
|
|
async def challenge_wizard(challenge_req: WizardChallengeRequest):
|
|
|
|
|
try:
|
2026-09-10 00:32:22 +00:00
|
|
|
result = await game_engine.challenge_wizard(
|
|
|
|
|
challenge_req.player_id,
|
|
|
|
|
reward_choice=challenge_req.reward_choice or "score",
|
|
|
|
|
)
|
2026-09-09 22:11:54 +00:00
|
|
|
board_state = await game_engine.get_board_state()
|
|
|
|
|
|
|
|
|
|
await manager.broadcast({
|
|
|
|
|
"event": "wizard_challenge_resolved",
|
|
|
|
|
"challenge_result": result.model_dump(),
|
|
|
|
|
"wizard": board_state.wizard.model_dump() if board_state.wizard else None,
|
|
|
|
|
"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 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))
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
# ==========================================
|
|
|
|
|
# Movement & Turn Endpoints
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/players/{player_id}/available-moves",
|
|
|
|
|
response_model=AvailableMovesResponse,
|
2026-09-05 21:55:07 +00:00
|
|
|
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(
|
|
|
|
|
...,
|
2026-09-05 21:55:07 +00:00
|
|
|
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,
|
2026-09-05 21:55:07 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-09-05 21:55:07 +00:00
|
|
|
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,
|
2026-09-05 21:55:07 +00:00
|
|
|
"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,
|
2026-09-05 21:55:07 +00:00
|
|
|
"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(),
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-05 22:09:18 +00:00
|
|
|
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(),
|
|
|
|
|
})
|
2026-09-09 22:11:54 +00:00
|
|
|
elif result.wizard_challenge_result:
|
|
|
|
|
await manager.broadcast({
|
|
|
|
|
"event": "wizard_challenge_resolved",
|
|
|
|
|
"challenge_result": result.wizard_challenge_result.model_dump(),
|
|
|
|
|
"wizard": board_state.wizard.model_dump() if board_state.wizard else None,
|
|
|
|
|
"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
|
|
|
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),
|
|
|
|
|
)
|
2026-09-10 00:32:22 +00:00
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail=str(e),
|
|
|
|
|
)
|
2026-09-05 21:20:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
2026-09-06 13:24:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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),
|
|
|
|
|
)
|