botWebWars/backend/app/game.py

1383 lines
56 KiB
Python
Raw Normal View History

import asyncio
import random
import uuid
from typing import Dict, List, Optional, Set, Tuple
from app.config import settings
2026-09-05 21:20:39 +00:00
from app.models import (
2026-09-05 23:20:54 +00:00
AiStepResponse,
AvailableMovesResponse,
BattleBout,
BattleResult,
2026-09-05 21:20:39 +00:00
BoardConfig,
BoardState,
BotMemoryResponse,
BotRadarResponse,
DIRECTION_OFFSETS,
2026-09-05 23:50:27 +00:00
GameConclusion,
2026-09-05 21:20:39 +00:00
MoveCheckResult,
MoveResponse,
Party,
PartyDefeatResult,
PartyInvite,
2026-09-05 21:20:39 +00:00
Player,
PlayerCreate,
RadarTarget,
2026-09-05 21:20:39 +00:00
TurnInfo,
)
STANDARD_DIRECTIONS = [
("UP", 0, -1),
("UP_RIGHT", 1, -1),
("RIGHT", 1, 0),
("DOWN_RIGHT", 1, 1),
("DOWN", 0, 1),
("DOWN_LEFT", -1, 1),
("LEFT", -1, 0),
("UP_LEFT", -1, -1),
]
class GameEngine:
def __init__(self):
self._lock = asyncio.Lock()
self.players: Dict[str, Player] = {}
self.parties: Dict[str, Party] = {}
self.invites: Dict[str, PartyInvite] = {}
2026-09-05 21:20:39 +00:00
self.turn_order: List[str] = []
self.current_turn_index: int = 0
self.round_number: int = 1
self.turn_number: int = 0
self.config = BoardConfig(
min_x=settings.GRID_MIN_X,
max_x=settings.GRID_MAX_X,
min_y=settings.GRID_MIN_Y,
max_y=settings.GRID_MAX_Y,
grid_cells_x=settings.GRID_MAX_X - settings.GRID_MIN_X,
grid_cells_y=settings.GRID_MAX_Y - settings.GRID_MIN_Y,
)
2026-09-05 21:20:39 +00:00
def _get_occupied_coordinates(self) -> Dict[Tuple[int, int], Player]:
return {(p.x, p.y): p for p in self.players.values()}
def _find_random_free_position(self) -> Tuple[int, int]:
occupied = self._get_occupied_coordinates()
total_possible = (self.config.max_x - self.config.min_x + 1) * (
self.config.max_y - self.config.min_y + 1
)
if len(occupied) >= total_possible:
return (
random.randint(self.config.min_x, self.config.max_x),
random.randint(self.config.min_y, self.config.max_y),
)
for _ in range(100):
rx = random.randint(self.config.min_x, self.config.max_x)
ry = random.randint(self.config.min_y, self.config.max_y)
if (rx, ry) not in occupied:
return (rx, ry)
all_coords = [
(x, y)
for x in range(self.config.min_x, self.config.max_x + 1)
for y in range(self.config.min_y, self.config.max_y + 1)
if (x, y) not in occupied
]
return random.choice(all_coords)
def _get_active_turn_actors(self) -> List[str]:
actors = []
for pid in self.turn_order:
p = self.players.get(pid)
if not p:
continue
if not p.party_id or p.is_party_leader:
actors.append(pid)
return actors
2026-09-05 21:20:39 +00:00
def _get_current_player(self) -> Optional[Player]:
actors = self._get_active_turn_actors()
if not actors:
2026-09-05 21:20:39 +00:00
return None
safe_index = self.current_turn_index % len(actors)
player_id = actors[safe_index]
2026-09-05 21:20:39 +00:00
return self.players.get(player_id)
def _get_turn_info(self) -> TurnInfo:
current = self._get_current_player()
actors = self._get_active_turn_actors()
2026-09-05 21:20:39 +00:00
return TurnInfo(
current_player_id=current.id if current else None,
current_player_name=current.name if current else None,
round_number=self.round_number,
turn_number=self.turn_number,
turn_order=actors,
2026-09-05 21:20:39 +00:00
)
def _advance_turn(self):
actors = self._get_active_turn_actors()
if not actors:
2026-09-05 21:20:39 +00:00
self.current_turn_index = 0
return
self.turn_number += 1
self.current_turn_index = (self.current_turn_index + 1) % len(actors)
2026-09-05 21:20:39 +00:00
if self.current_turn_index == 0:
self.round_number += 1
def _update_party_strength(self, party: Party):
total = sum(self.players[m].strength for m in party.member_ids if m in self.players)
party.total_strength = max(1, total)
2026-09-05 23:50:27 +00:00
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]}"
x, y = self._find_random_free_position()
2026-09-05 21:20:39 +00:00
player = Player(
id=player_id,
name=player_in.name,
color=player_in.color,
x=x,
y=y,
strength=player_in.strength,
score=0,
party_id=None,
is_party_leader=False,
visited_locations=[{"x": x, "y": y}],
)
self.players[player_id] = player
2026-09-05 21:20:39 +00:00
self.turn_order.append(player_id)
return player
async def get_player(self, player_id: str) -> Optional[Player]:
async with self._lock:
return self.players.get(player_id)
async def get_all_players(self) -> List[Player]:
async with self._lock:
return list(self.players.values())
async def remove_player(self, player_id: str) -> bool:
async with self._lock:
2026-09-05 21:20:39 +00:00
if player_id not in self.players:
return False
player = self.players[player_id]
if player.party_id and player.party_id in self.parties:
party = self.parties[player.party_id]
if player_id in party.member_ids:
party.member_ids.remove(player_id)
if party.leader_id == player_id:
if party.member_ids:
new_leader_id = random.choice(party.member_ids)
party.leader_id = new_leader_id
new_lead = self.players.get(new_leader_id)
if new_lead:
new_lead.is_party_leader = True
party.leader_name = new_lead.name
self._update_party_strength(party)
else:
del self.parties[party.id]
elif party.member_ids:
self._update_party_strength(party)
else:
del self.parties[party.id]
2026-09-05 21:20:39 +00:00
del self.players[player_id]
if player_id in self.turn_order:
self.turn_order.remove(player_id)
actors = self._get_active_turn_actors()
if actors:
self.current_turn_index %= len(actors)
else:
self.current_turn_index = 0
2026-09-05 21:20:39 +00:00
return True
async def reset(self) -> None:
async with self._lock:
self.players.clear()
self.parties.clear()
self.invites.clear()
2026-09-05 21:20:39 +00:00
self.turn_order.clear()
self.current_turn_index = 0
self.round_number = 1
self.turn_number = 0
async def get_board_state(self) -> BoardState:
async with self._lock:
players_list = list(self.players.values())
parties_list = list(self.parties.values())
return BoardState(
config=self.config,
player_count=len(players_list),
players=players_list,
parties=parties_list,
2026-09-05 21:20:39 +00:00
turn=self._get_turn_info(),
2026-09-05 23:50:27 +00:00
conclusion=self._check_game_concluded(),
)
2026-09-05 23:50:27 +00:00
# ==========================================\n # Bot Memory & Radar Awareness
# ==========================================
async def get_bot_memory(
self, player_id: str, check_x: Optional[int] = None, check_y: Optional[int] = None
) -> BotMemoryResponse:
async with self._lock:
player = self.players.get(player_id)
if not player:
raise KeyError(f"Player '{player_id}' not found")
has_visited = True
if check_x is not None and check_y is not None:
has_visited = any(loc["x"] == check_x and loc["y"] == check_y for loc in player.visited_locations)
return BotMemoryResponse(
player_id=player.id,
player_name=player.name,
current_x=player.x,
current_y=player.y,
visited_count=len(player.visited_locations),
visited_history=list(player.visited_locations),
has_visited_current=has_visited,
)
async def get_bot_radar(self, player_id: str) -> BotRadarResponse:
async with self._lock:
player = self.players.get(player_id)
if not player:
raise KeyError(f"Player '{player_id}' not found")
2026-09-05 23:20:54 +00:00
bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party"
targets: List[RadarTarget] = []
2026-09-05 23:20:54 +00:00
for other in self.players.values():
if other.id == player.id:
continue
dist = max(abs(player.x - other.x), abs(player.y - other.y))
is_ally = bool(player.party_id and player.party_id == other.party_id)
is_enemy = not is_ally
party_obj = self.parties.get(other.party_id) if other.party_id else None
2026-09-05 23:50:27 +00:00
# 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)
2026-09-05 23:20:54 +00:00
targets.append(
RadarTarget(
id=other.id,
name=other.name,
color=other.color,
x=other.x,
y=other.y,
2026-09-05 23:20:54 +00:00
strength=other.strength,
distance=dist,
party_id=other.party_id,
party_name=party_obj.name if party_obj else None,
is_ally=is_ally,
is_enemy=is_enemy,
2026-09-05 23:20:54 +00:00
can_recruit=can_recruit,
can_battle=can_battle,
)
)
targets.sort(key=lambda t: t.distance)
2026-09-05 23:20:54 +00:00
# Choose primary target based on goal:
2026-09-05 23:50:27 +00:00
# - If unpartied: seek closest bot/party to join/form party with
# - If partied: seek closest enemy party or refusing solo bot to battle
2026-09-05 23:20:54 +00:00
primary_targets = []
if bot_goal == "form_party":
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:
2026-09-05 23:50:27 +00:00
battle_targets = [t for t in targets if t.is_enemy and (t.party_id or t.can_battle)]
2026-09-05 23:20:54 +00:00
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)
rec_dir = None
rec_act = "explore_unvisited"
if nearest:
dx = 1 if nearest.x > player.x else (-1 if nearest.x < player.x else 0)
dy = 1 if nearest.y > player.y else (-1 if nearest.y < player.y else 0)
for name, (ox, oy) in DIRECTION_OFFSETS.items():
if ox == dx and oy == dy and "_" in name:
rec_dir = name
break
if not rec_dir:
for name, (ox, oy) in DIRECTION_OFFSETS.items():
if ox == dx and oy == dy:
rec_dir = name
break
if nearest.distance <= 1:
2026-09-05 23:50:27 +00:00
if nearest.can_recruit:
2026-09-05 23:20:54 +00:00
rec_act = "form_party"
else:
rec_act = "engage_battle"
else:
2026-09-05 23:20:54 +00:00
if bot_goal == "form_party":
rec_act = "seek_partner"
else:
rec_act = "hunt_party"
return BotRadarResponse(
player_id=player.id,
current_x=player.x,
current_y=player.y,
2026-09-05 23:20:54 +00:00
bot_goal=bot_goal,
targets=targets,
nearest_target=nearest,
recommended_direction=rec_dir,
recommended_action=rec_act,
)
2026-09-05 23:50:27 +00:00
# ==========================================\n # Party Logic & Leadership Negotiation
# ==========================================
@staticmethod
def _are_adjacent(p1: Player, p2: Player) -> bool:
return max(abs(p1.x - p2.x), abs(p1.y - p2.y)) <= 1
def _verify_party_connectivity(self, member_players: List[Player]) -> bool:
if len(member_players) <= 1:
return True
visited: Set[str] = set()
queue = [member_players[0]]
visited.add(member_players[0].id)
while queue:
curr = queue.pop(0)
for other in member_players:
if other.id not in visited and self._are_adjacent(curr, other):
visited.add(other.id)
queue.append(other)
return len(visited) == len(member_players)
2026-09-05 23:20:54 +00:00
def _negotiate_party_leader_id(self, bot_a: Player, bot_b: Player) -> str:
if bot_a.strength > bot_b.strength:
return bot_a.id
elif bot_b.strength > bot_a.strength:
return bot_b.id
else:
if bot_a.score > bot_b.score:
return bot_a.id
elif bot_b.score > bot_a.score:
return bot_b.id
return bot_a.id
async def form_party(
self, member_ids: List[str], leader_id: str, name: Optional[str] = None
) -> Party:
async with self._lock:
if len(member_ids) < 2:
raise ValueError("A party must have at least 2 members")
if leader_id not in member_ids:
raise ValueError("Agreed leader must be one of the party members")
member_players: List[Player] = []
for mid in member_ids:
p = self.players.get(mid)
if not p:
raise KeyError(f"Player '{mid}' not found")
member_players.append(p)
if not self._verify_party_connectivity(member_players):
raise ValueError(
"All party members must be linked within 1 distance of each other (directly or via connected teammates)."
)
for p in member_players:
if p.party_id and p.party_id in self.parties:
old_party = self.parties[p.party_id]
if p.id in old_party.member_ids:
old_party.member_ids.remove(p.id)
if not old_party.member_ids:
del self.parties[old_party.id]
party_id = f"party_{uuid.uuid4().hex[:8]}"
leader = self.players[leader_id]
party_name = name or f"Squad {leader.name}"
party = Party(
id=party_id,
name=party_name,
leader_id=leader_id,
leader_name=leader.name,
member_ids=list(member_ids),
total_strength=sum(p.strength for p in member_players),
)
for p in member_players:
p.party_id = party_id
p.is_party_leader = p.id == leader_id
self.parties[party_id] = party
return party
async def invite_to_party(
self,
inviter_id: str,
invitee_id: str,
proposed_leader_id: str,
party_name: Optional[str] = None,
) -> PartyInvite:
async with self._lock:
inviter = self.players.get(inviter_id)
invitee = self.players.get(invitee_id)
if not inviter or not invitee:
raise KeyError("Inviter or invitee not found")
eligible_hosts = [inviter]
if inviter.party_id and inviter.party_id in self.parties:
party = self.parties[inviter.party_id]
eligible_hosts = [self.players[mid] for mid in party.member_ids if mid in self.players]
is_adjacent = any(self._are_adjacent(invitee, host) for host in eligible_hosts)
if not is_adjacent:
raise ValueError(
f"Bot '{invitee.name}' is too far away. Must be within 1 distance of a party member to join."
)
invite_id = f"inv_{uuid.uuid4().hex[:8]}"
invite = PartyInvite(
id=invite_id,
inviter_id=inviter_id,
invitee_id=invitee_id,
proposed_leader_id=proposed_leader_id,
party_id=inviter.party_id,
)
self.invites[invite_id] = invite
return invite
async def respond_to_invite(self, invite_id: str, accept: bool) -> Optional[Party]:
async with self._lock:
invite = self.invites.get(invite_id)
if not invite:
raise KeyError(f"Invite '{invite_id}' not found")
if invite.status != "pending":
raise ValueError(f"Invite already {invite.status}")
if not accept:
invite.status = "rejected"
return None
invite.status = "accepted"
inviter = self.players.get(invite.inviter_id)
invitee = self.players.get(invite.invitee_id)
if not inviter or not invitee:
raise KeyError("Inviter or invitee no longer active")
if inviter.party_id and inviter.party_id in self.parties:
party = self.parties[inviter.party_id]
if invitee.id not in party.member_ids:
party.member_ids.append(invitee.id)
invitee.party_id = party.id
if invite.proposed_leader_id in party.member_ids:
party.leader_id = invite.proposed_leader_id
for mid in party.member_ids:
p = self.players.get(mid)
if p:
p.is_party_leader = p.id == party.leader_id
party.leader_name = self.players[party.leader_id].name
self._update_party_strength(party)
return party
members = [inviter.id, invitee.id]
leader_id = invite.proposed_leader_id if invite.proposed_leader_id in members else inviter.id
party_id = f"party_{uuid.uuid4().hex[:8]}"
leader = self.players[leader_id]
party = Party(
id=party_id,
name=f"Squad {leader.name}",
leader_id=leader_id,
leader_name=leader.name,
member_ids=members,
total_strength=inviter.strength + invitee.strength,
)
inviter.party_id = party_id
inviter.is_party_leader = inviter.id == leader_id
invitee.party_id = party_id
invitee.is_party_leader = invitee.id == leader_id
self.parties[party_id] = party
return party
async def get_all_parties(self) -> List[Party]:
async with self._lock:
return list(self.parties.values())
async def get_party(self, party_id: str) -> Optional[Party]:
async with self._lock:
return self.parties.get(party_id)
2026-09-05 23:50:27 +00:00
# ==========================================\n # Movement Checking & Execution
# ==========================================
2026-09-05 21:20:39 +00:00
def _check_move_internal(
self,
player: Player,
dx: int,
dy: int,
direction_name: str,
occupied_map: Dict[Tuple[int, int], Player],
) -> MoveCheckResult:
if player.party_id and player.party_id in self.parties and player.is_party_leader:
party = self.parties[player.party_id]
party_members = [self.players[mid] for mid in party.member_ids if mid in self.players]
party_member_ids = {m.id for m in party_members}
for m in party_members:
tx = m.x + dx
ty = m.y + dy
if tx < self.config.min_x or tx > self.config.max_x or ty < self.config.min_y or ty > self.config.max_y:
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=False,
reason=f"Party member '{m.name}' would hit boundary wall at ({tx}, {ty}).",
)
occupant = occupied_map.get((tx, ty))
if occupant is not None and occupant.id not in party_member_ids:
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=False,
reason=f"Party member '{m.name}' path blocked by '{occupant.name}' at ({tx}, {ty}).",
)
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=True,
reason=None,
)
2026-09-05 21:20:39 +00:00
target_x = player.x + dx
target_y = player.y + dy
2026-09-05 23:50:27 +00:00
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
):
2026-09-05 21:20:39 +00:00
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=target_x,
target_y=target_y,
available=False,
2026-09-05 23:50:27 +00:00
reason=f"Hit boundary wall at ({target_x}, {target_y}). Coordinates must remain between {self.config.min_x} and {self.config.max_x}.",
2026-09-05 21:20:39 +00:00
)
occupant = occupied_map.get((target_x, target_y))
if occupant is not None and occupant.id != player.id:
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=target_x,
target_y=target_y,
available=False,
2026-09-05 23:50:27 +00:00
reason=f"Target square ({target_x}, {target_y}) is occupied by player '{occupant.name}'.",
2026-09-05 21:20:39 +00:00
)
return MoveCheckResult(
direction=direction_name,
dx=dx,
dy=dy,
target_x=target_x,
target_y=target_y,
available=True,
reason=None,
)
async def check_single_move(self, player_id: str, direction_input: str) -> MoveCheckResult:
async with self._lock:
player = self.players.get(player_id)
if not player:
raise KeyError(f"Player '{player_id}' not found")
if player.party_id and not player.is_party_leader:
party = self.parties.get(player.party_id)
leader_name = party.leader_name if party else "Leader"
return MoveCheckResult(
direction=direction_input,
dx=0,
dy=0,
target_x=player.x,
target_y=player.y,
available=False,
reason=f"Only party leader '{leader_name}' controls movement for the party.",
)
2026-09-05 21:20:39 +00:00
normalized = direction_input.strip().upper().replace(" ", "_")
if normalized not in DIRECTION_OFFSETS:
raise ValueError(f"Unknown direction '{direction_input}'")
2026-09-05 21:20:39 +00:00
dx, dy = DIRECTION_OFFSETS[normalized]
occupied = self._get_occupied_coordinates()
return self._check_move_internal(player, dx, dy, normalized, occupied)
async def get_available_moves(self, player_id: str) -> AvailableMovesResponse:
async with self._lock:
player = self.players.get(player_id)
if not player:
raise KeyError(f"Player '{player_id}' not found")
current_turn_player = self._get_current_player()
is_turn = current_turn_player is not None and current_turn_player.id == player_id
occupied = self._get_occupied_coordinates()
party = self.parties.get(player.party_id) if player.party_id else None
is_leader = player.is_party_leader or party is None
member_count = len(party.member_ids) if party else 1
2026-09-05 21:20:39 +00:00
moves: Dict[str, MoveCheckResult] = {}
for name, dx, dy in STANDARD_DIRECTIONS:
if player.party_id and not player.is_party_leader:
moves[name] = MoveCheckResult(
direction=name,
dx=dx,
dy=dy,
target_x=player.x + dx,
target_y=player.y + dy,
available=False,
reason=f"Only party leader '{party.leader_name if party else 'Leader'}' controls group movement.",
)
else:
moves[name] = self._check_move_internal(player, dx, dy, name, occupied)
2026-09-05 21:20:39 +00:00
return AvailableMovesResponse(
player_id=player.id,
player_name=player.name,
current_x=player.x,
current_y=player.y,
is_turn=is_turn and is_leader,
is_party_leader=player.is_party_leader,
party_id=player.party_id,
party_member_count=member_count,
2026-09-05 21:20:39 +00:00
current_turn_player_id=current_turn_player.id if current_turn_player else None,
moves=moves,
)
2026-09-05 23:50:27 +00:00
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!
2026-09-05 23:20:54 +00:00
"""
2026-09-05 23:50:27 +00:00
# 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
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 not self._are_adjacent(player, other):
continue
2026-09-05 23:20:54 +00:00
2026-09-05 23:50:27 +00:00
# Solo Bot meets another Solo Bot -> form party
2026-09-05 23:20:54 +00:00
if other.party_id is None:
leader_id = self._negotiate_party_leader_id(player, other)
leader = self.players[leader_id]
party_id = f"party_{uuid.uuid4().hex[:8]}"
party = Party(
id=party_id,
name=f"Squad {leader.name}",
leader_id=leader_id,
leader_name=leader.name,
member_ids=[player.id, other.id],
total_strength=player.strength + other.strength,
)
player.party_id = party_id
player.is_party_leader = player.id == leader_id
other.party_id = party_id
other.is_party_leader = other.id == leader_id
self.parties[party_id] = party
2026-09-05 23:50:27 +00:00
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
2026-09-05 23:20:54 +00:00
player.is_party_leader = False
2026-09-05 23:50:27 +00:00
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
2026-09-05 23:20:54 +00:00
async def move_player(
self, player_id: str, dx: int, dy: int, direction_name: str
) -> MoveResponse:
2026-09-05 21:20:39 +00:00
async with self._lock:
player = self.players.get(player_id)
if not player:
raise KeyError(f"Player '{player_id}' not found")
if player.party_id and not player.is_party_leader:
party = self.parties.get(player.party_id)
leader_name = party.leader_name if party else "Leader"
raise PermissionError(
f"Party member '{player.name}' cannot move individually. Only party leader '{leader_name}' controls movement for the party."
)
2026-09-05 21:20:39 +00:00
current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id:
curr_name = current_turn_player.name if current_turn_player else "Nobody"
curr_id = current_turn_player.id if current_turn_player else "None"
raise PermissionError(
f"It is not your turn. Current turn belongs to '{curr_name}' ({curr_id})."
)
occupied = self._get_occupied_coordinates()
check = self._check_move_internal(player, dx, dy, direction_name, occupied)
if not check.available:
raise ValueError(f"Illegal move: {check.reason}")
prev_pos = {"x": player.x, "y": player.y}
affected_players: List[Player] = []
# Move party in unison or single bot
if player.party_id and player.party_id in self.parties:
party = self.parties[player.party_id]
for mid in party.member_ids:
m = self.players.get(mid)
if m:
m.x += dx
m.y += dy
m.visited_locations.append({"x": m.x, "y": m.y})
affected_players.append(m)
else:
player.x = check.target_x
player.y = check.target_y
player.visited_locations.append({"x": player.x, "y": player.y})
affected_players.append(player)
2026-09-05 21:20:39 +00:00
new_pos = {"x": player.x, "y": player.y}
2026-09-05 23:50:27 +00:00
# Check for adjacent interactions (party formation or battle)
formed_party, battle_result = self._check_adjacent_encounter(player)
2026-09-05 21:20:39 +00:00
self._advance_turn()
turn_info = self._get_turn_info()
2026-09-05 23:50:27 +00:00
conclusion = self._check_game_concluded()
2026-09-05 21:20:39 +00:00
return MoveResponse(
success=True,
player=player,
direction=direction_name,
party_moved=bool(player.party_id),
affected_players=affected_players,
2026-09-05 21:20:39 +00:00
previous_position=prev_pos,
new_position=new_pos,
2026-09-05 23:20:54 +00:00
party_formed_triggered=bool(formed_party),
formed_party=formed_party,
2026-09-05 23:50:27 +00:00
battle_triggered=bool(battle_result),
battle_result=battle_result,
2026-09-05 23:50:27 +00:00
game_concluded=conclusion if conclusion.concluded else None,
2026-09-05 21:20:39 +00:00
turn=turn_info,
)
async def pass_turn(self, player_id: str) -> TurnInfo:
async with self._lock:
player = self.players.get(player_id)
if not player:
raise KeyError(f"Player '{player_id}' not found")
current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id:
curr_name = current_turn_player.name if current_turn_player else "Nobody"
curr_id = current_turn_player.id if current_turn_player else "None"
raise PermissionError(
f"Cannot pass: it is not your turn. Current turn belongs to '{curr_name}' ({curr_id})."
)
self._advance_turn()
return self._get_turn_info()
2026-09-05 23:50:27 +00:00
# ==========================================\n # Autonomous Goal Step (AI Logic)
2026-09-05 23:20:54 +00:00
# ==========================================
async def step_bot_ai(self, player_id: str) -> AiStepResponse:
async with self._lock:
player = self.players.get(player_id)
if not player:
raise KeyError(f"Player '{player_id}' not found")
current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id:
raise PermissionError(
f"Not your turn. Current turn: '{current_turn_player.name if current_turn_player else 'None'}'"
)
bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party"
2026-09-05 23:50:27 +00:00
# 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,
player_name=player.name,
bot_goal=bot_goal,
direction=None,
move_result=None,
formed_party=None,
battle_result=battle_res,
game_concluded=conclusion if conclusion.concluded else None,
turn=self._get_turn_info(),
)
2026-09-05 23:20:54 +00:00
2026-09-05 23:50:27 +00:00
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(),
)
2026-09-05 23:20:54 +00:00
2026-09-05 23:50:27 +00:00
# 2. Navigate towards target using radar & memory
2026-09-05 23:20:54 +00:00
occupied = self._get_occupied_coordinates()
moves_map: Dict[str, MoveCheckResult] = {}
for name, dx, dy in STANDARD_DIRECTIONS:
moves_map[name] = self._check_move_internal(player, dx, dy, name, occupied)
available_dirs = [name for name, chk in moves_map.items() if chk.available]
if not available_dirs:
self._advance_turn()
2026-09-05 23:50:27 +00:00
conclusion = self._check_game_concluded()
2026-09-05 23:20:54 +00:00
return AiStepResponse(
action_taken="passed",
player_id=player.id,
player_name=player.name,
bot_goal=bot_goal,
direction=None,
move_result=None,
formed_party=None,
battle_result=None,
2026-09-05 23:50:27 +00:00
game_concluded=conclusion if conclusion.concluded else None,
2026-09-05 23:20:54 +00:00
turn=self._get_turn_info(),
)
# Find nearest target according to goal
targets = []
for other in self.players.values():
if other.id == player.id:
continue
if player.party_id and player.party_id == other.party_id:
continue
dist = max(abs(player.x - other.x), abs(player.y - other.y))
targets.append((dist, other))
targets.sort(key=lambda t: t[0])
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)
else:
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)
visited_set = {(loc["x"], loc["y"]) for loc in player.visited_locations}
best_dir = available_dirs[0]
best_score = float("-inf")
for dir_name in available_dirs:
dx, dy = DIRECTION_OFFSETS[dir_name]
tx = player.x + dx
ty = player.y + dy
score = 0.0
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))
2026-09-05 23:50:27 +00:00
score += (old_dist - new_dist) * 10.0
2026-09-05 23:20:54 +00:00
if (tx, ty) not in visited_set:
2026-09-05 23:50:27 +00:00
score += 2.0
2026-09-05 23:20:54 +00:00
if score > best_score:
best_score = score
best_dir = dir_name
dx, dy = DIRECTION_OFFSETS[best_dir]
prev_pos = {"x": player.x, "y": player.y}
affected_players: List[Player] = []
if player.party_id and player.party_id in self.parties:
party = self.parties[player.party_id]
for mid in party.member_ids:
m = self.players.get(mid)
if m:
m.x += dx
m.y += dy
m.visited_locations.append({"x": m.x, "y": m.y})
affected_players.append(m)
else:
player.x += dx
player.y += dy
player.visited_locations.append({"x": player.x, "y": player.y})
affected_players.append(player)
new_pos = {"x": player.x, "y": player.y}
2026-09-05 23:50:27 +00:00
# Check for adjacent interactions after movement
formed_party, battle_result = self._check_adjacent_encounter(player)
2026-09-05 23:20:54 +00:00
self._advance_turn()
turn_info = self._get_turn_info()
2026-09-05 23:50:27 +00:00
conclusion = self._check_game_concluded()
2026-09-05 23:20:54 +00:00
move_res = MoveResponse(
success=True,
player=player,
direction=best_dir,
party_moved=bool(player.party_id),
affected_players=affected_players,
previous_position=prev_pos,
new_position=new_pos,
party_formed_triggered=bool(formed_party),
formed_party=formed_party,
2026-09-05 23:50:27 +00:00
battle_triggered=bool(battle_result),
2026-09-05 23:20:54 +00:00
battle_result=battle_result,
2026-09-05 23:50:27 +00:00
game_concluded=conclusion if conclusion.concluded else None,
2026-09-05 23:20:54 +00:00
turn=turn_info,
)
2026-09-05 23:50:27 +00:00
action_taken = "battled" if battle_result else ("formed_party" if formed_party else "moved")
2026-09-05 23:20:54 +00:00
return AiStepResponse(
action_taken=action_taken,
player_id=player.id,
player_name=player.name,
bot_goal=bot_goal,
direction=best_dir,
move_result=move_res,
formed_party=formed_party,
battle_result=battle_result,
2026-09-05 23:50:27 +00:00
game_concluded=conclusion if conclusion.concluded else None,
2026-09-05 23:20:54 +00:00
turn=turn_info,
)
2026-09-05 23:50:27 +00:00
# ==========================================\n # 3-Bout D20 Battle Mechanics
# ==========================================
def _find_adjacent_opposing_party(self, party: Party) -> Optional[Party]:
my_members = [self.players[mid] for mid in party.member_ids if mid in self.players]
for other_party in self.parties.values():
if other_party.id == party.id:
continue
other_members = [self.players[mid] for mid in other_party.member_ids if mid in self.players]
for m1 in my_members:
for m2 in other_members:
if self._are_adjacent(m1, m2):
return other_party
return None
def _resolve_3bout_battle_internal(self, party1: Party, party2: Party) -> BattleResult:
str1 = sum(self.players[m].strength for m in party1.member_ids if m in self.players)
str2 = sum(self.players[m].strength for m in party2.member_ids if m in self.players)
bouts: List[BattleBout] = []
p1_bouts_won = 0
p2_bouts_won = 0
p1_total_score = 0
p2_total_score = 0
for i in range(1, 4):
roll1 = random.randint(1, 20)
roll2 = random.randint(1, 20)
score1 = str1 * roll1
score2 = str2 * roll2
p1_total_score += score1
p2_total_score += score2
if score1 > score2:
winner_name = party1.name
p1_bouts_won += 1
elif score2 > score1:
winner_name = party2.name
p2_bouts_won += 1
else:
tie_roll1 = random.randint(1, 20)
tie_roll2 = random.randint(1, 20)
if tie_roll1 >= tie_roll2:
winner_name = party1.name
p1_bouts_won += 1
else:
winner_name = party2.name
p2_bouts_won += 1
bouts.append(
BattleBout(
bout_number=i,
party1_roll=roll1,
party1_strength=str1,
party1_score=score1,
party2_roll=roll2,
party2_strength=str2,
party2_score=score2,
winner_name=winner_name,
)
)
2026-09-05 23:20:54 +00:00
# Overall battle winner
if p1_bouts_won > p2_bouts_won:
winner_party, defeated_party = party1, party2
elif p2_bouts_won > p1_bouts_won:
winner_party, defeated_party = party2, party1
else:
if p1_total_score >= p2_total_score:
winner_party, defeated_party = party1, party2
else:
winner_party, defeated_party = party2, party1
# 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
2026-09-05 23:50:27 +00:00
killed_leader_id = defeated_party.leader_id
killed_leader = self.players.get(killed_leader_id)
absorbed_members = []
2026-09-05 23:50:27 +00:00
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
killed_leader.is_party_leader = False
rx, ry = self._find_random_free_position()
killed_leader.x = rx
killed_leader.y = ry
killed_leader.visited_locations.append({"x": rx, "y": ry})
respawn_pos = {"x": rx, "y": ry}
else:
respawn_pos = {"x": 0, "y": 0}
# Remainder of defeated party joins the winning party (scores remain unchanged)
for mid in list(defeated_party.member_ids):
if mid != killed_leader_id:
m = self.players.get(mid)
if m:
m.party_id = winner_party.id
m.is_party_leader = False
if mid not in winner_party.member_ids:
winner_party.member_ids.append(mid)
absorbed_members.append(mid)
# Defeated party is dissolved
if defeated_party.id in self.parties:
del self.parties[defeated_party.id]
# Update winning party strength
self._update_party_strength(winner_party)
return BattleResult(
bouts=bouts,
party1_name=party1.name,
party2_name=party2.name,
party1_bouts_won=p1_bouts_won,
party2_bouts_won=p2_bouts_won,
party1_total_score=p1_total_score,
party2_total_score=p2_total_score,
winner_party_id=winner_party.id,
winner_party_name=winner_party.name,
winner_leader_id=winner_party.leader_id,
winner_leader_name=winner_party.leader_name,
defeated_party_id=defeated_party.id,
defeated_party_name=defeated_party.name,
killed_leader_id=killed_leader_id,
killed_leader_name=killed_leader.name if killed_leader else "Unknown",
killed_leader_new_score=killed_leader.score if killed_leader else -1,
killed_leader_respawn_position=respawn_pos,
absorbed_members=absorbed_members,
new_party_size=len(winner_party.member_ids),
new_party_strength=winner_party.total_strength,
)
async def defeat_party(self, party_id: str) -> PartyDefeatResult:
async with self._lock:
party = self.parties.get(party_id)
if not party:
raise KeyError(f"Party '{party_id}' not found")
old_leader_id = party.leader_id
leader = self.players.get(old_leader_id)
if not leader:
raise KeyError(f"Leader '{old_leader_id}' not found")
leader.score -= 1
leader.party_id = None
leader.is_party_leader = False
if old_leader_id in party.member_ids:
party.member_ids.remove(old_leader_id)
rx, ry = self._find_random_free_position()
leader.x = rx
leader.y = ry
leader.visited_locations.append({"x": rx, "y": ry})
respawn_pos = {"x": rx, "y": ry}
new_leader_id = None
new_leader_name = None
party_dissolved = False
if len(party.member_ids) > 0:
new_leader_id = random.choice(party.member_ids)
party.leader_id = new_leader_id
new_leader = self.players[new_leader_id]
new_leader.is_party_leader = True
new_leader_name = new_leader.name
party.leader_name = new_leader_name
self._update_party_strength(party)
else:
del self.parties[party_id]
party_dissolved = True
return PartyDefeatResult(
party_id=party_id,
killed_leader_id=leader.id,
killed_leader_name=leader.name,
killed_leader_new_score=leader.score,
killed_leader_respawn_position=respawn_pos,
new_leader_id=new_leader_id,
new_leader_name=new_leader_name,
remaining_members=list(party.member_ids),
party_dissolved=party_dissolved,
)
async def battle(self, challenger_id: str, defender_id: str) -> BattleResult:
async with self._lock:
p1 = self.players.get(challenger_id)
p2 = self.players.get(defender_id)
if not p1 or not p2:
raise KeyError("Both combatants must exist")
if p1.party_id and p1.party_id == p2.party_id:
raise ValueError("Cannot battle your own party member")
if not self._are_adjacent(p1, p2):
raise ValueError(
f"Players are not adjacent. Combat distance must be <= 1 (current distance: ({abs(p1.x - p2.x)}, {abs(p1.y - p2.y)}))"
)
# Form temporary 1-bot parties if they don't have one
party1 = self.parties.get(p1.party_id) if p1.party_id else None
if not party1:
party1_id = f"party_{uuid.uuid4().hex[:8]}"
party1 = Party(
id=party1_id,
name=f"Squad {p1.name}",
leader_id=p1.id,
leader_name=p1.name,
member_ids=[p1.id],
total_strength=p1.strength,
)
p1.party_id = party1_id
p1.is_party_leader = True
self.parties[party1_id] = party1
party2 = self.parties.get(p2.party_id) if p2.party_id else None
if not party2:
party2_id = f"party_{uuid.uuid4().hex[:8]}"
party2 = Party(
id=party2_id,
name=f"Squad {p2.name}",
leader_id=p2.id,
leader_name=p2.name,
member_ids=[p2.id],
total_strength=p2.strength,
)
p2.party_id = party2_id
p2.is_party_leader = True
self.parties[party2_id] = party2
return self._resolve_3bout_battle_internal(party1, party2)
# Global game engine instance
game_engine = GameEngine()