Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 8s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 8s

internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.

Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.

Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.

Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
This commit is contained in:
Ilia Denisov
2026-06-02 17:33:49 +02:00
parent f36f3df748
commit 751e74b14f
45 changed files with 4220 additions and 103 deletions
@@ -0,0 +1,23 @@
//
// 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 AccountStats struct {
AccountID uuid.UUID `sql:"primary_key"`
Wins int32
Losses int32
Draws int32
MaxGamePoints int32
MaxWordPoints int32
UpdatedAt time.Time
}
@@ -21,4 +21,7 @@ type Accounts struct {
BlockFriendRequests bool
CreatedAt time.Time
UpdatedAt time.Time
AwayStart time.Time
AwayEnd time.Time
HintBalance int32
}
@@ -0,0 +1,26 @@
//
// 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 Complaints struct {
ComplaintID uuid.UUID `sql:"primary_key"`
ComplainantID uuid.UUID
GameID uuid.UUID
Variant string
DictVersion string
Word string
WasValid bool
Note string
Status string
CreatedAt time.Time
}
@@ -0,0 +1,25 @@
//
// 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 GameMoves struct {
GameID uuid.UUID `sql:"primary_key"`
Seq int32 `sql:"primary_key"`
Seat int16
Action string
Score int32
RunningTotal int32
ExchangedCount int16
Payload string
CreatedAt time.Time
}
@@ -0,0 +1,21 @@
//
// 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"
)
type GamePlayers struct {
GameID uuid.UUID `sql:"primary_key"`
Seat int16 `sql:"primary_key"`
AccountID uuid.UUID
Score int32
HintsUsed int16
IsWinner bool
}
@@ -0,0 +1,32 @@
//
// 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 Games struct {
GameID uuid.UUID `sql:"primary_key"`
Variant string
DictVersion string
Seed int64
Status string
Players int16
ToMove int16
TurnStartedAt time.Time
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
MoveCount int32
EndReason *string
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
}
@@ -0,0 +1,96 @@
//
// 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 AccountStats = newAccountStatsTable("backend", "account_stats", "")
type accountStatsTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Wins postgres.ColumnInteger
Losses postgres.ColumnInteger
Draws postgres.ColumnInteger
MaxGamePoints postgres.ColumnInteger
MaxWordPoints postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type AccountStatsTable struct {
accountStatsTable
EXCLUDED accountStatsTable
}
// AS creates new AccountStatsTable with assigned alias
func (a AccountStatsTable) AS(alias string) *AccountStatsTable {
return newAccountStatsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AccountStatsTable with assigned schema name
func (a AccountStatsTable) FromSchema(schemaName string) *AccountStatsTable {
return newAccountStatsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AccountStatsTable with assigned table prefix
func (a AccountStatsTable) WithPrefix(prefix string) *AccountStatsTable {
return newAccountStatsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AccountStatsTable with assigned table suffix
func (a AccountStatsTable) WithSuffix(suffix string) *AccountStatsTable {
return newAccountStatsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAccountStatsTable(schemaName, tableName, alias string) *AccountStatsTable {
return &AccountStatsTable{
accountStatsTable: newAccountStatsTableImpl(schemaName, tableName, alias),
EXCLUDED: newAccountStatsTableImpl("", "excluded", ""),
}
}
func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
WinsColumn = postgres.IntegerColumn("wins")
LossesColumn = postgres.IntegerColumn("losses")
DrawsColumn = postgres.IntegerColumn("draws")
MaxGamePointsColumn = postgres.IntegerColumn("max_game_points")
MaxWordPointsColumn = postgres.IntegerColumn("max_word_points")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
)
return accountStatsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Wins: WinsColumn,
Losses: LossesColumn,
Draws: DrawsColumn,
MaxGamePoints: MaxGamePointsColumn,
MaxWordPoints: MaxWordPointsColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -25,6 +25,9 @@ type accountsTable struct {
BlockFriendRequests postgres.ColumnBool
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AwayStart postgres.ColumnTime
AwayEnd postgres.ColumnTime
HintBalance postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -74,9 +77,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn}
AwayStartColumn = postgres.TimeColumn("away_start")
AwayEndColumn = postgres.TimeColumn("away_end")
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn}
)
return accountsTable{
@@ -91,6 +97,9 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
BlockFriendRequests: BlockFriendRequestsColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AwayStart: AwayStartColumn,
AwayEnd: AwayEndColumn,
HintBalance: HintBalanceColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,105 @@
//
// 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 Complaints = newComplaintsTable("backend", "complaints", "")
type complaintsTable struct {
postgres.Table
// Columns
ComplaintID postgres.ColumnString
ComplainantID postgres.ColumnString
GameID postgres.ColumnString
Variant postgres.ColumnString
DictVersion postgres.ColumnString
Word postgres.ColumnString
WasValid postgres.ColumnBool
Note postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ComplaintsTable struct {
complaintsTable
EXCLUDED complaintsTable
}
// AS creates new ComplaintsTable with assigned alias
func (a ComplaintsTable) AS(alias string) *ComplaintsTable {
return newComplaintsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ComplaintsTable with assigned schema name
func (a ComplaintsTable) FromSchema(schemaName string) *ComplaintsTable {
return newComplaintsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ComplaintsTable with assigned table prefix
func (a ComplaintsTable) WithPrefix(prefix string) *ComplaintsTable {
return newComplaintsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ComplaintsTable with assigned table suffix
func (a ComplaintsTable) WithSuffix(suffix string) *ComplaintsTable {
return newComplaintsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newComplaintsTable(schemaName, tableName, alias string) *ComplaintsTable {
return &ComplaintsTable{
complaintsTable: newComplaintsTableImpl(schemaName, tableName, alias),
EXCLUDED: newComplaintsTableImpl("", "excluded", ""),
}
}
func newComplaintsTableImpl(schemaName, tableName, alias string) complaintsTable {
var (
ComplaintIDColumn = postgres.StringColumn("complaint_id")
ComplainantIDColumn = postgres.StringColumn("complainant_id")
GameIDColumn = postgres.StringColumn("game_id")
VariantColumn = postgres.StringColumn("variant")
DictVersionColumn = postgres.StringColumn("dict_version")
WordColumn = postgres.StringColumn("word")
WasValidColumn = postgres.BoolColumn("was_valid")
NoteColumn = postgres.StringColumn("note")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{ComplaintIDColumn, ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{NoteColumn, StatusColumn, CreatedAtColumn}
)
return complaintsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ComplaintID: ComplaintIDColumn,
ComplainantID: ComplainantIDColumn,
GameID: GameIDColumn,
Variant: VariantColumn,
DictVersion: DictVersionColumn,
Word: WordColumn,
WasValid: WasValidColumn,
Note: NoteColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,102 @@
//
// 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 GameMoves = newGameMovesTable("backend", "game_moves", "")
type gameMovesTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Seq postgres.ColumnInteger
Seat postgres.ColumnInteger
Action postgres.ColumnString
Score postgres.ColumnInteger
RunningTotal postgres.ColumnInteger
ExchangedCount postgres.ColumnInteger
Payload postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GameMovesTable struct {
gameMovesTable
EXCLUDED gameMovesTable
}
// AS creates new GameMovesTable with assigned alias
func (a GameMovesTable) AS(alias string) *GameMovesTable {
return newGameMovesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GameMovesTable with assigned schema name
func (a GameMovesTable) FromSchema(schemaName string) *GameMovesTable {
return newGameMovesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GameMovesTable with assigned table prefix
func (a GameMovesTable) WithPrefix(prefix string) *GameMovesTable {
return newGameMovesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GameMovesTable with assigned table suffix
func (a GameMovesTable) WithSuffix(suffix string) *GameMovesTable {
return newGameMovesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGameMovesTable(schemaName, tableName, alias string) *GameMovesTable {
return &GameMovesTable{
gameMovesTable: newGameMovesTableImpl(schemaName, tableName, alias),
EXCLUDED: newGameMovesTableImpl("", "excluded", ""),
}
}
func newGameMovesTableImpl(schemaName, tableName, alias string) gameMovesTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
SeqColumn = postgres.IntegerColumn("seq")
SeatColumn = postgres.IntegerColumn("seat")
ActionColumn = postgres.StringColumn("action")
ScoreColumn = postgres.IntegerColumn("score")
RunningTotalColumn = postgres.IntegerColumn("running_total")
ExchangedCountColumn = postgres.IntegerColumn("exchanged_count")
PayloadColumn = postgres.StringColumn("payload")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{GameIDColumn, SeqColumn, SeatColumn, ActionColumn, ScoreColumn, RunningTotalColumn, ExchangedCountColumn, PayloadColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{SeatColumn, ActionColumn, ScoreColumn, RunningTotalColumn, ExchangedCountColumn, PayloadColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{ScoreColumn, RunningTotalColumn, ExchangedCountColumn, PayloadColumn, CreatedAtColumn}
)
return gameMovesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Seq: SeqColumn,
Seat: SeatColumn,
Action: ActionColumn,
Score: ScoreColumn,
RunningTotal: RunningTotalColumn,
ExchangedCount: ExchangedCountColumn,
Payload: PayloadColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,93 @@
//
// 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 GamePlayers = newGamePlayersTable("backend", "game_players", "")
type gamePlayersTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Seat postgres.ColumnInteger
AccountID postgres.ColumnString
Score postgres.ColumnInteger
HintsUsed postgres.ColumnInteger
IsWinner postgres.ColumnBool
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GamePlayersTable struct {
gamePlayersTable
EXCLUDED gamePlayersTable
}
// AS creates new GamePlayersTable with assigned alias
func (a GamePlayersTable) AS(alias string) *GamePlayersTable {
return newGamePlayersTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GamePlayersTable with assigned schema name
func (a GamePlayersTable) FromSchema(schemaName string) *GamePlayersTable {
return newGamePlayersTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GamePlayersTable with assigned table prefix
func (a GamePlayersTable) WithPrefix(prefix string) *GamePlayersTable {
return newGamePlayersTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GamePlayersTable with assigned table suffix
func (a GamePlayersTable) WithSuffix(suffix string) *GamePlayersTable {
return newGamePlayersTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGamePlayersTable(schemaName, tableName, alias string) *GamePlayersTable {
return &GamePlayersTable{
gamePlayersTable: newGamePlayersTableImpl(schemaName, tableName, alias),
EXCLUDED: newGamePlayersTableImpl("", "excluded", ""),
}
}
func newGamePlayersTableImpl(schemaName, tableName, alias string) gamePlayersTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
SeatColumn = postgres.IntegerColumn("seat")
AccountIDColumn = postgres.StringColumn("account_id")
ScoreColumn = postgres.IntegerColumn("score")
HintsUsedColumn = postgres.IntegerColumn("hints_used")
IsWinnerColumn = postgres.BoolColumn("is_winner")
allColumns = postgres.ColumnList{GameIDColumn, SeatColumn, AccountIDColumn, ScoreColumn, HintsUsedColumn, IsWinnerColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, ScoreColumn, HintsUsedColumn, IsWinnerColumn}
defaultColumns = postgres.ColumnList{ScoreColumn, HintsUsedColumn, IsWinnerColumn}
)
return gamePlayersTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Seat: SeatColumn,
AccountID: AccountIDColumn,
Score: ScoreColumn,
HintsUsed: HintsUsedColumn,
IsWinner: IsWinnerColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,123 @@
//
// 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 Games = newGamesTable("backend", "games", "")
type gamesTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Variant postgres.ColumnString
DictVersion postgres.ColumnString
Seed postgres.ColumnInteger
Status postgres.ColumnString
Players postgres.ColumnInteger
ToMove postgres.ColumnInteger
TurnStartedAt postgres.ColumnTimestampz
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
MoveCount postgres.ColumnInteger
EndReason postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GamesTable struct {
gamesTable
EXCLUDED gamesTable
}
// AS creates new GamesTable with assigned alias
func (a GamesTable) AS(alias string) *GamesTable {
return newGamesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GamesTable with assigned schema name
func (a GamesTable) FromSchema(schemaName string) *GamesTable {
return newGamesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GamesTable with assigned table prefix
func (a GamesTable) WithPrefix(prefix string) *GamesTable {
return newGamesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GamesTable with assigned table suffix
func (a GamesTable) WithSuffix(suffix string) *GamesTable {
return newGamesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGamesTable(schemaName, tableName, alias string) *GamesTable {
return &GamesTable{
gamesTable: newGamesTableImpl(schemaName, tableName, alias),
EXCLUDED: newGamesTableImpl("", "excluded", ""),
}
}
func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
VariantColumn = postgres.StringColumn("variant")
DictVersionColumn = postgres.StringColumn("dict_version")
SeedColumn = postgres.IntegerColumn("seed")
StatusColumn = postgres.StringColumn("status")
PlayersColumn = postgres.IntegerColumn("players")
ToMoveColumn = postgres.IntegerColumn("to_move")
TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
MoveCountColumn = postgres.IntegerColumn("move_count")
EndReasonColumn = postgres.StringColumn("end_reason")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn}
)
return gamesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Variant: VariantColumn,
DictVersion: DictVersionColumn,
Seed: SeedColumn,
Status: StatusColumn,
Players: PlayersColumn,
ToMove: ToMoveColumn,
TurnStartedAt: TurnStartedAtColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
MoveCount: MoveCountColumn,
EndReason: EndReasonColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -10,7 +10,12 @@ package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program.
func UseSchema(schema string) {
AccountStats = AccountStats.FromSchema(schema)
Accounts = Accounts.FromSchema(schema)
Complaints = Complaints.FromSchema(schema)
GameMoves = GameMoves.FromSchema(schema)
GamePlayers = GamePlayers.FromSchema(schema)
Games = Games.FromSchema(schema)
Identities = Identities.FromSchema(schema)
Sessions = Sessions.FromSchema(schema)
}
@@ -0,0 +1,133 @@
-- +goose Up
-- Stage 3 game domain: the per-game lifecycle, the dictionary-independent move
-- journal, word-check complaints and per-account statistics, plus two account
-- columns the game domain needs.
SET search_path = backend, pg_catalog;
-- Extend accounts with the per-user away window (one interval per day, in the
-- account's local time_zone) honoured by the turn-timeout sweeper -- and, in a
-- later stage, by the robot's sleep -- and a hint wallet (purchasable hints; the
-- purchase flow lands later, so the balance defaults to empty). Profile editing
-- of the away window arrives with the profile surface (Stage 4).
ALTER TABLE accounts
ADD COLUMN away_start time NOT NULL DEFAULT '00:00',
ADD COLUMN away_end time NOT NULL DEFAULT '07:00',
ADD COLUMN hint_balance integer NOT NULL DEFAULT 0,
ADD CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0);
-- One match. The live position is event-sourced: this row carries the pinned
-- dictionary, the bag seed and the denormalised turn cursor the sweeper needs,
-- while game_moves is the append-only journal the in-memory engine.Game is
-- replayed from (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move
-- clock; its allowed values are enforced in Go. variant uses engine.Variant's
-- stable labels.
CREATE TABLE games (
game_id uuid PRIMARY KEY,
variant text NOT NULL,
dict_version text NOT NULL,
seed bigint NOT NULL,
status text NOT NULL DEFAULT 'active',
players smallint NOT NULL,
to_move smallint NOT NULL DEFAULT 0,
turn_started_at timestamptz NOT NULL DEFAULT now(),
turn_timeout_secs integer NOT NULL,
hints_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1,
move_count integer NOT NULL DEFAULT 0,
end_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
CONSTRAINT games_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0),
CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
)
);
-- The sweeper scans active games oldest-turn-first; a partial index keeps it
-- off the finished archive.
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
-- durable account (guests and robots are revisited when they arrive). score is
-- the running/final score, is_winner is stamped on finish (false for every seat
-- on a draw), hints_used counts the per-game allowance consumed before the
-- profile wallet.
CREATE TABLE game_players (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seat smallint NOT NULL,
account_id uuid NOT NULL REFERENCES accounts (account_id),
score integer NOT NULL DEFAULT 0,
hints_used smallint NOT NULL DEFAULT 0,
is_winner boolean NOT NULL DEFAULT false,
PRIMARY KEY (game_id, seat),
CONSTRAINT game_players_account_key UNIQUE (game_id, account_id)
);
CREATE INDEX game_players_account_idx ON game_players (account_id);
-- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md
-- §9.1). seq orders the moves from 0. payload holds the decoded values needed to
-- both replay the game through the engine and emit GCG without a dictionary: the
-- acting rack, and for a play its direction, placed tiles and formed words; for
-- an exchange the swapped tiles. score / running_total / exchanged_count are
-- lifted out for cheap history rendering.
CREATE TABLE game_moves (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seq integer NOT NULL,
seat smallint NOT NULL,
action text NOT NULL,
score integer NOT NULL DEFAULT 0,
running_total integer NOT NULL DEFAULT 0,
exchanged_count smallint NOT NULL DEFAULT 0,
payload text NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, seq),
CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout'))
);
-- Word-check complaints captured in the context of a game's pinned dictionary.
-- The admin review queue and the resolution lifecycle land in Stage 9, which
-- owns the status state machine; Stage 3 only ever writes 'open'.
CREATE TABLE complaints (
complaint_id uuid PRIMARY KEY,
complainant_id uuid NOT NULL REFERENCES accounts (account_id),
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
variant text NOT NULL,
dict_version text NOT NULL,
word text NOT NULL,
was_valid boolean NOT NULL,
note text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'open',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX complaints_status_idx ON complaints (status);
-- Per-account lifetime statistics, recomputed incrementally on each game finish.
-- Guests have no durable account and never appear here. A draw increments draws
-- only (neither wins nor losses). max_word_points is the best single move score
-- (which already folds in every word the move formed and the all-tiles bonus).
CREATE TABLE account_stats (
account_id uuid PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE,
wins integer NOT NULL DEFAULT 0,
losses integer NOT NULL DEFAULT 0,
draws integer NOT NULL DEFAULT 0,
max_game_points integer NOT NULL DEFAULT 0,
max_word_points integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE account_stats;
DROP TABLE complaints;
DROP TABLE game_moves;
DROP TABLE game_players;
DROP TABLE games;
ALTER TABLE accounts
DROP CONSTRAINT accounts_hint_balance_chk,
DROP COLUMN hint_balance,
DROP COLUMN away_end,
DROP COLUMN away_start;