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