From bfe982251e5adc806a00a15fd1661d9ce18120f4 Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Sun, 14 Jun 2026 09:14:58 -0700 Subject: [PATCH] Add teaching mode: admin can seed themselves as scene-1 Deep An admin can now opt into "teaching mode" from the lobby, seeding themselves as the Rank-3 player who must play the Deep in the first scene (instead of leaving it to the random starting-rank roll), so they can run the table for new crews. - Game.teaching_deep_player_id (+ Alembic migration) - maybe_finish_assign_techniques honors it before rolling ranks, so both the normal flow and the dev-mode skip path respect it - Admin-gated POST .../teaching-mode toggle (pre-rank phases only) - Lobby checkbox UI, greyed out if another admin already volunteered Co-Authored-By: Claude Opus 4.8 --- TODO.md | 2 +- frontend/src/assets/css/lobby.css | 16 ++++++++++ frontend/src/components/LobbyPhase.svelte | 29 +++++++++++++++++ src/pirats/crud_character.py | 8 +++++ ...a0e_add_teaching_deep_player_id_to_game.py | 32 +++++++++++++++++++ src/pirats/models.py | 1 + src/pirats/routes_admin.py | 30 +++++++++++++++++ 7 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/pirats/migrations/versions/19ee9ae5ca0e_add_teaching_deep_player_id_to_game.py diff --git a/TODO.md b/TODO.md index cf1f774..c04750c 100644 --- a/TODO.md +++ b/TODO.md @@ -6,7 +6,7 @@ - [x] **Cancel Revise**. In character creation, if you click "Revise" and then don't want to make any changes after all, there should be a cancel button that throws away any edits you've made since you clicked revise. -- [ ] **Teaching mode**. The creator/admin should have an option to seed themselves as the player who is forced to be the Deep for the first scene +- [x] **Teaching mode**. The creator/admin should have an option to seed themselves as the player who is forced to be the Deep for the first scene - [ ] Visual feedback confirming hand refresh in Deep Upkeep diff --git a/frontend/src/assets/css/lobby.css b/frontend/src/assets/css/lobby.css index 8ec4a30..86dc8f2 100644 --- a/frontend/src/assets/css/lobby.css +++ b/frontend/src/assets/css/lobby.css @@ -13,6 +13,22 @@ margin-bottom: 2rem; } +.teaching-box { + text-align: left; + margin-bottom: 2rem; + padding: 1rem 1.25rem; +} + +.teaching-box .checkbox-label.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.teaching-box .info-text { + margin: 0.5rem 0 0; + font-size: 0.85rem; +} + .link-item { margin-bottom: 1.5rem; } diff --git a/frontend/src/components/LobbyPhase.svelte b/frontend/src/components/LobbyPhase.svelte index 2e11ca0..b5daaf7 100644 --- a/frontend/src/components/LobbyPhase.svelte +++ b/frontend/src/components/LobbyPhase.svelte @@ -24,6 +24,19 @@ starting = false; } } + + $: teaching = state.game.teaching_deep_player_id === state.player.id; + $: teachingTakenByOther = state.game.teaching_deep_player_id && !teaching; + + async function toggleTeaching() { + try { + await apiRequest(`/game/${state.game.id}/player/${state.player.id}/teaching-mode`, 'POST', { + enabled: !teaching + }); + } catch (err) { + console.error("Failed to toggle teaching mode:", err); + } + }
@@ -72,6 +85,22 @@ {/if}
+ {#if state.player.is_admin} +
+ +

+ {#if teachingTakenByOther} + Another admin has already volunteered to teach as the Deep. + {:else} + Seeds you as the Rank-3 player who must play the Deep in scene 1, so you can run the table for new crews. Otherwise it's assigned at random. + {/if} +

+
+ {/if} +
{#if state.player.is_admin} {#if state.players.length >= 3 || state.game.dev_mode} diff --git a/src/pirats/crud_character.py b/src/pirats/crud_character.py index 8a5ca96..d455ce4 100644 --- a/src/pirats/crud_character.py +++ b/src/pirats/crud_character.py @@ -435,6 +435,14 @@ def maybe_finish_assign_techniques(db: Session, game: Game): # 1. Randomly assign starting ranks random.shuffle(players_list) + # Teaching mode: an admin opted to be the forced Rank-3 Deep for the first + # scene, so seed them at the front instead of leaving it to chance. + if game.teaching_deep_player_id: + teacher = next((p for p in players_list if p.id == game.teaching_deep_player_id), None) + if teacher: + players_list.remove(teacher) + players_list.insert(0, teacher) + # "Randomly choose one Player to start the game at Rank 3. This Player must play the Deep during the first scene." # "Randomly choose one Player to start the game at Rank 1. This Player must play their Pi-Rat during the first scene." # "All remaining Players start at Rank 2. These Players may choose to play either during the first scene." diff --git a/src/pirats/migrations/versions/19ee9ae5ca0e_add_teaching_deep_player_id_to_game.py b/src/pirats/migrations/versions/19ee9ae5ca0e_add_teaching_deep_player_id_to_game.py new file mode 100644 index 0000000..437c8b5 --- /dev/null +++ b/src/pirats/migrations/versions/19ee9ae5ca0e_add_teaching_deep_player_id_to_game.py @@ -0,0 +1,32 @@ +"""add teaching_deep_player_id to game + +Revision ID: 19ee9ae5ca0e +Revises: e6294f6f8a81 +Create Date: 2026-06-14 09:11:17.810765 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision = '19ee9ae5ca0e' +down_revision = 'e6294f6f8a81' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('game', schema=None) as batch_op: + batch_op.add_column(sa.Column('teaching_deep_player_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('game', schema=None) as batch_op: + batch_op.drop_column('teaching_deep_player_id') + + # ### end Alembic commands ### diff --git a/src/pirats/models.py b/src/pirats/models.py index a53dc6d..71a05fb 100644 --- a/src/pirats/models.py +++ b/src/pirats/models.py @@ -18,6 +18,7 @@ class Game(SQLModel, table=True): completed_crew_2: bool = Field(default=False) # Choose a Captain completed_crew_3: bool = Field(default=False) # Commit Piracy captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship + teaching_deep_player_id: Optional[str] = Field(default=None) # Teaching mode: an admin who seeds themselves as the forced Rank-3 Deep for the first scene (set in the lobby, honored when starting ranks are rolled) players: List["Player"] = Relationship(back_populates="game", cascade_delete=True) obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True) diff --git a/src/pirats/routes_admin.py b/src/pirats/routes_admin.py index 656bd07..680ff81 100644 --- a/src/pirats/routes_admin.py +++ b/src/pirats/routes_admin.py @@ -33,6 +33,36 @@ def set_dev_mode( crud.add_game_event(db, game.id, f"🛠 Dev Mode turned {'ON' if enabled else 'OFF'} by {player.name}.", kind="info") return {"status": "ok", "dev_mode": game.dev_mode} +# Teaching mode: an admin volunteers to be the forced Rank-3 Deep for scene 1. +# Only meaningful before starting ranks are rolled (i.e. before scene setup). +_PRE_RANK_PHASES = {"lobby", "character_creation", "swap_techniques", "assign_techniques"} + +@router.post("/game/{game_id}/player/{player_id}/teaching-mode") +def set_teaching_mode( + game_id: str, + player_id: str, + enabled: bool = Form(...), + db: Session = Depends(get_session) +): + game, player = _get_admin(db, game_id, player_id) + if game.phase not in _PRE_RANK_PHASES: + raise HTTPException(status_code=400, detail="Starting ranks have already been rolled.") + if enabled: + game.teaching_deep_player_id = player.id + elif game.teaching_deep_player_id == player.id: + game.teaching_deep_player_id = None + else: + # Disabling someone else's teaching mode shouldn't silently clear it. + return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id} + db.add(game) + db.commit() + crud.add_game_event( + db, game.id, + f"📚 {player.name} {'will teach as the Deep' if enabled else 'cancelled teaching mode'} for the first scene.", + kind="info", + ) + return {"status": "ok", "teaching_deep_player_id": game.teaching_deep_player_id} + # Dev Mode shortcut: auto-complete character creation for everyone and move on @router.post("/game/{game_id}/player/{player_id}/skip-character-creation") def skip_character_creation_route(