diff --git a/AGENTS.md b/AGENTS.md index 48b1d50..b1bb3fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions. +- If Alembic autogeneration says the target database is not up to date, create a temporary database with `DATABASE_URL=sqlite:////tmp/.db .venv/bin/alembic upgrade head`, then run the autogeneration command with that same `DATABASE_URL`. + ## Versioning & changelog The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`). diff --git a/TODO.md b/TODO.md index aa0c2fa..776560a 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ - [x] In the admin panel, the "Open" and "Copy" buttons should be the same size - [x] Store the player name in local storage and pre-populate it when joining a new game (but don't force it, let the player edit it if they'd prefer a different name) - [x] If a player tries to rejoin a game that has ended or that they've been kicked from, they should get an error message and it should be removed from the list of re-joinable games -- [ ] Games should have join codes in addition to join links. Join codes should be a sequence of 5 alphanumeric characters (upper case letters only, no ambiguous letters like 0/O/1/I). Add a panel to the home page for joining a game via code, and display the join code on the admin page +- [x] Games should have join codes in addition to join links. Join codes should be a sequence of 5 alphanumeric characters (upper case letters only, no ambiguous letters like 0/O/1/I). Add a panel to the home page for joining a game via code, and display the join code on the admin page ## Words Words Words diff --git a/frontend/src/lib/changelog.js b/frontend/src/lib/changelog.js index 27c3b98..182d5bb 100644 --- a/frontend/src/lib/changelog.js +++ b/frontend/src/lib/changelog.js @@ -6,10 +6,17 @@ // - Add a CHANGELOG entry only when a commit changes something players can // see. Skip refactors, tests, and tooling. Keep wording player-facing. -export const VERSION = 18; +export const VERSION = 19; // Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }. export const CHANGELOG = [ + { + version: 19, + date: '2026-07-10', + changes: [ + 'Games now have short join codes that can be entered on the home page and shared from the Admin Panel.', + ], + }, { version: 18, date: '2026-07-10', diff --git a/frontend/src/pages/Admin.svelte b/frontend/src/pages/Admin.svelte index 99a3647..51aeb1b 100644 --- a/frontend/src/pages/Admin.svelte +++ b/frontend/src/pages/Admin.svelte @@ -119,6 +119,7 @@

Game: {data.game.crew_name}

Phase: {data.game.phase}

+

Join Code: {data.game.join_code}

Admin Key: {data.game.admin_key}

