10412fee8e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Failing after 12s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
- Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat (not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat. - Quick-match cancel was a UI no-op (only stopped polling): add the full path (REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no later robot-substituted game). NewGame dequeues on cancel and on abandon. - Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win. The winner now takes rank 1 and the viewer is placed from rank 2 — matching the game-detail screen. - Friend request to a robot: robots no longer block requests; the request stays pending and expires (friendRequestTTL), mirroring a human who ignores it. - Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a correct message; the chat nudge button disables during the hourly cooldown; the nudge note reads 'Waiting for your move!' (button keeps the Nudge action label). Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot inttest, result.ts 0-0 resignation, nudge_too_soon mapping.
312 lines
9.7 KiB
Go
312 lines
9.7 KiB
Go
package engine
|
|
|
|
import "testing"
|
|
|
|
// TestResignLeadingPlayerStillLoses is the core of the resignation fix: a player
|
|
// who resigns loses even when leading on score, the remaining player wins, and
|
|
// the resigner's score is frozen (no end-game rack adjustment).
|
|
func TestResignLeadingPlayerStillLoses(t *testing.T) {
|
|
g := openingGame(t)
|
|
|
|
hint, ok := g.HintView()
|
|
if !ok {
|
|
t.Fatal("opening game has no hint")
|
|
}
|
|
played, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
|
if err != nil {
|
|
t.Fatalf("player 0 play: %v", err)
|
|
}
|
|
if played.Score == 0 {
|
|
t.Fatal("opening play scored 0; pick a different seed")
|
|
}
|
|
|
|
if _, err := g.Pass(); err != nil { // player 1
|
|
t.Fatalf("player 1 pass: %v", err)
|
|
}
|
|
|
|
// Player 0 is now on turn and leads 0:played.Score; resigning must still lose.
|
|
if _, err := g.Resign(); err != nil {
|
|
t.Fatalf("player 0 resign: %v", err)
|
|
}
|
|
|
|
if !g.Over() || g.Reason() != EndResign {
|
|
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
|
|
}
|
|
res := g.Result()
|
|
if res.Winner != 1 {
|
|
t.Errorf("winner = %d, want 1 (the non-resigner) despite the resigner leading", res.Winner)
|
|
}
|
|
if g.Score(0) != played.Score {
|
|
t.Errorf("resigner score = %d, want frozen at %d (no rack adjustment)", g.Score(0), played.Score)
|
|
}
|
|
if g.Score(1) != 0 {
|
|
t.Errorf("opponent score = %d, want 0", g.Score(1))
|
|
}
|
|
if g.Score(0) <= g.Score(1) {
|
|
t.Fatal("test precondition: resigner should lead on raw score")
|
|
}
|
|
}
|
|
|
|
// TestResignTrailingPlayerLoses covers the ordinary case: the trailing player
|
|
// resigns and the leader wins.
|
|
func TestResignTrailingPlayerLoses(t *testing.T) {
|
|
g := openingGame(t)
|
|
|
|
hint, ok := g.HintView()
|
|
if !ok {
|
|
t.Fatal("opening game has no hint")
|
|
}
|
|
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 scores
|
|
t.Fatalf("player 0 play: %v", err)
|
|
}
|
|
|
|
// Player 1 (trailing 0 points) resigns.
|
|
if _, err := g.Resign(); err != nil {
|
|
t.Fatalf("player 1 resign: %v", err)
|
|
}
|
|
if res := g.Result(); res.Winner != 0 {
|
|
t.Errorf("winner = %d, want 0", res.Winner)
|
|
}
|
|
}
|
|
|
|
// TestResignSeatOffTurn covers a forfeit on the opponent's turn: after player 0
|
|
// moves it is player 1's turn, yet player 0 resigns its own seat — the resigner
|
|
// loses, the opponent wins, and the game ends.
|
|
func TestResignSeatOffTurn(t *testing.T) {
|
|
g := openingGame(t)
|
|
|
|
hint, ok := g.HintView()
|
|
if !ok {
|
|
t.Fatal("opening game has no hint")
|
|
}
|
|
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 moves
|
|
t.Fatalf("player 0 play: %v", err)
|
|
}
|
|
if g.ToMove() != 1 {
|
|
t.Fatalf("after player 0's move, toMove = %d, want 1", g.ToMove())
|
|
}
|
|
|
|
// Player 0 resigns although it is player 1's turn.
|
|
rec, err := g.ResignSeat(0)
|
|
if err != nil {
|
|
t.Fatalf("player 0 off-turn resign: %v", err)
|
|
}
|
|
if rec.Player != 0 || rec.Action != ActionResign {
|
|
t.Errorf("resign record = seat %d action %v, want seat 0 resign", rec.Player, rec.Action)
|
|
}
|
|
if !g.Over() || g.Reason() != EndResign {
|
|
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
|
|
}
|
|
if res := g.Result(); res.Winner != 1 {
|
|
t.Errorf("winner = %d, want 1 (the non-resigner)", res.Winner)
|
|
}
|
|
}
|
|
|
|
// TestResignOnFinishedGame rejects a second transition.
|
|
func TestResignOnFinishedGame(t *testing.T) {
|
|
g := newEnglishGame(t, 1)
|
|
if _, err := g.Resign(); err != nil {
|
|
t.Fatalf("first resign: %v", err)
|
|
}
|
|
if _, err := g.Resign(); err == nil {
|
|
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())
|
|
}
|
|
}
|