diff --git a/TODO.md b/TODO.md index 58bcd6e..9ee83d2 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ ## Preventing blocked play - [x] **Multiple admins.** The Admin player should be able to grant other players Admin privileges in the admin panel. -- [ ] **Player minimum.** You need at least three players to start a game, or to start a new scene if a player dropped out mid-game. +- [x] **Player minimum.** You need at least three players to start a game, or to start a new scene if a player dropped out mid-game. (Dev Mode bypasses the minimum for local testing.) - [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes. Admins can kick other admins. ## Session management diff --git a/frontend/src/components/LobbyPhase.svelte b/frontend/src/components/LobbyPhase.svelte index 714cf09..2e11ca0 100644 --- a/frontend/src/components/LobbyPhase.svelte +++ b/frontend/src/components/LobbyPhase.svelte @@ -74,14 +74,14 @@
{#if state.player.is_admin} - {#if state.players.length >= 1} - {:else} - + {/if} {:else}
diff --git a/frontend/src/components/SceneSetupPhase.svelte b/frontend/src/components/SceneSetupPhase.svelte index 521e334..6a099f2 100644 --- a/frontend/src/components/SceneSetupPhase.svelte +++ b/frontend/src/components/SceneSetupPhase.svelte @@ -44,7 +44,8 @@ $: allRolesAssigned = state.players.every(p => p.role !== null || p.needs_reroll); $: deepPlayer = state.players.find(p => p.role === 'deep'); $: piratPlayer = state.players.find(p => p.role === 'pirat'); - $: canStart = allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_admin || state.player.role === 'deep'); + $: enoughPlayers = state.players.length >= 3 || state.game.dev_mode; + $: canStart = enoughPlayers && allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_admin || state.player.role === 'deep');
@@ -137,6 +138,8 @@ class="btn btn-primary btn-large glow-effect"> {starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'} + {:else if !enoughPlayers} +

You need at least 3 players to start a scene — share the join link!

{:else if !allRolesAssigned}

Waiting for all players to choose a role...

{:else if !deepPlayer} diff --git a/src/pirats/crud_base.py b/src/pirats/crud_base.py index 3d1336e..738d493 100644 --- a/src/pirats/crud_base.py +++ b/src/pirats/crud_base.py @@ -234,6 +234,12 @@ def dev_mode_default() -> bool: return True return val.strip().lower() in ("1", "true", "yes", "on") +MIN_PLAYERS = 3 + +def has_min_players(game: Game) -> bool: + """The crew needs at least MIN_PLAYERS to start a game or a new scene; Dev Mode bypasses this for testing.""" + return game.dev_mode or len(game.players) >= MIN_PLAYERS + def create_game(db: Session, crew_name: Optional[str] = None) -> Game: deck = cards.get_fresh_deck() game = Game( diff --git a/src/pirats/crud_scene.py b/src/pirats/crud_scene.py index 8d36927..e8654aa 100644 --- a/src/pirats/crud_scene.py +++ b/src/pirats/crud_scene.py @@ -7,7 +7,7 @@ from . import cards from .crud_base import ( get_player, get_game, get_player_hand, set_player_hand, get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile, - change_player_rank, set_captain, evaluate_card_play + change_player_rank, set_captain, evaluate_card_play, has_min_players, MIN_PLAYERS ) # --- Scene Setup Operations --- @@ -33,6 +33,9 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: if not game: return False, "Game not found." + if not has_min_players(game): + return False, f"You need at least {MIN_PLAYERS} players to start a scene." + players = game.players # Default any unselected roles to 'pirat'. Pending recruits have no Pi-Rat diff --git a/src/pirats/routes_lobby.py b/src/pirats/routes_lobby.py index d750940..49d6f13 100644 --- a/src/pirats/routes_lobby.py +++ b/src/pirats/routes_lobby.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse from sqlmodel import Session from .database import get_session from . import crud @@ -10,6 +11,8 @@ def lobby_start_character_creation(game_id: str, db: Session = Depends(get_sessi game = crud.get_game(db, game_id) if not game: raise HTTPException(status_code=404, detail="Game not found") + if not crud.has_min_players(game): + return JSONResponse({"error": f"You need at least {crud.MIN_PLAYERS} players to start a game."}, status_code=400) game.phase = "character_creation" db.add(game) db.commit() diff --git a/tests/test_game.py b/tests/test_game.py index f30a7f7..c03f4f6 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1433,3 +1433,76 @@ def test_grant_and_revoke_admin(): assert "admin_key" not in response.json()["game"] finally: app.dependency_overrides.clear() + +def test_player_minimum(session): + game = crud.create_game(session) + game.dev_mode = False + session.add(game) + session.commit() + crud.add_player(session, game.id, "P1", is_creator=True) + crud.add_player(session, game.id, "P2") + session.refresh(game) + + # Two players can't start a scene + assert not crud.has_min_players(game) + ok, msg = crud.confirm_scene_setup(session, game.id) + assert not ok + assert "at least 3" in msg + + # Dev Mode bypasses the minimum for testing + game.dev_mode = True + session.add(game) + session.commit() + assert crud.has_min_players(game) + + # Three players is enough regardless + game.dev_mode = False + session.add(game) + session.commit() + crud.add_player(session, game.id, "P3") + session.refresh(game) + assert crud.has_min_players(game) + +def test_lobby_start_requires_min_players(): + from fastapi.testclient import TestClient + from sqlalchemy.pool import StaticPool + from sqlmodel import SQLModel, create_engine, Session + from pirats.main import app + from pirats import crud + + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + session = Session(engine) + + game = crud.create_game(session) + game.dev_mode = False + session.add(game) + session.commit() + crud.add_player(session, game.id, "P1", is_creator=True) + crud.add_player(session, game.id, "P2") + + def get_session_override(): + yield session + + from pirats.database import get_session + app.dependency_overrides[get_session] = get_session_override + client = TestClient(app) + + try: + response = client.post(f"/api/game/{game.id}/lobby/start") + assert response.status_code == 400 + assert "at least 3" in response.json()["error"] + session.refresh(game) + assert game.phase == "lobby" + + crud.add_player(session, game.id, "P3") + response = client.post(f"/api/game/{game.id}/lobby/start") + assert response.status_code == 200 + session.refresh(game) + assert game.phase == "character_creation" + finally: + app.dependency_overrides.clear()