feat(game): leave a dead end to the clock and show what was missed

Closing the position no longer wins the game on the spot. The player handed
a syllable that starts nothing keeps the turn they were given and loses it to
the clock, the way they lose any turn they cannot answer — the win used to
land before they had seen the board at all. A turn lost to a position nobody
could answer is reported as the dead end it was rather than as time spent
thinking.

The bot is the exception, and stays one: it has no clock to spend, so the
room settles its dead end the moment the search comes back empty. NoMove
does that without dressing it up as a resignation the bot never chose.

GameOver now carries a few of the words the position still had, filled for
the losing player only — the winner was not the one who was stuck. An empty
list on a loss is the other half of the message: nothing could have been
played, so the panel says so instead of listing nothing.
This commit is contained in:
tiennm99 committed 2026-09-07 15:51:22 +07:00
1 parent 69a13eccfb
commit ef638e52fd
19 files changed
+405 -55

No files matched your search

+39 -1
View File
@@ -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
+39 -18
View File
@@ -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))
}
}
+3
View File
@@ -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,