Player minimum of 3 to start a game or scene
Enforced server-side in lobby/start and confirm_scene_setup via crud_base.has_min_players; Dev Mode bypasses it so local single-player testing still works. Lobby and scene-setup UIs show a player-count message instead of the start button when below the minimum. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -18,7 +18,7 @@
|
|||||||
## Preventing blocked play
|
## Preventing blocked play
|
||||||
|
|
||||||
- [x] **Multiple admins.** The Admin player should be able to grant other players Admin privileges in the admin panel.
|
- [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.
|
- [ ] **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
|
## Session management
|
||||||
|
|||||||
@@ -74,14 +74,14 @@
|
|||||||
|
|
||||||
<div class="lobby-action">
|
<div class="lobby-action">
|
||||||
{#if state.player.is_admin}
|
{#if state.player.is_admin}
|
||||||
{#if state.players.length >= 1}
|
{#if state.players.length >= 3 || state.game.dev_mode}
|
||||||
<button on:click={startGame}
|
<button on:click={startGame}
|
||||||
disabled={starting}
|
disabled={starting}
|
||||||
class="btn btn-primary btn-large glow-effect">
|
class="btn btn-primary btn-large glow-effect">
|
||||||
{starting ? 'Starting...' : 'Start Character Creation'}
|
{starting ? 'Starting...' : 'Start Character Creation'}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn btn-primary btn-large" disabled>Waiting for players...</button>
|
<button class="btn btn-primary btn-large" disabled>Waiting for players... ({state.players.length}/3 minimum)</button>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="waiting-indicator">
|
<div class="waiting-indicator">
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
$: allRolesAssigned = state.players.every(p => p.role !== null || p.needs_reroll);
|
$: allRolesAssigned = state.players.every(p => p.role !== null || p.needs_reroll);
|
||||||
$: deepPlayer = state.players.find(p => p.role === 'deep');
|
$: deepPlayer = state.players.find(p => p.role === 'deep');
|
||||||
$: piratPlayer = state.players.find(p => p.role === 'pirat');
|
$: 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');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="scene-setup-view text-center">
|
<div class="scene-setup-view text-center">
|
||||||
@@ -137,6 +138,8 @@
|
|||||||
class="btn btn-primary btn-large glow-effect">
|
class="btn btn-primary btn-large glow-effect">
|
||||||
{starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'}
|
{starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'}
|
||||||
</button>
|
</button>
|
||||||
|
{:else if !enoughPlayers}
|
||||||
|
<p class="text-danger italic mt-4">You need at least 3 players to start a scene — share the join link!</p>
|
||||||
{:else if !allRolesAssigned}
|
{:else if !allRolesAssigned}
|
||||||
<p class="text-muted italic mt-4">Waiting for all players to choose a role...</p>
|
<p class="text-muted italic mt-4">Waiting for all players to choose a role...</p>
|
||||||
{:else if !deepPlayer}
|
{:else if !deepPlayer}
|
||||||
|
|||||||
@@ -234,6 +234,12 @@ def dev_mode_default() -> bool:
|
|||||||
return True
|
return True
|
||||||
return val.strip().lower() in ("1", "true", "yes", "on")
|
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:
|
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
||||||
deck = cards.get_fresh_deck()
|
deck = cards.get_fresh_deck()
|
||||||
game = Game(
|
game = Game(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from . import cards
|
|||||||
from .crud_base import (
|
from .crud_base import (
|
||||||
get_player, get_game, get_player_hand, set_player_hand,
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
get_game_deck, calculate_max_hand_size, add_game_event, reshuffle_discard_pile,
|
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 ---
|
# --- Scene Setup Operations ---
|
||||||
@@ -33,6 +33,9 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
if not game:
|
if not game:
|
||||||
return False, "Game not found."
|
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
|
players = game.players
|
||||||
|
|
||||||
# Default any unselected roles to 'pirat'. Pending recruits have no Pi-Rat
|
# Default any unselected roles to 'pirat'. Pending recruits have no Pi-Rat
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
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)
|
game = crud.get_game(db, game_id)
|
||||||
if not game:
|
if not game:
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
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"
|
game.phase = "character_creation"
|
||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -1433,3 +1433,76 @@ def test_grant_and_revoke_admin():
|
|||||||
assert "admin_key" not in response.json()["game"]
|
assert "admin_key" not in response.json()["game"]
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user