scoring, game win, noise

This commit is contained in:
Isaac Johnson 2026-09-05 18:50:27 -05:00
parent df75f43214
commit d2f2eed3f7
10 changed files with 1459 additions and 460 deletions

View File

@ -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({ await manager.broadcast({
"event": "player_joined", "event": "player_registered",
"player": player.model_dump(), "player": player.model_dump(),
"player_count": board_state.player_count, "total_players": len(board_state.players),
"turn": board_state.turn.model_dump(), "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)
if party:
board_state = await game_engine.get_board_state() board_state = await game_engine.get_board_state()
if party:
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.",
)

View File

@ -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,9 +290,28 @@ 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:
# - 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) can_battle = is_enemy and bool(player.party_id and other.party_id)
targets.append( targets.append(
@ -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]
my_members = [self.players[mid] for mid in my_party.member_ids if mid in self.players]
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: if other.id == player.id:
continue continue
if self._are_adjacent(player, other): 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:
other_party.member_ids.append(player.id)
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 return party
return None
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,30 +966,11 @@ 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()
return AiStepResponse(
action_taken="formed_party",
player_id=player.id,
player_name=player.name,
bot_goal=bot_goal,
direction=None,
move_result=None,
formed_party=formed,
battle_result=None,
turn=self._get_turn_info(),
)
# 2. If partied leader and already adjacent to an opposing party, fight immediately!
if player.party_id and player.is_party_leader and player.party_id in self.parties:
my_party = self.parties[player.party_id]
opposing = self._find_adjacent_opposing_party(my_party)
if opposing:
battle_res = self._resolve_3bout_battle_internal(my_party, opposing)
self._advance_turn() self._advance_turn()
conclusion = self._check_game_concluded()
return AiStepResponse( return AiStepResponse(
action_taken="battled", action_taken="battled",
player_id=player.id, player_id=player.id,
@ -897,10 +980,27 @@ class GameEngine:
move_result=None, move_result=None,
formed_party=None, formed_party=None,
battle_result=battle_res, battle_result=battle_res,
game_concluded=conclusion if conclusion.concluded else None,
turn=self._get_turn_info(), turn=self._get_turn_info(),
) )
# 3. Otherwise: navigate towards target using radar & memory if formed_party:
self._advance_turn()
conclusion = self._check_game_concluded()
return AiStepResponse(
action_taken="formed_party",
player_id=player.id,
player_name=player.name,
bot_goal=bot_goal,
direction=None,
move_result=None,
formed_party=formed_party,
battle_result=None,
game_concluded=conclusion if conclusion.concluded else None,
turn=self._get_turn_info(),
)
# 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,9 +1196,35 @@ 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 # Winning leader receives +2 points, and the rest of the winning party receives +1 point
for mid in list(winner_party.member_ids):
member = self.players.get(mid)
if member:
if mid == winner_party.leader_id:
member.score += 2
else:
member.score += 1
killed_leader_id = defeated_party.leader_id killed_leader_id = defeated_party.leader_id
killed_leader = self.players.get(killed_leader_id) killed_leader = self.players.get(killed_leader_id)
absorbed_members = []
if len(defeated_party.member_ids) == 1:
# Defeated party was a single refusing/solo bot:
# The party conquered the solo bot: it loses 1 point (-1 pt) and is absorbed into the winning party!
if killed_leader:
killed_leader.score -= 1
killed_leader.party_id = winner_party.id
killed_leader.is_party_leader = False
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: if killed_leader:
killed_leader.score -= 1 killed_leader.score -= 1
killed_leader.party_id = None killed_leader.party_id = None
@ -1127,19 +1237,7 @@ class GameEngine:
else: else:
respawn_pos = {"x": 0, "y": 0} respawn_pos = {"x": 0, "y": 0}
# Rest of losing party loses 0 points (scores remain unchanged) # Remainder of defeated party joins the winning party (scores remain unchanged)
# Winning leader receives +2 points, and the rest of the winning party receives +1 point
for mid in list(winner_party.member_ids):
member = self.players.get(mid)
if member:
if mid == winner_party.leader_id:
member.score += 2
else:
member.score += 1
# Remainder of defeated party joins the winning party
absorbed_members = []
for mid in list(defeated_party.member_ids): for mid in list(defeated_party.member_ids):
if mid != killed_leader_id: if mid != killed_leader_id:
m = self.players.get(mid) m = self.players.get(mid)

View File

@ -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

View File

@ -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

View File

@ -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>
); );
} }

View File

@ -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>
{battle.absorbed_members.length > 0 && !battle.absorbed_members.includes(battle.killed_leader_id) && (
<div className="text-slate-300 leading-relaxed"> <div className="text-slate-300 leading-relaxed">
<strong className="text-emerald-400">{battle.absorbed_members.length} surviving bot(s)</strong> from the <strong className="text-emerald-400">{battle.absorbed_members.length} surviving bot(s)</strong> from the
defeated party surrendered and <strong className="text-sky-300">joined {battle.winner_party_name}</strong>! defeated party surrendered and <strong className="text-sky-300">joined {battle.winner_party_name}</strong>!
</div> </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>

View File

@ -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"

View File

@ -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',
},
};

View File

@ -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,

View File

@ -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;
} }