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
+3
View File
@@ -97,6 +97,9 @@ var (
// ErrUnknownVersion is returned when no dictionary is registered for a
// (variant, version) pair.
ErrUnknownVersion = errors.New("engine: unknown dictionary version")
// ErrUnknownDropoutTiles is returned by ParseDropoutTiles for a label that is
// neither "remove" nor "return".
ErrUnknownDropoutTiles = errors.New("engine: unknown drop-out tile disposition")
// ErrIllegalPlay wraps a solver validation failure: off-board geometry, a
// word absent from the dictionary, or a play that does not connect.
ErrIllegalPlay = errors.New("engine: illegal play")
+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 {
+197
View File
@@ -79,3 +79,200 @@ func TestResignOnFinishedGame(t *testing.T) {
t.Error("resign on a finished game must error")
}
}
// openingGameN returns a players-seat English game whose opening rack has a legal
// move, searching a deterministic range of seeds.
func openingGameN(t *testing.T, players int, dt DropoutTiles) *Game {
t.Helper()
for seed := int64(1); seed <= 100; seed++ {
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: players, Seed: seed, DropoutTiles: dt})
if err != nil {
t.Fatalf("new game: %v", err)
}
if len(g.GenerateMoves()) > 0 {
return g
}
}
t.Fatal("no opening move found in seeds 1..100")
return nil
}
// TestMultiplayerResignContinues proves that in a three-player game one
// resignation does not end the game and the resigned seat is skipped in rotation.
func TestMultiplayerResignContinues(t *testing.T) {
g := openingGameN(t, 3, DropoutRemove)
if _, err := g.Resign(); err != nil { // seat 0
t.Fatalf("seat 0 resign: %v", err)
}
if g.Over() {
t.Fatal("a three-player game must continue after one resignation")
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1 (seat 0 skipped)", g.ToMove())
}
if _, err := g.Pass(); err != nil { // seat 1
t.Fatalf("seat 1 pass: %v", err)
}
if g.ToMove() != 2 {
t.Errorf("to move = %d, want 2", g.ToMove())
}
if _, err := g.Pass(); err != nil { // seat 2
t.Fatalf("seat 2 pass: %v", err)
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1 (seat 0 skipped on wrap)", g.ToMove())
}
}
// TestMultiplayerLastActiveWins proves that as seats drop out the sole survivor
// wins even when trailing, and resigners keep their (frozen) scores.
func TestMultiplayerLastActiveWins(t *testing.T) {
g := openingGameN(t, 3, DropoutRemove)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 takes the lead
if err != nil {
t.Fatalf("seat 0 play: %v", err)
}
if played.Score == 0 {
t.Fatal("opening play scored 0; pick a different seed")
}
if _, err := g.Pass(); err != nil { // seat 1
t.Fatalf("seat 1 pass: %v", err)
}
if _, err := g.Pass(); err != nil { // seat 2
t.Fatalf("seat 2 pass: %v", err)
}
if _, err := g.Resign(); err != nil { // seat 0 (leader) drops out
t.Fatalf("seat 0 resign: %v", err)
}
if g.Over() {
t.Fatal("game must continue with two active seats")
}
if g.ToMove() != 1 {
t.Fatalf("to move = %d, want 1", g.ToMove())
}
if _, err := g.Resign(); err != nil { // seat 1 drops out, leaving only seat 2
t.Fatalf("seat 1 resign: %v", err)
}
if !g.Over() || g.Reason() != EndResign {
t.Fatalf("over=%v reason=%v, want over with resign", g.Over(), g.Reason())
}
res := g.Result()
if res.Winner != 2 {
t.Errorf("winner = %d, want 2 (sole survivor) despite trailing", res.Winner)
}
if g.Score(0) != played.Score {
t.Errorf("resigner seat 0 score = %d, want frozen at %d", g.Score(0), played.Score)
}
if g.Score(2) != 0 {
t.Errorf("survivor seat 2 score = %d, want 0", g.Score(2))
}
}
// TestDropoutTileDisposition proves the per-game setting governs the bag: remove
// leaves it unchanged, return adds the leaver's full rack back.
func TestDropoutTileDisposition(t *testing.T) {
const seed = 7
remove, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 3, Seed: seed, DropoutTiles: DropoutRemove})
if err != nil {
t.Fatalf("new remove game: %v", err)
}
ret, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 3, Seed: seed, DropoutTiles: DropoutReturn})
if err != nil {
t.Fatalf("new return game: %v", err)
}
bagBefore := remove.BagLen()
if ret.BagLen() != bagBefore {
t.Fatalf("identical seeds must start with equal bags: %d vs %d", remove.BagLen(), ret.BagLen())
}
rackSize := remove.rules.RackSize // seat 0 holds a full rack on the opening turn
if _, err := remove.Resign(); err != nil {
t.Fatalf("remove resign: %v", err)
}
if _, err := ret.Resign(); err != nil {
t.Fatalf("return resign: %v", err)
}
if remove.BagLen() != bagBefore {
t.Errorf("remove: bag = %d, want unchanged %d", remove.BagLen(), bagBefore)
}
if ret.BagLen() != bagBefore+rackSize {
t.Errorf("return: bag = %d, want %d (rack returned)", ret.BagLen(), bagBefore+rackSize)
}
}
// TestResignedSeatExcludedFromWinOnScorelessEnd proves a resigned seat never wins
// even when the game ends by the scoreless limit rather than by the resignation.
func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) {
g := openingGameN(t, 3, DropoutRemove)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 leads
if err != nil {
t.Fatalf("seat 0 play: %v", err)
}
if played.Score == 0 {
t.Fatal("opening play scored 0; pick a different seed")
}
if _, err := g.Pass(); err != nil { // seat 1
t.Fatalf("seat 1 pass: %v", err)
}
if _, err := g.Pass(); err != nil { // seat 2
t.Fatalf("seat 2 pass: %v", err)
}
if _, err := g.Resign(); err != nil { // seat 0 drops out while leading
t.Fatalf("seat 0 resign: %v", err)
}
for !g.Over() { // seats 1 and 2 pass until the six-scoreless limit ends it
if _, err := g.Pass(); err != nil {
t.Fatalf("pass: %v", err)
}
}
if g.Reason() != EndScoreless {
t.Fatalf("reason = %v, want scoreless", g.Reason())
}
if res := g.Result(); res.Winner == 0 {
t.Error("winner = 0, but the resigned leader must be excluded")
}
}
// TestFourPlayerDropToTwoContinues proves two drop-outs in a four-player game
// leave the remaining two playing on, skipping both resigned seats.
func TestFourPlayerDropToTwoContinues(t *testing.T) {
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 4, Seed: 3, DropoutTiles: DropoutRemove})
if err != nil {
t.Fatalf("new game: %v", err)
}
if _, err := g.Resign(); err != nil { // seat 0
t.Fatalf("seat 0 resign: %v", err)
}
if g.ToMove() != 1 {
t.Fatalf("to move = %d, want 1", g.ToMove())
}
if _, err := g.Resign(); err != nil { // seat 1
t.Fatalf("seat 1 resign: %v", err)
}
if g.Over() {
t.Fatal("game with two active seats must continue")
}
if g.ToMove() != 2 {
t.Errorf("to move = %d, want 2", g.ToMove())
}
if _, err := g.Pass(); err != nil { // seat 2
t.Fatalf("seat 2 pass: %v", err)
}
if g.ToMove() != 3 {
t.Errorf("to move = %d, want 3", g.ToMove())
}
if _, err := g.Pass(); err != nil { // seat 3
t.Fatalf("seat 3 pass: %v", err)
}
if g.ToMove() != 2 {
t.Errorf("to move = %d, want 2 (seats 0,1 skipped)", g.ToMove())
}
}