The durable half of the game — who is playing, what the forge calls them, and what a finished round
leaves behind — while the sea itself stays in memory, where a 20 Hz tick belongs.
Done looks like
[ ] Migrations appended (never edited) to migrations in src/db.ts: `player (id TEXT PRIMARY KEY,
name TEXT NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('forge','guest')), created_at,
last_seen_at), round (id INTEGER PRIMARY KEY, started_at, ended_at, outcome TEXT),
score (round_id, player_id, points, PRIMARY KEY (round_id, player_id))`, plus the index the
ladder query needs.
[ ] src/session.ts (new, one concept): a guest key of 32 random bytes, base64url, set as an
HttpOnly; SameSite=Lax; Path=/ cookie with Secure wherever publicOrigin is https;
playerKey(request, who) returns the forge id for a signed-in person and the guest key
otherwise, and a guest key is validated by shape before it is ever used as an id.
[ ] A forge player's row carries their forge name; a guest's name is a generated two-word handle,
never anything a client sent.
[ ] Actions in src/actions.ts, (db, who, input) in and data or a named failure out:
[ ] Tests: a forge identity and a guest key make different players; the same guest key twice is one
player; signing in while holding a guest cookie leaves one identity and prefers the forge one; a
malformed guest key is refused rather than stored; ladder() contains no guests.
[ ] bun run src/db.ts --check reports the new version and integrity ok; bun test green.
Risks The cookie is the only thing that makes a guest the same person twice; a cleared cookie is a
new player, which is correct — say so in the README (#DOCS). Do not put world state in SQLite: 20 Hz
writes are the mistake this issue exists to prevent. Guest handles must not be mistakable for forge
names on the page (#LADDER).
Fits Vesper's brief (typed modules, tests); worth Sentinel on the pull for the cookie and the identity
merge.
The durable half of the game — who is playing, what the forge calls them, and what a finished round leaves behind — while the sea itself stays in memory, where a 20 Hz tick belongs.
Done looks like
migrationsinsrc/db.ts: `player (id TEXT PRIMARY KEY,name TEXT NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('forge','guest')), created_at, last_seen_at)
,round (id INTEGER PRIMARY KEY, started_at, ended_at, outcome TEXT),score (round_id, player_id, points, PRIMARY KEY (round_id, player_id))`, plus the index the ladder query needs.src/session.ts(new, one concept): a guest key of 32 random bytes, base64url, set as anHttpOnly; SameSite=Lax; Path=/cookie withSecurewhereverpublicOriginis https;playerKey(request, who)returns the forge id for a signed-in person and the guest key otherwise, and a guest key is validated by shape before it is ever used as an id.never anything a client sent.
src/actions.ts,(db, who, input)in and data or a named failure out:ensurePlayer,touchPlayer,ladder({ days, limit }).player; signing in while holding a guest cookie leaves one identity and prefers the forge one; a malformed guest key is refused rather than stored;
ladder()contains no guests.bun run src/db.ts --checkreports the new version andintegrity ok;bun testgreen.Files
src/db.ts,src/session.ts(new),src/session.test.ts(new),src/actions.ts,src/actions.test.ts.Risks The cookie is the only thing that makes a guest the same person twice; a cleared cookie is a new player, which is correct — say so in the README (#DOCS). Do not put world state in SQLite: 20 Hz writes are the mistake this issue exists to prevent. Guest handles must not be mistakable for forge names on the page (#LADDER).
Fits Vesper's brief (typed modules, tests); worth Sentinel on the pull for the cookie and the identity merge.