diff --git a/README.md b/README.md index bb275a1..ea985ce 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ of the previous word**. No word may be reused. Fail to answer in time and you lo ngôn ngữ → ngữ pháp → pháp luật → luật lệ → ... ``` +Playing a word that leaves the next player nothing to answer is not itself a win. They keep +the turn and lose it to the clock like any other, and the game-over screen then shows the +loser a few words the position still had — or tells them it had none. + ## Status Playable: vs bot at three difficulties, and online 1v1 by room code. See diff --git a/proto/noitu/v1/game.proto b/proto/noitu/v1/game.proto index 90e87bb..6d7b87d 100644 --- a/proto/noitu/v1/game.proto +++ b/proto/noitu/v1/game.proto @@ -177,11 +177,18 @@ message MoveRejected { uint32 turn_seq = 3; } +// GameOver is rendered per recipient: i_won is true for exactly one of the two +// players. message GameOver { bool i_won = 1; GameEndReason reason = 2; uint32 my_score = 3; uint32 chain_length = 4; + // A few words that could still have been played from the position the game + // ended on, filled only for the player who lost — the winner is not the one + // who needed them. An empty list on a loss is itself the answer: the + // position was a dead end and nobody could have answered it. + repeated string suggestions = 5; } message OpponentLeft { diff --git a/proto/testdata/server_game_over.bin b/proto/testdata/server_game_over.bin index 1eabc21..dab436d 100644 --- a/proto/testdata/server_game_over.bin +++ b/proto/testdata/server_game_over.bin @@ -1 +1,2 @@ -: \ No newline at end of file +: * +sinh viên* sinh sôi \ No newline at end of file diff --git a/server/gen/noitu/v1/game.pb.go b/server/gen/noitu/v1/game.pb.go index b9917d4..7dbc292 100644 --- a/server/gen/noitu/v1/game.pb.go +++ b/server/gen/noitu/v1/game.pb.go @@ -1254,12 +1254,19 @@ func (x *MoveRejected) GetTurnSeq() uint32 { return 0 } +// GameOver is rendered per recipient: i_won is true for exactly one of the two +// players. type GameOver struct { - state protoimpl.MessageState `protogen:"open.v1"` - IWon bool `protobuf:"varint,1,opt,name=i_won,json=iWon,proto3" json:"i_won,omitempty"` - Reason GameEndReason `protobuf:"varint,2,opt,name=reason,proto3,enum=noitu.v1.GameEndReason" json:"reason,omitempty"` - MyScore uint32 `protobuf:"varint,3,opt,name=my_score,json=myScore,proto3" json:"my_score,omitempty"` - ChainLength uint32 `protobuf:"varint,4,opt,name=chain_length,json=chainLength,proto3" json:"chain_length,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + IWon bool `protobuf:"varint,1,opt,name=i_won,json=iWon,proto3" json:"i_won,omitempty"` + Reason GameEndReason `protobuf:"varint,2,opt,name=reason,proto3,enum=noitu.v1.GameEndReason" json:"reason,omitempty"` + MyScore uint32 `protobuf:"varint,3,opt,name=my_score,json=myScore,proto3" json:"my_score,omitempty"` + ChainLength uint32 `protobuf:"varint,4,opt,name=chain_length,json=chainLength,proto3" json:"chain_length,omitempty"` + // A few words that could still have been played from the position the game + // ended on, filled only for the player who lost — the winner is not the one + // who needed them. An empty list on a loss is itself the answer: the + // position was a dead end and nobody could have answered it. + Suggestions []string `protobuf:"bytes,5,rep,name=suggestions,proto3" json:"suggestions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1322,6 +1329,13 @@ func (x *GameOver) GetChainLength() uint32 { return 0 } +func (x *GameOver) GetSuggestions() []string { + if x != nil { + return x.Suggestions + } + return nil +} + type OpponentLeft struct { state protoimpl.MessageState `protogen:"open.v1"` CanReconnect bool `protobuf:"varint,1,opt,name=can_reconnect,json=canReconnect,proto3" json:"can_reconnect,omitempty"` @@ -1848,12 +1862,13 @@ const file_noitu_v1_game_proto_rawDesc = "" + "\fMoveRejected\x12.\n" + "\x06reason\x18\x01 \x01(\x0e2\x16.noitu.v1.RejectReasonR\x06reason\x12\x12\n" + "\x04word\x18\x02 \x01(\tR\x04word\x12\x19\n" + - "\bturn_seq\x18\x03 \x01(\rR\aturnSeq\"\x8e\x01\n" + + "\bturn_seq\x18\x03 \x01(\rR\aturnSeq\"\xb0\x01\n" + "\bGameOver\x12\x13\n" + "\x05i_won\x18\x01 \x01(\bR\x04iWon\x12/\n" + "\x06reason\x18\x02 \x01(\x0e2\x17.noitu.v1.GameEndReasonR\x06reason\x12\x19\n" + "\bmy_score\x18\x03 \x01(\rR\amyScore\x12!\n" + - "\fchain_length\x18\x04 \x01(\rR\vchainLength\"N\n" + + "\fchain_length\x18\x04 \x01(\rR\vchainLength\x12 \n" + + "\vsuggestions\x18\x05 \x03(\tR\vsuggestions\"N\n" + "\fOpponentLeft\x12#\n" + "\rcan_reconnect\x18\x01 \x01(\bR\fcanReconnect\x12\x19\n" + "\bgrace_ms\x18\x02 \x01(\rR\agraceMs\";\n" + diff --git a/server/internal/bot/realcorpus_test.go b/server/internal/bot/realcorpus_test.go index 0195c1c..672fcf4 100644 --- a/server/internal/bot/realcorpus_test.go +++ b/server/internal/bot/realcorpus_test.go @@ -58,7 +58,13 @@ func playRealGame(tb testing.TB, dict game.Dictionary, first, second Strategy, s p := e.Turn() move, err := strategies[p].Choose(board) if err == ErrNoMove { - tb.Fatalf("bot had no move at %q but the engine had not ended the game", e.Current()) + // The dead end the bot walked into. The engine leaves a stuck + // player their turn, so the harness settles it the way the room + // settles a bot's: immediately, against the player to act. + if !e.NoMove() { + tb.Fatalf("bot had no move at %q but the engine says the position has one", e.Current()) + } + break } if err != nil { tb.Fatalf("Choose: %v", err) diff --git a/server/internal/bot/simulate_test.go b/server/internal/bot/simulate_test.go index 8ced021..6fd2196 100644 --- a/server/internal/bot/simulate_test.go +++ b/server/internal/bot/simulate_test.go @@ -116,11 +116,13 @@ func playGame(t *testing.T, dict game.Dictionary, first, second Strategy) game.P p := e.Turn() move, err := strategies[p].Choose(board) if err == ErrNoMove { - // The engine ends a game on a dead end before the bot is asked, so - // this should be unreachable. If it ever fires, the player to move - // has lost, so the winner is the opponent -- returning the player - // to move would silently invert every ladder number. - t.Fatalf("bot had no move at %q but the engine had not ended the game", e.Current()) + // A dead end no longer ends the game on its own: a human keeps the + // turn and loses it to the clock. Two bots have no clock, so this + // is where the position is settled -- exactly as the room does it. + if !e.NoMove() { + t.Fatalf("bot had no move at %q but the engine says the position has one", e.Current()) + } + break } if err != nil { t.Fatalf("Choose: %v", err) diff --git a/server/internal/game/engine.go b/server/internal/game/engine.go index c068c6b..13ba619 100644 --- a/server/internal/game/engine.go +++ b/server/internal/game/engine.go @@ -10,6 +10,7 @@ package game import ( "errors" "fmt" + "slices" "time" "github.com/tiennm99dev/noitu/server/internal/vietnamese" @@ -144,7 +145,7 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas return Move{}, ReasonNotYourTurn } if e.IsExpired(now) { - e.finish(e.opponentOf(p), EndTimeout) + e.expire() return Move{}, ReasonTimeout } @@ -193,10 +194,11 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas e.turnIndex = (e.turnIndex + 1) % len(e.players) e.deadline = now.Add(e.turnLimit) - // The player who now has to move may have nothing to play. - if !e.HasLegalMove() { - e.finish(p, EndNoLegalMove) - } + // A dead end is deliberately not the end of the game. Ending it here would + // hand the mover a win the moment the position closed, before the other + // player had seen the board at all — they get their turn, and lose it to + // the clock like any other they cannot answer. NoMove lets a caller who + // has nothing to wait for (the bot) settle it immediately instead. return move, ReasonNone } @@ -222,6 +224,21 @@ func (e *Engine) LegalMoves() []string { return moves } +// Suggestions lists up to n words the player to act could still play, in a +// stable order so the same position always answers the same way. +// +// It is what a player who has just lost is shown, which is also why an empty +// result carries information: the position was a dead end, and nothing they +// could have typed would have answered it. +func (e *Engine) Suggestions(n int) []string { + if n <= 0 { + return nil + } + moves := e.LegalMoves() + slices.Sort(moves) + return moves[:min(n, len(moves))] +} + // HasLegalMove reports whether the player to act has anything to play. It stops // at the first unused candidate instead of building the whole list. func (e *Engine) HasLegalMove() bool { @@ -244,10 +261,37 @@ func (e *Engine) Timeout(now time.Time) bool { if e.over || !e.IsExpired(now) { return false } - e.finish(e.opponentOf(e.Turn()), EndTimeout) + e.expire() return true } +// NoMove ends the game against the player to act when the position leaves +// them nothing to play, without waiting for their clock to run out. +// +// Only for a player who has no clock to wait for — the bot answers the moment +// it has searched, and making it sit out a turn limit it cannot use would +// stall the room. A human keeps their turn: see Submit. +func (e *Engine) NoMove() bool { + if e.over || e.HasLegalMove() { + return false + } + e.finish(e.opponentOf(e.Turn()), EndNoLegalMove) + return true +} + +// expire ends the game against the player to act, whose turn has run out. +// +// A player who never had a word to play did not run out of thinking time: +// there was nothing to think about, and reporting a timeout would blame them +// for a position nobody could have answered. +func (e *Engine) expire() { + reason := EndTimeout + if !e.HasLegalMove() { + reason = EndNoLegalMove + } + e.finish(e.opponentOf(e.Turn()), reason) +} + // Resign ends the game against the player who gave up. func (e *Engine) Resign(p PlayerID) bool { if e.over { diff --git a/server/internal/game/engine_test.go b/server/internal/game/engine_test.go index 9aaea87..e698e85 100644 --- a/server/internal/game/engine_test.go +++ b/server/internal/game/engine_test.go @@ -316,10 +316,75 @@ func TestLegalMovesAndHasLegalMove(t *testing.T) { } } -// The player handed a dead end loses, and the engine says so without waiting -// for a timer. -func TestNoLegalMoveEndsGame(t *testing.T) { - // "lệ" starts nothing, so whoever receives it cannot move. +// A dead end does not end the game by itself. The player who walked into one +// keeps the turn they were given, and it is the clock that takes it from them. +func TestDeadEndLeavesTheTurnWithThePlayer(t *testing.T) { + e := deadEndGame(t) + + if e.Over() { + t.Fatal("game ended on the move into a dead end instead of leaving the turn to be played") + } + if e.Turn() != alice { + t.Errorf("Turn = %q, want %q", e.Turn(), alice) + } + if e.HasLegalMove() { + t.Error("HasLegalMove = true in a position with nothing to play") + } +} + +// Losing a turn nobody could have answered is reported as the dead end it was, +// not as time spent thinking. +func TestDeadEndEndsOnTheClockAsNoLegalMove(t *testing.T) { + e := deadEndGame(t) + + if !e.Timeout(e.Deadline().Add(time.Nanosecond)) { + t.Fatal("Timeout did not end an expired turn") + } + if e.Winner() != bob { + t.Errorf("Winner = %q, want %q (alice had no move)", e.Winner(), bob) + } + if got := e.Snapshot().EndReason; got != EndNoLegalMove { + t.Errorf("EndReason = %s, want %s", got, EndNoLegalMove) + } +} + +// NoMove settles a dead end without a clock, which is what the room does for +// the bot: it has nothing to wait for. +func TestNoMoveEndsADeadEndImmediately(t *testing.T) { + e := deadEndGame(t) + + if !e.NoMove() { + t.Fatal("NoMove = false in a position with nothing to play") + } + if e.Winner() != bob { + t.Errorf("Winner = %q, want %q", e.Winner(), bob) + } + if got := e.Snapshot().EndReason; got != EndNoLegalMove { + t.Errorf("EndReason = %s, want %s", got, EndNoLegalMove) + } + + if e.NoMove() { + t.Error("NoMove ended an already finished game a second time") + } +} + +// NoMove is not a resignation button: a player who still has words to play +// cannot use it to end the game. +func TestNoMoveRefusesAPlayablePosition(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + + if e.NoMove() { + t.Error("NoMove = true with legal moves available") + } + if e.Over() { + t.Error("game ended from a playable position") + } +} + +// deadEndGame leaves alice on turn with nothing to play: "lệ" starts no word. +func deadEndGame(t *testing.T) *Engine { + t.Helper() + d := newDict("ngôn ngữ", "ngữ luật", "luật lệ") e := newGame(t, d, "ngôn ngữ") @@ -329,15 +394,35 @@ func TestNoLegalMoveEndsGame(t *testing.T) { if _, r := e.Submit(bob, "luật lệ", t0); r != ReasonNone { t.Fatalf("bob's move rejected: %s", r) } + return e +} - if !e.Over() { - t.Fatal("game not over after a move into a dead end") +// What a losing player is shown: a few of the words the position still had, +// and nothing at all when it had none. +func TestSuggestions(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + + if got, want := e.Suggestions(3), []string{"ngữ pháp", "ngữ điệu"}; !slices.Equal(got, want) { + t.Errorf("Suggestions(3) = %v, want %v", got, want) } - if e.Winner() != bob { - t.Errorf("Winner = %q, want %q (alice has no move)", e.Winner(), bob) + // Capped, and stable: the same position answers the same way every time. + if got := e.Suggestions(1); !slices.Equal(got, []string{"ngữ pháp"}) { + t.Errorf("Suggestions(1) = %v, want [ngữ pháp]", got) } - if e.Snapshot().EndReason != EndNoLegalMove { - t.Errorf("EndReason = %s, want %s", e.Snapshot().EndReason, EndNoLegalMove) + if got := e.Suggestions(0); got != nil { + t.Errorf("Suggestions(0) = %v, want nil", got) + } + + // A played word is no longer a suggestion. + e.Submit(alice, "ngữ pháp", t0) + for _, word := range e.Suggestions(3) { + if word == "ngữ pháp" { + t.Error("Suggestions offered a word that had already been played") + } + } + + if got := deadEndGame(t).Suggestions(3); len(got) != 0 { + t.Errorf("Suggestions in a dead end = %v, want none", got) } } diff --git a/server/internal/wsapi/regression_test.go b/server/internal/wsapi/regression_test.go index e5ab0c3..6520552 100644 --- a/server/internal/wsapi/regression_test.go +++ b/server/internal/wsapi/regression_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -199,8 +200,12 @@ func TestRoomCreationIsRateLimited(t *testing.T) { // TestPvPGameRunsToAWinner plays a PvP game to its natural end, rather than // stopping after the first move as the alternation test does. +// +// The dead end at the end of the chain does not decide the game by itself: +// the guest is left holding a turn nobody could answer, and it is the clock +// that ends it. The turn limit is short because this test spends one. func TestPvPGameRunsToAWinner(t *testing.T) { - _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second}) + _, url := newTestServer(t, chainDict(), Config{TurnLimit: time.Second}) host, guest, _ := startPvP(t, url) // a b (opening) -> b c -> c d -> d e, after which the guest has nothing. @@ -223,6 +228,39 @@ func TestPvPGameRunsToAWinner(t *testing.T) { if hostOver.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE { t.Errorf("reason = %v, want NO_LEGAL_MOVE", hostOver.GetReason()) } + + // Nothing could have been played, and the empty list is how the loser is + // told so. The winner is never sent one. + if got := guestOver.GetSuggestions(); len(got) != 0 { + t.Errorf("the losing player was offered %v out of a dead end, want nothing", got) + } + if got := hostOver.GetSuggestions(); len(got) != 0 { + t.Errorf("the winner was sent suggestions %v, want none", got) + } +} + +// TestLosingPlayerIsToldWhatCouldHaveBeenPlayed covers the other half of the +// same message: a player who loses a position that still had words in it is +// shown some of them. +func TestLosingPlayerIsToldWhatCouldHaveBeenPlayed(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second}) + host, guest, _ := startPvP(t, url) + + // The opening is "a b", so "b c" is still there to be played. + guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}}) + + guestOver := guest.await("game_over").GetGameOver() + hostOver := host.await("game_over").GetGameOver() + + if guestOver.GetIWon() { + t.Fatal("the player who resigned was told they won") + } + if got := guestOver.GetSuggestions(); !slices.Equal(got, []string{"b c"}) { + t.Errorf("suggestions = %v, want [b c]", got) + } + if got := hostOver.GetSuggestions(); len(got) != 0 { + t.Errorf("the winner was sent suggestions %v, want none", got) + } } // awaitMyTurn reads turn updates until this client is the one on turn. Both diff --git a/server/internal/wsapi/room.go b/server/internal/wsapi/room.go index 93ae419..8d5d713 100644 --- a/server/internal/wsapi/room.go +++ b/server/internal/wsapi/room.go @@ -21,6 +21,11 @@ const botPlayerID game.PlayerID = "bot" // syllable with two continuations makes for a game that ends before it starts. const minOpeningOutDegree = 20 +// maxSuggestions is how many of the words still playable a losing player is +// shown. Enough to see what the position wanted, few enough that it reads as +// a hint rather than a dump of the dictionary. +const maxSuggestions = 3 + // roomInputCap buffers the room's inbox. A sender that finds it full is either // flooding past the rate limiter or racing a room that is shutting down; // neither is worth blocking a session goroutine for. @@ -520,8 +525,13 @@ func (r *room) handleBotMove(m botMoveInput) { } if m.err != nil { - // No legal move: the bot has lost by the same rule a human would. - r.engine.Resign(botPlayerID) + // The bot has nothing to play. A human in this position keeps their + // turn and loses it to the clock; the bot has no clock to spend, so + // the position is settled now and reported for what it is rather than + // as a resignation it never chose. + if !r.engine.NoMove() { + r.engine.Resign(botPlayerID) + } r.broadcastGameOver() return } @@ -604,17 +614,35 @@ func (r *room) broadcastGameOver() { if s.sess == nil { continue } - s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{ - GameOver: &noituv1.GameOver{ - IWon: state.Winner == s.id, - Reason: EndReason(state.EndReason), - MyScore: uint32(state.Scores[s.id]), - ChainLength: uint32(state.ChainLength), - }, - }}) + s.sess.send(r.gameOverFor(state, s.id, EndReason(state.EndReason))) } } +// gameOverFor renders a finished game for one seat. +// +// The loser is told what could have been played from the position the game +// ended on. The winner is not: they are not the one who was stuck, and it is +// the loser for whom an empty list answers the question — nothing could have +// been played, so the position, not the player, ended the game. +func (r *room) gameOverFor(state game.State, id game.PlayerID, reason noituv1.GameEndReason) *noituv1.ServerMessage { + iWon := state.Winner == id + + var suggestions []string + if !iWon { + suggestions = r.engine.Suggestions(maxSuggestions) + } + + return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{ + GameOver: &noituv1.GameOver{ + IWon: iWon, + Reason: reason, + MyScore: uint32(state.Scores[id]), + ChainLength: uint32(state.ChainLength), + Suggestions: suggestions, + }, + }} +} + // handleDisconnect holds the seat open, reporting whether a grace window // should now run. // handleDisconnect reports two separate things, because the caller needs both @@ -737,14 +765,7 @@ func (r *room) endForAbandonment() { if s.sess == nil { continue } - s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{ - GameOver: &noituv1.GameOver{ - IWon: state.Winner == s.id, - Reason: noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT, - MyScore: uint32(state.Scores[s.id]), - ChainLength: uint32(state.ChainLength), - }, - }}) + s.sess.send(r.gameOverFor(state, s.id, noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT)) } } diff --git a/server/internal/wsapi/wire_test.go b/server/internal/wsapi/wire_test.go index 69447d8..920445b 100644 --- a/server/internal/wsapi/wire_test.go +++ b/server/internal/wsapi/wire_test.go @@ -103,6 +103,9 @@ func serverVariants() map[string]*noituv1.ServerMessage { Reason: noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE, MyScore: 7, ChainLength: 11, + // A repeated string of Vietnamese words: the one field in the + // contract whose encoding is neither a scalar nor a submessage. + Suggestions: []string{"sinh viên", "sinh sôi"}, }}}, "server_opponent_left": {Payload: &noituv1.ServerMessage_OpponentLeft{OpponentLeft: &noituv1.OpponentLeft{ CanReconnect: true, diff --git a/web/e2e/bot-game.spec.js b/web/e2e/bot-game.spec.js index 7261981..729c453 100644 --- a/web/e2e/bot-game.spec.js +++ b/web/e2e/bot-game.spec.js @@ -133,6 +133,24 @@ test.describe('playing the bot', () => { await expect(page.getByRole('button', { name: 'Chơi lại' })).toBeVisible(); }); + test('a loss shows what could have been played', async ({ page }) => { + await page.goto('/play?difficulty=1'); + await waitForMyTurn(page); + + const syllable = (await board(page).syllable.textContent())?.trim() ?? ''; + + page.on('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Đầu hàng' }).click(); + + await expect(page.getByRole('heading', { name: 'Bạn có thể nối' })).toBeVisible(); + const offered = await page.locator('.suggestions li').allTextContents(); + expect(offered.length).toBeGreaterThan(0); + expect(offered.length).toBeLessThanOrEqual(3); + for (const word of offered) { + expect(word.startsWith(`${syllable} `)).toBe(true); + } + }); + test('a finished game can be downloaded as a transcript', async ({ page }) => { await page.goto('/play?difficulty=1'); await waitForMyTurn(page); diff --git a/web/src/lib/components/GameOverPanel.svelte b/web/src/lib/components/GameOverPanel.svelte index 73be31d..a932f06 100644 --- a/web/src/lib/components/GameOverPanel.svelte +++ b/web/src/lib/components/GameOverPanel.svelte @@ -1,6 +1,6 @@