1414 lines
56 KiB
Python
1414 lines
56 KiB
Python
import asyncio
|
|
from fastapi.testclient import TestClient
|
|
from app.main import app
|
|
from app.game import game_engine
|
|
|
|
|
|
def test_health_check():
|
|
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():
|
|
client = TestClient(app)
|
|
client.post("/api/reset")
|
|
|
|
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()
|
|
|
|
# Memory endpoint
|
|
mem_res = client.get(f"/api/players/{p1['id']}/memory?check_x={p1['x']}&check_y={p1['y']}")
|
|
assert mem_res.status_code == 200
|
|
mem_data = mem_res.json()
|
|
assert mem_data["visited_count"] == 1
|
|
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
|
|
|
|
# Radar endpoint
|
|
radar_res = client.get(f"/api/players/{p1['id']}/radar")
|
|
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"
|
|
|
|
|
|
def test_bot_ai_step_forms_parties_and_prioritizes_stronger_leader():
|
|
client = TestClient(app)
|
|
client.post("/api/reset")
|
|
|
|
# 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()
|
|
|
|
# Position them adjacent (e.g. at 5,5 and 5,6)
|
|
async def set_positions():
|
|
p1 = await game_engine.get_player(b1["id"])
|
|
p1.x, p1.y = 5, 5
|
|
p2 = await game_engine.get_player(b2["id"])
|
|
p2.x, p2.y = 5, 6
|
|
asyncio.run(set_positions())
|
|
|
|
client.post("/api/game/start")
|
|
|
|
# Step WeakBot turn
|
|
step_res = client.post(f"/api/players/{b1['id']}/ai-step")
|
|
assert step_res.status_code == 200
|
|
data = step_res.json()
|
|
|
|
assert data["bot_goal"] == "form_party"
|
|
assert data["action_taken"] in ["moved", "formed_party"]
|
|
|
|
|
|
def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
|
client = TestClient(app)
|
|
client.post("/api/reset")
|
|
|
|
# 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()
|
|
|
|
# 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"])
|
|
b1.x, b1.y = 10, 10
|
|
b2 = await game_engine.get_player(p2["id"])
|
|
b2.x, b2.y = 10, 11
|
|
b3 = await game_engine.get_player(p3["id"])
|
|
b3.x, b3.y = 11, 10
|
|
b4 = await game_engine.get_player(p4["id"])
|
|
b4.x, b4.y = 11, 11
|
|
asyncio.run(setup_combat_positions())
|
|
|
|
client.post("/api/game/start")
|
|
|
|
# Form Party 1 (Strength 10)
|
|
client.post("/api/parties", json={
|
|
"member_ids": [p1["id"], p2["id"]],
|
|
"leader_id": p1["id"],
|
|
"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
|
|
battle_res = client.post("/api/battles/fight", json={
|
|
"challenger_id": p1["id"],
|
|
"defender_id": p3["id"],
|
|
})
|
|
assert battle_res.status_code == 200
|
|
battle_data = battle_res.json()
|
|
|
|
assert len(battle_data["bouts"]) == 3
|
|
assert battle_data["bouts"][0]["party1_strength"] == 10
|
|
assert battle_data["bouts"][0]["party2_strength"] == 2
|
|
|
|
# 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
|
|
|
|
|
|
def test_game_conclusion_and_scoreboard():
|
|
client = TestClient(app)
|
|
client.post("/api/reset")
|
|
|
|
# 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()
|
|
|
|
# Position them adjacent in a connected chain
|
|
async def setup_positions():
|
|
b1 = await game_engine.get_player(p1["id"])
|
|
b1.x, b1.y = 12, 12
|
|
b1.score = 6 # 1st place
|
|
b2 = await game_engine.get_player(p2["id"])
|
|
b2.x, b2.y = 12, 13
|
|
b2.score = 4 # 2nd place
|
|
b3 = await game_engine.get_player(p3["id"])
|
|
b3.x, b3.y = 13, 13
|
|
b3.score = 2 # 3rd place
|
|
b4 = await game_engine.get_player(p4["id"])
|
|
b4.x, b4.y = 13, 14
|
|
b4.score = 0 # 4th place
|
|
asyncio.run(setup_positions())
|
|
|
|
client.post("/api/game/start")
|
|
|
|
# Before joining all into 1 party, conclusion should be false
|
|
conc_before = client.get("/api/game/conclusion").json()
|
|
assert conc_before["concluded"] is False
|
|
|
|
# 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")
|
|
|
|
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
|
|
b3 = await game_engine.get_player(p3["id"])
|
|
b3.x, b3.y = 21, 20
|
|
asyncio.run(setup_positions())
|
|
|
|
client.post("/api/game/start")
|
|
|
|
client.post("/api/parties", json={
|
|
"member_ids": [p1["id"], p2["id"]],
|
|
"leader_id": p1["id"],
|
|
"name": "WeakSquad",
|
|
})
|
|
|
|
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")
|
|
|
|
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
|
|
|
|
|
|
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"
|
|
assert wiz_data["name"] == "Gary the Wizard"
|
|
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"})
|
|
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"
|
|
assert res_data["score_change"] == 2
|
|
assert res_data["strength_change"] == 0.0
|
|
assert res_data["health_change"] == 0
|
|
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
|
|
|
|
# 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"])
|
|
|
|
# 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():
|
|
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
|
|
bot2.score = 5
|
|
bot2.is_alive = True
|
|
asyncio.run(setup_low_health())
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
def test_radar_seek_wizard_when_health_less_than_2():
|
|
"""Test that when a player's health is less than 2, get_bot_radar recommends 'seek_wizard'
|
|
and directs the player toward Gary the Wizard NPC."""
|
|
client = TestClient(app)
|
|
client.post("/api/reset")
|
|
|
|
# Register two players
|
|
p1 = client.post("/api/players", json={"name": "HurtBot", "color": "#ef4444", "strength": 3}).json()
|
|
p2 = client.post("/api/players", json={"name": "HealthyBot", "color": "#10b981", "strength": 3}).json()
|
|
|
|
# 1. Initially both players have 10 HP (>= 2), so recommended_action should NOT be seek_wizard
|
|
radar1 = client.get(f"/api/players/{p1['id']}/radar").json()
|
|
assert radar1["recommended_action"] != "seek_wizard"
|
|
assert radar1["recommended_action"] in ("seek_partner", "form_party", "explore_unvisited")
|
|
|
|
# 2. Set p1 health to 1 (< 2)
|
|
async def set_p1_low_health():
|
|
b1 = await game_engine.get_player(p1["id"])
|
|
b1.health = 1
|
|
asyncio.run(set_p1_low_health())
|
|
|
|
# Verify radar recommended_action is now 'seek_wizard'
|
|
radar_low = client.get(f"/api/players/{p1['id']}/radar").json()
|
|
assert radar_low["recommended_action"] == "seek_wizard"
|
|
assert radar_low["recommended_direction"] is not None
|
|
assert "wizard" in radar_low
|
|
assert radar_low["wizard"]["name"] == "Gary the Wizard"
|
|
|
|
# 3. Set p1 health to 2 (>= 2)
|
|
async def set_p1_health_2():
|
|
b1 = await game_engine.get_player(p1["id"])
|
|
b1.health = 2
|
|
asyncio.run(set_p1_health_2())
|
|
|
|
# Verify radar recommended_action reverts back from 'seek_wizard'
|
|
radar_hp2 = client.get(f"/api/players/{p1['id']}/radar").json()
|
|
assert radar_hp2["recommended_action"] != "seek_wizard"
|
|
|
|
|
|
def test_step_bot_ai_seeks_and_challenges_wizard_when_low_health():
|
|
"""Test that step_bot_ai navigates towards the wizard when HP < 2,
|
|
and voluntarily challenges the wizard for healing upon arriving adjacent."""
|
|
client = TestClient(app)
|
|
client.post("/api/reset")
|
|
|
|
# Register a weak bot with 1 HP
|
|
p = client.post("/api/players", json={"name": "SickBot", "color": "#10b981", "strength": 1}).json()
|
|
wiz = client.get("/api/wizard").json()
|
|
wx, wy = wiz["x"], wiz["y"]
|
|
|
|
# Place bot 2 steps away from Gary the Wizard (ensure within bounds)
|
|
bot_x = wx + 2 if wx <= 62 else wx - 2
|
|
bot_y = wy
|
|
|
|
async def setup_low_hp_bot():
|
|
b = await game_engine.get_player(p["id"])
|
|
b.x = bot_x
|
|
b.y = bot_y
|
|
b.health = 1
|
|
b.strength = 1.0 # Weaker than Gary (3.0)
|
|
asyncio.run(setup_low_hp_bot())
|
|
|
|
client.post("/api/game/start")
|
|
game_engine.turn_order = [p["id"]]
|
|
game_engine.current_turn_index = 0
|
|
|
|
# 1. First ai-step: Should navigate closer to the wizard with bot_goal='seek_wizard'
|
|
step1_res = client.post(f"/api/players/{p['id']}/ai-step")
|
|
assert step1_res.status_code == 200
|
|
step1_data = step1_res.json()
|
|
assert step1_data["bot_goal"] == "seek_wizard"
|
|
assert step1_data["action_taken"] == "moved"
|
|
|
|
# Distance to wizard should have decreased
|
|
dist_after_move = max(abs(step1_data["move_result"]["new_position"]["x"] - wx),
|
|
abs(step1_data["move_result"]["new_position"]["y"] - wy))
|
|
assert dist_after_move <= 1
|
|
|
|
# 2. Place bot adjacent to Gary and execute ai-step: should challenge Gary for health reward!
|
|
game_engine.turn_order = [p["id"]]
|
|
game_engine.current_turn_index = 0
|
|
|
|
step2_res = client.post(f"/api/players/{p['id']}/ai-step")
|
|
assert step2_res.status_code == 200
|
|
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():
|
|
for coord in [(10, 10), (10, 11), (11, 10)]:
|
|
game_engine.obstacles.pop(coord, None)
|
|
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():
|
|
game_engine.obstacles.pop((10, 11), None)
|
|
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"
|
|
|
|
|
|
def test_party_unstuck_after_two_subsequent_passes():
|
|
"""
|
|
When a party leader passes for 2 subsequent turns, all characters in the party
|
|
are forced to move around by 1 space to get unstuck, with the leader moving away
|
|
from the nearest obstacle and followers maintaining party connectivity.
|
|
"""
|
|
client = TestClient(app)
|
|
client.post("/api/reset")
|
|
|
|
p1 = client.post("/api/players", json={"name": "LeaderBot", "color": "#3b82f6", "strength": 5, "character_type": "player"}).json()
|
|
p2 = client.post("/api/players", json={"name": "FollowerBot", "color": "#10b981", "strength": 3, "character_type": "player"}).json()
|
|
# Add a 3rd entity so game doesn't conclude when party is formed
|
|
t1 = client.post("/api/players", json={"name": "WatcherTroll", "color": "#16a34a", "strength": 2, "character_type": "troll"}).json()
|
|
|
|
# Set up positions near an obstacle
|
|
async def setup_party_near_obstacle():
|
|
# Clear obstacles in the immediate testing area
|
|
for x in range(18, 23):
|
|
for y in range(19, 24):
|
|
game_engine.obstacles.pop((x, y), None)
|
|
# Place obstacle at (20, 20)
|
|
from app.models import Obstacle
|
|
game_engine.obstacles[(20, 20)] = Obstacle(x=20, y=20, type="mountain")
|
|
# Place leader adjacent to obstacle at (20, 21)
|
|
lead = await game_engine.get_player(p1["id"])
|
|
lead.x = 20
|
|
lead.y = 21
|
|
# Place follower at (20, 22)
|
|
fol = await game_engine.get_player(p2["id"])
|
|
fol.x = 20
|
|
fol.y = 22
|
|
# Place troll far away
|
|
tr = await game_engine.get_player(t1["id"])
|
|
tr.x = 40
|
|
tr.y = 40
|
|
asyncio.run(setup_party_near_obstacle())
|
|
|
|
client.post("/api/game/start")
|
|
|
|
# Form party now that game is started and bots are adjacent
|
|
party_res = client.post("/api/parties", json={
|
|
"member_ids": [p1["id"], p2["id"]],
|
|
"leader_id": p1["id"],
|
|
"name": "SquadLeaderBot",
|
|
})
|
|
assert party_res.status_code == 201
|
|
party = party_res.json()
|
|
|
|
# Ensure it is leader's turn
|
|
game_engine.turn_order = [p1["id"], t1["id"]]
|
|
game_engine.current_turn_index = 0
|
|
|
|
# Verify initial positions
|
|
party_obj = client.get(f"/api/parties/{party['id']}").json()
|
|
assert party_obj["consecutive_passes"] == 0
|
|
|
|
# 1st Pass: consecutive_passes should become 1, no unstuck move
|
|
pass1_res = client.post(f"/api/players/{p1['id']}/pass")
|
|
assert pass1_res.status_code == 200
|
|
pass1_data = pass1_res.json()
|
|
assert pass1_data.get("unstuck_triggered") is False
|
|
|
|
party_after_pass1 = client.get(f"/api/parties/{party['id']}").json()
|
|
assert party_after_pass1["consecutive_passes"] == 1
|
|
|
|
# Leader and follower should NOT have moved yet
|
|
lead_pos1 = client.get(f"/api/players/{p1['id']}").json()
|
|
fol_pos1 = client.get(f"/api/players/{p2['id']}").json()
|
|
assert (lead_pos1["x"], lead_pos1["y"]) == (20, 21)
|
|
assert (fol_pos1["x"], fol_pos1["y"]) == (20, 22)
|
|
|
|
# Troll passes turn back to leader
|
|
client.post(f"/api/players/{t1['id']}/pass")
|
|
|
|
# 2nd Subsequent Pass: Should trigger unstuck!
|
|
pass2_res = client.post(f"/api/players/{p1['id']}/pass")
|
|
assert pass2_res.status_code == 200
|
|
pass2_data = pass2_res.json()
|
|
assert pass2_data.get("unstuck_triggered") is True
|
|
assert "unstuck" in pass2_data.get("unstuck_message", "").lower()
|
|
|
|
# Consecutive passes should be reset to 0
|
|
party_after_pass2 = client.get(f"/api/parties/{party['id']}").json()
|
|
assert party_after_pass2["consecutive_passes"] == 0
|
|
|
|
# Leader and follower MUST have moved by 1 space
|
|
lead_pos2 = client.get(f"/api/players/{p1['id']}").json()
|
|
fol_pos2 = client.get(f"/api/players/{p2['id']}").json()
|
|
|
|
# Both must move by Chebyshev distance == 1
|
|
lead_dist = max(abs(lead_pos2["x"] - 20), abs(lead_pos2["y"] - 21))
|
|
fol_dist = max(abs(fol_pos2["x"] - 20), abs(fol_pos2["y"] - 22))
|
|
assert lead_dist == 1
|
|
assert fol_dist == 1
|
|
|
|
# Leader must have moved 1 space away from the obstacle at (20, 20)
|
|
# Old dist from (20, 20) was 1 (max(0, 1) = 1)
|
|
new_obs_dist = max(abs(lead_pos2["x"] - 20), abs(lead_pos2["y"] - 20))
|
|
assert new_obs_dist >= 1
|
|
|
|
# Follower and leader must maintain connectivity (Chebyshev distance <= 1)
|
|
inter_member_dist = max(abs(lead_pos2["x"] - fol_pos2["x"]), abs(lead_pos2["y"] - fol_pos2["y"]))
|
|
assert inter_member_dist <= 1
|
|
|
|
|
|
def test_moving_resets_party_consecutive_passes():
|
|
"""Moving successfully resets consecutive_passes back to 0."""
|
|
client = TestClient(app)
|
|
client.post("/api/reset")
|
|
|
|
p1 = client.post("/api/players", json={"name": "ResetLead", "color": "#3b82f6", "strength": 5, "character_type": "player"}).json()
|
|
p2 = client.post("/api/players", json={"name": "ResetFollower", "color": "#10b981", "strength": 3, "character_type": "player"}).json()
|
|
t1 = client.post("/api/players", json={"name": "TrollWatcher", "color": "#16a34a", "strength": 2, "character_type": "troll"}).json()
|
|
|
|
# Set coordinates in open field
|
|
async def setup_open_field():
|
|
for x in range(28, 33):
|
|
for y in range(28, 33):
|
|
game_engine.obstacles.pop((x, y), None)
|
|
lead = await game_engine.get_player(p1["id"])
|
|
lead.x = 30
|
|
lead.y = 30
|
|
fol = await game_engine.get_player(p2["id"])
|
|
fol.x = 30
|
|
fol.y = 31
|
|
tr = await game_engine.get_player(t1["id"])
|
|
tr.x = 50
|
|
tr.y = 50
|
|
asyncio.run(setup_open_field())
|
|
|
|
client.post("/api/game/start")
|
|
|
|
party_res = client.post("/api/parties", json={
|
|
"member_ids": [p1["id"], p2["id"]],
|
|
"leader_id": p1["id"],
|
|
"name": "SquadResetLead",
|
|
})
|
|
assert party_res.status_code == 201
|
|
party = party_res.json()
|
|
|
|
game_engine.turn_order = [p1["id"], t1["id"]]
|
|
game_engine.current_turn_index = 0
|
|
|
|
# 1st Pass
|
|
client.post(f"/api/players/{p1['id']}/pass")
|
|
party_state = client.get(f"/api/parties/{party['id']}").json()
|
|
assert party_state["consecutive_passes"] == 1
|
|
|
|
# Troll passes
|
|
client.post(f"/api/players/{t1['id']}/pass")
|
|
|
|
# Now leader moves instead of passing
|
|
move_res = client.post(f"/api/players/{p1['id']}/move", json={"direction": "UP"})
|
|
assert move_res.status_code == 200
|
|
|
|
# consecutive_passes must be reset to 0!
|
|
party_after_move = client.get(f"/api/parties/{party['id']}").json()
|
|
assert party_after_move["consecutive_passes"] == 0
|
|
|
|
|
|
def test_party_unstuck_via_ai_step():
|
|
"""When a bot party has no available moves, 2 consecutive ai-step calls trigger unstuck."""
|
|
client = TestClient(app)
|
|
client.post("/api/reset")
|
|
|
|
p1 = client.post("/api/players", json={"name": "AiLead", "color": "#3b82f6", "strength": 5, "character_type": "player"}).json()
|
|
p2 = client.post("/api/players", json={"name": "AiFollower1", "color": "#10b981", "strength": 3, "character_type": "player"}).json()
|
|
p3 = client.post("/api/players", json={"name": "AiFollower2", "color": "#a855f7", "strength": 2, "character_type": "player"}).json()
|
|
p4 = client.post("/api/players", json={"name": "AiFollower3", "color": "#f59e0b", "strength": 2, "character_type": "player"}).json()
|
|
t1 = client.post("/api/players", json={"name": "AiTroll", "color": "#16a34a", "strength": 2, "character_type": "troll"}).json()
|
|
|
|
# Set up party of 4 in a horizontal dead-end corridor:
|
|
# Leader at (10, 10), F1 at (11, 10), F2 at (12, 10), F3 at (13, 10).
|
|
# Impassable obstacles wall in the leader on left/top/bottom and corridor edges.
|
|
async def setup_dead_end():
|
|
from app.models import Obstacle
|
|
# Clear area first
|
|
for x in range(8, 16):
|
|
for y in range(8, 13):
|
|
game_engine.obstacles.pop((x, y), None)
|
|
|
|
# Place obstacles
|
|
blocked_coords = [
|
|
(10, 11), (9, 10), (10, 9), (9, 11), (9, 9),
|
|
(11, 11), (11, 9),
|
|
(12, 11), (12, 9),
|
|
(13, 11), (13, 9),
|
|
]
|
|
for bx, by in blocked_coords:
|
|
game_engine.obstacles[(bx, by)] = Obstacle(x=bx, y=by, type="mountain")
|
|
|
|
lead = await game_engine.get_player(p1["id"])
|
|
lead.x = 10
|
|
lead.y = 10
|
|
fol1 = await game_engine.get_player(p2["id"])
|
|
fol1.x = 11
|
|
fol1.y = 10
|
|
fol2 = await game_engine.get_player(p3["id"])
|
|
fol2.x = 12
|
|
fol2.y = 10
|
|
fol3 = await game_engine.get_player(p4["id"])
|
|
fol3.x = 13
|
|
fol3.y = 10
|
|
tr = await game_engine.get_player(t1["id"])
|
|
tr.x = 50
|
|
tr.y = 50
|
|
asyncio.run(setup_dead_end())
|
|
|
|
client.post("/api/game/start")
|
|
|
|
party_res = client.post("/api/parties", json={
|
|
"member_ids": [p1["id"], p2["id"], p3["id"], p4["id"]],
|
|
"leader_id": p1["id"],
|
|
"name": "AiSquad",
|
|
})
|
|
assert party_res.status_code == 201
|
|
party = party_res.json()
|
|
|
|
game_engine.turn_order = [p1["id"], t1["id"]]
|
|
game_engine.current_turn_index = 0
|
|
|
|
# Verify Leader has 0 available moves
|
|
moves_res = client.get(f"/api/players/{p1['id']}/available-moves").json()
|
|
available_count = sum(1 for m in moves_res["moves"].values() if m["available"])
|
|
assert available_count == 0
|
|
|
|
# 1st ai-step: Should pass
|
|
step1_res = client.post(f"/api/players/{p1['id']}/ai-step")
|
|
assert step1_res.status_code == 200
|
|
step1_data = step1_res.json()
|
|
assert step1_data["action_taken"] == "passed"
|
|
|
|
party_state1 = client.get(f"/api/parties/{party['id']}").json()
|
|
assert party_state1["consecutive_passes"] == 1
|
|
|
|
# Troll passes
|
|
client.post(f"/api/players/{t1['id']}/pass")
|
|
|
|
# 2nd ai-step: Should trigger unstuck maneuver!
|
|
step2_res = client.post(f"/api/players/{p1['id']}/ai-step")
|
|
assert step2_res.status_code == 200
|
|
step2_data = step2_res.json()
|
|
assert step2_data["action_taken"] == "moved"
|
|
assert step2_data["bot_goal"] == "unstuck_from_obstacles"
|
|
assert step2_data["move_result"] is not None
|
|
|
|
# Consecutive passes reset to 0
|
|
party_state2 = client.get(f"/api/parties/{party['id']}").json()
|
|
assert party_state2["consecutive_passes"] == 0
|
|
|
|
# Verify all 4 bots moved by 1 space
|
|
lead_pos = client.get(f"/api/players/{p1['id']}").json()
|
|
fol1_pos = client.get(f"/api/players/{p2['id']}").json()
|
|
fol2_pos = client.get(f"/api/players/{p3['id']}").json()
|
|
fol3_pos = client.get(f"/api/players/{p4['id']}").json()
|
|
|
|
assert max(abs(lead_pos["x"] - 10), abs(lead_pos["y"] - 10)) == 1
|
|
assert max(abs(fol1_pos["x"] - 11), abs(fol1_pos["y"] - 10)) == 1
|
|
assert max(abs(fol2_pos["x"] - 12), abs(fol2_pos["y"] - 10)) == 1
|
|
assert max(abs(fol3_pos["x"] - 13), abs(fol3_pos["y"] - 10)) == 1
|
|
|
|
# Check connectivity maintained
|
|
assert max(abs(lead_pos["x"] - fol1_pos["x"]), abs(lead_pos["y"] - fol1_pos["y"])) <= 1
|
|
assert max(abs(fol1_pos["x"] - fol2_pos["x"]), abs(fol1_pos["y"] - fol2_pos["y"])) <= 1
|
|
assert max(abs(fol2_pos["x"] - fol3_pos["x"]), abs(fol2_pos["y"] - fol3_pos["y"])) <= 1
|
|
|
|
|
|
|