mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-15 22:14:57 +00:00
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:
1 parent
69a13eccfb
commit
ef638e52fd
19 files changed
+405
-55
No files matched your search
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user