botWebWars/backend/tests/test_api.py

768 lines
29 KiB
Python
Raw Permalink Normal View History

2026-09-05 23:50:27 +00:00
import asyncio
from fastapi.testclient import TestClient
from app.main import app
from app.game import game_engine
def test_health_check():
2026-09-05 23:50:27 +00:00
client = TestClient(app)
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["success"] is True
def test_lobby_and_start_game_lifecycle():
client = TestClient(app)
client.post("/api/reset")
# 1. Before bots register: not started, no current player
board = client.get("/api/board").json()
assert board["turn"]["game_started"] is False
assert board["turn"]["current_player_id"] is None
# 2. Bots register (Lobby phase)
b1 = client.post("/api/players", json={"name": "LobbyBot1", "color": "#111111", "strength": 2}).json()
b2 = client.post("/api/players", json={"name": "LobbyBot2", "color": "#222222", "strength": 3}).json()
# Still not started, no one's turn
turn_res = client.get("/api/turn").json()
assert turn_res["game_started"] is False
assert turn_res["current_player_id"] is None
# 3. Attempting to move or take turns before start is rejected
move_res = client.post(f"/api/players/{b1['id']}/move", json={"direction": "UP"})
assert move_res.status_code == 400
assert "Game has not started yet" in move_res.json()["detail"]
step_res = client.post(f"/api/players/{b1['id']}/ai-step")
assert step_res.status_code == 400
assert "Game has not started yet" in step_res.json()["detail"]
# 4. Bots can depart freely during lobby
del_res = client.delete(f"/api/players/{b2['id']}")
assert del_res.status_code == 200
board = client.get("/api/board").json()
assert board["player_count"] == 1
# Add another bot back
b3 = client.post("/api/players", json={"name": "LobbyBot3", "color": "#333333", "strength": 4}).json()
# 5. Start Game button pressed!
start_res = client.post("/api/game/start")
assert start_res.status_code == 200
start_data = start_res.json()
assert start_data["game_started"] is True
assert start_data["current_player_id"] in [b1["id"], b3["id"]]
assert start_data["round_number"] == 1
assert start_data["turn_number"] == 1
# 6. Now bots can take their turns
acting_id = start_data["current_player_id"]
pass_res = client.post(f"/api/players/{acting_id}/pass")
assert pass_res.status_code == 200
assert pass_res.json()["turn_number"] == 2
def test_register_player_and_get_board():
client = TestClient(app)
# Reset board first
client.post("/api/reset")
payload = {"name": "TestBot", "color": "#00FFCC"}
response = client.post("/api/players", json=payload)
assert response.status_code == 201
data = response.json()
assert data["name"] == "TestBot"
assert data["color"] == "#00FFCC"
assert 0 <= data["x"] <= 64
assert 0 <= data["y"] <= 64
assert data["score"] == 0
assert data["visited_locations"] == [{"x": data["x"], "y": data["y"]}]
board_res = client.get("/api/board")
assert board_res.status_code == 200
board_data = board_res.json()
assert board_data["player_count"] == 1
assert board_data["players"][0]["name"] == "TestBot"
assert len(board_data["obstacles"]) > 0
assert board_data["turn"]["game_started"] is False
assert board_data["turn"]["current_player_id"] is None
def test_bot_memory_and_radar_endpoints():
2026-09-05 23:20:54 +00:00
client = TestClient(app)
client.post("/api/reset")
2026-09-05 23:20:54 +00:00
p1 = client.post("/api/players", json={"name": "BotAlpha", "color": "#38bdf8", "strength": 1}).json()
p2 = client.post("/api/players", json={"name": "BotBeta", "color": "#f43f5e", "strength": 2}).json()
2026-09-05 23:50:27 +00:00
# Memory endpoint
mem_res = client.get(f"/api/players/{p1['id']}/memory?check_x={p1['x']}&check_y={p1['y']}")
2026-09-05 23:50:27 +00:00
assert mem_res.status_code == 200
mem_data = mem_res.json()
assert mem_data["visited_count"] == 1
2026-09-05 23:50:27 +00:00
assert mem_data["has_visited_current"] is True
# Check unvisited location
mem_unvisited = client.get(f"/api/players/{p1['id']}/memory?check_x=999&check_y=999").json()
assert mem_unvisited["has_visited_current"] is False
2026-09-05 23:50:27 +00:00
# Radar endpoint
radar_res = client.get(f"/api/players/{p1['id']}/radar")
2026-09-05 23:50:27 +00:00
assert radar_res.status_code == 200
radar_data = radar_res.json()
assert radar_data["bot_goal"] == "form_party"
assert len(radar_data["targets"]) == 1
assert radar_data["targets"][0]["name"] == "BotBeta"
2026-09-05 23:50:27 +00:00
def test_bot_ai_step_forms_parties_and_prioritizes_stronger_leader():
2026-09-05 23:50:27 +00:00
client = TestClient(app)
client.post("/api/reset")
2026-09-05 23:50:27 +00:00
# Bot1 (strength 1) and Bot2 (strength 3)
b1 = client.post("/api/players", json={"name": "WeakBot", "color": "#111111", "strength": 1}).json()
b2 = client.post("/api/players", json={"name": "StrongBot", "color": "#222222", "strength": 3}).json()
2026-09-05 23:50:27 +00:00
# Position them adjacent (e.g. at 5,5 and 5,6)
async def set_positions():
2026-09-05 23:50:27 +00:00
p1 = await game_engine.get_player(b1["id"])
p1.x, p1.y = 5, 5
2026-09-05 23:50:27 +00:00
p2 = await game_engine.get_player(b2["id"])
p2.x, p2.y = 5, 6
asyncio.run(set_positions())
2026-09-05 23:50:27 +00:00
client.post("/api/game/start")
# Step WeakBot turn
2026-09-05 23:50:27 +00:00
step_res = client.post(f"/api/players/{b1['id']}/ai-step")
assert step_res.status_code == 200
data = step_res.json()
2026-09-05 23:20:54 +00:00
2026-09-05 23:50:27 +00:00
assert data["bot_goal"] == "form_party"
assert data["action_taken"] in ["moved", "formed_party"]
2026-09-05 23:20:54 +00:00
def test_3bout_d20_battle_with_defeated_members_joining_winner():
client = TestClient(app)
client.post("/api/reset")
2026-09-05 23:50:27 +00:00
# Party 1: AlphaLeader + AlphaMember
p1 = client.post("/api/players", json={"name": "AlphaLead", "color": "#111111", "strength": 5}).json()
p2 = client.post("/api/players", json={"name": "AlphaWing", "color": "#222222", "strength": 5}).json()
2026-09-05 23:50:27 +00:00
# Party 2: BetaLeader + BetaMember
p3 = client.post("/api/players", json={"name": "BetaLead", "color": "#333333", "strength": 1}).json()
p4 = client.post("/api/players", json={"name": "BetaWing", "color": "#444444", "strength": 1}).json()
# Position them adjacent
async def setup_combat_positions():
b1 = await game_engine.get_player(p1["id"])
2026-09-05 23:50:27 +00:00
b1.x, b1.y = 10, 10
b2 = await game_engine.get_player(p2["id"])
2026-09-05 23:50:27 +00:00
b2.x, b2.y = 10, 11
b3 = await game_engine.get_player(p3["id"])
2026-09-05 23:50:27 +00:00
b3.x, b3.y = 11, 10
b4 = await game_engine.get_player(p4["id"])
2026-09-05 23:50:27 +00:00
b4.x, b4.y = 11, 11
asyncio.run(setup_combat_positions())
client.post("/api/game/start")
2026-09-05 23:50:27 +00:00
# Form Party 1 (Strength 10)
client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
"leader_id": p1["id"],
2026-09-05 23:50:27 +00:00
"name": "AlphaSquad",
})
# Form Party 2 (Strength 2)
client.post("/api/parties", json={
"member_ids": [p3["id"], p4["id"]],
"leader_id": p3["id"],
"name": "BetaSquad",
})
# Trigger explicit 3-bout D20 battle between AlphaLeader and BetaLeader
2026-09-05 23:50:27 +00:00
battle_res = client.post("/api/battles/fight", json={
"challenger_id": p1["id"],
2026-09-05 23:50:27 +00:00
"defender_id": p3["id"],
})
2026-09-05 23:50:27 +00:00
assert battle_res.status_code == 200
battle_data = battle_res.json()
2026-09-05 23:50:27 +00:00
assert len(battle_data["bouts"]) == 3
assert battle_data["bouts"][0]["party1_strength"] == 10
assert battle_data["bouts"][0]["party2_strength"] == 2
2026-09-05 23:50:27 +00:00
2026-09-09 22:11:54 +00:00
# Verify losing party members (leader BetaLead + follower BetaWing) each lost 1-3 health points
assert "health_losses" in battle_data
assert p3["id"] in battle_data["health_losses"]
assert p4["id"] in battle_data["health_losses"]
assert 1 <= battle_data["health_losses"][p3["id"]] <= 3
assert 1 <= battle_data["health_losses"][p4["id"]] <= 3
# Check updated health of defeated members
b3_after = client.get(f"/api/players/{p3['id']}").json()
b4_after = client.get(f"/api/players/{p4['id']}").json()
assert b3_after["health"] == 10 - battle_data["health_losses"][p3["id"]]
assert b4_after["health"] == 10 - battle_data["health_losses"][p4["id"]]
def test_player_health_default_and_custom_registration():
client = TestClient(app)
client.post("/api/reset")
# Default health should be 10
p_default = client.post("/api/players", json={"name": "DefaultHPBot", "color": "#123456", "strength": 3}).json()
assert p_default["health"] == 10
assert p_default["max_health"] == 10
# Custom health (e.g. 18)
p_custom = client.post("/api/players", json={"name": "TankHPBot", "color": "#654321", "strength": 4, "health": 18}).json()
assert p_custom["health"] == 18
assert p_custom["max_health"] == 18
2026-09-05 23:50:27 +00:00
def test_game_conclusion_and_scoreboard():
client = TestClient(app)
client.post("/api/reset")
2026-09-05 21:20:39 +00:00
# Register 4 bots
p1 = client.post("/api/players", json={"name": "AlphaLeader", "color": "#111111", "strength": 5}).json()
p2 = client.post("/api/players", json={"name": "BravoMember", "color": "#222222", "strength": 3}).json()
p3 = client.post("/api/players", json={"name": "CharlieMember", "color": "#333333", "strength": 2}).json()
p4 = client.post("/api/players", json={"name": "DeltaMember", "color": "#444444", "strength": 1}).json()
2026-09-05 23:50:27 +00:00
# Position them adjacent in a connected chain
2026-09-05 23:50:27 +00:00
async def setup_positions():
b1 = await game_engine.get_player(p1["id"])
b1.x, b1.y = 12, 12
2026-09-05 23:50:27 +00:00
b1.score = 6 # 1st place
b2 = await game_engine.get_player(p2["id"])
b2.x, b2.y = 12, 13
2026-09-05 23:50:27 +00:00
b2.score = 4 # 2nd place
b3 = await game_engine.get_player(p3["id"])
b3.x, b3.y = 13, 13
2026-09-05 23:50:27 +00:00
b3.score = 2 # 3rd place
b4 = await game_engine.get_player(p4["id"])
b4.x, b4.y = 13, 14
2026-09-05 23:50:27 +00:00
b4.score = 0 # 4th place
asyncio.run(setup_positions())
client.post("/api/game/start")
2026-09-05 23:50:27 +00:00
# Before joining all into 1 party, conclusion should be false
conc_before = client.get("/api/game/conclusion").json()
assert conc_before["concluded"] is False
2026-09-05 21:20:39 +00:00
2026-09-05 23:50:27 +00:00
# Form 1 party that contains all 4 bots
party_res = client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"], p3["id"], p4["id"]],
"leader_id": p1["id"],
"name": "UnitedLegion",
})
assert party_res.status_code == 201
# Now there is just 1 party and all bots are in that party -> game concluded!
conc_after = client.get("/api/game/conclusion").json()
assert conc_after["concluded"] is True
assert conc_after["winning_party_name"] == "UnitedLegion"
assert conc_after["winning_leader_name"] == "AlphaLeader"
assert conc_after["total_bots"] == 4
rankings = conc_after["rankings"]
assert len(rankings) == 4
assert rankings[0]["id"] == p1["id"]
assert rankings[0]["score"] == 6
def test_solo_bot_refuses_weaker_leader_party_responds_with_battle():
client = TestClient(app)
client.post("/api/reset")
2026-09-05 21:20:39 +00:00
2026-09-05 23:50:27 +00:00
p1 = client.post("/api/players", json={"name": "WeakLeader", "color": "#111111", "strength": 1}).json()
p2 = client.post("/api/players", json={"name": "HeavyFollower", "color": "#222222", "strength": 10}).json()
p3 = client.post("/api/players", json={"name": "ProudSolo", "color": "#FF0000", "strength": 5}).json()
async def setup_positions():
b1 = await game_engine.get_player(p1["id"])
b1.x, b1.y = 20, 20
b2 = await game_engine.get_player(p2["id"])
b2.x, b2.y = 20, 21
2026-09-05 23:50:27 +00:00
b3 = await game_engine.get_player(p3["id"])
b3.x, b3.y = 21, 20
2026-09-05 23:50:27 +00:00
asyncio.run(setup_positions())
client.post("/api/game/start")
client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
"leader_id": p1["id"],
2026-09-05 23:50:27 +00:00
"name": "WeakSquad",
})
2026-09-05 23:50:27 +00:00
turn_info = client.get("/api/turn").json()
acting_id = turn_info["current_player_id"]
step_res = client.post(f"/api/players/{acting_id}/ai-step")
assert step_res.status_code == 200
data = step_res.json()
assert data["action_taken"] == "battled"
assert data["battle_result"] is not None
def test_map_obstacles_mountains_valleys_and_connectivity():
"""Verify obstacle constraints:
1. Obstacles include both mountains and forests.
2. Obstacles cover <= 50% of total map tiles.
3. All passable areas form a SINGLE connected component (no isolated bodies).
4. Spawned bots never spawn on an obstacle.
5. Moving into an obstacle is blocked.
"""
client = TestClient(app)
client.post("/api/reset")
2026-09-05 23:50:27 +00:00
board = client.get("/api/board").json()
obstacles = board.get("obstacles", [])
assert len(obstacles) > 0
total_cells = 65 * 65 # 4225 tiles
# Must be strictly no more than 50%
assert len(obstacles) <= int(total_cells * 0.5)
obstacle_types = {obs["type"] for obs in obstacles}
assert "mountain" in obstacle_types
assert "forest" in obstacle_types
obstacle_coords = {(obs["x"], obs["y"]) for obs in obstacles}
# Verify single connected component of all passable tiles
all_coords = {(x, y) for x in range(65) for y in range(65)}
passable = all_coords - obstacle_coords
assert len(passable) >= int(total_cells * 0.5)
start = next(iter(passable))
visited = set()
queue = [start]
visited.add(start)
while queue:
cx, cy = queue.pop(0)
for nx, ny in [(cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)]:
if 0 <= nx <= 64 and 0 <= ny <= 64:
if (nx, ny) in passable and (nx, ny) not in visited:
visited.add((nx, ny))
queue.append((nx, ny))
# All passable cells must be reachable!
assert len(visited) == len(passable)
# Verify spawning never places a bot on an obstacle
for i in range(10):
p = client.post("/api/players", json={"name": f"SpawnBot_{i}", "color": "#00FFCC"}).json()
assert (p["x"], p["y"]) not in obstacle_coords
# Verify check-move blocks movement into an obstacle
# Place a bot next to an obstacle
obs_sample = next(iter(obstacles))
ox, oy = obs_sample["x"], obs_sample["y"]
# Find a passable neighbor next to this obstacle
free_neighbor = None
dir_to_obs = None
for name, (dx, dy) in [("LEFT", (-1, 0)), ("RIGHT", (1, 0)), ("UP", (0, -1)), ("DOWN", (0, 1))]:
cand = (ox - dx, oy - dy)
if 0 <= cand[0] <= 64 and 0 <= cand[1] <= 64 and cand not in obstacle_coords:
free_neighbor = cand
dir_to_obs = name
break
if free_neighbor and dir_to_obs:
test_bot = client.post("/api/players", json={"name": "ObstacleTester", "color": "#123456"}).json()
async def place_tester():
bot = await game_engine.get_player(test_bot["id"])
bot.x, bot.y = free_neighbor[0], free_neighbor[1]
asyncio.run(place_tester())
chk = client.get(f"/api/players/{test_bot['id']}/check-move?direction={dir_to_obs}").json()
assert chk["available"] is False
assert "impassable" in chk["reason"].lower()
def test_party_movement_forms_line():
client = TestClient(app)
client.post("/api/reset")
# Clear obstacles in 15..25 x 15..25 so we have a completely open test area
for x in range(15, 26):
for y in range(15, 26):
if (x, y) in game_engine.obstacles:
del game_engine.obstacles[(x, y)]
# Spawn 3 bots
p1 = client.post("/api/players", json={"name": "LineLeader", "color": "#FF0000", "strength": 3}).json()
p2 = client.post("/api/players", json={"name": "LineBot1", "color": "#00FF00", "strength": 2}).json()
p3 = client.post("/api/players", json={"name": "LineBot2", "color": "#0000FF", "strength": 1}).json()
# Place in a horizontal row: (20, 20), (21, 20), (22, 20)
async def place_bots():
b1 = await game_engine.get_player(p1["id"])
b2 = await game_engine.get_player(p2["id"])
b3 = await game_engine.get_player(p3["id"])
b1.x, b1.y = 20, 20
b2.x, b2.y = 21, 20
b3.x, b3.y = 22, 20
asyncio.run(place_bots())
client.post("/api/game/start")
# Form party
res = client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"], p3["id"]],
"leader_id": p1["id"],
"name": "LineSquad"
})
assert res.status_code == 201
# Ensure LineLeader turn is active
game_engine.turn_order = [p1["id"], p2["id"], p3["id"]]
game_engine.current_turn_index = 0
# Step 1: Move UP (0, -1)
move1 = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"}).json()
assert move1["success"] is True
b1 = client.get(f"/api/players/{p1['id']}").json()
b2 = client.get(f"/api/players/{p2['id']}").json()
b3 = client.get(f"/api/players/{p3['id']}").json()
# Leader moved UP, B1 stepped into Leader's old spot, B2 stepped into B1's old spot
assert (b1["x"], b1["y"]) == (20, 19)
assert (b2["x"], b2["y"]) == (20, 20)
assert (b3["x"], b3["y"]) == (21, 20)
# Step 2: Set turn to leader again and Move UP (0, -1)
game_engine.current_turn_index = 0
move2 = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"}).json()
assert move2["success"] is True
b1 = client.get(f"/api/players/{p1['id']}").json()
b2 = client.get(f"/api/players/{p2['id']}").json()
b3 = client.get(f"/api/players/{p3['id']}").json()
# Now completely in a vertical line!
assert (b1["x"], b1["y"]) == (20, 18)
assert (b2["x"], b2["y"]) == (20, 19)
assert (b3["x"], b3["y"]) == (20, 20)
# Step 3: Turn RIGHT (1, 0)
game_engine.current_turn_index = 0
move3 = client.post(f"/api/players/{p1['id']}/move", json={"direction": "RIGHT"}).json()
assert move3["success"] is True
b1 = client.get(f"/api/players/{p1['id']}").json()
b2 = client.get(f"/api/players/{p2['id']}").json()
b3 = client.get(f"/api/players/{p3['id']}").json()
# Follow-the-leader around the corner
assert (b1["x"], b1["y"]) == (21, 18)
assert (b2["x"], b2["y"]) == (20, 18)
assert (b3["x"], b3["y"]) == (20, 19)
# Distances are all <= 1
assert max(abs(b1["x"] - b2["x"]), abs(b1["y"] - b2["y"])) <= 1
assert max(abs(b2["x"] - b3["x"]), abs(b2["y"] - b3["y"])) <= 1
2026-09-09 22:11:54 +00:00
def test_wizard_npc_existence_and_radar_detection():
client = TestClient(app)
client.post("/api/reset")
# 1. Check GET /api/wizard
wiz_res = client.get("/api/wizard")
assert wiz_res.status_code == 200
wiz_data = wiz_res.json()
assert wiz_data["id"] == "wizard_npc"
2026-09-10 00:46:30 +00:00
assert wiz_data["name"] == "Gary the Wizard"
2026-09-09 22:11:54 +00:00
assert wiz_data["strength"] == 3.0
assert 0 <= wiz_data["x"] <= 64
assert 0 <= wiz_data["y"] <= 64
# 2. Check BoardState includes wizard
board_res = client.get("/api/board").json()
assert board_res["wizard"] is not None
assert board_res["wizard"]["id"] == "wizard_npc"
# 3. Register a bot and check radar includes wizard target
p = client.post("/api/players", json={"name": "RadarExplorer", "color": "#123456", "strength": 4}).json()
radar_res = client.get(f"/api/players/{p['id']}/radar").json()
assert "wizard" in radar_res
assert radar_res["wizard"] is not None
assert radar_res["wizard"]["id"] == "wizard_npc"
assert radar_res["wizard"]["strength"] == 3.0
def test_wizard_challenge_mechanics_victory_and_defeat():
client = TestClient(app)
client.post("/api/reset")
p1 = client.post("/api/players", json={"name": "ChallengerBot", "color": "#38bdf8", "strength": 50}).json()
p2 = client.post("/api/players", json={"name": "WeakChallenger", "color": "#f43f5e", "strength": 1}).json()
# Move p1 adjacent to wizard
wiz = client.get("/api/wizard").json()
wx, wy = wiz["x"], wiz["y"]
async def setup_wizard_adjacent():
# Place p1 adjacent to wizard (e.g. wx+1, wy if within bounds)
adj_x = wx + 1 if wx < 64 else wx - 1
adj_y = wy
bot1 = await game_engine.get_player(p1["id"])
bot1.x = adj_x
bot1.y = adj_y
bot1.health = 10
bot1.score = 0
asyncio.run(setup_wizard_adjacent())
client.post("/api/game/start")
# Set turn to ChallengerBot
game_engine.turn_order = [p1["id"], p2["id"]]
game_engine.current_turn_index = 0
# 1a. Challenge the wizard with default/score reward (strength 50 guaranteed win)
chal_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"], "reward_choice": "score"})
2026-09-09 22:11:54 +00:00
assert chal_res.status_code == 200
res_data = chal_res.json()
assert len(res_data["bouts"]) == 3
assert res_data["player_won"] is True
assert res_data["reward_chosen"] == "score"
2026-09-09 22:11:54 +00:00
assert res_data["score_change"] == 2
assert res_data["strength_change"] == 0.0
assert res_data["health_change"] == 0
2026-09-09 22:11:54 +00:00
assert res_data["new_score"] == 2
assert res_data["new_health"] == 10
# 1b. Challenge with "strength" reward
wiz_loc = res_data["wizard_respawn_position"]
async def place_p1_for_strength_win():
b = await game_engine.get_player(p1["id"])
b.x = wiz_loc["x"] + 1 if wiz_loc["x"] < 64 else wiz_loc["x"] - 1
b.y = wiz_loc["y"]
asyncio.run(place_p1_for_strength_win())
game_engine.turn_order = [p1["id"], p2["id"]]
game_engine.current_turn_index = 0
str_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"], "reward_choice": "strength"})
assert str_res.status_code == 200
str_data = str_res.json()
assert str_data["player_won"] is True
assert str_data["reward_chosen"] == "strength"
assert str_data["strength_change"] == 2.0
assert str_data["new_strength"] == 52.0
assert str_data["score_change"] == 0
assert str_data["health_change"] == 0
# 1c. Challenge with "health" reward
wiz_loc = str_data["wizard_respawn_position"]
async def place_p1_for_health_win():
b = await game_engine.get_player(p1["id"])
b.x = wiz_loc["x"] + 1 if wiz_loc["x"] < 64 else wiz_loc["x"] - 1
b.y = wiz_loc["y"]
b.health = 8
asyncio.run(place_p1_for_health_win())
game_engine.turn_order = [p1["id"], p2["id"]]
game_engine.current_turn_index = 0
hp_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"], "reward_choice": "health"})
assert hp_res.status_code == 200
hp_data = hp_res.json()
assert hp_data["player_won"] is True
assert hp_data["reward_chosen"] == "health"
assert hp_data["health_change"] == 2
assert hp_data["new_health"] == 10
assert hp_data["score_change"] == 0
assert hp_data["strength_change"] == 0.0
2026-09-09 22:11:54 +00:00
# Wizard teleports to a new location
wiz_after = client.get("/api/wizard").json()
assert (wiz_after["x"], wiz_after["y"]) == (hp_data["wizard_respawn_position"]["x"], hp_data["wizard_respawn_position"]["y"])
2026-09-09 22:11:54 +00:00
# 2. Test defeat case: bot loses 2 health
# Set turn to WeakChallenger, bot strength 0.001 (guaranteed loss)
async def setup_weak_loss():
new_wx, new_wy = wiz_after["x"], wiz_after["y"]
adj_x = new_wx + 1 if new_wx < 64 else new_wx - 1
adj_y = new_wy
bot2 = await game_engine.get_player(p2["id"])
bot2.x = adj_x
bot2.y = adj_y
bot2.strength = 0.001
bot2.health = 10
bot2.score = 5
asyncio.run(setup_weak_loss())
game_engine.current_turn_index = 1 # p2's turn
loss_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
assert loss_res.status_code == 200
loss_data = loss_res.json()
assert loss_data["player_won"] is False
assert loss_data["health_change"] == -2
assert loss_data["new_health"] == 8
assert loss_data["new_score"] == 5
# 3. Test defeat when health reaches 0: bot dies, health becomes 0, player_died is True
async def setup_low_health():
2026-09-09 22:11:54 +00:00
cur_wiz = client.get("/api/wizard").json()
adj_x = cur_wiz["x"] + 1 if cur_wiz["x"] < 64 else cur_wiz["x"] - 1
adj_y = cur_wiz["y"]
bot2 = await game_engine.get_player(p2["id"])
bot2.x = adj_x
bot2.y = adj_y
bot2.strength = 0.001
bot2.health = 1 # 1 HP left, losing 2 HP will reduce HP to 0 and score by 1
2026-09-09 22:11:54 +00:00
bot2.score = 5
bot2.is_alive = True
asyncio.run(setup_low_health())
2026-09-09 22:11:54 +00:00
# Set turn back to p2
game_engine.current_turn_index = 0
game_engine.turn_order = [p2["id"]]
death_chal_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
assert death_chal_res.status_code == 200
death_data = death_chal_res.json()
assert death_data["player_won"] is False
assert death_data["health_change"] == -1
assert death_data["score_change"] == -1
assert death_data["new_health"] == 0
assert death_data["new_score"] == 4
assert death_data["player_died"] is True
# Verify dead bot state: health is 0, is_alive is False
bot2_dead = client.get(f"/api/players/{p2['id']}").json()
assert bot2_dead["health"] == 0
assert bot2_dead["is_alive"] is False
# Dead bot cannot challenge wizard or move (no actions)
dead_action_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]})
assert dead_action_res.status_code == 400
assert "dead" in dead_action_res.json()["detail"].lower()
def test_player_death_rule_and_scoreboard_preservation():
"""Test the rule:
When a player loses enough health points to reach 0, they are dead.
They are disconnected from any existing party and a gravestone replaces their icon on the board.
They still are listed on the scores, but they can no longer move and they no longer have a turn.
"""
client = TestClient(app)
client.post("/api/reset")
# Register 3 bots: Winner (party of 1), LoserLead (party of 2), LoserFollower (party of 2)
b1 = client.post("/api/players", json={"name": "IronChampion", "color": "#10b981", "strength": 20, "health": 10}).json()
b2 = client.post("/api/players", json={"name": "FragileLeader", "color": "#ef4444", "strength": 1, "health": 2}).json()
b3 = client.post("/api/players", json={"name": "DoomedFollower", "color": "#f59e0b", "strength": 1, "health": 1}).json()
# Place b2 and b3 in a party
async def setup_party_and_combat():
p1 = await game_engine.get_player(b1["id"])
p2 = await game_engine.get_player(b2["id"])
p3 = await game_engine.get_player(b3["id"])
p1.x, p1.y = 20, 20
p2.x, p2.y = 20, 21
p3.x, p3.y = 21, 21
asyncio.run(setup_party_and_combat())
client.post("/api/game/start")
# Form losing party with b2 (leader) and b3 (follower)
party_loser = client.post("/api/parties", json={
"member_ids": [b2["id"], b3["id"]],
"leader_id": b2["id"],
"name": "FragileSquad",
}).json()
# Form winning party with b1
party_winner = client.post("/api/parties", json={
"member_ids": [b1["id"]],
"leader_id": b1["id"],
"name": "IronSquad",
})
# If parties requires min 2 members via API, b1 battles as solo vs party directly
# Trigger battle between b1 and b2
battle_res = client.post("/api/battles/fight", json={
"challenger_id": b1["id"],
"defender_id": b2["id"],
})
assert battle_res.status_code == 200
battle_data = battle_res.json()
# Verify health loss caused DoomedFollower (starting HP 1, losing 1-3) to DIE
p3_after = client.get(f"/api/players/{b3['id']}").json()
assert p3_after["health"] == 0
assert p3_after["is_alive"] is False
assert p3_after["party_id"] is None
assert p3_after["is_party_leader"] is False
# Dead follower must NOT be absorbed into winning party
assert b3["id"] not in battle_data["absorbed_members"]
# If FragileLeader also died (starting HP 2, lost >= 2):
p2_after = client.get(f"/api/players/{b2['id']}").json()
if p2_after["health"] == 0:
assert p2_after["is_alive"] is False
assert p2_after["party_id"] is None
assert p2_after["is_party_leader"] is False
else:
# If leader survived with 1 HP, manually drop them to 0 to verify death
async def kill_leader():
lead = await game_engine.get_player(b2["id"])
lead.health = 0
game_engine._check_and_apply_death(lead)
asyncio.run(kill_leader())
p2_after = client.get(f"/api/players/{b2['id']}").json()
assert p2_after["health"] == 0
assert p2_after["is_alive"] is False
# Verify dead bots CANNOT move
move_attempt = client.post(f"/api/players/{b3['id']}/move", json={"direction": "UP"})
assert move_attempt.status_code == 400
assert "dead" in move_attempt.json()["detail"].lower()
# Verify dead bots CANNOT pass
pass_attempt = client.post(f"/api/players/{b3['id']}/pass")
assert pass_attempt.status_code == 400
assert "dead" in pass_attempt.json()["detail"].lower()
# Verify available moves returns all unavailable
moves_res = client.get(f"/api/players/{b3['id']}/available-moves").json()
for move in moves_res["moves"].values():
assert move["available"] is False
assert "dead" in move["reason"].lower()
# Verify dead bots do NOT have a turn in turn rotation
turn_data = client.get("/api/turn").json()
assert b3["id"] not in turn_data["turn_order"]
assert b2["id"] not in turn_data["turn_order"]
assert turn_data["current_player_id"] == b1["id"]
# Verify dead bots STILL ARE LISTED on the players/scores list
all_players = client.get("/api/players").json()
all_player_ids = [p["id"] for p in all_players]
assert b1["id"] in all_player_ids
assert b2["id"] in all_player_ids
assert b3["id"] in all_player_ids
# Verify game conclusion: only b1 is alive, so game is concluded!
board = client.get("/api/board").json()
assert board["conclusion"]["concluded"] is True
# All 3 bots (including dead ones) are listed in rankings with their scores!
ranking_ids = [r["id"] for r in board["conclusion"]["rankings"]]
assert len(ranking_ids) == 3
assert b1["id"] in ranking_ids
assert b2["id"] in ranking_ids
assert b3["id"] in ranking_ids
2026-09-09 22:11:54 +00:00