feat(game): official first-move tile draw + admin step-by-step replay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

Decide who moves first by the official rule: each seated player draws one
tile, the one closest to "A" leads (a blank beats every letter), ties
re-drawing until a single leader remains. Each draw uses honest per-draw
crypto/rand entropy (not the deterministic bag seed), so the recorded draw —
not a seed — is the only account of the outcome. The leader takes seat 0, so
the engine and journal replay are unchanged.

The draw is recorded with the game (game_setup_draws, migration 00013) for
future tournaments, designed as a discrete "player N draws a tile" step.
Friend/AI games draw at creation. Auto-match draws when the game opens,
against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are
back-filled to the real opponent on join — so the opener's seat is fixed up
front and the existing open-game pre-move is preserved with no reseating.

Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step
board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with
A-O/1-15 headers and highlighted premium squares, placed letters with their
tile value as a subscript, rack panels around the board (seat 0 top, 1
bottom, 2 left, 3 right) with the current player highlighted, and a per-move
log with the tiles drawn and the bag remainder.

Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
This commit is contained in:
Ilia Denisov
2026-06-20 08:47:18 +02:00
parent 76d4610e6f
commit caefc8f579
27 changed files with 1661 additions and 59 deletions
@@ -0,0 +1,24 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type GameSetupDraws struct {
GameID uuid.UUID `sql:"primary_key"`
Round int16 `sql:"primary_key"`
PickNo int16 `sql:"primary_key"`
AccountID *uuid.UUID
Letter string
IsBlank bool
DrawRank int16
CreatedAt time.Time
}
@@ -0,0 +1,99 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var GameSetupDraws = newGameSetupDrawsTable("backend", "game_setup_draws", "")
type gameSetupDrawsTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Round postgres.ColumnInteger
PickNo postgres.ColumnInteger
AccountID postgres.ColumnString
Letter postgres.ColumnString
IsBlank postgres.ColumnBool
DrawRank postgres.ColumnInteger
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GameSetupDrawsTable struct {
gameSetupDrawsTable
EXCLUDED gameSetupDrawsTable
}
// AS creates new GameSetupDrawsTable with assigned alias
func (a GameSetupDrawsTable) AS(alias string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GameSetupDrawsTable with assigned schema name
func (a GameSetupDrawsTable) FromSchema(schemaName string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GameSetupDrawsTable with assigned table prefix
func (a GameSetupDrawsTable) WithPrefix(prefix string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GameSetupDrawsTable with assigned table suffix
func (a GameSetupDrawsTable) WithSuffix(suffix string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGameSetupDrawsTable(schemaName, tableName, alias string) *GameSetupDrawsTable {
return &GameSetupDrawsTable{
gameSetupDrawsTable: newGameSetupDrawsTableImpl(schemaName, tableName, alias),
EXCLUDED: newGameSetupDrawsTableImpl("", "excluded", ""),
}
}
func newGameSetupDrawsTableImpl(schemaName, tableName, alias string) gameSetupDrawsTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
RoundColumn = postgres.IntegerColumn("round")
PickNoColumn = postgres.IntegerColumn("pick_no")
AccountIDColumn = postgres.StringColumn("account_id")
LetterColumn = postgres.StringColumn("letter")
IsBlankColumn = postgres.BoolColumn("is_blank")
DrawRankColumn = postgres.IntegerColumn("draw_rank")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{GameIDColumn, RoundColumn, PickNoColumn, AccountIDColumn, LetterColumn, IsBlankColumn, DrawRankColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, LetterColumn, IsBlankColumn, DrawRankColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{IsBlankColumn, CreatedAtColumn}
)
return gameSetupDrawsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Round: RoundColumn,
PickNo: PickNoColumn,
AccountID: AccountIDColumn,
Letter: LetterColumn,
IsBlank: IsBlankColumn,
DrawRank: DrawRankColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -31,6 +31,7 @@ func UseSchema(schema string) {
GameInvitations = GameInvitations.FromSchema(schema)
GameMoves = GameMoves.FromSchema(schema)
GamePlayers = GamePlayers.FromSchema(schema)
GameSetupDraws = GameSetupDraws.FromSchema(schema)
Games = Games.FromSchema(schema)
Identities = Identities.FromSchema(schema)
Sessions = Sessions.FromSchema(schema)
@@ -0,0 +1,31 @@
-- +goose Up
-- The first-move draw (docs/ARCHITECTURE.md §6): before a game starts, each seated
-- player draws one tile from the bag and the tile closest to "A" decides who moves
-- first (a blank supersedes all letters); players tied for the best tile re-draw
-- until a single leader remains. Each draw uses fresh entropy (not the game's
-- deterministic bag seed), so this record — not a seed — is the only account of how
-- the order was chosen. It is kept for tournaments, where the draw becomes a manual
-- per-tile call. The record is dictionary-independent: the decoded letter, the blank
-- flag and the numeric draw rank describe each draw without any alphabet table. The
-- winner is reflected as seat 0 in game_players, so no order column is duplicated
-- here. In auto-match the opponent is unknown at draw time (the draw runs against a
-- synthetic placeholder when the game opens), so their draw rows carry a NULL account_id,
-- back-filled when a real opponent joins. Hidden from players for now; surfaced only in the
-- admin console.
SET search_path = backend, pg_catalog;
CREATE TABLE game_setup_draws (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
round smallint NOT NULL,
pick_no smallint NOT NULL,
account_id uuid REFERENCES accounts (account_id) ON DELETE CASCADE,
letter text NOT NULL,
is_blank boolean NOT NULL DEFAULT false,
draw_rank smallint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, round, pick_no)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE game_setup_draws;