botWebWars/trollagent/troll_agent.py

355 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()