added and tested trolls

This commit is contained in:
Isaac Johnson 2026-09-13 14:30:16 -05:00
parent 0bc1c972b3
commit 862db5e8c3
29 changed files with 2759 additions and 142 deletions

View File

@ -16,6 +16,9 @@ Welcome to **botWebWars**! This document provides architectural context, compone
- `botagent`: Python heuristic agent (radar scanning, frontier exploration, deterministic leader negotiation). - `botagent`: Python heuristic agent (radar scanning, frontier exploration, deterministic leader negotiation).
- `botagent_ai`: LLM-assisted Python agent (Ollama / LLM-driven strategic negotiation and pathing). - `botagent_ai`: LLM-assisted Python agent (Ollama / LLM-driven strategic negotiation and pathing).
- `botagent_gear`: Vertex AI (Gemini) LLM-assisted agent (Google Cloud Vertex AI / Gemini 2.5 & 1.5 strategic reasoning). - `botagent_gear`: Vertex AI (Gemini) LLM-assisted agent (Google Cloud Vertex AI / Gemini 2.5 & 1.5 strategic reasoning).
- `trollagent`: Heuristic autonomous troll agent (solitary player hunter, sleep healing, ignores other trolls).
- `trollagent_ai`: LLM-assisted Python troll agent (Ollama / LLM-driven tactical hunting and sleep actions).
- `trollagent_gear`: Vertex AI (Gemini) LLM-assisted troll agent (Google Cloud Vertex AI / Gemini 2.5 & 1.5 strategic hunting).
--- ---
@ -67,6 +70,23 @@ botWebWars/
│ ├── INSTALL.md # Installation, virtual environment, and dependency instructions │ ├── INSTALL.md # Installation, virtual environment, and dependency instructions
│ └── README.md # Feature overview and invocation examples │ └── README.md # Feature overview and invocation examples
├── trollagent/ # Heuristic autonomous troll agent
│ ├── troll_agent.py # Autonomous troll client (player hunting, sleep healing)
│ └── README.md # Execution command examples
├── trollagent_ai/ # LLM-assisted autonomous troll agent (Ollama)
│ ├── bot.py # Troll delegating hunting/sleep strategy to Ollama LLM
│ ├── requirements.txt # Dependencies (requests)
│ ├── INSTALL.md # Setup & execution instructions
│ └── README.md # Feature overview
├── trollagent_gear/ # LLM-assisted autonomous troll agent (Google Vertex AI / Gemini)
│ ├── bot.py # Troll delegating tactical hunting to Vertex AI Gemini
│ ├── requirements.txt # Dependencies (google-auth, requests)
│ ├── SETUP.md # Authentication guide
│ ├── INSTALL.md # Installation instructions
│ └── README.md # Feature overview
└── examples/ # Reference screenshots and board style designs └── examples/ # Reference screenshots and board style designs
``` ```
@ -136,9 +156,21 @@ When writing bots, backend engine logic, or game simulations, adhere strictly to
- **No Turns or Actions**: Dead bots are omitted from turn order rotation and can **no longer move, duel, or take any actions**. - **No Turns or Actions**: Dead bots are omitted from turn order rotation and can **no longer move, duel, or take any actions**.
- **Scoreboard Preservation**: Dead bots **remain listed on the scores and scoreboard rankings** with their final achieved score, strength, and visited locations. - **Scoreboard Preservation**: Dead bots **remain listed on the scores and scoreboard rankings** with their final achieved score, strength, and visited locations.
### 8. Game Conclusion ### 8. Trolls (Character Type & Unique Mechanics)
- The game concludes when all surviving bots on the board are united into a **single remaining party** (or if only 1 survivor remains). - **No Alliances**: Trolls never form or join parties. They remain lone hunters throughout the game.
- Final rankings and trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker), with all bots (surviving and deceased) included on the scoreboard. - **Troll Truce**: Trolls do not battle each other. They ignore adjacent trolls and continue searching for players.
- **Player Hunting & Mandatory Battles**: Trolls actively hunt players and squads. Battles with adjacent players are mandatory.
- **Victory Scoring & No Strength Gain**: Winning a 3-bout D20 battle grants **+2 score points**. Trolls do not gain strength or absorb followers.
- **Health Damage & Death**: Losing battles inflicts **1 to 3 HP damage**. At 0 HP, trolls die and are marked with a gravestone.
- **No Gary the Wizard Duels**: Trolls cannot challenge or engage Gary the Wizard.
- **Rest & Sleep Action (+0.1 HP)**: A troll may spend its turn sleeping (`POST /api/players/{id}/sleep`), skipping movement to regenerate **+0.1 health points**.
### 9. Game Conclusion
- The game concludes under any of the following conditions:
1. **All players joined into one party**: When all surviving players have united into a single remaining party, even if trolls remain on the board (win condition for the united party).
2. **1 entity remains**: When exactly 1 living entity (player or troll) remains as the sole survivor.
3. **Only trolls remain**: When all players have been defeated and only trolls remain (due to troll mutual truce).
- Final rankings and trophies (1st, 2nd, 3rd) are awarded based on **Score** (with **Strength** as the tiebreaker), with all bots (surviving and deceased, players and trolls) included on the scoreboard. Trolls can achieve 1st place champion status.
--- ---
@ -150,7 +182,7 @@ The backend serves both REST endpoints under `/api` and a live WebSocket stream
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `GET` | `/api/health` | Container healthcheck endpoint | | `GET` | `/api/health` | Container healthcheck endpoint |
| `POST` | `/api/players` | Register bot: `{"name": str, "color": str, "strength": int, "health": int}` | | `POST` | `/api/players` | Register bot: `{"name": str, "color": str, "strength": int, "health": int, "character_type": "player"|"troll"}` |
| `GET` | `/api/players` | List all active players, scores, health, and positions | | `GET` | `/api/players` | List all active players, scores, health, and positions |
| `GET` | `/api/board` | Full board state (grid, obstacles, players, parties, wizard, current turn) | | `GET` | `/api/board` | Full board state (grid, obstacles, players, parties, wizard, current turn) |
| `POST` | `/api/board/reset` | Clear board, reset parties, reset players, and respawn wizard | | `POST` | `/api/board/reset` | Clear board, reset parties, reset players, and respawn wizard |
@ -160,6 +192,7 @@ The backend serves both REST endpoints under `/api` and a live WebSocket stream
| `GET` | `/api/players/{id}/memory` | Coordinate history and visited locations | | `GET` | `/api/players/{id}/memory` | Coordinate history and visited locations |
| `GET` | `/api/players/{id}/available-moves` | Valid movements in all 8 directions (evaluates terrain & obstacles) | | `GET` | `/api/players/{id}/available-moves` | Valid movements in all 8 directions (evaluates terrain & obstacles) |
| `POST` | `/api/players/{id}/move` | Execute a move (`{"direction": "N"|"S"|"E"|"W"|"NE"|"NW"|"SE"|"SW"}`) | | `POST` | `/api/players/{id}/move` | Execute a move (`{"direction": "N"|"S"|"E"|"W"|"NE"|"NW"|"SE"|"SW"}`) |
| `POST` | `/api/players/{id}/sleep` | Troll rest action: forfeit movement to regenerate +0.1 health |
| `POST` | `/api/players/{id}/ai-step` | Perform one autonomous decision step via internal game engine | | `POST` | `/api/players/{id}/ai-step` | Perform one autonomous decision step via internal game engine |
| `POST` | `/api/players/{id}/pass` | Pass turn to next queued entity | | `POST` | `/api/players/{id}/pass` | Pass turn to next queued entity |
| `GET` | `/api/turn` | Current turn owner, round number, and turn order | | `GET` | `/api/turn` | Current turn owner, round number, and turn order |
@ -267,6 +300,48 @@ The application is containerized into a single unified image via [Dockerfile](Do
python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5 -H 10 python3 botagent_gear/bot.py --name GeminiGear --color "#4285f4" -s 5 -H 10
``` ```
### D. Heuristic Troll Bot: `trollagent/`
- **Entry File**: `trollagent/troll_agent.py`
- **Technique**: Autonomous rule-based troll hunter.
- **Workflow**:
1. Registers with `character_type="troll"`, `piece_type="troll"`.
2. Polls turn queue; checks life status.
3. Uses radar sensor to scan for nearest player bots (ignores other trolls and Gary the Wizard).
4. Attacks adjacent players in mandatory 3-bout D20 combat (+2 score on win, 1-3 damage on defeat).
5. Evaluates health vs. `--sleep-threshold` (default 6.0 HP) or radar recommendation to execute rest turns (`POST /api/players/{id}/sleep`) for +0.1 HP regeneration.
6. Navigates around obstacles using Chebyshev path minimization.
- **Run Command**:
```bash
python3 trollagent/troll_agent.py --name GorgonTroll --color "#16a34a" -s 3 -H 10 --sleep-threshold 6.0
```
### E. LLM-Assisted Troll Bot (Ollama): `trollagent_ai/`
- **Entry File**: `trollagent_ai/bot.py`
- **Technique**: Ollama LLM-assisted tactical troll hunter with strict rule safeguards.
- **LLM Integration**:
- Prompts model with Troll rules of engagement (hunting players, no alliances, no wizard duels, sleep healing).
- Delegates discretionary tactical choices to the model:
- Deciding between resting to heal (`sleep`) vs. hunting (`move`).
- Selecting passable movement directions around obstacles.
- **Run Command**:
```bash
export OLLAMA_BASE_URL="http://localhost:11434"
export OLLAMA_MODEL="gemma4:12b"
python3 trollagent_ai/bot.py -n CarnageTroll -s 4 -H 10 -c "#15803d"
```
### F. Vertex AI (Gemini) Troll Bot: `trollagent_gear/`
- **Entry File**: `trollagent_gear/bot.py`
- **Technique**: Google Cloud Vertex AI (Gemini) model reasoning for tactical spatial pathing, ambush tracking, and strategic sleep turns.
- **LLM Integration**:
- Uses structured JSON generation (`responseMimeType: application/json`).
- Evaluates live health, nearest player distance, and obstacle bottlenecks to decide between healing (`sleep`) and hunting (`move`).
- Supports Google Cloud ADC (`gcloud auth application-default login`), service account keys, and direct API keys (`GEMINI_API_KEY` or `VERTEX_API_KEY`).
- **Run Command**:
```bash
python3 trollagent_gear/bot.py --name GeminiBrute --color "#047857" -s 4 -H 10
```
--- ---
## 8. Common Developer Workflows ## 8. Common Developer Workflows

View File

@ -53,8 +53,26 @@
- **No Turns or Actions**: Dead players are removed from the turn rotation and can **no longer move, duel, or perform any actions**. - **No Turns or Actions**: Dead players are removed from the turn rotation and can **no longer move, duel, or perform any actions**.
- **Scoreboard Preservation**: Dead players **remain listed on the scores and scoreboard rankings** with their final achieved score, strength, and stats. - **Scoreboard Preservation**: Dead players **remain listed on the scores and scoreboard rankings** with their final achieved score, strength, and stats.
8. **Trolls (Character Type & Unique Mechanics)**:
1. **Registration**: Trolls register with `character_type: "troll"` and `piece_type: "troll"`.
2. **No Alliances**: Trolls never form or join parties. They remain lone hunters throughout the game.
3. **Troll Truce**: Trolls do not battle each other. When trolls encounter each other on adjacent coordinates, they ignore each other and continue their hunt.
4. **Player Hunting & Mandatory Combat**: Trolls seek out player characters and player squads. When a troll is adjacent to a player or player party, battle is mandatory.
5. **Battle Resolution & Scoring**:
- Battles follow the standard 3-bout D20 format.
- **Victory**: If a troll wins, it receives **+2 victory points (score)**. Trolls **do not gain strength** and **never absorb** defeated followers.
- **Defeat**: If a troll loses, it takes **1 to 3 health damage** (randomized).
6. **Damage & Death**: Trolls can die when their health reaches **0 HP**, placing a gravestone marker on the board. Dead trolls cease taking turns but remain preserved on the scoreboard.
7. **No Wizard Duels**: Trolls cannot engage or duel with Gary the Wizard NPC.
8. **Rest & Sleep Action (+0.1 HP)**:
- A troll may choose to take a turn sleeping (`POST /api/players/{id}/sleep`).
- Choosing to sleep forfeits the troll's movement for that turn and regenerates **+0.1 health points**.
# Game Conclusion # Game Conclusion
The game ends when all surviving bots on the board are united into a single remaining party (or if only one surviving bot remains). The game reaches its conclusion and triggers the final scoreboard when any of the following end conditions are met:
1. **All Players Joined into One Party**: When all surviving players on the board have joined into a single remaining party, even if trolls remain on the board. This is a win condition for the united party.
2. **Single Surviving Entity**: When exactly 1 living entity (player or troll) remains on the board (sole survivor).
3. **Only Trolls Remain**: When all players have been defeated and only trolls remain on the board (since trolls observe a mutual truce and do not battle or band together).
All players (both living and deceased) are listed on the final scoreboard rankings. Final rankings and trophies (1st, 2nd, and 3rd place) are awarded based on **Score** (with **Strength** as the tiebreaker). All characters (both living and deceased, players and trolls) are listed on the final scoreboard rankings. Final rankings and trophies (1st, 2nd, and 3rd place) are awarded based on **Score** (with **Strength** as the tiebreaker). Trolls can win the match and achieve 1st place champion status on the final scores like players.

View File

@ -23,6 +23,7 @@ from app.models import (
PartyInviteResponse, PartyInviteResponse,
Player, Player,
PlayerCreate, PlayerCreate,
SleepResponse,
TurnInfo, TurnInfo,
WizardChallengeRequest, WizardChallengeRequest,
WizardChallengeResult, WizardChallengeResult,
@ -44,7 +45,7 @@ async def health_check():
@router.get( @router.get(
"/game/conclusion", "/game/conclusion",
response_model=GameConclusion, response_model=GameConclusion,
summary="Get the game conclusion state and final rankings when 1 party remains with all bots", summary="Get the game conclusion state and final rankings when an end condition is met",
tags=["System"], tags=["System"],
) )
async def get_game_conclusion(): async def get_game_conclusion():
@ -584,6 +585,15 @@ async def step_bot_ai(player_id: str):
"parties": [p.model_dump() for p in board_state.parties], "parties": [p.model_dump() for p in board_state.parties],
"turn": board_state.turn.model_dump(), "turn": board_state.turn.model_dump(),
}) })
elif result.sleep_result:
await manager.broadcast({
"event": "player_slept",
"player": result.sleep_result.player.model_dump(),
"health_gained": result.sleep_result.health_gained,
"new_health": result.sleep_result.new_health,
"players": [p.model_dump() for p in board_state.players],
"turn": result.turn.model_dump(),
})
elif result.move_result: elif result.move_result:
await manager.broadcast({ await manager.broadcast({
"event": "player_moved", "event": "player_moved",
@ -646,6 +656,52 @@ async def step_bot_ai(player_id: str):
) )
@router.post(
"/players/{player_id}/sleep",
response_model=SleepResponse,
summary="Troll takes the sleep action: misses turn and gains 0.1 health",
tags=["Movement"],
)
async def sleep_player(player_id: str):
try:
result = await game_engine.sleep_player(player_id)
board_state = await game_engine.get_board_state()
await manager.broadcast({
"event": "player_slept",
"player": result.player.model_dump(),
"health_gained": result.health_gained,
"new_health": result.new_health,
"players": [p.model_dump() for p in board_state.players],
"turn": result.turn.model_dump(),
})
if board_state.conclusion and board_state.conclusion.concluded:
await manager.broadcast({
"event": "game_concluded",
"conclusion": board_state.conclusion.model_dump(),
"players": [p.model_dump() for p in board_state.players],
"parties": [p.model_dump() for p in board_state.parties],
"turn": board_state.turn.model_dump(),
})
return result
except KeyError:
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,

View File

@ -23,6 +23,7 @@ from app.models import (
Player, Player,
PlayerCreate, PlayerCreate,
RadarTarget, RadarTarget,
SleepResponse,
TurnInfo, TurnInfo,
WizardChallengeBout, WizardChallengeBout,
WizardChallengeResult, WizardChallengeResult,
@ -384,20 +385,35 @@ class GameEngine:
player_id = f"player_{uuid.uuid4().hex[:8]}" player_id = f"player_{uuid.uuid4().hex[:8]}"
spawn_x, spawn_y = self._find_random_free_position() spawn_x, spawn_y = self._find_random_free_position()
# Determine piece class (knight or warrior) # Determine character type (player or troll)
char_type = (getattr(player_in, "character_type", None) or "player").lower().strip()
if char_type not in ["player", "troll"]:
char_type = "player"
# Determine piece class (knight, warrior, or troll)
piece_type = getattr(player_in, "piece_type", None) piece_type = getattr(player_in, "piece_type", None)
if not piece_type: if piece_type:
piece_type = piece_type.lower().strip()
if char_type == "troll" and (not piece_type or piece_type in ("knight", "warrior")):
piece_type = "troll"
elif not piece_type:
name_lower = player_in.name.lower() name_lower = player_in.name.lower()
if "warrior" in name_lower or "striker" in name_lower or "scout" in name_lower: if "troll" in name_lower:
piece_type = "troll"
char_type = "troll"
elif "warrior" in name_lower or "striker" in name_lower or "scout" in name_lower:
piece_type = "warrior" piece_type = "warrior"
elif "knight" in name_lower or "tank" in name_lower or "titan" in name_lower: elif "knight" in name_lower or "tank" in name_lower or "titan" in name_lower:
piece_type = "knight" piece_type = "knight"
else: else:
piece_type = "knight" if len(player_in.name) % 2 == 0 else "warrior" piece_type = "knight" if len(player_in.name) % 2 == 0 else "warrior"
elif piece_type == "troll":
char_type = "troll"
health = getattr(player_in, "health", 10) health = getattr(player_in, "health", 10.0)
if health is None: if health is None:
health = 10 health = 10.0
health = float(health)
player = Player( player = Player(
id=player_id, id=player_id,
@ -410,6 +426,7 @@ class GameEngine:
x=spawn_x, x=spawn_x,
y=spawn_y, y=spawn_y,
piece_type=piece_type, piece_type=piece_type,
character_type=char_type,
party_id=None, party_id=None,
is_party_leader=False, is_party_leader=False,
visited_locations=[{"x": spawn_x, "y": spawn_y}], visited_locations=[{"x": spawn_x, "y": spawn_y}],
@ -551,6 +568,10 @@ class GameEngine:
if not player: if not player:
raise KeyError(f"Player '{player_id}' not found") raise KeyError(f"Player '{player_id}' not found")
is_troll = player.character_type == "troll"
if is_troll:
bot_goal = "hunt_players"
else:
bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party" bot_goal = "find_and_defeat_all_parties" if player.party_id else "form_party"
targets: List[RadarTarget] = [] targets: List[RadarTarget] = []
@ -558,9 +579,28 @@ class GameEngine:
if other.id == player.id or not other.is_alive or other.health <= 0: if other.id == player.id or not other.is_alive or other.health <= 0:
continue continue
dist = max(abs(player.x - other.x), abs(player.y - other.y)) dist = max(abs(player.x - other.x), abs(player.y - other.y))
party_obj = self.parties.get(other.party_id) if other.party_id else None
if is_troll:
if other.character_type == "troll":
is_ally = False
is_enemy = False
can_recruit = False
can_battle = False
else:
is_ally = False
is_enemy = True
can_recruit = False
can_battle = True
else:
if other.character_type == "troll":
is_ally = False
is_enemy = True
can_recruit = False
can_battle = True
else:
is_ally = bool(player.party_id and player.party_id == other.party_id) is_ally = bool(player.party_id and player.party_id == other.party_id)
is_enemy = not is_ally is_enemy = not is_ally
party_obj = self.parties.get(other.party_id) if other.party_id else None
if player.party_id and player.is_party_leader and other.party_id is None: if player.party_id and player.is_party_leader and other.party_id is None:
can_recruit = other.strength <= player.strength can_recruit = other.strength <= player.strength
@ -598,8 +638,10 @@ class GameEngine:
targets.sort(key=lambda t: t.distance) targets.sort(key=lambda t: t.distance)
primary_targets = [] primary_targets = []
if bot_goal == "form_party": if is_troll:
recruit_targets = [t for t in targets if t.can_recruit or not t.party_id] primary_targets = [t for t in targets if t.is_enemy]
elif bot_goal == "form_party":
recruit_targets = [t for t in targets if t.can_recruit or (not t.party_id and not t.is_enemy)]
primary_targets = recruit_targets if recruit_targets else targets primary_targets = recruit_targets if recruit_targets else targets
else: else:
battle_targets = [t for t in targets if t.is_enemy and (t.party_id or t.can_battle)] battle_targets = [t for t in targets if t.is_enemy and (t.party_id or t.can_battle)]
@ -615,13 +657,13 @@ class GameEngine:
y=self.wizard.y, y=self.wizard.y,
distance=wiz_dist, distance=wiz_dist,
strength=self.wizard.strength, strength=self.wizard.strength,
can_challenge=(wiz_dist <= 1), can_challenge=(wiz_dist <= 1 and not is_troll),
) )
rec_dir = None rec_dir = None
rec_act = "explore_unvisited" rec_act = "explore_unvisited"
if player.health < 2 and self.wizard: if not is_troll and player.health < 2 and self.wizard:
rec_act = "seek_wizard" rec_act = "seek_wizard"
# Use BFS pathfinder to recommend direction navigating toward Gary the Wizard # Use BFS pathfinder to recommend direction navigating toward Gary the Wizard
bfs_path = self._find_path_bfs((player.x, player.y), (self.wizard.x, self.wizard.y)) bfs_path = self._find_path_bfs((player.x, player.y), (self.wizard.x, self.wizard.y))
@ -663,6 +705,14 @@ class GameEngine:
rec_dir = name rec_dir = name
break break
if is_troll:
if nearest.distance <= 1:
rec_act = "engage_battle"
elif player.health < 4.0:
rec_act = "sleep"
else:
rec_act = "hunt_players"
else:
if nearest.distance <= 1: if nearest.distance <= 1:
if nearest.can_recruit: if nearest.can_recruit:
rec_act = "form_party" rec_act = "form_party"
@ -741,6 +791,8 @@ class GameEngine:
raise KeyError(f"Player '{mid}' not found") raise KeyError(f"Player '{mid}' not found")
if not p.is_alive or p.health <= 0: if not p.is_alive or p.health <= 0:
raise ValueError(f"Player '{p.name}' is dead and cannot join a party.") raise ValueError(f"Player '{p.name}' is dead and cannot join a party.")
if p.character_type == "troll":
raise ValueError(f"Troll '{p.name}' cannot join or form a party. Trolls do not band together.")
member_players.append(p) member_players.append(p)
if not self._verify_party_connectivity(member_players): if not self._verify_party_connectivity(member_players):
@ -788,6 +840,8 @@ class GameEngine:
invitee = self.players.get(invitee_id) invitee = self.players.get(invitee_id)
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")
if inviter.character_type == "troll" or invitee.character_type == "troll":
raise ValueError("Trolls do not band together into parties.")
if not inviter.is_alive or inviter.health <= 0: if not inviter.is_alive or inviter.health <= 0:
raise ValueError(f"Player '{inviter.name}' is dead and cannot invite players.") raise ValueError(f"Player '{inviter.name}' is dead and cannot invite players.")
if not invitee.is_alive or invitee.health <= 0: if not invitee.is_alive or invitee.health <= 0:
@ -832,6 +886,8 @@ class GameEngine:
invitee = self.players.get(invite.invitee_id) invitee = self.players.get(invite.invitee_id)
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.character_type == "troll" or invitee.character_type == "troll":
raise ValueError("Trolls do not band together into parties.")
if not inviter.is_alive or not invitee.is_alive: if not inviter.is_alive or not invitee.is_alive:
raise ValueError("Cannot join party with deceased player.") raise ValueError("Cannot join party with deceased player.")
@ -1255,6 +1311,7 @@ class GameEngine:
defeated_party = party1 defeated_party = party1
winner_lead = self.players.get(winner_party.leader_id) winner_lead = self.players.get(winner_party.leader_id)
is_winner_troll = bool(winner_lead and winner_lead.character_type == "troll")
if winner_lead: if winner_lead:
winner_lead.score += 2 winner_lead.score += 2
@ -1263,6 +1320,7 @@ class GameEngine:
self.players[mid].score += 1 self.players[mid].score += 1
killed_leader = self.players.get(defeated_party.leader_id) killed_leader = self.players.get(defeated_party.leader_id)
is_defeated_troll = bool(killed_leader and killed_leader.character_type == "troll")
absorbed_members: List[str] = [] absorbed_members: List[str] = []
dead_players: List[str] = [] dead_players: List[str] = []
@ -1276,9 +1334,9 @@ class GameEngine:
p = self.players.get(mid) p = self.players.get(mid)
if p: if p:
hp_loss = random.randint(1, 3) hp_loss = random.randint(1, 3)
p.health = max(0, p.health - hp_loss) p.health = round(max(0.0, p.health - hp_loss), 1)
health_losses[mid] = hp_loss health_losses[mid] = hp_loss
if p.health == 0 and p.is_alive: if p.health <= 0 and p.is_alive:
self._check_and_apply_death(p) self._check_and_apply_death(p)
dead_players.append(mid) dead_players.append(mid)
@ -1286,7 +1344,15 @@ class GameEngine:
killed_leader.score -= 1 killed_leader.score -= 1
if killed_leader.is_alive: if killed_leader.is_alive:
if len(defeated_party.member_ids) == 1: if is_winner_troll or is_defeated_troll:
# Trolls do not absorb members and cannot be absorbed
respawn_x, respawn_y = self._find_random_free_position()
killed_leader.x = respawn_x
killed_leader.y = respawn_y
killed_leader.party_id = None
killed_leader.is_party_leader = False
respawn_pos = {"x": respawn_x, "y": respawn_y}
elif len(defeated_party.member_ids) == 1:
killed_leader.party_id = winner_party.id killed_leader.party_id = winner_party.id
killed_leader.is_party_leader = False killed_leader.is_party_leader = False
if killed_leader.id not in winner_party.member_ids: if killed_leader.id not in winner_party.member_ids:
@ -1304,7 +1370,39 @@ class GameEngine:
# Leader is dead: remains at final coordinates as gravestone, disconnected from party # Leader is dead: remains at final coordinates as gravestone, disconnected from party
respawn_pos = {"x": killed_leader.x, "y": killed_leader.y} respawn_pos = {"x": killed_leader.x, "y": killed_leader.y}
# Only surviving defeated party followers are absorbed into winning party if is_winner_troll:
# Winner is a troll: NO followers are absorbed, troll gains NO strength!
surviving_followers = [
self.players[mid] for mid in defeated_party.member_ids
if mid != defeated_party.leader_id and self.players.get(mid) and self.players[mid].is_alive
]
if surviving_followers:
new_lead = max(surviving_followers, key=lambda f: (f.strength, f.score))
defeated_party.leader_id = new_lead.id
defeated_party.leader_name = new_lead.name
defeated_party.member_ids = [f.id for f in surviving_followers]
new_lead.is_party_leader = True
self._update_party_strength(defeated_party)
else:
if defeated_party.id in self.parties:
del self.parties[defeated_party.id]
# Clean up temporary troll party so troll stays completely solo
if winner_party.id in self.parties:
del self.parties[winner_party.id]
if winner_lead:
winner_lead.party_id = None
winner_lead.is_party_leader = False
elif is_defeated_troll:
# Defeated was a troll: No one absorbed from troll
if defeated_party.id in self.parties:
del self.parties[defeated_party.id]
if killed_leader:
killed_leader.party_id = None
killed_leader.is_party_leader = False
self._update_party_strength(winner_party)
else:
# Standard player vs player party defeat: Only surviving defeated followers are absorbed
for mid in list(defeated_party.member_ids): for mid in list(defeated_party.member_ids):
if mid != defeated_party.leader_id: if mid != defeated_party.leader_id:
m = self.players.get(mid) m = self.players.get(mid)
@ -1358,6 +1456,9 @@ class GameEngine:
if not p2.is_alive or p2.health <= 0: if not p2.is_alive or p2.health <= 0:
raise ValueError(f"Defender '{p2.name}' is dead and cannot battle.") raise ValueError(f"Defender '{p2.name}' is dead and cannot battle.")
if p1.character_type == "troll" and p2.character_type == "troll":
raise ValueError("Trolls do not battle each other.")
if p1.party_id and p1.party_id == p2.party_id: if p1.party_id and p1.party_id == p2.party_id:
raise ValueError("Cannot battle members of your own party") raise ValueError("Cannot battle members of your own party")
@ -1369,9 +1470,10 @@ class GameEngine:
if not party1: if not party1:
temp_p1_id = f"party_{uuid.uuid4().hex[:8]}" temp_p1_id = f"party_{uuid.uuid4().hex[:8]}"
p1_prefix = "Troll" if p1.character_type == "troll" else "Squad"
party1 = Party( party1 = Party(
id=temp_p1_id, id=temp_p1_id,
name=f"Squad {p1.name}", name=f"{p1_prefix} {p1.name}",
leader_id=p1.id, leader_id=p1.id,
leader_name=p1.name, leader_name=p1.name,
member_ids=[p1.id], member_ids=[p1.id],
@ -1383,9 +1485,10 @@ class GameEngine:
if not party2: if not party2:
temp_p2_id = f"party_{uuid.uuid4().hex[:8]}" temp_p2_id = f"party_{uuid.uuid4().hex[:8]}"
p2_prefix = "Troll" if p2.character_type == "troll" else "Squad"
party2 = Party( party2 = Party(
id=temp_p2_id, id=temp_p2_id,
name=f"Squad {p2.name}", name=f"{p2_prefix} {p2.name}",
leader_id=p2.id, leader_id=p2.id,
leader_name=p2.name, leader_name=p2.name,
member_ids=[p2.id], member_ids=[p2.id],
@ -1536,6 +1639,9 @@ class GameEngine:
if not player.is_alive or player.health <= 0: if not player.is_alive or player.health <= 0:
raise ValueError(f"Player '{player.name}' is dead and cannot challenge the wizard.") raise ValueError(f"Player '{player.name}' is dead and cannot challenge the wizard.")
if player.character_type == "troll":
raise ValueError("Trolls cannot engage with Gary the Wizard.")
current_turn_player = self._get_current_player() current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id: if not current_turn_player or current_turn_player.id != player_id:
curr_name = current_turn_player.name if current_turn_player else "Nobody" curr_name = current_turn_player.name if current_turn_player else "Nobody"
@ -1568,7 +1674,7 @@ class GameEngine:
if len(self.players) < 2: if len(self.players) < 2:
return GameConclusion(concluded=False) return GameConclusion(concluded=False)
living_players = [p for p in self.players.values() if p.is_alive] living_entities = [p for p in self.players.values() if p.is_alive]
total_bots = len(self.players) total_bots = len(self.players)
rankings = sorted( rankings = sorted(
self.players.values(), self.players.values(),
@ -1576,43 +1682,67 @@ class GameEngine:
reverse=True, reverse=True,
) )
# Case 1: All bots died # Case 0: All bots died
if len(living_players) == 0: if len(living_entities) == 0:
return GameConclusion( return GameConclusion(
concluded=True, concluded=True,
total_bots=total_bots, total_bots=total_bots,
rankings=rankings, rankings=rankings,
) )
# Case 2: Exactly 1 living bot remains (sole survivor) # End Condition 1: Exactly 1 living entity remains (player or troll)
if len(living_players) == 1: if len(living_entities) == 1:
survivor = living_players[0] survivor = living_entities[0]
party = self.parties.get(survivor.party_id) if survivor.party_id else None party = self.parties.get(survivor.party_id) if survivor.party_id else None
is_survivor_troll = survivor.character_type == "troll"
default_name = f"Troll {survivor.name}" if is_survivor_troll else f"Squad {survivor.name}"
return GameConclusion( return GameConclusion(
concluded=True, concluded=True,
winning_party_id=party.id if party else None, winning_party_id=party.id if party else None,
winning_party_name=party.name if party else f"Squad {survivor.name}", winning_party_name=party.name if party else default_name,
winning_leader_id=party.leader_id if party else survivor.id, winning_leader_id=party.leader_id if party else survivor.id,
winning_leader_name=party.leader_name if party else survivor.name, winning_leader_name=party.leader_name if party else survivor.name,
total_bots=total_bots, total_bots=total_bots,
rankings=rankings, rankings=rankings,
) )
# Case 3: All living bots are united into a single remaining party living_trolls = [p for p in living_entities if p.character_type == "troll"]
if len(self.parties) == 1: living_players = [p for p in living_entities if p.character_type != "troll"]
only_party = next(iter(self.parties.values()))
living_member_ids = { # End Condition 2: Only trolls remain (all players defeated)
mid for mid in only_party.member_ids # Since trolls do not band together or battle each other, the game concludes.
if self.players.get(mid) and self.players[mid].is_alive if len(living_players) == 0 and len(living_trolls) > 0:
} surviving_trolls = sorted(
living_player_ids = {p.id for p in living_players} living_trolls,
if living_member_ids == living_player_ids and len(living_player_ids) >= 1: key=lambda p: (p.score, p.strength, p.name),
reverse=True,
)
top_troll = surviving_trolls[0]
return GameConclusion( return GameConclusion(
concluded=True, concluded=True,
winning_party_id=only_party.id, winning_party_id=None,
winning_party_name=only_party.name, winning_party_name=f"Troll {top_troll.name}",
winning_leader_id=only_party.leader_id, winning_leader_id=top_troll.id,
winning_leader_name=only_party.leader_name, winning_leader_name=top_troll.name,
total_bots=total_bots,
rankings=rankings,
)
# End Condition 3: All players have joined into one party (even if trolls remain)
if len(living_players) >= 2:
first_party_id = living_players[0].party_id
if first_party_id and all(p.party_id == first_party_id for p in living_players):
party = self.parties.get(first_party_id)
if party:
leader = self.players.get(party.leader_id)
leader_id = leader.id if (leader and leader.is_alive) else living_players[0].id
leader_name = leader.name if (leader and leader.is_alive) else living_players[0].name
return GameConclusion(
concluded=True,
winning_party_id=party.id,
winning_party_name=party.name,
winning_leader_id=leader_id,
winning_leader_name=leader_name,
total_bots=total_bots, total_bots=total_bots,
rankings=rankings, rankings=rankings,
) )
@ -1636,6 +1766,48 @@ class GameEngine:
if not self._are_adjacent(player, other): if not self._are_adjacent(player, other):
continue continue
# Case Trolls Encounter Logic:
# 1. Trolls do not band together or battle each other
if player.character_type == "troll" and other.character_type == "troll":
continue
# 2. Troll vs Player (or Player Party): MANDATORY BATTLE!
if player.character_type == "troll" or other.character_type == "troll":
p1_party = self.parties.get(player.party_id) if player.party_id else None
if not p1_party:
temp_p1_id = f"party_{uuid.uuid4().hex[:8]}"
p1_prefix = "Troll" if player.character_type == "troll" else "Squad"
p1_party = Party(
id=temp_p1_id,
name=f"{p1_prefix} {player.name}",
leader_id=player.id,
leader_name=player.name,
member_ids=[player.id],
total_strength=player.strength,
)
player.party_id = temp_p1_id
player.is_party_leader = True
self.parties[temp_p1_id] = p1_party
p2_party = self.parties.get(other.party_id) if other.party_id else None
if not p2_party:
temp_p2_id = f"party_{uuid.uuid4().hex[:8]}"
p2_prefix = "Troll" if other.character_type == "troll" else "Squad"
p2_party = Party(
id=temp_p2_id,
name=f"{p2_prefix} {other.name}",
leader_id=other.id,
leader_name=other.name,
member_ids=[other.id],
total_strength=other.strength,
)
other.party_id = temp_p2_id
other.is_party_leader = True
self.parties[temp_p2_id] = p2_party
battle_res = self._resolve_3bout_battle_internal(p1_party, p2_party)
return None, battle_res
# Case A: Solo + Solo encounter # Case A: Solo + Solo encounter
if not player.party_id and not other.party_id: if not player.party_id and not other.party_id:
leader_id = self._negotiate_party_leader_id(player, other) leader_id = self._negotiate_party_leader_id(player, other)
@ -1847,6 +2019,41 @@ class GameEngine:
self._advance_turn() self._advance_turn()
return self._get_turn_info() return self._get_turn_info()
async def sleep_player(self, player_id: str) -> SleepResponse:
async with self._lock:
if not self.game_started:
raise ValueError("Game has not started yet. Waiting for Start Game button in UI.")
player = self.players.get(player_id)
if not player:
raise KeyError(f"Player '{player_id}' not found")
if not player.is_alive or player.health <= 0:
raise ValueError(f"Player '{player.name}' is dead and cannot act.")
if player.character_type != "troll":
raise ValueError("Only trolls can take the sleep action.")
current_turn_player = self._get_current_player()
if not current_turn_player or current_turn_player.id != player_id:
curr_name = current_turn_player.name if current_turn_player else "Nobody"
curr_id = current_turn_player.id if current_turn_player else "None"
raise PermissionError(
f"It is not your turn to sleep. Current turn belongs to '{curr_name}' ({curr_id})."
)
# Troll misses a turn and gains 0.1 health
player.health = round(player.health + 0.1, 1)
player.max_health = max(player.max_health, player.health)
self._advance_turn()
turn_info = self._get_turn_info()
return SleepResponse(
success=True,
player=player,
health_gained=0.1,
new_health=player.health,
turn=turn_info,
)
async def step_bot_ai(self, player_id: str) -> AiStepResponse: async def step_bot_ai(self, player_id: str) -> AiStepResponse:
async with self._lock: async with self._lock:
if not self.game_started: if not self.game_started:
@ -1865,6 +2072,163 @@ class GameEngine:
f"It is not your turn. Current turn belongs to '{curr_name}' ({curr_id})." f"It is not your turn. Current turn belongs to '{curr_name}' ({curr_id})."
) )
if player.character_type == "troll":
bot_goal = "hunt_players"
# 1. Check if already adjacent to encounter before moving
formed_party, battle_res = self._check_adjacent_encounter(player)
if battle_res:
self._advance_turn()
conclusion = self._check_game_concluded()
return AiStepResponse(
action_taken="battled",
player_id=player.id,
player_name=player.name,
bot_goal=bot_goal,
direction=None,
move_result=None,
formed_party=None,
battle_result=battle_res,
game_concluded=conclusion if conclusion.concluded else None,
turn=self._get_turn_info(),
)
# 2. Check if troll chooses to sleep:
if player.health < 6.0:
player.health = round(player.health + 0.1, 1)
player.max_health = max(player.max_health, player.health)
self._advance_turn()
turn_info = self._get_turn_info()
conclusion = self._check_game_concluded()
sleep_res = SleepResponse(
success=True,
player=player,
health_gained=0.1,
new_health=player.health,
turn=turn_info,
)
return AiStepResponse(
action_taken="slept",
player_id=player.id,
player_name=player.name,
bot_goal=bot_goal,
direction=None,
move_result=None,
formed_party=None,
battle_result=None,
sleep_result=sleep_res,
game_concluded=conclusion if conclusion.concluded else None,
turn=turn_info,
)
# 3. Troll navigates towards nearest player
occupied = self._get_occupied_coordinates()
moves_map: Dict[str, MoveCheckResult] = {}
for name, dx, dy in STANDARD_DIRECTIONS:
moves_map[name] = self._check_move_internal(player, dx, dy, name, occupied)
available_dirs = [name for name, chk in moves_map.items() if chk.available]
if not available_dirs:
self._advance_turn()
conclusion = self._check_game_concluded()
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,
game_concluded=conclusion if conclusion.concluded else None,
turn=self._get_turn_info(),
)
living_players = [
(max(abs(player.x - other.x), abs(player.y - other.y)), other)
for other in self.players.values()
if other.id != player.id and other.is_alive and other.health > 0 and other.character_type != "troll"
]
living_players.sort(key=lambda t: t[0])
target_bot = living_players[0][1] if living_players else None
bfs_next_step: Optional[Tuple[int, int]] = None
if target_bot:
path = self._find_path_bfs((player.x, player.y), (target_bot.x, target_bot.y))
if path and len(path) >= 2:
bfs_next_step = (path[1][0] - player.x, path[1][1] - player.y)
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 bfs_next_step and (dx, dy) == bfs_next_step:
score += 50.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
if (tx, ty) not in visited_set:
score += 2.0
if score > best_score:
best_score = score
best_dir = dir_name
dx, dy = DIRECTION_OFFSETS[best_dir]
prev_pos = {"x": player.x, "y": player.y}
player.x += dx
player.y += dy
player.visited_locations.append({"x": player.x, "y": player.y})
affected_players = [player]
new_pos = {"x": player.x, "y": player.y}
formed_party, battle_result = self._check_adjacent_encounter(player)
self._advance_turn()
turn_info = self._get_turn_info()
conclusion = self._check_game_concluded()
move_res = MoveResponse(
success=True,
player=player,
direction=best_dir,
party_moved=False,
affected_players=affected_players,
previous_position=prev_pos,
new_position=new_pos,
party_formed_triggered=False,
formed_party=None,
battle_triggered=bool(battle_result),
battle_result=battle_result,
game_concluded=conclusion if conclusion.concluded else None,
turn=turn_info,
)
action_name = "moved"
if battle_result:
action_name = "battled"
return AiStepResponse(
action_taken=action_name,
player_id=player.id,
player_name=player.name,
bot_goal=bot_goal,
direction=best_dir,
move_result=move_res,
formed_party=None,
battle_result=battle_result,
game_concluded=conclusion if conclusion.concluded else None,
turn=turn_info,
)
seeking_wizard = bool(player.health < 2 and self.wizard) seeking_wizard = bool(player.health < 2 and self.wizard)
bot_goal = ( bot_goal = (
"seek_wizard" "seek_wizard"

View File

@ -119,8 +119,9 @@ 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: float = Field(default=1.0, ge=1, description="Bot strength (default is 1)") strength: float = Field(default=1.0, ge=1, description="Bot strength (default is 1)")
health: Optional[int] = Field(default=10, ge=1, description="Starting health points (default is 10)") health: Optional[float] = Field(default=10.0, ge=0.1, description="Starting health points (default is 10)")
piece_type: Optional[str] = Field(default="knight", description="Board game piece class: 'knight' or 'warrior'") piece_type: Optional[str] = Field(default=None, description="Board game piece class: 'knight', 'warrior', or 'troll'")
character_type: Optional[str] = Field(default="player", description="Character type: 'player' or 'troll'")
@field_validator("name") @field_validator("name")
@classmethod @classmethod
@ -149,9 +150,10 @@ class Player(BaseModel):
y: int y: int
strength: float = 1.0 strength: float = 1.0
score: int = 0 score: int = 0
health: int = 10 health: float = 10.0
max_health: int = 10 max_health: float = 10.0
piece_type: Optional[str] = "knight" piece_type: Optional[str] = "knight"
character_type: str = "player" # "player" or "troll"
party_id: Optional[str] = None party_id: Optional[str] = None
is_party_leader: bool = False is_party_leader: bool = False
is_alive: bool = True is_alive: bool = True
@ -427,16 +429,25 @@ class MoveResponse(BaseModel):
turn: TurnInfo turn: TurnInfo
class SleepResponse(BaseModel):
success: bool = True
player: Player
health_gained: float = 0.1
new_health: float
turn: TurnInfo
class AiStepResponse(BaseModel): class AiStepResponse(BaseModel):
action_taken: str # "formed_party", "battled", "challenged_wizard", "moved", "passed" action_taken: str # "formed_party", "battled", "challenged_wizard", "moved", "passed", "slept"
player_id: str player_id: str
player_name: str player_name: str
bot_goal: str # "form_party" or "find_and_defeat_all_parties" bot_goal: str # "form_party", "find_and_defeat_all_parties", "hunt_players"
direction: Optional[str] = None direction: Optional[str] = None
move_result: Optional[MoveResponse] = None move_result: Optional[MoveResponse] = None
formed_party: Optional[Party] = None formed_party: Optional[Party] = None
battle_result: Optional[BattleResult] = None battle_result: Optional[BattleResult] = None
wizard_challenge_result: Optional[WizardChallengeResult] = None wizard_challenge_result: Optional[WizardChallengeResult] = None
sleep_result: Optional[SleepResponse] = None
game_concluded: Optional[GameConclusion] = None game_concluded: Optional[GameConclusion] = None
turn: TurnInfo turn: TurnInfo

View File

@ -854,3 +854,288 @@ def test_step_bot_ai_seeks_and_challenges_wizard_when_low_health():
step2_data = step2_res.json() step2_data = step2_res.json()
assert step2_data["action_taken"] == "challenged_wizard" assert step2_data["action_taken"] == "challenged_wizard"
assert step2_data["wizard_challenge_result"] is not None assert step2_data["wizard_challenge_result"] is not None
def test_troll_registration_and_attributes():
client = TestClient(app)
client.post("/api/reset")
troll_res = client.post("/api/players", json={
"name": "GorgTroll",
"color": "#16a34a",
"strength": 5,
"health": 12,
"character_type": "troll",
})
assert troll_res.status_code == 201
troll = troll_res.json()
assert troll["name"] == "GorgTroll"
assert troll["character_type"] == "troll"
assert troll["piece_type"] == "troll"
assert troll["health"] == 12.0
assert troll["strength"] == 5.0
assert troll["party_id"] is None
assert troll["is_party_leader"] is False
def test_trolls_do_not_band_together_or_battle_each_other():
client = TestClient(app)
client.post("/api/reset")
t1 = client.post("/api/players", json={"name": "Troll1", "color": "#16a34a", "strength": 5, "character_type": "troll"}).json()
t2 = client.post("/api/players", json={"name": "Troll2", "color": "#15803d", "strength": 4, "character_type": "troll"}).json()
# Place trolls adjacent
async def place_trolls():
b1 = await game_engine.get_player(t1["id"])
b2 = await game_engine.get_player(t2["id"])
b1.x, b1.y = 10, 10
b2.x, b2.y = 10, 11
asyncio.run(place_trolls())
client.post("/api/game/start")
# 1. Attempting direct party formation between trolls must fail
party_res = client.post("/api/parties", json={"member_ids": [t1["id"], t2["id"]], "leader_id": t1["id"]})
assert party_res.status_code == 400
assert "Trolls do not band together" in party_res.json()["detail"]
# 2. Attempting invite between trolls must fail
inv_res = client.post("/api/parties/invite", json={
"inviter_id": t1["id"],
"invitee_id": t2["id"],
"proposed_leader_id": t1["id"],
})
assert inv_res.status_code == 400
assert "Trolls do not band together" in inv_res.json()["detail"]
# 3. Attempting battle between trolls must fail
fight_res = client.post("/api/battles/fight", json={"challenger_id": t1["id"], "defender_id": t2["id"]})
assert fight_res.status_code == 400
assert "Trolls do not battle each other" in fight_res.json()["detail"]
# 4. Moving troll next to another troll should NOT trigger encounter or battle
game_engine.turn_order = [t1["id"], t2["id"]]
game_engine.current_turn_index = 0
move_res = client.post(f"/api/players/{t1['id']}/move", json={"direction": "RIGHT"})
assert move_res.status_code == 200
data = move_res.json()
assert data["party_formed_triggered"] is False
assert data["battle_triggered"] is False
def test_troll_vs_player_mandatory_battle_and_mechanics():
client = TestClient(app)
client.post("/api/reset")
troll = client.post("/api/players", json={"name": "BruteTroll", "color": "#16a34a", "strength": 5, "character_type": "troll"}).json()
player = client.post("/api/players", json={"name": "BraveKnight", "color": "#38bdf8", "strength": 3, "character_type": "player"}).json()
# Place them 1 tile apart: player at (10, 10), troll at (10, 12)
async def place_combatants():
t = await game_engine.get_player(troll["id"])
p = await game_engine.get_player(player["id"])
t.x, t.y = 10, 12
p.x, p.y = 10, 10
asyncio.run(place_combatants())
client.post("/api/game/start")
game_engine.turn_order = [troll["id"], player["id"]]
game_engine.current_turn_index = 0
initial_troll_str = troll["strength"]
# Troll moves UP to (10, 11) adjacent to player at (10, 10) -> MANDATORY BATTLE!
move_res = client.post(f"/api/players/{troll['id']}/move", json={"direction": "UP"})
assert move_res.status_code == 200
data = move_res.json()
assert data["battle_triggered"] is True
assert data["battle_result"] is not None
b_res = data["battle_result"]
# Verify: Trolls do NOT absorb members
assert len(b_res["absorbed_members"]) == 0
# Verify: Troll does not remain in any party after battle
t_after = client.get(f"/api/players/{troll['id']}").json()
assert t_after["party_id"] is None
assert t_after["is_party_leader"] is False
# Troll did not gain strength
assert t_after["strength"] == initial_troll_str
def test_troll_cannot_challenge_wizard():
client = TestClient(app)
client.post("/api/reset")
troll = client.post("/api/players", json={"name": "CaveTroll", "color": "#16a34a", "strength": 6, "character_type": "troll"}).json()
wiz = client.get("/api/wizard").json()
async def place_troll_at_wizard():
t = await game_engine.get_player(troll["id"])
t.x, t.y = wiz["x"] + 1, wiz["y"]
asyncio.run(place_troll_at_wizard())
client.post("/api/game/start")
game_engine.turn_order = [troll["id"]]
game_engine.current_turn_index = 0
# Radar can_challenge must be False for wizard
radar = client.get(f"/api/players/{troll['id']}/radar").json()
assert radar["wizard"]["can_challenge"] is False
assert radar["recommended_action"] != "challenge_wizard"
assert radar["recommended_action"] != "seek_wizard"
# Attempting to challenge Gary directly must be rejected
chal_res = client.post("/api/wizard/challenge", json={"player_id": troll["id"], "reward_choice": "score"})
assert chal_res.status_code == 400
assert "Trolls cannot engage with Gary the Wizard" in chal_res.json()["detail"]
def test_troll_sleep_action_and_health_regen():
client = TestClient(app)
client.post("/api/reset")
troll = client.post("/api/players", json={"name": "SleepyTroll", "color": "#16a34a", "strength": 4, "health": 8, "character_type": "troll"}).json()
player = client.post("/api/players", json={"name": "AwakePlayer", "color": "#38bdf8", "strength": 2, "character_type": "player"}).json()
client.post("/api/game/start")
game_engine.turn_order = [troll["id"], player["id"]]
game_engine.current_turn_index = 0
# 1. Troll sleeps: gains 0.1 health and advances turn
sleep_res = client.post(f"/api/players/{troll['id']}/sleep")
assert sleep_res.status_code == 200
s_data = sleep_res.json()
assert s_data["success"] is True
assert s_data["health_gained"] == 0.1
assert s_data["new_health"] == 8.1
assert s_data["player"]["health"] == 8.1
# Turn advanced to player
assert s_data["turn"]["current_player_id"] == player["id"]
# 2. Player cannot sleep (it is player's turn now)
player_sleep = client.post(f"/api/players/{player['id']}/sleep")
assert player_sleep.status_code == 400
assert "Only trolls can take the sleep action" in player_sleep.json()["detail"]
def test_troll_game_conclusion_and_rankings():
client = TestClient(app)
client.post("/api/reset")
troll1 = client.post("/api/players", json={"name": "TrollKing", "color": "#16a34a", "strength": 5, "character_type": "troll"}).json()
troll2 = client.post("/api/players", json={"name": "TrollGuard", "color": "#15803d", "strength": 3, "character_type": "troll"}).json()
# Give TrollKing higher score
async def set_troll_score():
tk = await game_engine.get_player(troll1["id"])
tk.score = 6
asyncio.run(set_troll_score())
# Only trolls remain: game concludes and troll can win!
conc = client.get("/api/game/conclusion").json()
assert conc["concluded"] is True
assert conc["winning_leader_id"] == troll1["id"]
assert conc["winning_leader_name"] == "TrollKing"
assert conc["rankings"][0]["name"] == "TrollKing"
assert conc["rankings"][0]["score"] == 6
def test_win_condition_all_players_joined_one_party_with_trolls_remaining():
"""When all players have joined one party, even if there are trolls remaining, that is a win condition."""
client = TestClient(app)
client.post("/api/reset")
p1 = client.post("/api/players", json={"name": "PlayerAlpha", "color": "#3b82f6", "strength": 4, "character_type": "player"}).json()
p2 = client.post("/api/players", json={"name": "PlayerBeta", "color": "#8b5cf6", "strength": 3, "character_type": "player"}).json()
t1 = client.post("/api/players", json={"name": "TrollBrute", "color": "#16a34a", "strength": 5, "character_type": "troll"}).json()
t2 = client.post("/api/players", json={"name": "TrollGorgon", "color": "#15803d", "strength": 4, "character_type": "troll"}).json()
# Set up adjacent positions
async def setup_positions():
b1 = await game_engine.get_player(p1["id"])
b1.x, b1.y = 10, 10
b2 = await game_engine.get_player(p2["id"])
b2.x, b2.y = 10, 11
bt1 = await game_engine.get_player(t1["id"])
bt1.x, bt1.y = 25, 25
bt2 = await game_engine.get_player(t2["id"])
bt2.x, bt2.y = 30, 30
asyncio.run(setup_positions())
client.post("/api/game/start")
# Before players unite, game is not concluded
conc_before = client.get("/api/game/conclusion").json()
assert conc_before["concluded"] is False
# All living players (p1, p2) join into one party, while trolls t1, t2 remain on the board
party_res = client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
"leader_id": p1["id"],
"name": "UnitedPlayers",
})
assert party_res.status_code == 201
# Trolls remain alive, but all players joined 1 party -> WIN CONDITION! Game concludes!
conc_after = client.get("/api/game/conclusion").json()
assert conc_after["concluded"] is True
assert conc_after["winning_party_name"] == "UnitedPlayers"
assert conc_after["winning_leader_id"] == p1["id"]
assert conc_after["winning_leader_name"] == "PlayerAlpha"
assert conc_after["total_bots"] == 4
def test_game_does_not_conclude_with_one_solo_player_and_multiple_trolls_until_elimination():
"""1 solo player and trolls alive should continue fighting until 1 entity remains or only trolls remain."""
client = TestClient(app)
client.post("/api/reset")
p1 = client.post("/api/players", json={"name": "SoloHero", "color": "#3b82f6", "strength": 4, "character_type": "player"}).json()
t1 = client.post("/api/players", json={"name": "Troll1", "color": "#16a34a", "strength": 3, "character_type": "troll"}).json()
t2 = client.post("/api/players", json={"name": "Troll2", "color": "#15803d", "strength": 3, "character_type": "troll"}).json()
client.post("/api/game/start")
# 1 player and 2 trolls: total 3 entities, player is not in a party. Game must not be concluded yet!
conc = client.get("/api/game/conclusion").json()
assert conc["concluded"] is False
# Troll defeats player: player dies
async def kill_player():
hero = await game_engine.get_player(p1["id"])
hero.health = 0
game_engine._check_and_apply_death(hero)
asyncio.run(kill_player())
# Now only trolls remain (all players defeated) -> Game concludes!
conc_after_death = client.get("/api/game/conclusion").json()
assert conc_after_death["concluded"] is True
assert conc_after_death["winning_leader_name"] in ["Troll1", "Troll2"]
def test_game_concludes_when_single_entity_remains():
"""When only 1 entity (player or troll) remains, game concludes."""
client = TestClient(app)
client.post("/api/reset")
p1 = client.post("/api/players", json={"name": "LoneSurvivor", "color": "#3b82f6", "strength": 4, "character_type": "player"}).json()
t1 = client.post("/api/players", json={"name": "FallenTroll", "color": "#16a34a", "strength": 3, "character_type": "troll"}).json()
client.post("/api/game/start")
# Kill troll
async def kill_troll():
tr = await game_engine.get_player(t1["id"])
tr.health = 0
game_engine._check_and_apply_death(tr)
asyncio.run(kill_troll())
# Exactly 1 entity remains
conc = client.get("/api/game/conclusion").json()
assert conc["concluded"] is True
assert conc["winning_leader_id"] == p1["id"]
assert conc["winning_leader_name"] == "LoneSurvivor"

View File

@ -0,0 +1 @@
requests>=2.31.0

View File

@ -12,15 +12,23 @@ import { WizardPromptModal } from './components/WizardPromptModal';
import { ScoreboardModal } from './components/ScoreboardModal'; import { ScoreboardModal } from './components/ScoreboardModal';
import type { WizardRewardChoice } from './types'; import type { WizardRewardChoice } from './types';
const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [ const BOT_PRESETS: {
{ name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight' }, name: string;
{ name: 'CrimsonWarrior', color: '#f43f5e', strength: 2, piece_type: 'warrior' }, color: string;
{ name: 'AmethystKnight', color: '#a855f7', strength: 3, piece_type: 'knight' }, strength: number;
{ name: 'EmeraldWarrior', color: '#10b981', strength: 1, piece_type: 'warrior' }, piece_type: 'knight' | 'warrior' | 'troll';
{ name: 'SolarKnight', color: '#eab308', strength: 4, piece_type: 'knight' }, character_type?: 'player' | 'troll';
{ name: 'RosebladeWarrior', color: '#ec4899', strength: 1, piece_type: 'warrior' }, }[] = [
{ name: 'FrostguardKnight', color: '#06b6d4', strength: 2, piece_type: 'knight' }, { name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight', character_type: 'player' },
{ name: 'TwilightWarrior', color: '#6366f1', strength: 3, piece_type: 'warrior' }, { name: 'CrimsonWarrior', color: '#f43f5e', strength: 2, piece_type: 'warrior', character_type: 'player' },
{ name: 'AmethystKnight', color: '#a855f7', strength: 3, piece_type: 'knight', character_type: 'player' },
{ name: 'EmeraldWarrior', color: '#10b981', strength: 1, piece_type: 'warrior', character_type: 'player' },
{ name: 'MountainTroll', color: '#16a34a', strength: 3, piece_type: 'troll', character_type: 'troll' },
{ name: 'SolarKnight', color: '#eab308', strength: 4, piece_type: 'knight', character_type: 'player' },
{ name: 'RosebladeWarrior', color: '#ec4899', strength: 1, piece_type: 'warrior', character_type: 'player' },
{ name: 'CaveTroll', color: '#059669', strength: 2, piece_type: 'troll', character_type: 'troll' },
{ name: 'FrostguardKnight', color: '#06b6d4', strength: 2, piece_type: 'knight', character_type: 'player' },
{ name: 'TwilightWarrior', color: '#6366f1', strength: 3, piece_type: 'warrior', character_type: 'player' },
]; ];
export function App() { export function App() {
@ -46,6 +54,7 @@ export function App() {
fightBattle, fightBattle,
movePlayer, movePlayer,
passTurn, passTurn,
sleepPlayer,
challengeWizard, challengeWizard,
stepActiveBotTurn, stepActiveBotTurn,
startGame, startGame,
@ -99,19 +108,28 @@ export function App() {
const handleQuickSpawn = async () => { const handleQuickSpawn = async () => {
const existingNames = new Set(boardState.players.map((p) => p.name)); const existingNames = new Set(boardState.players.map((p) => p.name));
const availablePresets = BOT_PRESETS.filter((p) => !existingNames.has(p.name)); const availablePresets = BOT_PRESETS.filter((p) => !existingNames.has(p.name));
const randomPiece: 'knight' | 'warrior' = Math.random() > 0.5 ? 'knight' : 'warrior'; const roll = Math.random();
const randomPiece: 'knight' | 'warrior' | 'troll' = roll < 0.25 ? 'troll' : roll < 0.62 ? 'knight' : 'warrior';
const preset = const preset =
availablePresets.length > 0 availablePresets.length > 0
? availablePresets[Math.floor(Math.random() * availablePresets.length)] ? availablePresets[Math.floor(Math.random() * availablePresets.length)]
: { : {
name: `${randomPiece === 'knight' ? 'Knight' : 'Warrior'}_${Math.floor(Math.random() * 1000)}`, name: `${randomPiece === 'knight' ? 'Knight' : randomPiece === 'warrior' ? 'Warrior' : 'Troll'}_${Math.floor(Math.random() * 1000)}`,
color: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`, color: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`,
strength: Math.floor(Math.random() * 3) + 1, strength: Math.floor(Math.random() * 3) + 1,
piece_type: randomPiece, piece_type: randomPiece,
character_type: (randomPiece === 'troll' ? 'troll' : 'player') as 'player' | 'troll',
}; };
try { try {
const player = await registerPlayer(preset.name, preset.color, preset.strength, preset.piece_type); const player = await registerPlayer(
preset.name,
preset.color,
preset.strength,
preset.piece_type,
10,
preset.character_type
);
showNotification(`Spawned ${player.name} (${preset.piece_type}) at (${player.x}, ${player.y})`); showNotification(`Spawned ${player.name} (${preset.piece_type}) at (${player.x}, ${player.y})`);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {
@ -208,6 +226,9 @@ export function App() {
onPass={async (id) => { onPass={async (id) => {
await passTurn(id); await passTurn(id);
}} }}
onSleep={async (id) => {
await sleepPlayer(id);
}}
onChallengeWizard={handleOpenWizardPrompt} onChallengeWizard={handleOpenWizardPrompt}
onStepBot={stepActiveBotTurn} onStepBot={stepActiveBotTurn}
isAutoPlaying={isAutoPlaying} isAutoPlaying={isAutoPlaying}
@ -239,8 +260,8 @@ export function App() {
<RegisterModal <RegisterModal
isOpen={isRegisterOpen} isOpen={isRegisterOpen}
onClose={() => setIsRegisterOpen(false)} onClose={() => setIsRegisterOpen(false)}
onRegister={async (name, color, pieceType, health) => { onRegister={async (name, color, pieceType, health, characterType) => {
const player = await registerPlayer(name, color, 1, pieceType, health); const player = await registerPlayer(name, color, 1, pieceType, health, characterType);
showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) with ${player.health} HP at (${player.x}, ${player.y})!`); showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) with ${player.health} HP at (${player.x}, ${player.y})!`);
}} }}
/> />

View File

@ -8,6 +8,7 @@ interface MovementControlsProps {
availableMoves: AvailableMovesResponse | null; availableMoves: AvailableMovesResponse | null;
onMove: (playerId: string, direction: string) => Promise<unknown>; onMove: (playerId: string, direction: string) => Promise<unknown>;
onPass: (playerId: string) => Promise<unknown>; onPass: (playerId: string) => Promise<unknown>;
onSleep?: (playerId: string) => Promise<unknown>;
onChallengeWizard?: (playerId: string) => Promise<unknown>; onChallengeWizard?: (playerId: string) => Promise<unknown>;
onStepBot: () => void; onStepBot: () => void;
isAutoPlaying: boolean; isAutoPlaying: boolean;
@ -20,6 +21,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
availableMoves, availableMoves,
onMove, onMove,
onPass, onPass,
onSleep,
onChallengeWizard, onChallengeWizard,
onStepBot, onStepBot,
isAutoPlaying, isAutoPlaying,
@ -34,6 +36,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
: null; : null;
const controlledPlayer = liveSelectedPlayer || activePlayer; const controlledPlayer = liveSelectedPlayer || activePlayer;
const isDead = Boolean(controlledPlayer && isPlayerDead(controlledPlayer)); const isDead = Boolean(controlledPlayer && isPlayerDead(controlledPlayer));
const isTroll = controlledPlayer?.character_type === 'troll';
const isMyTurn = isStarted && !isDead && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId); const isMyTurn = isStarted && !isDead && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
const isAdjacentToWizard = Boolean( const isAdjacentToWizard = Boolean(
@ -45,7 +48,14 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
Math.abs(controlledPlayer.y - boardState.wizard.y) Math.abs(controlledPlayer.y - boardState.wizard.y)
) <= 1 ) <= 1
); );
const canInitiateWizardChallenge = !controlledPlayer?.party_id || controlledPlayer?.is_party_leader; const canInitiateWizardChallenge = !isTroll && (!controlledPlayer?.party_id || controlledPlayer?.is_party_leader);
const handleSleepClick = useCallback(() => {
if (!isStarted || !controlledPlayer || !isMyTurn || !isTroll) return;
onSleep?.(controlledPlayer.id).catch((err: unknown) => {
if (err instanceof Error) alert(err.message);
});
}, [isStarted, controlledPlayer, isMyTurn, isTroll, onSleep]);
const handleChallengeWizardClick = useCallback(() => { const handleChallengeWizardClick = useCallback(() => {
if (!controlledPlayer) return; if (!controlledPlayer) return;
@ -149,12 +159,19 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
e.preventDefault(); e.preventDefault();
handlePassClick(); handlePassClick();
break; break;
case 'r':
case 'R':
if (isTroll) {
e.preventDefault();
handleSleepClick();
}
break;
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isStarted, isMyTurn, controlledPlayer, handleDirectionClick, handlePassClick]); }, [isStarted, isMyTurn, controlledPlayer, isTroll, handleDirectionClick, handlePassClick, handleSleepClick]);
if (boardState.players.length === 0) { if (boardState.players.length === 0) {
return null; return null;
@ -279,8 +296,31 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
</div> </div>
</div> </div>
{/* Challenge Wizard Button if adjacent */} {/* Troll Sleep Action */}
{isAdjacentToWizard && onChallengeWizard && ( {isTroll && onSleep && (
<button
onClick={handleSleepClick}
disabled={!isStarted || !isMyTurn}
className={`w-full py-2.5 px-3 rounded-xl font-bold font-mono text-xs flex items-center justify-center gap-2 border transition-all ${
!isStarted || !isMyTurn
? 'bg-emerald-950/40 text-emerald-400/50 border-emerald-900/50 cursor-not-allowed'
: 'bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-400 shadow-xl shadow-emerald-950/60 animate-pulse active:scale-95 cursor-pointer'
}`}
title={
!isStarted
? 'Game has not started yet'
: !isMyTurn
? `Waiting for ${controlledPlayer?.name}'s turn`
: 'Rest and sleep for 1 turn to regenerate +0.1 HP (Hotkey: R)'
}
>
<span className="text-base">💤</span>
<span>Sleep (+0.1 HP)</span>
</button>
)}
{/* Challenge Wizard Button if adjacent (Players/Leaders only) */}
{isAdjacentToWizard && onChallengeWizard && !isTroll && (
<button <button
onClick={handleChallengeWizardClick} onClick={handleChallengeWizardClick}
disabled={!isStarted || !isMyTurn || !canInitiateWizardChallenge} disabled={!isStarted || !isMyTurn || !canInitiateWizardChallenge}

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { BoardState, Player } from '../types'; import type { BoardState, Player } from '../types';
import { isPlayerDead } from '../utils/pixelAvatars';
interface PartyModalProps { interface PartyModalProps {
isOpen: boolean; isOpen: boolean;
@ -22,7 +23,9 @@ export const PartyModal: React.FC<PartyModalProps> = ({
if (!isOpen) return null; if (!isOpen) return null;
const { players } = boardState; const eligiblePlayers = boardState.players.filter(
(p) => !isPlayerDead(p) && p.character_type !== 'troll'
);
// Check if two players are adjacent (Chebyshev distance <= 1) // Check if two players are adjacent (Chebyshev distance <= 1)
const areAdjacent = (p1: Player, p2: Player) => { const areAdjacent = (p1: Player, p2: Player) => {
@ -31,10 +34,10 @@ export const PartyModal: React.FC<PartyModalProps> = ({
// Find any adjacent pairs in the game for quick selection // Find any adjacent pairs in the game for quick selection
const findAdjacentPairs = () => { const findAdjacentPairs = () => {
for (let i = 0; i < players.length; i++) { for (let i = 0; i < eligiblePlayers.length; i++) {
for (let j = i + 1; j < players.length; j++) { for (let j = i + 1; j < eligiblePlayers.length; j++) {
if (areAdjacent(players[i], players[j])) { if (areAdjacent(eligiblePlayers[i], eligiblePlayers[j])) {
return [players[i], players[j]]; return [eligiblePlayers[i], eligiblePlayers[j]];
} }
} }
} }
@ -43,7 +46,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
const pickStrongestLeader = (ids: string[]) => { const pickStrongestLeader = (ids: string[]) => {
if (ids.length === 0) return ''; if (ids.length === 0) return '';
const selectedBots = players.filter((p) => ids.includes(p.id)); const selectedBots = eligiblePlayers.filter((p) => ids.includes(p.id));
selectedBots.sort((a, b) => (b.strength || 1) - (a.strength || 1)); selectedBots.sort((a, b) => (b.strength || 1) - (a.strength || 1));
return selectedBots[0]?.id || ids[0]; return selectedBots[0]?.id || ids[0];
}; };
@ -55,7 +58,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
setSelectedIds(ids); setSelectedIds(ids);
const chosenLeader = pickStrongestLeader(ids); const chosenLeader = pickStrongestLeader(ids);
setLeaderId(chosenLeader); setLeaderId(chosenLeader);
const leadBot = players.find((p) => p.id === chosenLeader) || pair[0]; const leadBot = eligiblePlayers.find((p) => p.id === chosenLeader) || pair[0];
setPartyName(`Squad ${leadBot.name}`); setPartyName(`Squad ${leadBot.name}`);
setError(null); setError(null);
} else { } else {
@ -148,7 +151,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
Select Members (At least 2): Select Members (At least 2):
</label> </label>
<div className="max-h-48 overflow-y-auto space-y-1.5 border border-slate-800 rounded-xl p-2 bg-slate-950/50"> <div className="max-h-48 overflow-y-auto space-y-1.5 border border-slate-800 rounded-xl p-2 bg-slate-950/50">
{players.map((p) => { {eligiblePlayers.map((p) => {
const isChecked = selectedIds.includes(p.id); const isChecked = selectedIds.includes(p.id);
return ( return (
<div <div
@ -196,7 +199,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
</div> </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 = eligiblePlayers.find((p) => p.id === id);
if (!bot) return null; if (!bot) return null;
const isLeader = leaderId === id; const isLeader = leaderId === id;
return ( return (

View File

@ -45,9 +45,11 @@ export const PlayerList: React.FC<PlayerListProps> = ({
) <= 1 ) <= 1
); );
const candidateIsTurn = Boolean(candidate && isStarted && candidate.id === currentTurnId); const candidateIsTurn = Boolean(candidate && isStarted && candidate.id === currentTurnId);
const candidateIsTroll = candidate?.character_type === 'troll';
const candidateCanChallenge = Boolean( const candidateCanChallenge = Boolean(
candidateAdjacent && candidateAdjacent &&
candidateIsTurn && candidateIsTurn &&
!candidateIsTroll &&
(!candidate?.party_id || candidate?.is_party_leader) (!candidate?.party_id || candidate?.is_party_leader)
); );
@ -198,6 +200,7 @@ export const PlayerList: React.FC<PlayerListProps> = ({
) : ( ) : (
players.map((player) => { players.map((player) => {
const isDead = isPlayerDead(player); const isDead = isPlayerDead(player);
const isTroll = player.character_type === 'troll';
const isSelected = selectedPlayer?.id === player.id; const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = !isDead && currentTurnId === player.id; const isCurrentTurn = !isDead && currentTurnId === player.id;
const isLeader = !isDead && player.is_party_leader; const isLeader = !isDead && player.is_party_leader;
@ -259,6 +262,11 @@ export const PlayerList: React.FC<PlayerListProps> = ({
<span className={`text-xs font-semibold truncate ${isDead ? 'text-slate-400 line-through' : 'text-slate-200'}`}> <span className={`text-xs font-semibold truncate ${isDead ? 'text-slate-400 line-through' : 'text-slate-200'}`}>
{player.name} {player.name}
</span> </span>
{isTroll && (
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-emerald-950 text-emerald-300 border border-emerald-700 font-bold">
👹 TROLL
</span>
)}
{isDead ? ( {isDead ? (
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-rose-950/80 text-rose-400 border border-rose-800 font-bold"> <span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-rose-950/80 text-rose-400 border border-rose-800 font-bold">
💀 DEAD 💀 DEAD
@ -278,9 +286,9 @@ export const PlayerList: React.FC<PlayerListProps> = ({
)} )}
</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> Str: <span className="text-amber-400">{player.strength || 1}</span> HP:{' '} pos: <span className="text-emerald-400">({player.x}, {player.y})</span> Str: <span className="text-amber-400">{Number(player.strength.toFixed(1))}</span> HP:{' '}
<span className={isDead ? 'text-rose-500 font-bold' : 'text-rose-400 font-bold'}> <span className={isDead ? 'text-rose-500 font-bold' : 'text-rose-400 font-bold'}>
{isDead ? '💀 0' : `❤️${player.health ?? 10}`} {isDead ? '💀 0' : `❤️${Number((player.health ?? 10).toFixed(1))}`}
</span> </span>
{isDead && ( {isDead && (
<span className="ml-1 text-slate-500 font-sans italic"> <span className="ml-1 text-slate-500 font-sans italic">
@ -292,12 +300,18 @@ export const PlayerList: React.FC<PlayerListProps> = ({
{isLeader ? 'Leader' : 'Squad'} {isLeader ? 'Leader' : 'Squad'}
</span> </span>
)} )}
{isTroll && !isDead && (
<span className="ml-1 text-emerald-400 truncate font-sans">
Solo Hunter
</span>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 opacity-70 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1.5 opacity-70 group-hover:opacity-100 transition-opacity">
{!isDead && {!isDead &&
!isTroll &&
boardState.wizard && boardState.wizard &&
Math.max( Math.max(
Math.abs(player.x - boardState.wizard.x), Math.abs(player.x - boardState.wizard.x),

View File

@ -27,12 +27,22 @@ const RANDOM_NAMES = [
'ViperWarrior', 'ViperWarrior',
'SolarTemplar', 'SolarTemplar',
'EmeraldWarden', 'EmeraldWarden',
'MountainTroll',
'StoneBrute',
'CaveStalker',
'ForestTroll',
]; ];
interface RegisterModalProps { interface RegisterModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onRegister: (name: string, color: string, pieceType?: PieceType, health?: number) => Promise<unknown>; onRegister: (
name: string,
color: string,
pieceType?: PieceType,
health?: number,
characterType?: 'player' | 'troll'
) => Promise<unknown>;
} }
export const RegisterModal: React.FC<RegisterModalProps> = ({ export const RegisterModal: React.FC<RegisterModalProps> = ({
@ -59,7 +69,8 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
'_' + '_' +
Math.floor(Math.random() * 900 + 100); Math.floor(Math.random() * 900 + 100);
const randomFaction = PRESET_FACTIONS[Math.floor(Math.random() * PRESET_FACTIONS.length)]; const randomFaction = PRESET_FACTIONS[Math.floor(Math.random() * PRESET_FACTIONS.length)];
const randomType: PieceType = Math.random() > 0.5 ? 'knight' : 'warrior'; const roll = Math.random();
const randomType: PieceType = roll < 0.33 ? 'troll' : roll < 0.66 ? 'knight' : 'warrior';
setName(randomName); setName(randomName);
setColor(randomFaction.color); setColor(randomFaction.color);
setPieceType(randomType); setPieceType(randomType);
@ -75,7 +86,8 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
try { try {
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
await onRegister(name.trim(), color, pieceType, health); const characterType = pieceType === 'troll' ? 'troll' : 'player';
await onRegister(name.trim(), color, pieceType, health, characterType);
onClose(); onClose();
setName(''); setName('');
setHealth(10); setHealth(10);
@ -100,7 +112,7 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
Register Board Game Piece Register Board Game Piece
</h2> </h2>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
Choose your Knight or Warrior miniature & faction colors Choose your Knight, Warrior, or Troll miniature & faction colors
</p> </p>
</div> </div>
<button <button
@ -140,7 +152,7 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
</div> </div>
<div className="text-[11px] text-slate-400 flex items-center justify-center gap-1.5 mt-0.5"> <div className="text-[11px] text-slate-400 flex items-center justify-center gap-1.5 mt-0.5">
<span className="text-amber-400 font-mono"> <span className="text-amber-400 font-mono">
{pieceType === 'knight' ? '⚔️ Knight Piece' : '🪓 Warrior Piece'} {pieceType === 'knight' ? '⚔️ Knight Piece' : pieceType === 'warrior' ? '🪓 Warrior Piece' : '👹 Troll (Hunter)'}
</span> </span>
{currentFaction && ( {currentFaction && (
<> <>
@ -153,41 +165,57 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
</div> </div>
</div> </div>
{/* Piece Class Selector (Knight vs Warrior) */} {/* Piece Class Selector (Knight vs Warrior vs Troll) */}
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5"> <label className="block text-xs font-semibold text-slate-300 mb-1.5">
Board Game Piece Class Board Game Piece Class
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-3 gap-2">
<button <button
type="button" type="button"
onClick={() => setPieceType('knight')} onClick={() => setPieceType('knight')}
className={`flex items-center justify-center gap-2.5 p-2 rounded-xl border text-xs font-mono transition-all ${ className={`flex items-center justify-center gap-2 p-2 rounded-xl border text-xs font-mono transition-all ${
pieceType === 'knight' pieceType === 'knight'
? 'bg-sky-950/60 border-sky-500 text-sky-200 shadow-md shadow-sky-500/20 ring-1 ring-sky-400' ? 'bg-sky-950/60 border-sky-500 text-sky-200 shadow-md shadow-sky-500/20 ring-1 ring-sky-400'
: 'bg-slate-950/60 border-slate-800 text-slate-400 hover:border-slate-700 hover:text-slate-200' : 'bg-slate-950/60 border-slate-800 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`} }`}
> >
<PixelAvatar pieceType="knight" color={color} size={28} /> <PixelAvatar pieceType="knight" color={color} size={24} />
<div className="text-left"> <div className="text-left">
<div className="font-bold">Knight</div> <div className="font-bold">Knight</div>
<div className="text-[10px] text-slate-400">Sword & Shield</div> <div className="text-[9px] text-slate-400">Sword/Shield</div>
</div> </div>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setPieceType('warrior')} onClick={() => setPieceType('warrior')}
className={`flex items-center justify-center gap-2.5 p-2 rounded-xl border text-xs font-mono transition-all ${ className={`flex items-center justify-center gap-2 p-2 rounded-xl border text-xs font-mono transition-all ${
pieceType === 'warrior' pieceType === 'warrior'
? 'bg-amber-950/60 border-amber-500 text-amber-200 shadow-md shadow-amber-500/20 ring-1 ring-amber-400' ? 'bg-amber-950/60 border-amber-500 text-amber-200 shadow-md shadow-amber-500/20 ring-1 ring-amber-400'
: 'bg-slate-950/60 border-slate-800 text-slate-400 hover:border-slate-700 hover:text-slate-200' : 'bg-slate-950/60 border-slate-800 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`} }`}
> >
<PixelAvatar pieceType="warrior" color={color} size={28} /> <PixelAvatar pieceType="warrior" color={color} size={24} />
<div className="text-left"> <div className="text-left">
<div className="font-bold">Warrior</div> <div className="font-bold">Warrior</div>
<div className="text-[10px] text-slate-400">Battle Axe</div> <div className="text-[9px] text-slate-400">Battle Axe</div>
</div>
</button>
<button
type="button"
onClick={() => setPieceType('troll')}
className={`flex items-center justify-center gap-2 p-2 rounded-xl border text-xs font-mono transition-all ${
pieceType === 'troll'
? 'bg-emerald-950/60 border-emerald-500 text-emerald-200 shadow-md shadow-emerald-500/20 ring-1 ring-emerald-400'
: 'bg-slate-950/60 border-slate-800 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`}
>
<PixelAvatar pieceType="troll" color={color} size={24} />
<div className="text-left">
<div className="font-bold">Troll</div>
<div className="text-[9px] text-slate-400">Brute Club</div>
</div> </div>
</button> </button>
</div> </div>

View File

@ -184,10 +184,10 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
<div style={styles.trophyBurst}> <div style={styles.trophyBurst}>
<span style={styles.trophyIcon}>🏆</span> <span style={styles.trophyIcon}>🏆</span>
</div> </div>
<h1 style={styles.title}>VICTORY! ALL BOTS UNITED</h1> <h1 style={styles.title}>VICTORY! ARENA CONCLUDED</h1>
<p style={styles.subtitle}> <p style={styles.subtitle}>
The game has concluded! Every bot has united into the supreme party{' '} The game has concluded! Champion: <strong style={styles.goldText}>{conclusion.winning_leader_name}</strong> (
<strong style={styles.goldText}>"{conclusion.winning_party_name || 'Dominant Squad'}"</strong>! <span style={{ color: '#fbbf24' }}>"{conclusion.winning_party_name || 'Lone Survivor'}"</span>)!
</p> </p>
<div style={styles.fanfareControls}> <div style={styles.fanfareControls}>
@ -343,6 +343,7 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
} }
const isLeader = !isDead && player.id === conclusion.winning_leader_id; const isLeader = !isDead && player.id === conclusion.winning_leader_id;
const isTroll = player.character_type === 'troll';
return ( return (
<tr key={player.id} style={isDead ? { ...rowStyle, opacity: 0.8 } : rowStyle}> <tr key={player.id} style={isDead ? { ...rowStyle, opacity: 0.8 } : rowStyle}>
@ -357,18 +358,21 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
/> />
<span style={styles.botNameText}>{player.name}</span> <span style={styles.botNameText}>{player.name}</span>
{isDead && <span style={{ ...styles.inlineLeaderTag, backgroundColor: '#7f1d1d', color: '#fca5a5' }}>💀 Fallen</span>} {isDead && <span style={{ ...styles.inlineLeaderTag, backgroundColor: '#7f1d1d', color: '#fca5a5' }}>💀 Fallen</span>}
{isTroll && !isDead && <span style={{ ...styles.inlineLeaderTag, backgroundColor: '#065f46', color: '#6ee7b7' }}>👹 Troll</span>}
{isLeader && <span style={styles.inlineLeaderTag}>👑 Leader</span>} {isLeader && <span style={styles.inlineLeaderTag}>👑 Leader</span>}
</td> </td>
<td style={styles.tdRole}> <td style={styles.tdRole}>
{isDead ? ( {isDead ? (
<span style={{ color: '#ef4444', fontWeight: 'bold' }}>Fallen (Deceased)</span> <span style={{ color: '#ef4444', fontWeight: 'bold' }}>Fallen (Deceased)</span>
) : isTroll ? (
<span style={{ color: '#10b981', fontWeight: 'bold' }}>Lone Troll Hunter</span>
) : isLeader ? ( ) : isLeader ? (
'Supreme Leader' 'Supreme Leader'
) : ( ) : (
'Squad Member' 'Squad Member'
)} )}
</td> </td>
<td style={styles.tdStrength}> {player.strength}</td> <td style={styles.tdStrength}> {Number(player.strength.toFixed(1))}</td>
<td style={styles.tdVisited}> <td style={styles.tdVisited}>
🗺 {player.visited_locations?.length || 1} 🗺 {player.visited_locations?.length || 1}
</td> </td>

View File

@ -9,6 +9,7 @@ import type {
Party, Party,
PartyDefeatResult, PartyDefeatResult,
Player, Player,
SleepResponse,
TurnInfo, TurnInfo,
WizardChallengeResult, WizardChallengeResult,
WizardRewardChoice, WizardRewardChoice,
@ -257,6 +258,14 @@ export function useGameSocket() {
...prev, ...prev,
turn: data.turn ?? prev.turn, turn: data.turn ?? prev.turn,
})); }));
} else if (data.event === 'player_slept') {
setBoardState((prev) => ({
...prev,
players: prev.players.map((p) => (p.id === data.player?.id ? data.player : p)),
turn: data.turn ?? prev.turn,
}));
setSelectedPlayer((curr) => (curr?.id === data.player?.id ? data.player : curr));
setLastEventMessage(`💤 ${data.player?.name || 'Troll'} slept to recover health (+0.1 HP -> ${data.sleep_result?.new_health ?? data.player?.health} HP)!`);
} else if (data.event === 'player_left') { } else if (data.event === 'player_left') {
setBoardState((prev) => { setBoardState((prev) => {
const nextPlayers = prev.players.filter((p) => p.id !== data.player_id); const nextPlayers = prev.players.filter((p) => p.id !== data.player_id);
@ -323,12 +332,13 @@ export function useGameSocket() {
color: string, color: string,
strength: number = 1, strength: number = 1,
piece_type?: string, piece_type?: string,
health: number = 10 health: number = 10,
character_type?: 'player' | 'troll'
): Promise<Player> => { ): 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, strength, piece_type, health }), body: JSON.stringify({ name, color, strength, piece_type, health, character_type }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
@ -338,6 +348,21 @@ export function useGameSocket() {
return player; return player;
}; };
const sleepPlayer = async (playerId: string): Promise<SleepResponse> => {
const res = await fetch(`/api/players/${playerId}/sleep`, {
method: 'POST',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to sleep');
}
const data: SleepResponse = await res.json();
if (data.player) {
setSelectedPlayer((curr) => (curr?.id === data.player.id ? data.player : curr));
}
return data;
};
const removePlayer = async (playerId: string): Promise<void> => { const removePlayer = async (playerId: string): Promise<void> => {
const res = await fetch(`/api/players/${playerId}`, { const res = await fetch(`/api/players/${playerId}`, {
method: 'DELETE', method: 'DELETE',
@ -501,6 +526,8 @@ export function useGameSocket() {
? `🧙 ${wcr.challenger_name} defeated ${wizName}! (+${wcr.score_change} score)` ? `🧙 ${wcr.challenger_name} defeated ${wizName}! (+${wcr.score_change} score)`
: `🧙 ${wcr.challenger_name} lost to ${wizName}! (${wcr.health_change < 0 ? `${wcr.health_change} HP` : `${wcr.score_change} pts`})` : `🧙 ${wcr.challenger_name} lost to ${wizName}! (${wcr.health_change < 0 ? `${wcr.health_change} HP` : `${wcr.score_change} pts`})`
); );
} else if (data.action_taken === 'slept' && data.sleep_result) {
setLastEventMessage(`💤 ${data.player_name} took a restful sleep (+${data.sleep_result.health_gained} HP -> ${data.sleep_result.new_health} HP)!`);
} else if (data.move_result?.battle_result) { } else if (data.move_result?.battle_result) {
setActiveBattle(data.move_result.battle_result); setActiveBattle(data.move_result.battle_result);
} }
@ -555,6 +582,7 @@ export function useGameSocket() {
fightBattle, fightBattle,
movePlayer, movePlayer,
passTurn, passTurn,
sleepPlayer,
challengeWizard, challengeWizard,
stepActiveBotTurn, stepActiveBotTurn,
startGame, startGame,

View File

@ -24,7 +24,8 @@ export interface Player {
health?: number; health?: number;
max_health?: number; max_health?: number;
is_alive?: boolean; is_alive?: boolean;
piece_type?: 'knight' | 'warrior'; piece_type?: 'knight' | 'warrior' | 'troll' | 'wizard' | 'gravestone';
character_type?: 'player' | 'troll';
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 }[];
@ -255,6 +256,14 @@ export interface MoveResponse {
turn: TurnInfo; turn: TurnInfo;
} }
export interface SleepResponse {
success: boolean;
player: Player;
health_gained: number;
new_health: number;
turn: TurnInfo;
}
export interface AiStepResponse { export interface AiStepResponse {
action_taken: string; action_taken: string;
player_id: string; player_id: string;
@ -265,6 +274,7 @@ export interface AiStepResponse {
formed_party?: Party | null; formed_party?: Party | null;
battle_result?: BattleResult | null; battle_result?: BattleResult | null;
wizard_challenge_result?: WizardChallengeResult | null; wizard_challenge_result?: WizardChallengeResult | null;
sleep_result?: SleepResponse | null;
game_concluded?: GameConclusion | null; game_concluded?: GameConclusion | null;
turn: TurnInfo; turn: TurnInfo;
} }

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import type { Player } from '../types'; import type { Player } from '../types';
export type PieceType = 'knight' | 'warrior' | 'wizard' | 'gravestone'; export type PieceType = 'knight' | 'warrior' | 'wizard' | 'gravestone' | 'troll';
// Hex color parser and manipulator // Hex color parser and manipulator
function parseHex(hex: string): [number, number, number] { function parseHex(hex: string): [number, number, number] {
@ -169,6 +169,25 @@ const GRAVESTONE_SPRITE: string[] = [
'..____________..', // Row 15: Drop shadow '..____________..', // Row 15: Drop shadow
]; ];
const TROLL_SPRITE: string[] = [
'...........Mm...', // Row 0: Spiked club head spike tip
'..........HMmH..', // Row 1: Spiked wooden club head
'...KK...KK..HH..', // Row 2: Heavy sloping brute shoulders & ears
'..KCCCCCCCK.Hh..', // Row 3: Brute horned brow
'..KLDCRRCLLK.Hh.', // Row 4: Glowing red eyes & heavy brow + club haft
'..KDCWWCCDDK.Hh.', // Row 5: Snarling mouth & white upward tusks + club haft
'...KCCCCCCK..HH.', // Row 6: Thick chin / neck + club grip
'..KLDCCCCDLK....', // Row 7: Massive hunched torso in faction color
'..KLCCCCCCLK....', // Row 8: Brute torso & heavy fists
'...KSDHHDSSK....', // Row 9: Spiked iron belt & hide loincloth
'...KLL..LLK.....', // Row 10: Thick brute legs
'..KDD....DDK....', // Row 11: Heavy clawed combat feet
'...KKKKKKKKK....', // Row 12: Pedestal top bevel
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
'.KbbbbbbbbbbbbK.', // Row 14: Pedestal stone bevel rim
'..____________..', // Row 15: Miniature drop shadow
];
// Death status helper // Death status helper
export function isPlayerDead(player: { is_alive?: boolean; health?: number }): boolean { export function isPlayerDead(player: { is_alive?: boolean; health?: number }): boolean {
if (player.is_alive === false) return true; if (player.is_alive === false) return true;
@ -212,6 +231,7 @@ export function getPlayerPieceType(player: {
name: string; name: string;
id?: string; id?: string;
piece_type?: string; piece_type?: string;
character_type?: string;
is_party_leader?: boolean; is_party_leader?: boolean;
party_id?: string | null; party_id?: string | null;
is_alive?: boolean; is_alive?: boolean;
@ -221,6 +241,10 @@ export function getPlayerPieceType(player: {
if (isPlayerDead(player)) { if (isPlayerDead(player)) {
return 'gravestone'; return 'gravestone';
} }
// Trolls are always troll miniatures
if (player.character_type === 'troll' || player.piece_type === 'troll') {
return 'troll';
}
// Party leaders are always Knights commanding the squad // Party leaders are always Knights commanding the squad
if (player.is_party_leader) { if (player.is_party_leader) {
return 'knight'; return 'knight';
@ -235,6 +259,9 @@ export function getPlayerPieceType(player: {
} }
// Check name indicators // Check name indicators
const lower = player.name.toLowerCase(); const lower = player.name.toLowerCase();
if (lower.includes('troll')) {
return 'troll';
}
if ( if (
lower.includes('knight') || lower.includes('knight') ||
lower.includes('tank') || lower.includes('tank') ||
@ -293,6 +320,8 @@ export function getSpriteCanvas(
spriteMatrix = WIZARD_SPRITE; spriteMatrix = WIZARD_SPRITE;
} else if (pieceType === 'gravestone') { } else if (pieceType === 'gravestone') {
spriteMatrix = GRAVESTONE_SPRITE; spriteMatrix = GRAVESTONE_SPRITE;
} else if (pieceType === 'troll') {
spriteMatrix = TROLL_SPRITE;
} else { } else {
spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE; spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE;
} }
@ -414,10 +443,12 @@ export function drawPlayerPiece(
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const roleIcon = dead ? '🪦' : isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓'; const isTroll = player.character_type === 'troll' || pieceType === 'troll';
const roleIcon = dead ? '🪦' : isLeader ? '👑' : isTroll ? '👹' : pieceType === 'knight' ? '⚔️' : '🪓';
const hpStr = player.health !== undefined ? ` ❤️${Number(player.health.toFixed(1))}` : '';
const text = dead const text = dead
? `🪦 ${player.name} [DEAD]` ? `🪦 ${player.name} [DEAD]`
: `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${player.health !== undefined ? ` ❤️${player.health}` : ''}]`; : `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}${hpStr}]`;
const textMetrics = ctx.measureText(text); const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12; const bgWidth = textMetrics.width + 12;
const bgHeight = 16; const bgHeight = 16;

50
trollagent/README.md Normal file
View File

@ -0,0 +1,50 @@
# trollagent - Autonomous Heuristic Troll Bot
`trollagent` is an autonomous heuristic troll agent designed for **botWebWars**. Unlike players who form alliances and battle for squad dominance, a troll is an isolated brute driven solely to hunt down and attack players.
---
## 👹 Troll Rules of Engagement
1. **No Alliances**: Trolls never band together into parties. They remain solitary hunters throughout the match.
2. **Troll Truce**: Trolls do not battle each other. When meeting another troll, they ignore each other.
3. **Player Hunting**: Trolls scan the grid with radar, locate players and player squads, and close in for mandatory 3-bout D20 tactical combat.
4. **No Strength Gain**: When a troll wins a battle, it receives **+2 victory points (score)**, but does not gain strength or absorb followers.
5. **Damage & Death**: Defeated trolls suffer 1 to 3 health points of damage. At 0 HP, a troll dies and is marked on the board with a gravestone. Dead trolls can win the final rankings based on their achieved score.
6. **No Wizard Duels**: Trolls cannot engage Gary the Wizard.
7. **Sleep & Heal**: Trolls can choose to take turns sleeping (`POST /api/players/{id}/sleep`), skipping their turn action to regenerate **+0.1 health**.
---
## 🚀 Execution & Usage
### Prerequisites
- Python 3.10+
- `requests` library
### Running the Troll Agent
```bash
# Run with default settings (Name: GorgonTroll, Color: #16a34a, Str: 3, HP: 10)
python3 trollagent/troll_agent.py
# Custom name, color, strength, health, and sleep threshold:
python3 trollagent/troll_agent.py \
--name IronTroll \
--color "#059669" \
--strength 4 \
--health 12 \
--sleep-threshold 7.0 \
--url http://localhost:8000/api
```
### CLI Options
| Flag | Short | Default | Description |
|---|---|---|---|
| `--url` | `-u` | `http://localhost:8000/api` | botWebWars API base endpoint |
| `--name` | `-n` | `GorgonTroll` | Bot avatar name |
| `--color` | `-c` | `#16a34a` | Hex color code |
| `--strength` | `-s` | `3.0` | Initial troll strength |
| `--health` | `-H` | `10.0` | Initial troll health points |
| `--sleep-threshold` | | `6.0` | HP threshold below which troll rests to heal |
| `--loop-delay` | | `1.0` | Turn polling frequency in seconds |

View File

@ -0,0 +1 @@
requests>=2.31.0

354
trollagent/troll_agent.py Normal file
View File

@ -0,0 +1,354 @@
"""Autonomous Heuristic Troll Agent for botWebWars:
- Registers as character_type='troll' and piece_type='troll'.
- Trolls do not band together or form alliances.
- Trolls do not battle each other.
- Trolls only hunt and battle players and player squads.
- Trolls do not gain strength from victory, but earn +2 victory points (score).
- Trolls take damage (1-3 HP) upon defeat and die if HP reaches 0.
- Trolls cannot duel Gary the Wizard.
- Trolls can take a rest turn to SLEEP (POST /api/players/{id}/sleep), recovering +0.1 HP.
- Configurable via CLI arguments or environment variables.
"""
import os
import sys
import time
import argparse
import requests
from typing import Optional, Dict, Any, List
DEFAULT_SERVER_URL = os.getenv("TROLL_SERVER_URL", os.getenv("BOT_SERVER_URL", "http://localhost:8000/api"))
DEFAULT_TROLL_NAME = os.getenv("TROLL_NAME", "GorgonTroll")
DEFAULT_TROLL_COLOR = os.getenv("TROLL_COLOR", "#16a34a")
DEFAULT_TROLL_STRENGTH = float(os.getenv("TROLL_STRENGTH", "3.0"))
DEFAULT_TROLL_HEALTH = float(os.getenv("TROLL_HEALTH", "10.0"))
DEFAULT_SLEEP_THRESHOLD = float(os.getenv("TROLL_SLEEP_THRESHOLD", "6.0"))
def normalize_url(url: str) -> str:
"""Ensure the API URL ends with /api without trailing slashes."""
cleaned = url.rstrip("/")
if not cleaned.endswith("/api"):
cleaned = f"{cleaned}/api"
return cleaned
class SmartTrollAgent:
def __init__(
self,
name: str = DEFAULT_TROLL_NAME,
color: str = DEFAULT_TROLL_COLOR,
strength: float = DEFAULT_TROLL_STRENGTH,
health: float = DEFAULT_TROLL_HEALTH,
server_url: str = DEFAULT_SERVER_URL,
sleep_threshold: float = DEFAULT_SLEEP_THRESHOLD,
loop_delay: float = 1.0,
):
self.name = name
self.color = color
self.strength = strength
self.health = health
self.server_url = server_url
self.base_url = normalize_url(server_url)
self.sleep_threshold = sleep_threshold
self.loop_delay = loop_delay
self.bot_id: Optional[str] = None
def register(self):
"""Register the troll avatar on the 64x64 grid or reconnect if already present."""
try:
players = requests.get(f"{self.base_url}/players").json()
for p in players:
if p.get("name") == self.name:
self.bot_id = p["id"]
print(
f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} "
f"(ID: {self.bot_id}, Str: {p.get('strength', self.strength)}, "
f"HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})"
)
return
except Exception:
pass
payload = {
"name": self.name,
"color": self.color,
"strength": self.strength,
"health": self.health,
"piece_type": "troll",
"character_type": "troll",
}
res = requests.post(f"{self.base_url}/players", json=payload)
if res.status_code == 400 and "already registered" in res.text:
players = requests.get(f"{self.base_url}/players").json()
for p in players:
if p.get("name") == self.name:
self.bot_id = p["id"]
print(
f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} "
f"(ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})"
)
return
res.raise_for_status()
data = res.json()
self.bot_id = data["id"]
print(
f"👹 [REGISTER] Spawned Troll {self.name} (ID: {self.bot_id}, Str: {self.strength}, "
f"HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})"
)
def refresh_status(self) -> Optional[Dict[str, Any]]:
"""Update troll status (health, score, position, alive status)."""
try:
res = requests.get(f"{self.base_url}/players/{self.bot_id}")
if res.status_code == 200:
return res.json()
except Exception as e:
print(f"Status refresh error: {e}")
return None
def sleep_and_heal(self) -> bool:
"""Execute sleep turn to recover +0.1 HP."""
try:
res = requests.post(f"{self.base_url}/players/{self.bot_id}/sleep")
if res.status_code == 200:
data = res.json()
print(
f"💤 [SLEEP] {self.name} curled up and slept for 1 turn. "
f"(+{data.get('health_gained', 0.1)} HP -> ❤️ {data.get('new_health')} HP)"
)
return True
else:
print(f"Sleep failed ({res.status_code}): {res.text}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
return False
except Exception as e:
print(f"Error sleeping: {e}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
return False
def pass_turn(self):
"""Pass turn to next queued entity."""
try:
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
print(f"⏩ [PASS] {self.name} passed turn.")
except Exception as e:
print(f"Error passing turn: {e}")
def _get_best_move_towards(self, target_x: int, target_y: int, moves: Dict[str, Any]) -> Optional[str]:
"""Pick the available direction that minimizes Chebyshev distance to (target_x, target_y)."""
valid_moves = {d: chk for d, chk in moves.items() if chk.get("available")}
if not valid_moves:
return None
def dist(chk: Dict[str, Any]) -> int:
return max(abs(chk["target_x"] - target_x), abs(chk["target_y"] - target_y))
# Prioritize moves without penalty, then minimum distance
return min(valid_moves.keys(), key=lambda d: (valid_moves[d].get("strength_penalty", 0.0) > 0, dist(valid_moves[d])))
def _find_adjacent_player(self, my_x: int, my_y: int) -> Optional[Dict[str, Any]]:
"""Find an adjacent living player or party to attack (ignoring other trolls)."""
try:
players: List[Dict[str, Any]] = requests.get(f"{self.base_url}/players").json()
for p in players:
if p.get("id") == self.bot_id:
continue
# Trolls ignore other trolls
if p.get("character_type") == "troll":
continue
# Ignore dead players
if p.get("is_alive") is False or p.get("health", 10) <= 0:
continue
chebyshev = max(abs(p["x"] - my_x), abs(p["y"] - my_y))
if chebyshev <= 1:
return p
except Exception as e:
print(f"Error scanning adjacent players: {e}")
return None
def attack_player(self, defender: Dict[str, Any]):
"""Initiate mandatory 3-bout D20 battle against adjacent player or party."""
target_label = f"Squad '{defender.get('party_id')}'" if defender.get("party_id") else f"Player {defender.get('name')}"
print(f"⚔️ [BATTLE CLASH] Troll {self.name} engages {target_label} in 3-bout D20 combat!")
try:
res = requests.post(
f"{self.base_url}/battles/fight",
json={"challenger_id": self.bot_id, "defender_id": defender["id"]},
)
if res.status_code == 200:
b = res.json()
won = b.get("winner_leader_name") == self.name or b.get("winner_party_name", "").startswith(self.name)
outcome = "VICTORY (+2 Victory Points!)" if won else "DEFEAT (Health Damage sustained)"
print(f"⚔️ [RESULT] {outcome}: {b.get('winner_party_name')} defeated {b.get('defeated_party_name')}")
for bout in b.get("bouts", []):
print(
f" Bout #{bout['bout_number']}: "
f"{bout['party1_name']} D20({bout['party1_roll']})×Str({bout['party1_strength']})={bout['party1_score']:.1f} vs "
f"{bout['party2_name']} D20({bout['party2_roll']})×Str({bout['party2_strength']})={bout['party2_score']:.1f} "
f"-> Winner: {bout['winner']}"
)
else:
print(f"Battle initiation failed ({res.status_code}): {res.text}")
self.pass_turn()
except Exception as e:
print(f"Error executing battle: {e}")
self.pass_turn()
def decide_and_act(self):
"""Evaluate troll radar, health condition, and surroundings to execute best action."""
my_status = self.refresh_status()
if not my_status:
self.pass_turn()
return
my_x = my_status.get("x", 0)
my_y = my_status.get("y", 0)
my_hp = float(my_status.get("health", 10.0))
# Check for immediate adjacent player to attack
adjacent_player = self._find_adjacent_player(my_x, my_y)
if adjacent_player:
self.attack_player(adjacent_player)
return
# Check radar sensor
try:
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
except Exception as e:
print(f"Radar failure: {e}")
radar_res = {}
recommended_action = radar_res.get("recommended_action")
# Sleep condition:
# 1. Radar recommends sleep (e.g. damaged and no immediate contact)
# 2. Or current health is below configured sleep threshold
if (recommended_action == "sleep" or my_hp < self.sleep_threshold) and my_hp < float(my_status.get("max_health", 10.0)):
print(f"🩸 Low health alert (HP: {my_hp:.1f} < threshold: {self.sleep_threshold:.1f}). Taking sleep turn to heal...")
self.sleep_and_heal()
return
# Navigation: Hunt closest player
rec_dir = radar_res.get("recommended_direction")
nearest = radar_res.get("nearest_target")
moves_res = requests.get(f"{self.base_url}/players/{self.bot_id}/available-moves").json()
moves = moves_res.get("moves", {})
available = [d for d, chk in moves.items() if chk.get("available")]
if not available:
# If completely cornered/trapped, rest and sleep instead of passing
if my_hp < float(my_status.get("max_health", 10.0)):
print("🚫 All adjacent paths blocked by terrain. Sleeping to regenerate health...")
self.sleep_and_heal()
else:
print("🚫 All adjacent paths blocked by terrain. Passing turn.")
self.pass_turn()
return
# 1. Prefer radar BFS route direction
if rec_dir and rec_dir in available:
chosen_dir = rec_dir
# 2. Move towards nearest player target
elif nearest:
chosen_dir = self._get_best_move_towards(nearest["x"], nearest["y"], moves) or available[0]
# 3. Fallback open move
else:
chosen_dir = available[0]
target_info = f"closest player '{nearest.get('name')}' at dist {nearest.get('distance')}" if nearest else "open frontier"
print(f"👹 [HUNT] Moving {chosen_dir} towards {target_info} (HP: {my_hp:.1f}, Str: {self.strength:.1f})")
try:
res = requests.post(
f"{self.base_url}/players/{self.bot_id}/move",
json={"direction": chosen_dir},
).json()
if res.get("battle_triggered") and res.get("battle_result"):
b = res["battle_result"]
print(f"⚔️ Move clash! Winner: {b.get('winner_party_name')} (Defeated: {b.get('defeated_party_name')})")
except Exception as e:
print(f"Error during move: {e}")
self.pass_turn()
def run(self):
"""Main game loop for autonomous troll agent."""
self.register()
try:
while True:
# Check life status
my_status = self.refresh_status()
if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0):
print(f"\n🪦 [FALLEN TROLL] {self.name} has fallen (0 HP)! Gravestone marked on board.")
print(f"Final Achieved Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating...")
while True:
try:
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Game ended! Winner: '{conc.get('winning_party_name')}'")
return
except Exception:
pass
time.sleep(2.0)
turn_info = requests.get(f"{self.base_url}/turn").json()
if not turn_info.get("game_started", False):
# Check if bot was removed by board reset
if self.bot_id:
res = requests.get(f"{self.base_url}/players/{self.bot_id}")
if res.status_code == 404:
print("\n⚠️ [RESET] Board was regenerated. Rejoining lobby...")
self.register()
print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI... ", end="\r", flush=True)
time.sleep(1.0)
continue
curr_player_id = turn_info.get("current_player_id")
if curr_player_id == self.bot_id:
print(f"\n⚡ [MY TURN] Troll {self.name}'s turn (Round {turn_info.get('round_number')}, Turn {turn_info.get('turn_number')})")
self.decide_and_act()
# Check for game conclusion
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Arena concluded! Winner: '{conc.get('winning_party_name')}'!")
break
time.sleep(self.loop_delay)
except KeyboardInterrupt:
print(f"\n👋 Troll agent {self.name} disconnecting gracefully.")
def main():
parser = argparse.ArgumentParser(description="Autonomous Heuristic Troll Agent for botWebWars")
parser.add_argument("-u", "--url", default=DEFAULT_SERVER_URL, help="botWebWars API base URL")
parser.add_argument("-n", "--name", default=DEFAULT_TROLL_NAME, help="Troll name")
parser.add_argument("-c", "--color", default=DEFAULT_TROLL_COLOR, help="Troll color hex code")
parser.add_argument("-s", "--strength", type=float, default=DEFAULT_TROLL_STRENGTH, help="Starting strength (1-10)")
parser.add_argument("-H", "--health", type=float, default=DEFAULT_TROLL_HEALTH, help="Starting health points (default: 10.0)")
parser.add_argument("--sleep-threshold", type=float, default=DEFAULT_SLEEP_THRESHOLD, help="HP threshold below which troll sleeps to heal (default: 6.0)")
parser.add_argument("--loop-delay", type=float, default=1.0, help="Polling interval in seconds (default: 1.0)")
args = parser.parse_args()
agent = SmartTrollAgent(
name=args.name,
color=args.color,
strength=args.strength,
health=args.health,
server_url=args.url,
sleep_threshold=args.sleep_threshold,
loop_delay=args.loop_delay,
)
agent.run()
if __name__ == "__main__":
main()

30
trollagent_ai/INSTALL.md Normal file
View File

@ -0,0 +1,30 @@
# Installation & Setup for `trollagent_ai`
## 1. Prerequisites
- Python 3.10+
- An accessible [Ollama](https://ollama.com/) instance serving a reasoning model (default: `gemma4:12b` or any OpenAI-compatible Ollama model).
## 2. Environment Setup
```bash
cd trollagent_ai
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
## 3. Ollama Configuration
By default, the agent connects to:
- `OLLAMA_BASE_URL`: `http://192.168.1.220:11434` (or `http://localhost:11434` for local instance)
- `OLLAMA_MODEL`: `gemma4:12b`
Make sure the model is pulled and running:
```bash
ollama pull gemma4:12b
# Or test with llama3 / mistral
ollama pull llama3
```
## 4. Run the Bot
```bash
python3 bot.py --name BloodTroll --color "#15803d" --strength 4 --health 10
```

45
trollagent_ai/README.md Normal file
View File

@ -0,0 +1,45 @@
# trollagent_ai - LLM-Assisted Autonomous Troll Agent (Ollama)
`trollagent_ai` delegates tactical reasoning to a local or remote Ollama LLM while operating under the canonical Troll mechanics of **botWebWars**.
---
## 👹 Troll Rules & LLM Strategy
- **Solitary Brute**: Never forms parties or alliances.
- **Troll Truce**: Ignores other trolls and never battles them.
- **Predatory Focus**: Relentlessly hunts and battles players and squads.
- **Healing via Sleep**: When injured or navigating complex obstacle layouts, the LLM strategically chooses whether to **rest and sleep** (+0.1 HP regeneration per turn) or push forward to ambush players.
- **Obstacle Navigation**: Evaluates available paths around mountains and forests, balancing Chebyshev distance against diagonal squeeze strength penalties.
- **No Wizard Challenges**: Gary the Wizard cannot be engaged.
---
## ⚙️ Configuration & Environment Variables
| Variable | CLI Flag | Default | Description |
|---|---|---|---|
| `TROLL_SERVER_URL` | `-u`, `--url` | `http://localhost:8000/api` | botWebWars API base endpoint |
| `TROLL_NAME` | `-n`, `--name` | `GorgonAITroll` | Troll avatar name |
| `TROLL_COLOR` | `-c`, `--color` | `#15803d` | Troll hex color |
| `TROLL_STRENGTH` | `-s`, `--strength` | `3.0` | Initial troll strength |
| `TROLL_HEALTH` | `-H`, `--health` | `10.0` | Initial health points |
| `OLLAMA_BASE_URL` | `--ollama-url` | `http://192.168.1.220:11434` | Ollama server URL |
| `OLLAMA_MODEL` | `--ollama-model` | `gemma4:12b` | Ollama model identifier |
| | `--loop-delay` | `1.0` | Turn polling loop delay (s) |
---
## 🚀 Execution Example
```bash
export OLLAMA_BASE_URL="http://localhost:11434"
export OLLAMA_MODEL="gemma4:12b"
python3 trollagent_ai/bot.py \
--name CarnageTroll \
--color "#047857" \
--strength 4 \
--health 10 \
--url http://localhost:8000/api
```

429
trollagent_ai/bot.py Normal file
View File

@ -0,0 +1,429 @@
"""AI-driven Troll Agent for botWebWars, powered by an Ollama LLM.
Implements the unique rules and tactical gameplay of the Troll character type:
- Solitary hunter: Never joins or forms parties.
- Troll truce: Never battles other trolls.
- Relentless hunter: Mandatory tactical combat against players and squads.
- Strategic healing: Can take turns sleeping to regenerate +0.1 HP.
- Cannot challenge or duel Gary the Wizard.
- Strategic decision making via local/remote Ollama LLM:
1. Action Choice: Evaluates health and distance to decide whether to SLEEP (recover health) or HUNT (move).
2. Directional Navigation: Chooses the optimal passable path around obstacles towards target players.
"""
import argparse
import json
import os
import re
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
DEFAULT_SERVER_URL = os.getenv("TROLL_SERVER_URL", os.getenv("BOT_SERVER_URL", "http://localhost:8000/api"))
DEFAULT_TROLL_NAME = os.getenv("TROLL_NAME", "GorgonAITroll")
DEFAULT_TROLL_COLOR = os.getenv("TROLL_COLOR", "#15803d")
DEFAULT_TROLL_STRENGTH = float(os.getenv("TROLL_STRENGTH", "3.0"))
DEFAULT_TROLL_HEALTH = float(os.getenv("TROLL_HEALTH", "10.0"))
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:12b")
TROLL_RULES_SUMMARY = """
You are an autonomous tactical Troll in botWebWars!
Troll Rules of Engagement:
- You NEVER form alliances or parties with anyone. You are a solitary brute hunter.
- You NEVER fight other trolls. Trolls maintain an instinctive truce.
- Your sole mission is to hunt down human/bot players and player squads and crush them in 3-bout D20 battles.
- When victorious in battle, you earn +2 victory points (score). You do NOT gain strength or absorb squad members.
- If defeated in battle, you take 1 to 3 health points damage. At 0 HP, you die and a gravestone is placed.
- You CANNOT duel or interact with Gary the Wizard.
- SLEEP RESTORATION: On any turn when you are not in adjacent combat, you may choose to SLEEP. Sleeping skips movement but regenerates +0.1 HP!
- Squeezing diagonally between obstacle corners costs -0.1 strength penalty.
- The game concludes when all surviving entities are resolved. You can win the game on the final scoreboards!
"""
def normalize_url(url: str) -> str:
"""Ensure the API URL ends with /api without trailing slashes."""
cleaned = url.rstrip("/")
if not cleaned.endswith("/api"):
cleaned = f"{cleaned}/api"
return cleaned
def extract_json(text: str) -> Optional[Dict[str, Any]]:
"""Extract first valid JSON object from model output."""
if not text:
return None
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
return None
class OllamaClient:
def __init__(self, base_url: str, model: str):
self.base_url = base_url.rstrip("/")
self.model = model
def ask_json(self, prompt: str) -> Optional[Dict[str, Any]]:
"""Query Ollama with a JSON formatting constraint."""
url = f"{self.base_url}/api/generate"
payload = {
"model": self.model,
"prompt": prompt,
"format": "json",
"stream": False,
"options": {"temperature": 0.3},
}
try:
res = requests.post(url, json=payload, timeout=45)
res.raise_for_status()
raw = res.json().get("response", "")
return extract_json(raw)
except requests.exceptions.RequestException as e:
print(f"⚠️ [OLLAMA ERROR] {e}")
return None
class AITrollAgent:
def __init__(
self,
name: str = DEFAULT_TROLL_NAME,
color: str = DEFAULT_TROLL_COLOR,
strength: float = DEFAULT_TROLL_STRENGTH,
health: float = DEFAULT_TROLL_HEALTH,
server_url: str = DEFAULT_SERVER_URL,
ollama_url: str = OLLAMA_BASE_URL,
ollama_model: str = OLLAMA_MODEL,
loop_delay: float = 1.0,
):
self.name = name
self.color = color
self.strength = strength
self.health = health
self.base_url = normalize_url(server_url)
self.llm = OllamaClient(ollama_url, ollama_model)
self.loop_delay = loop_delay
self.bot_id: Optional[str] = None
def register(self):
"""Register the troll avatar on the grid or reconnect if existing."""
try:
players = requests.get(f"{self.base_url}/players").json()
for p in players:
if p.get("name") == self.name:
self.bot_id = p["id"]
print(
f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} "
f"(ID: {self.bot_id}, Str: {p.get('strength', self.strength)}, "
f"HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})"
)
return
except Exception:
pass
payload = {
"name": self.name,
"color": self.color,
"strength": self.strength,
"health": self.health,
"piece_type": "troll",
"character_type": "troll",
}
res = requests.post(f"{self.base_url}/players", json=payload)
if res.status_code == 400 and "already registered" in res.text:
players = requests.get(f"{self.base_url}/players").json()
for p in players:
if p.get("name") == self.name:
self.bot_id = p["id"]
print(
f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} "
f"(ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})"
)
return
res.raise_for_status()
data = res.json()
self.bot_id = data["id"]
print(
f"👹 [REGISTER] Spawned AI Troll {self.name} (ID: {self.bot_id}, Str: {self.strength}, "
f"HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})"
)
def refresh_status(self) -> Optional[Dict[str, Any]]:
"""Get live troll state."""
try:
res = requests.get(f"{self.base_url}/players/{self.bot_id}")
if res.status_code == 200:
return res.json()
except Exception as e:
print(f"Status refresh error: {e}")
return None
def sleep_and_heal(self) -> bool:
"""Rest for 1 turn to regenerate +0.1 HP."""
try:
res = requests.post(f"{self.base_url}/players/{self.bot_id}/sleep")
if res.status_code == 200:
data = res.json()
print(
f"💤 [SLEEP] {self.name} rested for 1 turn. "
f"(+{data.get('health_gained', 0.1)} HP -> ❤️ {data.get('new_health')} HP)"
)
return True
else:
print(f"Sleep failed ({res.status_code}): {res.text}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
return False
except Exception as e:
print(f"Error sleeping: {e}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
return False
def pass_turn(self):
"""Pass turn when no actions are possible."""
try:
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
print(f"⏩ [PASS] {self.name} passed turn.")
except Exception as e:
print(f"Error passing turn: {e}")
def _find_adjacent_player(self, my_x: int, my_y: int) -> Optional[Dict[str, Any]]:
"""Detect living adjacent player or player squad (ignores other trolls)."""
try:
players: List[Dict[str, Any]] = requests.get(f"{self.base_url}/players").json()
for p in players:
if p.get("id") == self.bot_id:
continue
if p.get("character_type") == "troll":
continue
if p.get("is_alive") is False or p.get("health", 10) <= 0:
continue
chebyshev = max(abs(p["x"] - my_x), abs(p["y"] - my_y))
if chebyshev <= 1:
return p
except Exception as e:
print(f"Error checking adjacent players: {e}")
return None
def attack_player(self, defender: Dict[str, Any]):
"""Initiate mandatory 3-bout D20 battle against adjacent player."""
target_label = f"Squad '{defender.get('party_id')}'" if defender.get("party_id") else f"Player {defender.get('name')}"
print(f"⚔️ [BATTLE CLASH] Troll {self.name} ambushes {target_label} in 3-bout tactical D20 confrontation!")
try:
res = requests.post(
f"{self.base_url}/battles/fight",
json={"challenger_id": self.bot_id, "defender_id": defender["id"]},
)
if res.status_code == 200:
b = res.json()
won = b.get("winner_leader_name") == self.name or b.get("winner_party_name", "").startswith(self.name)
outcome = "VICTORY (+2 Victory Points!)" if won else "DEFEAT (HP damage taken)"
print(f"⚔️ [RESULT] {outcome}: {b.get('winner_party_name')} defeated {b.get('defeated_party_name')}")
for bout in b.get("bouts", []):
print(
f" Bout #{bout['bout_number']}: "
f"{bout['party1_name']} D20({bout['party1_roll']})×Str({bout['party1_strength']})={bout['party1_score']:.1f} vs "
f"{bout['party2_name']} D20({bout['party2_roll']})×Str({bout['party2_strength']})={bout['party2_score']:.1f} "
f"-> Winner: {bout['winner']}"
)
else:
print(f"Battle failed ({res.status_code}): {res.text}")
self.pass_turn()
except Exception as e:
print(f"Error fighting battle: {e}")
self.pass_turn()
def _decide_action_and_direction(
self,
my_info: Dict[str, Any],
radar_res: Dict[str, Any],
available_moves: Dict[str, Any],
) -> Tuple[str, Optional[str], str]:
"""Ask the LLM to decide whether to sleep or move, and if moving, which direction."""
my_hp = float(my_info.get("health", 10.0))
max_hp = float(my_info.get("max_health", 10.0))
nearest = radar_res.get("nearest_target")
rec_dir = radar_res.get("recommended_direction")
# Format available movement choices
choices_desc = []
for d, chk in available_moves.items():
if chk.get("available"):
pen = " [squeeze penalty -0.1 STR]" if chk.get("strength_penalty", 0.0) > 0 else ""
choices_desc.append(f"- {d}: Target ({chk.get('target_x')}, {chk.get('target_y')}){pen}")
choices_str = "\n".join(choices_desc) if choices_desc else "No open moves available (all blocked by obstacles/bounds)."
prompt = f"""{TROLL_RULES_SUMMARY}
Current Troll Status:
- Name: "{self.name}" | Current HP: {my_hp:.1f} / {max_hp:.1f} | Strength: {self.strength:.1f} | Score: {my_info['score']}
- Position: ({my_info['x']}, {my_info['y']})
- Nearest Target Player: {nearest.get('name') if nearest else 'None detected'} (Distance: {nearest.get('distance') if nearest else 'N/A'}, Pos: {nearest.get('x') if nearest else '?'},{nearest.get('y') if nearest else '?'})
- Radar Pathfinder Suggestion: {rec_dir or 'None'}
Available Passable Moves:
{choices_str}
OPTIONS:
1. "sleep" - Take a rest turn to regenerate +0.1 HP. (Recommended if wounded or trapped).
2. "move" - Move in one of the passable directions toward the nearest player.
Decide which action to take. If action is "move", pick the best passable direction from the list.
Respond ONLY with a JSON object:
{{"action": "sleep"|"move", "direction": "UP"|"DOWN"|"LEFT"|"RIGHT"|"UP_LEFT"|"UP_RIGHT"|"DOWN_LEFT"|"DOWN_RIGHT"|null, "reasoning": "short tactical rationale"}}
"""
decision = self.llm.ask_json(prompt) or {}
action = str(decision.get("action", "move")).lower().strip()
direction = decision.get("direction")
reasoning = decision.get("reasoning", "")
# Fallback validation
if action not in ("sleep", "move"):
action = "sleep" if my_hp < 6.0 and my_hp < max_hp else "move"
valid_dirs = [d for d, chk in available_moves.items() if chk.get("available")]
if action == "move":
if direction not in valid_dirs:
direction = rec_dir if rec_dir in valid_dirs else (valid_dirs[0] if valid_dirs else None)
if not direction:
action = "sleep" if my_hp < max_hp else "pass"
return action, direction, reasoning
def decide_and_act(self):
"""Turn execution pipeline: combat check -> LLM reasoning -> act."""
my_info = self.refresh_status()
if not my_info:
self.pass_turn()
return
my_x = my_info.get("x", 0)
my_y = my_info.get("y", 0)
# 1. Immediate combat check: Trolls always attack adjacent players
adjacent_player = self._find_adjacent_player(my_x, my_y)
if adjacent_player:
self.attack_player(adjacent_player)
return
# 2. Get sensor data
try:
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
except Exception:
radar_res = {}
try:
moves_res = requests.get(f"{self.base_url}/players/{self.bot_id}/available-moves").json()
available_moves = moves_res.get("moves", {})
except Exception:
available_moves = {}
# 3. LLM strategic decision
action, direction, reasoning = self._decide_action_and_direction(my_info, radar_res, available_moves)
print(f"🧠 [LLM STRATEGY] Action: {action.upper()}{f' -> {direction}' if direction else ''}. Reasoning: {reasoning}")
if action == "sleep":
self.sleep_and_heal()
elif action == "move" and direction:
try:
res = requests.post(
f"{self.base_url}/players/{self.bot_id}/move",
json={"direction": direction},
).json()
if res.get("battle_triggered") and res.get("battle_result"):
b = res["battle_result"]
print(f"⚔️ Move clash! Winner: {b.get('winner_party_name')} (Defeated: {b.get('defeated_party_name')})")
except Exception as e:
print(f"Move failed: {e}")
self.pass_turn()
else:
self.pass_turn()
def run(self):
"""Main game loop."""
self.register()
try:
while True:
# Check life status
my_status = self.refresh_status()
if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0):
print(f"\n🪦 [FALLEN TROLL] {self.name} has fallen (0 HP)! Gravestone marked on board.")
print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating...")
while True:
try:
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Arena concluded! Winner: '{conc.get('winning_party_name')}'")
return
except Exception:
pass
time.sleep(2.0)
turn_info = requests.get(f"{self.base_url}/turn").json()
if not turn_info.get("game_started", False):
if self.bot_id:
res = requests.get(f"{self.base_url}/players/{self.bot_id}")
if res.status_code == 404:
print("\n⚠️ [RESET] Board regenerated. Rejoining lobby...")
self.register()
print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI... ", end="\r", flush=True)
time.sleep(1.0)
continue
curr_player_id = turn_info.get("current_player_id")
if curr_player_id == self.bot_id:
print(f"\n⚡ [MY TURN] AI Troll {self.name} (Round {turn_info.get('round_number')}, Turn {turn_info.get('turn_number')})")
self.decide_and_act()
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Arena concluded! Winner: '{conc.get('winning_party_name')}'!")
break
time.sleep(self.loop_delay)
except KeyboardInterrupt:
print(f"\n👋 AI Troll {self.name} disconnecting gracefully.")
def main():
parser = argparse.ArgumentParser(description="Ollama LLM-Assisted Autonomous Troll Agent for botWebWars")
parser.add_argument("-u", "--url", default=DEFAULT_SERVER_URL, help="botWebWars API base URL")
parser.add_argument("-n", "--name", default=DEFAULT_TROLL_NAME, help="Troll name")
parser.add_argument("-c", "--color", default=DEFAULT_TROLL_COLOR, help="Troll color hex code")
parser.add_argument("-s", "--strength", type=float, default=DEFAULT_TROLL_STRENGTH, help="Starting strength (1-10)")
parser.add_argument("-H", "--health", type=float, default=DEFAULT_TROLL_HEALTH, help="Starting health points (default: 10.0)")
parser.add_argument("--ollama-url", default=OLLAMA_BASE_URL, help="Ollama API base URL")
parser.add_argument("--ollama-model", default=OLLAMA_MODEL, help="Ollama model name (default: gemma4:12b)")
parser.add_argument("--loop-delay", type=float, default=1.0, help="Polling interval in seconds")
args = parser.parse_args()
agent = AITrollAgent(
name=args.name,
color=args.color,
strength=args.strength,
health=args.health,
server_url=args.url,
ollama_url=args.ollama_url,
ollama_model=args.ollama_model,
loop_delay=args.loop_delay,
)
agent.run()
if __name__ == "__main__":
main()

View File

@ -0,0 +1 @@
requests>=2.31.0

View File

@ -0,0 +1,18 @@
# Installation Guide for `trollagent_gear`
## 1. Prerequisites
- Python 3.10+
- Google Cloud Project with Vertex AI enabled, or a Google AI Studio Gemini API key.
## 2. Virtual Environment & Dependencies
```bash
cd trollagent_gear
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
## 3. Run the Bot
```bash
python3 bot.py --name GeminiBrute --color "#047857" --strength 4 --health 10
```

47
trollagent_gear/README.md Normal file
View File

@ -0,0 +1,47 @@
# trollagent_gear - Google Cloud Vertex AI (Gemini) Troll Agent
`trollagent_gear` delegates autonomous tactical reasoning to Google Cloud Vertex AI and Gemini models (`gemini-2.5-flash`, `gemini-1.5-flash`, etc.) to drive a solitary Troll character in **botWebWars**.
---
## 👹 Troll Strategy & Gemini Reasoning
- **Hunter Instinct**: Evaluates radar minimaps to track and corner player bots and squads.
- **Troll Truce**: Instinctively ignores other trolls.
- **Sleep & Regeneration Strategy**: Leverages Gemini reasoning to evaluate current HP against target proximity—choosing when to safely **rest and sleep** (+0.1 HP regeneration) vs. when to strike.
- **Obstacle Navigation**: Balances Euclidean and Chebyshev distance against the -0.1 strength diagonal squeeze penalty.
- **No Wizard Challenges**: Never interacts with Gary the Wizard.
---
## ⚙️ Configuration & CLI Options
| Flag | Short | Default | Description |
|---|---|---|---|
| `--url` | `-u` | `http://localhost:8000/api` | botWebWars API base endpoint |
| `--name` | `-n` | `GeminiTroll` | Troll avatar name |
| `--color` | `-c` | `#047857` | Troll hex color |
| `--strength` | `-s` | `3.0` | Initial troll strength |
| `--health` | `-H` | `10.0` | Initial troll health points |
| `--project-id` | | `None` (auto-detected) | Google Cloud Project ID |
| `--location` | | `global` | Vertex AI region (e.g. `global`, `us-central1`) |
| `--model` | | `gemini-2.5-flash` | Gemini model name |
| `--api-key` | | `None` | Google AI Studio or Vertex API key |
| `--loop-delay` | | `1.0` | Turn polling loop delay in seconds |
---
## 🚀 Invocation Example
```bash
# Using gcloud ADC authentication
gcloud auth application-default login
export VERTEX_PROJECT_ID="my-gcp-project"
python3 trollagent_gear/bot.py \
--name ApexTroll \
--color "#059669" \
--strength 5 \
--health 12 \
--url http://localhost:8000/api
```

38
trollagent_gear/SETUP.md Normal file
View File

@ -0,0 +1,38 @@
# Google Cloud & Vertex AI Authentication Setup for `trollagent_gear`
`trollagent_gear` connects to Google Cloud Vertex AI (or Google AI Studio) to query Gemini models (`gemini-2.5-flash`, `gemini-1.5-flash`, etc.) for tactical spatial navigation, obstacle avoidance, and health regeneration decisions.
---
## Quick Setup Options
### Option A: Application Default Credentials (ADC) - Recommended
```bash
# 1. Login with your Google Cloud account:
gcloud auth application-default login
# 2. Set your default project:
gcloud config set project YOUR_PROJECT_ID
export VERTEX_PROJECT_ID="YOUR_PROJECT_ID"
```
### Option B: Direct API Key (Google AI Studio or Vertex AI)
```bash
export GEMINI_API_KEY="AIzaSy..."
# Or:
export VERTEX_API_KEY="AIzaSy..."
```
### Option C: Service Account Key
```bash
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"
export VERTEX_PROJECT_ID="YOUR_PROJECT_ID"
```
---
## Verifying Authentication
Run the auth check directly by starting the bot with `--help`:
```bash
python3 trollagent_gear/bot.py --help
```

613
trollagent_gear/bot.py Normal file
View File

@ -0,0 +1,613 @@
"""AI-driven Troll Agent for botWebWars, powered by Google Cloud Vertex AI (Gemini).
Implements the unique rules and tactical gameplay of the Troll character type:
- Solitary brute: Never joins or forms parties/alliances.
- Troll truce: Never battles other trolls.
- Relentless hunter: Mandatory tactical combat against players and squads.
- Strategic healing: Can take turns sleeping to regenerate +0.1 HP.
- Cannot challenge or duel Gary the Wizard.
- Strategic decision making via Vertex AI / Gemini:
1. Action Choice: Evaluates health and distance to decide whether to SLEEP (recover health) or HUNT (move).
2. Directional Navigation: Chooses the optimal passable path around obstacles towards target players.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
# Optional google-auth integration
try:
import google.auth
import google.auth.transport.requests
HAVE_GOOGLE_AUTH = True
except ImportError:
HAVE_GOOGLE_AUTH = False
DEFAULT_SERVER_URL = os.getenv("TROLL_SERVER_URL", os.getenv("BOT_SERVER_URL", "http://localhost:8000/api"))
DEFAULT_TROLL_NAME = os.getenv("TROLL_NAME", "GeminiTroll")
DEFAULT_TROLL_COLOR = os.getenv("TROLL_COLOR", "#047857")
DEFAULT_TROLL_STRENGTH = float(os.getenv("TROLL_STRENGTH", "3.0"))
DEFAULT_TROLL_HEALTH = float(os.getenv("TROLL_HEALTH", "10.0"))
DEFAULT_MODEL = os.getenv("VERTEX_MODEL", os.getenv("GEMINI_MODEL", "gemini-2.5-flash"))
DEFAULT_LOCATION = os.getenv("VERTEX_LOCATION", "global")
TROLL_RULES_SUMMARY = """
You are an autonomous tactical Troll in botWebWars!
Troll Rules of Engagement:
- You NEVER form alliances or parties with anyone. You are a solitary brute hunter.
- You NEVER fight other trolls. Trolls maintain an instinctive truce.
- Your sole mission is to hunt down human/bot players and player squads and crush them in 3-bout D20 battles.
- When victorious in battle, you earn +2 victory points (score). You do NOT gain strength or absorb squad members.
- If defeated in battle, you take 1 to 3 health points damage. At 0 HP, you die and a gravestone is placed on the board.
- You CANNOT duel or interact with Gary the Wizard.
- SLEEP RESTORATION: On any turn when you are not locked in combat, you may choose to SLEEP. Sleeping skips movement but regenerates +0.1 HP!
- Squeezing diagonally between obstacle corners costs -0.1 strength penalty.
- The game concludes when all surviving entities are resolved. You can win the game on the final scoreboards!
"""
def normalize_url(url: str) -> str:
"""Ensure the API URL ends with /api without trailing slashes."""
cleaned = url.rstrip("/")
if not cleaned.endswith("/api"):
cleaned = f"{cleaned}/api"
return cleaned
def extract_json(text: str) -> Optional[Dict[str, Any]]:
"""Extract first valid JSON object from model response."""
if not text:
return None
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
return None
class VertexGeminiClient:
"""Client for querying Gemini models on Google Cloud Vertex AI or Google AI Studio."""
def __init__(
self,
project_id: Optional[str] = None,
location: str = DEFAULT_LOCATION,
model: str = DEFAULT_MODEL,
api_key: Optional[str] = None,
):
self.location = location or DEFAULT_LOCATION
self.model = model or DEFAULT_MODEL
self.api_key = api_key or os.getenv("VERTEX_API_KEY") or os.getenv("GEMINI_API_KEY")
self.project_id = project_id or os.getenv("VERTEX_PROJECT_ID") or os.getenv("GCP_PROJECT") or os.getenv("GOOGLE_CLOUD_PROJECT")
self._cached_token: Optional[str] = None
self._token_expiry: float = 0.0
if not self.project_id and not self.api_key:
self.project_id = self._detect_project()
def _detect_project(self) -> Optional[str]:
"""Attempt to determine the GCP project from environment or gcloud config."""
if HAVE_GOOGLE_AUTH:
try:
_, proj = google.auth.default()
if proj:
return proj
except Exception:
pass
if shutil.which("gcloud"):
try:
res = subprocess.check_output(
["gcloud", "config", "get-value", "project"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
if res and res != "(unset)":
return res
except Exception:
pass
return None
def _get_access_token(self) -> Optional[str]:
"""Obtain a valid OAuth 2.0 Bearer access token for Vertex AI."""
env_token = os.getenv("VERTEX_ACCESS_TOKEN") or os.getenv("GOOGLE_OAUTH_ACCESS_TOKEN")
if env_token:
return env_token
now = time.time()
if self._cached_token and now < self._token_expiry - 60:
return self._cached_token
if HAVE_GOOGLE_AUTH:
try:
credentials, _ = google.auth.default(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
)
auth_req = google.auth.transport.requests.Request()
credentials.refresh(auth_req)
self._cached_token = credentials.token
self._token_expiry = now + 3000
return self._cached_token
except Exception:
pass
if shutil.which("gcloud"):
try:
token = subprocess.check_output(
["gcloud", "auth", "print-access-token"],
stderr=subprocess.PIPE,
text=True,
).strip()
if token:
self._cached_token = token
self._token_expiry = now + 3000
return token
except subprocess.CalledProcessError:
pass
return None
def check_auth(self) -> Tuple[bool, str]:
"""Validate whether authentication is ready."""
if self.api_key:
return True, f"Using direct API key (model: {self.model})"
if not self.project_id:
return False, (
"No Google Cloud project ID detected.\n"
"Please set VERTEX_PROJECT_ID=<your-project-id> or run:\n"
" gcloud config set project <your-project-id>"
)
token = self._get_access_token()
if not token:
return False, (
"Unable to obtain Google Cloud authentication token.\n"
"Please authenticate using:\n"
" 1. gcloud auth application-default login\n"
" 2. Or set GEMINI_API_KEY / VERTEX_API_KEY"
)
return True, f"Authenticated to GCP Project '{self.project_id}' in region '{self.location}' (model: {self.model})"
def _build_vertex_url(self, location: Optional[str] = None) -> str:
loc = location or self.location or "global"
if loc == "global":
endpoint = "aiplatform.googleapis.com"
else:
endpoint = f"{loc}-aiplatform.googleapis.com"
return f"https://{endpoint}/v1/projects/{self.project_id}/locations/{loc}/publishers/google/models/{self.model}:generateContent"
def ask_json(self, prompt: str, system_instruction: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Query Gemini model requesting a structured JSON response."""
# Method 1: Google AI Studio API key
if self.api_key:
url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent?key={self.api_key}"
headers = {"Content-Type": "application/json"}
body: Dict[str, Any] = {
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"generationConfig": {
"temperature": 0.3,
"responseMimeType": "application/json",
},
}
if system_instruction:
body["systemInstruction"] = {"parts": [{"text": system_instruction}]}
try:
res = requests.post(url, headers=headers, json=body, timeout=45)
res.raise_for_status()
data = res.json()
text = (
data.get("candidates", [{}])[0]
.get("content", {})
.get("parts", [{}])[0]
.get("text", "")
)
return extract_json(text)
except Exception as e:
print(f"⚠️ [GEMINI API KEY ERROR] {e}")
return None
# Method 2: Vertex AI endpoint
token = self._get_access_token()
if not token:
print("⚠️ [AUTH ERROR] No Google Cloud access token available.")
return None
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
body = {
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"generationConfig": {
"temperature": 0.3,
"responseMimeType": "application/json",
},
}
if system_instruction:
body["systemInstruction"] = {"parts": [{"text": system_instruction}]}
url = self._build_vertex_url()
try:
res = requests.post(url, headers=headers, json=body, timeout=45)
if res.status_code == 404 and self.location == "global":
url_fallback = self._build_vertex_url("us-central1")
res = requests.post(url_fallback, headers=headers, json=body, timeout=45)
res.raise_for_status()
data = res.json()
text = (
data.get("candidates", [{}])[0]
.get("content", {})
.get("parts", [{}])[0]
.get("text", "")
)
return extract_json(text)
except Exception as e:
print(f"⚠️ [VERTEX AI ERROR] {e}")
return None
class GearTrollAgent:
def __init__(
self,
name: str = DEFAULT_TROLL_NAME,
color: str = DEFAULT_TROLL_COLOR,
strength: float = DEFAULT_TROLL_STRENGTH,
health: float = DEFAULT_TROLL_HEALTH,
server_url: str = DEFAULT_SERVER_URL,
project_id: Optional[str] = None,
location: str = DEFAULT_LOCATION,
model: str = DEFAULT_MODEL,
api_key: Optional[str] = None,
loop_delay: float = 1.0,
):
self.name = name
self.color = color
self.strength = strength
self.health = health
self.base_url = normalize_url(server_url)
self.gemini = VertexGeminiClient(
project_id=project_id,
location=location,
model=model,
api_key=api_key,
)
self.loop_delay = loop_delay
self.bot_id: Optional[str] = None
def register(self):
"""Register the troll avatar or reconnect."""
try:
players = requests.get(f"{self.base_url}/players").json()
for p in players:
if p.get("name") == self.name:
self.bot_id = p["id"]
print(
f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} "
f"(ID: {self.bot_id}, Str: {p.get('strength', self.strength)}, "
f"HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})"
)
return
except Exception:
pass
payload = {
"name": self.name,
"color": self.color,
"strength": self.strength,
"health": self.health,
"piece_type": "troll",
"character_type": "troll",
}
res = requests.post(f"{self.base_url}/players", json=payload)
if res.status_code == 400 and "already registered" in res.text:
players = requests.get(f"{self.base_url}/players").json()
for p in players:
if p.get("name") == self.name:
self.bot_id = p["id"]
print(
f"🔄 [RECONNECT] Reconnected to existing Troll {self.name} "
f"(ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})"
)
return
res.raise_for_status()
data = res.json()
self.bot_id = data["id"]
print(
f"👹 [REGISTER] Spawned Vertex AI Troll {self.name} (ID: {self.bot_id}, Str: {self.strength}, "
f"HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})"
)
def refresh_status(self) -> Optional[Dict[str, Any]]:
"""Get live troll state."""
try:
res = requests.get(f"{self.base_url}/players/{self.bot_id}")
if res.status_code == 200:
return res.json()
except Exception as e:
print(f"Status refresh error: {e}")
return None
def sleep_and_heal(self) -> bool:
"""Execute sleep turn to recover +0.1 HP."""
try:
res = requests.post(f"{self.base_url}/players/{self.bot_id}/sleep")
if res.status_code == 200:
data = res.json()
print(
f"💤 [SLEEP] {self.name} curled up and slept for 1 turn. "
f"(+{data.get('health_gained', 0.1)} HP -> ❤️ {data.get('new_health')} HP)"
)
return True
else:
print(f"Sleep failed ({res.status_code}): {res.text}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
return False
except Exception as e:
print(f"Error sleeping: {e}")
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
return False
def pass_turn(self):
"""Pass turn."""
try:
requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
print(f"⏩ [PASS] {self.name} passed turn.")
except Exception as e:
print(f"Error passing turn: {e}")
def _find_adjacent_player(self, my_x: int, my_y: int) -> Optional[Dict[str, Any]]:
"""Detect living adjacent player or player squad (ignores other trolls)."""
try:
players: List[Dict[str, Any]] = requests.get(f"{self.base_url}/players").json()
for p in players:
if p.get("id") == self.bot_id:
continue
if p.get("character_type") == "troll":
continue
if p.get("is_alive") is False or p.get("health", 10) <= 0:
continue
chebyshev = max(abs(p["x"] - my_x), abs(p["y"] - my_y))
if chebyshev <= 1:
return p
except Exception as e:
print(f"Error scanning adjacent players: {e}")
return None
def attack_player(self, defender: Dict[str, Any]):
"""Initiate mandatory 3-bout D20 battle against adjacent player."""
target_label = f"Squad '{defender.get('party_id')}'" if defender.get("party_id") else f"Player {defender.get('name')}"
print(f"⚔️ [BATTLE CLASH] Troll {self.name} ambushes {target_label} in 3-bout D20 combat!")
try:
res = requests.post(
f"{self.base_url}/battles/fight",
json={"challenger_id": self.bot_id, "defender_id": defender["id"]},
)
if res.status_code == 200:
b = res.json()
won = b.get("winner_leader_name") == self.name or b.get("winner_party_name", "").startswith(self.name)
outcome = "VICTORY (+2 Victory Points!)" if won else "DEFEAT (HP damage taken)"
print(f"⚔️ [RESULT] {outcome}: {b.get('winner_party_name')} defeated {b.get('defeated_party_name')}")
for bout in b.get("bouts", []):
print(
f" Bout #{bout['bout_number']}: "
f"{bout['party1_name']} D20({bout['party1_roll']})×Str({bout['party1_strength']})={bout['party1_score']:.1f} vs "
f"{bout['party2_name']} D20({bout['party2_roll']})×Str({bout['party2_strength']})={bout['party2_score']:.1f} "
f"-> Winner: {bout['winner']}"
)
else:
print(f"Battle failed ({res.status_code}): {res.text}")
self.pass_turn()
except Exception as e:
print(f"Error fighting battle: {e}")
self.pass_turn()
def _decide_action_and_direction(
self,
my_info: Dict[str, Any],
radar_res: Dict[str, Any],
available_moves: Dict[str, Any],
) -> Tuple[str, Optional[str], str]:
"""Ask Vertex AI Gemini whether to sleep or move, and which direction."""
my_hp = float(my_info.get("health", 10.0))
max_hp = float(my_info.get("max_health", 10.0))
nearest = radar_res.get("nearest_target")
rec_dir = radar_res.get("recommended_direction")
choices_desc = []
for d, chk in available_moves.items():
if chk.get("available"):
pen = " [squeeze penalty -0.1 STR]" if chk.get("strength_penalty", 0.0) > 0 else ""
choices_desc.append(f"- {d}: Target ({chk.get('target_x')}, {chk.get('target_y')}){pen}")
choices_str = "\n".join(choices_desc) if choices_desc else "No open moves available."
prompt = f"""Current Troll Status:
- Name: "{self.name}" | HP: {my_hp:.1f} / {max_hp:.1f} | Strength: {self.strength:.1f} | Score: {my_info['score']}
- Position: ({my_info['x']}, {my_info['y']})
- Closest Detected Player: {nearest.get('name') if nearest else 'None'} (Distance: {nearest.get('distance') if nearest else 'N/A'}, Pos: {nearest.get('x') if nearest else '?'},{nearest.get('y') if nearest else '?'})
- Radar Pathfinder Recommendation: {rec_dir or 'None'}
Available Passable Moves:
{choices_str}
OPTIONS:
1. "sleep" - Rest for 1 turn to heal +0.1 HP (best if injured and not in direct pursuit).
2. "move" - Advance in one of the passable directions toward the nearest player.
Decide which action to take. If moving, select the best direction from the available moves.
Respond ONLY with JSON:
{{"action": "sleep"|"move", "direction": "UP"|"DOWN"|"LEFT"|"RIGHT"|"UP_LEFT"|"UP_RIGHT"|"DOWN_LEFT"|"DOWN_RIGHT"|null, "reasoning": "concise strategic reasoning"}}
"""
decision = self.gemini.ask_json(prompt, system_instruction=TROLL_RULES_SUMMARY) or {}
action = str(decision.get("action", "move")).lower().strip()
direction = decision.get("direction")
reasoning = decision.get("reasoning", "")
if action not in ("sleep", "move"):
action = "sleep" if my_hp < 6.0 and my_hp < max_hp else "move"
valid_dirs = [d for d, chk in available_moves.items() if chk.get("available")]
if action == "move":
if direction not in valid_dirs:
direction = rec_dir if rec_dir in valid_dirs else (valid_dirs[0] if valid_dirs else None)
if not direction:
action = "sleep" if my_hp < max_hp else "pass"
return action, direction, reasoning
def decide_and_act(self):
"""Main turn decision logic."""
my_info = self.refresh_status()
if not my_info:
self.pass_turn()
return
my_x = my_info.get("x", 0)
my_y = my_info.get("y", 0)
# 1. Combat check: Trolls always attack adjacent players
adjacent_player = self._find_adjacent_player(my_x, my_y)
if adjacent_player:
self.attack_player(adjacent_player)
return
# 2. Get sensors
try:
radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
except Exception:
radar_res = {}
try:
moves_res = requests.get(f"{self.base_url}/players/{self.bot_id}/available-moves").json()
available_moves = moves_res.get("moves", {})
except Exception:
available_moves = {}
# 3. Gemini reasoning
action, direction, reasoning = self._decide_action_and_direction(my_info, radar_res, available_moves)
print(f"✨ [GEMINI REASONING] Action: {action.upper()}{f' -> {direction}' if direction else ''}. Rationale: {reasoning}")
if action == "sleep":
self.sleep_and_heal()
elif action == "move" and direction:
try:
res = requests.post(
f"{self.base_url}/players/{self.bot_id}/move",
json={"direction": direction},
).json()
if res.get("battle_triggered") and res.get("battle_result"):
b = res["battle_result"]
print(f"⚔️ Move clash! Winner: {b.get('winner_party_name')} (Defeated: {b.get('defeated_party_name')})")
except Exception as e:
print(f"Move failed: {e}")
self.pass_turn()
else:
self.pass_turn()
def run(self):
"""Main loop."""
self.register()
try:
while True:
my_status = self.refresh_status()
if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0):
print(f"\n🪦 [FALLEN TROLL] {self.name} has fallen (0 HP)! Gravestone on board.")
print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating...")
while True:
try:
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Arena concluded! Winner: '{conc.get('winning_party_name')}'")
return
except Exception:
pass
time.sleep(2.0)
turn_info = requests.get(f"{self.base_url}/turn").json()
if not turn_info.get("game_started", False):
if self.bot_id:
res = requests.get(f"{self.base_url}/players/{self.bot_id}")
if res.status_code == 404:
print("\n⚠️ [RESET] Board regenerated. Rejoining lobby...")
self.register()
print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI... ", end="\r", flush=True)
time.sleep(1.0)
continue
curr_player_id = turn_info.get("current_player_id")
if curr_player_id == self.bot_id:
print(f"\n⚡ [MY TURN] Vertex Gemini Troll {self.name} (Round {turn_info.get('round_number')}, Turn {turn_info.get('turn_number')})")
self.decide_and_act()
conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] Arena concluded! Winner: '{conc.get('winning_party_name')}'!")
break
time.sleep(self.loop_delay)
except KeyboardInterrupt:
print(f"\n👋 Vertex Gemini Troll {self.name} disconnecting gracefully.")
def main():
parser = argparse.ArgumentParser(description="Vertex AI (Gemini) Autonomous Troll Agent for botWebWars")
parser.add_argument("-u", "--url", default=DEFAULT_SERVER_URL, help="botWebWars API base URL")
parser.add_argument("-n", "--name", default=DEFAULT_TROLL_NAME, help="Troll name")
parser.add_argument("-c", "--color", default=DEFAULT_TROLL_COLOR, help="Troll color hex code")
parser.add_argument("-s", "--strength", type=float, default=DEFAULT_TROLL_STRENGTH, help="Starting strength (1-10)")
parser.add_argument("-H", "--health", type=float, default=DEFAULT_TROLL_HEALTH, help="Starting health points (default: 10.0)")
parser.add_argument("--project-id", default=None, help="Google Cloud project ID (or VERTEX_PROJECT_ID)")
parser.add_argument("--location", default=DEFAULT_LOCATION, help="Vertex AI region (default: global)")
parser.add_argument("--model", default=DEFAULT_MODEL, help="Gemini model (default: gemini-2.5-flash)")
parser.add_argument("--api-key", default=None, help="Google AI Studio or Vertex API key")
parser.add_argument("--loop-delay", type=float, default=1.0, help="Polling interval in seconds")
args = parser.parse_args()
agent = GearTrollAgent(
name=args.name,
color=args.color,
strength=args.strength,
health=args.health,
server_url=args.url,
project_id=args.project_id,
location=args.location,
model=args.model,
api_key=args.api_key,
loop_delay=args.loop_delay,
)
auth_ok, auth_msg = agent.gemini.check_auth()
print(f"🔐 [AUTH CHECK] {auth_msg}")
if not auth_ok:
print("⚠️ Warning: Gemini calls will fail without valid credentials.")
agent.run()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,2 @@
requests>=2.31.0
google-auth>=2.28.0

View File

@ -1,2 +1,2 @@
[metadata] [metadata]
version = 1.3 version = 1.4