feat: add lobby phase with Start Game button and turn gating

This commit is contained in:
Isaac Johnson 2026-09-06 08:24:06 -05:00
parent 5fb7e07103
commit 7e7173280b
10 changed files with 300 additions and 41 deletions

View File

@ -618,3 +618,26 @@ async def pass_turn(player_id: str):
async def get_turn():
board_state = await game_engine.get_board_state()
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),
)

View File

@ -48,6 +48,7 @@ class GameEngine:
self.current_turn_index: int = 0
self.round_number: int = 1
self.turn_number: int = 0
self.game_started: bool = False
self.config = BoardConfig(
min_x=settings.GRID_MIN_X,
@ -250,6 +251,8 @@ class GameEngine:
)
def _get_active_turn_actors(self) -> List[str]:
if not self.game_started:
return []
actors = []
for pid in self.turn_order:
p = self.players.get(pid)
@ -268,9 +271,19 @@ class GameEngine:
return self.players.get(player_id)
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()
actors = self._get_active_turn_actors()
return TurnInfo(
game_started=True,
current_player_id=current.id if current else None,
current_player_name=current.name if current else None,
round_number=self.round_number,
@ -329,6 +342,10 @@ class GameEngine:
async with self._lock:
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 with self._lock:
if player_id not in self.players:
@ -370,6 +387,22 @@ class GameEngine:
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 with self._lock:
self.players.clear()
@ -379,6 +412,7 @@ class GameEngine:
self.current_turn_index = 0
self.round_number = 1
self.turn_number = 0
self.game_started = False
# Regenerate fresh procedural mountain ranges and valley trenches on reset
self.obstacles = self._generate_terrain()
@ -394,6 +428,7 @@ class GameEngine:
parties=parties_list,
obstacles=obstacles_list,
turn=self._get_turn_info(),
game_started=self.game_started,
conclusion=self._check_game_concluded(),
)
@ -572,6 +607,8 @@ class GameEngine:
self, member_ids: List[str], leader_id: str, name: Optional[str] = None
) -> Party:
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:
raise ValueError("A party must have at least 2 members")
if leader_id not in member_ids:
@ -857,6 +894,17 @@ class GameEngine:
moves: Dict[str, MoveCheckResult] = {}
for name, dx, dy in STANDARD_DIRECTIONS:
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
@ -1015,6 +1063,8 @@ class GameEngine:
async def fight_battle(self, challenger_id: str, defender_id: str) -> BattleResult:
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)
p2 = self.players.get(defender_id)
if not p1 or not p2:
@ -1203,6 +1253,8 @@ class GameEngine:
self, player_id: str, dx: int, dy: int, direction_name: str
) -> MoveResponse:
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)
if not player:
raise KeyError(f"Player '{player_id}' not found")
@ -1274,6 +1326,8 @@ class GameEngine:
async def pass_turn(self, player_id: str) -> TurnInfo:
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)
if not player:
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 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)
if not player:
raise KeyError(f"Player '{player_id}' not found")

View File

@ -298,6 +298,7 @@ class BoardConfig(BaseModel):
class TurnInfo(BaseModel):
game_started: bool = False
current_player_id: Optional[str] = None
current_player_name: Optional[str] = None
round_number: int = 1
@ -326,6 +327,7 @@ class BoardState(BaseModel):
parties: List[Party] = []
obstacles: List[Obstacle] = Field(default_factory=list)
turn: TurnInfo
game_started: bool = False
conclusion: Optional[GameConclusion] = None

View File

