added some battle mechanics and scoring details
This commit is contained in:
parent
2f02259c38
commit
f78df72cad
85
README.md
85
README.md
|
|
@ -1,34 +1,56 @@
|
||||||
# botWebWars
|
# botWebWars
|
||||||
|
|
||||||
A FastAPI and React-based web arena featuring a real-time **64 × 64 grid** with coordinates ranging from `(0, 0)` to `(64, 64)`. Players/bots register with their name and avatar color, find teammates, form **Parties**, take turns moving around the arena, and engage in tactical squad battles.
|
A FastAPI and React-based web arena featuring a real-time **64 × 64 grid** with coordinates ranging from `(0, 0)` to `(64, 64)`. Players/bots register with their name, color, and strength, remember everywhere they have been, seek other bots to form **Parties**, take turns moving around the arena, and engage in **3-bout D20 tactical battles**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### 1. Parties & Squad Formations
|
### 1. Bot Location Memory & Awareness
|
||||||
|
- **Location Awareness**:
|
||||||
|
- Every bot knows its current coordinate `(x, y)` and tracks a memory history of all coordinates it has visited.
|
||||||
|
- Queryable via `GET /api/players/{player_id}/memory`.
|
||||||
|
- Enables bots to remember if they have visited a location before to avoid looping and explore unvisited territory.
|
||||||
|
- **Bot Seeking & Radar**:
|
||||||
|
- Bots can actively seek other bots via `GET /api/players/{player_id}/radar`.
|
||||||
|
- Scans for nearest friendly/neutral bots to recruit into a party, or detects enemy squads to engage in battle.
|
||||||
|
|
||||||
|
### 2. Parties & Squad Formations
|
||||||
- **Goal & Linked Connectivity**:
|
- **Goal & Linked Connectivity**:
|
||||||
- Bots can find each other and join together as a **Party**.
|
- Bots seek each other out to join together as a **Party**.
|
||||||
- All party members must be within **1 distance** of each other (Chebyshev distance $\le 1$, including diagonals and cardinals), forming linked connections (clusters or single-file lines).
|
- All party members must be within **1 distance** of each other (Chebyshev distance $\le 1$, including diagonals and cardinals), forming linked connections (clusters or single-file lines).
|
||||||
- Bots must agree on a **Party Leader**.
|
- Bots must agree on a **Party Leader**.
|
||||||
|
- **Party Strength**:
|
||||||
|
- Each bot has a default **strength of 1**.
|
||||||
|
- Total party strength is the sum of all bots in the party:
|
||||||
|
$$\text{Party Strength} = \sum_{b \in \text{Party}} \text{bot.strength}$$
|
||||||
- **Leader Group Movement**:
|
- **Leader Group Movement**:
|
||||||
- The Party Leader controls the movement for the entire squad.
|
- The Party Leader controls the movement for the entire squad.
|
||||||
- All party members translate together in the chosen direction (preserving their relative shape and linked adjacency).
|
- All party members translate together in the chosen direction (preserving their relative shape and linked adjacency).
|
||||||
- If any party member's path is blocked by an outside bot or boundary wall, the entire group move is prevented.
|
- If any party member's path is blocked by an outside bot or boundary wall, the entire group move is prevented.
|
||||||
- Non-leader party members cannot move independently.
|
- Non-leader party members cannot move independently.
|
||||||
|
|
||||||
### 2. Battle, Defeat & Respawn Mechanics
|
### 3. 3-Bout D20 Battle Mechanics & Scoring Rules
|
||||||
- **Scoring**:
|
- **Engagement**:
|
||||||
- All players/bots start with a score of **0**.
|
- When two opposing parties engage (move into adjacent contact or call battle), a battle must be fought.
|
||||||
- **Leader Defeat**:
|
- **3-Bout Resolution with D20 Roll Multiplier**:
|
||||||
- If a party is defeated in battle (or triggered via `/api/parties/{id}/defeat`), the **Party Leader is killed**:
|
- Each battle consists of **3 bouts**.
|
||||||
1. Leader's score decreases by 1 (`score -= 1`).
|
- In each bout, both parties roll a **20-sided die (D20)** (random number from 1 to 20).
|
||||||
2. Leader is removed from their prior party (`party_id = null`, `is_party_leader = false`).
|
- The roll acts as a multiplier on the party's total strength:
|
||||||
3. Leader respawns at a random unoccupied position on the board.
|
$$\text{Bout Score} = \text{Party Strength} \times \text{D20 Roll}$$
|
||||||
4. A **new leader is randomly assigned** from the remainder of the party.
|
- The party with the higher bout score wins that bout.
|
||||||
5. If no members remain, the party dissolves.
|
- The overall battle winner is the party that scores the higher outcome across the 3 bouts.
|
||||||
|
- **Battle Scoring Rules**:
|
||||||
|
- **Winning Party Leader**: Receives **+2 points**.
|
||||||
|
- **Rest of Winning Party**: Each member receives **+1 point**.
|
||||||
|
- **Losing Party Leader**: Receives **-1 point**, loses leadership, and respawns at a random free position.
|
||||||
|
- **Rest of Losing Party**: Loses **0 points** (scores remain unchanged).
|
||||||
|
- **Surrender & Absorption**:
|
||||||
|
- The **remainder of the defeated party joins the winning party**!
|
||||||
|
- Surviving bots have their party affiliation updated to the winning party.
|
||||||
|
- The winning squad grows in size and increases its total strength, and the round continues.
|
||||||
|
|
||||||
### 3. Turn-Based 8-Directional Movement
|
### 4. Turn-Based 8-Directional Movement
|
||||||
- **8 Directions**:
|
- **8 Directions**:
|
||||||
- Cardinal: `UP`, `DOWN`, `LEFT`, `RIGHT`
|
- Cardinal: `UP`, `DOWN`, `LEFT`, `RIGHT`
|
||||||
- Diagonal: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT`
|
- Diagonal: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT`
|
||||||
|
|
@ -40,47 +62,32 @@ A FastAPI and React-based web arena featuring a real-time **64 × 64 grid** with
|
||||||
|
|
||||||
## REST API Endpoints
|
## REST API Endpoints
|
||||||
|
|
||||||
|
### Intelligence & Memory Endpoints
|
||||||
|
- `GET /api/players/{player_id}/memory`: Retrieve a bot's current location, count of visited locations, and visited coordinates history.
|
||||||
|
- `GET /api/players/{player_id}/radar`: Scan for nearby bots, calculate distances, classify allies vs enemies, and provide recommended navigation direction (`seek_join`, `engage_battle`, `explore_unvisited`).
|
||||||
|
|
||||||
### Party & Battle Endpoints
|
### Party & Battle Endpoints
|
||||||
- `POST /api/parties`: Form a party directly.
|
- `POST /api/parties`: Form a party directly.
|
||||||
- Body: `{"member_ids": ["bot_1", "bot_2"], "leader_id": "bot_1", "name": "Squadrons"}`
|
- Body: `{"member_ids": ["bot_1", "bot_2"], "leader_id": "bot_1", "name": "Squadrons"}`
|
||||||
- Validates that members are within 1 distance of each other and agree on the leader.
|
- `GET /api/parties`: List all active parties, leaders, members, and total strength.
|
||||||
- `POST /api/parties/invite`: Propose a party invite to an adjacent bot with an agreed leader.
|
|
||||||
- `POST /api/parties/invites/{invite_id}/respond`: Accept or reject party invite (`{"accept": true}`).
|
|
||||||
- `GET /api/parties`: List all active parties, leaders, and members.
|
|
||||||
- `GET /api/parties/{party_id}`: Get party details.
|
|
||||||
- `POST /api/parties/{party_id}/defeat`: Trigger party defeat.
|
- `POST /api/parties/{party_id}/defeat`: Trigger party defeat.
|
||||||
- Leader is killed, loses 1 point, leaves party, and respawns randomly.
|
- `POST /api/battles/fight`: Initiate a 3-bout D20 battle between two adjacent parties/bots.
|
||||||
- Remainder of party elects a new random leader.
|
|
||||||
- `POST /api/battles/fight`: Initiate a battle between adjacent bots/parties.
|
|
||||||
- Body: `{"challenger_id": "bot_1", "defender_id": "bot_2"}`
|
- Body: `{"challenger_id": "bot_1", "defender_id": "bot_2"}`
|
||||||
|
- Awards **+2 pts** to winning leader, **+1 pt** to winning members, **-1 pt** to losing leader, **0 pts lost** for losing members, and absorbs surviving bots into the winning squad!
|
||||||
|
|
||||||
### Movement & Turn Endpoints
|
### Movement & Turn Endpoints
|
||||||
- `GET /api/players/{player_id}/available-moves`: Checks availability of all 8 directions (for a solo bot or the whole party if leader).
|
- `GET /api/players/{player_id}/available-moves`: Checks availability of all 8 directions (for a solo bot or the whole party if leader).
|
||||||
- `GET /api/players/{player_id}/check-move?direction={DIR}`: Check single direction.
|
- `GET /api/players/{player_id}/check-move?direction={DIR}`: Check single direction.
|
||||||
- `POST /api/players/{player_id}/move`: Move bot or entire party if leader (`{"direction": "UP_RIGHT"}`).
|
- `POST /api/players/{player_id}/move`: Move bot or entire party if leader. Automatically detects and resolves battles upon engagement.
|
||||||
- `POST /api/players/{player_id}/pass`: Pass turn to next player.
|
- `POST /api/players/{player_id}/pass`: Pass turn to next player.
|
||||||
- `GET /api/turn`: Get current turn status, round number, and turn queue.
|
- `GET /api/turn`: Get current turn status, round number, and turn queue.
|
||||||
|
|
||||||
### Player & Board Endpoints
|
### Player & Board Endpoints
|
||||||
- `POST /api/players`: Register a new player with `name` and `color`.
|
- `POST /api/players`: Register a new player with `name`, `color`, and optional `strength` (default 1).
|
||||||
- `GET /api/players`: List active players and their scores.
|
- `GET /api/players`: List active players and their scores.
|
||||||
- `GET /api/board`: Full board state, player positions, parties, and turn data.
|
- `GET /api/board`: Full board state, player positions, parties, and turn data.
|
||||||
- `POST /api/board/reset`: Clear board, players, and parties.
|
- `POST /api/board/reset`: Clear board, players, and parties.
|
||||||
- `/ws`: Real-time WebSocket connection broadcasting player joins, squad movements, party formations, defeats, and turn changes.
|
- `/ws`: Real-time WebSocket connection broadcasting player joins, squad movements, party formations, 3-bout D20 battles, defeats, and turn changes.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Interactive Web UI (React 19 + Vite + Tailwind CSS)
|
|
||||||
|
|
||||||
- **Canvas Arena**:
|
|
||||||
- Laser energy beams connecting adjacent party members.
|
|
||||||
- Crown `👑` on the Party Leader and golden pulsing aura for active turn.
|
|
||||||
- Hover coordinate tracker, zoom & pan controls.
|
|
||||||
- **Controls & Modals**:
|
|
||||||
- **8-Directional D-Pad** with real-time green/red cell availability and keyboard bindings (WASD / Arrows / Numpad).
|
|
||||||
- **Form / Join Party Modal** with auto-detection of nearest adjacent bots and agreed leader selector.
|
|
||||||
- **💥 Defeat Button** on parties in the roster to test the leader death, respawn, and leadership succession flow.
|
|
||||||
- **⚡ Step Bot** & **▶ Auto-Play** simulation mode.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from app.models import (
|
||||||
BattleRequest,
|
BattleRequest,
|
||||||
BattleResult,
|
BattleResult,
|
||||||
BoardState,
|
BoardState,
|
||||||
|
BotMemoryResponse,
|
||||||
|
BotRadarResponse,
|
||||||
MoveCheckResult,
|
MoveCheckResult,
|
||||||
MoveRequest,
|
MoveRequest,
|
||||||
MoveResponse,
|
MoveResponse,
|
||||||
|
|
@ -120,6 +122,46 @@ async def remove_player(player_id: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Bot Memory & Radar Endpoints
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/players/{player_id}/memory",
|
||||||
|
response_model=BotMemoryResponse,
|
||||||
|
summary="Retrieve a bot's current location and visited locations memory",
|
||||||
|
tags=["Bot Intelligence"],
|
||||||
|
)
|
||||||
|
async def get_bot_memory(
|
||||||
|
player_id: str,
|
||||||
|
check_x: Optional[int] = Query(None, description="Check if bot has visited this X coordinate"),
|
||||||
|
check_y: Optional[int] = Query(None, description="Check if bot has visited this Y coordinate"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await game_engine.get_bot_memory(player_id, check_x, check_y)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Player '{player_id}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/players/{player_id}/radar",
|
||||||
|
response_model=BotRadarResponse,
|
||||||
|
summary="Scan surroundings for other bots, distance, allies/enemies, and recommended direction",
|
||||||
|
tags=["Bot Intelligence"],
|
||||||
|
)
|
||||||
|
async def get_bot_radar(player_id: str):
|
||||||
|
try:
|
||||||
|
return await game_engine.get_bot_radar(player_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Player '{player_id}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Party & Squad Endpoints
|
# Party & Squad Endpoints
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
@ -252,7 +294,7 @@ async def defeat_party(party_id: str):
|
||||||
@router.post(
|
@router.post(
|
||||||
"/battles/fight",
|
"/battles/fight",
|
||||||
response_model=BattleResult,
|
response_model=BattleResult,
|
||||||
summary="Initiate battle between two adjacent bots/parties",
|
summary="Initiate a 3-bout D20 battle between two adjacent parties/bots",
|
||||||
tags=["Battles"],
|
tags=["Battles"],
|
||||||
)
|
)
|
||||||
async def fight_battle(battle_req: BattleRequest):
|
async def fight_battle(battle_req: BattleRequest):
|
||||||
|
|
@ -346,6 +388,15 @@ async def move_player(player_id: str, move_req: MoveRequest):
|
||||||
"turn": result.turn.model_dump(),
|
"turn": result.turn.model_dump(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if result.battle_triggered and result.battle_result:
|
||||||
|
await manager.broadcast({
|
||||||
|
"event": "battle_resolved",
|
||||||
|
"battle": result.battle_result.model_dump(),
|
||||||
|
"players": [p.model_dump() for p in board_state.players],
|
||||||
|
"parties": [p.model_dump() for p in board_state.parties],
|
||||||
|
"turn": board_state.turn.model_dump(),
|
||||||
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,12 @@ from typing import Dict, List, Optional, Set, Tuple
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AvailableMovesResponse,
|
AvailableMovesResponse,
|
||||||
|
BattleBout,
|
||||||
BattleResult,
|
BattleResult,
|
||||||
BoardConfig,
|
BoardConfig,
|
||||||
BoardState,
|
BoardState,
|
||||||
|
BotMemoryResponse,
|
||||||
|
BotRadarResponse,
|
||||||
DIRECTION_OFFSETS,
|
DIRECTION_OFFSETS,
|
||||||
MoveCheckResult,
|
MoveCheckResult,
|
||||||
MoveResponse,
|
MoveResponse,
|
||||||
|
|
@ -16,6 +19,7 @@ from app.models import (
|
||||||
PartyInvite,
|
PartyInvite,
|
||||||
Player,
|
Player,
|
||||||
PlayerCreate,
|
PlayerCreate,
|
||||||
|
RadarTarget,
|
||||||
TurnInfo,
|
TurnInfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -81,7 +85,6 @@ class GameEngine:
|
||||||
return random.choice(all_coords)
|
return random.choice(all_coords)
|
||||||
|
|
||||||
def _get_active_turn_actors(self) -> List[str]:
|
def _get_active_turn_actors(self) -> List[str]:
|
||||||
# Return actors that can take turns: party leaders and solo players
|
|
||||||
actors = []
|
actors = []
|
||||||
for pid in self.turn_order:
|
for pid in self.turn_order:
|
||||||
p = self.players.get(pid)
|
p = self.players.get(pid)
|
||||||
|
|
@ -120,6 +123,10 @@ class GameEngine:
|
||||||
if self.current_turn_index == 0:
|
if self.current_turn_index == 0:
|
||||||
self.round_number += 1
|
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)
|
||||||
|
|
||||||
async def register_player(self, player_in: PlayerCreate) -> Player:
|
async def register_player(self, player_in: PlayerCreate) -> Player:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
player_id = f"bot_{uuid.uuid4().hex[:8]}"
|
player_id = f"bot_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
@ -131,9 +138,11 @@ class GameEngine:
|
||||||
color=player_in.color,
|
color=player_in.color,
|
||||||
x=x,
|
x=x,
|
||||||
y=y,
|
y=y,
|
||||||
|
strength=player_in.strength,
|
||||||
score=0,
|
score=0,
|
||||||
party_id=None,
|
party_id=None,
|
||||||
is_party_leader=False,
|
is_party_leader=False,
|
||||||
|
visited_locations=[{"x": x, "y": y}],
|
||||||
)
|
)
|
||||||
self.players[player_id] = player
|
self.players[player_id] = player
|
||||||
self.turn_order.append(player_id)
|
self.turn_order.append(player_id)
|
||||||
|
|
@ -154,7 +163,6 @@ class GameEngine:
|
||||||
|
|
||||||
player = self.players[player_id]
|
player = self.players[player_id]
|
||||||
|
|
||||||
# If player was in a party, clean up party membership
|
|
||||||
if player.party_id and player.party_id in self.parties:
|
if player.party_id and player.party_id in self.parties:
|
||||||
party = self.parties[player.party_id]
|
party = self.parties[player.party_id]
|
||||||
if player_id in party.member_ids:
|
if player_id in party.member_ids:
|
||||||
|
|
@ -167,9 +175,12 @@ class GameEngine:
|
||||||
if new_lead:
|
if new_lead:
|
||||||
new_lead.is_party_leader = True
|
new_lead.is_party_leader = True
|
||||||
party.leader_name = new_lead.name
|
party.leader_name = new_lead.name
|
||||||
|
self._update_party_strength(party)
|
||||||
else:
|
else:
|
||||||
del self.parties[party.id]
|
del self.parties[party.id]
|
||||||
elif not party.member_ids:
|
elif party.member_ids:
|
||||||
|
self._update_party_strength(party)
|
||||||
|
else:
|
||||||
del self.parties[party.id]
|
del self.parties[party.id]
|
||||||
|
|
||||||
del self.players[player_id]
|
del self.players[player_id]
|
||||||
|
|
@ -207,17 +218,106 @@ class GameEngine:
|
||||||
turn=self._get_turn_info(),
|
turn=self._get_turn_info(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
targets: List[RadarTarget] = []
|
||||||
|
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
|
||||||
|
|
||||||
|
targets.append(
|
||||||
|
RadarTarget(
|
||||||
|
id=other.id,
|
||||||
|
name=other.name,
|
||||||
|
color=other.color,
|
||||||
|
x=other.x,
|
||||||
|
y=other.y,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
targets.sort(key=lambda t: t.distance)
|
||||||
|
nearest = 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:
|
||||||
|
rec_act = "engage_battle" if (nearest.is_enemy and nearest.party_id) else "seek_join"
|
||||||
|
else:
|
||||||
|
rec_act = "seek_join" if not nearest.party_id else "engage_battle"
|
||||||
|
|
||||||
|
return BotRadarResponse(
|
||||||
|
player_id=player.id,
|
||||||
|
current_x=player.x,
|
||||||
|
current_y=player.y,
|
||||||
|
targets=targets,
|
||||||
|
nearest_target=nearest,
|
||||||
|
recommended_direction=rec_dir,
|
||||||
|
recommended_action=rec_act,
|
||||||
|
)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Party Logic & Graph Adjacency
|
# Party Logic & Graph Adjacency
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _are_adjacent(p1: Player, p2: Player) -> bool:
|
def _are_adjacent(p1: Player, p2: Player) -> bool:
|
||||||
# Distance <= 1 (cardinal or diagonal Chebyshev distance)
|
|
||||||
return max(abs(p1.x - p2.x), abs(p1.y - p2.y)) <= 1
|
return max(abs(p1.x - p2.x), abs(p1.y - p2.y)) <= 1
|
||||||
|
|
||||||
def _verify_party_connectivity(self, member_players: List[Player]) -> bool:
|
def _verify_party_connectivity(self, member_players: List[Player]) -> bool:
|
||||||
"""Verifies all party members form a single connected component where each is within 1 distance."""
|
|
||||||
if len(member_players) <= 1:
|
if len(member_players) <= 1:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -250,13 +350,11 @@ class GameEngine:
|
||||||
raise KeyError(f"Player '{mid}' not found")
|
raise KeyError(f"Player '{mid}' not found")
|
||||||
member_players.append(p)
|
member_players.append(p)
|
||||||
|
|
||||||
# Check adjacency / connected link
|
|
||||||
if not self._verify_party_connectivity(member_players):
|
if not self._verify_party_connectivity(member_players):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"All party members must be linked within 1 distance of each other (directly or via connected teammates)."
|
"All party members must be linked within 1 distance of each other (directly or via connected teammates)."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clean up any existing party memberships
|
|
||||||
for p in member_players:
|
for p in member_players:
|
||||||
if p.party_id and p.party_id in self.parties:
|
if p.party_id and p.party_id in self.parties:
|
||||||
old_party = self.parties[p.party_id]
|
old_party = self.parties[p.party_id]
|
||||||
|
|
@ -275,6 +373,7 @@ class GameEngine:
|
||||||
leader_id=leader_id,
|
leader_id=leader_id,
|
||||||
leader_name=leader.name,
|
leader_name=leader.name,
|
||||||
member_ids=list(member_ids),
|
member_ids=list(member_ids),
|
||||||
|
total_strength=sum(p.strength for p in member_players),
|
||||||
)
|
)
|
||||||
|
|
||||||
for p in member_players:
|
for p in member_players:
|
||||||
|
|
@ -297,7 +396,6 @@ class GameEngine:
|
||||||
if not inviter or not invitee:
|
if not inviter or not invitee:
|
||||||
raise KeyError("Inviter or invitee not found")
|
raise KeyError("Inviter or invitee not found")
|
||||||
|
|
||||||
# Must be adjacent to inviter or to at least one member of inviter's party
|
|
||||||
eligible_hosts = [inviter]
|
eligible_hosts = [inviter]
|
||||||
if inviter.party_id and inviter.party_id in self.parties:
|
if inviter.party_id and inviter.party_id in self.parties:
|
||||||
party = self.parties[inviter.party_id]
|
party = self.parties[inviter.party_id]
|
||||||
|
|
@ -338,13 +436,11 @@ class GameEngine:
|
||||||
if not inviter or not invitee:
|
if not inviter or not invitee:
|
||||||
raise KeyError("Inviter or invitee no longer active")
|
raise KeyError("Inviter or invitee no longer active")
|
||||||
|
|
||||||
# If inviter is already in a party, add invitee to that party
|
|
||||||
if inviter.party_id and inviter.party_id in self.parties:
|
if inviter.party_id and inviter.party_id in self.parties:
|
||||||
party = self.parties[inviter.party_id]
|
party = self.parties[inviter.party_id]
|
||||||
if invitee.id not in party.member_ids:
|
if invitee.id not in party.member_ids:
|
||||||
party.member_ids.append(invitee.id)
|
party.member_ids.append(invitee.id)
|
||||||
invitee.party_id = party.id
|
invitee.party_id = party.id
|
||||||
# Set agreed leader
|
|
||||||
if invite.proposed_leader_id in party.member_ids:
|
if invite.proposed_leader_id in party.member_ids:
|
||||||
party.leader_id = invite.proposed_leader_id
|
party.leader_id = invite.proposed_leader_id
|
||||||
for mid in party.member_ids:
|
for mid in party.member_ids:
|
||||||
|
|
@ -352,9 +448,9 @@ class GameEngine:
|
||||||
if p:
|
if p:
|
||||||
p.is_party_leader = p.id == party.leader_id
|
p.is_party_leader = p.id == party.leader_id
|
||||||
party.leader_name = self.players[party.leader_id].name
|
party.leader_name = self.players[party.leader_id].name
|
||||||
|
self._update_party_strength(party)
|
||||||
return party
|
return party
|
||||||
|
|
||||||
# Otherwise form new party with both
|
|
||||||
members = [inviter.id, invitee.id]
|
members = [inviter.id, invitee.id]
|
||||||
leader_id = invite.proposed_leader_id if invite.proposed_leader_id in members else inviter.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]}"
|
party_id = f"party_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
@ -365,6 +461,7 @@ class GameEngine:
|
||||||
leader_id=leader_id,
|
leader_id=leader_id,
|
||||||
leader_name=leader.name,
|
leader_name=leader.name,
|
||||||
member_ids=members,
|
member_ids=members,
|
||||||
|
total_strength=inviter.strength + invitee.strength,
|
||||||
)
|
)
|
||||||
inviter.party_id = party_id
|
inviter.party_id = party_id
|
||||||
inviter.is_party_leader = inviter.id == leader_id
|
inviter.is_party_leader = inviter.id == leader_id
|
||||||
|
|
@ -382,7 +479,7 @@ class GameEngine:
|
||||||
return self.parties.get(party_id)
|
return self.parties.get(party_id)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Movement Checking (Single bot or Party)
|
# Movement Checking & Execution
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
def _check_move_internal(
|
def _check_move_internal(
|
||||||
|
|
@ -393,7 +490,6 @@ class GameEngine:
|
||||||
direction_name: str,
|
direction_name: str,
|
||||||
occupied_map: Dict[Tuple[int, int], Player],
|
occupied_map: Dict[Tuple[int, int], Player],
|
||||||
) -> MoveCheckResult:
|
) -> MoveCheckResult:
|
||||||
# If player is part of a party and IS the leader, check if ALL members can translate by (dx, dy)
|
|
||||||
if player.party_id and player.party_id in self.parties and player.is_party_leader:
|
if player.party_id and player.party_id in self.parties and player.is_party_leader:
|
||||||
party = self.parties[player.party_id]
|
party = self.parties[player.party_id]
|
||||||
party_members = [self.players[mid] for mid in party.member_ids if mid in self.players]
|
party_members = [self.players[mid] for mid in party.member_ids if mid in self.players]
|
||||||
|
|
@ -403,7 +499,6 @@ class GameEngine:
|
||||||
tx = m.x + dx
|
tx = m.x + dx
|
||||||
ty = m.y + dy
|
ty = m.y + dy
|
||||||
|
|
||||||
# Wall boundary
|
|
||||||
if tx < self.config.min_x or tx > self.config.max_x or ty < self.config.min_y or ty > self.config.max_y:
|
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(
|
return MoveCheckResult(
|
||||||
direction=direction_name,
|
direction=direction_name,
|
||||||
|
|
@ -415,7 +510,6 @@ class GameEngine:
|
||||||
reason=f"Party member '{m.name}' would hit boundary wall at ({tx}, {ty}).",
|
reason=f"Party member '{m.name}' would hit boundary wall at ({tx}, {ty}).",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Collision with non-party bot
|
|
||||||
occupant = occupied_map.get((tx, ty))
|
occupant = occupied_map.get((tx, ty))
|
||||||
if occupant is not None and occupant.id not in party_member_ids:
|
if occupant is not None and occupant.id not in party_member_ids:
|
||||||
return MoveCheckResult(
|
return MoveCheckResult(
|
||||||
|
|
@ -438,7 +532,6 @@ class GameEngine:
|
||||||
reason=None,
|
reason=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Single player or non-leader
|
|
||||||
target_x = player.x + dx
|
target_x = player.x + dx
|
||||||
target_y = player.y + dy
|
target_y = player.y + dy
|
||||||
|
|
||||||
|
|
@ -481,7 +574,6 @@ class GameEngine:
|
||||||
if not player:
|
if not player:
|
||||||
raise KeyError(f"Player '{player_id}' not found")
|
raise KeyError(f"Player '{player_id}' not found")
|
||||||
|
|
||||||
# Non-leaders cannot move independently
|
|
||||||
if player.party_id and not player.is_party_leader:
|
if player.party_id and not player.is_party_leader:
|
||||||
party = self.parties.get(player.party_id)
|
party = self.parties.get(player.party_id)
|
||||||
leader_name = party.leader_name if party else "Leader"
|
leader_name = party.leader_name if party else "Leader"
|
||||||
|
|
@ -497,9 +589,7 @@ class GameEngine:
|
||||||
|
|
||||||
normalized = direction_input.strip().upper().replace(" ", "_")
|
normalized = direction_input.strip().upper().replace(" ", "_")
|
||||||
if normalized not in DIRECTION_OFFSETS:
|
if normalized not in DIRECTION_OFFSETS:
|
||||||
raise ValueError(
|
raise ValueError(f"Unknown direction '{direction_input}'")
|
||||||
f"Unknown direction '{direction_input}'. Allowed: UP, DOWN, LEFT, RIGHT, UP_LEFT, UP_RIGHT, DOWN_LEFT, DOWN_RIGHT"
|
|
||||||
)
|
|
||||||
|
|
||||||
dx, dy = DIRECTION_OFFSETS[normalized]
|
dx, dy = DIRECTION_OFFSETS[normalized]
|
||||||
occupied = self._get_occupied_coordinates()
|
occupied = self._get_occupied_coordinates()
|
||||||
|
|
@ -555,7 +645,6 @@ class GameEngine:
|
||||||
if not player:
|
if not player:
|
||||||
raise KeyError(f"Player '{player_id}' not found")
|
raise KeyError(f"Player '{player_id}' not found")
|
||||||
|
|
||||||
# Non-leaders cannot move
|
|
||||||
if player.party_id and not player.is_party_leader:
|
if player.party_id and not player.is_party_leader:
|
||||||
party = self.parties.get(player.party_id)
|
party = self.parties.get(player.party_id)
|
||||||
leader_name = party.leader_name if party else "Leader"
|
leader_name = party.leader_name if party else "Leader"
|
||||||
|
|
@ -580,7 +669,7 @@ class GameEngine:
|
||||||
prev_pos = {"x": player.x, "y": player.y}
|
prev_pos = {"x": player.x, "y": player.y}
|
||||||
affected_players: List[Player] = []
|
affected_players: List[Player] = []
|
||||||
|
|
||||||
# If party leader, move all party members in unison
|
# Move party in unison or single bot
|
||||||
if player.party_id and player.party_id in self.parties:
|
if player.party_id and player.party_id in self.parties:
|
||||||
party = self.parties[player.party_id]
|
party = self.parties[player.party_id]
|
||||||
for mid in party.member_ids:
|
for mid in party.member_ids:
|
||||||
|
|
@ -588,14 +677,28 @@ class GameEngine:
|
||||||
if m:
|
if m:
|
||||||
m.x += dx
|
m.x += dx
|
||||||
m.y += dy
|
m.y += dy
|
||||||
|
m.visited_locations.append({"x": m.x, "y": m.y})
|
||||||
affected_players.append(m)
|
affected_players.append(m)
|
||||||
else:
|
else:
|
||||||
player.x = check.target_x
|
player.x = check.target_x
|
||||||
player.y = check.target_y
|
player.y = check.target_y
|
||||||
|
player.visited_locations.append({"x": player.x, "y": player.y})
|
||||||
affected_players.append(player)
|
affected_players.append(player)
|
||||||
|
|
||||||
new_pos = {"x": player.x, "y": player.y}
|
new_pos = {"x": player.x, "y": player.y}
|
||||||
|
|
||||||
|
# Check if moving party / bot engages another 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:
|
||||||
|
# Automatic battle engagement!
|
||||||
|
battle_result = self._resolve_3bout_battle_internal(my_party, opposing_party)
|
||||||
|
battle_triggered = True
|
||||||
|
|
||||||
self._advance_turn()
|
self._advance_turn()
|
||||||
turn_info = self._get_turn_info()
|
turn_info = self._get_turn_info()
|
||||||
|
|
||||||
|
|
@ -607,6 +710,8 @@ class GameEngine:
|
||||||
affected_players=affected_players,
|
affected_players=affected_players,
|
||||||
previous_position=prev_pos,
|
previous_position=prev_pos,
|
||||||
new_position=new_pos,
|
new_position=new_pos,
|
||||||
|
battle_triggered=battle_triggered,
|
||||||
|
battle_result=battle_result,
|
||||||
turn=turn_info,
|
turn=turn_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -628,14 +733,157 @@ class GameEngine:
|
||||||
return self._get_turn_info()
|
return self._get_turn_info()
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Battle & Party Defeat Logic
|
# 3-Bout D20 Battle Mechanics
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
async def defeat_party(self, party_id: str) -> PartyDefeatResult:
|
def _find_adjacent_opposing_party(self, party: Party) -> Optional[Party]:
|
||||||
"""Kills the party leader: score -= 1, respawns at random position, removed from 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
|
||||||
|
|
||||||
and randomly assigns a new leader from the remainder of the party.
|
def _resolve_3bout_battle_internal(self, party1: Party, party2: Party) -> BattleResult:
|
||||||
|
"""Executes a 3-bout battle with a 20-sided die roll multiplier.
|
||||||
|
|
||||||
|
Scores:
|
||||||
|
- Winning leader receives +2 points.
|
||||||
|
- Rest of the winning party receives +1 point.
|
||||||
|
- Losing leader receives -1 point and respawns.
|
||||||
|
- Rest of the losing party loses 0 points and joins the winning party.
|
||||||
"""
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Overall battle winner (most bouts won, or higher total score)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Defeated Party loses leader: leader gets -1 point and respawns
|
||||||
|
killed_leader_id = defeated_party.leader_id
|
||||||
|
killed_leader = self.players.get(killed_leader_id)
|
||||||
|
if killed_leader:
|
||||||
|
killed_leader.score -= 1
|
||||||
|
killed_leader.party_id = None
|
||||||
|
killed_leader.is_party_leader = False
|
||||||
|
rx, ry = self._find_random_free_position()
|
||||||
|
killed_leader.x = rx
|
||||||
|
killed_leader.y = ry
|
||||||
|
killed_leader.visited_locations.append({"x": rx, "y": ry})
|
||||||
|
respawn_pos = {"x": rx, "y": ry}
|
||||||
|
else:
|
||||||
|
respawn_pos = {"x": 0, "y": 0}
|
||||||
|
|
||||||
|
# Rest of losing party loses 0 points (scores remain unchanged)
|
||||||
|
|
||||||
|
# Winning leader receives +2 points, and the rest of the winning party receives +1 point
|
||||||
|
for mid in list(winner_party.member_ids):
|
||||||
|
member = self.players.get(mid)
|
||||||
|
if member:
|
||||||
|
if mid == winner_party.leader_id:
|
||||||
|
member.score += 2
|
||||||
|
else:
|
||||||
|
member.score += 1
|
||||||
|
|
||||||
|
# Remainder of defeated party joins the winning party
|
||||||
|
absorbed_members = []
|
||||||
|
for mid in list(defeated_party.member_ids):
|
||||||
|
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:
|
async with self._lock:
|
||||||
party = self.parties.get(party_id)
|
party = self.parties.get(party_id)
|
||||||
if not party:
|
if not party:
|
||||||
|
|
@ -646,22 +894,18 @@ class GameEngine:
|
||||||
if not leader:
|
if not leader:
|
||||||
raise KeyError(f"Leader '{old_leader_id}' not found")
|
raise KeyError(f"Leader '{old_leader_id}' not found")
|
||||||
|
|
||||||
# 1. Leader killed: score goes down by 1
|
|
||||||
leader.score -= 1
|
leader.score -= 1
|
||||||
|
|
||||||
# 2. Leader is removed from party
|
|
||||||
leader.party_id = None
|
leader.party_id = None
|
||||||
leader.is_party_leader = False
|
leader.is_party_leader = False
|
||||||
if old_leader_id in party.member_ids:
|
if old_leader_id in party.member_ids:
|
||||||
party.member_ids.remove(old_leader_id)
|
party.member_ids.remove(old_leader_id)
|
||||||
|
|
||||||
# 3. Leader respawns at random free position
|
|
||||||
rx, ry = self._find_random_free_position()
|
rx, ry = self._find_random_free_position()
|
||||||
leader.x = rx
|
leader.x = rx
|
||||||
leader.y = ry
|
leader.y = ry
|
||||||
|
leader.visited_locations.append({"x": rx, "y": ry})
|
||||||
respawn_pos = {"x": rx, "y": ry}
|
respawn_pos = {"x": rx, "y": ry}
|
||||||
|
|
||||||
# 4. New leader is randomly assigned from the remainder of the party
|
|
||||||
new_leader_id = None
|
new_leader_id = None
|
||||||
new_leader_name = None
|
new_leader_name = None
|
||||||
party_dissolved = False
|
party_dissolved = False
|
||||||
|
|
@ -673,8 +917,8 @@ class GameEngine:
|
||||||
new_leader.is_party_leader = True
|
new_leader.is_party_leader = True
|
||||||
new_leader_name = new_leader.name
|
new_leader_name = new_leader.name
|
||||||
party.leader_name = new_leader_name
|
party.leader_name = new_leader_name
|
||||||
|
self._update_party_strength(party)
|
||||||
else:
|
else:
|
||||||
# No members left in party
|
|
||||||
del self.parties[party_id]
|
del self.parties[party_id]
|
||||||
party_dissolved = True
|
party_dissolved = True
|
||||||
|
|
||||||
|
|
@ -691,10 +935,6 @@ class GameEngine:
|
||||||
)
|
)
|
||||||
|
|
||||||
async def battle(self, challenger_id: str, defender_id: str) -> BattleResult:
|
async def battle(self, challenger_id: str, defender_id: str) -> BattleResult:
|
||||||
"""Resolves a battle between two adjacent players/bots.
|
|
||||||
|
|
||||||
The losing party's leader is killed, respawns, score -1, and remainder assigns new leader.
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
p1 = self.players.get(challenger_id)
|
p1 = self.players.get(challenger_id)
|
||||||
p2 = self.players.get(defender_id)
|
p2 = self.players.get(defender_id)
|
||||||
|
|
@ -709,93 +949,38 @@ class GameEngine:
|
||||||
f"Players are not adjacent. Combat distance must be <= 1 (current distance: ({abs(p1.x - p2.x)}, {abs(p1.y - p2.y)}))"
|
f"Players are not adjacent. Combat distance must be <= 1 (current distance: ({abs(p1.x - p2.x)}, {abs(p1.y - p2.y)}))"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Party size calculation for battle advantage
|
# Form temporary 1-bot parties if they don't have one
|
||||||
size1 = len(self.parties[p1.party_id].member_ids) if p1.party_id and p1.party_id in self.parties else 1
|
party1 = self.parties.get(p1.party_id) if p1.party_id else None
|
||||||
size2 = len(self.parties[p2.party_id].member_ids) if p2.party_id and p2.party_id in self.parties else 1
|
if not party1:
|
||||||
|
party1_id = f"party_{uuid.uuid4().hex[:8]}"
|
||||||
if size1 > size2:
|
party1 = Party(
|
||||||
winner, loser = p1, p2
|
id=party1_id,
|
||||||
elif size2 > size1:
|
name=f"Squad {p1.name}",
|
||||||
winner, loser = p2, p1
|
leader_id=p1.id,
|
||||||
else:
|
leader_name=p1.name,
|
||||||
# Tied party sizes: 50/50 roll
|
member_ids=[p1.id],
|
||||||
if random.random() < 0.5:
|
total_strength=p1.strength,
|
||||||
winner, loser = p1, p2
|
|
||||||
else:
|
|
||||||
winner, loser = p2, p1
|
|
||||||
|
|
||||||
# Winner score +1
|
|
||||||
winner.score += 1
|
|
||||||
|
|
||||||
# Resolve defeat for loser
|
|
||||||
if loser.party_id and loser.party_id in self.parties:
|
|
||||||
# Must release lock temporarily or run defeat internally
|
|
||||||
party_id = loser.party_id
|
|
||||||
party = self.parties[party_id]
|
|
||||||
old_leader_id = party.leader_id
|
|
||||||
leader = self.players[old_leader_id]
|
|
||||||
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
|
|
||||||
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
|
|
||||||
else:
|
|
||||||
del self.parties[party_id]
|
|
||||||
party_dissolved = True
|
|
||||||
|
|
||||||
defeat_result = 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,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Solo loser
|
|
||||||
loser.score -= 1
|
|
||||||
rx, ry = self._find_random_free_position()
|
|
||||||
loser.x = rx
|
|
||||||
loser.y = ry
|
|
||||||
defeat_result = PartyDefeatResult(
|
|
||||||
party_id="none",
|
|
||||||
killed_leader_id=loser.id,
|
|
||||||
killed_leader_name=loser.name,
|
|
||||||
killed_leader_new_score=loser.score,
|
|
||||||
killed_leader_respawn_position={"x": rx, "y": ry},
|
|
||||||
new_leader_id=None,
|
|
||||||
new_leader_name=None,
|
|
||||||
remaining_members=[],
|
|
||||||
party_dissolved=True,
|
|
||||||
)
|
)
|
||||||
|
p1.party_id = party1_id
|
||||||
|
p1.is_party_leader = True
|
||||||
|
self.parties[party1_id] = party1
|
||||||
|
|
||||||
return BattleResult(
|
party2 = self.parties.get(p2.party_id) if p2.party_id else None
|
||||||
winner_id=winner.id,
|
if not party2:
|
||||||
winner_name=winner.name,
|
party2_id = f"party_{uuid.uuid4().hex[:8]}"
|
||||||
loser_id=loser.id,
|
party2 = Party(
|
||||||
loser_name=loser.name,
|
id=party2_id,
|
||||||
defeat_details=defeat_result,
|
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
|
# Global game engine instance
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ class AvailableMovesResponse(BaseModel):
|
||||||
class PlayerCreate(BaseModel):
|
class PlayerCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=32, description="Display name of the player")
|
name: str = Field(..., min_length=1, max_length=32, description="Display name of the player")
|
||||||
color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name")
|
color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name")
|
||||||
|
strength: int = Field(default=1, ge=1, description="Bot strength (default is 1)")
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -127,9 +128,11 @@ class Player(BaseModel):
|
||||||
color: str
|
color: str
|
||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
|
strength: int = 1
|
||||||
score: int = 0
|
score: int = 0
|
||||||
party_id: Optional[str] = None
|
party_id: Optional[str] = None
|
||||||
is_party_leader: bool = False
|
is_party_leader: bool = False
|
||||||
|
visited_locations: List[Dict[str, int]] = Field(default_factory=list)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -139,6 +142,7 @@ class Party(BaseModel):
|
||||||
leader_id: str
|
leader_id: str
|
||||||
leader_name: str
|
leader_name: str
|
||||||
member_ids: List[str]
|
member_ids: List[str]
|
||||||
|
total_strength: int = 1
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -181,17 +185,87 @@ class PartyDefeatResult(BaseModel):
|
||||||
party_dissolved: bool = False
|
party_dissolved: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3-Bout D20 Battle Models
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
class BattleBout(BaseModel):
|
||||||
|
bout_number: int
|
||||||
|
party1_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for party 1")
|
||||||
|
party1_strength: int
|
||||||
|
party1_score: int = Field(..., description="party1_strength * party1_roll")
|
||||||
|
party2_roll: int = Field(..., ge=1, le=20, description="D20 roll (1 to 20) for party 2")
|
||||||
|
party2_strength: int
|
||||||
|
party2_score: int = Field(..., description="party2_strength * party2_roll")
|
||||||
|
winner_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BattleResult(BaseModel):
|
||||||
|
bouts: List[BattleBout]
|
||||||
|
party1_name: str
|
||||||
|
party2_name: str
|
||||||
|
party1_bouts_won: int
|
||||||
|
party2_bouts_won: int
|
||||||
|
party1_total_score: int
|
||||||
|
party2_total_score: int
|
||||||
|
winner_party_id: str
|
||||||
|
winner_party_name: str
|
||||||
|
winner_leader_id: str
|
||||||
|
winner_leader_name: str
|
||||||
|
defeated_party_id: str
|
||||||
|
defeated_party_name: str
|
||||||
|
killed_leader_id: str
|
||||||
|
killed_leader_name: str
|
||||||
|
killed_leader_new_score: int
|
||||||
|
killed_leader_respawn_position: Dict[str, int]
|
||||||
|
absorbed_members: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Remaining bots from the defeated party that merged into the winning party",
|
||||||
|
)
|
||||||
|
new_party_size: int
|
||||||
|
new_party_strength: int
|
||||||
|
|
||||||
|
|
||||||
class BattleRequest(BaseModel):
|
class BattleRequest(BaseModel):
|
||||||
challenger_id: str
|
challenger_id: str
|
||||||
defender_id: str
|
defender_id: str
|
||||||
|
|
||||||
|
|
||||||
class BattleResult(BaseModel):
|
# ==========================================
|
||||||
winner_id: str
|
# Memory & Radar Models
|
||||||
winner_name: str
|
# ==========================================
|
||||||
loser_id: str
|
|
||||||
loser_name: str
|
class BotMemoryResponse(BaseModel):
|
||||||
defeat_details: PartyDefeatResult
|
player_id: str
|
||||||
|
player_name: str
|
||||||
|
current_x: int
|
||||||
|
current_y: int
|
||||||
|
visited_count: int
|
||||||
|
visited_history: List[Dict[str, int]]
|
||||||
|
has_visited_current: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class RadarTarget(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
distance: int
|
||||||
|
party_id: Optional[str] = None
|
||||||
|
party_name: Optional[str] = None
|
||||||
|
is_ally: bool
|
||||||
|
is_enemy: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BotRadarResponse(BaseModel):
|
||||||
|
player_id: str
|
||||||
|
current_x: int
|
||||||
|
current_y: int
|
||||||
|
targets: List[RadarTarget]
|
||||||
|
nearest_target: Optional[RadarTarget] = None
|
||||||
|
recommended_direction: Optional[str] = None
|
||||||
|
recommended_action: str # "seek_join", "engage_battle", "explore_unvisited"
|
||||||
|
|
||||||
|
|
||||||
class BoardConfig(BaseModel):
|
class BoardConfig(BaseModel):
|
||||||
|
|
@ -227,6 +301,8 @@ class MoveResponse(BaseModel):
|
||||||
affected_players: List[Player] = []
|
affected_players: List[Player] = []
|
||||||
previous_position: Dict[str, int]
|
previous_position: Dict[str, int]
|
||||||
new_position: Dict[str, int]
|
new_position: Dict[str, int]
|
||||||
|
battle_triggered: bool = False
|
||||||
|
battle_result: Optional[BattleResult] = None
|
||||||
turn: TurnInfo
|
turn: TurnInfo
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,152 @@ def reset_board_state():
|
||||||
asyncio.run(game_engine.reset())
|
asyncio.run(game_engine.reset())
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
# 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}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Move p1
|
||||||
|
res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
|
||||||
|
assert res_move.status_code == 200
|
||||||
|
|
||||||
|
# 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}
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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():
|
def test_party_formation_and_group_movement():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
p1 = client.post("/api/players", json={"name": "LeaderBot", "color": "#00FFAA"}).json()
|
p1 = client.post("/api/players", json={"name": "LeaderBot", "color": "#00FFAA"}).json()
|
||||||
p2 = client.post("/api/players", json={"name": "FollowerBot", "color": "#FF00AA"}).json()
|
p2 = client.post("/api/players", json={"name": "FollowerBot", "color": "#FF00AA"}).json()
|
||||||
|
|
||||||
# Place adjacent: Leader at (10, 10), Follower at (10, 11) (within distance 1)
|
|
||||||
import asyncio
|
import asyncio
|
||||||
async def place_adjacent():
|
async def place_adjacent():
|
||||||
b1 = await game_engine.get_player(p1["id"])
|
b1 = await game_engine.get_player(p1["id"])
|
||||||
|
|
@ -26,7 +166,6 @@ def test_party_formation_and_group_movement():
|
||||||
b2.x, b2.y = 10, 11
|
b2.x, b2.y = 10, 11
|
||||||
asyncio.run(place_adjacent())
|
asyncio.run(place_adjacent())
|
||||||
|
|
||||||
# Form party with agreed leader p1
|
|
||||||
form_res = client.post("/api/parties", json={
|
form_res = client.post("/api/parties", json={
|
||||||
"member_ids": [p1["id"], p2["id"]],
|
"member_ids": [p1["id"], p2["id"]],
|
||||||
"leader_id": p1["id"],
|
"leader_id": p1["id"],
|
||||||
|
|
@ -37,34 +176,21 @@ def test_party_formation_and_group_movement():
|
||||||
assert party["leader_id"] == p1["id"]
|
assert party["leader_id"] == p1["id"]
|
||||||
assert len(party["member_ids"]) == 2
|
assert len(party["member_ids"]) == 2
|
||||||
|
|
||||||
# Follower cannot move individually
|
|
||||||
res_follow = client.post(f"/api/players/{p2['id']}/move", json={"direction": "UP"})
|
res_follow = client.post(f"/api/players/{p2['id']}/move", json={"direction": "UP"})
|
||||||
assert res_follow.status_code == 403
|
assert res_follow.status_code == 403
|
||||||
assert "Only party leader" in res_follow.json()["detail"]
|
|
||||||
|
|
||||||
# Leader moves UP: Leader moves from (10,10)->(10,9), Follower moves from (10,11)->(10,10)
|
|
||||||
res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
|
res_move = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
|
||||||
assert res_move.status_code == 200
|
assert res_move.status_code == 200
|
||||||
move_data = res_move.json()
|
move_data = res_move.json()
|
||||||
assert move_data["party_moved"] is True
|
assert move_data["party_moved"] is True
|
||||||
assert len(move_data["affected_players"]) == 2
|
assert len(move_data["affected_players"]) == 2
|
||||||
|
|
||||||
# Check positions in board
|
|
||||||
board = client.get("/api/board").json()
|
|
||||||
bots_by_id = {b["id"]: b for b in board["players"]}
|
|
||||||
assert bots_by_id[p1["id"]]["x"] == 10
|
|
||||||
assert bots_by_id[p1["id"]]["y"] == 9
|
|
||||||
assert bots_by_id[p2["id"]]["x"] == 10
|
|
||||||
assert bots_by_id[p2["id"]]["y"] == 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_party_wall_collision_for_follower():
|
def test_party_wall_collision_for_follower():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
p1 = client.post("/api/players", json={"name": "Leader", "color": "#111111"}).json()
|
p1 = client.post("/api/players", json={"name": "Leader", "color": "#111111"}).json()
|
||||||
p2 = client.post("/api/players", json={"name": "Follower", "color": "#222222"}).json()
|
p2 = client.post("/api/players", json={"name": "Follower", "color": "#222222"}).json()
|
||||||
|
|
||||||
# Place Leader at (5, 1), Follower at (5, 0)
|
|
||||||
# Moving UP would push Follower to (5, -1) which hits the wall!
|
|
||||||
import asyncio
|
import asyncio
|
||||||
async def setup_near_wall():
|
async def setup_near_wall():
|
||||||
b1 = await game_engine.get_player(p1["id"])
|
b1 = await game_engine.get_player(p1["id"])
|
||||||
|
|
@ -78,100 +204,6 @@ def test_party_wall_collision_for_follower():
|
||||||
"leader_id": p1["id"],
|
"leader_id": p1["id"],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Available moves check should mark UP as blocked due to follower hitting wall
|
|
||||||
moves = client.get(f"/api/players/{p1['id']}/available-moves").json()
|
moves = client.get(f"/api/players/{p1['id']}/available-moves").json()
|
||||||
assert moves["moves"]["UP"]["available"] is False
|
assert moves["moves"]["UP"]["available"] is False
|
||||||
assert "boundary wall" in moves["moves"]["UP"]["reason"]
|
assert "boundary wall" in moves["moves"]["UP"]["reason"]
|
||||||
|
|
||||||
# Trying to move UP should fail
|
|
||||||
res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
|
|
||||||
assert res.status_code == 400
|
|
||||||
assert "Illegal move" in res.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_party_defeat_and_leader_respawn():
|
|
||||||
client = TestClient(app)
|
|
||||||
p1 = client.post("/api/players", json={"name": "LeaderToDie", "color": "#AA0000"}).json()
|
|
||||||
p2 = client.post("/api/players", json={"name": "Survivor1", "color": "#00AA00"}).json()
|
|
||||||
p3 = client.post("/api/players", json={"name": "Survivor2", "color": "#0000AA"}).json()
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
async def place_trio():
|
|
||||||
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
|
|
||||||
asyncio.run(place_trio())
|
|
||||||
|
|
||||||
party = client.post("/api/parties", json={
|
|
||||||
"member_ids": [p1["id"], p2["id"], p3["id"]],
|
|
||||||
"leader_id": p1["id"],
|
|
||||||
}).json()
|
|
||||||
|
|
||||||
# Trigger defeat on party
|
|
||||||
defeat_res = client.post(f"/api/parties/{party['id']}/defeat")
|
|
||||||
assert defeat_res.status_code == 200
|
|
||||||
data = defeat_res.json()
|
|
||||||
|
|
||||||
# 1. Leader was killed, score is -1
|
|
||||||
assert data["killed_leader_id"] == p1["id"]
|
|
||||||
assert data["killed_leader_new_score"] == -1
|
|
||||||
# 2. Leader respawned position is recorded
|
|
||||||
assert "x" in data["killed_leader_respawn_position"]
|
|
||||||
# 3. New leader randomly assigned from remaining members
|
|
||||||
assert data["new_leader_id"] in [p2["id"], p3["id"]]
|
|
||||||
assert len(data["remaining_members"]) == 2
|
|
||||||
|
|
||||||
# Verify leader is no longer part of the party
|
|
||||||
dead_leader = client.get(f"/api/players/{p1['id']}").json()
|
|
||||||
assert dead_leader["party_id"] is None
|
|
||||||
assert dead_leader["is_party_leader"] is False
|
|
||||||
assert dead_leader["score"] == -1
|
|
||||||
|
|
||||||
# Verify new leader in party
|
|
||||||
updated_party = client.get(f"/api/parties/{party['id']}").json()
|
|
||||||
assert updated_party["leader_id"] == data["new_leader_id"]
|
|
||||||
assert p1["id"] not in updated_party["member_ids"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_battle_between_parties():
|
|
||||||
client = TestClient(app)
|
|
||||||
# Party 1 has 2 bots
|
|
||||||
p1 = client.post("/api/players", json={"name": "Warrior1", "color": "#111111"}).json()
|
|
||||||
p2 = client.post("/api/players", json={"name": "Warrior2", "color": "#222222"}).json()
|
|
||||||
# Party 2 has 1 solo bot
|
|
||||||
p3 = client.post("/api/players", json={"name": "SoloDefender", "color": "#333333"}).json()
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
async def place_combatants():
|
|
||||||
b1 = await game_engine.get_player(p1["id"])
|
|
||||||
b1.x, b1.y = 30, 30
|
|
||||||
b2 = await game_engine.get_player(p2["id"])
|
|
||||||
b2.x, b2.y = 30, 31
|
|
||||||
b3 = await game_engine.get_player(p3["id"])
|
|
||||||
b3.x, b3.y = 31, 30 # adjacent to b1
|
|
||||||
asyncio.run(place_combatants())
|
|
||||||
|
|
||||||
client.post("/api/parties", json={
|
|
||||||
"member_ids": [p1["id"], p2["id"]],
|
|
||||||
"leader_id": p1["id"],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Initiate battle: 2-bot party vs 1-bot solo -> Party 1 wins
|
|
||||||
res = client.post("/api/battles/fight", json={
|
|
||||||
"challenger_id": p1["id"],
|
|
||||||
"defender_id": p3["id"],
|
|
||||||
})
|
|
||||||
assert res.status_code == 200
|
|
||||||
battle = res.json()
|
|
||||||
assert battle["winner_id"] == p1["id"]
|
|
||||||
assert battle["loser_id"] == p3["id"]
|
|
||||||
|
|
||||||
# Check loser score = -1
|
|
||||||
loser_bot = client.get(f"/api/players/{p3['id']}").json()
|
|
||||||
assert loser_bot["score"] == -1
|
|
||||||
# Check winner score = +1
|
|
||||||
winner_bot = client.get(f"/api/players/{p1['id']}").json()
|
|
||||||
assert winner_bot["score"] == 1
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useGameSocket } from './hooks/useGameSocket';
|
import { useGameSocket } from './hooks/useGameSocket';
|
||||||
|
import { BattleModal } from './components/BattleModal';
|
||||||
import { BoardCanvas } from './components/BoardCanvas';
|
import { BoardCanvas } from './components/BoardCanvas';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { MovementControls } from './components/MovementControls';
|
import { MovementControls } from './components/MovementControls';
|
||||||
|
|
@ -41,10 +42,13 @@ export function App() {
|
||||||
isAutoPlaying,
|
isAutoPlaying,
|
||||||
setIsAutoPlaying,
|
setIsAutoPlaying,
|
||||||
lastEventMessage,
|
lastEventMessage,
|
||||||
|
activeBattle,
|
||||||
|
setActiveBattle,
|
||||||
registerPlayer,
|
registerPlayer,
|
||||||
removePlayer,
|
removePlayer,
|
||||||
formParty,
|
formParty,
|
||||||
defeatParty,
|
defeatParty,
|
||||||
|
fightBattle,
|
||||||
movePlayer,
|
movePlayer,
|
||||||
passTurn,
|
passTurn,
|
||||||
stepActiveBotTurn,
|
stepActiveBotTurn,
|
||||||
|
|
@ -105,6 +109,16 @@ export function App() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFightBattle = async (challengerId: string, defenderId: string) => {
|
||||||
|
try {
|
||||||
|
await fightBattle(challengerId, defenderId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
showNotification(`Battle error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
|
<div className="flex flex-col h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
|
||||||
{/* Top Navigation Bar */}
|
{/* Top Navigation Bar */}
|
||||||
|
|
@ -150,11 +164,12 @@ export function App() {
|
||||||
onRemovePlayer={removePlayer}
|
onRemovePlayer={removePlayer}
|
||||||
onOpenPartyModal={() => setIsPartyModalOpen(true)}
|
onOpenPartyModal={() => setIsPartyModalOpen(true)}
|
||||||
onDefeatParty={handleDefeatParty}
|
onDefeatParty={handleDefeatParty}
|
||||||
|
onFightBattle={handleFightBattle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Live Event Feed Notification */}
|
{/* Live Event Feed Notification */}
|
||||||
{(lastEventMessage || notification) && (
|
{(lastEventMessage || notification) && (
|
||||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 bg-slate-900/95 border border-sky-500/60 text-sky-200 px-4 py-2.5 rounded-xl shadow-2xl backdrop-blur-md text-xs font-mono flex items-center gap-2 max-w-lg truncate">
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-40 bg-slate-900/95 border border-sky-500/60 text-sky-200 px-4 py-2.5 rounded-xl shadow-2xl backdrop-blur-md text-xs font-mono flex items-center gap-2 max-w-lg truncate pointer-events-none">
|
||||||
<span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
|
||||||
<span className="truncate">{notification || lastEventMessage}</span>
|
<span className="truncate">{notification || lastEventMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,6 +196,12 @@ export function App() {
|
||||||
showNotification(`Party "${party.name}" established under leader ${party.leader_name}!`);
|
showNotification(`Party "${party.name}" established under leader ${party.leader_name}!`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 3-Bout D20 Battle Modal */}
|
||||||
|
<BattleModal
|
||||||
|
battle={activeBattle}
|
||||||
|
onClose={() => setActiveBattle(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import React from 'react';
|
||||||
|
import type { BattleResult } from '../types';
|
||||||
|
|
||||||
|
interface BattleModalProps {
|
||||||
|
battle: BattleResult | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BattleModal: React.FC<BattleModalProps> = ({ battle, onClose }) => {
|
||||||
|
if (!battle) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-slate-900 border-2 border-amber-500/80 rounded-2xl w-full max-w-xl shadow-2xl p-6 relative overflow-hidden">
|
||||||
|
{/* 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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">⚔️</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-slate-100 font-mono tracking-wide">
|
||||||
|
PARTY ENGAGEMENT: 3-BOUT D20 CLASH
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{battle.party1_name} vs {battle.party2_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-slate-200 p-1 text-sm font-mono"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 Bouts Breakdown */}
|
||||||
|
<div className="space-y-3 mb-5">
|
||||||
|
{battle.bouts.map((bout) => (
|
||||||
|
<div
|
||||||
|
key={bout.bout_number}
|
||||||
|
className="bg-slate-950/80 border border-slate-800 rounded-xl p-3 text-xs font-mono"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-2 pb-1 border-b border-slate-800/80">
|
||||||
|
<span className="text-amber-400 font-bold">BOUT #{bout.bout_number}</span>
|
||||||
|
<span className="text-slate-300">
|
||||||
|
Winner: <strong className="text-emerald-400">{bout.winner_name || 'Tied'}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Party 1 */}
|
||||||
|
<div className="p-2 rounded-lg bg-slate-900/90 border border-slate-800">
|
||||||
|
<div className="text-slate-400 font-semibold truncate mb-1">
|
||||||
|
{battle.party1_name}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-slate-300">
|
||||||
|
<span>
|
||||||
|
Str: {bout.party1_strength} × 🎲 {bout.party1_roll} =
|
||||||
|
</span>
|
||||||
|
<strong className="text-sm text-sky-400">{bout.party1_score}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Party 2 */}
|
||||||
|
<div className="p-2 rounded-lg bg-slate-900/90 border border-slate-800">
|
||||||
|
<div className="text-slate-400 font-semibold truncate mb-1">
|
||||||
|
{battle.party2_name}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-slate-300">
|
||||||
|
<span>
|
||||||
|
Str: {bout.party2_strength} × 🎲 {bout.party2_roll} =
|
||||||
|
</span>
|
||||||
|
<strong className="text-sm text-sky-400">{bout.party2_score}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Battle Resolution Summary */}
|
||||||
|
<div className="bg-amber-950/20 border border-amber-500/40 rounded-xl p-4 text-xs font-mono space-y-2 mb-5">
|
||||||
|
<div className="flex items-center justify-between text-sm font-bold">
|
||||||
|
<span className="text-amber-300 flex items-center gap-1.5">
|
||||||
|
<span>🏆</span> VICTORY: {battle.winner_party_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400 text-xs font-normal">
|
||||||
|
Bouts: {battle.party1_bouts_won} - {battle.party2_bouts_won}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-slate-300 leading-relaxed">
|
||||||
|
• Winning Leader <strong className="text-amber-300">{battle.winner_leader_name}</strong> receives{' '}
|
||||||
|
<span className="text-emerald-400 font-bold">+2 points</span>, and all other winning squad members receive{' '}
|
||||||
|
<span className="text-emerald-400 font-bold">+1 point</span>!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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:{' '}
|
||||||
|
<strong className="text-amber-400">{battle.new_party_strength}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Continue Button */}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Acknowledge & Continue Battle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,7 @@ interface PlayerListProps {
|
||||||
onRemovePlayer: (id: string) => void;
|
onRemovePlayer: (id: string) => void;
|
||||||
onOpenPartyModal?: () => void;
|
onOpenPartyModal?: () => void;
|
||||||
onDefeatParty?: (partyId: string) => void;
|
onDefeatParty?: (partyId: string) => void;
|
||||||
|
onFightBattle?: (challengerId: string, defenderId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerList: React.FC<PlayerListProps> = ({
|
export const PlayerList: React.FC<PlayerListProps> = ({
|
||||||
|
|
@ -69,14 +70,14 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-slate-400 font-mono">
|
<div className="text-[10px] text-slate-400 font-mono">
|
||||||
Leader: <strong className="text-amber-300">{party.leader_name}</strong> •{' '}
|
Leader: <strong className="text-amber-300">{party.leader_name}</strong> •{' '}
|
||||||
{party.member_ids.length} members
|
{party.member_ids.length} bots • <span className="text-amber-400 font-bold">⚡{party.total_strength}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{onDefeatParty && (
|
{onDefeatParty && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onDefeatParty(party.id)}
|
onClick={() => onDefeatParty(party.id)}
|
||||||
className="ml-2 bg-rose-950/80 hover:bg-rose-900 text-rose-300 border border-rose-700/60 px-2 py-1 rounded text-[10px] font-mono transition-all"
|
className="ml-2 bg-rose-950/80 hover:bg-rose-900 text-rose-300 border border-rose-700/60 px-2 py-1 rounded text-[10px] font-mono transition-all"
|
||||||
title="Defeat party: kills leader (-1 pt), leader respawns, remainder elects new leader"
|
title="Defeat party: leader killed (-1 pt), leader respawns, remainder assigns new leader"
|
||||||
>
|
>
|
||||||
💥 Defeat
|
💥 Defeat
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -167,7 +168,7 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] font-mono text-slate-400">
|
<div className="text-[11px] font-mono text-slate-400">
|
||||||
pos: <span className="text-emerald-400">({player.x}, {player.y})</span>
|
pos: <span className="text-emerald-400">({player.x}, {player.y})</span> • Str: <span className="text-amber-400">⚡{player.strength || 1}</span>
|
||||||
{partyName && (
|
{partyName && (
|
||||||
<span className="ml-1 text-sky-400 truncate font-sans">
|
<span className="ml-1 text-sky-400 truncate font-sans">
|
||||||
• {isLeader ? 'Leader' : 'Squad'}
|
• {isLeader ? 'Leader' : 'Squad'}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export function useGameSocket() {
|
||||||
const [availableMoves, setAvailableMoves] = useState<AvailableMovesResponse | null>(null);
|
const [availableMoves, setAvailableMoves] = useState<AvailableMovesResponse | null>(null);
|
||||||
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
|
const [isAutoPlaying, setIsAutoPlaying] = useState(false);
|
||||||
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
|
const [lastEventMessage, setLastEventMessage] = useState<string | null>(null);
|
||||||
|
const [activeBattle, setActiveBattle] = useState<BattleResult | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
@ -159,7 +160,8 @@ export function useGameSocket() {
|
||||||
turn: data.turn ?? prev.turn,
|
turn: data.turn ?? prev.turn,
|
||||||
}));
|
}));
|
||||||
const b: BattleResult = data.battle;
|
const b: BattleResult = data.battle;
|
||||||
setLastEventMessage(`⚔️ Battle Victory: ${b.winner_name} defeated ${b.loser_name}!`);
|
setActiveBattle(b);
|
||||||
|
setLastEventMessage(`⚔️ 3-Bout D20 Battle Victory: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
|
||||||
} else if (data.event === 'turn_passed') {
|
} else if (data.event === 'turn_passed') {
|
||||||
setBoardState((prev) => ({
|
setBoardState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -187,6 +189,7 @@ export function useGameSocket() {
|
||||||
}));
|
}));
|
||||||
setSelectedPlayer(null);
|
setSelectedPlayer(null);
|
||||||
setAvailableMoves(null);
|
setAvailableMoves(null);
|
||||||
|
setActiveBattle(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error parsing WebSocket message:', err);
|
console.error('Error parsing WebSocket message:', err);
|
||||||
|
|
@ -220,7 +223,6 @@ export function useGameSocket() {
|
||||||
};
|
};
|
||||||
}, [fetchBoard, connectWebSocket]);
|
}, [fetchBoard, connectWebSocket]);
|
||||||
|
|
||||||
// Keep selected player synced with board state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPlayer) {
|
if (selectedPlayer) {
|
||||||
const updated = boardState.players.find((p) => p.id === selectedPlayer.id);
|
const updated = boardState.players.find((p) => p.id === selectedPlayer.id);
|
||||||
|
|
@ -233,7 +235,6 @@ export function useGameSocket() {
|
||||||
}
|
}
|
||||||
}, [boardState.players, selectedPlayer]);
|
}, [boardState.players, selectedPlayer]);
|
||||||
|
|
||||||
// Auto-fetch available moves for active player or selected player
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const targetId = selectedPlayer?.id || boardState.turn.current_player_id;
|
const targetId = selectedPlayer?.id || boardState.turn.current_player_id;
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
|
|
@ -304,6 +305,7 @@ export function useGameSocket() {
|
||||||
throw new Error(err.detail || 'Battle failed');
|
throw new Error(err.detail || 'Battle failed');
|
||||||
}
|
}
|
||||||
const result: BattleResult = await res.json();
|
const result: BattleResult = await res.json();
|
||||||
|
setActiveBattle(result);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -318,6 +320,9 @@ export function useGameSocket() {
|
||||||
throw new Error(err.detail || 'Failed to move player');
|
throw new Error(err.detail || 'Failed to move player');
|
||||||
}
|
}
|
||||||
const data: MoveResponse = await res.json();
|
const data: MoveResponse = await res.json();
|
||||||
|
if (data.battle_triggered && data.battle_result) {
|
||||||
|
setActiveBattle(data.battle_result);
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -342,12 +347,22 @@ export function useGameSocket() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step a single turn for the active bot / party leader
|
// Step a single turn: Bot seeking behavior using Radar and Memory
|
||||||
const stepActiveBotTurn = useCallback(async () => {
|
const stepActiveBotTurn = useCallback(async () => {
|
||||||
const currentId = boardState.turn.current_player_id;
|
const currentId = boardState.turn.current_player_id;
|
||||||
if (!currentId) return;
|
if (!currentId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Check radar for nearest bot to seek/join or engage
|
||||||
|
const radarRes = await fetch(`/api/players/${currentId}/radar`).catch(() => null);
|
||||||
|
let targetDirection: string | null = null;
|
||||||
|
|
||||||
|
if (radarRes && radarRes.ok) {
|
||||||
|
const radarData = await radarRes.json();
|
||||||
|
targetDirection = radarData.recommended_direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check available moves
|
||||||
const movesData = await fetchAvailableMoves(currentId);
|
const movesData = await fetchAvailableMoves(currentId);
|
||||||
if (!movesData) return;
|
if (!movesData) return;
|
||||||
|
|
||||||
|
|
@ -356,8 +371,12 @@ export function useGameSocket() {
|
||||||
.map(([dir]) => dir);
|
.map(([dir]) => dir);
|
||||||
|
|
||||||
if (validDirections.length > 0) {
|
if (validDirections.length > 0) {
|
||||||
const randomDir = validDirections[Math.floor(Math.random() * validDirections.length)];
|
// Prefer recommended direction towards nearest bot if available
|
||||||
await movePlayer(currentId, randomDir);
|
let chosenDir = targetDirection && validDirections.includes(targetDirection) ? targetDirection : null;
|
||||||
|
if (!chosenDir) {
|
||||||
|
chosenDir = validDirections[Math.floor(Math.random() * validDirections.length)];
|
||||||
|
}
|
||||||
|
await movePlayer(currentId, chosenDir);
|
||||||
} else {
|
} else {
|
||||||
await passTurn(currentId);
|
await passTurn(currentId);
|
||||||
}
|
}
|
||||||
|
|
@ -393,6 +412,8 @@ export function useGameSocket() {
|
||||||
isAutoPlaying,
|
isAutoPlaying,
|
||||||
setIsAutoPlaying,
|
setIsAutoPlaying,
|
||||||
lastEventMessage,
|
lastEventMessage,
|
||||||
|
activeBattle,
|
||||||
|
setActiveBattle,
|
||||||
registerPlayer,
|
registerPlayer,
|
||||||
removePlayer,
|
removePlayer,
|
||||||
formParty,
|
formParty,
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ export interface Player {
|
||||||
color: string;
|
color: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
strength: number;
|
||||||
score: number;
|
score: number;
|
||||||
party_id?: string | null;
|
party_id?: string | null;
|
||||||
is_party_leader?: boolean;
|
is_party_leader?: boolean;
|
||||||
|
visited_locations?: { x: number; y: number }[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,6 +18,7 @@ export interface Party {
|
||||||
leader_id: string;
|
leader_id: string;
|
||||||
leader_name: string;
|
leader_name: string;
|
||||||
member_ids: string[];
|
member_ids: string[];
|
||||||
|
total_strength: number;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,6 +50,7 @@ export interface BoardState {
|
||||||
export interface PlayerCreateRequest {
|
export interface PlayerCreateRequest {
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
strength?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MoveCheckResult {
|
export interface MoveCheckResult {
|
||||||
|
|
@ -72,6 +76,40 @@ export interface AvailableMovesResponse {
|
||||||
moves: Record<string, MoveCheckResult>;
|
moves: Record<string, MoveCheckResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BattleBout {
|
||||||
|
bout_number: number;
|
||||||
|
party1_roll: number;
|
||||||
|
party1_strength: number;
|
||||||
|
party1_score: number;
|
||||||
|
party2_roll: number;
|
||||||
|
party2_strength: number;
|
||||||
|
party2_score: number;
|
||||||
|
winner_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleResult {
|
||||||
|
bouts: BattleBout[];
|
||||||
|
party1_name: string;
|
||||||
|
party2_name: string;
|
||||||
|
party1_bouts_won: number;
|
||||||
|
party2_bouts_won: number;
|
||||||
|
party1_total_score: number;
|
||||||
|
party2_total_score: number;
|
||||||
|
winner_party_id: string;
|
||||||
|
winner_party_name: string;
|
||||||
|
winner_leader_id: string;
|
||||||
|
winner_leader_name: string;
|
||||||
|
defeated_party_id: string;
|
||||||
|
defeated_party_name: string;
|
||||||
|
killed_leader_id: string;
|
||||||
|
killed_leader_name: string;
|
||||||
|
killed_leader_new_score: number;
|
||||||
|
killed_leader_respawn_position: { x: number; y: number };
|
||||||
|
absorbed_members: string[];
|
||||||
|
new_party_size: number;
|
||||||
|
new_party_strength: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MoveResponse {
|
export interface MoveResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
player: Player;
|
player: Player;
|
||||||
|
|
@ -80,6 +118,8 @@ export interface MoveResponse {
|
||||||
affected_players?: Player[];
|
affected_players?: Player[];
|
||||||
previous_position: { x: number; y: number };
|
previous_position: { x: number; y: number };
|
||||||
new_position: { x: number; y: number };
|
new_position: { x: number; y: number };
|
||||||
|
battle_triggered?: boolean;
|
||||||
|
battle_result?: BattleResult | null;
|
||||||
turn: TurnInfo;
|
turn: TurnInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,10 +135,35 @@ export interface PartyDefeatResult {
|
||||||
party_dissolved: boolean;
|
party_dissolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BattleResult {
|
export interface BotMemoryResponse {
|
||||||
winner_id: string;
|
player_id: string;
|
||||||
winner_name: string;
|
player_name: string;
|
||||||
loser_id: string;
|
current_x: number;
|
||||||
loser_name: string;
|
current_y: number;
|
||||||
defeat_details: PartyDefeatResult;
|
visited_count: number;
|
||||||
|
visited_history: { x: number; y: number }[];
|
||||||
|
has_visited_current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadarTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
distance: number;
|
||||||
|
party_id?: string | null;
|
||||||
|
party_name?: string | null;
|
||||||
|
is_ally: boolean;
|
||||||
|
is_enemy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotRadarResponse {
|
||||||
|
player_id: string;
|
||||||
|
current_x: number;
|
||||||
|
current_y: number;
|
||||||
|
targets: RadarTarget[];
|
||||||
|
nearest_target?: RadarTarget | null;
|
||||||
|
recommended_direction?: string | null;
|
||||||
|
recommended_action: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue