364 lines
13 KiB
Python
364 lines
13 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
|
|
|
|
|
|
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()
|