Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 9s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 9s

Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged.

New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer).

Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
This commit is contained in:
Ilia Denisov
2026-06-02 19:29:30 +02:00
parent 571bc8c9f2
commit bfa8797f8c
54 changed files with 4270 additions and 81 deletions
@@ -0,0 +1,19 @@
//
// 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 Blocks struct {
BlockerID uuid.UUID `sql:"primary_key"`
BlockedID uuid.UUID `sql:"primary_key"`
CreatedAt time.Time
}
@@ -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 ChatMessages struct {
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP *string
CreatedAt time.Time
}
@@ -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 EmailConfirmations struct {
ConfirmationID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Email string
CodeHash string
ExpiresAt time.Time
Attempts int16
ConsumedAt *time.Time
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"
"time"
)
type Friendships struct {
RequesterID uuid.UUID `sql:"primary_key"`
AddresseeID uuid.UUID `sql:"primary_key"`
Status string
CreatedAt time.Time
RespondedAt *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"
"time"
)
type GameInvitationInvitees struct {
InvitationID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID `sql:"primary_key"`
Seat int16
Response string
RespondedAt *time.Time
}
@@ -0,0 +1,28 @@
//
// 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 GameInvitations struct {
InvitationID uuid.UUID `sql:"primary_key"`
InviterID uuid.UUID
Variant string
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
DropoutTiles string
Status string
GameID *uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -29,4 +29,5 @@ type Games struct {
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
DropoutTiles string
}
@@ -0,0 +1,84 @@
//
// 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 Blocks = newBlocksTable("backend", "blocks", "")
type blocksTable struct {
postgres.Table
// Columns
BlockerID postgres.ColumnString
BlockedID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type BlocksTable struct {
blocksTable
EXCLUDED blocksTable
}
// AS creates new BlocksTable with assigned alias
func (a BlocksTable) AS(alias string) *BlocksTable {
return newBlocksTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new BlocksTable with assigned schema name
func (a BlocksTable) FromSchema(schemaName string) *BlocksTable {
return newBlocksTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new BlocksTable with assigned table prefix
func (a BlocksTable) WithPrefix(prefix string) *BlocksTable {
return newBlocksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new BlocksTable with assigned table suffix
func (a BlocksTable) WithSuffix(suffix string) *BlocksTable {
return newBlocksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newBlocksTable(schemaName, tableName, alias string) *BlocksTable {
return &BlocksTable{
blocksTable: newBlocksTableImpl(schemaName, tableName, alias),
EXCLUDED: newBlocksTableImpl("", "excluded", ""),
}
}
func newBlocksTableImpl(schemaName, tableName, alias string) blocksTable {
var (
BlockerIDColumn = postgres.StringColumn("blocker_id")
BlockedIDColumn = postgres.StringColumn("blocked_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{BlockerIDColumn, BlockedIDColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{CreatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return blocksTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
BlockerID: BlockerIDColumn,
BlockedID: BlockedIDColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -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 ChatMessages = newChatMessagesTable("backend", "chat_messages", "")
type chatMessagesTable struct {
postgres.Table
// Columns
MessageID postgres.ColumnString
GameID postgres.ColumnString
SenderID postgres.ColumnString
Kind postgres.ColumnString
Body postgres.ColumnString
SenderIP postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ChatMessagesTable struct {
chatMessagesTable
EXCLUDED chatMessagesTable
}
// AS creates new ChatMessagesTable with assigned alias
func (a ChatMessagesTable) AS(alias string) *ChatMessagesTable {
return newChatMessagesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ChatMessagesTable with assigned schema name
func (a ChatMessagesTable) FromSchema(schemaName string) *ChatMessagesTable {
return newChatMessagesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ChatMessagesTable with assigned table prefix
func (a ChatMessagesTable) WithPrefix(prefix string) *ChatMessagesTable {
return newChatMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ChatMessagesTable with assigned table suffix
func (a ChatMessagesTable) WithSuffix(suffix string) *ChatMessagesTable {
return newChatMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newChatMessagesTable(schemaName, tableName, alias string) *ChatMessagesTable {
return &ChatMessagesTable{
chatMessagesTable: newChatMessagesTableImpl(schemaName, tableName, alias),
EXCLUDED: newChatMessagesTableImpl("", "excluded", ""),
}
}
func newChatMessagesTableImpl(schemaName, tableName, alias string) chatMessagesTable {
var (
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
SenderIDColumn = postgres.StringColumn("sender_id")
KindColumn = postgres.StringColumn("kind")
BodyColumn = postgres.StringColumn("body")
SenderIPColumn = postgres.StringColumn("sender_ip")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn}
)
return chatMessagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
MessageID: MessageIDColumn,
GameID: GameIDColumn,
SenderID: SenderIDColumn,
Kind: KindColumn,
Body: BodyColumn,
SenderIP: SenderIPColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -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 EmailConfirmations = newEmailConfirmationsTable("backend", "email_confirmations", "")
type emailConfirmationsTable struct {
postgres.Table
// Columns
ConfirmationID postgres.ColumnString
AccountID postgres.ColumnString
Email postgres.ColumnString
CodeHash postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz
Attempts postgres.ColumnInteger
ConsumedAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type EmailConfirmationsTable struct {
emailConfirmationsTable
EXCLUDED emailConfirmationsTable
}
// AS creates new EmailConfirmationsTable with assigned alias
func (a EmailConfirmationsTable) AS(alias string) *EmailConfirmationsTable {
return newEmailConfirmationsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new EmailConfirmationsTable with assigned schema name
func (a EmailConfirmationsTable) FromSchema(schemaName string) *EmailConfirmationsTable {
return newEmailConfirmationsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new EmailConfirmationsTable with assigned table prefix
func (a EmailConfirmationsTable) WithPrefix(prefix string) *EmailConfirmationsTable {
return newEmailConfirmationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new EmailConfirmationsTable with assigned table suffix
func (a EmailConfirmationsTable) WithSuffix(suffix string) *EmailConfirmationsTable {
return newEmailConfirmationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newEmailConfirmationsTable(schemaName, tableName, alias string) *EmailConfirmationsTable {
return &EmailConfirmationsTable{
emailConfirmationsTable: newEmailConfirmationsTableImpl(schemaName, tableName, alias),
EXCLUDED: newEmailConfirmationsTableImpl("", "excluded", ""),
}
}
func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailConfirmationsTable {
var (
ConfirmationIDColumn = postgres.StringColumn("confirmation_id")
AccountIDColumn = postgres.StringColumn("account_id")
EmailColumn = postgres.StringColumn("email")
CodeHashColumn = postgres.StringColumn("code_hash")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
AttemptsColumn = postgres.IntegerColumn("attempts")
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn}
)
return emailConfirmationsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ConfirmationID: ConfirmationIDColumn,
AccountID: AccountIDColumn,
Email: EmailColumn,
CodeHash: CodeHashColumn,
ExpiresAt: ExpiresAtColumn,
Attempts: AttemptsColumn,
ConsumedAt: ConsumedAtColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,90 @@
//
// 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 Friendships = newFriendshipsTable("backend", "friendships", "")
type friendshipsTable struct {
postgres.Table
// Columns
RequesterID postgres.ColumnString
AddresseeID postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
RespondedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type FriendshipsTable struct {
friendshipsTable
EXCLUDED friendshipsTable
}
// AS creates new FriendshipsTable with assigned alias
func (a FriendshipsTable) AS(alias string) *FriendshipsTable {
return newFriendshipsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new FriendshipsTable with assigned schema name
func (a FriendshipsTable) FromSchema(schemaName string) *FriendshipsTable {
return newFriendshipsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new FriendshipsTable with assigned table prefix
func (a FriendshipsTable) WithPrefix(prefix string) *FriendshipsTable {
return newFriendshipsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new FriendshipsTable with assigned table suffix
func (a FriendshipsTable) WithSuffix(suffix string) *FriendshipsTable {
return newFriendshipsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newFriendshipsTable(schemaName, tableName, alias string) *FriendshipsTable {
return &FriendshipsTable{
friendshipsTable: newFriendshipsTableImpl(schemaName, tableName, alias),
EXCLUDED: newFriendshipsTableImpl("", "excluded", ""),
}
}
func newFriendshipsTableImpl(schemaName, tableName, alias string) friendshipsTable {
var (
RequesterIDColumn = postgres.StringColumn("requester_id")
AddresseeIDColumn = postgres.StringColumn("addressee_id")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
RespondedAtColumn = postgres.TimestampzColumn("responded_at")
allColumns = postgres.ColumnList{RequesterIDColumn, AddresseeIDColumn, StatusColumn, CreatedAtColumn, RespondedAtColumn}
mutableColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, RespondedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
)
return friendshipsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
RequesterID: RequesterIDColumn,
AddresseeID: AddresseeIDColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
RespondedAt: RespondedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,90 @@
//
// 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 GameInvitationInvitees = newGameInvitationInviteesTable("backend", "game_invitation_invitees", "")
type gameInvitationInviteesTable struct {
postgres.Table
// Columns
InvitationID postgres.ColumnString
AccountID postgres.ColumnString
Seat postgres.ColumnInteger
Response postgres.ColumnString
RespondedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GameInvitationInviteesTable struct {
gameInvitationInviteesTable
EXCLUDED gameInvitationInviteesTable
}
// AS creates new GameInvitationInviteesTable with assigned alias
func (a GameInvitationInviteesTable) AS(alias string) *GameInvitationInviteesTable {
return newGameInvitationInviteesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GameInvitationInviteesTable with assigned schema name
func (a GameInvitationInviteesTable) FromSchema(schemaName string) *GameInvitationInviteesTable {
return newGameInvitationInviteesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GameInvitationInviteesTable with assigned table prefix
func (a GameInvitationInviteesTable) WithPrefix(prefix string) *GameInvitationInviteesTable {
return newGameInvitationInviteesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GameInvitationInviteesTable with assigned table suffix
func (a GameInvitationInviteesTable) WithSuffix(suffix string) *GameInvitationInviteesTable {
return newGameInvitationInviteesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGameInvitationInviteesTable(schemaName, tableName, alias string) *GameInvitationInviteesTable {
return &GameInvitationInviteesTable{
gameInvitationInviteesTable: newGameInvitationInviteesTableImpl(schemaName, tableName, alias),
EXCLUDED: newGameInvitationInviteesTableImpl("", "excluded", ""),
}
}
func newGameInvitationInviteesTableImpl(schemaName, tableName, alias string) gameInvitationInviteesTable {
var (
InvitationIDColumn = postgres.StringColumn("invitation_id")
AccountIDColumn = postgres.StringColumn("account_id")
SeatColumn = postgres.IntegerColumn("seat")
ResponseColumn = postgres.StringColumn("response")
RespondedAtColumn = postgres.TimestampzColumn("responded_at")
allColumns = postgres.ColumnList{InvitationIDColumn, AccountIDColumn, SeatColumn, ResponseColumn, RespondedAtColumn}
mutableColumns = postgres.ColumnList{SeatColumn, ResponseColumn, RespondedAtColumn}
defaultColumns = postgres.ColumnList{ResponseColumn}
)
return gameInvitationInviteesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
InvitationID: InvitationIDColumn,
AccountID: AccountIDColumn,
Seat: SeatColumn,
Response: ResponseColumn,
RespondedAt: RespondedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,111 @@
//
// 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 GameInvitations = newGameInvitationsTable("backend", "game_invitations", "")
type gameInvitationsTable struct {
postgres.Table
// Columns
InvitationID postgres.ColumnString
InviterID postgres.ColumnString
Variant postgres.ColumnString
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
DropoutTiles postgres.ColumnString
Status postgres.ColumnString
GameID postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GameInvitationsTable struct {
gameInvitationsTable
EXCLUDED gameInvitationsTable
}
// AS creates new GameInvitationsTable with assigned alias
func (a GameInvitationsTable) AS(alias string) *GameInvitationsTable {
return newGameInvitationsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GameInvitationsTable with assigned schema name
func (a GameInvitationsTable) FromSchema(schemaName string) *GameInvitationsTable {
return newGameInvitationsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GameInvitationsTable with assigned table prefix
func (a GameInvitationsTable) WithPrefix(prefix string) *GameInvitationsTable {
return newGameInvitationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GameInvitationsTable with assigned table suffix
func (a GameInvitationsTable) WithSuffix(suffix string) *GameInvitationsTable {
return newGameInvitationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGameInvitationsTable(schemaName, tableName, alias string) *GameInvitationsTable {
return &GameInvitationsTable{
gameInvitationsTable: newGameInvitationsTableImpl(schemaName, tableName, alias),
EXCLUDED: newGameInvitationsTableImpl("", "excluded", ""),
}
}
func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvitationsTable {
var (
InvitationIDColumn = postgres.StringColumn("invitation_id")
InviterIDColumn = postgres.StringColumn("inviter_id")
VariantColumn = postgres.StringColumn("variant")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
StatusColumn = postgres.StringColumn("status")
GameIDColumn = postgres.StringColumn("game_id")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
)
return gameInvitationsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
InvitationID: InvitationIDColumn,
InviterID: InviterIDColumn,
Variant: VariantColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
DropoutTiles: DropoutTilesColumn,
Status: StatusColumn,
GameID: GameIDColumn,
ExpiresAt: ExpiresAtColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -33,6 +33,7 @@ type gamesTable struct {
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -90,9 +91,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
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}
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn}
)
return gamesTable{
@@ -115,6 +117,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn,
DropoutTiles: DropoutTilesColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -12,7 +12,13 @@ package table
func UseSchema(schema string) {
AccountStats = AccountStats.FromSchema(schema)
Accounts = Accounts.FromSchema(schema)
Blocks = Blocks.FromSchema(schema)
ChatMessages = ChatMessages.FromSchema(schema)
Complaints = Complaints.FromSchema(schema)
EmailConfirmations = EmailConfirmations.FromSchema(schema)
Friendships = Friendships.FromSchema(schema)
GameInvitationInvitees = GameInvitationInvitees.FromSchema(schema)
GameInvitations = GameInvitations.FromSchema(schema)
GameMoves = GameMoves.FromSchema(schema)
GamePlayers = GamePlayers.FromSchema(schema)
Games = Games.FromSchema(schema)
@@ -0,0 +1,136 @@
-- +goose Up
-- Stage 4 lobby & social: the friend graph, per-user blocks, per-game chat (with
-- nudge folded in as a message kind), email confirm-codes, and friend-game
-- invitations -- plus the per-game drop-out tile disposition the multi-player
-- engine needs. Matchmaking is an in-memory pool and persists nothing.
SET search_path = backend, pg_catalog;
-- The disposition of a dropped-out player's tiles in a game with three or more
-- seats (docs/ARCHITECTURE.md §6), chosen at creation: 'remove' burns them
-- (default), 'return' puts them back in the bag. Moot for a two-player game,
-- which ends on the first drop-out. engine.DropoutTiles owns the stable labels.
ALTER TABLE games
ADD COLUMN dropout_tiles text NOT NULL DEFAULT 'remove',
ADD CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'));
-- The friend graph. A row is created by the requester as 'pending' and flipped to
-- 'accepted' by the addressee; declining, cancelling or unfriending deletes the
-- row. Friendship is symmetric: a player's friends are the accepted rows in
-- either direction. A pair has at most one row (guarded in Go against either
-- direction existing).
CREATE TABLE friendships (
requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
responded_at timestamptz,
PRIMARY KEY (requester_id, addressee_id),
CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted')),
CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
);
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);
-- Per-user blocks. blocker_id has blocked blocked_id; the effect is applied
-- mutually by the social checks (a block in either direction suppresses chat
-- visibility and prevents requests/invitations between the pair).
CREATE TABLE blocks (
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (blocker_id, blocked_id),
CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id)
);
CREATE INDEX blocks_blocked_idx ON blocks (blocked_id);
-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty
-- body, so one journal carries both chatter and nudges. body is capped at 60
-- runes (enforced again in Go on input, where the content filter also rejects
-- links/emails/phone numbers). sender_ip holds the gateway-forwarded client IP as
-- a validated string (text, not inet, to avoid go-jet literal friction; the
-- gateway populates it in Stage 6). Chat is part of the game archive and is never
-- purged; it cascades away only with its game.
CREATE TABLE chat_messages (
message_id uuid PRIMARY KEY,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
sender_id uuid NOT NULL REFERENCES accounts (account_id),
kind text NOT NULL DEFAULT 'message',
body text NOT NULL DEFAULT '',
sender_ip text,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')),
CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60),
CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '')
);
CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at);
-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender).
CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at)
WHERE kind = 'nudge';
-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the
-- 6-digit code (the plaintext is never stored, matching the session model);
-- expires_at bounds the TTL and attempts caps brute force. A row is consumed
-- (consumed_at stamped) on success. A re-request deletes the prior pending row
-- for the same (account, lowercased email) and inserts a fresh one.
CREATE TABLE email_confirmations (
confirmation_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
email text NOT NULL,
code_hash text NOT NULL,
expires_at timestamptz NOT NULL,
attempts smallint NOT NULL DEFAULT 0,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0)
);
CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id);
-- A friend-game invitation. The inviter (seat 0) proposes the game settings to
-- 1..3 invitees; the game starts only when every invitee has accepted, and any
-- decline cancels the whole invitation. Lazily expired after expires_at (no
-- background sweep). game_id is set when the game is started.
CREATE TABLE game_invitations (
invitation_id uuid PRIMARY KEY,
inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
variant text NOT NULL,
turn_timeout_secs integer NOT NULL,
hints_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1,
dropout_tiles text NOT NULL DEFAULT 'remove',
status text NOT NULL DEFAULT 'pending',
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')),
CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')),
CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0),
CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0)
);
CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id);
-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's
-- seat in the started game (1..3, in invitation order). response tracks each
-- invitee's pending/accepted/declined decision.
CREATE TABLE game_invitation_invitees (
invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
seat smallint NOT NULL,
response text NOT NULL DEFAULT 'pending',
responded_at timestamptz,
PRIMARY KEY (invitation_id, account_id),
CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')),
CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3)
);
CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id);
-- +goose Down
DROP TABLE game_invitation_invitees;
DROP TABLE game_invitations;
DROP TABLE email_confirmations;
DROP TABLE chat_messages;
DROP TABLE blocks;
DROP TABLE friendships;
ALTER TABLE games
DROP CONSTRAINT games_dropout_tiles_chk,
DROP COLUMN dropout_tiles;