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
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
@ -98,6 +98,21 @@ class AvailableMovesResponse(BaseModel):
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):
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")
@ -308,6 +323,7 @@ class BoardState(BaseModel):
player_count: int
players: List[Player]
parties: List[Party] = []
obstacles: List[Obstacle] = Field(default_factory=list)
turn: TurnInfo
conclusion: Optional[GameConclusion] = None
@ -344,4 +360,4 @@ class AiStepResponse(BaseModel):
class ApiResponse(BaseModel):
success: bool
message: str
data: Optional[dict] = None
data: Optional[Dict[str, Any]] = None

View File

@ -1,102 +1,84 @@
import pytest
import asyncio
from fastapi.testclient import TestClient
from app.main import app
from app.game import game_engine
@pytest.fixture(autouse=True)
def reset_game_state():
def test_health_check():
client = TestClient(app)
client.post("/api/reset")
yield
response = client.get("/api/health")
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")
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.post("/api/reset")
# Register BotStrong with strength 5 and BotWeak with strength 2
b1 = client.post("/api/players", json={"name": "BotStrong", "color": "#FF0000", "strength": 5}).json()
b2 = client.post("/api/players", json={"name": "BotWeak", "color": "#0000FF", "strength": 2}).json()
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()
# Place them adjacent to each other
async def place():
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")
# 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
assert mem_data["visited_count"] >= 1
# Check memory: has NOT visited (20, 20)
mem_unvis = client.get(f"/api/players/{b1['id']}/memory?check_x=20&check_y=20").json()
assert mem_unvis["has_visited_current"] is False
# 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
# Check radar awareness
radar_res = client.get(f"/api/players/{b1['id']}/radar")
# 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["nearest_target"]["name"] == "BotBravo"
assert radar_data["nearest_target"]["distance"] == 3
assert len(radar_data["targets"]) == 1
assert radar_data["targets"][0]["name"] == "BotBeta"
def test_unpartied_bot_seeks_party_autonomous_turn():
def test_bot_ai_step_forms_parties_and_prioritizes_stronger_leader():
client = TestClient(app)
client.post("/api/reset")
b1 = client.post("/api/players", json={"name": "SeekerA", "color": "#00FFFF", "strength": 1}).json()
b2 = client.post("/api/players", json={"name": "SeekerB", "color": "#FFFF00", "strength": 1}).json()
# 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()
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.x, p1.y = 10, 10
p1.x, p1.y = 5, 5
p2 = await game_engine.get_player(b2["id"])
p2.x, p2.y = 10, 12
asyncio.run(setup_grid())
p2.x, p2.y = 5, 6
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")
assert step_res.status_code == 200
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():
client = TestClient(app)
client.post("/api/reset")
# Party 1: AlphaLeader + AlphaMember
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"],
})
assert battle_res.status_code == 200
battle = battle_res.json()
battle_data = battle_res.json()
assert len(battle["bouts"]) == 3
assert battle["winner_party_name"] in ["AlphaSquad", "BetaSquad"]
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
assert len(battle_data["bouts"]) == 3
assert battle_data["bouts"][0]["party1_strength"] == 10
assert battle_data["bouts"][0]["party2_strength"] == 2
def test_game_conclusion_and_scoreboard_rankings():
def test_game_conclusion_and_scoreboard():
client = TestClient(app)
client.post("/api/reset")
p1 = client.post("/api/players", json={"name": "AlphaLeader", "color": "#FFD700", "strength": 5}).json()
p2 = client.post("/api/players", json={"name": "BetaRival", "color": "#C0C0C0", "strength": 3}).json()
p3 = client.post("/api/players", json={"name": "GammaHero", "color": "#CD7F32", "strength": 2}).json()
p4 = client.post("/api/players", json={"name": "DeltaCadet", "color": "#4A5568", "strength": 1}).json()
# 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 all players contiguously within 1 unit
# Position them adjacent in a connected chain
async def setup_positions():
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
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
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
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
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["total_bots"] == 4
# Verify scoreboard rankings (highest score on top)
rankings = conc_after["rankings"]
assert len(rankings) == 4
assert rankings[0]["id"] == p1["id"]
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():
"""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.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()
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()
async def setup_positions():
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.x, b2.y = 10, 11
b2.x, b2.y = 20, 21
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())
# Form party with WeakLeader and HeavyFollower
client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
"leader_id": p1["id"],
"name": "WeakSquad",
})
# WeakLeader (or ProudSolo) takes their turn
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()
# Action taken must be "battled" because ProudSolo refused to join the weaker leader!
assert data["action_taken"] == "battled"
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()
obstacles = board.get("obstacles", [])
assert len(obstacles) > 0
# Both parties or party + solo bot resolved
# If WeakSquad (total str 11) defeated ProudSolo (str 5):
if battle["winner_party_name"] == "WeakSquad":
# 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"]
total_cells = 65 * 65 # 4225 tiles
# Must be strictly no more than 50%
assert len(obstacles) <= int(total_cells * 0.5)
# WeakSquad leader received +2 points
lead_after = client.get(f"/api/players/{p1['id']}").json()
assert lead_after["score"] == 2
obstacle_types = {obs["type"] for obs in obstacles}
assert "mountain" in obstacle_types
assert "valley" in obstacle_types
# All 3 bots on board are now in WeakSquad -> Game concluded!
conc = client.get("/api/game/conclusion").json()
assert conc["concluded"] is True
assert conc["winning_party_name"] == "WeakSquad"
assert conc["total_bots"] == 3
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()

View File

@ -160,6 +160,119 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
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
if (hoveredCoord) {
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++) {
const p1 = members[i];
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) {
const x1 = startX + (p1.x - min_x) * cellSize;
const y1 = startY + (p1.y - min_y) * cellSize;
@ -230,73 +342,71 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
ctx.restore();
});
// Draw active players
const now = Date.now() / 400;
const pulse = Math.sin(now) * 0.25 + 0.75;
// Draw Players / Bots
boardState.players.forEach((player) => {
const px = startX + (player.x - min_x) * cellSize;
const py = startY + (player.y - min_y) * cellSize;
const isSelected = selectedPlayer?.id === player.id;
const isCurrentTurn = currentTurnId === player.id;
const isHovered = hoveredPlayer?.id === player.id;
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));
const radius = isSelected ? baseRadius * 1.35 : isHovered ? baseRadius * 1.2 : baseRadius;
// Active turn beacon aura
// Turn indicator pulsating halo
if (isCurrentTurn) {
ctx.save();
ctx.beginPath();
ctx.arc(px, py, radius * (1.8 + pulse * 0.5), 0, Math.PI * 2);
ctx.strokeStyle = '#fbbf24';
ctx.arc(px, py, radius * 1.5, 0, Math.PI * 2);
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.setLineDash([4, 4]);
ctx.setLineDash([3, 3]);
ctx.stroke();
ctx.restore();
}
// Glow effect
// Bot core body
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.arc(px, py, radius, 0, Math.PI * 2);
ctx.fillStyle = player.color;
ctx.shadowColor = player.color;
ctx.shadowBlur = 10;
ctx.fill();
// Inner Core
ctx.beginPath();
ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2);
ctx.fillStyle = isCurrentTurn || isLeader ? '#fef08a' : '#ffffff';
ctx.fill();
// Bot border
ctx.lineWidth = isLeader ? 2.5 : 1.5;
ctx.strokeStyle = isLeader ? '#fbbf24' : '#ffffff';
ctx.stroke();
ctx.restore();
// Label above player
if (zoom > 1.6 || isSelected || isHovered || isCurrentTurn || isLeader) {
// Leader Crown / Star emblem
if (isLeader) {
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.textBaseline = 'middle';
ctx.fillText('👑', px, py - radius - 5);
ctx.restore();
}
let prefix = '';
if (isLeader) prefix = '👑 ';
else if (inParty) prefix = '🔗 ';
// Bot Name and Strength Label
if (cellSize >= 16 || isSelected || isCurrentTurn) {
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 = `${prefix}${player.name} ${scoreText}`;
const text = `${player.name} [⚡${player.strength}]`;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 12;
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 (
<div
ref={containerRef}
@ -445,8 +559,8 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
>
<canvas ref={canvasRef} className="w-full h-full block" />
{/* Floating 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">
{/* 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 flex-wrap">
<span className="flex items-center gap-1.5 text-sky-400">
<span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
Grid: 64x64
@ -458,10 +572,29 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
{hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'}
</strong>
</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>
Zoom: <strong className="text-amber-400">{(zoom * 100).toFixed(0)}%</strong>
</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>
{/* Floating Reset View Button */}

View File

@ -24,6 +24,7 @@ const INITIAL_BOARD: BoardState = {
player_count: 0,
players: [],
parties: [],
obstacles: [],
turn: {
current_player_id: null,
current_player_name: null,

View File

@ -7,6 +7,12 @@ export interface GridConfig {
grid_cells_y: number;
}
export interface Obstacle {
x: number;
y: number;
type: 'mountain' | 'valley';
}
export interface Player {
id: string;
name: string;
@ -54,6 +60,7 @@ export interface BoardState {
player_count: number;
players: Player[];
parties: Party[];
obstacles: Obstacle[];
turn: TurnInfo;
conclusion?: GameConclusion | null;
}
@ -77,10 +84,47 @@ export interface AvailableMovesResponse {
is_party_leader: boolean;
party_id?: string | null;
party_member_count: number;
current_turn_player_id: string | null;
current_turn_player_id?: string | null;
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 {
bout_number: number;
party1_roll: number;
@ -115,35 +159,6 @@ export interface BattleResult {
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 {
party_id: string;
killed_leader_id: string;
@ -156,39 +171,31 @@ export interface PartyDefeatResult {
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_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;
direction?: string | null;
move_result?: MoveResponse | null;
formed_party?: Party | null;
battle_result?: BattleResult | null;
game_concluded?: GameConclusion | null;
turn: TurnInfo;
}