@ -11,6 +11,59 @@ def test_health_check():
assert response.json()["success"] is True
def test_lobby_and_start_game_lifecycle():
client = TestClient(app)
client.post("/api/reset")
# 1. Before bots register: not started, no current player
board = client.get("/api/board").json()
assert board["turn"]["game_started"] is False
assert board["turn"]["current_player_id"] is None
# 2. Bots register (Lobby phase)
b1 = client.post("/api/players", json={"name": "LobbyBot1", "color": "#111111", "strength": 2}).json()
b2 = client.post("/api/players", json={"name": "LobbyBot2", "color": "#222222", "strength": 3}).json()
# Still not started, no one's turn
turn_res = client.get("/api/turn").json()
assert turn_res["game_started"] is False
assert turn_res["current_player_id"] is None
# 3. Attempting to move or take turns before start is rejected
move_res = client.post(f"/api/players/{b1['id']}/move", json={"direction": "UP"})
assert move_res.status_code == 400
assert "Game has not started yet" in move_res.json()["detail"]
step_res = client.post(f"/api/players/{b1['id']}/ai-step")
assert step_res.status_code == 400
assert "Game has not started yet" in step_res.json()["detail"]
# 4. Bots can depart freely during lobby
del_res = client.delete(f"/api/players/{b2['id']}")
assert del_res.status_code == 200
board = client.get("/api/board").json()
assert board["player_count"] == 1
# Add another bot back
b3 = client.post("/api/players", json={"name": "LobbyBot3", "color": "#333333", "strength": 4}).json()
# 5. Start Game button pressed!
start_res = client.post("/api/game/start")
assert start_res.status_code == 200
start_data = start_res.json()
assert start_data["game_started"] is True
assert start_data["current_player_id"] in [b1["id"], b3["id"]]
assert start_data["round_number"] == 1
assert start_data["turn_number"] == 1
# 6. Now bots can take their turns
acting_id = start_data["current_player_id"]
pass_res = client.post(f"/api/players/{acting_id}/pass")
assert pass_res.status_code == 200
assert pass_res.json()["turn_number"] == 2
def test_register_player_and_get_board():
client = TestClient(app)
# Reset board first
@ -33,6 +86,8 @@ def test_register_player_and_get_board():
assert board_data["player_count"] == 1
assert board_data["players"][0]["name"] == "TestBot"
assert len(board_data["obstacles"]) > 0
assert board_data["turn"]["game_started"] is False
assert board_data["turn"]["current_player_id"] is None
def test_bot_memory_and_radar_endpoints():
@ -78,6 +133,8 @@ def test_bot_ai_step_forms_parties_and_prioritizes_stronger_leader():
p2.x, p2.y = 5, 6
asyncio.run(set_positions())
client.post("/api/game/start")
# Step WeakBot turn
step_res = client.post(f"/api/players/{b1['id']}/ai-step")
assert step_res.status_code == 200
@ -111,6 +168,8 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner():
b4.x, b4.y = 11, 11
asyncio.run(setup_combat_positions())
client.post("/api/game/start")
# Form Party 1 (Strength 10)
client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
@ -125,7 +184,7 @@ def test_3bout_d20_battle_with_defeated_members_joining_winner():
"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={
"challenger_id": p1["id"],
"defender_id": p3["id"],
@ -164,6 +223,8 @@ def test_game_conclusion_and_scoreboard():
b4.score = 0 # 4th place
asyncio.run(setup_positions())
client.post("/api/game/start")
# Before joining all into 1 party, conclusion should be false
conc_before = client.get("/api/game/conclusion").json()
assert conc_before["concluded"] is False
@ -206,6 +267,8 @@ def test_solo_bot_refuses_weaker_leader_party_responds_with_battle():
b3.x, b3.y = 21, 20
asyncio.run(setup_positions())
client.post("/api/game/start")
client.post("/api/parties", json={
"member_ids": [p1["id"], p2["id"]],
"leader_id": p1["id"],
@ -224,7 +287,7 @@ def test_solo_bot_refuses_weaker_leader_party_responds_with_battle():
def test_map_obstacles_mountains_valleys_and_connectivity():
"""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.
3. All passable areas form a SINGLE connected component (no isolated bodies).
4. Spawned bots never spawn on an obstacle.

View File

@ -230,6 +230,11 @@ class SmartBotAgent:
try:
while True:
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")
if curr_player_id == self.bot_id:

View File

@ -40,6 +40,7 @@ export function App() {
movePlayer,
passTurn,
stepActiveBotTurn,
startGame,
resetBoard,
} = 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 () => {
try {
await resetBoard();
@ -125,6 +137,8 @@ export function App() {
{/* Top Navigation Bar */}
<Header
isConnected={isConnected}
gameStarted={Boolean(boardState.turn?.game_started)}
onStartGame={handleStartGame}
onOpenRegister={() => setIsRegisterOpen(true)}
onQuickSpawn={handleQuickSpawn}
onResetBoard={handleReset}

View File

@ -2,6 +2,8 @@ import React from 'react';
interface HeaderProps {
isConnected: boolean;
gameStarted: boolean;
onStartGame: () => void;
onOpenRegister: () => void;
onQuickSpawn: () => void;
onResetBoard: () => void;
@ -12,6 +14,8 @@ interface HeaderProps {
export const Header: React.FC<HeaderProps> = ({
isConnected,
gameStarted,
onStartGame,
onOpenRegister,
onQuickSpawn,
onResetBoard,
@ -52,6 +56,23 @@ export const Header: React.FC<HeaderProps> = ({
</span>
</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 */}
{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">
@ -62,6 +83,22 @@ export const Header: React.FC<HeaderProps> = ({
{/* Action Buttons */}
<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 && (
<button
onClick={onOpenScoreboard}

View File

@ -22,23 +22,24 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
isAutoPlaying,
onToggleAutoPlay,
}) => {
const isStarted = Boolean(boardState.turn?.game_started);
const currentTurnId = boardState.turn.current_player_id;
const activePlayer = boardState.players.find((p) => p.id === currentTurnId);
const controlledPlayer = selectedPlayer || activePlayer;
const isMyTurn = controlledPlayer && controlledPlayer.id === currentTurnId;
const isMyTurn = isStarted && Boolean(controlledPlayer && controlledPlayer.id === currentTurnId);
const handleDirectionClick = useCallback(
(dir: string) => {
if (!controlledPlayer || !isMyTurn) return;
if (!isStarted || !controlledPlayer || !isMyTurn) return;
onMove(controlledPlayer.id, dir).catch((err) => alert(err.message));
},
[controlledPlayer, isMyTurn, onMove]
[isStarted, controlledPlayer, isMyTurn, onMove]
);
const handlePassClick = useCallback(() => {
if (!controlledPlayer || !isMyTurn) return;
if (!isStarted || !controlledPlayer || !isMyTurn) return;
onPass(controlledPlayer.id).catch((err) => alert(err.message));
}, [controlledPlayer, isMyTurn, onPass]);
}, [isStarted, controlledPlayer, isMyTurn, onPass]);
// Keyboard shortcut listener
useEffect(() => {
@ -47,7 +48,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement)?.tagName)) {
return;
}
if (!isMyTurn || !controlledPlayer) return;
if (!isStarted || !isMyTurn || !controlledPlayer) return;
switch (e.key) {
case 'ArrowUp':
@ -111,7 +112,7 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isMyTurn, controlledPlayer, handleDirectionClick, handlePassClick]);
}, [isStarted, isMyTurn, controlledPlayer, handleDirectionClick, handlePassClick]);
if (boardState.players.length === 0) {
return null;
@ -122,8 +123,10 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
const renderDirButton = (dir: string, label: string) => {
const check = moves[dir];
const isAvailable = check?.available ?? false;
const disabled = !isMyTurn || !isAvailable;
const reason = check?.reason || (isAvailable ? `Move to (${check?.target_x}, ${check?.target_y})` : 'Blocked');
const disabled = !isStarted || !isMyTurn || !isAvailable;
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 (
<button
@ -145,6 +148,19 @@ 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]">
{/* Turn Status Banner */}
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
{!isStarted ? (
<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 className="flex flex-col">
<span className="text-[10px] text-amber-400 font-mono leading-none">
LOBBY PHASE
</span>
<span className="text-xs font-bold text-slate-100">
Waiting for "Start Game"
</span>
</div>
</div>
) : (
<div className="flex items-center gap-2">
{activePlayer ? (
<>
@ -165,8 +181,13 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
<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">
YOUR TURN
</span>
@ -191,10 +212,10 @@ export const MovementControls: React.FC<MovementControlsProps> = ({
{renderDirButton('LEFT', '←')}
<button
onClick={handlePassClick}
disabled={!isMyTurn}
title="Pass turn (Spacebar)"
disabled={!isStarted || !isMyTurn}
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 ${
!isMyTurn
!isStarted || !isMyTurn
? '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'
}`}
@ -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">
<button
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"
title="Make 1 random valid move for the active bot"
disabled={!isStarted}
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
</button>
<button
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 ${
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-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'}
</button>
</div>
<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>
);

View File

@ -26,12 +26,14 @@ const INITIAL_BOARD: BoardState = {
parties: [],
obstacles: [],
turn: {
game_started: false,
current_player_id: null,
current_player_name: null,
round_number: 1,
turn_number: 0,
turn_order: [],
},
game_started: false,
conclusion: null,
};
@ -191,6 +193,17 @@ export function useGameSocket() {
setLastEventMessage(
`🏆 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') {
setBoardState((prev) => ({
...prev,
@ -367,10 +380,24 @@ export function useGameSocket() {
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:
// - 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
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 (activeBattleRef.current) return;
@ -441,6 +468,7 @@ export function useGameSocket() {
movePlayer,
passTurn,
stepActiveBotTurn,
startGame,
resetBoard,
};
}

View File

@ -38,6 +38,7 @@ export interface Party {
}
export interface TurnInfo {
game_started?: boolean;
current_player_id: string | null;
current_player_name: string | null;
round_number: number;
@ -62,6 +63,7 @@ export interface BoardState {
parties: Party[];
obstacles: Obstacle[];
turn: TurnInfo;
game_started?: boolean;
conclusion?: GameConclusion | null;
}