scoring, game win, noise
This commit is contained in:
parent
df75f43214
commit
d2f2eed3f7
|
|
@ -11,6 +11,7 @@ from app.models import (
|
|||
BoardState,
|
||||
BotMemoryResponse,
|
||||
BotRadarResponse,
|
||||
GameConclusion,
|
||||
MoveCheckResult,
|
||||
MoveRequest,
|
||||
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(
|
||||
"/players",
|
||||
response_model=Player,
|
||||
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"],
|
||||
)
|
||||
async def register_player(player_in: PlayerCreate):
|
||||
try:
|
||||
player = await game_engine.register_player(player_in)
|
||||
board_state = await game_engine.get_board_state()
|
||||
|
||||
await manager.broadcast({
|
||||
"event": "player_joined",
|
||||
"event": "player_registered",
|
||||
"player": player.model_dump(),
|
||||
"player_count": board_state.player_count,
|
||||
"total_players": len(board_state.players),
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
return player
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/players",
|
||||
response_model=List[Player],
|
||||
summary="List all registered players and their scores/status",
|
||||
summary="List all registered players/bots on the board",
|
||||
tags=["Players"],
|
||||
)
|
||||
async def list_players():
|
||||
|
|
@ -81,7 +108,7 @@ async def list_players():
|
|||
@router.get(
|
||||
"/players/{player_id}",
|
||||
response_model=Player,
|
||||
summary="Get details of a specific player by ID",
|
||||
summary="Get details of a specific player/bot",
|
||||
tags=["Players"],
|
||||
)
|
||||
async def get_player(player_id: str):
|
||||
|
|
@ -97,7 +124,7 @@ async def get_player(player_id: str):
|
|||
@router.delete(
|
||||
"/players/{player_id}",
|
||||
response_model=ApiResponse,
|
||||
summary="Remove a player from the game board",
|
||||
summary="Remove a player/bot from the board",
|
||||
tags=["Players"],
|
||||
)
|
||||
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,
|
||||
detail=f"Player '{player_id}' not found",
|
||||
)
|
||||
|
||||
board_state = await game_engine.get_board_state()
|
||||
await manager.broadcast({
|
||||
"event": "player_left",
|
||||
"event": "player_removed",
|
||||
"player_id": player_id,
|
||||
"player_count": board_state.player_count,
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"total_players": len(board_state.players),
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
return ApiResponse(
|
||||
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(
|
||||
"/players/{player_id}/memory",
|
||||
response_model=BotMemoryResponse,
|
||||
summary="Get bot's location memory history and check if location was visited before",
|
||||
tags=["Intelligence"],
|
||||
summary="Get bot memory: coordinates visited and whether a specific location has been visited before",
|
||||
tags=["Bot AI & Awareness"],
|
||||
)
|
||||
async def get_bot_memory(
|
||||
player_id: str,
|
||||
check_x: Optional[int] = Query(None, description="Optional X coordinate to check if bot has visited before"),
|
||||
check_y: Optional[int] = Query(None, description="Optional Y 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"),
|
||||
):
|
||||
try:
|
||||
return await game_engine.get_bot_memory(player_id, check_x, check_y)
|
||||
|
|
@ -150,8 +174,8 @@ async def get_bot_memory(
|
|||
@router.get(
|
||||
"/players/{player_id}/radar",
|
||||
response_model=BotRadarResponse,
|
||||
summary="Scan surroundings for nearby bots, determine allies/enemies, and recommend action/direction",
|
||||
tags=["Intelligence"],
|
||||
summary="Scan radar for other bots/parties, calculating distance, ally/enemy status, recruit/battle actions",
|
||||
tags=["Bot AI & Awareness"],
|
||||
)
|
||||
async def get_bot_radar(player_id: str):
|
||||
try:
|
||||
|
|
@ -164,13 +188,13 @@ async def get_bot_radar(player_id: str):
|
|||
|
||||
|
||||
# ==========================================
|
||||
# Party Management & 3-Bout Battles
|
||||
# Party & Battle Endpoints
|
||||
# ==========================================
|
||||
|
||||
@router.get(
|
||||
"/parties",
|
||||
response_model=List[Party],
|
||||
summary="List all active parties",
|
||||
summary="List all active parties and their members",
|
||||
tags=["Parties"],
|
||||
)
|
||||
async def list_parties():
|
||||
|
|
@ -216,6 +240,15 @@ async def form_party(form_req: PartyDirectForm):
|
|||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
if board_state.conclusion and board_state.conclusion.concluded:
|
||||
await manager.broadcast({
|
||||
"event": "game_concluded",
|
||||
"conclusion": board_state.conclusion.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
return party
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
|
|
@ -250,17 +283,26 @@ async def invite_to_party(invite_req: PartyInviteCreate):
|
|||
summary="Accept or reject a party invitation",
|
||||
tags=["Parties"],
|
||||
)
|
||||
async def respond_to_invite(invite_id: str, resp: PartyInviteResponse):
|
||||
async def respond_to_invite(invite_id: str, response: PartyInviteResponse):
|
||||
try:
|
||||
party = await game_engine.respond_to_invite(invite_id, resp.accept)
|
||||
if party:
|
||||
party = await game_engine.respond_to_invite(invite_id, response.accept)
|
||||
board_state = await game_engine.get_board_state()
|
||||
|
||||
if party:
|
||||
await manager.broadcast({
|
||||
"event": "party_updated",
|
||||
"event": "party_formed",
|
||||
"party": party.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
if board_state.conclusion and board_state.conclusion.concluded:
|
||||
await manager.broadcast({
|
||||
"event": "game_concluded",
|
||||
"conclusion": board_state.conclusion.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
return party
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
|
|
@ -287,6 +329,15 @@ async def defeat_party(party_id: str):
|
|||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
if board_state.conclusion and board_state.conclusion.concluded:
|
||||
await manager.broadcast({
|
||||
"event": "game_concluded",
|
||||
"conclusion": board_state.conclusion.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
return result
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
|
|
@ -311,6 +362,15 @@ async def fight_battle(battle_req: BattleRequest):
|
|||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
if board_state.conclusion and board_state.conclusion.concluded:
|
||||
await manager.broadcast({
|
||||
"event": "game_concluded",
|
||||
"conclusion": board_state.conclusion.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
return result
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
|
|
@ -406,6 +466,16 @@ async def move_player(player_id: str, move_req: MoveRequest):
|
|||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
if result.game_concluded or (board_state.conclusion and board_state.conclusion.concluded):
|
||||
conclusion_obj = result.game_concluded or board_state.conclusion
|
||||
await manager.broadcast({
|
||||
"event": "game_concluded",
|
||||
"conclusion": conclusion_obj.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
return result
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
|
|
@ -462,6 +532,21 @@ async def step_bot_ai(player_id: str):
|
|||
"players": [p.model_dump() for p in board_state.players],
|
||||
"turn": result.move_result.turn.model_dump(),
|
||||
})
|
||||
if result.move_result.party_formed_triggered and result.move_result.formed_party:
|
||||
await manager.broadcast({
|
||||
"event": "party_formed",
|
||||
"party": result.move_result.formed_party.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
if result.move_result.battle_triggered and result.move_result.battle_result:
|
||||
await manager.broadcast({
|
||||
"event": "battle_resolved",
|
||||
"battle": result.move_result.battle_result.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
else:
|
||||
await manager.broadcast({
|
||||
"event": "turn_passed",
|
||||
|
|
@ -469,6 +554,16 @@ async def step_bot_ai(player_id: str):
|
|||
"turn": result.turn.model_dump(),
|
||||
})
|
||||
|
||||
if result.game_concluded or (board_state.conclusion and board_state.conclusion.concluded):
|
||||
conclusion_obj = result.game_concluded or board_state.conclusion
|
||||
await manager.broadcast({
|
||||
"event": "game_concluded",
|
||||
"conclusion": conclusion_obj.model_dump(),
|
||||
"players": [p.model_dump() for p in board_state.players],
|
||||
"parties": [p.model_dump() for p in board_state.parties],
|
||||
"turn": board_state.turn.model_dump(),
|
||||
})
|
||||
|
||||
return result
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
|
|
@ -517,43 +612,9 @@ async def pass_turn(player_id: str):
|
|||
@router.get(
|
||||
"/turn",
|
||||
response_model=TurnInfo,
|
||||
summary="Get current turn details and round information",
|
||||
tags=["Turn"],
|
||||
summary="Get current turn details, round number, active player, and turn order",
|
||||
tags=["Movement"],
|
||||
)
|
||||
async def get_turn():
|
||||
board = await game_engine.get_board_state()
|
||||
return board.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.",
|
||||
)
|
||||
board_state = await game_engine.get_board_state()
|
||||
return board_state.turn
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from app.models import (
|
|||
BotMemoryResponse,
|
||||
BotRadarResponse,
|
||||
DIRECTION_OFFSETS,
|
||||
GameConclusion,
|
||||
MoveCheckResult,
|
||||
MoveResponse,
|
||||
Party,
|
||||
|
|
@ -128,6 +129,33 @@ class GameEngine:
|
|||
total = sum(self.players[m].strength for m in party.member_ids if m in self.players)
|
||||
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 with self._lock:
|
||||
player_id = f"bot_{uuid.uuid4().hex[:8]}"
|
||||
|
|
@ -217,10 +245,10 @@ class GameEngine:
|
|||
players=players_list,
|
||||
parties=parties_list,
|
||||
turn=self._get_turn_info(),
|
||||
conclusion=self._check_game_concluded(),
|
||||
)
|
||||
|
||||
# ==========================================
|
||||
# Bot Memory & Radar Awareness
|
||||
# ==========================================\n # Bot Memory & Radar Awareness
|
||||
# ==========================================
|
||||
|
||||
async def get_bot_memory(
|
||||
|
|
@ -262,9 +290,28 @@ class GameEngine:
|
|||
is_enemy = not is_ally
|
||||
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 (
|
||||
player.party_id and player.is_party_leader and other.party_id is None
|
||||
)
|
||||
# Recruitment vs Battle rules:
|
||||
# 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)
|
||||
|
||||
targets.append(
|
||||
|
|
@ -288,16 +335,14 @@ class GameEngine:
|
|||
targets.sort(key=lambda t: t.distance)
|
||||
|
||||
# Choose primary target based on goal:
|
||||
# - If unpartied: seek closest bot to form party with
|
||||
# - If partied: seek closest enemy party to battle
|
||||
# - If unpartied: seek closest bot/party to join/form party with
|
||||
# - If partied: seek closest enemy party or refusing solo bot to battle
|
||||
primary_targets = []
|
||||
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]
|
||||
primary_targets = recruit_targets if recruit_targets else targets
|
||||
else:
|
||||
# Partied: seek enemy parties
|
||||
battle_targets = [t for t in targets if t.is_enemy and t.party_id]
|
||||
battle_targets = [t for t in targets if t.is_enemy and (t.party_id or t.can_battle)]
|
||||
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)
|
||||
|
|
@ -320,7 +365,7 @@ class GameEngine:
|
|||
break
|
||||
|
||||
if nearest.distance <= 1:
|
||||
if bot_goal == "form_party" or nearest.can_recruit:
|
||||
if nearest.can_recruit:
|
||||
rec_act = "form_party"
|
||||
else:
|
||||
rec_act = "engage_battle"
|
||||
|
|
@ -341,8 +386,7 @@ class GameEngine:
|
|||
recommended_action=rec_act,
|
||||
)
|
||||
|
||||
# ==========================================
|
||||
# Party Logic & Leadership Negotiation
|
||||
# ==========================================\n # Party Logic & Leadership Negotiation
|
||||
# ==========================================
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -367,14 +411,6 @@ class GameEngine:
|
|||
return len(visited) == len(member_players)
|
||||
|
||||
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:
|
||||
return bot_a.id
|
||||
elif bot_b.strength > bot_a.strength:
|
||||
|
|
@ -530,8 +566,7 @@ class GameEngine:
|
|||
async with self._lock:
|
||||
return self.parties.get(party_id)
|
||||
|
||||
# ==========================================
|
||||
# Movement Checking & Execution
|
||||
# ==========================================\n # Movement Checking & Execution
|
||||
# ==========================================
|
||||
|
||||
def _check_move_internal(
|
||||
|
|
@ -587,7 +622,12 @@ class GameEngine:
|
|||
target_x = player.x + dx
|
||||
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(
|
||||
direction=direction_name,
|
||||
dx=dx,
|
||||
|
|
@ -595,7 +635,7 @@ class GameEngine:
|
|||
target_x=target_x,
|
||||
target_y=target_y,
|
||||
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))
|
||||
|
|
@ -607,7 +647,7 @@ class GameEngine:
|
|||
target_x=target_x,
|
||||
target_y=target_y,
|
||||
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(
|
||||
|
|
@ -689,19 +729,78 @@ class GameEngine:
|
|||
moves=moves,
|
||||
)
|
||||
|
||||
def _check_and_auto_form_party(self, player: Player) -> Optional[Party]:
|
||||
"""When an unpartied bot sees it is adjacent to another bot, negotiate and form a party:
|
||||
|
||||
- Lower strength insists on joining higher strength.
|
||||
- Higher strength insists on being leader.
|
||||
def _check_adjacent_encounter(
|
||||
self, player: Player
|
||||
) -> Tuple[Optional[Party], Optional[BattleResult]]:
|
||||
"""Evaluates immediate adjacent encounters for party formation or battle:
|
||||
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:
|
||||
return None
|
||||
# Case A: Player is in a party
|
||||
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:
|
||||
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:
|
||||
leader_id = self._negotiate_party_leader_id(player, other)
|
||||
leader = self.players[leader_id]
|
||||
|
|
@ -719,25 +818,46 @@ class GameEngine:
|
|||
other.party_id = party_id
|
||||
other.is_party_leader = other.id == leader_id
|
||||
self.parties[party_id] = party
|
||||
return party
|
||||
elif other.is_party_leader and other.party_id in self.parties:
|
||||
party = self.parties[other.party_id]
|
||||
if player.strength > party.total_strength:
|
||||
# Player is stronger than entire party: player insists on leading
|
||||
party.member_ids.append(player.id)
|
||||
player.party_id = party.id
|
||||
player.is_party_leader = True
|
||||
other.is_party_leader = False
|
||||
party.leader_id = player.id
|
||||
party.leader_name = player.name
|
||||
else:
|
||||
# Party is equal or stronger: player joins under existing leader
|
||||
party.member_ids.append(player.id)
|
||||
player.party_id = party.id
|
||||
return party, None
|
||||
|
||||
# Solo Bot meets a Party Member
|
||||
if other.party_id and other.party_id in self.parties:
|
||||
other_party = self.parties[other.party_id]
|
||||
leader = self.players.get(other_party.leader_id)
|
||||
leader_strength = leader.strength if leader else 1
|
||||
|
||||
if player.strength <= leader_strength:
|
||||
# Player willingly joins party under equal/stronger leader
|
||||
player.party_id = other_party.id
|
||||
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 None
|
||||
|
||||
async def move_player(
|
||||
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}
|
||||
|
||||
# Check for party formation if solo bot moved next to another bot
|
||||
formed_party = None
|
||||
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
|
||||
# Check for adjacent interactions (party formation or battle)
|
||||
formed_party, battle_result = self._check_adjacent_encounter(player)
|
||||
|
||||
self._advance_turn()
|
||||
turn_info = self._get_turn_info()
|
||||
conclusion = self._check_game_concluded()
|
||||
|
||||
return MoveResponse(
|
||||
success=True,
|
||||
|
|
@ -818,8 +926,9 @@ class GameEngine:
|
|||
new_position=new_pos,
|
||||
party_formed_triggered=bool(formed_party),
|
||||
formed_party=formed_party,
|
||||
battle_triggered=battle_triggered,
|
||||
battle_triggered=bool(battle_result),
|
||||
battle_result=battle_result,
|
||||
game_concluded=conclusion if conclusion.concluded else None,
|
||||
turn=turn_info,
|
||||
)
|
||||
|
||||
|
|
@ -840,17 +949,10 @@ class GameEngine:
|
|||
self._advance_turn()
|
||||
return self._get_turn_info()
|
||||
|
||||
# ==========================================
|
||||
# Autonomous Goal Step (AI Logic)
|
||||
# ==========================================\n # Autonomous Goal Step (AI Logic)
|
||||
# ==========================================
|
||||
|
||||
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:
|
||||
player = self.players.get(player_id)
|
||||
if not player:
|
||||
|
|
@ -864,30 +966,11 @@ class GameEngine:
|
|||
|
||||
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!
|
||||
if not player.party_id:
|
||||
formed = self._check_and_auto_form_party(player)
|
||||
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)
|
||||
# 1. Check for immediate adjacent encounter before moving
|
||||
formed_party, battle_res = self._check_adjacent_encounter(player)
|
||||
if battle_res:
|
||||
self._advance_turn()
|
||||
conclusion = self._check_game_concluded()
|
||||
return AiStepResponse(
|
||||
action_taken="battled",
|
||||
player_id=player.id,
|
||||
|
|
@ -897,10 +980,27 @@ class GameEngine:
|
|||
move_result=None,
|
||||
formed_party=None,
|
||||
battle_result=battle_res,
|
||||
game_concluded=conclusion if conclusion.concluded else None,
|
||||
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()
|
||||
moves_map: Dict[str, MoveCheckResult] = {}
|
||||
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]
|
||||
if not available_dirs:
|
||||
# No move available, pass turn
|
||||
self._advance_turn()
|
||||
conclusion = self._check_game_concluded()
|
||||
return AiStepResponse(
|
||||
action_taken="passed",
|
||||
player_id=player.id,
|
||||
|
|
@ -919,6 +1019,7 @@ class GameEngine:
|
|||
move_result=None,
|
||||
formed_party=None,
|
||||
battle_result=None,
|
||||
game_concluded=conclusion if conclusion.concluded else None,
|
||||
turn=self._get_turn_info(),
|
||||
)
|
||||
|
||||
|
|
@ -934,9 +1035,6 @@ class GameEngine:
|
|||
|
||||
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":
|
||||
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)
|
||||
|
|
@ -944,9 +1042,6 @@ class GameEngine:
|
|||
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)
|
||||
|
||||
# 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}
|
||||
best_dir = available_dirs[0]
|
||||
best_score = float("-inf")
|
||||
|
|
@ -960,17 +1055,15 @@ class GameEngine:
|
|||
if target_bot:
|
||||
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))
|
||||
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:
|
||||
score += 2.0 # Exploration bonus
|
||||
score += 2.0
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_dir = dir_name
|
||||
|
||||
# Execute move with chosen direction
|
||||
dx, dy = DIRECTION_OFFSETS[best_dir]
|
||||
prev_pos = {"x": player.x, "y": player.y}
|
||||
affected_players: List[Player] = []
|
||||
|
|
@ -992,22 +1085,12 @@ class GameEngine:
|
|||
|
||||
new_pos = {"x": player.x, "y": player.y}
|
||||
|
||||
# Check party formation / battle engagement after move
|
||||
formed_party = None
|
||||
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
|
||||
# Check for adjacent interactions after movement
|
||||
formed_party, battle_result = self._check_adjacent_encounter(player)
|
||||
|
||||
self._advance_turn()
|
||||
turn_info = self._get_turn_info()
|
||||
conclusion = self._check_game_concluded()
|
||||
|
||||
move_res = MoveResponse(
|
||||
success=True,
|
||||
|
|
@ -1019,12 +1102,13 @@ class GameEngine:
|
|||
new_position=new_pos,
|
||||
party_formed_triggered=bool(formed_party),
|
||||
formed_party=formed_party,
|
||||
battle_triggered=battle_triggered,
|
||||
battle_triggered=bool(battle_result),
|
||||
battle_result=battle_result,
|
||||
game_concluded=conclusion if conclusion.concluded else None,
|
||||
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(
|
||||
action_taken=action_taken,
|
||||
player_id=player.id,
|
||||
|
|
@ -1034,11 +1118,11 @@ class GameEngine:
|
|||
move_result=move_res,
|
||||
formed_party=formed_party,
|
||||
battle_result=battle_result,
|
||||
game_concluded=conclusion if conclusion.concluded else None,
|
||||
turn=turn_info,
|
||||
)
|
||||
|
||||
# ==========================================
|
||||
# 3-Bout D20 Battle Mechanics
|
||||
# ==========================================\n # 3-Bout D20 Battle Mechanics
|
||||
# ==========================================
|
||||
|
||||
def _find_adjacent_opposing_party(self, party: Party) -> Optional[Party]:
|
||||
|
|
@ -1112,9 +1196,35 @@ class GameEngine:
|
|||
else:
|
||||
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 = 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:
|
||||
killed_leader.score -= 1
|
||||
killed_leader.party_id = None
|
||||
|
|
@ -1127,19 +1237,7 @@ class GameEngine:
|
|||
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
|
||||
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 = []
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -289,12 +289,27 @@ class TurnInfo(BaseModel):
|
|||
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):
|
||||
config: BoardConfig
|
||||
player_count: int
|
||||
players: List[Player]
|
||||
parties: List[Party] = []
|
||||
turn: TurnInfo
|
||||
conclusion: Optional[GameConclusion] = None
|
||||
|
||||
|
||||
class MoveResponse(BaseModel):
|
||||
|
|
@ -309,6 +324,7 @@ class MoveResponse(BaseModel):
|
|||
formed_party: Optional[Party] = None
|
||||
battle_triggered: bool = False
|
||||
battle_result: Optional[BattleResult] = None
|
||||
game_concluded: Optional[GameConclusion] = None
|
||||
turn: TurnInfo
|
||||
|
||||
|
||||
|
|
@ -321,6 +337,7 @@ class AiStepResponse(BaseModel):
|
|||
move_result: Optional[MoveResponse] = None
|
||||
formed_party: Optional[Party] = None
|
||||
battle_result: Optional[BattleResult] = None
|
||||
game_concluded: Optional[GameConclusion] = None
|
||||
turn: TurnInfo
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,250 +1,294 @@
|
|||
import pytest
|
||||
import asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
from app.game import game_engine
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_board_state():
|
||||
import asyncio
|
||||
asyncio.run(game_engine.reset())
|
||||
def reset_game_state():
|
||||
client = TestClient(app)
|
||||
client.post("/api/reset")
|
||||
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():
|
||||
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
|
||||
async def place_bots():
|
||||
b1 = await game_engine.get_player(p1["id"])
|
||||
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())
|
||||
# Register BotAlpha and BotBravo
|
||||
b1 = client.post("/api/players", json={"name": "BotAlpha", "color": "#FF0000", "strength": 1}).json()
|
||||
b2 = client.post("/api/players", json={"name": "BotBravo", "color": "#00FF00", "strength": 1}).json()
|
||||
|
||||
# Check memory of p1
|
||||
mem = client.get(f"/api/players/{p1['id']}/memory").json()
|
||||
assert mem["player_id"] == p1["id"]
|
||||
assert mem["current_x"] == 15
|
||||
assert mem["current_y"] == 15
|
||||
assert mem["visited_count"] == 1
|
||||
assert mem["visited_history"][0] == {"x": 15, "y": 15}
|
||||
# Move BotAlpha manually
|
||||
async def set_loc():
|
||||
p1 = await game_engine.get_player(b1["id"])
|
||||
p1.x, p1.y = 5, 5
|
||||
p1.visited_locations = [{"x": 5, "y": 5}]
|
||||
p2 = await game_engine.get_player(b2["id"])
|
||||
p2.x, p2.y = 5, 8
|
||||
asyncio.run(set_loc())
|
||||
|
||||
# Check radar
|
||||
radar = client.get(f"/api/players/{p1['id']}/radar").json()
|
||||
assert radar["player_id"] == p1["id"]
|
||||
assert len(radar["targets"]) == 1
|
||||
assert radar["targets"][0]["id"] == p2["id"]
|
||||
assert radar["targets"][0]["distance"] == 1
|
||||
# Check memory: has visited (5, 5)
|
||||
mem_res = client.get(f"/api/players/{b1['id']}/memory?check_x=5&check_y=5")
|
||||
assert mem_res.status_code == 200
|
||||
mem_data = mem_res.json()
|
||||
assert mem_data["has_visited_current"] is True
|
||||
assert mem_data["visited_count"] >= 1
|
||||
|
||||
# Move p1
|
||||
res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
|
||||
assert res_move.status_code == 200
|
||||
# Check memory: has NOT visited (20, 20)
|
||||
mem_unvis = client.get(f"/api/players/{b1['id']}/memory?check_x=20&check_y=20").json()
|
||||
assert mem_unvis["has_visited_current"] is False
|
||||
|
||||
# Memory should now have 2 visited locations
|
||||
mem2 = client.get(f"/api/players/{p1['id']}/memory").json()
|
||||
assert mem2["visited_count"] == 2
|
||||
assert mem2["visited_history"][-1] == {"x": 15, "y": 14}
|
||||
# Check radar awareness
|
||||
radar_res = client.get(f"/api/players/{b1['id']}/radar")
|
||||
assert radar_res.status_code == 200
|
||||
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():
|
||||
"""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.
|
||||
"""
|
||||
def test_unpartied_bot_seeks_party_autonomous_turn():
|
||||
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
|
||||
async def place_adjacent():
|
||||
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())
|
||||
b1 = client.post("/api/players", json={"name": "SeekerA", "color": "#00FFFF", "strength": 1}).json()
|
||||
b2 = client.post("/api/players", json={"name": "SeekerB", "color": "#FFFF00", "strength": 1}).json()
|
||||
|
||||
# Step AI for pA: see pB adjacent, negotiate party
|
||||
ai_res = client.post(f"/api/players/{pA['id']}/ai-step")
|
||||
assert ai_res.status_code == 200
|
||||
data = ai_res.json()
|
||||
assert data["action_taken"] == "formed_party"
|
||||
assert data["formed_party"] is not None
|
||||
async def setup_grid():
|
||||
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, 12
|
||||
asyncio.run(setup_grid())
|
||||
|
||||
# StrongBot insisted on being leader, WeakerBot joined stronger
|
||||
formed = data["formed_party"]
|
||||
assert formed["leader_id"] == pA["id"]
|
||||
assert formed["leader_name"] == "StrongBot"
|
||||
assert formed["total_strength"] == 12
|
||||
# Execute AI step for SeekerA (distance 2, should step down towards SeekerB)
|
||||
step_res = client.post(f"/api/players/{b1['id']}/ai-step")
|
||||
assert step_res.status_code == 200
|
||||
data = step_res.json()
|
||||
|
||||
botA_state = client.get(f"/api/players/{pA['id']}").json()
|
||||
botB_state = client.get(f"/api/players/{pB['id']}").json()
|
||||
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"]
|
||||
assert data["bot_goal"] == "form_party"
|
||||
assert data["action_taken"] in ["moved", "formed_party"]
|
||||
|
||||
|
||||
def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
||||
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)
|
||||
p4 = client.post("/api/players", json={"name": "SquadLeader2", "color": "#444444"}).json()
|
||||
p5 = client.post("/api/players", json={"name": "WingmanC", "color": "#555555"}).json()
|
||||
# Party 1: AlphaLeader + AlphaMember
|
||||
p1 = client.post("/api/players", json={"name": "AlphaLead", "color": "#111111", "strength": 5}).json()
|
||||
p2 = client.post("/api/players", json={"name": "AlphaWing", "color": "#222222", "strength": 5}).json()
|
||||
|
||||
import asyncio
|
||||
async def setup_parties():
|
||||
b1 = await game_engine.get_player(p1["id"])
|
||||
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
|
||||
# Party 2: BetaLeader + BetaMember
|
||||
p3 = client.post("/api/players", json={"name": "BetaLead", "color": "#333333", "strength": 1}).json()
|
||||
p4 = client.post("/api/players", json={"name": "BetaWing", "color": "#444444", "strength": 1}).json()
|
||||
|
||||
b4 = await game_engine.get_player(p4["id"])
|
||||
b4.x, b4.y = 21, 20 # Adjacent to b1
|
||||
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():
|
||||
# Position them adjacent
|
||||
async def setup_combat_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
|
||||
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"]],
|
||||
"leader_id": p1["id"],
|
||||
"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"})
|
||||
assert res_follow.status_code == 403
|
||||
# Form Party 2 (Strength 2)
|
||||
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"})
|
||||
assert res_move.status_code == 200
|
||||
move_data = res_move.json()
|
||||
assert move_data["party_moved"] is True
|
||||
assert len(move_data["affected_players"]) == 2
|
||||
# Fight battle between AlphaLead and BetaLead
|
||||
battle_res = client.post("/api/battles/fight", json={
|
||||
"challenger_id": p1["id"],
|
||||
"defender_id": p3["id"],
|
||||
})
|
||||
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)
|
||||
p1 = client.post("/api/players", json={"name": "Leader", "color": "#111111"}).json()
|
||||
p2 = client.post("/api/players", json={"name": "Follower", "color": "#222222"}).json()
|
||||
|
||||
import asyncio
|
||||
async def setup_near_wall():
|
||||
p1 = client.post("/api/players", json={"name": "AlphaLeader", "color": "#FFD700", "strength": 5}).json()
|
||||
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.x, b1.y = 5, 1
|
||||
b2 = await game_engine.get_player(p2["id"])
|
||||
b2.x, b2.y = 5, 0
|
||||
asyncio.run(setup_near_wall())
|
||||
b1.x, b1.y = 10, 10
|
||||
b1.score = 6 # 1st place
|
||||
|
||||
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={
|
||||
"member_ids": [p1["id"], p2["id"]],
|
||||
"leader_id": p1["id"],
|
||||
"name": "WeakSquad",
|
||||
})
|
||||
|
||||
moves = client.get(f"/api/players/{p1['id']}/available-moves").json()
|
||||
assert moves["moves"]["UP"]["available"] is False
|
||||
assert "boundary wall" in moves["moves"]["UP"]["reason"]
|
||||
# WeakLeader (or ProudSolo) takes their turn
|
||||
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()
|
||||
|
||||
# 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 { PlayerList } from './components/PlayerList';
|
||||
import { RegisterModal } from './components/RegisterModal';
|
||||
import { ScoreboardModal } from './components/ScoreboardModal';
|
||||
|
||||
const BOT_NAMES = [
|
||||
'ViperStrike',
|
||||
|
|
@ -44,6 +45,8 @@ export function App() {
|
|||
lastEventMessage,
|
||||
activeBattle,
|
||||
setActiveBattle,
|
||||
showScoreboard,
|
||||
setShowScoreboard,
|
||||
registerPlayer,
|
||||
removePlayer,
|
||||
formParty,
|
||||
|
|
@ -119,6 +122,8 @@ export function App() {
|
|||
}
|
||||
};
|
||||
|
||||
const isConcluded = Boolean(boardState.conclusion && boardState.conclusion.concluded);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
|
||||
{/* Top Navigation Bar */}
|
||||
|
|
@ -128,6 +133,8 @@ export function App() {
|
|||
onQuickSpawn={handleQuickSpawn}
|
||||
onResetBoard={handleReset}
|
||||
playerCount={boardState.player_count}
|
||||
isConcluded={isConcluded}
|
||||
onOpenScoreboard={() => setShowScoreboard(true)}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
|
|
@ -202,6 +209,19 @@ export function App() {
|
|||
battle={activeBattle}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { BattleResult } from '../types';
|
||||
|
||||
interface BattleModalProps {
|
||||
|
|
@ -7,6 +7,30 @@ interface BattleModalProps {
|
|||
}
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
|
|
@ -15,7 +39,15 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
|||
{/* 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="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">
|
||||
<span className="text-2xl">⚔️</span>
|
||||
<div>
|
||||
|
|
@ -30,6 +62,7 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
|||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-200 p-1 text-sm font-mono"
|
||||
title="Dismiss now"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
@ -99,17 +132,20 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
|||
|
||||
<div className="text-slate-300 leading-relaxed">
|
||||
• 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-emerald-400">
|
||||
({battle.killed_leader_respawn_position.x}, {battle.killed_leader_respawn_position.y})
|
||||
</span>
|
||||
. (Surviving defeated bots lose 0 points).
|
||||
<span className="text-rose-400 font-bold">-1 point</span>.
|
||||
{battle.absorbed_members.includes(battle.killed_leader_id) ? (
|
||||
<span> Resistance broken! Surrendered and <strong className="text-sky-300">joined {battle.winner_party_name}</strong>.</span>
|
||||
) : (
|
||||
<span> Respawned at ({battle.killed_leader_respawn_position.x}, {battle.killed_leader_respawn_position.y}).</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{battle.absorbed_members.length > 0 && !battle.absorbed_members.includes(battle.killed_leader_id) && (
|
||||
<div className="text-slate-300 leading-relaxed">
|
||||
• <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>!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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:{' '}
|
||||
|
|
@ -117,12 +153,15 @@ export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) =>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Continue Button */}
|
||||
{/* Continue Button with 5s Auto-close Countdown */}
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ interface HeaderProps {
|
|||
onQuickSpawn: () => void;
|
||||
onResetBoard: () => void;
|
||||
playerCount: number;
|
||||
isConcluded?: boolean;
|
||||
onOpenScoreboard?: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
|
|
@ -14,6 +16,8 @@ export const Header: React.FC<HeaderProps> = ({
|
|||
onQuickSpawn,
|
||||
onResetBoard,
|
||||
playerCount,
|
||||
isConcluded,
|
||||
onOpenScoreboard,
|
||||
}) => {
|
||||
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">
|
||||
|
|
@ -47,10 +51,26 @@ export const Header: React.FC<HeaderProps> = ({
|
|||
{isConnected ? 'LIVE SYNC' : 'CONNECTING...'}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<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
|
||||
href="/docs"
|
||||
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,
|
||||
BattleResult,
|
||||
BoardState,
|
||||
GameConclusion,
|
||||
MoveResponse,
|
||||
Party,
|
||||
PartyDefeatResult,
|
||||
|
|
@ -30,6 +31,7 @@ const INITIAL_BOARD: BoardState = {
|
|||
turn_number: 0,
|
||||
turn_order: [],
|
||||
},
|
||||
conclusion: null,
|
||||
};
|
||||
|
||||
export function useGameSocket() {
|
||||
|
|
@ -40,6 +42,7 @@ export function useGameSocket() {
|
|||
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
|
||||
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
|
||||
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
|
||||
const [showScoreboard, setShowScoreboard] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
|
|
@ -53,7 +56,11 @@ export function useGameSocket() {
|
|||
setBoardState((prev) => ({
|
||||
...data,
|
||||
parties: data.parties || prev.parties || [],
|
||||
conclusion: data.conclusion || null,
|
||||
}));
|
||||
if (data.conclusion && data.conclusion.concluded) {
|
||||
setShowScoreboard(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch board state:', err);
|
||||
|
|
@ -98,7 +105,11 @@ export function useGameSocket() {
|
|||
setBoardState({
|
||||
...data.state,
|
||||
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') {
|
||||
setBoardState((prev) => {
|
||||
const exists = prev.players.some((p) => p.id === data.player.id);
|
||||
|
|
@ -163,6 +174,20 @@ export function useGameSocket() {
|
|||
const b: BattleResult = data.battle;
|
||||
setActiveBattle(b);
|
||||
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') {
|
||||
setBoardState((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -187,10 +212,12 @@ export function useGameSocket() {
|
|||
players: [],
|
||||
parties: [],
|
||||
turn: data.turn ?? INITIAL_BOARD.turn,
|
||||
conclusion: null,
|
||||
}));
|
||||
setSelectedPlayer(null);
|
||||
setAvailableMoves(null);
|
||||
setActiveBattle(null);
|
||||
setShowScoreboard(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing WebSocket message:', err);
|
||||
|
|
@ -324,6 +351,10 @@ export function useGameSocket() {
|
|||
if (data.battle_triggered && data.battle_result) {
|
||||
setActiveBattle(data.battle_result);
|
||||
}
|
||||
if (data.game_concluded && data.game_concluded.concluded) {
|
||||
setIsAutoPlaying(false);
|
||||
setShowScoreboard(true);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
|
|
@ -346,6 +377,7 @@ export function useGameSocket() {
|
|||
if (!res.ok) {
|
||||
throw new Error('Failed to reset board');
|
||||
}
|
||||
setShowScoreboard(false);
|
||||
};
|
||||
|
||||
// Step active bot turn according to its explicit autonomous goal:
|
||||
|
|
@ -369,6 +401,11 @@ export function useGameSocket() {
|
|||
} else if (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) {
|
||||
console.warn('Bot AI step error:', err);
|
||||
|
|
@ -404,6 +441,8 @@ export function useGameSocket() {
|
|||
lastEventMessage,
|
||||
activeBattle,
|
||||
setActiveBattle,
|
||||
showScoreboard,
|
||||
setShowScoreboard,
|
||||
registerPlayer,
|
||||
removePlayer,
|
||||
formParty,
|
||||
|
|
|
|||
|
|
@ -39,12 +39,23 @@ export interface TurnInfo {
|
|||
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 {
|
||||
config: GridConfig;
|
||||
player_count: number;
|
||||
players: Player[];
|
||||
parties: Party[];
|
||||
turn: TurnInfo;
|
||||
conclusion?: GameConclusion | null;
|
||||
}
|
||||
|
||||
export interface MoveCheckResult {
|
||||
|
|
@ -116,6 +127,7 @@ export interface MoveResponse {
|
|||
formed_party?: Party | null;
|
||||
battle_triggered?: boolean;
|
||||
battle_result?: BattleResult | null;
|
||||
game_concluded?: GameConclusion | null;
|
||||
turn: TurnInfo;
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +140,7 @@ export interface AiStepResponse {
|
|||
move_result?: MoveResponse | null;
|
||||
formed_party?: Party | null;
|
||||
battle_result?: BattleResult | null;
|
||||
game_concluded?: GameConclusion | null;
|
||||
turn: TurnInfo;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue