scoring, game win, noise
This commit is contained in:
parent
df75f43214
commit
d2f2eed3f7
|
|
@ -11,6 +11,7 @@ from app.models import (
|
||||||
BoardState,
|
BoardState,
|
||||||
BotMemoryResponse,
|
BotMemoryResponse,
|
||||||
BotRadarResponse,
|
BotRadarResponse,
|
||||||
|
GameConclusion,
|
||||||
MoveCheckResult,
|
MoveCheckResult,
|
||||||
MoveRequest,
|
MoveRequest,
|
||||||
MoveResponse,
|
MoveResponse,
|
||||||
|
|
@ -37,41 +38,67 @@ async def health_check():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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 Registration & Management
|
# Player Endpoints
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/players",
|
"/players",
|
||||||
response_model=Player,
|
response_model=Player,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Register a new bot/player with an avatar name and color",
|
summary="Register a new player/bot avatar on the 64x64 grid",
|
||||||
tags=["Players"],
|
tags=["Players"],
|
||||||
)
|
)
|
||||||
async def register_player(player_in: PlayerCreate):
|
async def register_player(player_in: PlayerCreate):
|
||||||
try:
|
player = await game_engine.register_player(player_in)
|
||||||
player = await game_engine.register_player(player_in)
|
board_state = await game_engine.get_board_state()
|
||||||
board_state = await game_engine.get_board_state()
|
await manager.broadcast({
|
||||||
|
"event": "player_registered",
|
||||||
await manager.broadcast({
|
"player": player.model_dump(),
|
||||||
"event": "player_joined",
|
"total_players": len(board_state.players),
|
||||||
"player": player.model_dump(),
|
"turn": board_state.turn.model_dump(),
|
||||||
"player_count": board_state.player_count,
|
})
|
||||||
"turn": board_state.turn.model_dump(),
|
return player
|
||||||
})
|
|
||||||
|
|
||||||
return player
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/players",
|
"/players",
|
||||||
response_model=List[Player],
|
response_model=List[Player],
|
||||||
summary="List all registered players and their scores/status",
|
summary="List all registered players/bots on the board",
|
||||||
tags=["Players"],
|
tags=["Players"],
|
||||||
)
|
)
|
||||||
async def list_players():
|
async def list_players():
|
||||||
|
|
@ -81,7 +108,7 @@ async def list_players():
|
||||||
@router.get(
|
@router.get(
|
||||||
"/players/{player_id}",
|
"/players/{player_id}",
|
||||||
response_model=Player,
|
response_model=Player,
|
||||||
summary="Get details of a specific player by ID",
|
summary="Get details of a specific player/bot",
|
||||||
tags=["Players"],
|
tags=["Players"],
|
||||||
)
|
)
|
||||||
async def get_player(player_id: str):
|
async def get_player(player_id: str):
|
||||||
|
|
@ -97,7 +124,7 @@ async def get_player(player_id: str):
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/players/{player_id}",
|
"/players/{player_id}",
|
||||||
response_model=ApiResponse,
|
response_model=ApiResponse,
|
||||||
summary="Remove a player from the game board",
|
summary="Remove a player/bot from the board",
|
||||||
tags=["Players"],
|
tags=["Players"],
|
||||||
)
|
)
|
||||||
async def remove_player(player_id: str):
|
async def remove_player(player_id: str):
|
||||||
|
|
@ -107,36 +134,33 @@ async def remove_player(player_id: str):
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Player '{player_id}' not found",
|
detail=f"Player '{player_id}' not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
board_state = await game_engine.get_board_state()
|
board_state = await game_engine.get_board_state()
|
||||||
await manager.broadcast({
|
await manager.broadcast({
|
||||||
"event": "player_left",
|
"event": "player_removed",
|
||||||
"player_id": player_id,
|
"player_id": player_id,
|
||||||
"player_count": board_state.player_count,
|
"total_players": len(board_state.players),
|
||||||
"parties": [p.model_dump() for p in board_state.parties],
|
|
||||||
"turn": board_state.turn.model_dump(),
|
"turn": board_state.turn.model_dump(),
|
||||||
})
|
})
|
||||||
|
|
||||||
return ApiResponse(
|
return ApiResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Player '{player_id}' removed from board",
|
message=f"Player '{player_id}' has been removed",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Bot Memory & Radar Endpoints
|
# Bot Memory & Radar Awareness Endpoints
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/players/{player_id}/memory",
|
"/players/{player_id}/memory",
|
||||||
response_model=BotMemoryResponse,
|
response_model=BotMemoryResponse,
|
||||||
summary="Get bot's location memory history and check if location was visited before",
|
summary="Get bot memory: coordinates visited and whether a specific location has been visited before",
|
||||||
tags=["Intelligence"],
|
tags=["Bot AI & Awareness"],
|
||||||
)
|
)
|
||||||
async def get_bot_memory(
|
async def get_bot_memory(
|
||||||
player_id: str,
|
player_id: str,
|
||||||
check_x: Optional[int] = Query(None, description="Optional X coordinate to check if bot has visited before"),
|
check_x: Optional[int] = Query(None, description="Optional X coordinate to check"),
|
||||||
check_y: Optional[int] = Query(None, description="Optional Y coordinate to check if bot has visited before"),
|
check_y: Optional[int] = Query(None, description="Optional Y coordinate to check"),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
return await game_engine.get_bot_memory(player_id, check_x, check_y)
|
return await game_engine.get_bot_memory(player_id, check_x, check_y)
|
||||||
|
|
@ -150,8 +174,8 @@ async def get_bot_memory(
|
||||||
@router.get(
|
@router.get(
|
||||||
"/players/{player_id}/radar",
|
"/players/{player_id}/radar",
|
||||||
response_model=BotRadarResponse,
|
response_model=BotRadarResponse,
|
||||||
summary="Scan surroundings for nearby bots, determine allies/enemies, and recommend action/direction",
|
summary="Scan radar for other bots/parties, calculating distance, ally/enemy status, recruit/battle actions",
|
||||||
tags=["Intelligence"],
|
tags=["Bot AI & Awareness"],
|
||||||
)
|
)
|
||||||
async def get_bot_radar(player_id: str):
|
async def get_bot_radar(player_id: str):
|
||||||
try:
|
try:
|
||||||
|
|
@ -164,13 +188,13 @@ async def get_bot_radar(player_id: str):
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Party Management & 3-Bout Battles
|
# Party & Battle Endpoints
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/parties",
|
"/parties",
|
||||||
response_model=List[Party],
|
response_model=List[Party],
|
||||||
summary="List all active parties",
|
summary="List all active parties and their members",
|
||||||
tags=["Parties"],
|
tags=["Parties"],
|
||||||
)
|
)
|
||||||
async def list_parties():
|
async def list_parties():
|
||||||
|
|
@ -216,6 +240,15 @@ async def form_party(form_req: PartyDirectForm):
|
||||||
"turn": board_state.turn.model_dump(),
|
"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
|
return party
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
|
@ -250,17 +283,26 @@ async def invite_to_party(invite_req: PartyInviteCreate):
|
||||||
summary="Accept or reject a party invitation",
|
summary="Accept or reject a party invitation",
|
||||||
tags=["Parties"],
|
tags=["Parties"],
|
||||||
)
|
)
|
||||||
async def respond_to_invite(invite_id: str, resp: PartyInviteResponse):
|
async def respond_to_invite(invite_id: str, response: PartyInviteResponse):
|
||||||
try:
|
try:
|
||||||
party = await game_engine.respond_to_invite(invite_id, resp.accept)
|
party = await game_engine.respond_to_invite(invite_id, response.accept)
|
||||||
|
board_state = await game_engine.get_board_state()
|
||||||
|
|
||||||
if party:
|
if party:
|
||||||
board_state = await game_engine.get_board_state()
|
|
||||||
await manager.broadcast({
|
await manager.broadcast({
|
||||||
"event": "party_updated",
|
"event": "party_formed",
|
||||||
"party": party.model_dump(),
|
"party": party.model_dump(),
|
||||||
"players": [p.model_dump() for p in board_state.players],
|
"players": [p.model_dump() for p in board_state.players],
|
||||||
"turn": board_state.turn.model_dump(),
|
"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
|
return party
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
|
@ -287,6 +329,15 @@ async def defeat_party(party_id: str):
|
||||||
"turn": board_state.turn.model_dump(),
|
"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
|
return result
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
|
@ -311,6 +362,15 @@ async def fight_battle(battle_req: BattleRequest):
|
||||||
"turn": board_state.turn.model_dump(),
|
"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
|
return result
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
|
@ -406,6 +466,16 @@ async def move_player(player_id: str, move_req: MoveRequest):
|
||||||
"turn": board_state.turn.model_dump(),
|
"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
|
return result
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -462,6 +532,21 @@ async def step_bot_ai(player_id: str):
|
||||||
"players": [p.model_dump() for p in board_state.players],
|
"players": [p.model_dump() for p in board_state.players],
|
||||||
"turn": result.move_result.turn.model_dump(),
|
"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:
|
else:
|
||||||
await manager.broadcast({
|
await manager.broadcast({
|
||||||
"event": "turn_passed",
|
"event": "turn_passed",
|
||||||
|
|
@ -469,6 +554,16 @@ async def step_bot_ai(player_id: str):
|
||||||
"turn": result.turn.model_dump(),
|
"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
|
return result
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -517,43 +612,9 @@ async def pass_turn(player_id: str):
|
||||||
@router.get(
|
@router.get(
|
||||||
"/turn",
|
"/turn",
|
||||||
response_model=TurnInfo,
|
response_model=TurnInfo,
|
||||||
summary="Get current turn details and round information",
|
summary="Get current turn details, round number, active player, and turn order",
|
||||||
tags=["Turn"],
|
tags=["Movement"],
|
||||||
)
|
)
|
||||||
async def get_turn():
|
async def get_turn():
|
||||||
board = await game_engine.get_board_state()
|
board_state = await game_engine.get_board_state()
|
||||||
return board.turn
|
return board_state.turn
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Board Endpoints
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/board",
|
|
||||||
response_model=BoardState,
|
|
||||||
summary="Get full board configuration, parties, and player positions",
|
|
||||||
tags=["Board"],
|
|
||||||
)
|
|
||||||
async def get_board():
|
|
||||||
return await game_engine.get_board_state()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/board/reset",
|
|
||||||
response_model=ApiResponse,
|
|
||||||
summary="Reset the board and clear all players and parties",
|
|
||||||
tags=["Board"],
|
|
||||||
)
|
|
||||||
async def reset_board():
|
|
||||||
await game_engine.reset()
|
|
||||||
board = await game_engine.get_board_state()
|
|
||||||
await manager.broadcast({
|
|
||||||
"event": "board_reset",
|
|
||||||
"player_count": 0,
|
|
||||||
"turn": board.turn.model_dump(),
|
|
||||||
})
|
|
||||||
return ApiResponse(
|
|
||||||
success=True,
|
|
||||||
message="Board reset successfully. All players cleared.",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from app.models import (
|
||||||
BotMemoryResponse,
|
BotMemoryResponse,
|
||||||
BotRadarResponse,
|
BotRadarResponse,
|
||||||
DIRECTION_OFFSETS,
|
DIRECTION_OFFSETS,
|
||||||
|
GameConclusion,
|
||||||
MoveCheckResult,
|
MoveCheckResult,
|
||||||
MoveResponse,
|
MoveResponse,
|
||||||
Party,
|
Party,
|
||||||
|
|
@ -128,6 +129,33 @@ class GameEngine:
|
||||||
total = sum(self.players[m].strength for m in party.member_ids if m in self.players)
|
total = sum(self.players[m].strength for m in party.member_ids if m in self.players)
|
||||||
party.total_strength = max(1, total)
|
party.total_strength = max(1, total)
|
||||||
|
|
||||||
|
def _check_game_concluded(self) -> GameConclusion:
|
||||||
|
"""The game concludes when there is just 1 party left and all bots are in that party."""
|
||||||
|
if len(self.players) >= 2 and len(self.parties) == 1:
|
||||||
|
only_party = list(self.parties.values())[0]
|
||||||
|
if len(only_party.member_ids) == len(self.players) and all(
|
||||||
|
p.party_id == only_party.id for p in self.players.values()
|
||||||
|
):
|
||||||
|
rankings = sorted(
|
||||||
|
self.players.values(),
|
||||||
|
key=lambda p: (p.score, p.strength),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return GameConclusion(
|
||||||
|
concluded=True,
|
||||||
|
winning_party_id=only_party.id,
|
||||||
|
winning_party_name=only_party.name,
|
||||||
|
winning_leader_id=only_party.leader_id,
|
||||||
|
winning_leader_name=only_party.leader_name,
|
||||||
|
total_bots=len(self.players),
|
||||||
|
rankings=rankings,
|
||||||
|
)
|
||||||
|
return GameConclusion(concluded=False)
|
||||||
|
|
||||||
|
async def get_game_conclusion(self) -> GameConclusion:
|
||||||
|
async with self._lock:
|
||||||
|
return self._check_game_concluded()
|
||||||
|
|
||||||
async def register_player(self, player_in: PlayerCreate) -> Player:
|
async def register_player(self, player_in: PlayerCreate) -> Player:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
player_id = f"bot_{uuid.uuid4().hex[:8]}"
|
player_id = f"bot_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
@ -217,10 +245,10 @@ class GameEngine:
|
||||||
players=players_list,
|
players=players_list,
|
||||||
parties=parties_list,
|
parties=parties_list,
|
||||||
turn=self._get_turn_info(),
|
turn=self._get_turn_info(),
|
||||||
|
conclusion=self._check_game_concluded(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================\n # Bot Memory & Radar Awareness
|
||||||
# Bot Memory & Radar Awareness
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
async def get_bot_memory(
|
async def get_bot_memory(
|
||||||
|
|
@ -262,10 +290,29 @@ class GameEngine:
|
||||||
is_enemy = not is_ally
|
is_enemy = not is_ally
|
||||||
party_obj = self.parties.get(other.party_id) if other.party_id else None
|
party_obj = self.parties.get(other.party_id) if other.party_id else None
|
||||||
|
|
||||||
can_recruit = (player.party_id is None and other.party_id is None) or (
|
# Recruitment vs Battle rules:
|
||||||
player.party_id and player.is_party_leader and other.party_id is None
|
# 1. Unpartied vs unpartied: recruit (form party)
|
||||||
)
|
# 2. Party leader vs solo bot:
|
||||||
can_battle = is_enemy and bool(player.party_id and other.party_id)
|
# - If solo bot strength <= leader strength: recruit (bot desires equal/stronger leader)
|
||||||
|
# - If solo bot strength > leader strength: battle (bot refuses weaker leader, party responds with battle!)
|
||||||
|
# 3. Solo bot vs party:
|
||||||
|
# - If solo bot strength <= other party leader strength: recruit (join)
|
||||||
|
# - If solo bot strength > other party leader strength: battle (refuses, party responds with battle)
|
||||||
|
# 4. Party vs Party: battle
|
||||||
|
if player.party_id and player.is_party_leader and other.party_id is None:
|
||||||
|
can_recruit = other.strength <= player.strength
|
||||||
|
can_battle = other.strength > player.strength
|
||||||
|
elif player.party_id is None and other.party_id is None:
|
||||||
|
can_recruit = True
|
||||||
|
can_battle = False
|
||||||
|
elif player.party_id is None and other.party_id:
|
||||||
|
other_lead = self.players.get(party_obj.leader_id) if party_obj else None
|
||||||
|
lead_str = other_lead.strength if other_lead else 1
|
||||||
|
can_recruit = player.strength <= lead_str
|
||||||
|
can_battle = player.strength > lead_str
|
||||||
|
else:
|
||||||
|
can_recruit = False
|
||||||
|
can_battle = is_enemy and bool(player.party_id and other.party_id)
|
||||||
|
|
||||||
targets.append(
|
targets.append(
|
||||||
RadarTarget(
|
RadarTarget(
|
||||||
|
|
@ -288,16 +335,14 @@ class GameEngine:
|
||||||
targets.sort(key=lambda t: t.distance)
|
targets.sort(key=lambda t: t.distance)
|
||||||
|
|
||||||
# Choose primary target based on goal:
|
# Choose primary target based on goal:
|
||||||
# - If unpartied: seek closest bot to form party with
|
# - If unpartied: seek closest bot/party to join/form party with
|
||||||
# - If partied: seek closest enemy party to battle
|
# - If partied: seek closest enemy party or refusing solo bot to battle
|
||||||
primary_targets = []
|
primary_targets = []
|
||||||
if bot_goal == "form_party":
|
if bot_goal == "form_party":
|
||||||
# Prefer unpartied bots or friendly recruitment targets
|
|
||||||
recruit_targets = [t for t in targets if t.can_recruit or not t.party_id]
|
recruit_targets = [t for t in targets if t.can_recruit or not t.party_id]
|
||||||
primary_targets = recruit_targets if recruit_targets else targets
|
primary_targets = recruit_targets if recruit_targets else targets
|
||||||
else:
|
else:
|
||||||
# Partied: seek enemy parties
|
battle_targets = [t for t in targets if t.is_enemy and (t.party_id or t.can_battle)]
|
||||||
battle_targets = [t for t in targets if t.is_enemy and t.party_id]
|
|
||||||
primary_targets = battle_targets if battle_targets else [t for t in targets if t.is_enemy]
|
primary_targets = battle_targets if battle_targets else [t for t in targets if t.is_enemy]
|
||||||
|
|
||||||
nearest = primary_targets[0] if primary_targets else (targets[0] if targets else None)
|
nearest = primary_targets[0] if primary_targets else (targets[0] if targets else None)
|
||||||
|
|
@ -320,7 +365,7 @@ class GameEngine:
|
||||||
break
|
break
|
||||||
|
|
||||||
if nearest.distance <= 1:
|
if nearest.distance <= 1:
|
||||||
if bot_goal == "form_party" or nearest.can_recruit:
|
if nearest.can_recruit:
|
||||||
rec_act = "form_party"
|
rec_act = "form_party"
|
||||||
else:
|
else:
|
||||||
rec_act = "engage_battle"
|
rec_act = "engage_battle"
|
||||||
|
|
@ -341,8 +386,7 @@ class GameEngine:
|
||||||
recommended_action=rec_act,
|
recommended_action=rec_act,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================\n # Party Logic & Leadership Negotiation
|
||||||
# Party Logic & Leadership Negotiation
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -367,14 +411,6 @@ class GameEngine:
|
||||||
return len(visited) == len(member_players)
|
return len(visited) == len(member_players)
|
||||||
|
|
||||||
def _negotiate_party_leader_id(self, bot_a: Player, bot_b: Player) -> str:
|
def _negotiate_party_leader_id(self, bot_a: Player, bot_b: Player) -> str:
|
||||||
"""Rules:
|
|
||||||
|
|
||||||
- If a bot considers the other bot less than them (lower strength), they insist on being
|
|
||||||
leader.
|
|
||||||
- A bot desires to join a bot that is equal or stronger.
|
|
||||||
- Therefore, the bot with higher strength is the agreed leader.
|
|
||||||
- If strengths are equal, break ties by score or bot_a.
|
|
||||||
"""
|
|
||||||
if bot_a.strength > bot_b.strength:
|
if bot_a.strength > bot_b.strength:
|
||||||
return bot_a.id
|
return bot_a.id
|
||||||
elif bot_b.strength > bot_a.strength:
|
elif bot_b.strength > bot_a.strength:
|
||||||
|
|
@ -530,8 +566,7 @@ class GameEngine:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
return self.parties.get(party_id)
|
return self.parties.get(party_id)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================\n # Movement Checking & Execution
|
||||||
# Movement Checking & Execution
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
def _check_move_internal(
|
def _check_move_internal(
|
||||||
|
|
@ -587,7 +622,12 @@ class GameEngine:
|
||||||
target_x = player.x + dx
|
target_x = player.x + dx
|
||||||
target_y = player.y + dy
|
target_y = player.y + dy
|
||||||
|
|
||||||
if target_x < self.config.min_x or target_x > self.config.max_x or target_y < self.config.min_y or target_y > self.config.max_y:
|
if (
|
||||||
|
target_x < self.config.min_x
|
||||||
|
or target_x > self.config.max_x
|
||||||
|
or target_y < self.config.min_y
|
||||||
|
or target_y > self.config.max_y
|
||||||
|
):
|
||||||
return MoveCheckResult(
|
return MoveCheckResult(
|
||||||
direction=direction_name,
|
direction=direction_name,
|
||||||
dx=dx,
|
dx=dx,
|
||||||
|
|
@ -595,7 +635,7 @@ class GameEngine:
|
||||||
target_x=target_x,
|
target_x=target_x,
|
||||||
target_y=target_y,
|
target_y=target_y,
|
||||||
available=False,
|
available=False,
|
||||||
reason=f"Wall collision at ({target_x}, {target_y}).",
|
reason=f"Hit boundary wall at ({target_x}, {target_y}). Coordinates must remain between {self.config.min_x} and {self.config.max_x}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
occupant = occupied_map.get((target_x, target_y))
|
occupant = occupied_map.get((target_x, target_y))
|
||||||
|
|
@ -607,7 +647,7 @@ class GameEngine:
|
||||||
target_x=target_x,
|
target_x=target_x,
|
||||||
target_y=target_y,
|
target_y=target_y,
|
||||||
available=False,
|
available=False,
|
||||||
reason=f"Space occupied by {occupant.name} ({occupant.id}) at ({target_x}, {target_y}).",
|
reason=f"Target square ({target_x}, {target_y}) is occupied by player '{occupant.name}'.",
|
||||||
)
|
)
|
||||||
|
|
||||||
return MoveCheckResult(
|
return MoveCheckResult(
|
||||||
|
|
@ -689,19 +729,78 @@ class GameEngine:
|
||||||
moves=moves,
|
moves=moves,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_and_auto_form_party(self, player: Player) -> Optional[Party]:
|
def _check_adjacent_encounter(
|
||||||
"""When an unpartied bot sees it is adjacent to another bot, negotiate and form a party:
|
self, player: Player
|
||||||
|
) -> Tuple[Optional[Party], Optional[BattleResult]]:
|
||||||
- Lower strength insists on joining higher strength.
|
"""Evaluates immediate adjacent encounters for party formation or battle:
|
||||||
- Higher strength insists on being leader.
|
1. Two unpartied bots meet -> negotiate leadership (stronger leads), form party.
|
||||||
|
2. Unpartied bot meets a party:
|
||||||
|
- If bot strength <= leader strength: bot joins party willingly (equal or stronger leader).
|
||||||
|
- If bot strength > leader strength: bot refuses to join weaker leader;
|
||||||
|
party responds by doing battle!
|
||||||
|
3. Two opposing parties meet -> engage in 3-bout D20 battle!
|
||||||
"""
|
"""
|
||||||
if player.party_id:
|
# Case A: Player is in a party
|
||||||
return None
|
if player.party_id and player.party_id in self.parties:
|
||||||
|
if not player.is_party_leader:
|
||||||
|
return None, None
|
||||||
|
|
||||||
for other in self.players.values():
|
my_party = self.parties[player.party_id]
|
||||||
if other.id == player.id:
|
my_members = [self.players[mid] for mid in my_party.member_ids if mid in self.players]
|
||||||
continue
|
|
||||||
if self._are_adjacent(player, other):
|
for m in my_members:
|
||||||
|
for other in list(self.players.values()):
|
||||||
|
if other.id in my_party.member_ids:
|
||||||
|
continue
|
||||||
|
if not self._are_adjacent(m, other):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Opposing party
|
||||||
|
if other.party_id and other.party_id in self.parties:
|
||||||
|
opposing_party = self.parties[other.party_id]
|
||||||
|
battle_res = self._resolve_3bout_battle_internal(my_party, opposing_party)
|
||||||
|
return None, battle_res
|
||||||
|
|
||||||
|
# Solo bot (other.party_id is None)
|
||||||
|
if other.party_id is None:
|
||||||
|
if other.strength <= player.strength:
|
||||||
|
# Bot willingly joins party under equal/stronger leader
|
||||||
|
other.party_id = my_party.id
|
||||||
|
other.is_party_leader = False
|
||||||
|
if other.id not in my_party.member_ids:
|
||||||
|
my_party.member_ids.append(other.id)
|
||||||
|
self._update_party_strength(my_party)
|
||||||
|
return my_party, None
|
||||||
|
else:
|
||||||
|
# other.strength > player.strength:
|
||||||
|
# Bot refuses to join weaker leader! Party responds by doing battle!
|
||||||
|
solo_party_id = f"party_{uuid.uuid4().hex[:8]}"
|
||||||
|
solo_party = Party(
|
||||||
|
id=solo_party_id,
|
||||||
|
name=f"Squad {other.name}",
|
||||||
|
leader_id=other.id,
|
||||||
|
leader_name=other.name,
|
||||||
|
member_ids=[other.id],
|
||||||
|
total_strength=other.strength,
|
||||||
|
)
|
||||||
|
other.party_id = solo_party_id
|
||||||
|
other.is_party_leader = True
|
||||||
|
self.parties[solo_party_id] = solo_party
|
||||||
|
|
||||||
|
battle_res = self._resolve_3bout_battle_internal(my_party, solo_party)
|
||||||
|
return None, battle_res
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Case B: Player is a Solo Bot
|
||||||
|
else:
|
||||||
|
for other in list(self.players.values()):
|
||||||
|
if other.id == player.id:
|
||||||
|
continue
|
||||||
|
if not self._are_adjacent(player, other):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Solo Bot meets another Solo Bot -> form party
|
||||||
if other.party_id is None:
|
if other.party_id is None:
|
||||||
leader_id = self._negotiate_party_leader_id(player, other)
|
leader_id = self._negotiate_party_leader_id(player, other)
|
||||||
leader = self.players[leader_id]
|
leader = self.players[leader_id]
|
||||||
|
|
@ -719,25 +818,46 @@ class GameEngine:
|
||||||
other.party_id = party_id
|
other.party_id = party_id
|
||||||
other.is_party_leader = other.id == leader_id
|
other.is_party_leader = other.id == leader_id
|
||||||
self.parties[party_id] = party
|
self.parties[party_id] = party
|
||||||
return party
|
return party, None
|
||||||
elif other.is_party_leader and other.party_id in self.parties:
|
|
||||||
party = self.parties[other.party_id]
|
# Solo Bot meets a Party Member
|
||||||
if player.strength > party.total_strength:
|
if other.party_id and other.party_id in self.parties:
|
||||||
# Player is stronger than entire party: player insists on leading
|
other_party = self.parties[other.party_id]
|
||||||
party.member_ids.append(player.id)
|
leader = self.players.get(other_party.leader_id)
|
||||||
player.party_id = party.id
|
leader_strength = leader.strength if leader else 1
|
||||||
player.is_party_leader = True
|
|
||||||
other.is_party_leader = False
|
if player.strength <= leader_strength:
|
||||||
party.leader_id = player.id
|
# Player willingly joins party under equal/stronger leader
|
||||||
party.leader_name = player.name
|
player.party_id = other_party.id
|
||||||
else:
|
|
||||||
# Party is equal or stronger: player joins under existing leader
|
|
||||||
party.member_ids.append(player.id)
|
|
||||||
player.party_id = party.id
|
|
||||||
player.is_party_leader = False
|
player.is_party_leader = False
|
||||||
self._update_party_strength(party)
|
if player.id not in other_party.member_ids:
|
||||||
return party
|
other_party.member_ids.append(player.id)
|
||||||
return None
|
self._update_party_strength(other_party)
|
||||||
|
return other_party, None
|
||||||
|
else:
|
||||||
|
# player.strength > leader_strength:
|
||||||
|
# Player refuses to join weaker leader! Party responds by doing battle!
|
||||||
|
solo_party_id = f"party_{uuid.uuid4().hex[:8]}"
|
||||||
|
solo_party = Party(
|
||||||
|
id=solo_party_id,
|
||||||
|
name=f"Squad {player.name}",
|
||||||
|
leader_id=player.id,
|
||||||
|
leader_name=player.name,
|
||||||
|
member_ids=[player.id],
|
||||||
|
total_strength=player.strength,
|
||||||
|
)
|
||||||
|
player.party_id = solo_party_id
|
||||||
|
player.is_party_leader = True
|
||||||
|
self.parties[solo_party_id] = solo_party
|
||||||
|
|
||||||
|
battle_res = self._resolve_3bout_battle_internal(other_party, solo_party)
|
||||||
|
return None, battle_res
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _check_and_auto_form_party(self, player: Player) -> Optional[Party]:
|
||||||
|
party, _ = self._check_adjacent_encounter(player)
|
||||||
|
return party
|
||||||
|
|
||||||
async def move_player(
|
async def move_player(
|
||||||
self, player_id: str, dx: int, dy: int, direction_name: str
|
self, player_id: str, dx: int, dy: int, direction_name: str
|
||||||
|
|
@ -789,24 +909,12 @@ class GameEngine:
|
||||||
|
|
||||||
new_pos = {"x": player.x, "y": player.y}
|
new_pos = {"x": player.x, "y": player.y}
|
||||||
|
|
||||||
# Check for party formation if solo bot moved next to another bot
|
# Check for adjacent interactions (party formation or battle)
|
||||||
formed_party = None
|
formed_party, battle_result = self._check_adjacent_encounter(player)
|
||||||
if player.party_id is None:
|
|
||||||
formed_party = self._check_and_auto_form_party(player)
|
|
||||||
|
|
||||||
# Check if moving party engages an opposing party in battle
|
|
||||||
battle_result = None
|
|
||||||
battle_triggered = False
|
|
||||||
|
|
||||||
if player.party_id and player.party_id in self.parties:
|
|
||||||
my_party = self.parties[player.party_id]
|
|
||||||
opposing_party = self._find_adjacent_opposing_party(my_party)
|
|
||||||
if opposing_party:
|
|
||||||
battle_result = self._resolve_3bout_battle_internal(my_party, opposing_party)
|
|
||||||
battle_triggered = True
|
|
||||||
|
|
||||||
self._advance_turn()
|
self._advance_turn()
|
||||||
turn_info = self._get_turn_info()
|
turn_info = self._get_turn_info()
|
||||||
|
conclusion = self._check_game_concluded()
|
||||||
|
|
||||||
return MoveResponse(
|
return MoveResponse(
|
||||||
success=True,
|
success=True,
|
||||||
|
|
@ -818,8 +926,9 @@ class GameEngine:
|
||||||
new_position=new_pos,
|
new_position=new_pos,
|
||||||
party_formed_triggered=bool(formed_party),
|
party_formed_triggered=bool(formed_party),
|
||||||
formed_party=formed_party,
|
formed_party=formed_party,
|
||||||
battle_triggered=battle_triggered,
|
battle_triggered=bool(battle_result),
|
||||||
battle_result=battle_result,
|
battle_result=battle_result,
|
||||||
|
game_concluded=conclusion if conclusion.concluded else None,
|
||||||
turn=turn_info,
|
turn=turn_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -840,17 +949,10 @@ class GameEngine:
|
||||||
self._advance_turn()
|
self._advance_turn()
|
||||||
return self._get_turn_info()
|
return self._get_turn_info()
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================\n # Autonomous Goal Step (AI Logic)
|
||||||
# Autonomous Goal Step (AI Logic)
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
async def step_bot_ai(self, player_id: str) -> AiStepResponse:
|
async def step_bot_ai(self, player_id: str) -> AiStepResponse:
|
||||||
"""Executes one autonomous turn for the bot or party leader following exact goals:
|
|
||||||
|
|
||||||
1. Bot without party: Goal is to seek other bots and form a party (stronger bot insists on
|
|
||||||
being leader).
|
|
||||||
2. Party leader: Goal is to find and defeat all other parties.
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
player = self.players.get(player_id)
|
player = self.players.get(player_id)
|
||||||
if not player:
|
if not player:
|
||||||
|
|
@ -864,43 +966,41 @@ class GameEngine:
|
||||||
|
|
||||||
bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party"
|
bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party"
|
||||||
|
|
||||||
# 1. If unpartied and already adjacent to another bot, form a party immediately!
|
# 1. Check for immediate adjacent encounter before moving
|
||||||
if not player.party_id:
|
formed_party, battle_res = self._check_adjacent_encounter(player)
|
||||||
formed = self._check_and_auto_form_party(player)
|
if battle_res:
|
||||||
if formed:
|
self._advance_turn()
|
||||||
self._advance_turn()
|
conclusion = self._check_game_concluded()
|
||||||
return AiStepResponse(
|
return AiStepResponse(
|
||||||
action_taken="formed_party",
|
action_taken="battled",
|
||||||
player_id=player.id,
|
player_id=player.id,
|
||||||
player_name=player.name,
|
player_name=player.name,
|
||||||
bot_goal=bot_goal,
|
bot_goal=bot_goal,
|
||||||
direction=None,
|
direction=None,
|
||||||
move_result=None,
|
move_result=None,
|
||||||
formed_party=formed,
|
formed_party=None,
|
||||||
battle_result=None,
|
battle_result=battle_res,
|
||||||
turn=self._get_turn_info(),
|
game_concluded=conclusion if conclusion.concluded else None,
|
||||||
)
|
turn=self._get_turn_info(),
|
||||||
|
)
|
||||||
|
|
||||||
# 2. If partied leader and already adjacent to an opposing party, fight immediately!
|
if formed_party:
|
||||||
if player.party_id and player.is_party_leader and player.party_id in self.parties:
|
self._advance_turn()
|
||||||
my_party = self.parties[player.party_id]
|
conclusion = self._check_game_concluded()
|
||||||
opposing = self._find_adjacent_opposing_party(my_party)
|
return AiStepResponse(
|
||||||
if opposing:
|
action_taken="formed_party",
|
||||||
battle_res = self._resolve_3bout_battle_internal(my_party, opposing)
|
player_id=player.id,
|
||||||
self._advance_turn()
|
player_name=player.name,
|
||||||
return AiStepResponse(
|
bot_goal=bot_goal,
|
||||||
action_taken="battled",
|
direction=None,
|
||||||
player_id=player.id,
|
move_result=None,
|
||||||
player_name=player.name,
|
formed_party=formed_party,
|
||||||
bot_goal=bot_goal,
|
battle_result=None,
|
||||||
direction=None,
|
game_concluded=conclusion if conclusion.concluded else None,
|
||||||
move_result=None,
|
turn=self._get_turn_info(),
|
||||||
formed_party=None,
|
)
|
||||||
battle_result=battle_res,
|
|
||||||
turn=self._get_turn_info(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Otherwise: navigate towards target using radar & memory
|
# 2. Navigate towards target using radar & memory
|
||||||
occupied = self._get_occupied_coordinates()
|
occupied = self._get_occupied_coordinates()
|
||||||
moves_map: Dict[str, MoveCheckResult] = {}
|
moves_map: Dict[str, MoveCheckResult] = {}
|
||||||
for name, dx, dy in STANDARD_DIRECTIONS:
|
for name, dx, dy in STANDARD_DIRECTIONS:
|
||||||
|
|
@ -908,8 +1008,8 @@ class GameEngine:
|
||||||
|
|
||||||
available_dirs = [name for name, chk in moves_map.items() if chk.available]
|
available_dirs = [name for name, chk in moves_map.items() if chk.available]
|
||||||
if not available_dirs:
|
if not available_dirs:
|
||||||
# No move available, pass turn
|
|
||||||
self._advance_turn()
|
self._advance_turn()
|
||||||
|
conclusion = self._check_game_concluded()
|
||||||
return AiStepResponse(
|
return AiStepResponse(
|
||||||
action_taken="passed",
|
action_taken="passed",
|
||||||
player_id=player.id,
|
player_id=player.id,
|
||||||
|
|
@ -919,6 +1019,7 @@ class GameEngine:
|
||||||
move_result=None,
|
move_result=None,
|
||||||
formed_party=None,
|
formed_party=None,
|
||||||
battle_result=None,
|
battle_result=None,
|
||||||
|
game_concluded=conclusion if conclusion.concluded else None,
|
||||||
turn=self._get_turn_info(),
|
turn=self._get_turn_info(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -934,9 +1035,6 @@ class GameEngine:
|
||||||
|
|
||||||
targets.sort(key=lambda t: t[0])
|
targets.sort(key=lambda t: t[0])
|
||||||
|
|
||||||
# Filter targets:
|
|
||||||
# - If solo bot: prioritize unpartied bots
|
|
||||||
# - If party: prioritize enemy parties
|
|
||||||
if bot_goal == "form_party":
|
if bot_goal == "form_party":
|
||||||
preferred = [t for t in targets if not t[1].party_id]
|
preferred = [t for t in targets if not t[1].party_id]
|
||||||
target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None)
|
target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None)
|
||||||
|
|
@ -944,9 +1042,6 @@ class GameEngine:
|
||||||
preferred = [t for t in targets if t[1].party_id]
|
preferred = [t for t in targets if t[1].party_id]
|
||||||
target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None)
|
target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None)
|
||||||
|
|
||||||
# Score each available direction based on:
|
|
||||||
# 1. Getting closer to target
|
|
||||||
# 2. Preferring unvisited tiles (location memory)
|
|
||||||
visited_set = {(loc["x"], loc["y"]) for loc in player.visited_locations}
|
visited_set = {(loc["x"], loc["y"]) for loc in player.visited_locations}
|
||||||
best_dir = available_dirs[0]
|
best_dir = available_dirs[0]
|
||||||
best_score = float("-inf")
|
best_score = float("-inf")
|
||||||
|
|
@ -960,17 +1055,15 @@ class GameEngine:
|
||||||
if target_bot:
|
if target_bot:
|
||||||
old_dist = max(abs(player.x - target_bot.x), abs(player.y - target_bot.y))
|
old_dist = max(abs(player.x - target_bot.x), abs(player.y - target_bot.y))
|
||||||
new_dist = max(abs(tx - target_bot.x), abs(ty - target_bot.y))
|
new_dist = max(abs(tx - target_bot.x), abs(ty - target_bot.y))
|
||||||
score += (old_dist - new_dist) * 10.0 # Reward moving closer
|
score += (old_dist - new_dist) * 10.0
|
||||||
|
|
||||||
# Memory penalty for visited coordinates
|
|
||||||
if (tx, ty) not in visited_set:
|
if (tx, ty) not in visited_set:
|
||||||
score += 2.0 # Exploration bonus
|
score += 2.0
|
||||||
|
|
||||||
if score > best_score:
|
if score > best_score:
|
||||||
best_score = score
|
best_score = score
|
||||||
best_dir = dir_name
|
best_dir = dir_name
|
||||||
|
|
||||||
# Execute move with chosen direction
|
|
||||||
dx, dy = DIRECTION_OFFSETS[best_dir]
|
dx, dy = DIRECTION_OFFSETS[best_dir]
|
||||||
prev_pos = {"x": player.x, "y": player.y}
|
prev_pos = {"x": player.x, "y": player.y}
|
||||||
affected_players: List[Player] = []
|
affected_players: List[Player] = []
|
||||||
|
|
@ -992,22 +1085,12 @@ class GameEngine:
|
||||||
|
|
||||||
new_pos = {"x": player.x, "y": player.y}
|
new_pos = {"x": player.x, "y": player.y}
|
||||||
|
|
||||||
# Check party formation / battle engagement after move
|
# Check for adjacent interactions after movement
|
||||||
formed_party = None
|
formed_party, battle_result = self._check_adjacent_encounter(player)
|
||||||
if player.party_id is None:
|
|
||||||
formed_party = self._check_and_auto_form_party(player)
|
|
||||||
|
|
||||||
battle_result = None
|
|
||||||
battle_triggered = False
|
|
||||||
if player.party_id and player.party_id in self.parties:
|
|
||||||
my_party = self.parties[player.party_id]
|
|
||||||
opposing_party = self._find_adjacent_opposing_party(my_party)
|
|
||||||
if opposing_party:
|
|
||||||
battle_result = self._resolve_3bout_battle_internal(my_party, opposing_party)
|
|
||||||
battle_triggered = True
|
|
||||||
|
|
||||||
self._advance_turn()
|
self._advance_turn()
|
||||||
turn_info = self._get_turn_info()
|
turn_info = self._get_turn_info()
|
||||||
|
conclusion = self._check_game_concluded()
|
||||||
|
|
||||||
move_res = MoveResponse(
|
move_res = MoveResponse(
|
||||||
success=True,
|
success=True,
|
||||||
|
|
@ -1019,12 +1102,13 @@ class GameEngine:
|
||||||
new_position=new_pos,
|
new_position=new_pos,
|
||||||
party_formed_triggered=bool(formed_party),
|
party_formed_triggered=bool(formed_party),
|
||||||
formed_party=formed_party,
|
formed_party=formed_party,
|
||||||
battle_triggered=battle_triggered,
|
battle_triggered=bool(battle_result),
|
||||||
battle_result=battle_result,
|
battle_result=battle_result,
|
||||||
|
game_concluded=conclusion if conclusion.concluded else None,
|
||||||
turn=turn_info,
|
turn=turn_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
action_taken = "battled" if battle_triggered else ("formed_party" if formed_party else "moved")
|
action_taken = "battled" if battle_result else ("formed_party" if formed_party else "moved")
|
||||||
return AiStepResponse(
|
return AiStepResponse(
|
||||||
action_taken=action_taken,
|
action_taken=action_taken,
|
||||||
player_id=player.id,
|
player_id=player.id,
|
||||||
|
|
@ -1034,11 +1118,11 @@ class GameEngine:
|
||||||
move_result=move_res,
|
move_result=move_res,
|
||||||
formed_party=formed_party,
|
formed_party=formed_party,
|
||||||
battle_result=battle_result,
|
battle_result=battle_result,
|
||||||
|
game_concluded=conclusion if conclusion.concluded else None,
|
||||||
turn=turn_info,
|
turn=turn_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================\n # 3-Bout D20 Battle Mechanics
|
||||||
# 3-Bout D20 Battle Mechanics
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
def _find_adjacent_opposing_party(self, party: Party) -> Optional[Party]:
|
def _find_adjacent_opposing_party(self, party: Party) -> Optional[Party]:
|
||||||
|
|
@ -1112,23 +1196,6 @@ class GameEngine:
|
||||||
else:
|
else:
|
||||||
winner_party, defeated_party = party2, party1
|
winner_party, defeated_party = party2, party1
|
||||||
|
|
||||||
# Defeated Party loses leader: leader gets -1 point and respawns
|
|
||||||
killed_leader_id = defeated_party.leader_id
|
|
||||||
killed_leader = self.players.get(killed_leader_id)
|
|
||||||
if killed_leader:
|
|
||||||
killed_leader.score -= 1
|
|
||||||
killed_leader.party_id = None
|
|
||||||
killed_leader.is_party_leader = False
|
|
||||||
rx, ry = self._find_random_free_position()
|
|
||||||
killed_leader.x = rx
|
|
||||||
killed_leader.y = ry
|
|
||||||
killed_leader.visited_locations.append({"x": rx, "y": ry})
|
|
||||||
respawn_pos = {"x": rx, "y": ry}
|
|
||||||
else:
|
|
||||||
respawn_pos = {"x": 0, "y": 0}
|
|
||||||
|
|
||||||
# Rest of losing party loses 0 points (scores remain unchanged)
|
|
||||||
|
|
||||||
# Winning leader receives +2 points, and the rest of the winning party receives +1 point
|
# Winning leader receives +2 points, and the rest of the winning party receives +1 point
|
||||||
for mid in list(winner_party.member_ids):
|
for mid in list(winner_party.member_ids):
|
||||||
member = self.players.get(mid)
|
member = self.players.get(mid)
|
||||||
|
|
@ -1138,17 +1205,48 @@ class GameEngine:
|
||||||
else:
|
else:
|
||||||
member.score += 1
|
member.score += 1
|
||||||
|
|
||||||
# Remainder of defeated party joins the winning party
|
killed_leader_id = defeated_party.leader_id
|
||||||
|
killed_leader = self.players.get(killed_leader_id)
|
||||||
absorbed_members = []
|
absorbed_members = []
|
||||||
for mid in list(defeated_party.member_ids):
|
|
||||||
if mid != killed_leader_id:
|
if len(defeated_party.member_ids) == 1:
|
||||||
m = self.players.get(mid)
|
# Defeated party was a single refusing/solo bot:
|
||||||
if m:
|
# The party conquered the solo bot: it loses 1 point (-1 pt) and is absorbed into the winning party!
|
||||||
m.party_id = winner_party.id
|
if killed_leader:
|
||||||
m.is_party_leader = False
|
killed_leader.score -= 1
|
||||||
if mid not in winner_party.member_ids:
|
killed_leader.party_id = winner_party.id
|
||||||
winner_party.member_ids.append(mid)
|
killed_leader.is_party_leader = False
|
||||||
absorbed_members.append(mid)
|
if killed_leader_id not in winner_party.member_ids:
|
||||||
|
winner_party.member_ids.append(killed_leader_id)
|
||||||
|
absorbed_members = [killed_leader_id]
|
||||||
|
respawn_pos = {"x": killed_leader.x, "y": killed_leader.y}
|
||||||
|
else:
|
||||||
|
respawn_pos = {"x": 0, "y": 0}
|
||||||
|
else:
|
||||||
|
# Multi-bot defeated party:
|
||||||
|
# Defeated party loses leader: leader gets -1 point, leaves party, and respawns at random position
|
||||||
|
if killed_leader:
|
||||||
|
killed_leader.score -= 1
|
||||||
|
killed_leader.party_id = None
|
||||||
|
killed_leader.is_party_leader = False
|
||||||
|
rx, ry = self._find_random_free_position()
|
||||||
|
killed_leader.x = rx
|
||||||
|
killed_leader.y = ry
|
||||||
|
killed_leader.visited_locations.append({"x": rx, "y": ry})
|
||||||
|
respawn_pos = {"x": rx, "y": ry}
|
||||||
|
else:
|
||||||
|
respawn_pos = {"x": 0, "y": 0}
|
||||||
|
|
||||||
|
# Remainder of defeated party joins the winning party (scores remain unchanged)
|
||||||
|
for mid in list(defeated_party.member_ids):
|
||||||
|
if mid != killed_leader_id:
|
||||||
|
m = self.players.get(mid)
|
||||||
|
if m:
|
||||||
|
m.party_id = winner_party.id
|
||||||
|
m.is_party_leader = False
|
||||||
|
if mid not in winner_party.member_ids:
|
||||||
|
winner_party.member_ids.append(mid)
|
||||||
|
absorbed_members.append(mid)
|
||||||
|
|
||||||
# Defeated party is dissolved
|
# Defeated party is dissolved
|
||||||
if defeated_party.id in self.parties:
|
if defeated_party.id in self.parties:
|
||||||
|
|
|
||||||
|
|
@ -289,12 +289,27 @@ class TurnInfo(BaseModel):
|
||||||
turn_order: List[str] = []
|
turn_order: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Game Conclusion & Scoreboard Models
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
class GameConclusion(BaseModel):
|
||||||
|
concluded: bool = False
|
||||||
|
winning_party_id: Optional[str] = None
|
||||||
|
winning_party_name: Optional[str] = None
|
||||||
|
winning_leader_id: Optional[str] = None
|
||||||
|
winning_leader_name: Optional[str] = None
|
||||||
|
total_bots: int = 0
|
||||||
|
rankings: List[Player] = []
|
||||||
|
|
||||||
|
|
||||||
class BoardState(BaseModel):
|
class BoardState(BaseModel):
|
||||||
config: BoardConfig
|
config: BoardConfig
|
||||||
player_count: int
|
player_count: int
|
||||||
players: List[Player]
|
players: List[Player]
|
||||||
parties: List[Party] = []
|
parties: List[Party] = []
|
||||||
turn: TurnInfo
|
turn: TurnInfo
|
||||||
|
conclusion: Optional[GameConclusion] = None
|
||||||
|
|
||||||
|
|
||||||
class MoveResponse(BaseModel):
|
class MoveResponse(BaseModel):
|
||||||
|
|
@ -309,6 +324,7 @@ class MoveResponse(BaseModel):
|
||||||
formed_party: Optional[Party] = None
|
formed_party: Optional[Party] = None
|
||||||
battle_triggered: bool = False
|
battle_triggered: bool = False
|
||||||
battle_result: Optional[BattleResult] = None
|
battle_result: Optional[BattleResult] = None
|
||||||
|
game_concluded: Optional[GameConclusion] = None
|
||||||
turn: TurnInfo
|
turn: TurnInfo
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -321,6 +337,7 @@ class AiStepResponse(BaseModel):
|
||||||
move_result: Optional[MoveResponse] = None
|
move_result: Optional[MoveResponse] = None
|
||||||
formed_party: Optional[Party] = None
|
formed_party: Optional[Party] = None
|
||||||
battle_result: Optional[BattleResult] = None
|
battle_result: Optional[BattleResult] = None
|
||||||
|
game_concluded: Optional[GameConclusion] = None
|
||||||
turn: TurnInfo
|
turn: TurnInfo
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,250 +1,294 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
import asyncio
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.game import game_engine
|
from app.game import game_engine
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_board_state():
|
def reset_game_state():
|
||||||
import asyncio
|
client = TestClient(app)
|
||||||
asyncio.run(game_engine.reset())
|
client.post("/api/reset")
|
||||||
yield
|
yield
|
||||||
asyncio.run(game_engine.reset())
|
client.post("/api/reset")
|
||||||
|
|
||||||
|
|
||||||
|
def test_strength_based_leadership_negotiation():
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Register BotStrong with strength 5 and BotWeak with strength 2
|
||||||
|
b1 = client.post("/api/players", json={"name": "BotStrong", "color": "#FF0000", "strength": 5}).json()
|
||||||
|
b2 = client.post("/api/players", json={"name": "BotWeak", "color": "#0000FF", "strength": 2}).json()
|
||||||
|
|
||||||
|
# Place them adjacent to each other
|
||||||
|
async def place():
|
||||||
|
p1 = await game_engine.get_player(b1["id"])
|
||||||
|
p1.x, p1.y = 10, 10
|
||||||
|
p2 = await game_engine.get_player(b2["id"])
|
||||||
|
p2.x, p2.y = 10, 11
|
||||||
|
asyncio.run(place())
|
||||||
|
|
||||||
|
# Step AI turn for the first player in turn order
|
||||||
|
turn_info = client.get("/api/turn").json()
|
||||||
|
acting_id = turn_info["current_player_id"]
|
||||||
|
step_res = client.post(f"/api/players/{acting_id}/ai-step")
|
||||||
|
assert step_res.status_code == 200
|
||||||
|
data = step_res.json()
|
||||||
|
|
||||||
|
# Formed party should have occurred
|
||||||
|
assert data["action_taken"] == "formed_party"
|
||||||
|
party = data["formed_party"]
|
||||||
|
assert party is not None
|
||||||
|
|
||||||
|
# The stronger bot (BotStrong, strength 5) must insist on being the leader!
|
||||||
|
assert party["leader_id"] == b1["id"]
|
||||||
|
assert party["leader_name"] == "BotStrong"
|
||||||
|
assert party["total_strength"] == 7 # 5 + 2
|
||||||
|
|
||||||
|
|
||||||
def test_bot_location_memory_and_radar():
|
def test_bot_location_memory_and_radar():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
p1 = client.post("/api/players", json={"name": "MemoryBot", "color": "#00FFAA"}).json()
|
|
||||||
p2 = client.post("/api/players", json={"name": "NearbyBot", "color": "#FF00AA"}).json()
|
|
||||||
|
|
||||||
import asyncio
|
# Register BotAlpha and BotBravo
|
||||||
async def place_bots():
|
b1 = client.post("/api/players", json={"name": "BotAlpha", "color": "#FF0000", "strength": 1}).json()
|
||||||
b1 = await game_engine.get_player(p1["id"])
|
b2 = client.post("/api/players", json={"name": "BotBravo", "color": "#00FF00", "strength": 1}).json()
|
||||||
b1.x, b1.y = 15, 15
|
|
||||||
b1.visited_locations = [{"x": 15, "y": 15}]
|
|
||||||
b2 = await game_engine.get_player(p2["id"])
|
|
||||||
b2.x, b2.y = 16, 16
|
|
||||||
b2.visited_locations = [{"x": 16, "y": 16}]
|
|
||||||
asyncio.run(place_bots())
|
|
||||||
|
|
||||||
# Check memory of p1
|
# Move BotAlpha manually
|
||||||
mem = client.get(f"/api/players/{p1['id']}/memory").json()
|
async def set_loc():
|
||||||
assert mem["player_id"] == p1["id"]
|
p1 = await game_engine.get_player(b1["id"])
|
||||||
assert mem["current_x"] == 15
|
p1.x, p1.y = 5, 5
|
||||||
assert mem["current_y"] == 15
|
p1.visited_locations = [{"x": 5, "y": 5}]
|
||||||
assert mem["visited_count"] == 1
|
p2 = await game_engine.get_player(b2["id"])
|
||||||
assert mem["visited_history"][0] == {"x": 15, "y": 15}
|
p2.x, p2.y = 5, 8
|
||||||
|
asyncio.run(set_loc())
|
||||||
|
|
||||||
# Check radar
|
# Check memory: has visited (5, 5)
|
||||||
radar = client.get(f"/api/players/{p1['id']}/radar").json()
|
mem_res = client.get(f"/api/players/{b1['id']}/memory?check_x=5&check_y=5")
|
||||||
assert radar["player_id"] == p1["id"]
|
assert mem_res.status_code == 200
|
||||||
assert len(radar["targets"]) == 1
|
mem_data = mem_res.json()
|
||||||
assert radar["targets"][0]["id"] == p2["id"]
|
assert mem_data["has_visited_current"] is True
|
||||||
assert radar["targets"][0]["distance"] == 1
|
assert mem_data["visited_count"] >= 1
|
||||||
|
|
||||||
# Move p1
|
# Check memory: has NOT visited (20, 20)
|
||||||
res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
|
mem_unvis = client.get(f"/api/players/{b1['id']}/memory?check_x=20&check_y=20").json()
|
||||||
assert res_move.status_code == 200
|
assert mem_unvis["has_visited_current"] is False
|
||||||
|
|
||||||
# Memory should now have 2 visited locations
|
# Check radar awareness
|
||||||
mem2 = client.get(f"/api/players/{p1['id']}/memory").json()
|
radar_res = client.get(f"/api/players/{b1['id']}/radar")
|
||||||
assert mem2["visited_count"] == 2
|
assert radar_res.status_code == 200
|
||||||
assert mem2["visited_history"][-1] == {"x": 15, "y": 14}
|
radar_data = radar_res.json()
|
||||||
|
assert radar_data["bot_goal"] == "form_party"
|
||||||
|
assert len(radar_data["targets"]) >= 1
|
||||||
|
assert radar_data["nearest_target"]["name"] == "BotBravo"
|
||||||
|
assert radar_data["nearest_target"]["distance"] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_strength_based_leadership_negotiation():
|
def test_unpartied_bot_seeks_party_autonomous_turn():
|
||||||
"""Rule test:
|
|
||||||
|
|
||||||
- If a bot considers the other bot less than them (lower strength), they insist on being party
|
|
||||||
leader.
|
|
||||||
- A bot desires to join a bot that is equal or stronger.
|
|
||||||
"""
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
# Bot A (strength 10), Bot B (strength 2) placed adjacent
|
|
||||||
pA = client.post("/api/players", json={"name": "StrongBot", "color": "#111111", "strength": 10}).json()
|
|
||||||
pB = client.post("/api/players", json={"name": "WeakerBot", "color": "#222222", "strength": 2}).json()
|
|
||||||
|
|
||||||
import asyncio
|
b1 = client.post("/api/players", json={"name": "SeekerA", "color": "#00FFFF", "strength": 1}).json()
|
||||||
async def place_adjacent():
|
b2 = client.post("/api/players", json={"name": "SeekerB", "color": "#FFFF00", "strength": 1}).json()
|
||||||
bA = await game_engine.get_player(pA["id"])
|
|
||||||
bA.x, bA.y = 25, 25
|
|
||||||
bB = await game_engine.get_player(pB["id"])
|
|
||||||
bB.x, bB.y = 25, 26
|
|
||||||
asyncio.run(place_adjacent())
|
|
||||||
|
|
||||||
# Step AI for pA: see pB adjacent, negotiate party
|
async def setup_grid():
|
||||||
ai_res = client.post(f"/api/players/{pA['id']}/ai-step")
|
p1 = await game_engine.get_player(b1["id"])
|
||||||
assert ai_res.status_code == 200
|
p1.x, p1.y = 10, 10
|
||||||
data = ai_res.json()
|
p2 = await game_engine.get_player(b2["id"])
|
||||||
assert data["action_taken"] == "formed_party"
|
p2.x, p2.y = 10, 12
|
||||||
assert data["formed_party"] is not None
|
asyncio.run(setup_grid())
|
||||||
|
|
||||||
# StrongBot insisted on being leader, WeakerBot joined stronger
|
# Execute AI step for SeekerA (distance 2, should step down towards SeekerB)
|
||||||
formed = data["formed_party"]
|
step_res = client.post(f"/api/players/{b1['id']}/ai-step")
|
||||||
assert formed["leader_id"] == pA["id"]
|
assert step_res.status_code == 200
|
||||||
assert formed["leader_name"] == "StrongBot"
|
data = step_res.json()
|
||||||
assert formed["total_strength"] == 12
|
|
||||||
|
|
||||||
botA_state = client.get(f"/api/players/{pA['id']}").json()
|
assert data["bot_goal"] == "form_party"
|
||||||
botB_state = client.get(f"/api/players/{pB['id']}").json()
|
assert data["action_taken"] in ["moved", "formed_party"]
|
||||||
assert botA_state["is_party_leader"] is True
|
|
||||||
assert botB_state["is_party_leader"] is False
|
|
||||||
assert botA_state["party_id"] == formed["id"]
|
|
||||||
assert botB_state["party_id"] == formed["id"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
# Party 1: Leader + 2 Followers (Total 3 bots, strength 3)
|
|
||||||
p1 = client.post("/api/players", json={"name": "SquadLeader1", "color": "#111111"}).json()
|
|
||||||
p2 = client.post("/api/players", json={"name": "WingmanA", "color": "#222222"}).json()
|
|
||||||
p3 = client.post("/api/players", json={"name": "WingmanB", "color": "#333333"}).json()
|
|
||||||
|
|
||||||
# Party 2: Leader + 1 Follower (Total 2 bots, strength 2)
|
# Party 1: AlphaLeader + AlphaMember
|
||||||
p4 = client.post("/api/players", json={"name": "SquadLeader2", "color": "#444444"}).json()
|
p1 = client.post("/api/players", json={"name": "AlphaLead", "color": "#111111", "strength": 5}).json()
|
||||||
p5 = client.post("/api/players", json={"name": "WingmanC", "color": "#555555"}).json()
|
p2 = client.post("/api/players", json={"name": "AlphaWing", "color": "#222222", "strength": 5}).json()
|
||||||
|
|
||||||
import asyncio
|
# Party 2: BetaLeader + BetaMember
|
||||||
async def setup_parties():
|
p3 = client.post("/api/players", json={"name": "BetaLead", "color": "#333333", "strength": 1}).json()
|
||||||
b1 = await game_engine.get_player(p1["id"])
|
p4 = client.post("/api/players", json={"name": "BetaWing", "color": "#444444", "strength": 1}).json()
|
||||||
b1.x, b1.y = 20, 20
|
|
||||||
b2 = await game_engine.get_player(p2["id"])
|
|
||||||
b2.x, b2.y = 20, 21
|
|
||||||
b3 = await game_engine.get_player(p3["id"])
|
|
||||||
b3.x, b3.y = 21, 21
|
|
||||||
|
|
||||||
b4 = await game_engine.get_player(p4["id"])
|
# Position them adjacent
|
||||||
b4.x, b4.y = 21, 20 # Adjacent to b1
|
async def setup_combat_positions():
|
||||||
b5 = await game_engine.get_player(p5["id"])
|
|
||||||
b5.x, b5.y = 22, 20
|
|
||||||
asyncio.run(setup_parties())
|
|
||||||
|
|
||||||
# Form Party 1
|
|
||||||
party1 = client.post("/api/parties", json={
|
|
||||||
"member_ids": [p1["id"], p2["id"], p3["id"]],
|
|
||||||
"leader_id": p1["id"],
|
|
||||||
"name": "Titans",
|
|
||||||
}).json()
|
|
||||||
|
|
||||||
# Form Party 2
|
|
||||||
party2 = client.post("/api/parties", json={
|
|
||||||
"member_ids": [p4["id"], p5["id"]],
|
|
||||||
"leader_id": p4["id"],
|
|
||||||
"name": "Phantoms",
|
|
||||||
}).json()
|
|
||||||
|
|
||||||
# Fight battle between the adjacent parties
|
|
||||||
res = client.post("/api/battles/fight", json={
|
|
||||||
"challenger_id": p1["id"],
|
|
||||||
"defender_id": p4["id"],
|
|
||||||
})
|
|
||||||
assert res.status_code == 200
|
|
||||||
battle = res.json()
|
|
||||||
|
|
||||||
# Verify 3 bouts were fought
|
|
||||||
assert len(battle["bouts"]) == 3
|
|
||||||
for bout in battle["bouts"]:
|
|
||||||
assert 1 <= bout["party1_roll"] <= 20
|
|
||||||
assert 1 <= bout["party2_roll"] <= 20
|
|
||||||
assert bout["party1_score"] == bout["party1_strength"] * bout["party1_roll"]
|
|
||||||
assert bout["party2_score"] == bout["party2_strength"] * bout["party2_roll"]
|
|
||||||
|
|
||||||
# Winner was crowned
|
|
||||||
winner_id = battle["winner_party_id"]
|
|
||||||
defeated_id = battle["defeated_party_id"]
|
|
||||||
assert winner_id in [party1["id"], party2["id"]]
|
|
||||||
assert defeated_id in [party1["id"], party2["id"]]
|
|
||||||
assert winner_id != defeated_id
|
|
||||||
|
|
||||||
# Defeated leader was killed and received -1 point
|
|
||||||
killed_leader_id = battle["killed_leader_id"]
|
|
||||||
dead_leader = client.get(f"/api/players/{killed_leader_id}").json()
|
|
||||||
assert dead_leader["score"] == -1
|
|
||||||
assert dead_leader["party_id"] is None
|
|
||||||
assert dead_leader["is_party_leader"] is False
|
|
||||||
|
|
||||||
# Winning leader received +2 points
|
|
||||||
winning_leader_id = battle["winner_leader_id"]
|
|
||||||
winning_leader = client.get(f"/api/players/{winning_leader_id}").json()
|
|
||||||
assert winning_leader["score"] == 2
|
|
||||||
|
|
||||||
# Winning original squad members received +1 point
|
|
||||||
# Defeated party members lost 0 points (score remains 0)
|
|
||||||
if winner_id == party1["id"]:
|
|
||||||
# Titans won: p2, p3 were in winning party -> score 1
|
|
||||||
# p5 was in defeated party -> score 0
|
|
||||||
assert client.get(f"/api/players/{p2['id']}").json()["score"] == 1
|
|
||||||
assert client.get(f"/api/players/{p3['id']}").json()["score"] == 1
|
|
||||||
assert client.get(f"/api/players/{p5['id']}").json()["score"] == 0
|
|
||||||
else:
|
|
||||||
# Phantoms won: p5 was in winning party -> score 1
|
|
||||||
# p2, p3 were in defeated party -> score 0
|
|
||||||
assert client.get(f"/api/players/{p5['id']}").json()["score"] == 1
|
|
||||||
assert client.get(f"/api/players/{p2['id']}").json()["score"] == 0
|
|
||||||
assert client.get(f"/api/players/{p3['id']}").json()["score"] == 0
|
|
||||||
|
|
||||||
# The remainder of the defeated party joined the winning party
|
|
||||||
winning_party = client.get(f"/api/parties/{winner_id}").json()
|
|
||||||
assert battle["new_party_size"] == len(winning_party["member_ids"])
|
|
||||||
|
|
||||||
# Absorbed bots are now members of winning_party
|
|
||||||
for absorbed_id in battle["absorbed_members"]:
|
|
||||||
absorbed_player = client.get(f"/api/players/{absorbed_id}").json()
|
|
||||||
assert absorbed_player["party_id"] == winner_id
|
|
||||||
assert absorbed_id in winning_party["member_ids"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_party_formation_and_group_movement():
|
|
||||||
client = TestClient(app)
|
|
||||||
p1 = client.post("/api/players", json={"name": "LeaderBot", "color": "#00FFAA"}).json()
|
|
||||||
p2 = client.post("/api/players", json={"name": "FollowerBot", "color": "#FF00AA"}).json()
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
async def place_adjacent():
|
|
||||||
b1 = await game_engine.get_player(p1["id"])
|
b1 = await game_engine.get_player(p1["id"])
|
||||||
b1.x, b1.y = 10, 10
|
b1.x, b1.y = 10, 10
|
||||||
b2 = await game_engine.get_player(p2["id"])
|
b2 = await game_engine.get_player(p2["id"])
|
||||||
b2.x, b2.y = 10, 11
|
b2.x, b2.y = 10, 11
|
||||||
asyncio.run(place_adjacent())
|
b3 = await game_engine.get_player(p3["id"])
|
||||||
|
b3.x, b3.y = 11, 10
|
||||||
|
b4 = await game_engine.get_player(p4["id"])
|
||||||
|
b4.x, b4.y = 11, 11
|
||||||
|
asyncio.run(setup_combat_positions())
|
||||||
|
|
||||||
form_res = client.post("/api/parties", json={
|
# Form Party 1 (Strength 10)
|
||||||
|
client.post("/api/parties", json={
|
||||||
"member_ids": [p1["id"], p2["id"]],
|
"member_ids": [p1["id"], p2["id"]],
|
||||||
"leader_id": p1["id"],
|
"leader_id": p1["id"],
|
||||||
"name": "AlphaSquad",
|
"name": "AlphaSquad",
|
||||||
})
|
})
|
||||||
assert form_res.status_code == 201
|
|
||||||
party = form_res.json()
|
|
||||||
assert party["leader_id"] == p1["id"]
|
|
||||||
assert len(party["member_ids"]) == 2
|
|
||||||
|
|
||||||
res_follow = client.post(f"/api/players/{p2['id']}/move", json={"direction": "UP"})
|
# Form Party 2 (Strength 2)
|
||||||
assert res_follow.status_code == 403
|
client.post("/api/parties", json={
|
||||||
|
"member_ids": [p3["id"], p4["id"]],
|
||||||
|
"leader_id": p3["id"],
|
||||||
|
"name": "BetaSquad",
|
||||||
|
})
|
||||||
|
|
||||||
res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
|
# Fight battle between AlphaLead and BetaLead
|
||||||
assert res_move.status_code == 200
|
battle_res = client.post("/api/battles/fight", json={
|
||||||
move_data = res_move.json()
|
"challenger_id": p1["id"],
|
||||||
assert move_data["party_moved"] is True
|
"defender_id": p3["id"],
|
||||||
assert len(move_data["affected_players"]) == 2
|
})
|
||||||
|
assert battle_res.status_code == 200
|
||||||
|
battle = battle_res.json()
|
||||||
|
|
||||||
|
assert len(battle["bouts"]) == 3
|
||||||
|
assert battle["winner_party_name"] in ["AlphaSquad", "BetaSquad"]
|
||||||
|
|
||||||
|
winner_leader = client.get(f"/api/players/{battle['winner_leader_id']}").json()
|
||||||
|
assert winner_leader["score"] >= 2
|
||||||
|
|
||||||
|
killed_leader = client.get(f"/api/players/{battle['killed_leader_id']}").json()
|
||||||
|
assert killed_leader["score"] == -1
|
||||||
|
assert killed_leader["party_id"] is None
|
||||||
|
|
||||||
|
assert battle["new_party_size"] >= 2
|
||||||
|
|
||||||
|
|
||||||
def test_party_wall_collision_for_follower():
|
def test_game_conclusion_and_scoreboard_rankings():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
p1 = client.post("/api/players", json={"name": "Leader", "color": "#111111"}).json()
|
|
||||||
p2 = client.post("/api/players", json={"name": "Follower", "color": "#222222"}).json()
|
|
||||||
|
|
||||||
import asyncio
|
p1 = client.post("/api/players", json={"name": "AlphaLeader", "color": "#FFD700", "strength": 5}).json()
|
||||||
async def setup_near_wall():
|
p2 = client.post("/api/players", json={"name": "BetaRival", "color": "#C0C0C0", "strength": 3}).json()
|
||||||
|
p3 = client.post("/api/players", json={"name": "GammaHero", "color": "#CD7F32", "strength": 2}).json()
|
||||||
|
p4 = client.post("/api/players", json={"name": "DeltaCadet", "color": "#4A5568", "strength": 1}).json()
|
||||||
|
|
||||||
|
# Position all players contiguously within 1 unit
|
||||||
|
async def setup_positions():
|
||||||
b1 = await game_engine.get_player(p1["id"])
|
b1 = await game_engine.get_player(p1["id"])
|
||||||
b1.x, b1.y = 5, 1
|
b1.x, b1.y = 10, 10
|
||||||
b2 = await game_engine.get_player(p2["id"])
|
b1.score = 6 # 1st place
|
||||||
b2.x, b2.y = 5, 0
|
|
||||||
asyncio.run(setup_near_wall())
|
|
||||||
|
|
||||||
|
b2 = await game_engine.get_player(p2["id"])
|
||||||
|
b2.x, b2.y = 10, 11
|
||||||
|
b2.score = 4 # 2nd place
|
||||||
|
|
||||||
|
b3 = await game_engine.get_player(p3["id"])
|
||||||
|
b3.x, b3.y = 10, 12
|
||||||
|
b3.score = 2 # 3rd place
|
||||||
|
|
||||||
|
b4 = await game_engine.get_player(p4["id"])
|
||||||
|
b4.x, b4.y = 10, 13
|
||||||
|
b4.score = 0 # 4th place
|
||||||
|
asyncio.run(setup_positions())
|
||||||
|
|
||||||
|
# Before joining all into 1 party, conclusion should be false
|
||||||
|
conc_before = client.get("/api/game/conclusion").json()
|
||||||
|
assert conc_before["concluded"] is False
|
||||||
|
|
||||||
|
# Form 1 party that contains all 4 bots
|
||||||
|
party_res = client.post("/api/parties", json={
|
||||||
|
"member_ids": [p1["id"], p2["id"], p3["id"], p4["id"]],
|
||||||
|
"leader_id": p1["id"],
|
||||||
|
"name": "UnitedLegion",
|
||||||
|
})
|
||||||
|
assert party_res.status_code == 201
|
||||||
|
|
||||||
|
# Now there is just 1 party and all bots are in that party -> game concluded!
|
||||||
|
conc_after = client.get("/api/game/conclusion").json()
|
||||||
|
assert conc_after["concluded"] is True
|
||||||
|
assert conc_after["winning_party_name"] == "UnitedLegion"
|
||||||
|
assert conc_after["winning_leader_name"] == "AlphaLeader"
|
||||||
|
assert conc_after["total_bots"] == 4
|
||||||
|
|
||||||
|
# Verify scoreboard rankings (highest score on top)
|
||||||
|
rankings = conc_after["rankings"]
|
||||||
|
assert len(rankings) == 4
|
||||||
|
assert rankings[0]["id"] == p1["id"]
|
||||||
|
assert rankings[0]["score"] == 6
|
||||||
|
assert rankings[1]["id"] == p2["id"]
|
||||||
|
assert rankings[1]["score"] == 4
|
||||||
|
assert rankings[2]["id"] == p3["id"]
|
||||||
|
assert rankings[2]["score"] == 2
|
||||||
|
assert rankings[3]["id"] == p4["id"]
|
||||||
|
assert rankings[3]["score"] == 0
|
||||||
|
|
||||||
|
# Board state also reflects conclusion
|
||||||
|
board = client.get("/api/board").json()
|
||||||
|
assert board["conclusion"]["concluded"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_solo_bot_refuses_weaker_leader_party_responds_with_battle():
|
||||||
|
"""When a single bot is adjacent to a party with a lower strength leader,
|
||||||
|
the bot refuses to join. The party responds by doing battle!
|
||||||
|
When the party conquers the solo bot, the solo bot loses 1 point and is absorbed into the party.
|
||||||
|
If all bots are now in the party, the game concludes!
|
||||||
|
"""
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Party leader has strength 1, teammate has strength 10 (party total strength 11)
|
||||||
|
p1 = client.post("/api/players", json={"name": "WeakLeader", "color": "#111111", "strength": 1}).json()
|
||||||
|
p2 = client.post("/api/players", json={"name": "HeavyFollower", "color": "#222222", "strength": 10}).json()
|
||||||
|
|
||||||
|
# Solo bot has strength 5 (strength 5 > leader's strength 1, so solo bot refuses to join!)
|
||||||
|
p3 = client.post("/api/players", json={"name": "ProudSolo", "color": "#FF0000", "strength": 5}).json()
|
||||||
|
|
||||||
|
async def setup_positions():
|
||||||
|
b1 = await game_engine.get_player(p1["id"])
|
||||||
|
b1.x, b1.y = 10, 10
|
||||||
|
b2 = await game_engine.get_player(p2["id"])
|
||||||
|
b2.x, b2.y = 10, 11
|
||||||
|
b3 = await game_engine.get_player(p3["id"])
|
||||||
|
b3.x, b3.y = 11, 10 # adjacent to WeakLeader
|
||||||
|
asyncio.run(setup_positions())
|
||||||
|
|
||||||
|
# Form party with WeakLeader and HeavyFollower
|
||||||
client.post("/api/parties", json={
|
client.post("/api/parties", json={
|
||||||
"member_ids": [p1["id"], p2["id"]],
|
"member_ids": [p1["id"], p2["id"]],
|
||||||
"leader_id": p1["id"],
|
"leader_id": p1["id"],
|
||||||
|
"name": "WeakSquad",
|
||||||
})
|
})
|
||||||
|
|
||||||
moves = client.get(f"/api/players/{p1['id']}/available-moves").json()
|
# WeakLeader (or ProudSolo) takes their turn
|
||||||
assert moves["moves"]["UP"]["available"] is False
|
turn_info = client.get("/api/turn").json()
|
||||||
assert "boundary wall" in moves["moves"]["UP"]["reason"]
|
acting_id = turn_info["current_player_id"]
|
||||||
|
|
||||||
|
step_res = client.post(f"/api/players/{acting_id}/ai-step")
|
||||||
|
assert step_res.status_code == 200
|
||||||
|
data = step_res.json()
|
||||||
|
|
||||||
|
# Action taken must be "battled" because ProudSolo refused to join the weaker leader!
|
||||||
|
assert data["action_taken"] == "battled"
|
||||||
|
assert data["battle_result"] is not None
|
||||||
|
|
||||||
|
battle = data["battle_result"]
|
||||||
|
assert len(battle["bouts"]) == 3
|
||||||
|
|
||||||
|
# Check board state
|
||||||
|
board = client.get("/api/board").json()
|
||||||
|
|
||||||
|
# Both parties or party + solo bot resolved
|
||||||
|
# If WeakSquad (total str 11) defeated ProudSolo (str 5):
|
||||||
|
if battle["winner_party_name"] == "WeakSquad":
|
||||||
|
# ProudSolo was conquered, received -1 point, and was absorbed into WeakSquad
|
||||||
|
solo_after = client.get(f"/api/players/{p3['id']}").json()
|
||||||
|
assert solo_after["score"] == -1
|
||||||
|
assert solo_after["party_id"] == battle["winner_party_id"]
|
||||||
|
|
||||||
|
# WeakSquad leader received +2 points
|
||||||
|
lead_after = client.get(f"/api/players/{p1['id']}").json()
|
||||||
|
assert lead_after["score"] == 2
|
||||||
|
|
||||||
|
# All 3 bots on board are now in WeakSquad -> Game concluded!
|
||||||
|
conc = client.get("/api/game/conclusion").json()
|
||||||
|
assert conc["concluded"] is True
|
||||||
|
assert conc["winning_party_name"] == "WeakSquad"
|
||||||
|
assert conc["total_bots"] == 3
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { MovementControls } from './components/MovementControls';
|
||||||
import { PartyModal } from './components/PartyModal';
|
import { PartyModal } from './components/PartyModal';
|
||||||
import { PlayerList } from './components/PlayerList';
|
import { PlayerList } from './components/PlayerList';
|
||||||
import { RegisterModal } from './components/RegisterModal';
|
import { RegisterModal } from './components/RegisterModal';
|
||||||
|
import { ScoreboardModal } from './components/ScoreboardModal';
|
||||||
|
|
||||||
const BOT_NAMES = [
|
const BOT_NAMES = [
|
||||||
'ViperStrike',
|
'ViperStrike',
|
||||||
|
|
@ -44,6 +45,8 @@ export function App() {
|
||||||
lastEventMessage,
|
lastEventMessage,
|
||||||
activeBattle,
|
activeBattle,
|
||||||
setActiveBattle,
|
setActiveBattle,
|
||||||
|
showScoreboard,
|
||||||
|
setShowScoreboard,
|
||||||
registerPlayer,
|
registerPlayer,
|
||||||
removePlayer,
|
removePlayer,
|
||||||
formParty,
|
formParty,
|
||||||
|
|
@ -119,6 +122,8 @@ export function App() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isConcluded = Boolean(boardState.conclusion && boardState.conclusion.concluded);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
|
<div className="flex flex-col h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
|
||||||
{/* Top Navigation Bar */}
|
{/* Top Navigation Bar */}
|
||||||
|
|
@ -128,6 +133,8 @@ export function App() {
|
||||||
onQuickSpawn={handleQuickSpawn}
|
onQuickSpawn={handleQuickSpawn}
|
||||||
onResetBoard={handleReset}
|
onResetBoard={handleReset}
|
||||||
playerCount={boardState.player_count}
|
playerCount={boardState.player_count}
|
||||||
|
isConcluded={isConcluded}
|
||||||
|
onOpenScoreboard={() => setShowScoreboard(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
|
|
@ -202,6 +209,19 @@ export function App() {
|
||||||
battle={activeBattle}
|
battle={activeBattle}
|
||||||
onClose={() => setActiveBattle(null)}
|
onClose={() => setActiveBattle(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Game Conclusion Scoreboard Modal */}
|
||||||
|
{showScoreboard && boardState.conclusion && boardState.conclusion.concluded && (
|
||||||
|
<ScoreboardModal
|
||||||
|
conclusion={boardState.conclusion}
|
||||||
|
players={boardState.players}
|
||||||
|
onClose={() => setShowScoreboard(false)}
|
||||||
|
onReset={async () => {
|
||||||
|
await resetBoard();
|
||||||
|
setShowScoreboard(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { BattleResult } from '../types';
|
import type { BattleResult } from '../types';
|
||||||
|
|
||||||
interface BattleModalProps {
|
interface BattleModalProps {
|
||||||
|
|
@ -7,6 +7,30 @@ interface BattleModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) => {
|
export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) => {
|
||||||
|
const [countdown, setCountdown] = useState(5);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!battle) return;
|
||||||
|
|
||||||
|
// Reset countdown to 5 whenever a new battle modal is opened
|
||||||
|
setCountdown(5);
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setCountdown((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
onClose();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [battle, onClose]);
|
||||||
|
|
||||||
if (!battle) return null;
|
if (!battle) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -15,7 +39,15 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
||||||
{/* Glowing cyber header banner */}
|
{/* Glowing cyber header banner */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-amber-500 via-rose-500 to-sky-500" />
|
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-amber-500 via-rose-500 to-sky-500" />
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
|
{/* 5-second automatic progress bar */}
|
||||||
|
<div className="absolute top-2 left-0 right-0 h-1 bg-slate-800">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-400 transition-all duration-1000 ease-linear"
|
||||||
|
style={{ width: `${(countdown / 5) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-4 pb-2 border-b border-slate-800 mt-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-2xl">⚔️</span>
|
<span className="text-2xl">⚔️</span>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -30,6 +62,7 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-slate-400 hover:text-slate-200 p-1 text-sm font-mono"
|
className="text-slate-400 hover:text-slate-200 p-1 text-sm font-mono"
|
||||||
|
title="Dismiss now"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -99,17 +132,20 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
||||||
|
|
||||||
<div className="text-slate-300 leading-relaxed">
|
<div className="text-slate-300 leading-relaxed">
|
||||||
• Defeated Leader <strong className="text-rose-400">{battle.killed_leader_name}</strong> receives{' '}
|
• Defeated Leader <strong className="text-rose-400">{battle.killed_leader_name}</strong> receives{' '}
|
||||||
<span className="text-rose-400 font-bold">-1 point</span>, leaves the party, and respawns at{' '}
|
<span className="text-rose-400 font-bold">-1 point</span>.
|
||||||
<span className="text-emerald-400">
|
{battle.absorbed_members.includes(battle.killed_leader_id) ? (
|
||||||
({battle.killed_leader_respawn_position.x}, {battle.killed_leader_respawn_position.y})
|
<span> Resistance broken! Surrendered and <strong className="text-sky-300">joined {battle.winner_party_name}</strong>.</span>
|
||||||
</span>
|
) : (
|
||||||
. (Surviving defeated bots lose 0 points).
|
<span> Respawned at ({battle.killed_leader_respawn_position.x}, {battle.killed_leader_respawn_position.y}).</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-slate-300 leading-relaxed">
|
{battle.absorbed_members.length > 0 && !battle.absorbed_members.includes(battle.killed_leader_id) && (
|
||||||
• <strong className="text-emerald-400">{battle.absorbed_members.length} surviving bot(s)</strong> from the
|
<div className="text-slate-300 leading-relaxed">
|
||||||
defeated party surrendered and <strong className="text-sky-300">joined {battle.winner_party_name}</strong>!
|
• <strong className="text-emerald-400">{battle.absorbed_members.length} surviving bot(s)</strong> from the
|
||||||
</div>
|
defeated party surrendered and <strong className="text-sky-300">joined {battle.winner_party_name}</strong>!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-slate-400 text-[11px] pt-1">
|
<div className="text-slate-400 text-[11px] pt-1">
|
||||||
New Squad Size: <strong className="text-slate-200">{battle.new_party_size} bots</strong> • Total Strength:{' '}
|
New Squad Size: <strong className="text-slate-200">{battle.new_party_size} bots</strong> • Total Strength:{' '}
|
||||||
|
|
@ -117,12 +153,15 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Continue Button */}
|
{/* Continue Button with 5s Auto-close Countdown */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-full bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold py-2.5 rounded-xl text-xs font-mono transition-all shadow-lg shadow-amber-500/20"
|
className="w-full bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold py-2.5 rounded-xl text-xs font-mono transition-all shadow-lg shadow-amber-500/20 flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
Acknowledge & Continue Battle
|
<span>Acknowledge & Continue Battle</span>
|
||||||
|
<span className="bg-amber-950 text-amber-300 px-2 py-0.5 rounded-full text-[11px] font-mono">
|
||||||
|
Auto-closing in {countdown}s
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ interface HeaderProps {
|
||||||
onQuickSpawn: () => void;
|
onQuickSpawn: () => void;
|
||||||
onResetBoard: () => void;
|
onResetBoard: () => void;
|
||||||
playerCount: number;
|
playerCount: number;
|
||||||
|
isConcluded?: boolean;
|
||||||
|
onOpenScoreboard?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({
|
export const Header: React.FC<HeaderProps> = ({
|
||||||
|
|
@ -14,6 +16,8 @@ export const Header: React.FC<HeaderProps> = ({
|
||||||
onQuickSpawn,
|
onQuickSpawn,
|
||||||
onResetBoard,
|
onResetBoard,
|
||||||
playerCount,
|
playerCount,
|
||||||
|
isConcluded,
|
||||||
|
onOpenScoreboard,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<header className="h-16 border-b border-slate-800 bg-slate-900/90 backdrop-blur px-6 flex items-center justify-between z-10">
|
<header className="h-16 border-b border-slate-800 bg-slate-900/90 backdrop-blur px-6 flex items-center justify-between z-10">
|
||||||
|
|
@ -47,10 +51,26 @@ export const Header: React.FC<HeaderProps> = ({
|
||||||
{isConnected ? 'LIVE SYNC' : 'CONNECTING...'}
|
{isConnected ? 'LIVE SYNC' : 'CONNECTING...'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Game concluded banner tag */}
|
||||||
|
{isConcluded && (
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/20 border border-amber-400 text-amber-300 text-xs font-bold animate-pulse">
|
||||||
|
<span>🏆</span> GAME CONCLUDED
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{isConcluded && onOpenScoreboard && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenScoreboard}
|
||||||
|
className="text-xs font-bold text-amber-950 px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-amber-400 to-yellow-300 hover:from-amber-300 hover:to-yellow-200 shadow-lg shadow-amber-400/30 flex items-center gap-1.5 transition-all"
|
||||||
|
>
|
||||||
|
<span>🏆</span> Final Scoreboard
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/docs"
|
href="/docs"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,648 @@
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import type { GameConclusion, Player } from '../types';
|
||||||
|
|
||||||
|
interface ScoreboardModalProps {
|
||||||
|
conclusion: GameConclusion | null;
|
||||||
|
players: Player[];
|
||||||
|
onClose: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web Audio API Synthesizer for Victory Trumpet Fanfare
|
||||||
|
export function playVictoryTrumpetFanfare() {
|
||||||
|
try {
|
||||||
|
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!AudioCtx) return;
|
||||||
|
const ctx = new AudioCtx();
|
||||||
|
|
||||||
|
if (ctx.state === 'suspended') {
|
||||||
|
ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brass notes sequence: G4 -> C5 -> E5 -> G5 -> E5 -> G5 -> C6 (triumphant climax)
|
||||||
|
const notes = [
|
||||||
|
{ freq: 392.00, start: 0.0, duration: 0.18, vol: 0.35 }, // G4
|
||||||
|
{ freq: 523.25, start: 0.20, duration: 0.18, vol: 0.38 }, // C5
|
||||||
|
{ freq: 659.25, start: 0.40, duration: 0.18, vol: 0.40 }, // E5
|
||||||
|
{ freq: 783.99, start: 0.60, duration: 0.38, vol: 0.45 }, // G5
|
||||||
|
{ freq: 659.25, start: 1.05, duration: 0.16, vol: 0.38 }, // E5
|
||||||
|
{ freq: 783.99, start: 1.23, duration: 0.22, vol: 0.42 }, // G5
|
||||||
|
{ freq: 1046.50, start: 1.48, duration: 0.85, vol: 0.50 }, // C6 (grand finale)
|
||||||
|
];
|
||||||
|
|
||||||
|
notes.forEach(({ freq, start, duration, vol }) => {
|
||||||
|
const startTime = ctx.currentTime + start;
|
||||||
|
const stopTime = startTime + duration;
|
||||||
|
|
||||||
|
// Primary trumpet oscillator (sawtooth for brass buzz)
|
||||||
|
const osc1 = ctx.createOscillator();
|
||||||
|
osc1.type = 'sawtooth';
|
||||||
|
osc1.frequency.setValueAtTime(freq, startTime);
|
||||||
|
|
||||||
|
// Secondary oscillator (square slightly detuned for chorus / rich brass body)
|
||||||
|
const osc2 = ctx.createOscillator();
|
||||||
|
osc2.type = 'square';
|
||||||
|
osc2.frequency.setValueAtTime(freq * 1.002, startTime);
|
||||||
|
|
||||||
|
// Low-pass filter to simulate trumpet brass bell resonance
|
||||||
|
const filter = ctx.createBiquadFilter();
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(freq * 1.8, startTime);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(freq * 3.5, startTime + 0.05);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(freq * 1.5, stopTime);
|
||||||
|
|
||||||
|
// Amplitude Envelope for crisp brass attack & gentle release
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
gainNode.gain.setValueAtTime(0.0001, startTime);
|
||||||
|
gainNode.gain.linearRampToValueAtTime(vol, startTime + 0.03);
|
||||||
|
gainNode.gain.setValueAtTime(vol * 0.9, startTime + duration * 0.7);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.0001, stopTime);
|
||||||
|
|
||||||
|
osc1.connect(filter);
|
||||||
|
osc2.connect(filter);
|
||||||
|
filter.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc1.start(startTime);
|
||||||
|
osc2.start(startTime);
|
||||||
|
osc1.stop(stopTime);
|
||||||
|
osc2.stop(stopTime);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Could not play Web Audio fanfare:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
|
||||||
|
conclusion,
|
||||||
|
players,
|
||||||
|
onClose,
|
||||||
|
onReset,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
// Play trumpet victory fanfare when modal mounts / opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (conclusion?.concluded) {
|
||||||
|
playVictoryTrumpetFanfare();
|
||||||
|
}
|
||||||
|
}, [conclusion?.concluded]);
|
||||||
|
|
||||||
|
// Confetti Particle Winning Animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!conclusion?.concluded) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animFrameId: number;
|
||||||
|
let width = (canvas.width = canvas.parentElement?.clientWidth || window.innerWidth);
|
||||||
|
let height = (canvas.height = canvas.parentElement?.clientHeight || window.innerHeight);
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (canvas && canvas.parentElement) {
|
||||||
|
width = canvas.width = canvas.parentElement.clientWidth;
|
||||||
|
height = canvas.height = canvas.parentElement.clientHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
const colors = ['#FFD700', '#FF4500', '#00FFCC', '#FF00AA', '#7928CA', '#00DFD8', '#FFFFFF', '#38EF7D'];
|
||||||
|
const particleCount = 100;
|
||||||
|
const particles = Array.from({ length: particleCount }).map(() => ({
|
||||||
|
x: Math.random() * width,
|
||||||
|
y: Math.random() * -height,
|
||||||
|
size: Math.random() * 8 + 4,
|
||||||
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
|
speedY: Math.random() * 3 + 2,
|
||||||
|
speedX: (Math.random() - 0.5) * 3,
|
||||||
|
rotation: Math.random() * Math.PI * 2,
|
||||||
|
rotationSpeed: (Math.random() - 0.5) * 0.1,
|
||||||
|
shape: Math.random() > 0.4 ? 'rect' : 'circle',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
particles.forEach((p) => {
|
||||||
|
p.y += p.speedY;
|
||||||
|
p.x += p.speedX;
|
||||||
|
p.rotation += p.rotationSpeed;
|
||||||
|
|
||||||
|
if (p.y > height) {
|
||||||
|
p.y = -10;
|
||||||
|
p.x = Math.random() * width;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x, p.y);
|
||||||
|
ctx.rotate(p.rotation);
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
|
||||||
|
if (p.shape === 'rect') {
|
||||||
|
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
|
||||||
|
} else {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
animFrameId = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animFrameId);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [conclusion?.concluded]);
|
||||||
|
|
||||||
|
if (!conclusion || !conclusion.concluded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted rankings list (highest score on top)
|
||||||
|
const rankedPlayers: Player[] = conclusion.rankings && conclusion.rankings.length > 0
|
||||||
|
? conclusion.rankings
|
||||||
|
: [...players].sort((a, b) => b.score - a.score || b.strength - a.strength);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.overlay}>
|
||||||
|
{/* Background celebration canvas */}
|
||||||
|
<canvas ref={canvasRef} style={styles.canvas} />
|
||||||
|
|
||||||
|
<div style={styles.modal}>
|
||||||
|
{/* Header Ribbon / Trophy Display */}
|
||||||
|
<div style={styles.header}>
|
||||||
|
<div style={styles.trophyBurst}>
|
||||||
|
<span style={styles.trophyIcon}>🏆</span>
|
||||||
|
</div>
|
||||||
|
<h1 style={styles.title}>VICTORY! ALL BOTS UNITED</h1>
|
||||||
|
<p style={styles.subtitle}>
|
||||||
|
The game has concluded! Every bot has united into the supreme party{' '}
|
||||||
|
<strong style={styles.goldText}>"{conclusion.winning_party_name || 'Dominant Squad'}"</strong>!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={styles.fanfareControls}>
|
||||||
|
<button
|
||||||
|
onClick={() => playVictoryTrumpetFanfare()}
|
||||||
|
style={styles.fanfareButton}
|
||||||
|
title="Play Trumpet Victory Fanfare sound"
|
||||||
|
>
|
||||||
|
🎺 Replay Victory Fanfare
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Podium for Top 3 */}
|
||||||
|
<div style={styles.podiumContainer}>
|
||||||
|
{/* 2nd Place */}
|
||||||
|
{rankedPlayers[1] && (
|
||||||
|
<div style={{ ...styles.podiumCard, ...styles.silverCard }}>
|
||||||
|
<div style={styles.medalBadge}>🥈</div>
|
||||||
|
<div style={styles.placeLabel}>2nd Place</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.avatarCircle,
|
||||||
|
backgroundColor: rankedPlayers[1].color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rankedPlayers[1].name.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={styles.podiumBotName}>{rankedPlayers[1].name}</div>
|
||||||
|
<div style={styles.podiumScore}>{rankedPlayers[1].score} pts</div>
|
||||||
|
<div style={styles.podiumDetails}>⚡ {rankedPlayers[1].strength} STR</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 1st Place (Center / Tallest) */}
|
||||||
|
{rankedPlayers[0] && (
|
||||||
|
<div style={{ ...styles.podiumCard, ...styles.goldCard }}>
|
||||||
|
<div style={styles.crown}>👑</div>
|
||||||
|
<div style={styles.medalBadge}>🥇</div>
|
||||||
|
<div style={{ ...styles.placeLabel, color: '#FFD700', fontWeight: 'bold' }}>
|
||||||
|
1st Place Champion
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.avatarCircle,
|
||||||
|
backgroundColor: rankedPlayers[0].color,
|
||||||
|
border: '3px solid #FFD700',
|
||||||
|
boxShadow: '0 0 15px rgba(255, 215, 0, 0.6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rankedPlayers[0].name.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={styles.podiumBotName}>{rankedPlayers[0].name}</div>
|
||||||
|
<div style={{ ...styles.podiumScore, color: '#FFD700' }}>
|
||||||
|
{rankedPlayers[0].score} pts
|
||||||
|
</div>
|
||||||
|
<div style={styles.podiumDetails}>⚡ {rankedPlayers[0].strength} STR</div>
|
||||||
|
{rankedPlayers[0].id === conclusion.winning_leader_id && (
|
||||||
|
<div style={styles.leaderBadge}>Supreme Leader</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3rd Place */}
|
||||||
|
{rankedPlayers[2] && (
|
||||||
|
<div style={{ ...styles.podiumCard, ...styles.bronzeCard }}>
|
||||||
|
<div style={styles.medalBadge}>🥉</div>
|
||||||
|
<div style={styles.placeLabel}>3rd Place</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.avatarCircle,
|
||||||
|
backgroundColor: rankedPlayers[2].color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rankedPlayers[2].name.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={styles.podiumBotName}>{rankedPlayers[2].name}</div>
|
||||||
|
<div style={styles.podiumScore}>{rankedPlayers[2].score} pts</div>
|
||||||
|
<div style={styles.podiumDetails}>⚡ {rankedPlayers[2].strength} STR</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Complete Scoreboard Rankings Table */}
|
||||||
|
<div style={styles.rankingsSection}>
|
||||||
|
<h2 style={styles.rankingsTitle}>🏆 Complete Scoreboard Rankings</h2>
|
||||||
|
<div style={styles.tableWrapper}>
|
||||||
|
<table style={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={styles.th}>Rank</th>
|
||||||
|
<th style={styles.th}>Bot</th>
|
||||||
|
<th style={styles.th}>Party Role</th>
|
||||||
|
<th style={styles.th}>Strength</th>
|
||||||
|
<th style={styles.th}>Visited Tiles</th>
|
||||||
|
<th style={styles.th}>Final Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rankedPlayers.map((player, idx) => {
|
||||||
|
const rank = idx + 1;
|
||||||
|
let rankBadge = `#${rank}`;
|
||||||
|
let rowStyle = styles.tr;
|
||||||
|
|
||||||
|
if (rank === 1) {
|
||||||
|
rankBadge = '🥇 1st';
|
||||||
|
rowStyle = { ...styles.tr, ...styles.goldRow };
|
||||||
|
} else if (rank === 2) {
|
||||||
|
rankBadge = '🥈 2nd';
|
||||||
|
rowStyle = { ...styles.tr, ...styles.silverRow };
|
||||||
|
} else if (rank === 3) {
|
||||||
|
rankBadge = '🥉 3rd';
|
||||||
|
rowStyle = { ...styles.tr, ...styles.bronzeRow };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLeader = player.id === conclusion.winning_leader_id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={player.id} style={rowStyle}>
|
||||||
|
<td style={styles.tdRank}>{rankBadge}</td>
|
||||||
|
<td style={styles.tdBot}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...styles.botColorIndicator,
|
||||||
|
backgroundColor: player.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={styles.botNameText}>{player.name}</span>
|
||||||
|
{isLeader && <span style={styles.inlineLeaderTag}>👑 Leader</span>}
|
||||||
|
</td>
|
||||||
|
<td style={styles.tdRole}>
|
||||||
|
{isLeader ? 'Supreme Leader' : 'Squad Member'}
|
||||||
|
</td>
|
||||||
|
<td style={styles.tdStrength}>⚡ {player.strength}</td>
|
||||||
|
<td style={styles.tdVisited}>
|
||||||
|
🗺️ {player.visited_locations?.length || 1}
|
||||||
|
</td>
|
||||||
|
<td style={styles.tdScore}>
|
||||||
|
<span style={styles.scorePill}>{player.score} pts</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Action Buttons */}
|
||||||
|
<div style={styles.footer}>
|
||||||
|
<button onClick={onReset} style={styles.resetButton}>
|
||||||
|
🔄 Start New Game / Reset Arena
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} style={styles.closeButton}>
|
||||||
|
👀 View Winning Board
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
overlay: {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(10, 14, 26, 0.88)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 9999,
|
||||||
|
padding: '20px',
|
||||||
|
},
|
||||||
|
canvas: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 2,
|
||||||
|
backgroundColor: '#161b22',
|
||||||
|
border: '2px solid #30363d',
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.8), 0 0 40px rgba(255, 215, 0, 0.25)',
|
||||||
|
maxWidth: '780px',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '28px',
|
||||||
|
color: '#f0f6fc',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '20px',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
},
|
||||||
|
trophyBurst: {
|
||||||
|
fontSize: '52px',
|
||||||
|
animation: 'bounce 1.5s infinite',
|
||||||
|
marginBottom: '-8px',
|
||||||
|
},
|
||||||
|
trophyIcon: {
|
||||||
|
filter: 'drop-shadow(0 0 16px rgba(255, 215, 0, 0.8))',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: '1px',
|
||||||
|
background: 'linear-gradient(135deg, #FFD700 0%, #FFA500 50%, #FF4500 100%)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: '15px',
|
||||||
|
color: '#8b949e',
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: '540px',
|
||||||
|
},
|
||||||
|
goldText: {
|
||||||
|
color: '#FFD700',
|
||||||
|
},
|
||||||
|
fanfareControls: {
|
||||||
|
marginTop: '6px',
|
||||||
|
},
|
||||||
|
fanfareButton: {
|
||||||
|
backgroundColor: '#21262d',
|
||||||
|
border: '1px solid #FFD700',
|
||||||
|
color: '#FFD700',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: '13px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
},
|
||||||
|
podiumContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: '14px',
|
||||||
|
padding: '12px 0',
|
||||||
|
},
|
||||||
|
podiumCard: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '14px 12px',
|
||||||
|
width: '180px',
|
||||||
|
position: 'relative',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||||
|
},
|
||||||
|
goldCard: {
|
||||||
|
backgroundColor: 'rgba(255, 215, 0, 0.08)',
|
||||||
|
border: '2px solid #FFD700',
|
||||||
|
minHeight: '210px',
|
||||||
|
order: 2,
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
},
|
||||||
|
silverCard: {
|
||||||
|
backgroundColor: 'rgba(192, 192, 192, 0.06)',
|
||||||
|
border: '2px solid #C0C0C0',
|
||||||
|
minHeight: '180px',
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
bronzeCard: {
|
||||||
|
backgroundColor: 'rgba(205, 127, 50, 0.06)',
|
||||||
|
border: '2px solid #CD7F32',
|
||||||
|
minHeight: '170px',
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
crown: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-18px',
|
||||||
|
fontSize: '24px',
|
||||||
|
filter: 'drop-shadow(0 0 8px rgba(255,215,0,0.8))',
|
||||||
|
},
|
||||||
|
medalBadge: {
|
||||||
|
fontSize: '26px',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
placeLabel: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#8b949e',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
avatarCircle: {
|
||||||
|
width: '42px',
|
||||||
|
height: '42px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#ffffff',
|
||||||
|
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
podiumBotName: {
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '14px',
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '150px',
|
||||||
|
},
|
||||||
|
podiumScore: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 800,
|
||||||
|
marginTop: '4px',
|
||||||
|
},
|
||||||
|
podiumDetails: {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#8b949e',
|
||||||
|
marginTop: '2px',
|
||||||
|
},
|
||||||
|
leaderBadge: {
|
||||||
|
backgroundColor: '#FFD700',
|
||||||
|
color: '#0d1117',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
marginTop: '6px',
|
||||||
|
},
|
||||||
|
rankingsSection: {
|
||||||
|
backgroundColor: '#0d1117',
|
||||||
|
border: '1px solid #30363d',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '16px',
|
||||||
|
},
|
||||||
|
rankingsTitle: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: '0 0 12px 0',
|
||||||
|
color: '#c9d1d9',
|
||||||
|
},
|
||||||
|
tableWrapper: {
|
||||||
|
overflowX: 'auto',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
fontSize: '13px',
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '8px 12px',
|
||||||
|
color: '#8b949e',
|
||||||
|
borderBottom: '1px solid #21262d',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
tr: {
|
||||||
|
borderBottom: '1px solid #161b22',
|
||||||
|
},
|
||||||
|
goldRow: {
|
||||||
|
backgroundColor: 'rgba(255, 215, 0, 0.08)',
|
||||||
|
},
|
||||||
|
silverRow: {
|
||||||
|
backgroundColor: 'rgba(192, 192, 192, 0.05)',
|
||||||
|
},
|
||||||
|
bronzeRow: {
|
||||||
|
backgroundColor: 'rgba(205, 127, 50, 0.05)',
|
||||||
|
},
|
||||||
|
tdRank: {
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
tdBot: {
|
||||||
|
padding: '10px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
},
|
||||||
|
botColorIndicator: {
|
||||||
|
width: '14px',
|
||||||
|
height: '14px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'inline-block',
|
||||||
|
},
|
||||||
|
botNameText: {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
inlineLeaderTag: {
|
||||||
|
backgroundColor: '#FFD700',
|
||||||
|
color: '#000',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1px 6px',
|
||||||
|
},
|
||||||
|
tdRole: {
|
||||||
|
padding: '10px 12px',
|
||||||
|
color: '#8b949e',
|
||||||
|
},
|
||||||
|
tdStrength: {
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
tdVisited: {
|
||||||
|
padding: '10px 12px',
|
||||||
|
color: '#8b949e',
|
||||||
|
},
|
||||||
|
tdScore: {
|
||||||
|
padding: '10px 12px',
|
||||||
|
},
|
||||||
|
scorePill: {
|
||||||
|
backgroundColor: '#238636',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '12px',
|
||||||
|
marginTop: '6px',
|
||||||
|
},
|
||||||
|
resetButton: {
|
||||||
|
backgroundColor: '#238636',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 18px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
backgroundColor: '#21262d',
|
||||||
|
color: '#c9d1d9',
|
||||||
|
border: '1px solid #30363d',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 18px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
AvailableMovesResponse,
|
AvailableMovesResponse,
|
||||||
BattleResult,
|
BattleResult,
|
||||||
BoardState,
|
BoardState,
|
||||||
|
GameConclusion,
|
||||||
MoveResponse,
|
MoveResponse,
|
||||||
Party,
|
Party,
|
||||||
PartyDefeatResult,
|
PartyDefeatResult,
|
||||||
|
|
@ -30,6 +31,7 @@ const INITIAL_BOARD: BoardState = {
|
||||||
turn_number: 0,
|
turn_number: 0,
|
||||||
turn_order: [],
|
turn_order: [],
|
||||||
},
|
},
|
||||||
|
conclusion: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useGameSocket() {
|
export function useGameSocket() {
|
||||||
|
|
@ -40,6 +42,7 @@ export function useGameSocket() {
|
||||||
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
|
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
|
||||||
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
|
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
|
||||||
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
|
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
|
||||||
|
const [showScoreboard, setShowScoreboard] = useState(false);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
@ -53,7 +56,11 @@ export function useGameSocket() {
|
||||||
setBoardState((prev) => ({
|
setBoardState((prev) => ({
|
||||||
...data,
|
...data,
|
||||||
parties: data.parties || prev.parties || [],
|
parties: data.parties || prev.parties || [],
|
||||||
|
conclusion: data.conclusion || null,
|
||||||
}));
|
}));
|
||||||
|
if (data.conclusion && data.conclusion.concluded) {
|
||||||
|
setShowScoreboard(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch board state:', err);
|
console.error('Failed to fetch board state:', err);
|
||||||
|
|
@ -98,7 +105,11 @@ export function useGameSocket() {
|
||||||
setBoardState({
|
setBoardState({
|
||||||
...data.state,
|
...data.state,
|
||||||
parties: data.state.parties || [],
|
parties: data.state.parties || [],
|
||||||
|
conclusion: data.state.conclusion || null,
|
||||||
});
|
});
|
||||||
|
if (data.state.conclusion && data.state.conclusion.concluded) {
|
||||||
|
setShowScoreboard(true);
|
||||||
|
}
|
||||||
} else if (data.event === 'player_joined') {
|
} else if (data.event === 'player_joined') {
|
||||||
setBoardState((prev) => {
|
setBoardState((prev) => {
|
||||||
const exists = prev.players.some((p) => p.id === data.player.id);
|
const exists = prev.players.some((p) => p.id === data.player.id);
|
||||||
|
|
@ -163,6 +174,20 @@ export function useGameSocket() {
|
||||||
const b: BattleResult = data.battle;
|
const b: BattleResult = data.battle;
|
||||||
setActiveBattle(b);
|
setActiveBattle(b);
|
||||||
setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
|
setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
|
||||||
|
} else if (data.event === 'game_concluded') {
|
||||||
|
const conc: GameConclusion = data.conclusion;
|
||||||
|
setBoardState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
players: data.players ?? prev.players,
|
||||||
|
parties: data.parties ?? prev.parties,
|
||||||
|
turn: data.turn ?? prev.turn,
|
||||||
|
conclusion: conc,
|
||||||
|
}));
|
||||||
|
setIsAutoPlaying(false);
|
||||||
|
setShowScoreboard(true);
|
||||||
|
setLastEventMessage(
|
||||||
|
`🏆 VICTORY! Game concluded! All bots united under "${conc.winning_party_name}" led by ${conc.winning_leader_name}!`
|
||||||
|
);
|
||||||
} else if (data.event === 'turn_passed') {
|
} else if (data.event === 'turn_passed') {
|
||||||
setBoardState((prev) => ({
|
setBoardState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -187,10 +212,12 @@ export function useGameSocket() {
|
||||||
players: [],
|
players: [],
|
||||||
parties: [],
|
parties: [],
|
||||||
turn: data.turn ?? INITIAL_BOARD.turn,
|
turn: data.turn ?? INITIAL_BOARD.turn,
|
||||||
|
conclusion: null,
|
||||||
}));
|
}));
|
||||||
setSelectedPlayer(null);
|
setSelectedPlayer(null);
|
||||||
setAvailableMoves(null);
|
setAvailableMoves(null);
|
||||||
setActiveBattle(null);
|
setActiveBattle(null);
|
||||||
|
setShowScoreboard(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error parsing WebSocket message:', err);
|
console.error('Error parsing WebSocket message:', err);
|
||||||
|
|
@ -324,6 +351,10 @@ export function useGameSocket() {
|
||||||
if (data.battle_triggered && data.battle_result) {
|
if (data.battle_triggered && data.battle_result) {
|
||||||
setActiveBattle(data.battle_result);
|
setActiveBattle(data.battle_result);
|
||||||
}
|
}
|
||||||
|
if (data.game_concluded && data.game_concluded.concluded) {
|
||||||
|
setIsAutoPlaying(false);
|
||||||
|
setShowScoreboard(true);
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -346,6 +377,7 @@ export function useGameSocket() {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Failed to reset board');
|
throw new Error('Failed to reset board');
|
||||||
}
|
}
|
||||||
|
setShowScoreboard(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step active bot turn according to its explicit autonomous goal:
|
// Step active bot turn according to its explicit autonomous goal:
|
||||||
|
|
@ -369,6 +401,11 @@ export function useGameSocket() {
|
||||||
} else if (data.move_result?.battle_result) {
|
} else if (data.move_result?.battle_result) {
|
||||||
setActiveBattle(data.move_result.battle_result);
|
setActiveBattle(data.move_result.battle_result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.game_concluded && data.game_concluded.concluded) {
|
||||||
|
setIsAutoPlaying(false);
|
||||||
|
setShowScoreboard(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Bot AI step error:', err);
|
console.warn('Bot AI step error:', err);
|
||||||
|
|
@ -404,6 +441,8 @@ export function useGameSocket() {
|
||||||
lastEventMessage,
|
lastEventMessage,
|
||||||
activeBattle,
|
activeBattle,
|
||||||
setActiveBattle,
|
setActiveBattle,
|
||||||
|
showScoreboard,
|
||||||
|
setShowScoreboard,
|
||||||
registerPlayer,
|
registerPlayer,
|
||||||
removePlayer,
|
removePlayer,
|
||||||
formParty,
|
formParty,
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,23 @@ export interface TurnInfo {
|
||||||
turn_order: string[];
|
turn_order: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameConclusion {
|
||||||
|
concluded: boolean;
|
||||||
|
winning_party_id?: string | null;
|
||||||
|
winning_party_name?: string | null;
|
||||||
|
winning_leader_id?: string | null;
|
||||||
|
winning_leader_name?: string | null;
|
||||||
|
total_bots?: number;
|
||||||
|
rankings?: Player[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface BoardState {
|
export interface BoardState {
|
||||||
config: GridConfig;
|
config: GridConfig;
|
||||||
player_count: number;
|
player_count: number;
|
||||||
players: Player[];
|
players: Player[];
|
||||||
parties: Party[];
|
parties: Party[];
|
||||||
turn: TurnInfo;
|
turn: TurnInfo;
|
||||||
|
conclusion?: GameConclusion | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MoveCheckResult {
|
export interface MoveCheckResult {
|
||||||
|
|
@ -116,6 +127,7 @@ export interface MoveResponse {
|
||||||
formed_party?: Party | null;
|
formed_party?: Party | null;
|
||||||
battle_triggered?: boolean;
|
battle_triggered?: boolean;
|
||||||
battle_result?: BattleResult | null;
|
battle_result?: BattleResult | null;
|
||||||
|
game_concluded?: GameConclusion | null;
|
||||||
turn: TurnInfo;
|
turn: TurnInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +140,7 @@ export interface AiStepResponse {
|
||||||
move_result?: MoveResponse | null;
|
move_result?: MoveResponse | null;
|
||||||
formed_party?: Party | null;
|
formed_party?: Party | null;
|
||||||
battle_result?: BattleResult | null;
|
battle_result?: BattleResult | null;
|
||||||
|
game_concluded?: GameConclusion | null;
|
||||||
turn: TurnInfo;
|
turn: TurnInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue