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