Add short game join codes
This commit is contained in:
@@ -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`).
|
||||
|
||||
2
TODO.md
2
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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 game’s 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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user