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()) } }