diff --git a/TODO.md b/TODO.md index dd7e8e6..05bb54e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,6 @@ A roughly triaged list of features and improvements needed for the app, in descending order of criticality. -- [ ] **Dev Mode.** Move the dev mode toggle in character creation to the hamburger mode (and leave it around for all phases in case we want to add other dev mode features). It's now only visible to admin player, but affects all players. For non-admin players, the "suggest" buttons in the character creator are hidden unless dev mode is on. For the admin player, they also get the "auto-assign everything" button in the hamburger menu, renamed to "Skip Character Creation", which fully automates any incomplete parts of character creation for all players and moves on to the pre-scene phase. - -- [ ] **Technique swapping.** During character creation, players can offer to swap techniques with another player, as long as the technique they're offering wasn't created by the player they're offering to swap with. The swap offer should contain no information other than that a swap has been offered. This requires tracking who created each technique, which we should be doing anyway since it's illegal for a player to wind up with their own techniques and I'm not sure the current shuffler cares about that. I want two options here: First option, players start with all of their own techniques and the game can't proceed to technique assignment to cards until they've successfully swapped them all away and everyone has clicked a "ready" button. Second, a button in the hamburger menu that the Admin player can click to fully auto-assign techniques, which randomly distributes them to eligible players and automatically assigns them to J/Q/K. This button should only appear during technique distribution. It does not require dev mode, just admin privileges. +- [ ] **Technique swapping.** During character creation, players can offer to swap techniques with another player, as long as the technique they're offering wasn't created by the player they're offering to swap with. The swap offer should contain no information other than that a swap has been offered (and who offered it). This requires tracking who created each technique, which we should be doing anyway since it's illegal for a player to wind up with their own techniques and I'm not sure the current shuffler cares about that. I want two options here: First option, players start with all of their own techniques and the game can't proceed to technique assignment to cards until they've successfully swapped them all away and everyone has clicked a "ready" button. Second, a button in the hamburger menu that the Admin player can click to fully auto-assign techniques, which randomly distributes them to eligible players and automatically assigns them to J/Q/K. This button should only appear during technique distribution. It does not require dev mode, just admin privileges. - [ ] **Light mode theme**. I like the "Lantern & Brine" look (see git log 0744893e7b82fe for your reasoning/description), and want to keep it as an option, but Yeld is generally speaking a lighter, more whimsical setting. I'd like the light theme to reflect a world of blue skies, high adventure, crashing waves, and bright red Xs on parchment treasure maps. Think "Wind Waker", "One Piece", or even "Skies of Arcadia". Add an option to the hamburger menu to toggle between light and dark themes. Light should probably be the default. Remember to update the CSS in the rulebook as well. diff --git a/flake.nix b/flake.nix index b94910d..6858b12 100644 --- a/flake.nix +++ b/flake.nix @@ -117,6 +117,11 @@ default = false; description = "Open ports in the firewall for the service."; }; + devModeDefault = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether new games start with Dev Mode (testing shortcuts) enabled."; + }; }; config = lib.mkIf cfg.enable { @@ -129,6 +134,9 @@ environment = { DATABASE_URL = "sqlite:///${cfg.databasePath}"; + # Unset means "local checkout" and defaults Dev Mode on, so the + # service must always set it explicitly. + PIRATS_DEV_MODE = lib.boolToString cfg.devModeDefault; }; serviceConfig = { diff --git a/frontend/src/assets/css/components.css b/frontend/src/assets/css/components.css index c66cb37..7455e4d 100644 --- a/frontend/src/assets/css/components.css +++ b/frontend/src/assets/css/components.css @@ -428,26 +428,3 @@ gap: 0.75rem; } -/* --- Dev mode --- */ -.dev-toggle { - display: flex; - justify-content: center; - align-items: center; - gap: 0.5rem; - font-size: 0.85rem; - color: var(--text-muted); - margin-top: 0.5rem; -} - -.dev-banner { - background: var(--well); - border: 1px solid var(--accent); - border-radius: var(--radius-md); - padding: 1rem; - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - margin: 0 auto 1.5rem; - max-width: 56rem; -} diff --git a/frontend/src/components/CharacterCreationPhase.svelte b/frontend/src/components/CharacterCreationPhase.svelte index 0164269..58eeedd 100644 --- a/frontend/src/components/CharacterCreationPhase.svelte +++ b/frontend/src/components/CharacterCreationPhase.svelte @@ -1,7 +1,7 @@ @@ -172,24 +120,8 @@

