added and tested trolls
This commit is contained in:
parent
0bc1c972b3
commit
862db5e8c3
83
AGENTS.md
83
AGENTS.md
|
|
@ -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_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).
|
||||
- `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
|
||||
│ └── 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
|
||||
```
|
||||
|
||||
|
|
@ -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**.
|
||||
- **Scoreboard Preservation**: Dead bots **remain listed on the scores and scoreboard rankings** with their final achieved score, strength, and visited locations.
|
||||
|
||||
### 8. Game Conclusion
|
||||
- The game concludes when all surviving bots on the board are united into a **single remaining party** (or if only 1 survivor remains).
|
||||
- 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.
|
||||
### 8. Trolls (Character Type & Unique Mechanics)
|
||||
- **No Alliances**: Trolls never form or join parties. They remain lone hunters throughout the game.
|
||||
- **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 |
|
||||
|---|---|---|
|
||||
| `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/board` | Full board state (grid, obstacles, players, parties, wizard, current turn) |
|
||||
| `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}/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}/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}/pass` | Pass turn to next queued entity |
|
||||
| `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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from app.models import (
|
|||
PartyInviteResponse,
|
||||
Player,
|
||||
PlayerCreate,
|
||||
SleepResponse,
|
||||
TurnInfo,
|
||||
WizardChallengeRequest,
|
||||
WizardChallengeResult,
|
||||
|
|
@ -44,7 +45,7 @@ async def health_check():
|
|||
@router.get(
|
||||
"/game/conclusion",
|
||||
response_model=GameConclusion,
|
||||
summary="Get the game conclusion state and final rankings when 1 party remains with all bots",
|
||||
summary="Get the game conclusion state and final rankings when an end condition is met",
|
||||
tags=["System"],
|
||||
)
|
||||
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],
|
||||
"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:
|
||||
await manager.broadcast({
|
||||
"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(
|
||||
"/players/{player_id}/pass",
|
||||
response_model=TurnInfo,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from app.models import (
|
|||
Player,
|
||||
PlayerCreate,
|
||||
RadarTarget,
|
||||
SleepResponse,
|
||||
TurnInfo,
|
||||
WizardChallengeBout,
|
||||
WizardChallengeResult,
|
||||
|
|
@ -384,20 +385,35 @@ class GameEngine:
|
|||
player_id = f"player_{uuid.uuid4().hex[:8]}"
|
||||
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)
|
||||
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()
|
||||
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"
|
||||
elif "knight" in name_lower or "tank" in name_lower or "titan" in name_lower:
|
||||
piece_type = "knight"
|
||||
else:
|
||||
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:
|
||||
health = 10
|
||||
health = 10.0
|
||||
health = float(health)
|
||||
|
||||
player = Player(
|
||||
id=player_id,
|
||||
|
|
@ -410,6 +426,7 @@ class GameEngine:
|
|||
x=spawn_x,
|
||||
y=spawn_y,
|
||||
piece_type=piece_type,
|
||||
character_type=char_type,
|
||||
party_id=None,
|
||||
is_party_leader=False,
|
||||
visited_locations=[{"x": spawn_x, "y": spawn_y}],
|
||||
|
|
@ -551,6 +568,10 @@ class GameEngine:
|
|||
if not player:
|
||||
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"
|
||||
targets: List[RadarTarget] = []
|
||||
|
||||
|
|
@ -558,9 +579,28 @@ class GameEngine:
|
|||
if other.id == player.id or not other.is_alive or other.health <= 0:
|
||||
continue
|
||||
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_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:
|
||||
can_recruit = other.strength <= player.strength
|
||||
|
|
@ -598,8 +638,10 @@ class GameEngine:
|
|||
targets.sort(key=lambda t: t.distance)
|
||||
|
||||
primary_targets = []
|
||||
if bot_goal == "form_party":
|
||||
recruit_targets = [t for t in targets if t.can_recruit or not t.party_id]
|
||||
if is_troll:
|
||||
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
|
||||
else:
|
||||
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,
|
||||
distance=wiz_dist,
|
||||
strength=self.wizard.strength,
|
||||
can_challenge=(wiz_dist <= 1),
|
||||
can_challenge=(wiz_dist <= 1 and not is_troll),
|
||||
)
|
||||
|
||||
rec_dir = None
|
||||
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"
|
||||
# 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))
|
||||
|
|
@ -663,6 +705,14 @@ class GameEngine:
|
|||
rec_dir = name
|
||||
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.can_recruit:
|
||||
rec_act = "form_party"
|
||||
|
|
@ -741,6 +791,8 @@ class GameEngine:
|
|||
raise KeyError(f"Player '{mid}' not found")
|
||||
if not p.is_alive or p.health <= 0:
|
||||
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)
|
||||
|
||||
if not self._verify_party_connectivity(member_players):
|
||||
|
|
@ -788,6 +840,8 @@ class GameEngine:
|
|||
invitee = self.players.get(invitee_id)
|
||||
if not inviter or not invitee:
|
||||
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:
|
||||
raise ValueError(f"Player '{inviter.name}' is dead and cannot invite players.")
|
||||
if not invitee.is_alive or invitee.health <= 0:
|
||||
|
|
@ -832,6 +886,8 @@ class GameEngine:
|
|||
invitee = self.players.get(invite.invitee_id)
|
||||
if not inviter or not invitee:
|
||||
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:
|
||||
raise ValueError("Cannot join party with deceased player.")
|
||||
|
||||
|
|
@ -1255,6 +1311,7 @@ class GameEngine:
|
|||
defeated_party = party1
|
||||
|
||||
winner_lead = self.players.get(winner_party.leader_id)
|
||||
is_winner_troll = bool(winner_lead and winner_lead.character_type == "troll")
|
||||
if winner_lead:
|
||||
winner_lead.score += 2
|
||||
|
||||
|
|
@ -1263,6 +1320,7 @@ class GameEngine:
|
|||
self.players[mid].score += 1
|
||||
|
||||
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] = []
|
||||
dead_players: List[str] = []
|
||||
|
||||
|
|
@ -1276,9 +1334,9 @@ class GameEngine:
|
|||
p = self.players.get(mid)
|
||||
if p:
|
||||
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
|
||||
if p.health == 0 and p.is_alive:
|
||||
if p.health <= 0 and p.is_alive:
|
||||
self._check_and_apply_death(p)
|
||||
dead_players.append(mid)
|
||||
|
||||
|
|
@ -1286,7 +1344,15 @@ class GameEngine:
|
|||
killed_leader.score -= 1
|
||||
|
||||
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.is_party_leader = False
|
||||
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
|
||||
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):
|
||||
if mid != defeated_party.leader_id:
|
||||
m = self.players.get(mid)
|
||||
|
|
@ -1358,6 +1456,9 @@ class GameEngine:
|
|||
if not p2.is_alive or p2.health <= 0:
|
||||
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:
|
||||
raise ValueError("Cannot battle members of your own party")
|
||||
|
||||
|
|
@ -1369,9 +1470,10 @@ class GameEngine:
|
|||
|
||||
if not party1:
|
||||
temp_p1_id = f"party_{uuid.uuid4().hex[:8]}"
|
||||
p1_prefix = "Troll" if p1.character_type == "troll" else "Squad"
|
||||
party1 = Party(
|
||||
id=temp_p1_id,
|
||||
name=f"Squad {p1.name}",
|
||||
name=f"{p1_prefix} {p1.name}",
|
||||
leader_id=p1.id,
|
||||
leader_name=p1.name,
|
||||
member_ids=[p1.id],
|
||||
|
|
@ -1383,9 +1485,10 @@ class GameEngine:
|
|||
|
||||
if not party2:
|
||||
temp_p2_id = f"party_{uuid.uuid4().hex[:8]}"
|
||||
p2_prefix = "Troll" if p2.character_type == "troll" else "Squad"
|
||||
party2 = Party(
|
||||
id=temp_p2_id,
|
||||
name=f"Squad {p2.name}",
|
||||
name=f"{p2_prefix} {p2.name}",
|
||||
leader_id=p2.id,
|
||||
leader_name=p2.name,
|
||||
member_ids=[p2.id],
|
||||
|
|
@ -1536,6 +1639,9 @@ class GameEngine:
|
|||
if not player.is_alive or player.health <= 0:
|
||||
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()
|
||||
if not current_turn_player or current_turn_player.id != player_id:
|
||||
curr_name = current_turn_player.name if current_turn_player else "Nobody"
|
||||
|
|
@ -1568,7 +1674,7 @@ class GameEngine:
|
|||
if len(self.players) < 2:
|
||||
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)
|
||||
rankings = sorted(
|
||||
self.players.values(),
|
||||
|
|
@ -1576,43 +1682,67 @@ class GameEngine:
|
|||
reverse=True,
|
||||
)
|
||||
|
||||
# Case 1: All bots died
|
||||
if len(living_players) == 0:
|
||||
# Case 0: All bots died
|
||||
if len(living_entities) == 0:
|
||||
return GameConclusion(
|
||||
concluded=True,
|
||||
total_bots=total_bots,
|
||||
rankings=rankings,
|
||||
)
|
||||
|
||||
# Case 2: Exactly 1 living bot remains (sole survivor)
|
||||
if len(living_players) == 1:
|
||||
survivor = living_players[0]
|
||||
# End Condition 1: Exactly 1 living entity remains (player or troll)
|
||||
if len(living_entities) == 1:
|
||||
survivor = living_entities[0]
|
||||
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(
|
||||
concluded=True,
|
||||
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_name=party.leader_name if party else survivor.name,
|
||||
total_bots=total_bots,
|
||||
rankings=rankings,
|
||||
)
|
||||
|
||||
# Case 3: All living bots are united into a single remaining party
|
||||
if len(self.parties) == 1:
|
||||
only_party = next(iter(self.parties.values()))
|
||||
living_member_ids = {
|
||||
mid for mid in only_party.member_ids
|
||||
if self.players.get(mid) and self.players[mid].is_alive
|
||||
}
|
||||
living_player_ids = {p.id for p in living_players}
|
||||
if living_member_ids == living_player_ids and len(living_player_ids) >= 1:
|
||||
living_trolls = [p for p in living_entities if p.character_type == "troll"]
|
||||
living_players = [p for p in living_entities if p.character_type != "troll"]
|
||||
|
||||
# End Condition 2: Only trolls remain (all players defeated)
|
||||
# Since trolls do not band together or battle each other, the game concludes.
|
||||
if len(living_players) == 0 and len(living_trolls) > 0:
|
||||
surviving_trolls = sorted(
|
||||
living_trolls,
|
||||
key=lambda p: (p.score, p.strength, p.name),
|
||||
reverse=True,
|
||||
)
|
||||
top_troll = surviving_trolls[0]
|
||||
return GameConclusion(
|
||||
concluded=True,
|
||||
winning_party_id=only_party.id,
|
||||
winning_party_name=only_party.name,
|
||||
winning_leader_id=only_party.leader_id,
|
||||
winning_leader_name=only_party.leader_name,
|
||||
winning_party_id=None,
|
||||
winning_party_name=f"Troll {top_troll.name}",
|
||||
winning_leader_id=top_troll.id,
|
||||
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,
|
||||
rankings=rankings,
|
||||
)
|
||||
|
|
@ -1636,6 +1766,48 @@ class GameEngine:
|
|||
if not self._are_adjacent(player, other):
|
||||
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
|
||||
if not player.party_id and not other.party_id:
|
||||
leader_id = self._negotiate_party_leader_id(player, other)
|
||||
|
|
@ -1847,6 +2019,41 @@ class GameEngine:
|
|||
self._advance_turn()
|
||||
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 with self._lock:
|
||||
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})."
|
||||
)
|
||||
|
||||
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)
|
||||
bot_goal = (
|
||||
"seek_wizard"
|
||||
|
|
|
|||
|
|
@ -119,8 +119,9 @@ class PlayerCreate(BaseModel):
|
|||
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")
|
||||
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)")
|
||||
piece_type: Optional[str] = Field(default="knight", description="Board game piece class: 'knight' or 'warrior'")
|
||||
health: Optional[float] = Field(default=10.0, ge=0.1, description="Starting health points (default is 10)")
|
||||
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")
|
||||
@classmethod
|
||||
|
|
@ -149,9 +150,10 @@ class Player(BaseModel):
|
|||
y: int
|
||||
strength: float = 1.0
|
||||
score: int = 0
|
||||
health: int = 10
|
||||
max_health: int = 10
|
||||
health: float = 10.0
|
||||
max_health: float = 10.0
|
||||
piece_type: Optional[str] = "knight"
|
||||
character_type: str = "player" # "player" or "troll"
|
||||
party_id: Optional[str] = None
|
||||
is_party_leader: bool = False
|
||||
is_alive: bool = True
|
||||
|
|
@ -427,16 +429,25 @@ class MoveResponse(BaseModel):
|
|||
turn: TurnInfo
|
||||
|
||||
|
||||
class SleepResponse(BaseModel):
|
||||
success: bool = True
|
||||
player: Player
|
||||
health_gained: float = 0.1
|
||||
new_health: float
|
||||
turn: TurnInfo
|
||||
|
||||
|
||||
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_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
|
||||
move_result: Optional[MoveResponse] = None
|
||||
formed_party: Optional[Party] = None
|
||||
battle_result: Optional[BattleResult] = None
|
||||
wizard_challenge_result: Optional[WizardChallengeResult] = None
|
||||
sleep_result: Optional[SleepResponse] = None
|
||||
game_concluded: Optional[GameConclusion] = None
|
||||
turn: TurnInfo
|
||||
|
||||
|
|
|
|||
|
|
@ -854,3 +854,288 @@ def test_step_bot_ai_seeks_and_challenges_wizard_when_low_health():
|
|||
step2_data = step2_res.json()
|
||||
assert step2_data["action_taken"] == "challenged_wizard"
|
||||
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"
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
requests>=2.31.0
|
||||
|
|
@ -12,15 +12,23 @@ import { WizardPromptModal } from './components/WizardPromptModal';
|
|||
import { ScoreboardModal } from './components/ScoreboardModal';
|
||||
import type { WizardRewardChoice } from './types';
|
||||
|
||||
const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [
|
||||
{ name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight' },
|
||||
{ name: 'CrimsonWarrior', color: '#f43f5e', strength: 2, piece_type: 'warrior' },
|
||||
{ name: 'AmethystKnight', color: '#a855f7', strength: 3, piece_type: 'knight' },
|
||||
{ name: 'EmeraldWarrior', color: '#10b981', strength: 1, piece_type: 'warrior' },
|
||||
{ name: 'SolarKnight', color: '#eab308', strength: 4, piece_type: 'knight' },
|
||||
{ name: 'RosebladeWarrior', color: '#ec4899', strength: 1, piece_type: 'warrior' },
|
||||
{ name: 'FrostguardKnight', color: '#06b6d4', strength: 2, piece_type: 'knight' },
|
||||
{ name: 'TwilightWarrior', color: '#6366f1', strength: 3, piece_type: 'warrior' },
|
||||
const BOT_PRESETS: {
|
||||
name: string;
|
||||
color: string;
|
||||
strength: number;
|
||||
piece_type: 'knight' | 'warrior' | 'troll';
|
||||
character_type?: 'player' | 'troll';
|
||||
}[] = [
|
||||
{ name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight', character_type: 'player' },
|
||||
{ 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() {
|
||||
|
|
@ -46,6 +54,7 @@ export function App() {
|
|||
fightBattle,
|
||||
movePlayer,
|
||||
passTurn,
|
||||
sleepPlayer,
|
||||
challengeWizard,
|
||||
stepActiveBotTurn,
|
||||
startGame,
|
||||
|
|
@ -99,19 +108,28 @@ export function App() {
|
|||
const handleQuickSpawn = async () => {
|
||||
const existingNames = new Set(boardState.players.map((p) => 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 =
|
||||
availablePresets.length > 0
|
||||
? 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')}`,
|
||||
strength: Math.floor(Math.random() * 3) + 1,
|
||||
piece_type: randomPiece,
|
||||
character_type: (randomPiece === 'troll' ? 'troll' : 'player') as 'player' | 'troll',
|
||||
};
|
||||
|
||||
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})`);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
|
|
@ -208,6 +226,9 @@ export function App() {
|
|||
onPass={async (id) => {
|
||||
await passTurn(id);
|
||||
}}
|
||||
onSleep={async (id) => {
|
||||
await sleepPlayer(id);
|
||||
}}
|
||||
onChallengeWizard={handleOpenWizardPrompt}
|
||||
onStepBot={stepActiveBotTurn}
|
||||
isAutoPlaying={isAutoPlaying}
|
||||
|
|
@ -239,8 +260,8 @@ export function App() {
|
|||
<RegisterModal
|
||||
isOpen={isRegisterOpen}
|
||||
onClose={() => setIsRegisterOpen(false)}
|
||||
onRegister={async (name, color, pieceType, health) => {
|
||||
const player = await registerPlayer(name, color, 1, pieceType, health);
|
||||
onRegister={async (name, color, pieceType, health, characterType) => {
|
||||
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})!`);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ interface MovementControlsProps {
|
|||
availableMoves: AvailableMovesResponse | null;
|
||||
onMove: (playerId: string, direction: string) => Promise<unknown>;
|
||||
onPass: (playerId: string) => Promise<unknown>;
|
||||
onSleep?: (playerId: string) => Promise<unknown>;
|
||||
onChallengeWizard?: (playerId: string) => Promise<unknown>;
|
||||
onStepBot: () => void;
|
||||
isAutoPlaying: boolean;
|
||||
|
|
@ -20,6 +21,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
|||
availableMoves,
|
||||
onMove,
|
||||
onPass,
|
||||
onSleep,
|
||||
onChallengeWizard,
|
||||
onStepBot,
|
||||
isAutoPlaying,
|
||||
|
|
@ -34,6 +36,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
|||
: null;
|
||||
const controlledPlayer = liveSelectedPlayer || activePlayer;
|
||||
const isDead = Boolean(controlledPlayer && isPlayerDead(controlledPlayer));
|
||||
const isTroll = controlledPlayer?.character_type === 'troll';
|
||||
const isMyTurn = isStarted && !isDead && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
|
||||
|
||||
const isAdjacentToWizard = Boolean(
|
||||
|
|
@ -45,7 +48,14 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
|||
Math.abs(controlledPlayer.y - boardState.wizard.y)
|
||||
) <= 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(() => {
|
||||
if (!controlledPlayer) return;
|
||||
|
|
@ -149,12 +159,19 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
|||
e.preventDefault();
|
||||
handlePassClick();
|
||||
break;
|
||||
case 'r':
|
||||
case 'R':
|
||||
if (isTroll) {
|
||||
e.preventDefault();
|
||||
handleSleepClick();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isStarted, isMyTurn, controlledPlayer, handleDirectionClick, handlePassClick]);
|
||||
}, [isStarted, isMyTurn, controlledPlayer, isTroll, handleDirectionClick, handlePassClick, handleSleepClick]);
|
||||
|
||||
if (boardState.players.length === 0) {
|
||||
return null;
|
||||
|
|
@ -279,8 +296,31 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Challenge Wizard Button if adjacent */}
|
||||
{isAdjacentToWizard && onChallengeWizard && (
|
||||
{/* Troll Sleep Action */}
|
||||
{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
|
||||
onClick={handleChallengeWizardClick}
|
||||
disabled={!isStarted || !isMyTurn || !canInitiateWizardChallenge}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import type { BoardState, Player } from '../types';
|
||||
import { isPlayerDead } from '../utils/pixelAvatars';
|
||||
|
||||
interface PartyModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -22,7 +23,9 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
|||
|
||||
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)
|
||||
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
|
||||
const findAdjacentPairs = () => {
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
for (let j = i + 1; j < players.length; j++) {
|
||||
if (areAdjacent(players[i], players[j])) {
|
||||
return [players[i], players[j]];
|
||||
for (let i = 0; i < eligiblePlayers.length; i++) {
|
||||
for (let j = i + 1; j < eligiblePlayers.length; j++) {
|
||||
if (areAdjacent(eligiblePlayers[i], eligiblePlayers[j])) {
|
||||
return [eligiblePlayers[i], eligiblePlayers[j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +46,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
|||
|
||||
const pickStrongestLeader = (ids: string[]) => {
|
||||
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));
|
||||
return selectedBots[0]?.id || ids[0];
|
||||
};
|
||||
|
|
@ -55,7 +58,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
|||
setSelectedIds(ids);
|
||||
const chosenLeader = pickStrongestLeader(ids);
|
||||
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}`);
|
||||
setError(null);
|
||||
} else {
|
||||
|
|
@ -148,7 +151,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
|||
Select Members (At least 2):
|
||||
</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">
|
||||
{players.map((p) => {
|
||||
{eligiblePlayers.map((p) => {
|
||||
const isChecked = selectedIds.includes(p.id);
|
||||
return (
|
||||
<div
|
||||
|
|
@ -196,7 +199,7 @@ export const PartyModal: React.FC<PartyModalProps> = ({
|
|||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{selectedIds.map((id) => {
|
||||
const bot = players.find((p) => p.id === id);
|
||||
const bot = eligiblePlayers.find((p) => p.id === id);
|
||||
if (!bot) return null;
|
||||
const isLeader = leaderId === id;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -45,9 +45,11 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
|||
) <= 1
|
||||
);
|
||||
const candidateIsTurn = Boolean(candidate && isStarted && candidate.id === currentTurnId);
|
||||
const candidateIsTroll = candidate?.character_type === 'troll';
|
||||
const candidateCanChallenge = Boolean(
|
||||
candidateAdjacent &&
|
||||
candidateIsTurn &&
|
||||
!candidateIsTroll &&
|
||||
(!candidate?.party_id || candidate?.is_party_leader)
|
||||
);
|
||||
|
||||
|
|
@ -198,6 +200,7 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
|||
) : (
|
||||
players.map((player) => {
|
||||
const isDead = isPlayerDead(player);
|
||||
const isTroll = player.character_type === 'troll';
|
||||
const isSelected = selectedPlayer?.id === player.id;
|
||||
const isCurrentTurn = !isDead && currentTurnId === player.id;
|
||||
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'}`}>
|
||||
{player.name}
|
||||
</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 ? (
|
||||
<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
|
||||
|
|
@ -278,9 +286,9 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<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'}>
|
||||
{isDead ? '💀 0' : `❤️${player.health ?? 10}`}
|
||||
{isDead ? '💀 0' : `❤️${Number((player.health ?? 10).toFixed(1))}`}
|
||||
</span>
|
||||
{isDead && (
|
||||
<span className="ml-1 text-slate-500 font-sans italic">
|
||||
|
|
@ -292,12 +300,18 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
|||
• {isLeader ? 'Leader' : 'Squad'}
|
||||
</span>
|
||||
)}
|
||||
{isTroll && !isDead && (
|
||||
<span className="ml-1 text-emerald-400 truncate font-sans">
|
||||
• Solo Hunter
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 opacity-70 group-hover:opacity-100 transition-opacity">
|
||||
{!isDead &&
|
||||
!isTroll &&
|
||||
boardState.wizard &&
|
||||
Math.max(
|
||||
Math.abs(player.x - boardState.wizard.x),
|
||||
|
|
|
|||
|
|
@ -27,12 +27,22 @@ const RANDOM_NAMES = [
|
|||
'ViperWarrior',
|
||||
'SolarTemplar',
|
||||
'EmeraldWarden',
|
||||
'MountainTroll',
|
||||
'StoneBrute',
|
||||
'CaveStalker',
|
||||
'ForestTroll',
|
||||
];
|
||||
|
||||
interface RegisterModalProps {
|
||||
isOpen: boolean;
|
||||
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> = ({
|
||||
|
|
@ -59,7 +69,8 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
|||
'_' +
|
||||
Math.floor(Math.random() * 900 + 100);
|
||||
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);
|
||||
setColor(randomFaction.color);
|
||||
setPieceType(randomType);
|
||||
|
|
@ -75,7 +86,8 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
|||
try {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
await onRegister(name.trim(), color, pieceType, health);
|
||||
const characterType = pieceType === 'troll' ? 'troll' : 'player';
|
||||
await onRegister(name.trim(), color, pieceType, health, characterType);
|
||||
onClose();
|
||||
setName('');
|
||||
setHealth(10);
|
||||
|
|
@ -100,7 +112,7 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
|||
Register Board Game Piece
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -140,7 +152,7 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
|||
</div>
|
||||
<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">
|
||||
{pieceType === 'knight' ? '⚔️ Knight Piece' : '🪓 Warrior Piece'}
|
||||
{pieceType === 'knight' ? '⚔️ Knight Piece' : pieceType === 'warrior' ? '🪓 Warrior Piece' : '👹 Troll (Hunter)'}
|
||||
</span>
|
||||
{currentFaction && (
|
||||
<>
|
||||
|
|
@ -153,41 +165,57 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Piece Class Selector (Knight vs Warrior) */}
|
||||
{/* Piece Class Selector (Knight vs Warrior vs Troll) */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Board Game Piece Class
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<PixelAvatar pieceType="knight" color={color} size={28} />
|
||||
<PixelAvatar pieceType="knight" color={color} size={24} />
|
||||
<div className="text-left">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<PixelAvatar pieceType="warrior" color={color} size={28} />
|
||||
<PixelAvatar pieceType="warrior" color={color} size={24} />
|
||||
<div className="text-left">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -184,10 +184,10 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
|
|||
<div style={styles.trophyBurst}>
|
||||
<span style={styles.trophyIcon}>🏆</span>
|
||||
</div>
|
||||
<h1 style={styles.title}>VICTORY! ALL BOTS UNITED</h1>
|
||||
<h1 style={styles.title}>VICTORY! ARENA CONCLUDED</h1>
|
||||
<p style={styles.subtitle}>
|
||||
The game has concluded! Every bot has united into the supreme party{' '}
|
||||
<strong style={styles.goldText}>"{conclusion.winning_party_name || 'Dominant Squad'}"</strong>!
|
||||
The game has concluded! Champion: <strong style={styles.goldText}>{conclusion.winning_leader_name}</strong> (
|
||||
<span style={{ color: '#fbbf24' }}>"{conclusion.winning_party_name || 'Lone Survivor'}"</span>)!
|
||||
</p>
|
||||
|
||||
<div style={styles.fanfareControls}>
|
||||
|
|
@ -343,6 +343,7 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
|
|||
}
|
||||
|
||||
const isLeader = !isDead && player.id === conclusion.winning_leader_id;
|
||||
const isTroll = player.character_type === 'troll';
|
||||
|
||||
return (
|
||||
<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>
|
||||
{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>}
|
||||
</td>
|
||||
<td style={styles.tdRole}>
|
||||
{isDead ? (
|
||||
<span style={{ color: '#ef4444', fontWeight: 'bold' }}>Fallen (Deceased)</span>
|
||||
) : isTroll ? (
|
||||
<span style={{ color: '#10b981', fontWeight: 'bold' }}>Lone Troll Hunter</span>
|
||||
) : isLeader ? (
|
||||
'Supreme Leader'
|
||||
) : (
|
||||
'Squad Member'
|
||||
)}
|
||||
</td>
|
||||
<td style={styles.tdStrength}>⚡ {player.strength}</td>
|
||||
<td style={styles.tdStrength}>⚡ {Number(player.strength.toFixed(1))}</td>
|
||||
<td style={styles.tdVisited}>
|
||||
🗺️ {player.visited_locations?.length || 1}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
Party,
|
||||
PartyDefeatResult,
|
||||
Player,
|
||||
SleepResponse,
|
||||
TurnInfo,
|
||||
WizardChallengeResult,
|
||||
WizardRewardChoice,
|
||||
|
|
@ -257,6 +258,14 @@ export function useGameSocket() {
|
|||
...prev,
|
||||
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') {
|
||||
setBoardState((prev) => {
|
||||
const nextPlayers = prev.players.filter((p) => p.id !== data.player_id);
|
||||
|
|
@ -323,12 +332,13 @@ export function useGameSocket() {
|
|||
color: string,
|
||||
strength: number = 1,
|
||||
piece_type?: string,
|
||||
health: number = 10
|
||||
health: number = 10,
|
||||
character_type?: 'player' | 'troll'
|
||||
): Promise<Player> => {
|
||||
const res = await fetch('/api/players', {
|
||||
method: 'POST',
|
||||
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) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
|
|
@ -338,6 +348,21 @@ export function useGameSocket() {
|
|||
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 res = await fetch(`/api/players/${playerId}`, {
|
||||
method: 'DELETE',
|
||||
|
|
@ -501,6 +526,8 @@ export function useGameSocket() {
|
|||
? `🧙 ${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`})`
|
||||
);
|
||||
} 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) {
|
||||
setActiveBattle(data.move_result.battle_result);
|
||||
}
|
||||
|
|
@ -555,6 +582,7 @@ export function useGameSocket() {
|
|||
fightBattle,
|
||||
movePlayer,
|
||||
passTurn,
|
||||
sleepPlayer,
|
||||
challengeWizard,
|
||||
stepActiveBotTurn,
|
||||
startGame,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ export interface Player {
|
|||
health?: number;
|
||||
max_health?: number;
|
||||
is_alive?: boolean;
|
||||
piece_type?: 'knight' | 'warrior';
|
||||
piece_type?: 'knight' | 'warrior' | 'troll' | 'wizard' | 'gravestone';
|
||||
character_type?: 'player' | 'troll';
|
||||
party_id?: string | null;
|
||||
is_party_leader: boolean;
|
||||
visited_locations?: { x: number; y: number }[];
|
||||
|
|
@ -255,6 +256,14 @@ export interface MoveResponse {
|
|||
turn: TurnInfo;
|
||||
}
|
||||
|
||||
export interface SleepResponse {
|
||||
success: boolean;
|
||||
player: Player;
|
||||
health_gained: number;
|
||||
new_health: number;
|
||||
turn: TurnInfo;
|
||||
}
|
||||
|
||||
export interface AiStepResponse {
|
||||
action_taken: string;
|
||||
player_id: string;
|
||||
|
|
@ -265,6 +274,7 @@ export interface AiStepResponse {
|
|||
formed_party?: Party | null;
|
||||
battle_result?: BattleResult | null;
|
||||
wizard_challenge_result?: WizardChallengeResult | null;
|
||||
sleep_result?: SleepResponse | null;
|
||||
game_concluded?: GameConclusion | null;
|
||||
turn: TurnInfo;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
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
|
||||
function parseHex(hex: string): [number, number, number] {
|
||||
|
|
@ -169,6 +169,25 @@ const GRAVESTONE_SPRITE: string[] = [
|
|||
'..____________..', // 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
|
||||
export function isPlayerDead(player: { is_alive?: boolean; health?: number }): boolean {
|
||||
if (player.is_alive === false) return true;
|
||||
|
|
@ -212,6 +231,7 @@ export function getPlayerPieceType(player: {
|
|||
name: string;
|
||||
id?: string;
|
||||
piece_type?: string;
|
||||
character_type?: string;
|
||||
is_party_leader?: boolean;
|
||||
party_id?: string | null;
|
||||
is_alive?: boolean;
|
||||
|
|
@ -221,6 +241,10 @@ export function getPlayerPieceType(player: {
|
|||
if (isPlayerDead(player)) {
|
||||
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
|
||||
if (player.is_party_leader) {
|
||||
return 'knight';
|
||||
|
|
@ -235,6 +259,9 @@ export function getPlayerPieceType(player: {
|
|||
}
|
||||
// Check name indicators
|
||||
const lower = player.name.toLowerCase();
|
||||
if (lower.includes('troll')) {
|
||||
return 'troll';
|
||||
}
|
||||
if (
|
||||
lower.includes('knight') ||
|
||||
lower.includes('tank') ||
|
||||
|
|
@ -293,6 +320,8 @@ export function getSpriteCanvas(
|
|||
spriteMatrix = WIZARD_SPRITE;
|
||||
} else if (pieceType === 'gravestone') {
|
||||
spriteMatrix = GRAVESTONE_SPRITE;
|
||||
} else if (pieceType === 'troll') {
|
||||
spriteMatrix = TROLL_SPRITE;
|
||||
} else {
|
||||
spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE;
|
||||
}
|
||||
|
|
@ -414,10 +443,12 @@ export function drawPlayerPiece(
|
|||
ctx.textAlign = 'center';
|
||||
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
|
||||
? `🪦 ${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 bgWidth = textMetrics.width + 12;
|
||||
const bgHeight = 16;
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -0,0 +1 @@
|
|||
requests>=2.31.0
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
requests>=2.31.0
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
requests>=2.31.0
|
||||
google-auth>=2.28.0
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
[metadata]
|
||||
version = 1.3
|
||||
version = 1.4
|
||||
|
|
|
|||
Loading…
Reference in New Issue