Add short game join codes

This commit is contained in:
2026-07-10 12:30:43 -07:00
parent 8470da4aa9
commit b253a06b45
11 changed files with 189 additions and 3 deletions

View File

@@ -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. 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/<name>.db .venv/bin/alembic upgrade head`, then run the autogeneration command with that same `DATABASE_URL`.
## Versioning & changelog ## 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`). 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`).

View File

@@ -7,7 +7,7 @@
- [x] In the admin panel, the "Open" and "Copy" buttons should be the same size - [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] 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 - [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 ## Words Words Words

View File

@@ -6,10 +6,17 @@
// - Add a CHANGELOG entry only when a commit changes something players can // - Add a CHANGELOG entry only when a commit changes something players can
// see. Skip refactors, tests, and tooling. Keep wording player-facing. // 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, ...] }. // Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
export const CHANGELOG = [ 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, version: 18,
date: '2026-07-10', date: '2026-07-10',

View File

@@ -119,6 +119,7 @@
<div class="card"> <div class="card">
<h2 class="mb-4">Game: {data.game.crew_name}</h2> <h2 class="mb-4">Game: {data.game.crew_name}</h2>
<p><strong>Phase:</strong> {data.game.phase}</p> <p><strong>Phase:</strong> {data.game.phase}</p>
<p><strong>Join Code:</strong> <span class="admin-key-chip">{data.game.join_code}</span></p>
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p> <p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
{#if error} {#if error}

View File

@@ -15,6 +15,9 @@
let crewName = ''; let crewName = '';
let error = ''; let error = '';
let creating = false; let creating = false;
let joinCode = '';
let joinCodeError = '';
let joiningByCode = false;
function sessionRat(s) { function sessionRat(s) {
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`; if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
@@ -53,6 +56,26 @@
creating = false; 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;
}
}
</script> </script>
<div class="welcome-container"> <div class="welcome-container">
@@ -91,6 +114,36 @@
</div> </div>
{/if} {/if}
<div class="glass-panel creation-card join-code-card">
<h2>Join a Crew</h2>
<p class="info-text">Enter the five-character code shared by your games Admin.</p>
<form class="join-code-form" on:submit|preventDefault={joinByCode}>
<label for="joinCode">Join Code</label>
<div class="join-code-action">
<input
type="text"
id="joinCode"
value={joinCode}
on:input={updateJoinCode}
required
minlength="5"
maxlength="5"
placeholder="ABCDE"
class="input-field input-large join-code-input"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
/>
<button type="submit" class="btn btn-primary btn-large" disabled={joiningByCode || joinCode.length !== 5}>
{joiningByCode ? 'Joining...' : 'Join'}
</button>
</div>
</form>
{#if joinCodeError}
<div class="alert alert-danger mt-4">{joinCodeError}</div>
{/if}
</div>
<div class="glass-panel creation-card"> <div class="glass-panel creation-card">
<h2>Muster a New Crew</h2> <h2>Muster a New Crew</h2>
<form on:submit|preventDefault={createGame}> <form on:submit|preventDefault={createGame}>
@@ -128,6 +181,25 @@
.saved-sessions { .saved-sessions {
margin-bottom: 1.75rem; 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 { .session-list {
list-style: none; list-style: none;
margin: 0; margin: 0;

View File

@@ -4,7 +4,7 @@ import os
import random import random
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from sqlmodel import Session, select from sqlmodel import Session, select
from .models import Game, Player, GameEvent from .models import Game, Player, GameEvent, new_join_code
from . import cards from . import cards
logger = logging.getLogger(__name__) 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: def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
deck = cards.get_fresh_deck() 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( game = Game(
join_code=join_code,
deck_cards=json.dumps(deck), deck_cards=json.dumps(deck),
phase="lobby", phase="lobby",
current_scene_number=1, 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]: def get_game(db: Session, game_id: str) -> Optional[Game]:
return db.get(Game, game_id) 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]: def get_player(db: Session, player_id: str) -> Optional[Player]:
return db.get(Player, player_id) return db.get(Player, player_id)

View File

@@ -242,6 +242,14 @@ def create_game_route(
# admin_key is only revealed here, to the creator; the state endpoint hides it # admin_key is only revealed here, to the creator; the state endpoint hides it
return {"id": game.id, "admin_key": game.admin_key} 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") @api.post("/game/{game_id}/join")
def join_game_submit( def join_game_submit(
game_id: str, game_id: str,

View File

@@ -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 ###

View File

@@ -1,11 +1,19 @@
import time import time
import uuid import uuid
import secrets
from typing import Optional, List from typing import Optional, List
from sqlmodel import Field, SQLModel, Relationship 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): class Game(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4())) 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) 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" 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 dev_mode: bool = Field(default=False) # Creator-toggled; reveals testing shortcuts (Suggest buttons, skip character creation) to the whole table

View File

@@ -133,6 +133,7 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
"game": { "game": {
"id": game.id, "id": game.id,
"crew_name": game.crew_name, "crew_name": game.crew_name,
"join_code": game.join_code,
"phase": game.phase, "phase": game.phase,
"admin_key": game.admin_key "admin_key": game.admin_key
}, },

View File

@@ -443,6 +443,11 @@ def test_game_creation_with_crew_name(session):
assert game.id is not None assert game.id is not None
assert game.crew_name == "The Black Gats" assert game.crew_name == "The Black Gats"
assert game.phase == "lobby" 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(): def test_create_game_endpoint():
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -475,6 +480,17 @@ def test_create_game_endpoint():
games = session.exec(select(Game)).all() games = session.exec(select(Game)).all()
assert len(games) == 1 assert len(games) == 1
assert games[0].crew_name == "The Math Mutineers" 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: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()