mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-18 20:14:49 +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
Generated
+22
-7
@@ -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" +
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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