Character Sheet Creation

Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.

-
- - -
- {#if devMode} -
- ๐Ÿ›  Dev Mode Tools - -
- {/if} -
@@ -212,28 +144,28 @@
- + {#if devMode}{/if}
- + {#if devMode}{/if}
- + {#if devMode}{/if}
- + {#if devMode}{/if}
@@ -279,7 +211,7 @@
- + {#if devMode}{/if}
@@ -289,7 +221,7 @@
- + {#if devMode}{/if}
@@ -315,21 +247,21 @@
- + {#if devMode}{/if}
- + {#if devMode}{/if}
- + {#if devMode}{/if}
diff --git a/frontend/src/components/CornerMenu.svelte b/frontend/src/components/CornerMenu.svelte index 13ad122..3082dea 100644 --- a/frontend/src/components/CornerMenu.svelte +++ b/frontend/src/components/CornerMenu.svelte @@ -25,13 +25,22 @@ {#if open} {/if} @@ -68,7 +77,7 @@ border-radius: var(--radius-md); overflow: hidden; } - .menu-dropdown a { + .menu-item { padding: 8px 16px; color: var(--accent); font-size: 0.85rem; @@ -77,11 +86,14 @@ font-family: var(--font-heading); text-align: right; white-space: nowrap; + background: none; + border: none; + cursor: pointer; } - .menu-dropdown a:hover { + .menu-item:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); } - .menu-dropdown a + a { + .menu-item + .menu-item { border-top: 1px solid var(--edge-soft); } diff --git a/frontend/src/components/RecruitPhase.svelte b/frontend/src/components/RecruitPhase.svelte index 9daaacc..fc35f0e 100644 --- a/frontend/src/components/RecruitPhase.svelte +++ b/frontend/src/components/RecruitPhase.svelte @@ -16,6 +16,10 @@ return p.incoming_techniques ? JSON.parse(p.incoming_techniques) : []; } + // Dev Mode (toggled by the creator from the corner menu) reveals the + // Suggest buttons; in real games players write their own material. + $: devMode = !!state.game.dev_mode; + $: me = state.player; $: recruits = state.players.filter(p => p.needs_reroll); $: iAmRecruit = me.needs_reroll; @@ -150,28 +154,28 @@
- + {#if devMode}{/if}
- + {#if devMode}{/if}
- + {#if devMode}{/if}
- + {#if devMode}{/if}
@@ -244,7 +248,7 @@
- + {#if devMode}{/if}
@@ -254,7 +258,7 @@
- + {#if devMode}{/if}
@@ -270,7 +274,7 @@ {/if}
- + {#if devMode}{/if}
diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 9776f16..6d5437d 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { apiRequest } from '../lib/api'; import { extraMenuLinks } from '../lib/menu'; + import { getSuggestion } from '../lib/suggestions'; import LobbyPhase from '../components/LobbyPhase.svelte'; import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte'; @@ -58,12 +59,60 @@ intervalId = setInterval(fetchState, 30000); }); - // Surface the creator-only Admin link in the corner menu - $: extraMenuLinks.set( - state?.player?.is_creator - ? [{ label: '๐Ÿ› ๏ธ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' }] - : [] - ); + // Creator-only corner menu entries: Dev Mode toggle (all phases), the + // Skip Character Creation shortcut (Dev Mode only), and the Admin link. + async function toggleDevMode() { + try { + await apiRequest(`/game/${gameId}/player/${playerId}/dev-mode`, 'POST', { + enabled: !state.game.dev_mode + }); + } catch (err) { + error = err.message; + } + } + + async function skipCharacterCreation() { + // Suggested values for everyone's free-text fields; the backend only + // applies them to fields players haven't filled in themselves. + const fills = {}; + for (const p of state.players) { + fills[p.id] = { + avatar_look: getSuggestion('look'), + avatar_smell: getSuggestion('smell'), + first_words: getSuggestion('first_words'), + good_at_math: getSuggestion('good_at_math'), + like: getSuggestion('like'), + hate: getSuggestion('hate'), + }; + } + try { + await apiRequest(`/game/${gameId}/player/${playerId}/skip-character-creation`, 'POST', { + fills: JSON.stringify(fills) + }); + } catch (err) { + error = err.message; + } + } + + $: { + const items = []; + if (state?.player?.is_creator) { + items.push({ + label: `๐Ÿงช Dev Mode: ${state.game.dev_mode ? 'On' : 'Off'}`, + action: toggleDevMode, + title: 'Toggle Dev Mode testing shortcuts for the whole table', + }); + if (state.game.dev_mode && (state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques')) { + items.push({ + label: 'โญ๏ธ Skip Character Creation', + action: skipCharacterCreation, + title: 'Auto-fill anything unfinished for all players and jump to scene setup', + }); + } + items.push({ label: '๐Ÿ› ๏ธ Admin', href: `#/game/${gameId}/admin`, title: 'Open the Admin Panel' }); + } + extraMenuLinks.set(items); + } onDestroy(() => { destroyed = true; diff --git a/src/pirats/crud_base.py b/src/pirats/crud_base.py index 86624d0..9b81cdf 100644 --- a/src/pirats/crud_base.py +++ b/src/pirats/crud_base.py @@ -1,4 +1,5 @@ import json +import os import random from typing import Any, Dict, List, Optional from sqlmodel import Session, select @@ -222,6 +223,17 @@ def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) - # --- CRUD Operations --- +def dev_mode_default() -> bool: + """ + Initial Dev Mode state for new games, from $PIRATS_DEV_MODE. Unset means a + local checkout, where defaulting on is convenient for testing; production + deployments (the Nix service) always set it, "false" by default. + """ + val = os.environ.get("PIRATS_DEV_MODE") + if val is None: + return True + return val.strip().lower() in ("1", "true", "yes", "on") + def create_game(db: Session, crew_name: Optional[str] = None) -> Game: deck = cards.get_fresh_deck() game = Game( @@ -229,7 +241,8 @@ def create_game(db: Session, crew_name: Optional[str] = None) -> Game: phase="lobby", current_scene_number=1, extra_obstacles=0, - crew_name=crew_name + crew_name=crew_name, + dev_mode=dev_mode_default() ) db.add(game) db.commit() diff --git a/src/pirats/crud_character.py b/src/pirats/crud_character.py index 82f60d5..8e5d177 100644 --- a/src/pirats/crud_character.py +++ b/src/pirats/crud_character.py @@ -263,6 +263,79 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s db.commit() add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase") +def skip_character_creation(db: Session, game: Game, fills: dict): + """ + Dev Mode shortcut: auto-completes every unfinished part of character + creation for ALL players and advances to scene setup. `fills` maps + player_id -> suggested values for the free-text fields (avatar_look, + avatar_smell, first_words, good_at_math, like, hate), supplied by the + admin's client from the frontend suggestion pools. Techniques come from + the backend pool. Already-filled fields are left untouched. + """ + from .cards import TECHNIQUE_SUGGESTIONS + + if game.phase not in ("character_creation", "swap_techniques"): + return False, "Character creation isn't in progress." + + if game.phase == "character_creation": + # 1. Basic Rat Records + for p in game.players: + f = fills.get(p.id, {}) + p.avatar_look = p.avatar_look or f.get("avatar_look", "A nondescript test rat") + p.avatar_smell = p.avatar_smell or f.get("avatar_smell", "of fresh paint") + p.first_words = p.first_words or f.get("first_words", "Is this thing on?") + p.good_at_math = p.good_at_math or f.get("good_at_math", "Untested") + if not p.completed_personal_2: + p.name = recruit_name(p) + db.add(p) + + # 2. Delegated Like/Hate questions + auto_delegate_all(db, game) + for p in game.players: + f = fills.get(p.id, {}) + if p.other_like_from_player_id and not p.other_like: + p.other_like = f.get("like", "Their impeccable punctuality") + if p.other_hate_from_player_id and not p.other_hate: + p.other_hate = f.get("hate", "Their impeccable punctuality") + db.add(p) + + # 3. Created techniques, drawn from the backend pool without collisions + taken = set() + for p in game.players: + for t in json.loads(p.created_techniques): + taken.add(t.strip().lower()) + available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in taken] + random.shuffle(available) + for p in game.players: + techs = json.loads(p.created_techniques) + if len(techs) < 3 or not all(techs): + techs = [available.pop() for _ in range(3)] + p.created_techniques = json.dumps(techs) + db.add(p) + db.commit() + + # 4. Shuffle and distribute (moves the game to swap_techniques) + trigger_technique_swap(db, game) + db.refresh(game) + if game.phase != "swap_techniques": + return False, "Technique swap failed; check the event log." + + # 5. Random J/Q/K assignment for anyone who hasn't done it; the last + # assignment triggers the rank rolls and the scene_setup transition. + for p in list(game.players): + if not (p.tech_jack and p.tech_queen and p.tech_king): + swapped = json.loads(p.swapped_techniques) + random.shuffle(swapped) + assign_face_card_techniques(db, p.id, swapped[0], swapped[1], swapped[2]) + elif not p.is_ready: + # Edge case: techniques assigned but ready flag lost โ€” re-assert it + assign_face_card_techniques(db, p.id, p.tech_jack, p.tech_queen, p.tech_king) + + db.refresh(game) + if game.phase != "scene_setup": + return False, "Could not finish character creation; check the event log." + return True, "Character creation skipped." + # --- Recruit Creation (death re-rolls and late joiners) --- # # Dead Pi-Rats who choose to re-roll (and players who joined after initial diff --git a/src/pirats/migrations/versions/6071db83ba81_add_game_dev_mode.py b/src/pirats/migrations/versions/6071db83ba81_add_game_dev_mode.py new file mode 100644 index 0000000..d12a3aa --- /dev/null +++ b/src/pirats/migrations/versions/6071db83ba81_add_game_dev_mode.py @@ -0,0 +1,32 @@ +"""Add Game.dev_mode + +Revision ID: 6071db83ba81 +Revises: 71c6d3e50ba3 +Create Date: 2026-06-12 11:37:36.965417 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision = '6071db83ba81' +down_revision = '71c6d3e50ba3' +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('dev_mode', sa.Boolean(), nullable=False, server_default=sa.false())) + + # ### 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('dev_mode') + + # ### end Alembic commands ### diff --git a/src/pirats/models.py b/src/pirats/models.py index cd88bb4..00d01b5 100644 --- a/src/pirats/models.py +++ b/src/pirats/models.py @@ -8,6 +8,7 @@ class Game(SQLModel, table=True): admin_key: str = Field(default_factory=lambda: str(uuid.uuid4())) crew_name: Optional[str] = Field(default=None) phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "recruit_creation", "ended" + dev_mode: bool = Field(default=False) # Creator-toggled; reveals testing shortcuts (Suggest buttons, skip character creation) to the whole table current_scene_number: int = Field(default=1) extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles) deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...] diff --git a/src/pirats/routes_admin.py b/src/pirats/routes_admin.py index fe23fc5..2c119b2 100644 --- a/src/pirats/routes_admin.py +++ b/src/pirats/routes_admin.py @@ -1,10 +1,61 @@ -from fastapi import APIRouter, Request, Depends, HTTPException +import json +from fastapi import APIRouter, Request, Form, Depends, HTTPException +from fastapi.responses import JSONResponse from sqlmodel import Session from .database import get_session from . import crud router = APIRouter() +def _get_creator(db: Session, game_id: str, player_id: str): + """Resolves (game, player), requiring the player to be the game's creator.""" + game = crud.get_game(db, game_id) + player = crud.get_player(db, player_id) + if not game or not player or player.game_id != game.id: + raise HTTPException(status_code=404, detail="Not found") + if not player.is_creator: + raise HTTPException(status_code=403, detail="Only the game creator can do that.") + return game, player + +# Creator toggles Dev Mode for the whole table (reveals testing shortcuts) +@router.post("/game/{game_id}/player/{player_id}/dev-mode") +def set_dev_mode( + game_id: str, + player_id: str, + enabled: bool = Form(...), + db: Session = Depends(get_session) +): + game, player = _get_creator(db, game_id, player_id) + if game.dev_mode != enabled: + game.dev_mode = enabled + db.add(game) + db.commit() + 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} + +# 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( + game_id: str, + player_id: str, + fills: str = Form("{}"), # JSON: player_id -> suggested values for free-text fields + db: Session = Depends(get_session) +): + game, player = _get_creator(db, game_id, player_id) + if not game.dev_mode: + raise HTTPException(status_code=403, detail="Dev Mode must be on to skip character creation.") + try: + fills_dict = json.loads(fills) if fills else {} + if not isinstance(fills_dict, dict): + raise ValueError + except ValueError: + raise HTTPException(status_code=400, detail="fills must be a JSON object.") + crud.add_game_event(db, game.id, f"๐Ÿ›  {player.name} is skipping the rest of character creation (Dev Mode).", kind="info") + ok, msg = crud.skip_character_creation(db, game, fills_dict) + if not ok: + return JSONResponse({"error": msg}, status_code=400) + return {"status": "ok"} + # Creator Admin Panel @router.get("/game/{game_id}/admin") def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)): diff --git a/tests/test_game.py b/tests/test_game.py index 885b53b..bc61a50 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1291,3 +1291,68 @@ def test_websocket_state_push(): assert ws.receive_json() == {"type": "state_changed"} finally: app.dependency_overrides.clear() + +def test_skip_character_creation(session): + game = crud.create_game(session) + p1 = crud.add_player(session, game.id, "Carlos", is_creator=True) + p2 = crud.add_player(session, game.id, "Barry") + p3 = crud.add_player(session, game.id, "Jaspar") + session.refresh(game) + game.phase = "character_creation" + game.dev_mode = True + session.add(game) + session.commit() + + # p1 did part of the work by hand; the skip must not overwrite it + p1.avatar_look = "Hand-written bandana" + session.add(p1) + session.commit() + crud.submit_techniques(session, p1.id, "Carlos Strike", "Hide", "Roll") + + fills = {p.id: { + "avatar_look": f"Look for {p.name}", + "avatar_smell": "of test cheese", + "first_words": "Test!", + "good_at_math": "Only in tests", + "like": "Their test coverage", + "hate": "Their flaky tests", + } for p in game.players} + + ok, msg = crud.skip_character_creation(session, game, fills) + assert ok, msg + session.refresh(game) + assert game.phase == "scene_setup" + + for p in (p1, p2, p3): + session.refresh(p) + assert p.avatar_look and p.avatar_smell and p.first_words and p.good_at_math + assert p.other_like and p.other_hate + assert p.tech_jack and p.tech_queen and p.tech_king + # Everyone holds techniques created by someone else + own = {t.lower() for t in json.loads(p.created_techniques)} + held = {p.tech_jack.lower(), p.tech_queen.lower(), p.tech_king.lower()} + assert not (own & held) + session.refresh(p1) + assert p1.avatar_look == "Hand-written bandana" + assert json.loads(p1.created_techniques) == ["Carlos Strike", "Hide", "Roll"] + + # Ranks were rolled: one rank-3 Deep, one rank-1 Pi-Rat, rest rank 2 + ranks = sorted(p.rank for p in (p1, p2, p3)) + assert ranks == [1, 2, 3] + +def test_skip_character_creation_wrong_phase(session): + game = crud.create_game(session) + crud.add_player(session, game.id, "Carlos", is_creator=True) + session.refresh(game) + ok, msg = crud.skip_character_creation(session, game, {}) + assert not ok + +def test_dev_mode_default_env(session, monkeypatch): + # Unset = local checkout, defaults on + monkeypatch.delenv("PIRATS_DEV_MODE", raising=False) + assert crud.create_game(session).dev_mode is True + # Set = deployment controls it explicitly + monkeypatch.setenv("PIRATS_DEV_MODE", "false") + assert crud.create_game(session).dev_mode is False + monkeypatch.setenv("PIRATS_DEV_MODE", "true") + assert crud.create_game(session).dev_mode is True