{#if error} diff --git a/frontend/src/pages/Home.svelte b/frontend/src/pages/Home.svelte index 79ccc3c..b26fbc5 100644 --- a/frontend/src/pages/Home.svelte +++ b/frontend/src/pages/Home.svelte @@ -15,6 +15,9 @@ let crewName = ''; let error = ''; let creating = false; + let joinCode = ''; + let joinCodeError = ''; + let joiningByCode = false; function sessionRat(s) { if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`; @@ -53,6 +56,26 @@ creating = false; } } + + function updateJoinCode(event) { + joinCode = event.currentTarget.value + .toUpperCase() + .replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, '') + .slice(0, 5); + } + + async function joinByCode() { + if (joinCode.length !== 5) return; + joiningByCode = true; + joinCodeError = ''; + try { + const data = await apiRequest(`/game/code/${joinCode}`); + push(`/game/${data.id}/join`); + } catch (err) { + joinCodeError = err.message; + joiningByCode = false; + } + }
@@ -91,6 +114,36 @@
{/if} +
+

Join a Crew

+

Enter the five-character code shared by your game’s Admin.

+
+ +
+ + +
+
+ {#if joinCodeError} +
{joinCodeError}
+ {/if} +
+

Muster a New Crew

@@ -128,6 +181,25 @@ .saved-sessions { margin-bottom: 1.75rem; } + .join-code-card { + margin-bottom: 1.75rem; + } + .join-code-form { + margin-top: 1rem; + } + .join-code-action { + display: flex; + gap: 0.75rem; + align-items: stretch; + } + .join-code-input { + flex: 1; + min-width: 0; + font-family: ui-monospace, monospace; + font-weight: 700; + letter-spacing: 0.25em; + text-transform: uppercase; + } .session-list { list-style: none; margin: 0; diff --git a/src/pirats/crud_base.py b/src/pirats/crud_base.py index 058b57d..4f514af 100644 --- a/src/pirats/crud_base.py +++ b/src/pirats/crud_base.py @@ -4,7 +4,7 @@ import os import random from typing import Any, Dict, List, Optional from sqlmodel import Session, select -from .models import Game, Player, GameEvent +from .models import Game, Player, GameEvent, new_join_code from . import cards logger = logging.getLogger(__name__) @@ -253,7 +253,16 @@ def has_min_players(game: Game) -> bool: def create_game(db: Session, crew_name: Optional[str] = None) -> Game: deck = cards.get_fresh_deck() + join_code = "" + for _ in range(100): + candidate = new_join_code() + if not db.exec(select(Game).where(Game.join_code == candidate)).first(): + join_code = candidate + break + if not join_code: + raise RuntimeError("Could not allocate a unique join code") game = Game( + join_code=join_code, deck_cards=json.dumps(deck), phase="lobby", current_scene_number=1, @@ -270,6 +279,9 @@ def create_game(db: Session, crew_name: Optional[str] = None) -> Game: def get_game(db: Session, game_id: str) -> Optional[Game]: return db.get(Game, game_id) +def get_game_by_join_code(db: Session, join_code: str) -> Optional[Game]: + return db.exec(select(Game).where(Game.join_code == join_code)).first() + def get_player(db: Session, player_id: str) -> Optional[Player]: return db.get(Player, player_id) diff --git a/src/pirats/main.py b/src/pirats/main.py index 21daeba..ccc0074 100644 --- a/src/pirats/main.py +++ b/src/pirats/main.py @@ -242,6 +242,14 @@ def create_game_route( # admin_key is only revealed here, to the creator; the state endpoint hides it return {"id": game.id, "admin_key": game.admin_key} +@api.get("/game/code/{join_code}") +def find_game_by_join_code(join_code: str, db: Session = Depends(get_session)): + normalized = join_code.strip().upper() + game = crud.get_game_by_join_code(db, normalized) + if not game or game.phase == "ended": + raise HTTPException(status_code=404, detail="No active game found for that join code") + return {"id": game.id} + @api.post("/game/{game_id}/join") def join_game_submit( game_id: str, diff --git a/src/pirats/migrations/versions/6ea41638edfc_add_game_join_codes.py b/src/pirats/migrations/versions/6ea41638edfc_add_game_join_codes.py new file mode 100644 index 0000000..6d28f07 --- /dev/null +++ b/src/pirats/migrations/versions/6ea41638edfc_add_game_join_codes.py @@ -0,0 +1,59 @@ +"""add game join codes + +Revision ID: 6ea41638edfc +Revises: 153132c8e583 +Create Date: 2026-07-10 12:28:45.476200 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel +import secrets + + +revision = '6ea41638edfc' +down_revision = '153132c8e583' +branch_labels = None +depends_on = None + +JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + +def _new_join_code(used: set[str]) -> str: + while True: + code = "".join(secrets.choice(JOIN_CODE_ALPHABET) for _ in range(5)) + if code not in used: + used.add(code) + return code + + +def upgrade() -> None: + # SQLite cannot add a required column to a populated table. Add it as + # nullable, backfill every existing game with a distinct code, then tighten + # the constraint and create the unique lookup index. + op.add_column('game', sa.Column('join_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + connection = op.get_bind() + game_ids = connection.execute(sa.text("SELECT id FROM game")).scalars().all() + used: set[str] = set() + for game_id in game_ids: + connection.execute( + sa.text("UPDATE game SET join_code = :join_code WHERE id = :game_id"), + {"join_code": _new_join_code(used), "game_id": game_id}, + ) + + with op.batch_alter_table('game', schema=None) as batch_op: + batch_op.alter_column( + 'join_code', + existing_type=sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + ) + batch_op.create_index(batch_op.f('ix_game_join_code'), ['join_code'], unique=True) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('game', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_game_join_code')) + batch_op.drop_column('join_code') + + # ### end Alembic commands ### diff --git a/src/pirats/models.py b/src/pirats/models.py index 41dc4a9..08f6790 100644 --- a/src/pirats/models.py +++ b/src/pirats/models.py @@ -1,11 +1,19 @@ import time import uuid +import secrets from typing import Optional, List from sqlmodel import Field, SQLModel, Relationship +JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + +def new_join_code() -> str: + return "".join(secrets.choice(JOIN_CODE_ALPHABET) for _ in range(5)) + class Game(SQLModel, table=True): id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) admin_key: str = Field(default_factory=lambda: str(uuid.uuid4())) + join_code: str = Field(default_factory=new_join_code, index=True, unique=True) crew_name: Optional[str] = Field(default=None) phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "assign_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 diff --git a/src/pirats/routes_admin.py b/src/pirats/routes_admin.py index 680ff81..19936ca 100644 --- a/src/pirats/routes_admin.py +++ b/src/pirats/routes_admin.py @@ -133,6 +133,7 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = "game": { "id": game.id, "crew_name": game.crew_name, + "join_code": game.join_code, "phase": game.phase, "admin_key": game.admin_key }, diff --git a/tests/test_game.py b/tests/test_game.py index 2ecf7e1..2a9a854 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -443,6 +443,11 @@ def test_game_creation_with_crew_name(session): assert game.id is not None assert game.crew_name == "The Black Gats" assert game.phase == "lobby" + assert len(game.join_code) == 5 + assert set(game.join_code) <= set("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") + + another = crud.create_game(session, crew_name="The Other Gats") + assert another.join_code != game.join_code def test_create_game_endpoint(): from fastapi.testclient import TestClient @@ -475,6 +480,17 @@ def test_create_game_endpoint(): games = session.exec(select(Game)).all() assert len(games) == 1 assert games[0].crew_name == "The Math Mutineers" + + # Codes are case-insensitive at the API boundary and resolve only + # active games without exposing the UUID in the code itself. + lookup = client.get(f"/api/game/code/{games[0].join_code.lower()}") + assert lookup.status_code == 200 + assert lookup.json() == {"id": games[0].id} + + games[0].phase = "ended" + session.add(games[0]) + session.commit() + assert client.get(f"/api/game/code/{games[0].join_code}").status_code == 404 finally: app.dependency_overrides.clear()