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
+102 -17
View File
@@ -42,6 +42,43 @@ func (r EndReason) String() string {
return "unknown"
}
// DropoutTiles is the per-game disposition of a dropped-out player's rack when
// they resign or time out of a game with three or more seats: the tiles are
// either removed from play or returned to the bag. It is agreed at game creation
// (docs/ARCHITECTURE.md §6) and is irrelevant to a two-player game, which ends on
// the first drop-out. In both dispositions the leaver's rack is never revealed to
// the remaining players.
type DropoutTiles uint8
const (
// DropoutRemove removes the dropped player's tiles from play; this is the
// default, so the zero value matches it.
DropoutRemove DropoutTiles = iota
// DropoutReturn returns the dropped player's tiles to the bag, where the
// remaining players may draw them.
DropoutReturn
)
// String renders the disposition as the stable label the game domain persists.
func (d DropoutTiles) String() string {
if d == DropoutReturn {
return "return"
}
return "remove"
}
// ParseDropoutTiles maps a persisted label back to a DropoutTiles, reporting
// ErrUnknownDropoutTiles for an unrecognised value.
func ParseDropoutTiles(s string) (DropoutTiles, error) {
switch s {
case "remove":
return DropoutRemove, nil
case "return":
return DropoutReturn, nil
}
return 0, fmt.Errorf("%w: %q", ErrUnknownDropoutTiles, s)
}
// Options configures a new game.
type Options struct {
// Variant selects the rules and dictionary.
@@ -52,6 +89,9 @@ type Options struct {
Players int
// Seed seeds the tile bag, making the game reproducible.
Seed int64
// DropoutTiles is the disposition of a dropped-out player's tiles in a game
// with three or more seats; the zero value removes them from play.
DropoutTiles DropoutTiles
}
// Game is the in-memory state of a single match and the pure rules engine over
@@ -72,7 +112,8 @@ type Game struct {
scorelessRun int
over bool
reason EndReason
resignedSeat int // seat that resigned, or -1; excludes the resigner from winning
resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
log []MoveRecord
}
@@ -107,7 +148,8 @@ func New(reg *Registry, opts Options) (*Game, error) {
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resignedSeat: -1,
resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles,
}
for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -195,22 +237,30 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
return rec, nil
}
// Resign ends the game on the current player's turn (EndReason EndResign). The
// resigner always forfeits the win and keeps their accumulated score (it is
// neither zeroed nor docked a rack adjustment); the win goes to the highest
// score among the remaining seats — in a two-player match, unconditionally to
// the other player. A missed-turn timeout reuses Resign in the game domain, so
// it inherits this win/loss. Richer multi-player drop-out handling belongs to
// the game domain in a later stage.
// Resign drops the current player out of the game. The resigner always forfeits
// the win and keeps their accumulated score (it is neither zeroed nor docked a
// rack adjustment), and their rack is disposed of per the game's DropoutTiles
// setting without ever being revealed to the remaining players. In a game with
// three or more seats the others play on with the resigned seat skipped, until
// one active seat is left (it wins) or the game ends by the ordinary conditions;
// the game finishes with EndResign only once a single active seat remains. A
// two-player game therefore ends on the first resignation, the other player
// winning regardless of score. A missed-turn timeout reuses Resign in the game
// domain, so it inherits this win/loss.
func (g *Game) Resign() (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
player := g.toMove
g.resignedSeat = player
g.resigned[player] = true
g.disposeHand(player)
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
g.log = append(g.log, rec)
g.finish(EndResign)
if g.activeCount() <= 1 {
g.finish(EndResign)
} else {
g.advance()
}
return rec, nil
}
@@ -330,20 +380,55 @@ func (g *Game) endTurnAfterScoreless() {
g.advance()
}
// advance moves play to the next seat.
func (g *Game) advance() { g.toMove = (g.toMove + 1) % len(g.hands) }
// advance moves play to the next active (non-resigned) seat. While a game is in
// progress at least two seats are active, so a next active seat always exists;
// the loop leaves toMove unchanged in the degenerate all-but-one-resigned case,
// which Resign turns into a finished game instead.
func (g *Game) advance() {
n := len(g.hands)
for i := 1; i <= n; i++ {
next := (g.toMove + i) % n
if !g.resigned[next] {
g.toMove = next
return
}
}
}
// activeCount returns the number of seats that have not resigned.
func (g *Game) activeCount() int {
n := 0
for _, r := range g.resigned {
if !r {
n++
}
}
return n
}
// disposeHand empties a resigned player's rack per the game's DropoutTiles
// setting: it returns the tiles to the bag or removes them from play. Either way
// the hand is cleared, so the end-game rack adjustment ignores the seat and the
// rack is never exposed.
func (g *Game) disposeHand(player int) {
if g.dropoutTiles == DropoutReturn {
g.bag.Return(g.hands[player])
}
g.hands[player] = nil
}
// winner returns the index of the single highest-scoring player, or -1 on a tie
// for the lead or while the game is unfinished. After a resignation the resigner
// is excluded, so a two-player game returns the remaining player even when the
// resigner led on score.
// for the lead or while the game is unfinished. Resigned (dropped-out) seats are
// always excluded, so a two-player game returns the remaining player even when
// the resigner led on score, and a multi-player game never awards the win to a
// seat that left.
func (g *Game) winner() int {
if !g.over {
return -1
}
best, tie := -1, false
for i := range g.scores {
if g.reason == EndResign && i == g.resignedSeat {
if g.resigned[i] {
continue
}
switch {