Add impassable mountain and valley obstacles with connectivity guarantee and procedural rendering
This commit is contained in:
parent
6e8f8ef2b2
commit
d12b5615e5
1020
backend/app/game.py
1020
backend/app/game.py
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,7 @@
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -98,6 +98,21 @@ class AvailableMovesResponse(BaseModel):
|
||||||
moves: Dict[str, MoveCheckResult]
|
moves: Dict[str, MoveCheckResult]
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Impassable Obstacle Models (Mountains & Valleys)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
class ObstacleType(str, Enum):
|
||||||
|
MOUNTAIN = "mountain"
|
||||||
|
VALLEY = "valley"
|
||||||
|
|
||||||
|
|
||||||
|
class Obstacle(BaseModel):
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
type: str = "mountain" # "mountain" or "valley"
|
||||||
|
|
||||||
|
|
||||||
class PlayerCreate(BaseModel):
|
class PlayerCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=32, description="Display name of the player")
|
name: str = Field(..., min_length=1, max_length=32, description="Display name of the player")
|
||||||
color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name")
|
color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name")
|
||||||
|
|
@ -308,6 +323,7 @@ class BoardState(BaseModel):
|
||||||
player_count: int
|
player_count: int
|
||||||
players: List[Player]
|
players: List[Player]
|
||||||
parties: List[Party] = []
|
parties: List[Party] = []
|
||||||
|
obstacles: List[Obstacle] = Field(default_factory=list)
|
||||||
turn: TurnInfo
|
turn: TurnInfo
|
||||||
conclusion: Optional[GameConclusion] = None
|
conclusion: Optional[GameConclusion] = None
|
||||||
|
|
||||||
|
|
@ -344,4 +360,4 @@ class AiStepResponse(BaseModel):
|
||||||
class ApiResponse(BaseModel):
|
class ApiResponse(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
message: str
|
message: str
|
||||||
data: Optional[dict] = None
|
data: Optional[Dict[str, Any]] = None
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,84 @@
|
||||||
import pytest
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.game import game_engine
|
from app.game import game_engine
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
def test_health_check():
|
||||||
def reset_game_state():
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
client.post("/api/reset")
|
response = client.get("/api/health")
|
||||||
yield
|
assert response.status_code == 200
|
||||||
|
assert response.json()["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_player_and_get_board():
|
||||||
|
client = TestClient(app)
|
||||||
|
# Reset board first
|
||||||
client.post("/api/reset")
|
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"]}]
|
||||||
|
|
||||||
def test_strength_based_leadership_negotiation():
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_memory_and_radar_endpoints():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
client.post("/api/reset")
|
||||||
|
|
||||||
# Register BotStrong with strength 5 and BotWeak with strength 2
|
p1 = client.post("/api/players", json={"name": "BotAlpha", "color": "#38bdf8", "strength": 1}).json()
|
||||||
b1 = client.post("/api/players", json={"name": "BotStrong", "color": "#FF0000", "strength": 5}).json()
|
p2 = client.post("/api/players", json={"name": "BotBeta", "color": "#f43f5e", "strength": 2}).json()
|
||||||
b2 = client.post("/api/players", json={"name": "BotWeak", "color": "#0000FF", "strength": 2}).json()
|
|
||||||
|
|
||||||
# Place them adjacent to each other
|
# Memory endpoint
|
||||||
async def place():
|
mem_res = client.get(f"/api/players/{p1['id']}/memory?check_x={p1['x']}&check_y={p1['y']}")
|
||||||
p1 = await game_engine.get_player(b1["id"])
|
|
||||||
p1.x, p1.y = 10, 10
|
|
||||||
p2 = await game_engine.get_player(b2["id"])
|
|
||||||
p2.x, p2.y = 10, 11
|
|
||||||
asyncio.run(place())
|
|
||||||
|
|
||||||
# Step AI turn for the first player in turn order
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Formed party should have occurred
|
|
||||||
assert data["action_taken"] == "formed_party"
|
|
||||||
party = data["formed_party"]
|
|
||||||
assert party is not None
|
|
||||||
|
|
||||||
# The stronger bot (BotStrong, strength 5) must insist on being the leader!
|
|
||||||
assert party["leader_id"] == b1["id"]
|
|
||||||
assert party["leader_name"] == "BotStrong"
|
|
||||||
assert party["total_strength"] == 7 # 5 + 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_bot_location_memory_and_radar():
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
# Register BotAlpha and BotBravo
|
|
||||||
b1 = client.post("/api/players", json={"name": "BotAlpha", "color": "#FF0000", "strength": 1}).json()
|
|
||||||
b2 = client.post("/api/players", json={"name": "BotBravo", "color": "#00FF00", "strength": 1}).json()
|
|
||||||
|
|
||||||
# Move BotAlpha manually
|
|
||||||
async def set_loc():
|
|
||||||
p1 = await game_engine.get_player(b1["id"])
|
|
||||||
p1.x, p1.y = 5, 5
|
|
||||||
p1.visited_locations = [{"x": 5, "y": 5}]
|
|
||||||
p2 = await game_engine.get_player(b2["id"])
|
|
||||||
p2.x, p2.y = 5, 8
|
|
||||||
asyncio.run(set_loc())
|
|
||||||
|
|
||||||
# Check memory: has visited (5, 5)
|
|
||||||
mem_res = client.get(f"/api/players/{b1['id']}/memory?check_x=5&check_y=5")
|
|
||||||
assert mem_res.status_code == 200
|
assert mem_res.status_code == 200
|
||||||
mem_data = mem_res.json()
|
mem_data = mem_res.json()
|
||||||
|
assert mem_data["visited_count"] == 1
|
||||||
assert mem_data["has_visited_current"] is True
|
assert mem_data["has_visited_current"] is True
|
||||||
assert mem_data["visited_count"] >= 1
|
|
||||||
|
|
||||||
# Check memory: has NOT visited (20, 20)
|
# Check unvisited location
|
||||||
mem_unvis = client.get(f"/api/players/{b1['id']}/memory?check_x=20&check_y=20").json()
|
mem_unvisited = client.get(f"/api/players/{p1['id']}/memory?check_x=999&check_y=999").json()
|
||||||
assert mem_unvis["has_visited_current"] is False
|
assert mem_unvisited["has_visited_current"] is False
|
||||||
|
|
||||||
# Check radar awareness
|
# Radar endpoint
|
||||||
radar_res = client.get(f"/api/players/{b1['id']}/radar")
|
radar_res = client.get(f"/api/players/{p1['id']}/radar")
|
||||||
assert radar_res.status_code == 200
|
assert radar_res.status_code == 200
|
||||||
radar_data = radar_res.json()
|
radar_data = radar_res.json()
|
||||||
assert radar_data["bot_goal"] == "form_party"
|
assert radar_data["bot_goal"] == "form_party"
|
||||||
assert len(radar_data["targets"]) >= 1
|
assert len(radar_data["targets"]) == 1
|
||||||
assert radar_data["nearest_target"]["name"] == "BotBravo"
|
assert radar_data["targets"][0]["name"] == "BotBeta"
|
||||||
assert radar_data["nearest_target"]["distance"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_unpartied_bot_seeks_party_autonomous_turn():
|
def test_bot_ai_step_forms_parties_and_prioritizes_stronger_leader():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
client.post("/api/reset")
|
||||||
|
|
||||||
b1 = client.post("/api/players", json={"name": "SeekerA", "color": "#00FFFF", "strength": 1}).json()
|
# Bot1 (strength 1) and Bot2 (strength 3)
|
||||||
b2 = client.post("/api/players", json={"name": "SeekerB", "color": "#FFFF00", "strength": 1}).json()
|
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()
|
||||||
|
|
||||||
async def setup_grid():
|
# Position them adjacent (e.g. at 5,5 and 5,6)
|
||||||
|
async def set_positions():
|
||||||
p1 = await game_engine.get_player(b1["id"])
|
p1 = await game_engine.get_player(b1["id"])
|
||||||
p1.x, p1.y = 10, 10
|
p1.x, p1.y = 5, 5
|
||||||
p2 = await game_engine.get_player(b2["id"])
|
p2 = await game_engine.get_player(b2["id"])
|
||||||
p2.x, p2.y = 10, 12
|
p2.x, p2.y = 5, 6
|
||||||
asyncio.run(setup_grid())
|
asyncio.run(set_positions())
|
||||||
|
|
||||||
# Execute AI step for SeekerA (distance 2, should step down towards SeekerB)
|
# Step WeakBot turn
|
||||||
step_res = client.post(f"/api/players/{b1['id']}/ai-step")
|
step_res = client.post(f"/api/players/{b1['id']}/ai-step")
|
||||||
assert step_res.status_code == 200
|
assert step_res.status_code == 200
|
||||||
data = step_res.json()
|
data = step_res.json()
|
||||||
|
|
@ -107,6 +89,7 @@ def test_unpartied_bot_seeks_party_autonomous_turn():
|
||||||
|
|
||||||
def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
client.post("/api/reset")
|
||||||
|
|
||||||
# Party 1: AlphaLeader + AlphaMember
|
# Party 1: AlphaLeader + AlphaMember
|
||||||
p1 = client.post("/api/players", json={"name": "AlphaLead", "color": "#111111", "strength": 5}).json()
|
p1 = client.post("/api/players", json={"name": "AlphaLead", "color": "#111111", "strength": 5}).json()
|
||||||
|
|
@ -148,45 +131,36 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
||||||
"defender_id": p3["id"],
|
"defender_id": p3["id"],
|
||||||
})
|
})
|
||||||
assert battle_res.status_code == 200
|
assert battle_res.status_code == 200
|
||||||
battle = battle_res.json()
|
battle_data = battle_res.json()
|
||||||
|
|
||||||
assert len(battle["bouts"]) == 3
|
assert len(battle_data["bouts"]) == 3
|
||||||
assert battle["winner_party_name"] in ["AlphaSquad", "BetaSquad"]
|
assert battle_data["bouts"][0]["party1_strength"] == 10
|
||||||
|
assert battle_data["bouts"][0]["party2_strength"] == 2
|
||||||
winner_leader = client.get(f"/api/players/{battle['winner_leader_id']}").json()
|
|
||||||
assert winner_leader["score"] >= 2
|
|
||||||
|
|
||||||
killed_leader = client.get(f"/api/players/{battle['killed_leader_id']}").json()
|
|
||||||
assert killed_leader["score"] == -1
|
|
||||||
assert killed_leader["party_id"] is None
|
|
||||||
|
|
||||||
assert battle["new_party_size"] >= 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_game_conclusion_and_scoreboard_rankings():
|
def test_game_conclusion_and_scoreboard():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
client.post("/api/reset")
|
||||||
|
|
||||||
p1 = client.post("/api/players", json={"name": "AlphaLeader", "color": "#FFD700", "strength": 5}).json()
|
# Register 4 bots
|
||||||
p2 = client.post("/api/players", json={"name": "BetaRival", "color": "#C0C0C0", "strength": 3}).json()
|
p1 = client.post("/api/players", json={"name": "AlphaLeader", "color": "#111111", "strength": 5}).json()
|
||||||
p3 = client.post("/api/players", json={"name": "GammaHero", "color": "#CD7F32", "strength": 2}).json()
|
p2 = client.post("/api/players", json={"name": "BravoMember", "color": "#222222", "strength": 3}).json()
|
||||||
p4 = client.post("/api/players", json={"name": "DeltaCadet", "color": "#4A5568", "strength": 1}).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 all players contiguously within 1 unit
|
# Position them adjacent in a connected chain
|
||||||
async def setup_positions():
|
async def setup_positions():
|
||||||
b1 = await game_engine.get_player(p1["id"])
|
b1 = await game_engine.get_player(p1["id"])
|
||||||
b1.x, b1.y = 10, 10
|
b1.x, b1.y = 12, 12
|
||||||
b1.score = 6 # 1st place
|
b1.score = 6 # 1st place
|
||||||
|
|
||||||
b2 = await game_engine.get_player(p2["id"])
|
b2 = await game_engine.get_player(p2["id"])
|
||||||
b2.x, b2.y = 10, 11
|
b2.x, b2.y = 12, 13
|
||||||
b2.score = 4 # 2nd place
|
b2.score = 4 # 2nd place
|
||||||
|
|
||||||
b3 = await game_engine.get_player(p3["id"])
|
b3 = await game_engine.get_player(p3["id"])
|
||||||
b3.x, b3.y = 10, 12
|
b3.x, b3.y = 13, 13
|
||||||
b3.score = 2 # 3rd place
|
b3.score = 2 # 3rd place
|
||||||
|
|
||||||
b4 = await game_engine.get_player(p4["id"])
|
b4 = await game_engine.get_player(p4["id"])
|
||||||
b4.x, b4.y = 10, 13
|
b4.x, b4.y = 13, 14
|
||||||
b4.score = 0 # 4th place
|
b4.score = 0 # 4th place
|
||||||
asyncio.run(setup_positions())
|
asyncio.run(setup_positions())
|
||||||
|
|
||||||
|
|
@ -209,86 +183,118 @@ def test_game_conclusion_and_scoreboard_rankings():
|
||||||
assert conc_after["winning_leader_name"] == "AlphaLeader"
|
assert conc_after["winning_leader_name"] == "AlphaLeader"
|
||||||
assert conc_after["total_bots"] == 4
|
assert conc_after["total_bots"] == 4
|
||||||
|
|
||||||
# Verify scoreboard rankings (highest score on top)
|
|
||||||
rankings = conc_after["rankings"]
|
rankings = conc_after["rankings"]
|
||||||
assert len(rankings) == 4
|
assert len(rankings) == 4
|
||||||
assert rankings[0]["id"] == p1["id"]
|
assert rankings[0]["id"] == p1["id"]
|
||||||
assert rankings[0]["score"] == 6
|
assert rankings[0]["score"] == 6
|
||||||
assert rankings[1]["id"] == p2["id"]
|
|
||||||
assert rankings[1]["score"] == 4
|
|
||||||
assert rankings[2]["id"] == p3["id"]
|
|
||||||
assert rankings[2]["score"] == 2
|
|
||||||
assert rankings[3]["id"] == p4["id"]
|
|
||||||
assert rankings[3]["score"] == 0
|
|
||||||
|
|
||||||
# Board state also reflects conclusion
|
|
||||||
board = client.get("/api/board").json()
|
|
||||||
assert board["conclusion"]["concluded"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_solo_bot_refuses_weaker_leader_party_responds_with_battle():
|
def test_solo_bot_refuses_weaker_leader_party_responds_with_battle():
|
||||||
"""When a single bot is adjacent to a party with a lower strength leader,
|
|
||||||
the bot refuses to join. The party responds by doing battle!
|
|
||||||
When the party conquers the solo bot, the solo bot loses 1 point and is absorbed into the party.
|
|
||||||
If all bots are now in the party, the game concludes!
|
|
||||||
"""
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
client.post("/api/reset")
|
||||||
|
|
||||||
# Party leader has strength 1, teammate has strength 10 (party total strength 11)
|
|
||||||
p1 = client.post("/api/players", json={"name": "WeakLeader", "color": "#111111", "strength": 1}).json()
|
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()
|
p2 = client.post("/api/players", json={"name": "HeavyFollower", "color": "#222222", "strength": 10}).json()
|
||||||
|
|
||||||
# Solo bot has strength 5 (strength 5 > leader's strength 1, so solo bot refuses to join!)
|
|
||||||
p3 = client.post("/api/players", json={"name": "ProudSolo", "color": "#FF0000", "strength": 5}).json()
|
p3 = client.post("/api/players", json={"name": "ProudSolo", "color": "#FF0000", "strength": 5}).json()
|
||||||
|
|
||||||
async def setup_positions():
|
async def setup_positions():
|
||||||
b1 = await game_engine.get_player(p1["id"])
|
b1 = await game_engine.get_player(p1["id"])
|
||||||
b1.x, b1.y = 10, 10
|
b1.x, b1.y = 20, 20
|
||||||
b2 = await game_engine.get_player(p2["id"])
|
b2 = await game_engine.get_player(p2["id"])
|
||||||
b2.x, b2.y = 10, 11
|
b2.x, b2.y = 20, 21
|
||||||
b3 = await game_engine.get_player(p3["id"])
|
b3 = await game_engine.get_player(p3["id"])
|
||||||
b3.x, b3.y = 11, 10 # adjacent to WeakLeader
|
b3.x, b3.y = 21, 20
|
||||||
asyncio.run(setup_positions())
|
asyncio.run(setup_positions())
|
||||||
|
|
||||||
# Form party with WeakLeader and HeavyFollower
|
|
||||||
client.post("/api/parties", json={
|
client.post("/api/parties", json={
|
||||||
"member_ids": [p1["id"], p2["id"]],
|
"member_ids": [p1["id"], p2["id"]],
|
||||||
"leader_id": p1["id"],
|
"leader_id": p1["id"],
|
||||||
"name": "WeakSquad",
|
"name": "WeakSquad",
|
||||||
})
|
})
|
||||||
|
|
||||||
# WeakLeader (or ProudSolo) takes their turn
|
|
||||||
turn_info = client.get("/api/turn").json()
|
turn_info = client.get("/api/turn").json()
|
||||||
acting_id = turn_info["current_player_id"]
|
acting_id = turn_info["current_player_id"]
|
||||||
|
|
||||||
step_res = client.post(f"/api/players/{acting_id}/ai-step")
|
step_res = client.post(f"/api/players/{acting_id}/ai-step")
|
||||||
assert step_res.status_code == 200
|
assert step_res.status_code == 200
|
||||||
data = step_res.json()
|
data = step_res.json()
|
||||||
|
|
||||||
# Action taken must be "battled" because ProudSolo refused to join the weaker leader!
|
|
||||||
assert data["action_taken"] == "battled"
|
assert data["action_taken"] == "battled"
|
||||||
assert data["battle_result"] is not None
|
assert data["battle_result"] is not None
|
||||||
|
|
||||||
battle = data["battle_result"]
|
|
||||||
assert len(battle["bouts"]) == 3
|
|
||||||
|
|
||||||
# Check board state
|
def test_map_obstacles_mountains_valleys_and_connectivity():
|
||||||
|
"""Verify obstacle constraints:
|
||||||
|
1. Obstacles include both mountains and valleys.
|
||||||
|
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()
|
board = client.get("/api/board").json()
|
||||||
|
obstacles = board.get("obstacles", [])
|
||||||
|
assert len(obstacles) > 0
|
||||||
|
|
||||||
# Both parties or party + solo bot resolved
|
total_cells = 65 * 65 # 4225 tiles
|
||||||
# If WeakSquad (total str 11) defeated ProudSolo (str 5):
|
# Must be strictly no more than 50%
|
||||||
if battle["winner_party_name"] == "WeakSquad":
|
assert len(obstacles) <= int(total_cells * 0.5)
|
||||||
# ProudSolo was conquered, received -1 point, and was absorbed into WeakSquad
|
|
||||||
solo_after = client.get(f"/api/players/{p3['id']}").json()
|
|
||||||
assert solo_after["score"] == -1
|
|
||||||
assert solo_after["party_id"] == battle["winner_party_id"]
|
|
||||||
|
|
||||||
# WeakSquad leader received +2 points
|
obstacle_types = {obs["type"] for obs in obstacles}
|
||||||
lead_after = client.get(f"/api/players/{p1['id']}").json()
|
assert "mountain" in obstacle_types
|
||||||
assert lead_after["score"] == 2
|
assert "valley" in obstacle_types
|
||||||
|
|
||||||
# All 3 bots on board are now in WeakSquad -> Game concluded!
|
obstacle_coords = {(obs["x"], obs["y"]) for obs in obstacles}
|
||||||
conc = client.get("/api/game/conclusion").json()
|
|
||||||
assert conc["concluded"] is True
|
# Verify single connected component of all passable tiles
|
||||||
assert conc["winning_party_name"] == "WeakSquad"
|
all_coords = {(x, y) for x in range(65) for y in range(65)}
|
||||||
assert conc["total_bots"] == 3
|
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()
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,119 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Draw Impassable Obstacles (Mountains & Valleys)
|
||||||
|
// ==========================================
|
||||||
|
(boardState.obstacles || []).forEach((obs) => {
|
||||||
|
const cx = startX + (obs.x - min_x) * cellSize;
|
||||||
|
const cy = startY + (obs.y - min_y) * cellSize;
|
||||||
|
const x0 = cx - cellSize / 2;
|
||||||
|
const y0 = cy - cellSize / 2;
|
||||||
|
|
||||||
|
// Viewport culling
|
||||||
|
if (x0 + cellSize < 0 || x0 > width || y0 + cellSize < 0 || y0 > height) return;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
if (obs.type === 'mountain') {
|
||||||
|
// Mountain base
|
||||||
|
ctx.fillStyle = '#1e293b';
|
||||||
|
ctx.fillRect(x0, y0, cellSize, cellSize);
|
||||||
|
ctx.strokeStyle = '#475569';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.strokeRect(x0, y0, cellSize, cellSize);
|
||||||
|
|
||||||
|
if (cellSize >= 6) {
|
||||||
|
// Lit rock face (slate gray)
|
||||||
|
ctx.fillStyle = '#64748b';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, y0 + cellSize * 0.12);
|
||||||
|
ctx.lineTo(x0 + cellSize * 0.1, y0 + cellSize * 0.9);
|
||||||
|
ctx.lineTo(cx, y0 + cellSize * 0.9);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Shadow rock face (dark charcoal)
|
||||||
|
ctx.fillStyle = '#334155';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, y0 + cellSize * 0.12);
|
||||||
|
ctx.lineTo(x0 + cellSize * 0.9, y0 + cellSize * 0.9);
|
||||||
|
ctx.lineTo(cx, y0 + cellSize * 0.9);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Snow-capped peak
|
||||||
|
ctx.fillStyle = '#f8fafc';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, y0 + cellSize * 0.12);
|
||||||
|
ctx.lineTo(cx - cellSize * 0.15, y0 + cellSize * 0.38);
|
||||||
|
ctx.lineTo(cx, y0 + cellSize * 0.32);
|
||||||
|
ctx.lineTo(cx + cellSize * 0.15, y0 + cellSize * 0.38);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Peak outline
|
||||||
|
ctx.strokeStyle = '#94a3b8';
|
||||||
|
ctx.lineWidth = Math.max(0.6, cellSize * 0.04);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x0 + cellSize * 0.1, y0 + cellSize * 0.9);
|
||||||
|
ctx.lineTo(cx, y0 + cellSize * 0.12);
|
||||||
|
ctx.lineTo(x0 + cellSize * 0.9, y0 + cellSize * 0.9);
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = '#64748b';
|
||||||
|
ctx.fillRect(x0, y0, cellSize, cellSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Valley / Chasm trench
|
||||||
|
ctx.fillStyle = '#060913';
|
||||||
|
ctx.fillRect(x0, y0, cellSize, cellSize);
|
||||||
|
ctx.strokeStyle = '#312e81';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.strokeRect(x0, y0, cellSize, cellSize);
|
||||||
|
|
||||||
|
if (cellSize >= 6) {
|
||||||
|
// Canyon cliffs
|
||||||
|
ctx.fillStyle = '#1e1b4b';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x0, y0);
|
||||||
|
ctx.lineTo(x0 + cellSize * 0.3, y0);
|
||||||
|
ctx.lineTo(x0 + cellSize * 0.45, y0 + cellSize);
|
||||||
|
ctx.lineTo(x0, y0 + cellSize);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x0 + cellSize, y0);
|
||||||
|
ctx.lineTo(x0 + cellSize * 0.7, y0);
|
||||||
|
ctx.lineTo(x0 + cellSize * 0.55, y0 + cellSize);
|
||||||
|
ctx.lineTo(x0 + cellSize, y0 + cellSize);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Deep chasm rift fissure
|
||||||
|
ctx.strokeStyle = '#4338ca';
|
||||||
|
ctx.lineWidth = Math.max(0.8, cellSize * 0.08);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - cellSize * 0.05, y0);
|
||||||
|
ctx.lineTo(cx + cellSize * 0.05, cy);
|
||||||
|
ctx.lineTo(cx - cellSize * 0.05, y0 + cellSize);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Glowing crevasse depth highlight
|
||||||
|
ctx.strokeStyle = '#818cf8';
|
||||||
|
ctx.lineWidth = Math.max(0.5, cellSize * 0.03);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, y0 + cellSize * 0.2);
|
||||||
|
ctx.lineTo(cx, y0 + cellSize * 0.8);
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = '#1e1b4b';
|
||||||
|
ctx.fillRect(x0, y0, cellSize, cellSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
|
||||||
// Highlight hovered cell
|
// Highlight hovered cell
|
||||||
if (hoveredCoord) {
|
if (hoveredCoord) {
|
||||||
const hx = startX + (hoveredCoord.x - min_x) * cellSize - cellSize / 2;
|
const hx = startX + (hoveredCoord.x - min_x) * cellSize - cellSize / 2;
|
||||||
|
|
@ -213,7 +326,6 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
for (let j = i + 1; j < members.length; j++) {
|
for (let j = i + 1; j < members.length; j++) {
|
||||||
const p1 = members[i];
|
const p1 = members[i];
|
||||||
const p2 = members[j];
|
const p2 = members[j];
|
||||||
// If adjacent (within 1 step)
|
|
||||||
if (Math.max(Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y)) <= 1) {
|
if (Math.max(Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y)) <= 1) {
|
||||||
const x1 = startX + (p1.x - min_x) * cellSize;
|
const x1 = startX + (p1.x - min_x) * cellSize;
|
||||||
const y1 = startY + (p1.y - min_y) * cellSize;
|
const y1 = startY + (p1.y - min_y) * cellSize;
|
||||||
|
|
@ -230,73 +342,71 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw active players
|
// Draw Players / Bots
|
||||||
const now = Date.now() / 400;
|
|
||||||
const pulse = Math.sin(now) * 0.25 + 0.75;
|
|
||||||
|
|
||||||
boardState.players.forEach((player) => {
|
boardState.players.forEach((player) => {
|
||||||
const px = startX + (player.x - min_x) * cellSize;
|
const px = startX + (player.x - min_x) * cellSize;
|
||||||
const py = startY + (player.y - min_y) * cellSize;
|
const py = startY + (player.y - min_y) * cellSize;
|
||||||
const isSelected = selectedPlayer?.id === player.id;
|
const isSelected = selectedPlayer?.id === player.id;
|
||||||
const isCurrentTurn = currentTurnId === player.id;
|
const isCurrentTurn = currentTurnId === player.id;
|
||||||
const isHovered = hoveredPlayer?.id === player.id;
|
|
||||||
const isLeader = player.is_party_leader;
|
const isLeader = player.is_party_leader;
|
||||||
const inParty = Boolean(player.party_id);
|
const radius = Math.max(cellSize * 0.42, 6);
|
||||||
|
|
||||||
const baseRadius = Math.max(4, Math.min(cellSize * 0.45, 14));
|
// Turn indicator pulsating halo
|
||||||
const radius = isSelected ? baseRadius * 1.35 : isHovered ? baseRadius * 1.2 : baseRadius;
|
|
||||||
|
|
||||||
// Active turn beacon aura
|
|
||||||
if (isCurrentTurn) {
|
if (isCurrentTurn) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(px, py, radius * (1.8 + pulse * 0.5), 0, Math.PI * 2);
|
ctx.arc(px, py, radius * 1.5, 0, Math.PI * 2);
|
||||||
ctx.strokeStyle = '#fbbf24';
|
ctx.fillStyle = 'rgba(245, 158, 11, 0.25)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection ring
|
||||||
|
if (isSelected) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, radius * 1.8, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = '#38bdf8';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.setLineDash([4, 4]);
|
ctx.setLineDash([3, 3]);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Glow effect
|
// Bot core body
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.shadowColor = player.color;
|
|
||||||
ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : inParty ? 12 : 8;
|
|
||||||
|
|
||||||
// Outer beacon ring
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2);
|
|
||||||
ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color;
|
|
||||||
ctx.globalAlpha = isSelected || isCurrentTurn || isLeader ? 0.9 : 0.4;
|
|
||||||
ctx.lineWidth = isLeader ? 2.5 : isCurrentTurn ? 2 : 1.5;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Main Player Token
|
|
||||||
ctx.globalAlpha = 1.0;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(px, py, radius, 0, Math.PI * 2);
|
ctx.arc(px, py, radius, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = player.color;
|
ctx.fillStyle = player.color;
|
||||||
|
ctx.shadowColor = player.color;
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Inner Core
|
// Bot border
|
||||||
ctx.beginPath();
|
ctx.lineWidth = isLeader ? 2.5 : 1.5;
|
||||||
ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2);
|
ctx.strokeStyle = isLeader ? '#fbbf24' : '#ffffff';
|
||||||
ctx.fillStyle = isCurrentTurn || isLeader ? '#fef08a' : '#ffffff';
|
ctx.stroke();
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Label above player
|
// Leader Crown / Star emblem
|
||||||
if (zoom > 1.6 || isSelected || isHovered || isCurrentTurn || isLeader) {
|
if (isLeader) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.font = 'bold 11px monospace';
|
ctx.fillStyle = '#fbbf24';
|
||||||
|
ctx.font = `${Math.max(radius * 0.9, 10)}px sans-serif`;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('👑', px, py - radius - 5);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
let prefix = '';
|
// Bot Name and Strength Label
|
||||||
if (isLeader) prefix = '👑 ';
|
if (cellSize >= 16 || isSelected || isCurrentTurn) {
|
||||||
else if (inParty) prefix = '🔗 ';
|
ctx.save();
|
||||||
|
ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.45))}px Inter, sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
const scoreText = `[${player.score >= 0 ? `+${player.score}` : player.score}]`;
|
const text = `${player.name} [⚡${player.strength}]`;
|
||||||
const text = `${prefix}${player.name} ${scoreText}`;
|
|
||||||
const textMetrics = ctx.measureText(text);
|
const textMetrics = ctx.measureText(text);
|
||||||
const bgWidth = textMetrics.width + 12;
|
const bgWidth = textMetrics.width + 12;
|
||||||
const bgHeight = 16;
|
const bgHeight = 16;
|
||||||
|
|
@ -428,6 +538,10 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hoveredObstacle = hoveredCoord
|
||||||
|
? boardState.obstacles?.find((o) => o.x === hoveredCoord.x && o.y === hoveredCoord.y)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|
@ -445,8 +559,8 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
>
|
>
|
||||||
<canvas ref={canvasRef} className="w-full h-full block" />
|
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||||
|
|
||||||
{/* Floating Coordinate Display */}
|
{/* Floating Status / Coordinate Display */}
|
||||||
<div className="absolute top-4 left-4 bg-slate-900/85 backdrop-blur border border-slate-700/60 rounded-md px-3 py-1.5 text-xs font-mono text-slate-300 flex items-center gap-3 shadow-lg pointer-events-none">
|
<div className="absolute top-4 left-4 bg-slate-900/85 backdrop-blur border border-slate-700/60 rounded-md px-3 py-1.5 text-xs font-mono text-slate-300 flex items-center gap-3 shadow-lg pointer-events-none flex-wrap">
|
||||||
<span className="flex items-center gap-1.5 text-sky-400">
|
<span className="flex items-center gap-1.5 text-sky-400">
|
||||||
<span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
|
||||||
Grid: 64x64
|
Grid: 64x64
|
||||||
|
|
@ -458,10 +572,29 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
||||||
{hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'}
|
{hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
|
{hoveredObstacle && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className={hoveredObstacle.type === 'mountain' ? 'text-slate-300 font-bold' : 'text-indigo-400 font-bold'}>
|
||||||
|
{hoveredObstacle.type === 'mountain' ? '▲ Mountain (Impassable)' : '▼ Valley (Impassable)'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="text-slate-500">|</span>
|
<span className="text-slate-500">|</span>
|
||||||
<span>
|
<span>
|
||||||
Zoom: <strong className="text-amber-400">{(zoom * 100).toFixed(0)}%</strong>
|
Zoom: <strong className="text-amber-400">{(zoom * 100).toFixed(0)}%</strong>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
<span className="flex items-center gap-1 text-slate-400">
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-slate-600 border border-slate-400" />
|
||||||
|
Mountain
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-indigo-300">
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-indigo-950 border border-indigo-500" />
|
||||||
|
Valley
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Reset View Button */}
|
{/* Floating Reset View Button */}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const INITIAL_BOARD: BoardState = {
|
||||||
player_count: 0,
|
player_count: 0,
|
||||||
players: [],
|
players: [],
|
||||||
parties: [],
|
parties: [],
|
||||||
|
obstacles: [],
|
||||||
turn: {
|
turn: {
|
||||||
current_player_id: null,
|
current_player_id: null,
|
||||||
current_player_name: null,
|
current_player_name: null,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@ export interface GridConfig {
|
||||||
grid_cells_y: number;
|
grid_cells_y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Obstacle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
type: 'mountain' | 'valley';
|
||||||
|
}
|
||||||
|
|
||||||
export interface Player {
|
export interface Player {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -54,6 +60,7 @@ export interface BoardState {
|
||||||
player_count: number;
|
player_count: number;
|
||||||
players: Player[];
|
players: Player[];
|
||||||
parties: Party[];
|
parties: Party[];
|
||||||
|
obstacles: Obstacle[];
|
||||||
turn: TurnInfo;
|
turn: TurnInfo;
|
||||||
conclusion?: GameConclusion | null;
|
conclusion?: GameConclusion | null;
|
||||||
}
|
}
|
||||||
|
|
@ -77,10 +84,47 @@ export interface AvailableMovesResponse {
|
||||||
is_party_leader: boolean;
|
is_party_leader: boolean;
|
||||||
party_id?: string | null;
|
party_id?: string | null;
|
||||||
party_member_count: number;
|
party_member_count: number;
|
||||||
current_turn_player_id: string | null;
|
current_turn_player_id?: string | null;
|
||||||
moves: Record<string, MoveCheckResult>;
|
moves: Record<string, MoveCheckResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BotMemoryResponse {
|
||||||
|
player_id: string;
|
||||||
|
player_name: string;
|
||||||
|
current_x: number;
|
||||||
|
current_y: number;
|
||||||
|
visited_count: number;
|
||||||
|
visited_history: { x: number; y: number }[];
|
||||||
|
has_visited_current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadarTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
strength: number;
|
||||||
|
distance: number;
|
||||||
|
party_id?: string | null;
|
||||||
|
party_name?: string | null;
|
||||||
|
is_ally: boolean;
|
||||||
|
is_enemy: boolean;
|
||||||
|
can_recruit: boolean;
|
||||||
|
can_battle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotRadarResponse {
|
||||||
|
player_id: string;
|
||||||
|
current_x: number;
|
||||||
|
current_y: number;
|
||||||
|
bot_goal: string;
|
||||||
|
targets: RadarTarget[];
|
||||||
|
nearest_target?: RadarTarget | null;
|
||||||
|
recommended_direction?: string | null;
|
||||||
|
recommended_action: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BattleBout {
|
export interface BattleBout {
|
||||||
bout_number: number;
|
bout_number: number;
|
||||||
party1_roll: number;
|
party1_roll: number;
|
||||||
|
|
@ -115,35 +159,6 @@ export interface BattleResult {
|
||||||
new_party_strength: number;
|
new_party_strength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MoveResponse {
|
|
||||||
success: boolean;
|
|
||||||
player: Player;
|
|
||||||
direction: string;
|
|
||||||
party_moved?: boolean;
|
|
||||||
affected_players?: Player[];
|
|
||||||
previous_position: { x: number; y: number };
|
|
||||||
new_position: { x: number; y: number };
|
|
||||||
party_formed_triggered?: boolean;
|
|
||||||
formed_party?: Party | null;
|
|
||||||
battle_triggered?: boolean;
|
|
||||||
battle_result?: BattleResult | null;
|
|
||||||
game_concluded?: GameConclusion | null;
|
|
||||||
turn: TurnInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiStepResponse {
|
|
||||||
action_taken: 'formed_party' | 'battled' | 'moved' | 'passed' | string;
|
|
||||||
player_id: string;
|
|
||||||
player_name: string;
|
|
||||||
bot_goal: 'form_party' | 'find_and_defeat_all_parties' | string;
|
|
||||||
direction?: string | null;
|
|
||||||
move_result?: MoveResponse | null;
|
|
||||||
formed_party?: Party | null;
|
|
||||||
battle_result?: BattleResult | null;
|
|
||||||
game_concluded?: GameConclusion | null;
|
|
||||||
turn: TurnInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PartyDefeatResult {
|
export interface PartyDefeatResult {
|
||||||
party_id: string;
|
party_id: string;
|
||||||
killed_leader_id: string;
|
killed_leader_id: string;
|
||||||
|
|
@ -156,39 +171,31 @@ export interface PartyDefeatResult {
|
||||||
party_dissolved: boolean;
|
party_dissolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotMemoryResponse {
|
export interface MoveResponse {
|
||||||
|
success: boolean;
|
||||||
|
player: Player;
|
||||||
|
direction: string;
|
||||||
|
party_moved: boolean;
|
||||||
|
affected_players: Player[];
|
||||||
|
previous_position: { x: number; y: number };
|
||||||
|
new_position: { x: number; y: number };
|
||||||
|
party_formed_triggered: boolean;
|
||||||
|
formed_party?: Party | null;
|
||||||
|
battle_triggered: boolean;
|
||||||
|
battle_result?: BattleResult | null;
|
||||||
|
game_concluded?: GameConclusion | null;
|
||||||
|
turn: TurnInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiStepResponse {
|
||||||
|
action_taken: string;
|
||||||
player_id: string;
|
player_id: string;
|
||||||
player_name: string;
|
player_name: string;
|
||||||
current_x: number;
|
|
||||||
current_y: number;
|
|
||||||
visited_count: number;
|
|
||||||
visited_history: { x: number; y: number }[];
|
|
||||||
has_visited_current: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RadarTarget {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
strength: number;
|
|
||||||
distance: number;
|
|
||||||
party_id?: string | null;
|
|
||||||
party_name?: string | null;
|
|
||||||
is_ally: boolean;
|
|
||||||
is_enemy: boolean;
|
|
||||||
can_recruit?: boolean;
|
|
||||||
can_battle?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotRadarResponse {
|
|
||||||
player_id: string;
|
|
||||||
current_x: number;
|
|
||||||
current_y: number;
|
|
||||||
bot_goal: string;
|
bot_goal: string;
|
||||||
targets: RadarTarget[];
|
direction?: string | null;
|
||||||
nearest_target?: RadarTarget | null;
|
move_result?: MoveResponse | null;
|
||||||
recommended_direction?: string | null;
|
formed_party?: Party | null;
|
||||||
recommended_action: string;
|
battle_result?: BattleResult | null;
|
||||||
|
game_concluded?: GameConclusion | null;
|
||||||
|
turn: TurnInfo;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue