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"] == "Grand 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 # 1. Challenge the wizard with super high strength (strength 50 guaranteed win) chal_res = client.post("/api/wizard/challenge", json={"player_id": p1["id"]}) 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["score_change"] == 2 assert res_data["new_score"] == 2 assert res_data["new_health"] == 10 # Wizard teleports to a new location wiz_after = client.get("/api/wizard").json() assert (wiz_after["x"], wiz_after["y"]) == (res_data["wizard_respawn_position"]["x"], res_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 is 0: bot loses 2 points (score) async def setup_zero_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 = 0 # no health to lose bot2.score = 5 asyncio.run(setup_zero_health()) # Set turn back to p2 game_engine.current_turn_index = 0 game_engine.turn_order = [p2["id"]] score_loss_res = client.post("/api/wizard/challenge", json={"player_id": p2["id"]}) assert score_loss_res.status_code == 200 score_loss_data = score_loss_res.json() assert score_loss_data["player_won"] is False assert score_loss_data["health_change"] == 0 assert score_loss_data["score_change"] == -2 assert score_loss_data["new_health"] == 0 assert score_loss_data["new_score"] == 3