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.
- 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
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] 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

View File

@@ -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',

View File

@@ -119,6 +119,7 @@
<div class="card">
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
<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>
{#if error}

View File

@@ -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;
}
}
</script>
<div class="welcome-container">
@@ -91,6 +114,36 @@
</div>
{/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">
<h2>Muster a New Crew</h2>
<form on:submit|preventDefault={createGame}>
@@ -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;

View File

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

View File

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

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

View File

@@ -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
},

View File

@@ -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()