feat: add lobby phase with Start Game button and turn gating
This commit is contained in:
parent
5fb7e07103
commit
7e7173280b
|
|
@ -618,3 +618,26 @@ async def pass_turn(player_id: str):
|
||||||
async def get_turn():
|
async def get_turn():
|
||||||
board_state = await game_engine.get_board_state()
|
board_state = await game_engine.get_board_state()
|
||||||
return board_state.turn
|
return board_state.turn
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/game/start",
|
||||||
|
response_model=TurnInfo,
|
||||||
|
summary="Start the game, activating turns and permitting bot moves",
|
||||||
|
tags=["Game"],
|
||||||
|
)
|
||||||
|
async def start_game():
|
||||||
|
try:
|
||||||
|
turn_info = await game_engine.start_game()
|
||||||
|
board_state = await game_engine.get_board_state()
|
||||||
|
await manager.broadcast({
|
||||||
|
"event": "game_started",
|
||||||
|
"turn": turn_info.model_dump(),
|
||||||
|
"board": board_state.model_dump(),
|
||||||
|
})
|
||||||
|
return turn_info
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class GameEngine:
|
||||||
self.current_turn_index: int = 0
|
self.current_turn_index: int = 0
|
||||||
self.round_number: int = 1
|
self.round_number: int = 1
|
||||||
self.turn_number: int = 0
|
self.turn_number: int = 0
|
||||||
|
self.game_started: bool = False
|
||||||
|
|
||||||
self.config = BoardConfig(
|
self.config = BoardConfig(
|
||||||
min_x=settings.GRID_MIN_X,
|
min_x=settings.GRID_MIN_X,
|
||||||
|
|
@ -250,6 +251,8 @@ class GameEngine:
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_active_turn_actors(self) -> List[str]:
|
def _get_active_turn_actors(self) -> List[str]:
|
||||||
|
if not self.game_started:
|
||||||
|
return []
|
||||||
actors = []
|
actors = []
|
||||||
for pid in self.turn_order:
|
for pid in self.turn_order:
|
||||||
p = self.players.get(pid)
|
p = self.players.get(pid)
|
||||||
|
|
@ -268,9 +271,19 @@ class GameEngine:
|
||||||
return self.players.get(player_id)
|
return self.players.get(player_id)
|
||||||
|
|
||||||
def _get_turn_info(self) -> TurnInfo:
|
def _get_turn_info(self) -> TurnInfo:
|
||||||
|
if not self.game_started:
|
||||||
|
return TurnInfo(
|
||||||
|
game_started=False,
|
||||||
|
current_player_id=None,
|
||||||
|
current_player_name=None,
|
||||||
|
round_number=0,
|
||||||
|
turn_number=0,
|
||||||
|
turn_order=[],
|
||||||
|
)
|
||||||
current = self._get_current_player()
|
current = self._get_current_player()
|
||||||
actors = self._get_active_turn_actors()
|
actors = self._get_active_turn_actors()
|
||||||
return TurnInfo(
|
return TurnInfo(
|
||||||
|
game_started=True,
|
||||||
current_player_id=current.id if current else None,
|
current_player_id=current.id if current else None,
|
||||||
current_player_name=current.name if current else None,
|
current_player_name=current.name if current else None,
|
||||||
round_number=self.round_number,
|
round_number=self.round_number,
|
||||||
|
|
@ -329,6 +342,10 @@ class GameEngine:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
return list(self.players.values())
|
return list(self.players.values())
|
||||||
|
|
||||||
|
# Alias remove_player to delete_player
|
||||||
|
async def remove_player(self, player_id: str) -> bool:
|
||||||
|
return await self.delete_player(player_id)
|
||||||
|
|
||||||
async def delete_player(self, player_id: str) -> bool:
|
async def delete_player(self, player_id: str) -> bool:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if player_id not in self.players:
|
if player_id not in self.players:
|
||||||
|
|
@ -370,6 +387,22 @@ class GameEngine:
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def start_game(self) -> TurnInfo:
|
||||||
|
async with self._lock:
|
||||||
|
if self.game_started:
|
||||||
|
return self._get_turn_info()
|
||||||
|
|
||||||
|
if len(self.players) == 0:
|
||||||
|
raise ValueError("Cannot start game: no bots have joined yet. Spawn or register bots first.")
|
||||||
|
|
||||||
|
self.game_started = True
|
||||||
|
# Build turn order from registered players
|
||||||
|
self.turn_order = list(self.players.keys())
|
||||||
|
self.current_turn_index = 0
|
||||||
|
self.round_number = 1
|
||||||
|
self.turn_number = 1
|
||||||
|
return self._get_turn_info()
|
||||||
|
|
||||||
async def reset(self) -> None:
|
async def reset(self) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self.players.clear()
|
self.players.clear()
|
||||||
|
|
@ -379,6 +412,7 @@ class GameEngine:
|
||||||
self.current_turn_index = 0
|
self.current_turn_index = 0
|
||||||
self.round_number = 1
|
self.round_number = 1
|
||||||
self.turn_number = 0
|
self.turn_number = 0
|
||||||
|
self.game_started = False
|
||||||
# Regenerate fresh procedural mountain ranges and valley trenches on reset
|
# Regenerate fresh procedural mountain ranges and valley trenches on reset
|
||||||
self.obstacles = self._generate_terrain()
|
self.obstacles = self._generate_terrain()
|
||||||
|
|
||||||
|
|
@ -394,6 +428,7 @@ class GameEngine:
|
||||||
parties=parties_list,
|
parties=parties_list,
|
||||||
obstacles=obstacles_list,
|
obstacles=obstacles_list,
|
||||||
turn=self._get_turn_info(),
|
turn=self._get_turn_info(),
|
||||||
|
game_started=self.game_started,
|
||||||
conclusion=self._check_game_concluded(),
|
conclusion=self._check_game_concluded(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -572,6 +607,8 @@ class GameEngine:
|
||||||
self, member_ids: List[str], leader_id: str, name: Optional[str] = None
|
self, member_ids: List[str], leader_id: str, name: Optional[str] = None
|
||||||
) -> Party:
|
) -> Party:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
if not self.game_started:
|
||||||
|
raise ValueError("Game has not started yet. Waiting for Start Game button in UI.")
|
||||||
if len(member_ids) < 2:
|
if len(member_ids) < 2:
|
||||||
raise ValueError("A party must have at least 2 members")
|
raise ValueError("A party must have at least 2 members")
|
||||||
if leader_id not in member_ids:
|
if leader_id not in member_ids:
|
||||||
|
|
@ -857,7 +894,18 @@ class GameEngine:
|
||||||
|
|
||||||
moves: Dict[str, MoveCheckResult] = {}
|
moves: Dict[str, MoveCheckResult] = {}
|
||||||
for name, dx, dy in STANDARD_DIRECTIONS:
|
for name, dx, dy in STANDARD_DIRECTIONS:
|
||||||
moves[name] = self._check_move_internal(player, dx, dy, name, occupied)
|
if not self.game_started:
|
||||||
|
moves[name] = MoveCheckResult(
|
||||||
|
direction=name,
|
||||||
|
dx=dx,
|
||||||
|
dy=dy,
|
||||||
|
target_x=player.x + dx,
|
||||||
|
target_y=player.y + dy,
|
||||||
|
available=False,
|
||||||
|
reason="Game has not started yet. Waiting for Start Game button in UI.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
moves[name] = self._check_move_internal(player, dx, dy, name, occupied)
|
||||||
|
|
||||||
member_count = 1
|
member_count = 1
|
||||||
if player.party_id and player.party_id in self.parties:
|
if player.party_id and player.party_id in self.parties:
|
||||||
|
|
@ -1015,6 +1063,8 @@ class GameEngine:
|
||||||
|
|
||||||
async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult:
|
async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
if not self.game_started:
|
||||||
|
raise ValueError("Game has not started yet. Waiting for Start Game button in UI.")
|
||||||
p1 = self.players.get(challenger_id)
|
p1 = self.players.get(challenger_id)
|
||||||
p2 = self.players.get(defender_id)
|
p2 = self.players.get(defender_id)
|
||||||
if not p1 or not p2:
|
if not p1 or not p2:
|
||||||
|
|
@ -1203,6 +1253,8 @@ class GameEngine:
|
||||||
self, player_id: str, dx: int, dy: int, direction_name: str
|
self, player_id: str, dx: int, dy: int, direction_name: str
|
||||||
) -> MoveResponse:
|
) -> MoveResponse:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
if not self.game_started:
|
||||||
|
raise ValueError("Game has not started yet. Waiting for Start Game button in UI.")
|
||||||
player = self.players.get(player_id)
|
player = self.players.get(player_id)
|
||||||
if not player:
|
if not player:
|
||||||
raise KeyError(f"Player '{player_id}' not found")
|
raise KeyError(f"Player '{player_id}' not found")
|
||||||
|
|
@ -1274,6 +1326,8 @@ class GameEngine:
|
||||||
|
|
||||||
async def pass_turn(self, player_id: str) -> TurnInfo:
|
async def pass_turn(self, player_id: str) -> TurnInfo:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
if not self.game_started:
|
||||||
|
raise ValueError("Game has not started yet. Waiting for Start Game button in UI.")
|
||||||
player = self.players.get(player_id)
|
player = self.players.get(player_id)
|
||||||
if not player:
|
if not player:
|
||||||
raise KeyError(f"Player '{player_id}' not found")
|
raise KeyError(f"Player '{player_id}' not found")
|
||||||
|
|
@ -1291,6 +1345,8 @@ class GameEngine:
|
||||||
|
|
||||||
async def step_bot_ai(self, player_id: str) -> AiStepResponse:
|
async def step_bot_ai(self, player_id: str) -> AiStepResponse:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
if not self.game_started:
|
||||||
|
raise ValueError("Game has not started yet. Waiting for Start Game button in UI.")
|
||||||
player = self.players.get(player_id)
|
player = self.players.get(player_id)
|
||||||
if not player:
|
if not player:
|
||||||
raise KeyError(f"Player '{player_id}' not found")
|
raise KeyError(f"Player '{player_id}' not found")
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,7 @@ class BoardConfig(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class TurnInfo(BaseModel):
|
class TurnInfo(BaseModel):
|
||||||
|
game_started: bool = False
|
||||||
current_player_id: Optional[str] = None
|
current_player_id: Optional[str] = None
|
||||||
current_player_name: Optional[str] = None
|
current_player_name: Optional[str] = None
|
||||||
round_number: int = 1
|
round_number: int = 1
|
||||||
|
|
@ -326,6 +327,7 @@ class BoardState(BaseModel):
|
||||||
parties: List[Party] = []
|
parties: List[Party] = []
|
||||||
obstacles: List[Obstacle] = Field(default_factory=list)
|
obstacles: List[Obstacle] = Field(default_factory=list)
|
||||||
turn: TurnInfo
|
turn: TurnInfo
|
||||||
|
game_started: bool = False
|
||||||
conclusion: Optional[GameConclusion] = None
|
conclusion: Optional[GameConclusion] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,59 @@ def test_health_check():
|
||||||
assert response.json()["success"] is True
|
assert response.json()["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_lobby_and_start_game_lifecycle():
|
||||||
|
client = TestClient(app)
|
||||||
|
client.post("/api/reset")
|
||||||
|
|
||||||
|
# 1. Before bots register: not started, no current player
|
||||||
|
board = client.get("/api/board").json()
|
||||||
|
assert board["turn"]["game_started"] is False
|
||||||
|
assert board["turn"]["current_player_id"] is None
|
||||||
|
|
||||||
|
# 2. Bots register (Lobby phase)
|
||||||
|
b1 = client.post("/api/players", json={"name": "LobbyBot1", "color": "#111111", "strength": 2}).json()
|
||||||
|
b2 = client.post("/api/players", json={"name": "LobbyBot2", "color": "#222222", "strength": 3}).json()
|
||||||
|
|
||||||
|
# Still not started, no one's turn
|
||||||
|
turn_res = client.get("/api/turn").json()
|
||||||
|
assert turn_res["game_started"] is False
|
||||||
|
assert turn_res["current_player_id"] is None
|
||||||
|
|
||||||
|
# 3. Attempting to move or take turns before start is rejected
|
||||||
|
move_res = client.post(f"/api/players/{b1['id']}/move", json={"direction": "UP"})
|
||||||
|
assert move_res.status_code == 400
|
||||||
|
assert "Game has not started yet" in move_res.json()["detail"]
|
||||||
|
|
||||||
|
step_res = client.post(f"/api/players/{b1['id']}/ai-step")
|
||||||
|
assert step_res.status_code == 400
|
||||||
|
assert "Game has not started yet" in step_res.json()["detail"]
|
||||||
|
|
||||||
|
# 4. Bots can depart freely during lobby
|
||||||
|
del_res = client.delete(f"/api/players/{b2['id']}")
|
||||||
|
assert del_res.status_code == 200
|
||||||
|
|
||||||
|
board = client.get("/api/board").json()
|
||||||
|
assert board["player_count"] == 1
|
||||||
|
|
||||||
|
# Add another bot back
|
||||||
|
b3 = client.post("/api/players", json={"name": "LobbyBot3", "color": "#333333", "strength": 4}).json()
|
||||||
|
|
||||||
|
# 5. Start Game button pressed!
|
||||||
|
start_res = client.post("/api/game/start")
|
||||||
|
assert start_res.status_code == 200
|
||||||
|
start_data = start_res.json()
|
||||||
|
assert start_data["game_started"] is True
|
||||||
|
assert start_data["current_player_id"] in [b1["id"], b3["id"]]
|
||||||
|
assert start_data["round_number"] == 1
|
||||||
|
assert start_data["turn_number"] == 1
|
||||||
|
|
||||||
|
# 6. Now bots can take their turns
|
||||||
|
acting_id = start_data["current_player_id"]
|
||||||
|
pass_res = client.post(f"/api/players/{acting_id}/pass")
|
||||||
|
assert pass_res.status_code == 200
|
||||||
|
assert pass_res.json()["turn_number"] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_register_player_and_get_board():
|
def test_register_player_and_get_board():
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
# Reset board first
|
# Reset board first
|
||||||
|
|
@ -33,6 +86,8 @@ def test_register_player_and_get_board():
|
||||||
assert board_data["player_count"] == 1
|
assert board_data["player_count"] == 1
|
||||||
assert board_data["players"][0]["name"] == "TestBot"
|
assert board_data["players"][0]["name"] == "TestBot"
|
||||||
assert len(board_data["obstacles"]) > 0
|
assert len(board_data["obstacles"]) > 0
|
||||||
|
assert board_data["turn"]["game_started"] is False
|
||||||
|
assert board_data["turn"]["current_player_id"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_bot_memory_and_radar_endpoints():
|
def test_bot_memory_and_radar_endpoints():
|
||||||
|
|
@ -78,6 +133,8 @@ def test_bot_ai_step_forms_parties_and_prioritizes_stronger_leader():
|
||||||
p2.x, p2.y = 5, 6
|
p2.x, p2.y = 5, 6
|
||||||
asyncio.run(set_positions())
|
asyncio.run(set_positions())
|
||||||
|
|
||||||
|
client.post("/api/game/start")
|
||||||
|
|
||||||
# Step WeakBot turn
|
# 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
|
||||||
|
|
@ -111,6 +168,8 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
||||||
b4.x, b4.y = 11, 11
|
b4.x, b4.y = 11, 11
|
||||||
asyncio.run(setup_combat_positions())
|
asyncio.run(setup_combat_positions())
|
||||||
|
|
||||||
|
client.post("/api/game/start")
|
||||||
|
|
||||||
# Form Party 1 (Strength 10)
|
# Form Party 1 (Strength 10)
|
||||||
client.post("/api/parties", json={
|
client.post("/api/parties", json={
|
||||||
"member_ids": [p1["id"], p2["id"]],
|
"member_ids": [p1["id"], p2["id"]],
|
||||||
|
|
@ -125,7 +184,7 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner():
|
||||||
"name": "BetaSquad",
|
"name": "BetaSquad",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Fight battle between AlphaLead and BetaLead
|
# Trigger explicit 3-bout D20 battle between AlphaLeader and BetaLeader
|
||||||
battle_res = client.post("/api/battles/fight", json={
|
battle_res = client.post("/api/battles/fight", json={
|
||||||
"challenger_id": p1["id"],
|
"challenger_id": p1["id"],
|
||||||
"defender_id": p3["id"],
|
"defender_id": p3["id"],
|
||||||
|
|
@ -164,6 +223,8 @@ def test_game_conclusion_and_scoreboard():
|
||||||
b4.score = 0 # 4th place
|
b4.score = 0 # 4th place
|
||||||
asyncio.run(setup_positions())
|
asyncio.run(setup_positions())
|
||||||
|
|
||||||
|
client.post("/api/game/start")
|
||||||
|
|
||||||
# Before joining all into 1 party, conclusion should be false
|
# Before joining all into 1 party, conclusion should be false
|
||||||
conc_before = client.get("/api/game/conclusion").json()
|
conc_before = client.get("/api/game/conclusion").json()
|
||||||
assert conc_before["concluded"] is False
|
assert conc_before["concluded"] is False
|
||||||
|
|
@ -206,6 +267,8 @@ def test_solo_bot_refuses_weaker_leader_party_responds_with_battle():
|
||||||
b3.x, b3.y = 21, 20
|
b3.x, b3.y = 21, 20
|
||||||
asyncio.run(setup_positions())
|
asyncio.run(setup_positions())
|
||||||
|
|
||||||
|
client.post("/api/game/start")
|
||||||
|
|
||||||
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"],
|
||||||
|
|
@ -224,7 +287,7 @@ def test_solo_bot_refuses_weaker_leader_party_responds_with_battle():
|
||||||
|
|
||||||
def test_map_obstacles_mountains_valleys_and_connectivity():
|
def test_map_obstacles_mountains_valleys_and_connectivity():
|
||||||
"""Verify obstacle constraints:
|
"""Verify obstacle constraints:
|
||||||
1. Obstacles include both mountains and valleys.
|
1. Obstacles include both mountains and forests.
|
||||||
2. Obstacles cover <= 50% of total map tiles.
|
2. Obstacles cover <= 50% of total map tiles.
|
||||||
3. All passable areas form a SINGLE connected component (no isolated bodies).
|
3. All passable areas form a SINGLE connected component (no isolated bodies).
|
||||||
4. Spawned bots never spawn on an obstacle.
|
4. Spawned bots never spawn on an obstacle.
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,11 @@ class SmartBotAgent:
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
turn_info = requests.get(f"{BASE_URL}/turn").json()
|
turn_info = requests.get(f"{BASE_URL}/turn").json()
|
||||||
|
if not turn_info.get("game_started", False):
|
||||||
|
print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI...", end="
")
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
curr_player_id = turn_info.get("current_player_id")
|
curr_player_id = turn_info.get("current_player_id")
|
||||||
|
|
||||||
if curr_player_id == self.bot_id:
|
if curr_player_id == self.bot_id:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export function App() {
|
||||||
movePlayer,
|
movePlayer,
|
||||||
passTurn,
|
passTurn,
|
||||||
stepActiveBotTurn,
|
stepActiveBotTurn,
|
||||||
|
startGame,
|
||||||
resetBoard,
|
resetBoard,
|
||||||
} = useGameSocket();
|
} = useGameSocket();
|
||||||
|
|
||||||
|
|
@ -80,6 +81,17 @@ export function App() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStartGame = async () => {
|
||||||
|
try {
|
||||||
|
const turnInfo = await startGame();
|
||||||
|
showNotification("🚀 Game started! First turn: " + (turnInfo.current_player_name || "Active Bot"));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
showNotification("Failed to start game: " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
try {
|
try {
|
||||||
await resetBoard();
|
await resetBoard();
|
||||||
|
|
@ -125,6 +137,8 @@ export function App() {
|
||||||
{/* Top Navigation Bar */}
|
{/* Top Navigation Bar */}
|
||||||
<Header
|
<Header
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
|
gameStarted={Boolean(boardState.turn?.game_started)}
|
||||||
|
onStartGame={handleStartGame}
|
||||||
onOpenRegister={() => setIsRegisterOpen(true)}
|
onOpenRegister={() => setIsRegisterOpen(true)}
|
||||||
onQuickSpawn={handleQuickSpawn}
|
onQuickSpawn={handleQuickSpawn}
|
||||||
onResetBoard={handleReset}
|
onResetBoard={handleReset}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import React from 'react';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
gameStarted: boolean;
|
||||||
|
onStartGame: () => void;
|
||||||
onOpenRegister: () => void;
|
onOpenRegister: () => void;
|
||||||
onQuickSpawn: () => void;
|
onQuickSpawn: () => void;
|
||||||
onResetBoard: () => void;
|
onResetBoard: () => void;
|
||||||
|
|
@ -12,6 +14,8 @@ interface HeaderProps {
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({
|
export const Header: React.FC<HeaderProps> = ({
|
||||||
isConnected,
|
isConnected,
|
||||||
|
gameStarted,
|
||||||
|
onStartGame,
|
||||||
onOpenRegister,
|
onOpenRegister,
|
||||||
onQuickSpawn,
|
onQuickSpawn,
|
||||||
onResetBoard,
|
onResetBoard,
|
||||||
|
|
@ -52,6 +56,23 @@ export const Header: React.FC<HeaderProps> = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Game Phase Badge */}
|
||||||
|
{!isConcluded && (
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-slate-800 border border-slate-700 text-xs font-mono">
|
||||||
|
{gameStarted ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-emerald-400 font-semibold">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
GAME IN PROGRESS
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5 text-amber-300 font-medium">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400 animate-ping" />
|
||||||
|
LOBBY (WAITING TO START)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Game concluded banner tag */}
|
{/* Game concluded banner tag */}
|
||||||
{isConcluded && (
|
{isConcluded && (
|
||||||
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/20 border border-amber-400 text-amber-300 text-xs font-bold animate-pulse">
|
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/20 border border-amber-400 text-amber-300 text-xs font-bold animate-pulse">
|
||||||
|
|
@ -62,6 +83,22 @@ export const Header: React.FC<HeaderProps> = ({
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Start Game Button (Visible during Lobby) */}
|
||||||
|
{!gameStarted && !isConcluded && (
|
||||||
|
<button
|
||||||
|
onClick={onStartGame}
|
||||||
|
disabled={playerCount === 0}
|
||||||
|
className={`text-xs font-bold px-4 py-1.5 rounded-lg flex items-center gap-1.5 shadow-lg transition-all ${
|
||||||
|
playerCount > 0
|
||||||
|
? 'bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-400 hover:to-teal-500 text-white shadow-emerald-500/30 animate-pulse cursor-pointer ring-1 ring-emerald-400'
|
||||||
|
: 'bg-slate-800 text-slate-500 border border-slate-700 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
title={playerCount === 0 ? 'Spawn or register bots before starting' : 'Start the game!'}
|
||||||
|
>
|
||||||
|
<span>▶</span> Start Game ({playerCount} {playerCount === 1 ? 'Bot' : 'Bots'})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isConcluded && onOpenScoreboard && (
|
{isConcluded && onOpenScoreboard && (
|
||||||
<button
|
<button
|
||||||
onClick={onOpenScoreboard}
|
onClick={onOpenScoreboard}
|
||||||
|
|
|
||||||
|
|
@ -22,23 +22,24 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
isAutoPlaying,
|
isAutoPlaying,
|
||||||
onToggleAutoPlay,
|
onToggleAutoPlay,
|
||||||
}) => {
|
}) => {
|
||||||
|
const isStarted = Boolean(boardState.turn?.game_started);
|
||||||
const currentTurnId = boardState.turn.current_player_id;
|
const currentTurnId = boardState.turn.current_player_id;
|
||||||
const activePlayer = boardState.players.find((p) => p.id === currentTurnId);
|
const activePlayer = boardState.players.find((p) => p.id === currentTurnId);
|
||||||
const controlledPlayer = selectedPlayer || activePlayer;
|
const controlledPlayer = selectedPlayer || activePlayer;
|
||||||
const isMyTurn = controlledPlayer && controlledPlayer.id === currentTurnId;
|
const isMyTurn = isStarted && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
|
||||||
|
|
||||||
const handleDirectionClick = useCallback(
|
const handleDirectionClick = useCallback(
|
||||||
(dir: string) => {
|
(dir: string) => {
|
||||||
if (!controlledPlayer || !isMyTurn) return;
|
if (!isStarted || !controlledPlayer || !isMyTurn) return;
|
||||||
onMove(controlledPlayer.id, dir).catch((err) => alert(err.message));
|
onMove(controlledPlayer.id, dir).catch((err) => alert(err.message));
|
||||||
},
|
},
|
||||||
[controlledPlayer, isMyTurn, onMove]
|
[isStarted, controlledPlayer, isMyTurn, onMove]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePassClick = useCallback(() => {
|
const handlePassClick = useCallback(() => {
|
||||||
if (!controlledPlayer || !isMyTurn) return;
|
if (!isStarted || !controlledPlayer || !isMyTurn) return;
|
||||||
onPass(controlledPlayer.id).catch((err) => alert(err.message));
|
onPass(controlledPlayer.id).catch((err) => alert(err.message));
|
||||||
}, [controlledPlayer, isMyTurn, onPass]);
|
}, [isStarted, controlledPlayer, isMyTurn, onPass]);
|
||||||
|
|
||||||
// Keyboard shortcut listener
|
// Keyboard shortcut listener
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -47,7 +48,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement)?.tagName)) {
|
if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement)?.tagName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isMyTurn || !controlledPlayer) return;
|
if (!isStarted || !isMyTurn || !controlledPlayer) return;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
|
|
@ -111,7 +112,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [isMyTurn, controlledPlayer, handleDirectionClick, handlePassClick]);
|
}, [isStarted, isMyTurn, controlledPlayer, handleDirectionClick, handlePassClick]);
|
||||||
|
|
||||||
if (boardState.players.length === 0) {
|
if (boardState.players.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -122,8 +123,10 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
const renderDirButton = (dir: string, label: string) => {
|
const renderDirButton = (dir: string, label: string) => {
|
||||||
const check = moves[dir];
|
const check = moves[dir];
|
||||||
const isAvailable = check?.available ?? false;
|
const isAvailable = check?.available ?? false;
|
||||||
const disabled = !isMyTurn || !isAvailable;
|
const disabled = !isStarted || !isMyTurn || !isAvailable;
|
||||||
const reason = check?.reason || (isAvailable ? `Move to (${check?.target_x}, ${check?.target_y})` : 'Blocked');
|
const reason = !isStarted
|
||||||
|
? "Game has not started yet. Click 'Start Game' in header."
|
||||||
|
: check?.reason || (isAvailable ? `Move to (${check?.target_x}, ${check?.target_y})` : 'Blocked');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -145,28 +148,46 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
<div className="absolute bottom-4 right-84 z-20 bg-slate-900/95 backdrop-blur-md border border-slate-700/80 rounded-2xl p-4 shadow-2xl flex flex-col gap-3 min-w-[240px]">
|
<div className="absolute bottom-4 right-84 z-20 bg-slate-900/95 backdrop-blur-md border border-slate-700/80 rounded-2xl p-4 shadow-2xl flex flex-col gap-3 min-w-[240px]">
|
||||||
{/* Turn Status Banner */}
|
{/* Turn Status Banner */}
|
||||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
|
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
|
||||||
<div className="flex items-center gap-2">
|
{!isStarted ? (
|
||||||
{activePlayer ? (
|
<div className="flex items-center gap-2 text-amber-300">
|
||||||
<>
|
<span className="w-2.5 h-2.5 rounded-full bg-amber-400 animate-ping" />
|
||||||
<div
|
<div className="flex flex-col">
|
||||||
className="w-3.5 h-3.5 rounded-full ring-2 ring-white/50 animate-pulse"
|
<span className="text-[10px] text-amber-400 font-mono leading-none">
|
||||||
style={{ backgroundColor: activePlayer.color }}
|
LOBBY PHASE
|
||||||
/>
|
</span>
|
||||||
<div className="flex flex-col">
|
<span className="text-xs font-bold text-slate-100">
|
||||||
<span className="text-[10px] text-slate-400 font-mono leading-none">
|
Waiting for "Start Game"
|
||||||
Round {boardState.turn.round_number} • Turn {boardState.turn.turn_number}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="text-xs font-bold text-slate-100 truncate max-w-[130px]">
|
</div>
|
||||||
{activePlayer.name}
|
) : (
|
||||||
</span>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
{activePlayer ? (
|
||||||
</>
|
<>
|
||||||
) : (
|
<div
|
||||||
<span className="text-xs text-slate-400">Waiting for bots...</span>
|
className="w-3.5 h-3.5 rounded-full ring-2 ring-white/50 animate-pulse"
|
||||||
)}
|
style={{ backgroundColor: activePlayer.color }}
|
||||||
</div>
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono leading-none">
|
||||||
|
Round {boardState.turn.round_number} • Turn {boardState.turn.turn_number}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-slate-100 truncate max-w-[130px]">
|
||||||
|
{activePlayer.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-400">Waiting for bots...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isMyTurn ? (
|
{!isStarted ? (
|
||||||
|
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-amber-950 text-amber-300 border border-amber-700">
|
||||||
|
NOT STARTED
|
||||||
|
</span>
|
||||||
|
) : isMyTurn ? (
|
||||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-300 border border-emerald-700 animate-pulse">
|
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-300 border border-emerald-700 animate-pulse">
|
||||||
YOUR TURN
|
YOUR TURN
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -191,10 +212,10 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
{renderDirButton('LEFT', '←')}
|
{renderDirButton('LEFT', '←')}
|
||||||
<button
|
<button
|
||||||
onClick={handlePassClick}
|
onClick={handlePassClick}
|
||||||
disabled={!isMyTurn}
|
disabled={!isStarted || !isMyTurn}
|
||||||
title="Pass turn (Spacebar)"
|
title={!isStarted ? "Game has not started yet" : "Pass turn (Spacebar)"}
|
||||||
className={`w-10 h-10 rounded-xl text-[10px] font-mono font-bold flex items-center justify-center transition-all ${
|
className={`w-10 h-10 rounded-xl text-[10px] font-mono font-bold flex items-center justify-center transition-all ${
|
||||||
!isMyTurn
|
!isStarted || !isMyTurn
|
||||||
? 'bg-slate-900/40 text-slate-600 border border-slate-800 cursor-not-allowed'
|
? 'bg-slate-900/40 text-slate-600 border border-slate-800 cursor-not-allowed'
|
||||||
: 'bg-slate-800 hover:bg-amber-600 hover:text-white text-amber-400 border border-amber-500/40 shadow-sm active:scale-95'
|
: 'bg-slate-800 hover:bg-amber-600 hover:text-white text-amber-400 border border-amber-500/40 shadow-sm active:scale-95'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -216,26 +237,34 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
|
||||||
<div className="pt-2 border-t border-slate-800/80 flex items-center gap-2">
|
<div className="pt-2 border-t border-slate-800/80 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onStepBot}
|
onClick={onStepBot}
|
||||||
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-mono py-1.5 px-2.5 rounded-lg border border-slate-700 transition-colors flex items-center justify-center gap-1"
|
disabled={!isStarted}
|
||||||
title="Make 1 random valid move for the active bot"
|
className={`flex-1 text-xs font-mono py-1.5 px-2.5 rounded-lg border transition-colors flex items-center justify-center gap-1 ${
|
||||||
|
!isStarted
|
||||||
|
? 'bg-slate-900/50 text-slate-600 border-slate-800 cursor-not-allowed'
|
||||||
|
: 'bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white border-slate-700'
|
||||||
|
}`}
|
||||||
|
title={isStarted ? 'Make 1 deliberate move for the active bot' : "Game has not started yet. Click 'Start Game' in header."}
|
||||||
>
|
>
|
||||||
<span>⚡</span> Step Bot
|
<span>⚡</span> Step Bot
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleAutoPlay}
|
onClick={onToggleAutoPlay}
|
||||||
|
disabled={!isStarted}
|
||||||
className={`flex-1 text-xs font-mono py-1.5 px-2.5 rounded-lg border transition-all flex items-center justify-center gap-1 ${
|
className={`flex-1 text-xs font-mono py-1.5 px-2.5 rounded-lg border transition-all flex items-center justify-center gap-1 ${
|
||||||
isAutoPlaying
|
!isStarted
|
||||||
|
? 'bg-slate-900/50 text-slate-600 border-slate-800 cursor-not-allowed'
|
||||||
|
: isAutoPlaying
|
||||||
? 'bg-amber-950/80 border-amber-500 text-amber-300 shadow-md shadow-amber-950/50 animate-pulse'
|
? 'bg-amber-950/80 border-amber-500 text-amber-300 shadow-md shadow-amber-950/50 animate-pulse'
|
||||||
: 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'
|
: 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'
|
||||||
}`}
|
}`}
|
||||||
title="Automatically cycle bot turns"
|
title={isStarted ? 'Automatically cycle bot turns' : "Game has not started yet. Click 'Start Game' in header."}
|
||||||
>
|
>
|
||||||
<span>{isAutoPlaying ? '⏸' : '▶'}</span> {isAutoPlaying ? 'Auto: ON' : 'Auto Play'}
|
<span>{isAutoPlaying ? '⏸' : '▶'}</span> {isAutoPlaying ? 'Auto: ON' : 'Auto Play'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[10px] text-slate-500 font-mono text-center">
|
<div className="text-[10px] text-slate-500 font-mono text-center">
|
||||||
WASD / Arrows / Numpad to move
|
{isStarted ? 'WASD / Arrows / Numpad to move' : 'Bots can join & depart in Lobby'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,14 @@ const INITIAL_BOARD: BoardState = {
|
||||||
parties: [],
|
parties: [],
|
||||||
obstacles: [],
|
obstacles: [],
|
||||||
turn: {
|
turn: {
|
||||||
|
game_started: false,
|
||||||
current_player_id: null,
|
current_player_id: null,
|
||||||
current_player_name: null,
|
current_player_name: null,
|
||||||
round_number: 1,
|
round_number: 1,
|
||||||
turn_number: 0,
|
turn_number: 0,
|
||||||
turn_order: [],
|
turn_order: [],
|
||||||
},
|
},
|
||||||
|
game_started: false,
|
||||||
conclusion: null,
|
conclusion: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -191,6 +193,17 @@ export function useGameSocket() {
|
||||||
setLastEventMessage(
|
setLastEventMessage(
|
||||||
`🏆 VICTORY! Game concluded! All bots united under "${conc.winning_party_name}" led by ${conc.winning_leader_name}!`
|
`🏆 VICTORY! Game concluded! All bots united under "${conc.winning_party_name}" led by ${conc.winning_leader_name}!`
|
||||||
);
|
);
|
||||||
|
} else if (data.event === 'game_started') {
|
||||||
|
if (data.board) {
|
||||||
|
setBoardState(data.board);
|
||||||
|
} else {
|
||||||
|
setBoardState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
turn: data.turn ?? { ...prev.turn, game_started: true },
|
||||||
|
game_started: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setLastEventMessage('🚀 Game Started! Turns are now active.');
|
||||||
} else if (data.event === 'turn_passed') {
|
} else if (data.event === 'turn_passed') {
|
||||||
setBoardState((prev) => ({
|
setBoardState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -367,10 +380,24 @@ export function useGameSocket() {
|
||||||
setShowScoreboard(false);
|
setShowScoreboard(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startGame = async (): Promise<TurnInfo> => {
|
||||||
|
const res = await fetch('/api/game/start', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Failed to start game');
|
||||||
|
}
|
||||||
|
const data: TurnInfo = await res.json();
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
// Step active bot turn according to its explicit autonomous goal:
|
// Step active bot turn according to its explicit autonomous goal:
|
||||||
// - Bot without party: seeks other bots to form a party (stronger bot insists on being leader)
|
// - Bot without party: seeks other bots to form a party (stronger bot insists on being leader)
|
||||||
// - Party leader: seeks other parties to find and defeat all other parties
|
// - Party leader: seeks other parties to find and defeat all other parties
|
||||||
const stepActiveBotTurn = useCallback(async () => {
|
const stepActiveBotTurn = useCallback(async () => {
|
||||||
|
// Cannot step if game hasn't started yet
|
||||||
|
if (!boardState.turn.game_started) return;
|
||||||
// If a battle modal is currently open, pause turn stepping until battle modal acknowledges/closes
|
// If a battle modal is currently open, pause turn stepping until battle modal acknowledges/closes
|
||||||
if (activeBattleRef.current) return;
|
if (activeBattleRef.current) return;
|
||||||
|
|
||||||
|
|
@ -441,6 +468,7 @@ export function useGameSocket() {
|
||||||
movePlayer,
|
movePlayer,
|
||||||
passTurn,
|
passTurn,
|
||||||
stepActiveBotTurn,
|
stepActiveBotTurn,
|
||||||
|
startGame,
|
||||||
resetBoard,
|
resetBoard,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export interface Party {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnInfo {
|
export interface TurnInfo {
|
||||||
|
game_started?: boolean;
|
||||||
current_player_id: string | null;
|
current_player_id: string | null;
|
||||||
current_player_name: string | null;
|
current_player_name: string | null;
|
||||||
round_number: number;
|
round_number: number;
|
||||||
|
|
@ -62,6 +63,7 @@ export interface BoardState {
|
||||||
parties: Party[];
|
parties: Party[];
|
||||||
obstacles: Obstacle[];
|
obstacles: Obstacle[];
|
||||||
turn: TurnInfo;
|
turn: TurnInfo;
|
||||||
|
game_started?: boolean;
|
||||||
conclusion?: GameConclusion | null;
|
conclusion?: GameConclusion | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue