Add impassable mountain and valley obstacles with connectivity guarantee and procedural rendering

This commit is contained in:
Isaac Johnson 2026-09-05 19:46:30 -05:00
parent 6e8f8ef2b2
commit d12b5615e5
6 changed files with 970 additions and 705 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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()

View File

@ -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 */}

View File

@ -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,

View File

@ -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;
} }