goals to form parties and battle setup
This commit is contained in:
parent
f78df72cad
commit
df75f43214
118
README.md
118
README.md
|
|
@ -1,93 +1,83 @@
|
||||||
# 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, 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**.
|
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, navigate autonomously towards their goals, negotiate **Parties** based on relative strength, take turns moving around the arena, and engage in **3-bout D20 tactical battles**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Autonomous Goals & Behaviors
|
||||||
|
|
||||||
### 1. Bot Location Memory & Awareness
|
### 1. Goal of a Bot Without a Party: Form a Party
|
||||||
- **Location Awareness**:
|
- **Primary Objective**: Seek other bots across the arena and form a party as soon as contact is made.
|
||||||
- Every bot knows its current coordinate `(x, y)` and tracks a memory history of all coordinates it has visited.
|
- **Seeking & Memory**:
|
||||||
- Queryable via `GET /api/players/{player_id}/memory`.
|
- Uses location memory history to prevent circular looping and prioritize unexplored coordinates.
|
||||||
- Enables bots to remember if they have visited a location before to avoid looping and explore unvisited territory.
|
- Radar targets the closest candidate bot.
|
||||||
- **Bot Seeking & Radar**:
|
- **Leadership Negotiation Rule**:
|
||||||
- Bots can actively seek other bots via `GET /api/players/{player_id}/radar`.
|
- **Insistence on Leadership**: If a bot considers the other bot less than them (**higher strength**), it **insists on being the party leader**.
|
||||||
- Scans for nearest friendly/neutral bots to recruit into a party, or detects enemy squads to engage in battle.
|
- **Desire to Join**: A bot desires to join a bot that is **equal or stronger**.
|
||||||
|
- **Agreed Outcome**:
|
||||||
|
- When two unpartied bots meet, the bot with the **higher strength** becomes the agreed **Party Leader**.
|
||||||
|
- If strengths are equal, tie-breaking chooses the bot with higher score or the initiator, and both agree since each is equal in strength.
|
||||||
|
- If an unpartied bot meets an existing party: if the solo bot is stronger than the party's total strength, it insists on becoming the new leader; otherwise, it joins under the existing leader.
|
||||||
|
|
||||||
### 2. Parties & Squad Formations
|
### 2. Goal of a Party: Find and Defeat All Other Parties
|
||||||
- **Goal & Linked Connectivity**:
|
- **Primary Objective**: Seek out and eliminate all opposing parties on the board.
|
||||||
- Bots seek each other out to join together as a **Party**.
|
- **Squad Navigation**:
|
||||||
- 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).
|
- The Party Leader controls group movement, steering the linked squad across the grid towards opposing squads.
|
||||||
- Bots must agree on a **Party Leader**.
|
- All party members maintain linked connectivity ($\le 1$ Chebyshev distance).
|
||||||
- **Party Strength**:
|
- **Battle Engagement**:
|
||||||
- Each bot has a default **strength of 1**.
|
- As soon as a party becomes adjacent to an opposing party, combat is engaged.
|
||||||
- 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**:
|
|
||||||
- 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).
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
### 3. 3-Bout D20 Battle Mechanics & Scoring Rules
|
---
|
||||||
- **Engagement**:
|
|
||||||
- When two opposing parties engage (move into adjacent contact or call battle), a battle must be fought.
|
|
||||||
- **3-Bout Resolution with D20 Roll Multiplier**:
|
|
||||||
- Each battle consists of **3 bouts**.
|
|
||||||
- In each bout, both parties roll a **20-sided die (D20)** (random number from 1 to 20).
|
|
||||||
- The roll acts as a multiplier on the party's total strength:
|
|
||||||
$$\text{Bout Score} = \text{Party Strength} \times \text{D20 Roll}$$
|
|
||||||
- The party with the higher bout score wins that bout.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
### 4. Turn-Based 8-Directional Movement
|
## 3-Bout D20 Battle Mechanics & Scoring
|
||||||
- **8 Directions**:
|
|
||||||
- Cardinal: `UP`, `DOWN`, `LEFT`, `RIGHT`
|
### 1. Engagement & 3-Bout Resolution
|
||||||
- Diagonal: `UP_LEFT`, `UP_RIGHT`, `DOWN_LEFT`, `DOWN_RIGHT`
|
- Each battle consists of **3 bouts**.
|
||||||
- **Collision & Wall Avoidance**:
|
- In each bout, both parties roll a **20-sided die (D20)** (1 to 20).
|
||||||
- Cannot break through boundary walls `[0..64, 0..64]`.
|
- Multiplier rule:
|
||||||
- Cannot move into cells occupied by outside bots.
|
$$\text{Bout Score} = \text{Party Strength} \times \text{D20 Roll}$$
|
||||||
|
- Winner is determined by whoever wins more bouts (or has a higher aggregate score in case of ties).
|
||||||
|
|
||||||
|
### 2. Score Distribution
|
||||||
|
- **Winning Party Leader**: Receives **+2 points**.
|
||||||
|
- **Rest of Winning Party**: Each member receives **+1 point**.
|
||||||
|
- **Losing Party Leader**: Receives **-1 point**, loses leadership, is removed from the party, and respawns at a random free position.
|
||||||
|
- **Rest of Losing Party**: Loses **0 points** (scores remain unchanged).
|
||||||
|
|
||||||
|
### 3. Surrender & Absorption
|
||||||
|
- The **surviving remainder of the defeated party joins the winning party**.
|
||||||
|
- The winning squad grows in numbers and total strength, and immediately resumes hunting down any remaining parties!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## REST API Endpoints
|
## REST API Endpoints
|
||||||
|
|
||||||
### Intelligence & Memory Endpoints
|
### AI & Autonomous Endpoints
|
||||||
- `GET /api/players/{player_id}/memory`: Retrieve a bot's current location, count of visited locations, and visited coordinates history.
|
- `POST /api/players/{player_id}/ai-step`: Executes one autonomous turn according to the bot's goal:
|
||||||
- `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`).
|
- If unpartied: seeks other bots; negotiates and forms a party under the stronger leader upon contact.
|
||||||
|
- If partied leader: hunts down opposing parties; engages and resolves 3-bout D20 battles.
|
||||||
### Party & Battle Endpoints
|
- `GET /api/players/{player_id}/radar`: Scans surroundings for nearby bots, calculates distance, determines allies/enemies, and identifies recruit/battle opportunities.
|
||||||
- `POST /api/parties`: Form a party directly.
|
- `GET /api/players/{player_id}/memory`: Retrieves coordinate history and tracks visited locations.
|
||||||
- Body: `{"member_ids": ["bot_1", "bot_2"], "leader_id": "bot_1", "name": "Squadrons"}`
|
|
||||||
- `GET /api/parties`: List all active parties, leaders, members, and total strength.
|
|
||||||
- `POST /api/parties/{party_id}/defeat`: Trigger party defeat.
|
|
||||||
- `POST /api/battles/fight`: Initiate a 3-bout D20 battle between two adjacent parties/bots.
|
|
||||||
- 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. Automatically detects and resolves battles upon engagement.
|
- `POST /api/players/{player_id}/move`: Move bot or entire party if leader. Automatically checks for party formation or battle engagement upon move completion.
|
||||||
- `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.
|
||||||
|
|
||||||
|
### Party & Battle Endpoints
|
||||||
|
- `POST /api/parties`: Directly form a party with linked members and an agreed leader.
|
||||||
|
- `GET /api/parties`: List all active parties, leaders, members, and total strength.
|
||||||
|
- `POST /api/parties/{party_id}/defeat`: Trigger party defeat.
|
||||||
|
- `POST /api/battles/fight`: Initiate a 3-bout D20 battle between two adjacent parties/bots.
|
||||||
|
|
||||||
### Player & Board Endpoints
|
### Player & Board Endpoints
|
||||||
- `POST /api/players`: Register a new player with `name`, `color`, and optional `strength` (default 1).
|
- `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, 3-bout D20 battles, defeats, and turn changes.
|
- `/ws`: Real-time WebSocket broadcasting movements, party formations, battles, and turn changes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, Query, status
|
||||||
from app.api.websocket import manager
|
from app.api.websocket import manager
|
||||||
from app.game import game_engine
|
from app.game import game_engine
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
AiStepResponse,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
AvailableMovesResponse,
|
AvailableMovesResponse,
|
||||||
BattleRequest,
|
BattleRequest,
|
||||||
|
|
@ -44,33 +45,33 @@ async def health_check():
|
||||||
"/players",
|
"/players",
|
||||||
response_model=Player,
|
response_model=Player,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Register a new player on the board",
|
summary="Register a new bot/player with an avatar name and color",
|
||||||
tags=["Players"],
|
tags=["Players"],
|
||||||
)
|
)
|
||||||
@router.post(
|
|
||||||
"/register",
|
|
||||||
response_model=Player,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
include_in_schema=False,
|
|
||||||
)
|
|
||||||
async def register_player(player_in: PlayerCreate):
|
async def register_player(player_in: PlayerCreate):
|
||||||
player = await game_engine.register_player(player_in)
|
try:
|
||||||
board_state = await game_engine.get_board_state()
|
player = await game_engine.register_player(player_in)
|
||||||
|
board_state = await game_engine.get_board_state()
|
||||||
|
|
||||||
await manager.broadcast({
|
await manager.broadcast({
|
||||||
"event": "player_joined",
|
"event": "player_joined",
|
||||||
"player": player.model_dump(),
|
"player": player.model_dump(),
|
||||||
"player_count": board_state.player_count,
|
"player_count": board_state.player_count,
|
||||||
"turn": board_state.turn.model_dump(),
|
"turn": board_state.turn.model_dump(),
|
||||||
})
|
})
|
||||||
|
|
||||||
return player
|
return player
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/players",
|
"/players",
|
||||||
response_model=List[Player],
|
response_model=List[Player],
|
||||||
summary="List all registered players",
|
summary="List all registered players and their scores/status",
|
||||||
tags=["Players"],
|
tags=["Players"],
|
||||||
)
|
)
|
||||||
async def list_players():
|
async def list_players():
|
||||||
|
|
@ -80,7 +81,7 @@ async def list_players():
|
||||||
@router.get(
|
@router.get(
|
||||||
"/players/{player_id}",
|
"/players/{player_id}",
|
||||||
response_model=Player,
|
response_model=Player,
|
||||||
summary="Get details of a specific player",
|
summary="Get details of a specific player by ID",
|
||||||
tags=["Players"],
|
tags=["Players"],
|
||||||
)
|
)
|
||||||
async def get_player(player_id: str):
|
async def get_player(player_id: str):
|
||||||
|
|
@ -96,7 +97,7 @@ async def get_player(player_id: str):
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/players/{player_id}",
|
"/players/{player_id}",
|
||||||
response_model=ApiResponse,
|
response_model=ApiResponse,
|
||||||
summary="Remove a player from the board",
|
summary="Remove a player from the game board",
|
||||||
tags=["Players"],
|
tags=["Players"],
|
||||||
)
|
)
|
||||||
async def remove_player(player_id: str):
|
async def remove_player(player_id: str):
|
||||||
|
|
@ -118,7 +119,7 @@ async def remove_player(player_id: str):
|
||||||
|
|
||||||
return ApiResponse(
|
return ApiResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Player '{player_id}' successfully removed",
|
message=f"Player '{player_id}' removed from board",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -129,13 +130,13 @@ async def remove_player(player_id: str):
|
||||||
@router.get(
|
@router.get(
|
||||||
"/players/{player_id}/memory",
|
"/players/{player_id}/memory",
|
||||||
response_model=BotMemoryResponse,
|
response_model=BotMemoryResponse,
|
||||||
summary="Retrieve a bot's current location and visited locations memory",
|
summary="Get bot's location memory history and check if location was visited before",
|
||||||
tags=["Bot Intelligence"],
|
tags=["Intelligence"],
|
||||||
)
|
)
|
||||||
async def get_bot_memory(
|
async def get_bot_memory(
|
||||||
player_id: str,
|
player_id: str,
|
||||||
check_x: Optional[int] = Query(None, description="Check if bot has visited this X coordinate"),
|
check_x: Optional[int] = Query(None, description="Optional X coordinate to check if bot has visited before"),
|
||||||
check_y: Optional[int] = Query(None, description="Check if bot has visited this Y coordinate"),
|
check_y: Optional[int] = Query(None, description="Optional Y coordinate to check if bot has visited before"),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
return await game_engine.get_bot_memory(player_id, check_x, check_y)
|
return await game_engine.get_bot_memory(player_id, check_x, check_y)
|
||||||
|
|
@ -149,8 +150,8 @@ async def get_bot_memory(
|
||||||
@router.get(
|
@router.get(
|
||||||
"/players/{player_id}/radar",
|
"/players/{player_id}/radar",
|
||||||
response_model=BotRadarResponse,
|
response_model=BotRadarResponse,
|
||||||
summary="Scan surroundings for other bots, distance, allies/enemies, and recommended direction",
|
summary="Scan surroundings for nearby bots, determine allies/enemies, and recommend action/direction",
|
||||||
tags=["Bot Intelligence"],
|
tags=["Intelligence"],
|
||||||
)
|
)
|
||||||
async def get_bot_radar(player_id: str):
|
async def get_bot_radar(player_id: str):
|
||||||
try:
|
try:
|
||||||
|
|
@ -163,7 +164,7 @@ async def get_bot_radar(player_id: str):
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Party & Squad Endpoints
|
# Party Management & 3-Bout Battles
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -388,6 +389,14 @@ async def move_player(player_id: str, move_req: MoveRequest):
|
||||||
"turn": result.turn.model_dump(),
|
"turn": result.turn.model_dump(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if result.party_formed_triggered and result.formed_party:
|
||||||
|
await manager.broadcast({
|
||||||
|
"event": "party_formed",
|
||||||
|
"party": result.formed_party.model_dump(),
|
||||||
|
"players": [p.model_dump() for p in board_state.players],
|
||||||
|
"turn": board_state.turn.model_dump(),
|
||||||
|
})
|
||||||
|
|
||||||
if result.battle_triggered and result.battle_result:
|
if result.battle_triggered and result.battle_result:
|
||||||
await manager.broadcast({
|
await manager.broadcast({
|
||||||
"event": "battle_resolved",
|
"event": "battle_resolved",
|
||||||
|
|
@ -415,6 +424,69 @@ async def move_player(player_id: str, move_req: MoveRequest):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/players/{player_id}/ai-step",
|
||||||
|
response_model=AiStepResponse,
|
||||||
|
summary="Autonomous AI turn: unpartied bots seek & form parties (stronger bot is leader); parties hunt & defeat all other parties",
|
||||||
|
tags=["Movement"],
|
||||||
|
)
|
||||||
|
async def step_bot_ai(player_id: str):
|
||||||
|
try:
|
||||||
|
result = await game_engine.step_bot_ai(player_id)
|
||||||
|
board_state = await game_engine.get_board_state()
|
||||||
|
|
||||||
|
if result.formed_party:
|
||||||
|
await manager.broadcast({
|
||||||
|
"event": "party_formed",
|
||||||
|
"party": result.formed_party.model_dump(),
|
||||||
|
"players": [p.model_dump() for p in board_state.players],
|
||||||
|
"turn": board_state.turn.model_dump(),
|
||||||
|
})
|
||||||
|
elif 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(),
|
||||||
|
})
|
||||||
|
elif result.move_result:
|
||||||
|
await manager.broadcast({
|
||||||
|
"event": "player_moved",
|
||||||
|
"player": result.move_result.player.model_dump(),
|
||||||
|
"direction": result.move_result.direction,
|
||||||
|
"party_moved": result.move_result.party_moved,
|
||||||
|
"affected_players": [p.model_dump() for p in result.move_result.affected_players],
|
||||||
|
"previous_position": result.move_result.previous_position,
|
||||||
|
"new_position": result.move_result.new_position,
|
||||||
|
"players": [p.model_dump() for p in board_state.players],
|
||||||
|
"turn": result.move_result.turn.model_dump(),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
await manager.broadcast({
|
||||||
|
"event": "turn_passed",
|
||||||
|
"passed_by": player_id,
|
||||||
|
"turn": result.turn.model_dump(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Player '{player_id}' not found",
|
||||||
|
)
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/players/{player_id}/pass",
|
"/players/{player_id}/pass",
|
||||||
response_model=TurnInfo,
|
response_model=TurnInfo,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import uuid
|
||||||
from typing import Dict, List, Optional, Set, Tuple
|
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 (
|
||||||
|
AiStepResponse,
|
||||||
AvailableMovesResponse,
|
AvailableMovesResponse,
|
||||||
BattleBout,
|
BattleBout,
|
||||||
BattleResult,
|
BattleResult,
|
||||||
|
|
@ -250,7 +251,9 @@ class GameEngine:
|
||||||
if not player:
|
if not player:
|
||||||
raise KeyError(f"Player '{player_id}' not found")
|
raise KeyError(f"Player '{player_id}' not found")
|
||||||
|
|
||||||
|
bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party"
|
||||||
targets: List[RadarTarget] = []
|
targets: List[RadarTarget] = []
|
||||||
|
|
||||||
for other in self.players.values():
|
for other in self.players.values():
|
||||||
if other.id == player.id:
|
if other.id == player.id:
|
||||||
continue
|
continue
|
||||||
|
|
@ -259,6 +262,11 @@ class GameEngine:
|
||||||
is_enemy = not is_ally
|
is_enemy = not is_ally
|
||||||
party_obj = self.parties.get(other.party_id) if other.party_id else None
|
party_obj = self.parties.get(other.party_id) if other.party_id else None
|
||||||
|
|
||||||
|
can_recruit = (player.party_id is None and other.party_id is None) or (
|
||||||
|
player.party_id and player.is_party_leader and other.party_id is None
|
||||||
|
)
|
||||||
|
can_battle = is_enemy and bool(player.party_id and other.party_id)
|
||||||
|
|
||||||
targets.append(
|
targets.append(
|
||||||
RadarTarget(
|
RadarTarget(
|
||||||
id=other.id,
|
id=other.id,
|
||||||
|
|
@ -266,16 +274,33 @@ class GameEngine:
|
||||||
color=other.color,
|
color=other.color,
|
||||||
x=other.x,
|
x=other.x,
|
||||||
y=other.y,
|
y=other.y,
|
||||||
|
strength=other.strength,
|
||||||
distance=dist,
|
distance=dist,
|
||||||
party_id=other.party_id,
|
party_id=other.party_id,
|
||||||
party_name=party_obj.name if party_obj else None,
|
party_name=party_obj.name if party_obj else None,
|
||||||
is_ally=is_ally,
|
is_ally=is_ally,
|
||||||
is_enemy=is_enemy,
|
is_enemy=is_enemy,
|
||||||
|
can_recruit=can_recruit,
|
||||||
|
can_battle=can_battle,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
targets.sort(key=lambda t: t.distance)
|
targets.sort(key=lambda t: t.distance)
|
||||||
nearest = targets[0] if targets else None
|
|
||||||
|
# Choose primary target based on goal:
|
||||||
|
# - If unpartied: seek closest bot to form party with
|
||||||
|
# - If partied: seek closest enemy party to battle
|
||||||
|
primary_targets = []
|
||||||
|
if bot_goal == "form_party":
|
||||||
|
# Prefer unpartied bots or friendly recruitment targets
|
||||||
|
recruit_targets = [t for t in targets if t.can_recruit or not t.party_id]
|
||||||
|
primary_targets = recruit_targets if recruit_targets else targets
|
||||||
|
else:
|
||||||
|
# Partied: seek enemy parties
|
||||||
|
battle_targets = [t for t in targets if t.is_enemy and t.party_id]
|
||||||
|
primary_targets = battle_targets if battle_targets else [t for t in targets if t.is_enemy]
|
||||||
|
|
||||||
|
nearest = primary_targets[0] if primary_targets else (targets[0] if targets else None)
|
||||||
|
|
||||||
rec_dir = None
|
rec_dir = None
|
||||||
rec_act = "explore_unvisited"
|
rec_act = "explore_unvisited"
|
||||||
|
|
@ -295,14 +320,21 @@ class GameEngine:
|
||||||
break
|
break
|
||||||
|
|
||||||
if nearest.distance <= 1:
|
if nearest.distance <= 1:
|
||||||
rec_act = "engage_battle" if (nearest.is_enemy and nearest.party_id) else "seek_join"
|
if bot_goal == "form_party" or nearest.can_recruit:
|
||||||
|
rec_act = "form_party"
|
||||||
|
else:
|
||||||
|
rec_act = "engage_battle"
|
||||||
else:
|
else:
|
||||||
rec_act = "seek_join" if not nearest.party_id else "engage_battle"
|
if bot_goal == "form_party":
|
||||||
|
rec_act = "seek_partner"
|
||||||
|
else:
|
||||||
|
rec_act = "hunt_party"
|
||||||
|
|
||||||
return BotRadarResponse(
|
return BotRadarResponse(
|
||||||
player_id=player.id,
|
player_id=player.id,
|
||||||
current_x=player.x,
|
current_x=player.x,
|
||||||
current_y=player.y,
|
current_y=player.y,
|
||||||
|
bot_goal=bot_goal,
|
||||||
targets=targets,
|
targets=targets,
|
||||||
nearest_target=nearest,
|
nearest_target=nearest,
|
||||||
recommended_direction=rec_dir,
|
recommended_direction=rec_dir,
|
||||||
|
|
@ -310,7 +342,7 @@ class GameEngine:
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Party Logic & Graph Adjacency
|
# Party Logic & Leadership Negotiation
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -334,6 +366,26 @@ class GameEngine:
|
||||||
|
|
||||||
return len(visited) == len(member_players)
|
return len(visited) == len(member_players)
|
||||||
|
|
||||||
|
def _negotiate_party_leader_id(self, bot_a: Player, bot_b: Player) -> str:
|
||||||
|
"""Rules:
|
||||||
|
|
||||||
|
- If a bot considers the other bot less than them (lower strength), they insist on being
|
||||||
|
leader.
|
||||||
|
- A bot desires to join a bot that is equal or stronger.
|
||||||
|
- Therefore, the bot with higher strength is the agreed leader.
|
||||||
|
- If strengths are equal, break ties by score or bot_a.
|
||||||
|
"""
|
||||||
|
if bot_a.strength > bot_b.strength:
|
||||||
|
return bot_a.id
|
||||||
|
elif bot_b.strength > bot_a.strength:
|
||||||
|
return bot_b.id
|
||||||
|
else:
|
||||||
|
if bot_a.score > bot_b.score:
|
||||||
|
return bot_a.id
|
||||||
|
elif bot_b.score > bot_a.score:
|
||||||
|
return bot_b.id
|
||||||
|
return bot_a.id
|
||||||
|
|
||||||
async def form_party(
|
async def form_party(
|
||||||
self, member_ids: List[str], leader_id: str, name: Optional[str] = None
|
self, member_ids: List[str], leader_id: str, name: Optional[str] = None
|
||||||
) -> Party:
|
) -> Party:
|
||||||
|
|
@ -637,6 +689,56 @@ class GameEngine:
|
||||||
moves=moves,
|
moves=moves,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_and_auto_form_party(self, player: Player) -> Optional[Party]:
|
||||||
|
"""When an unpartied bot sees it is adjacent to another bot, negotiate and form a party:
|
||||||
|
|
||||||
|
- Lower strength insists on joining higher strength.
|
||||||
|
- Higher strength insists on being leader.
|
||||||
|
"""
|
||||||
|
if player.party_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for other in self.players.values():
|
||||||
|
if other.id == player.id:
|
||||||
|
continue
|
||||||
|
if self._are_adjacent(player, other):
|
||||||
|
if other.party_id is None:
|
||||||
|
leader_id = self._negotiate_party_leader_id(player, other)
|
||||||
|
leader = self.players[leader_id]
|
||||||
|
party_id = f"party_{uuid.uuid4().hex[:8]}"
|
||||||
|
party = Party(
|
||||||
|
id=party_id,
|
||||||
|
name=f"Squad {leader.name}",
|
||||||
|
leader_id=leader_id,
|
||||||
|
leader_name=leader.name,
|
||||||
|
member_ids=[player.id, other.id],
|
||||||
|
total_strength=player.strength + other.strength,
|
||||||
|
)
|
||||||
|
player.party_id = party_id
|
||||||
|
player.is_party_leader = player.id == leader_id
|
||||||
|
other.party_id = party_id
|
||||||
|
other.is_party_leader = other.id == leader_id
|
||||||
|
self.parties[party_id] = party
|
||||||
|
return party
|
||||||
|
elif other.is_party_leader and other.party_id in self.parties:
|
||||||
|
party = self.parties[other.party_id]
|
||||||
|
if player.strength > party.total_strength:
|
||||||
|
# Player is stronger than entire party: player insists on leading
|
||||||
|
party.member_ids.append(player.id)
|
||||||
|
player.party_id = party.id
|
||||||
|
player.is_party_leader = True
|
||||||
|
other.is_party_leader = False
|
||||||
|
party.leader_id = player.id
|
||||||
|
party.leader_name = player.name
|
||||||
|
else:
|
||||||
|
# Party is equal or stronger: player joins under existing leader
|
||||||
|
party.member_ids.append(player.id)
|
||||||
|
player.party_id = party.id
|
||||||
|
player.is_party_leader = False
|
||||||
|
self._update_party_strength(party)
|
||||||
|
return party
|
||||||
|
return None
|
||||||
|
|
||||||
async def move_player(
|
async def move_player(
|
||||||
self, player_id: str, dx: int, dy: int, direction_name: str
|
self, player_id: str, dx: int, dy: int, direction_name: str
|
||||||
) -> MoveResponse:
|
) -> MoveResponse:
|
||||||
|
|
@ -687,7 +789,12 @@ class GameEngine:
|
||||||
|
|
||||||
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
|
# Check for party formation if solo bot moved next to another bot
|
||||||
|
formed_party = None
|
||||||
|
if player.party_id is None:
|
||||||
|
formed_party = self._check_and_auto_form_party(player)
|
||||||
|
|
||||||
|
# Check if moving party engages an opposing party in battle
|
||||||
battle_result = None
|
battle_result = None
|
||||||
battle_triggered = False
|
battle_triggered = False
|
||||||
|
|
||||||
|
|
@ -695,7 +802,6 @@ class GameEngine:
|
||||||
my_party = self.parties[player.party_id]
|
my_party = self.parties[player.party_id]
|
||||||
opposing_party = self._find_adjacent_opposing_party(my_party)
|
opposing_party = self._find_adjacent_opposing_party(my_party)
|
||||||
if opposing_party:
|
if opposing_party:
|
||||||
# Automatic battle engagement!
|
|
||||||
battle_result = self._resolve_3bout_battle_internal(my_party, opposing_party)
|
battle_result = self._resolve_3bout_battle_internal(my_party, opposing_party)
|
||||||
battle_triggered = True
|
battle_triggered = True
|
||||||
|
|
||||||
|
|
@ -710,6 +816,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,
|
||||||
|
party_formed_triggered=bool(formed_party),
|
||||||
|
formed_party=formed_party,
|
||||||
battle_triggered=battle_triggered,
|
battle_triggered=battle_triggered,
|
||||||
battle_result=battle_result,
|
battle_result=battle_result,
|
||||||
turn=turn_info,
|
turn=turn_info,
|
||||||
|
|
@ -732,6 +840,203 @@ class GameEngine:
|
||||||
self._advance_turn()
|
self._advance_turn()
|
||||||
return self._get_turn_info()
|
return self._get_turn_info()
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Autonomous Goal Step (AI Logic)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
async def step_bot_ai(self, player_id: str) -> AiStepResponse:
|
||||||
|
"""Executes one autonomous turn for the bot or party leader following exact goals:
|
||||||
|
|
||||||
|
1. Bot without party: Goal is to seek other bots and form a party (stronger bot insists on
|
||||||
|
being leader).
|
||||||
|
2. Party leader: Goal is to find and defeat all other parties.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
player = self.players.get(player_id)
|
||||||
|
if not player:
|
||||||
|
raise KeyError(f"Player '{player_id}' not found")
|
||||||
|
|
||||||
|
current_turn_player = self._get_current_player()
|
||||||
|
if not current_turn_player or current_turn_player.id != player_id:
|
||||||
|
raise PermissionError(
|
||||||
|
f"Not your turn. Current turn: '{current_turn_player.name if current_turn_player else 'None'}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party"
|
||||||
|
|
||||||
|
# 1. If unpartied and already adjacent to another bot, form a party immediately!
|
||||||
|
if not player.party_id:
|
||||||
|
formed = self._check_and_auto_form_party(player)
|
||||||
|
if formed:
|
||||||
|
self._advance_turn()
|
||||||
|
return AiStepResponse(
|
||||||
|
action_taken="formed_party",
|
||||||
|
player_id=player.id,
|
||||||
|
player_name=player.name,
|
||||||
|
bot_goal=bot_goal,
|
||||||
|
direction=None,
|
||||||
|
move_result=None,
|
||||||
|
formed_party=formed,
|
||||||
|
battle_result=None,
|
||||||
|
turn=self._get_turn_info(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. If partied leader and already adjacent to an opposing party, fight immediately!
|
||||||
|
if player.party_id and player.is_party_leader and player.party_id in self.parties:
|
||||||
|
my_party = self.parties[player.party_id]
|
||||||
|
opposing = self._find_adjacent_opposing_party(my_party)
|
||||||
|
if opposing:
|
||||||
|
battle_res = self._resolve_3bout_battle_internal(my_party, opposing)
|
||||||
|
self._advance_turn()
|
||||||
|
return AiStepResponse(
|
||||||
|
action_taken="battled",
|
||||||
|
player_id=player.id,
|
||||||
|
player_name=player.name,
|
||||||
|
bot_goal=bot_goal,
|
||||||
|
direction=None,
|
||||||
|
move_result=None,
|
||||||
|
formed_party=None,
|
||||||
|
battle_result=battle_res,
|
||||||
|
turn=self._get_turn_info(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Otherwise: navigate towards target using radar & memory
|
||||||
|
occupied = self._get_occupied_coordinates()
|
||||||
|
moves_map: Dict[str, MoveCheckResult] = {}
|
||||||
|
for name, dx, dy in STANDARD_DIRECTIONS:
|
||||||
|
moves_map[name] = self._check_move_internal(player, dx, dy, name, occupied)
|
||||||
|
|
||||||
|
available_dirs = [name for name, chk in moves_map.items() if chk.available]
|
||||||
|
if not available_dirs:
|
||||||
|
# No move available, pass turn
|
||||||
|
self._advance_turn()
|
||||||
|
return AiStepResponse(
|
||||||
|
action_taken="passed",
|
||||||
|
player_id=player.id,
|
||||||
|
player_name=player.name,
|
||||||
|
bot_goal=bot_goal,
|
||||||
|
direction=None,
|
||||||
|
move_result=None,
|
||||||
|
formed_party=None,
|
||||||
|
battle_result=None,
|
||||||
|
turn=self._get_turn_info(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find nearest target according to goal
|
||||||
|
targets = []
|
||||||
|
for other in self.players.values():
|
||||||
|
if other.id == player.id:
|
||||||
|
continue
|
||||||
|
if player.party_id and player.party_id == other.party_id:
|
||||||
|
continue
|
||||||
|
dist = max(abs(player.x - other.x), abs(player.y - other.y))
|
||||||
|
targets.append((dist, other))
|
||||||
|
|
||||||
|
targets.sort(key=lambda t: t[0])
|
||||||
|
|
||||||
|
# Filter targets:
|
||||||
|
# - If solo bot: prioritize unpartied bots
|
||||||
|
# - If party: prioritize enemy parties
|
||||||
|
if bot_goal == "form_party":
|
||||||
|
preferred = [t for t in targets if not t[1].party_id]
|
||||||
|
target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None)
|
||||||
|
else:
|
||||||
|
preferred = [t for t in targets if t[1].party_id]
|
||||||
|
target_bot = preferred[0][1] if preferred else (targets[0][1] if targets else None)
|
||||||
|
|
||||||
|
# Score each available direction based on:
|
||||||
|
# 1. Getting closer to target
|
||||||
|
# 2. Preferring unvisited tiles (location memory)
|
||||||
|
visited_set = {(loc["x"], loc["y"]) for loc in player.visited_locations}
|
||||||
|
best_dir = available_dirs[0]
|
||||||
|
best_score = float("-inf")
|
||||||
|
|
||||||
|
for dir_name in available_dirs:
|
||||||
|
dx, dy = DIRECTION_OFFSETS[dir_name]
|
||||||
|
tx = player.x + dx
|
||||||
|
ty = player.y + dy
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
if target_bot:
|
||||||
|
old_dist = max(abs(player.x - target_bot.x), abs(player.y - target_bot.y))
|
||||||
|
new_dist = max(abs(tx - target_bot.x), abs(ty - target_bot.y))
|
||||||
|
score += (old_dist - new_dist) * 10.0 # Reward moving closer
|
||||||
|
|
||||||
|
# Memory penalty for visited coordinates
|
||||||
|
if (tx, ty) not in visited_set:
|
||||||
|
score += 2.0 # Exploration bonus
|
||||||
|
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_dir = dir_name
|
||||||
|
|
||||||
|
# Execute move with chosen direction
|
||||||
|
dx, dy = DIRECTION_OFFSETS[best_dir]
|
||||||
|
prev_pos = {"x": player.x, "y": player.y}
|
||||||
|
affected_players: List[Player] = []
|
||||||
|
|
||||||
|
if player.party_id and player.party_id in self.parties:
|
||||||
|
party = self.parties[player.party_id]
|
||||||
|
for mid in party.member_ids:
|
||||||
|
m = self.players.get(mid)
|
||||||
|
if m:
|
||||||
|
m.x += dx
|
||||||
|
m.y += dy
|
||||||
|
m.visited_locations.append({"x": m.x, "y": m.y})
|
||||||
|
affected_players.append(m)
|
||||||
|
else:
|
||||||
|
player.x += dx
|
||||||
|
player.y += dy
|
||||||
|
player.visited_locations.append({"x": player.x, "y": player.y})
|
||||||
|
affected_players.append(player)
|
||||||
|
|
||||||
|
new_pos = {"x": player.x, "y": player.y}
|
||||||
|
|
||||||
|
# Check party formation / battle engagement after move
|
||||||
|
formed_party = None
|
||||||
|
if player.party_id is None:
|
||||||
|
formed_party = self._check_and_auto_form_party(player)
|
||||||
|
|
||||||
|
battle_result = None
|
||||||
|
battle_triggered = False
|
||||||
|
if player.party_id and player.party_id in self.parties:
|
||||||
|
my_party = self.parties[player.party_id]
|
||||||
|
opposing_party = self._find_adjacent_opposing_party(my_party)
|
||||||
|
if opposing_party:
|
||||||
|
battle_result = self._resolve_3bout_battle_internal(my_party, opposing_party)
|
||||||
|
battle_triggered = True
|
||||||
|
|
||||||
|
self._advance_turn()
|
||||||
|
turn_info = self._get_turn_info()
|
||||||
|
|
||||||
|
move_res = MoveResponse(
|
||||||
|
success=True,
|
||||||
|
player=player,
|
||||||
|
direction=best_dir,
|
||||||
|
party_moved=bool(player.party_id),
|
||||||
|
affected_players=affected_players,
|
||||||
|
previous_position=prev_pos,
|
||||||
|
new_position=new_pos,
|
||||||
|
party_formed_triggered=bool(formed_party),
|
||||||
|
formed_party=formed_party,
|
||||||
|
battle_triggered=battle_triggered,
|
||||||
|
battle_result=battle_result,
|
||||||
|
turn=turn_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
action_taken = "battled" if battle_triggered else ("formed_party" if formed_party else "moved")
|
||||||
|
return AiStepResponse(
|
||||||
|
action_taken=action_taken,
|
||||||
|
player_id=player.id,
|
||||||
|
player_name=player.name,
|
||||||
|
bot_goal=bot_goal,
|
||||||
|
direction=best_dir,
|
||||||
|
move_result=move_res,
|
||||||
|
formed_party=formed_party,
|
||||||
|
battle_result=battle_result,
|
||||||
|
turn=turn_info,
|
||||||
|
)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 3-Bout D20 Battle Mechanics
|
# 3-Bout D20 Battle Mechanics
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
@ -749,14 +1054,6 @@ class GameEngine:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _resolve_3bout_battle_internal(self, party1: Party, party2: Party) -> BattleResult:
|
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)
|
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)
|
str2 = sum(self.players[m].strength for m in party2.member_ids if m in self.players)
|
||||||
|
|
||||||
|
|
@ -804,7 +1101,7 @@ class GameEngine:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Overall battle winner (most bouts won, or higher total score)
|
# Overall battle winner
|
||||||
if p1_bouts_won > p2_bouts_won:
|
if p1_bouts_won > p2_bouts_won:
|
||||||
winner_party, defeated_party = party1, party2
|
winner_party, defeated_party = party1, party2
|
||||||
elif p2_bouts_won > p1_bouts_won:
|
elif p2_bouts_won > p1_bouts_won:
|
||||||
|
|
|
||||||
|
|
@ -251,21 +251,25 @@ class RadarTarget(BaseModel):
|
||||||
color: str
|
color: str
|
||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
|
strength: int = 1
|
||||||
distance: int
|
distance: int
|
||||||
party_id: Optional[str] = None
|
party_id: Optional[str] = None
|
||||||
party_name: Optional[str] = None
|
party_name: Optional[str] = None
|
||||||
is_ally: bool
|
is_ally: bool
|
||||||
is_enemy: bool
|
is_enemy: bool
|
||||||
|
can_recruit: bool = False
|
||||||
|
can_battle: bool = False
|
||||||
|
|
||||||
|
|
||||||
class BotRadarResponse(BaseModel):
|
class BotRadarResponse(BaseModel):
|
||||||
player_id: str
|
player_id: str
|
||||||
current_x: int
|
current_x: int
|
||||||
current_y: int
|
current_y: int
|
||||||
|
bot_goal: str # "form_party" or "find_and_defeat_all_parties"
|
||||||
targets: List[RadarTarget]
|
targets: List[RadarTarget]
|
||||||
nearest_target: Optional[RadarTarget] = None
|
nearest_target: Optional[RadarTarget] = None
|
||||||
recommended_direction: Optional[str] = None
|
recommended_direction: Optional[str] = None
|
||||||
recommended_action: str # "seek_join", "engage_battle", "explore_unvisited"
|
recommended_action: str # "seek_partner", "form_party", "hunt_party", "engage_battle", "explore_unvisited"
|
||||||
|
|
||||||
|
|
||||||
class BoardConfig(BaseModel):
|
class BoardConfig(BaseModel):
|
||||||
|
|
@ -301,11 +305,25 @@ 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]
|
||||||
|
party_formed_triggered: bool = False
|
||||||
|
formed_party: Optional[Party] = None
|
||||||
battle_triggered: bool = False
|
battle_triggered: bool = False
|
||||||
battle_result: Optional[BattleResult] = None
|
battle_result: Optional[BattleResult] = None
|
||||||
turn: TurnInfo
|
turn: TurnInfo
|
||||||
|
|
||||||
|
|
||||||
|
class AiStepResponse(BaseModel):
|
||||||
|
action_taken: str # "formed_party", "battled", "moved", "passed"
|
||||||
|
player_id: str
|
||||||
|
player_name: str
|
||||||
|
bot_goal: str # "form_party" or "find_and_defeat_all_parties"
|
||||||
|
direction: Optional[str] = None
|
||||||
|
move_result: Optional[MoveResponse] = None
|
||||||
|
formed_party: Optional[Party] = None
|
||||||
|
battle_result: Optional[BattleResult] = None
|
||||||
|
turn: TurnInfo
|
||||||
|
|
||||||
|
|
||||||
class ApiResponse(BaseModel):
|
class ApiResponse(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
message: str
|
message: str
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,47 @@ def test_bot_location_memory_and_radar():
|
||||||
assert mem2["visited_history"][-1] == {"x": 15, "y": 14}
|
assert mem2["visited_history"][-1] == {"x": 15, "y": 14}
|
||||||
|
|
||||||
|
|
||||||
|
def test_strength_based_leadership_negotiation():
|
||||||
|
"""Rule test:
|
||||||
|
|
||||||
|
- If a bot considers the other bot less than them (lower strength), they insist on being party
|
||||||
|
leader.
|
||||||
|
- A bot desires to join a bot that is equal or stronger.
|
||||||
|
"""
|
||||||
|
client = TestClient(app)
|
||||||
|
# Bot A (strength 10), Bot B (strength 2) placed adjacent
|
||||||
|
pA = client.post("/api/players", json={"name": "StrongBot", "color": "#111111", "strength": 10}).json()
|
||||||
|
pB = client.post("/api/players", json={"name": "WeakerBot", "color": "#222222", "strength": 2}).json()
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
async def place_adjacent():
|
||||||
|
bA = await game_engine.get_player(pA["id"])
|
||||||
|
bA.x, bA.y = 25, 25
|
||||||
|
bB = await game_engine.get_player(pB["id"])
|
||||||
|
bB.x, bB.y = 25, 26
|
||||||
|
asyncio.run(place_adjacent())
|
||||||
|
|
||||||
|
# Step AI for pA: see pB adjacent, negotiate party
|
||||||
|
ai_res = client.post(f"/api/players/{pA['id']}/ai-step")
|
||||||
|
assert ai_res.status_code == 200
|
||||||
|
data = ai_res.json()
|
||||||
|
assert data["action_taken"] == "formed_party"
|
||||||
|
assert data["formed_party"] is not None
|
||||||
|
|
||||||
|
# StrongBot insisted on being leader, WeakerBot joined stronger
|
||||||
|
formed = data["formed_party"]
|
||||||
|
assert formed["leader_id"] == pA["id"]
|
||||||
|
assert formed["leader_name"] == "StrongBot"
|
||||||
|
assert formed["total_strength"] == 12
|
||||||
|
|
||||||
|
botA_state = client.get(f"/api/players/{pA['id']}").json()
|
||||||
|
botB_state = client.get(f"/api/players/{pB['id']}").json()
|
||||||
|
assert botA_state["is_party_leader"] is True
|
||||||
|
assert botB_state["is_party_leader"] is False
|
||||||
|
assert botA_state["party_id"] == formed["id"]
|
||||||
|
assert botB_state["party_id"] == formed["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
# Party 1: Leader + 2 Followers (Total 3 bots, strength 3)
|
# Party 1: Leader + 2 Followers (Total 3 bots, strength 3)
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,22 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pickStrongestLeader = (ids: string[]) => {
|
||||||
|
if (ids.length === 0) return '';
|
||||||
|
const selectedBots = players.filter((p) => ids.includes(p.id));
|
||||||
|
selectedBots.sort((a, b) => (b.strength || 1) - (a.strength || 1));
|
||||||
|
return selectedBots[0]?.id || ids[0];
|
||||||
|
};
|
||||||
|
|
||||||
const handleQuickPair = () => {
|
const handleQuickPair = () => {
|
||||||
const pair = findAdjacentPairs();
|
const pair = findAdjacentPairs();
|
||||||
if (pair) {
|
if (pair) {
|
||||||
setSelectedIds([pair[0].id, pair[1].id]);
|
const ids = [pair[0].id, pair[1].id];
|
||||||
setLeaderId(pair[0].id);
|
setSelectedIds(ids);
|
||||||
setPartyName(`Squad ${pair[0].name}`);
|
const chosenLeader = pickStrongestLeader(ids);
|
||||||
|
setLeaderId(chosenLeader);
|
||||||
|
const leadBot = players.find((p) => p.id === chosenLeader) || pair[0];
|
||||||
|
setPartyName(`Squad ${leadBot.name}`);
|
||||||
setError(null);
|
setError(null);
|
||||||
} else {
|
} else {
|
||||||
setError('No adjacent bots found! Move bots within 1 distance of each other to link them.');
|
setError('No adjacent bots found! Move bots within 1 distance of each other to link them.');
|
||||||
|
|
@ -57,9 +67,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
||||||
setError(null);
|
setError(null);
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id];
|
const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id];
|
||||||
if (!next.includes(leaderId)) {
|
setLeaderId(pickStrongestLeader(next));
|
||||||
setLeaderId(next[0] || '');
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -108,9 +116,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-slate-400 mb-4 leading-relaxed">
|
<div className="text-xs text-slate-400 mb-4 leading-relaxed">
|
||||||
Bots can unite into a linked squad if they are within 1 distance of each other. The agreed{' '}
|
The goal of a bot without a party is to form a party! Bots insist on being leader if they consider the other bot less than them (higher strength), and desire to join a bot that is equal or stronger.
|
||||||
<strong className="text-amber-300">Party Leader</strong> controls group movement. If defeated, the leader is
|
|
||||||
killed (-1 score) & respawns, and the remainder elects a new leader.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|
@ -160,6 +166,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
||||||
style={{ backgroundColor: p.color }}
|
style={{ backgroundColor: p.color }}
|
||||||
/>
|
/>
|
||||||
<span className="font-semibold text-slate-200">{p.name}</span>
|
<span className="font-semibold text-slate-200">{p.name}</span>
|
||||||
|
<span className="text-amber-400 font-mono text-[11px]">⚡{p.strength || 1}</span>
|
||||||
<span className="font-mono text-[10px] text-slate-500">
|
<span className="font-mono text-[10px] text-slate-500">
|
||||||
({p.x}, {p.y})
|
({p.x}, {p.y})
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -179,9 +186,14 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
||||||
{/* Agreed Leader Selection */}
|
{/* Agreed Leader Selection */}
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-mono text-slate-400 mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
👑 Agreed Party Leader:
|
<label className="text-xs font-mono text-slate-400">
|
||||||
</label>
|
👑 Agreed Party Leader:
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] font-mono text-amber-400/90">
|
||||||
|
(Stronger bots insist on leading)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{selectedIds.map((id) => {
|
{selectedIds.map((id) => {
|
||||||
const bot = players.find((p) => p.id === id);
|
const bot = players.find((p) => p.id === id);
|
||||||
|
|
@ -192,14 +204,17 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
||||||
type="button"
|
type="button"
|
||||||
key={id}
|
key={id}
|
||||||
onClick={() => setLeaderId(id)}
|
onClick={() => setLeaderId(id)}
|
||||||
className={`p-2 rounded-lg border text-xs font-mono flex items-center gap-2 transition-all ${
|
className={`p-2 rounded-lg border text-xs font-mono flex items-center justify-between gap-1 transition-all ${
|
||||||
isLeader
|
isLeader
|
||||||
? 'bg-amber-950/80 border-amber-500 text-amber-200 shadow'
|
? 'bg-amber-950/80 border-amber-500 text-amber-200 shadow'
|
||||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700'
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{isLeader ? '👑' : '🛡️'}</span>
|
<div className="flex items-center gap-1.5 truncate">
|
||||||
<span className="truncate">{bot.name}</span>
|
<span>{isLeader ? '👑' : '🛡️'}</span>
|
||||||
|
<span className="truncate">{bot.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-amber-400 text-[10px]">⚡{bot.strength || 1}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import type {
|
import type {
|
||||||
|
AiStepResponse,
|
||||||
AvailableMovesResponse,
|
AvailableMovesResponse,
|
||||||
BattleResult,
|
BattleResult,
|
||||||
BoardState,
|
BoardState,
|
||||||
|
|
@ -138,7 +139,7 @@ export function useGameSocket() {
|
||||||
],
|
],
|
||||||
turn: data.turn ?? prev.turn,
|
turn: data.turn ?? prev.turn,
|
||||||
}));
|
}));
|
||||||
setLastEventMessage(`Party "${data.party.name}" established under leader ${data.party.leader_name}!`);
|
setLastEventMessage(`🤝 Party "${data.party.name}" established under leader ${data.party.leader_name}!`);
|
||||||
} else if (data.event === 'party_defeated') {
|
} else if (data.event === 'party_defeated') {
|
||||||
setBoardState((prev) => ({
|
setBoardState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -161,7 +162,7 @@ export function useGameSocket() {
|
||||||
}));
|
}));
|
||||||
const b: BattleResult = data.battle;
|
const b: BattleResult = data.battle;
|
||||||
setActiveBattle(b);
|
setActiveBattle(b);
|
||||||
setLastEventMessage(`⚔️ 3-Bout D20 Battle Victory: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
|
setLastEventMessage(`⚔️ 3-Bout D20 Battle: ${b.winner_party_name} defeated ${b.defeated_party_name}!`);
|
||||||
} else if (data.event === 'turn_passed') {
|
} else if (data.event === 'turn_passed') {
|
||||||
setBoardState((prev) => ({
|
setBoardState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -244,11 +245,11 @@ export function useGameSocket() {
|
||||||
}
|
}
|
||||||
}, [selectedPlayer?.id, boardState.turn.current_player_id, boardState.players, fetchAvailableMoves]);
|
}, [selectedPlayer?.id, boardState.turn.current_player_id, boardState.players, fetchAvailableMoves]);
|
||||||
|
|
||||||
const registerPlayer = async (name: string, color: string): Promise<Player> => {
|
const registerPlayer = async (name: string, color: string, strength: number = 1): Promise<Player> => {
|
||||||
const res = await fetch('/api/players', {
|
const res = await fetch('/api/players', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, color }),
|
body: JSON.stringify({ name, color, strength }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json().catch(() => ({}));
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
|
@ -347,43 +348,32 @@ export function useGameSocket() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step a single turn: Bot seeking behavior using Radar and Memory
|
// Step active bot turn according to its explicit autonomous goal:
|
||||||
|
// - Bot without party: seeks other bots to form a party (stronger bot insists on being leader)
|
||||||
|
// - Party leader: seeks other parties to find and defeat all other parties
|
||||||
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 res = await fetch(`/api/players/${currentId}/ai-step`, {
|
||||||
const radarRes = await fetch(`/api/players/${currentId}/radar`).catch(() => null);
|
method: 'POST',
|
||||||
let targetDirection: string | null = null;
|
});
|
||||||
|
if (res.ok) {
|
||||||
if (radarRes && radarRes.ok) {
|
const data: AiStepResponse = await res.json();
|
||||||
const radarData = await radarRes.json();
|
if (data.action_taken === 'formed_party' && data.formed_party) {
|
||||||
targetDirection = radarData.recommended_direction;
|
setLastEventMessage(`🤝 ${data.player_name} formed party "${data.formed_party.name}" under leader ${data.formed_party.leader_name}!`);
|
||||||
}
|
} else if (data.action_taken === 'battled' && data.battle_result) {
|
||||||
|
setActiveBattle(data.battle_result);
|
||||||
// 2. Check available moves
|
setLastEventMessage(`⚔️ Battle clash: ${data.battle_result.winner_party_name} defeated ${data.battle_result.defeated_party_name}!`);
|
||||||
const movesData = await fetchAvailableMoves(currentId);
|
} else if (data.move_result?.battle_result) {
|
||||||
if (!movesData) return;
|
setActiveBattle(data.move_result.battle_result);
|
||||||
|
|
||||||
const validDirections = Object.entries(movesData.moves)
|
|
||||||
.filter(([, result]) => result.available)
|
|
||||||
.map(([dir]) => dir);
|
|
||||||
|
|
||||||
if (validDirections.length > 0) {
|
|
||||||
// Prefer recommended direction towards nearest bot if available
|
|
||||||
let chosenDir = targetDirection && validDirections.includes(targetDirection) ? targetDirection : null;
|
|
||||||
if (!chosenDir) {
|
|
||||||
chosenDir = validDirections[Math.floor(Math.random() * validDirections.length)];
|
|
||||||
}
|
}
|
||||||
await movePlayer(currentId, chosenDir);
|
|
||||||
} else {
|
|
||||||
await passTurn(currentId);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Bot turn step error:', err);
|
console.warn('Bot AI step error:', err);
|
||||||
}
|
}
|
||||||
}, [boardState.turn.current_player_id, fetchAvailableMoves]);
|
}, [boardState.turn.current_player_id]);
|
||||||
|
|
||||||
// Autoplay interval loop
|
// Autoplay interval loop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,12 @@
|
||||||
|
export interface GridConfig {
|
||||||
|
min_x: number;
|
||||||
|
max_x: number;
|
||||||
|
min_y: number;
|
||||||
|
max_y: number;
|
||||||
|
grid_cells_x: number;
|
||||||
|
grid_cells_y: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Player {
|
export interface Player {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -7,9 +16,9 @@ export interface Player {
|
||||||
strength: 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 }[];
|
visited_locations?: { x: number; y: number }[];
|
||||||
created_at: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Party {
|
export interface Party {
|
||||||
|
|
@ -22,15 +31,6 @@ export interface Party {
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardConfig {
|
|
||||||
min_x: number;
|
|
||||||
max_x: number;
|
|
||||||
min_y: number;
|
|
||||||
max_y: number;
|
|
||||||
grid_cells_x: number;
|
|
||||||
grid_cells_y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TurnInfo {
|
export interface TurnInfo {
|
||||||
current_player_id: string | null;
|
current_player_id: string | null;
|
||||||
current_player_name: string | null;
|
current_player_name: string | null;
|
||||||
|
|
@ -40,19 +40,13 @@ export interface TurnInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardState {
|
export interface BoardState {
|
||||||
config: BoardConfig;
|
config: GridConfig;
|
||||||
player_count: number;
|
player_count: number;
|
||||||
players: Player[];
|
players: Player[];
|
||||||
parties: Party[];
|
parties: Party[];
|
||||||
turn: TurnInfo;
|
turn: TurnInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerCreateRequest {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
strength?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MoveCheckResult {
|
export interface MoveCheckResult {
|
||||||
direction: string;
|
direction: string;
|
||||||
dx: number;
|
dx: number;
|
||||||
|
|
@ -72,7 +66,7 @@ export interface AvailableMovesResponse {
|
||||||
is_party_leader: boolean;
|
is_party_leader: boolean;
|
||||||
party_id?: string | null;
|
party_id?: string | null;
|
||||||
party_member_count: number;
|
party_member_count: number;
|
||||||
current_turn_player_id?: string | null;
|
current_turn_player_id: string | null;
|
||||||
moves: Record<string, MoveCheckResult>;
|
moves: Record<string, MoveCheckResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,11 +112,25 @@ 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 };
|
||||||
|
party_formed_triggered?: boolean;
|
||||||
|
formed_party?: Party | null;
|
||||||
battle_triggered?: boolean;
|
battle_triggered?: boolean;
|
||||||
battle_result?: BattleResult | null;
|
battle_result?: BattleResult | null;
|
||||||
turn: TurnInfo;
|
turn: TurnInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiStepResponse {
|
||||||
|
action_taken: 'formed_party' | 'battled' | 'moved' | 'passed' | string;
|
||||||
|
player_id: string;
|
||||||
|
player_name: string;
|
||||||
|
bot_goal: 'form_party' | 'find_and_defeat_all_parties' | string;
|
||||||
|
direction?: string | null;
|
||||||
|
move_result?: MoveResponse | null;
|
||||||
|
formed_party?: Party | null;
|
||||||
|
battle_result?: BattleResult | null;
|
||||||
|
turn: TurnInfo;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PartyDefeatResult {
|
export interface PartyDefeatResult {
|
||||||
party_id: string;
|
party_id: string;
|
||||||
killed_leader_id: string;
|
killed_leader_id: string;
|
||||||
|
|
@ -151,17 +159,21 @@ export interface RadarTarget {
|
||||||
color: string;
|
color: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
strength: number;
|
||||||
distance: number;
|
distance: number;
|
||||||
party_id?: string | null;
|
party_id?: string | null;
|
||||||
party_name?: string | null;
|
party_name?: string | null;
|
||||||
is_ally: boolean;
|
is_ally: boolean;
|
||||||
is_enemy: boolean;
|
is_enemy: boolean;
|
||||||
|
can_recruit?: boolean;
|
||||||
|
can_battle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotRadarResponse {
|
export interface BotRadarResponse {
|
||||||
player_id: string;
|
player_id: string;
|
||||||
current_x: number;
|
current_x: number;
|
||||||
current_y: number;
|
current_y: number;
|
||||||
|
bot_goal: string;
|
||||||
targets: RadarTarget[];
|
targets: RadarTarget[];
|
||||||
nearest_target?: RadarTarget | null;
|
nearest_target?: RadarTarget | null;
|
||||||
recommended_direction?: string | null;
|
recommended_direction?: string | null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue