mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+2
-1
@@ -1 +1,2 @@
|
||||
:
|
||||
: *
|
||||
sinh viên* sinh sôi
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { chainToText, downloadText, historyFilename } from '$lib/history-export.js';
|
||||
import { endReasonMessages, t } from '$lib/i18n/vi.js';
|
||||
import { endReasonMessages, fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
let { isRecord, onrematch, onhome } = $props();
|
||||
|
||||
/** @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null} */
|
||||
/** @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number, suggestions: string[] } | null} */
|
||||
const result = $derived(game.state.result);
|
||||
|
||||
/** Hands the finished chain to the player as a text file to keep. */
|
||||
@@ -47,6 +47,27 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if !result.iWon}
|
||||
<!-- Only the loser is shown this, and only they were sent it. Losing
|
||||
without ever learning what the position wanted is the part that
|
||||
stings; an empty list says the position had nothing, which is
|
||||
worth hearing too. -->
|
||||
{#if result.suggestions.length > 0}
|
||||
<div class="suggestions">
|
||||
<h3>{t.suggestionsTitle}</h3>
|
||||
<ul>
|
||||
{#each result.suggestions as word}
|
||||
<li>{word}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="dead-end">
|
||||
{fill(t.noSuggestions, { syllable: game.state.currentSyllable })}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isRecord}
|
||||
<p class="record">{t.newRecord}</p>
|
||||
{/if}
|
||||
@@ -109,6 +130,38 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.suggestions h3 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.suggestions ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.suggestions li {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dead-end {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.record {
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
|
||||
@@ -48,6 +48,8 @@ export const t = {
|
||||
finalScore: 'Điểm cuối cùng',
|
||||
chainLength: 'Số từ trong chuỗi',
|
||||
newRecord: 'Kỷ lục mới!',
|
||||
suggestionsTitle: 'Bạn có thể nối',
|
||||
noSuggestions: 'Không còn từ nào bắt đầu bằng tiếng “{syllable}”. Ai gặp thế này cũng chịu thôi!',
|
||||
exportHistory: 'Tải chuỗi từ',
|
||||
exportOpening: 'từ mở đầu',
|
||||
bestScore: 'Kỷ lục',
|
||||
|
||||
+13
@@ -477,6 +477,9 @@ export declare type MoveRejected = Message<"noitu.v1.MoveRejected"> & {
|
||||
export declare const MoveRejectedSchema: GenMessage<MoveRejected>;
|
||||
|
||||
/**
|
||||
* GameOver is rendered per recipient: i_won is true for exactly one of the two
|
||||
* players.
|
||||
*
|
||||
* @generated from message noitu.v1.GameOver
|
||||
*/
|
||||
export declare type GameOver = Message<"noitu.v1.GameOver"> & {
|
||||
@@ -499,6 +502,16 @@ export declare type GameOver = Message<"noitu.v1.GameOver"> & {
|
||||
* @generated from field: uint32 chain_length = 4;
|
||||
*/
|
||||
chainLength: number;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @generated from field: repeated string suggestions = 5;
|
||||
*/
|
||||
suggestions: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Generated
+1
-1
@@ -21,7 +21,7 @@ import { enumDesc, fileDesc, messageDesc, tsEnum } from "@bufbuild/protobuf/code
|
||||
* Describes the file noitu/v1/game.proto.
|
||||
*/
|
||||
export const file_noitu_v1_game = /*@__PURE__*/
|
||||
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIQCg5SZXF1ZXN0UmVtYXRjaCIeCgRQaW5nEhYKDmNsaWVudF90aW1lX21zGAEgASgDIuoCCg1DbGllbnRNZXNzYWdlEiAKBWhlbGxvGAEgASgLMg8ubm9pdHUudjEuSGVsbG9IABIwCg5zdGFydF9ib3RfZ2FtZRgCIAEoCzIWLm5vaXR1LnYxLlN0YXJ0Qm90R2FtZUgAEisKC2NyZWF0ZV9yb29tGAMgASgLMhQubm9pdHUudjEuQ3JlYXRlUm9vbUgAEicKCWpvaW5fcm9vbRgEIAEoCzISLm5vaXR1LnYxLkpvaW5Sb29tSAASKwoLc3VibWl0X3dvcmQYBSABKAsyFC5ub2l0dS52MS5TdWJtaXRXb3JkSAASIgoGcmVzaWduGAYgASgLMhAubm9pdHUudjEuUmVzaWduSAASHgoEcGluZxgHIAEoCzIOLm5vaXR1LnYxLlBpbmdIABIzCg9yZXF1ZXN0X3JlbWF0Y2gYCCABKAsyGC5ub2l0dS52MS5SZXF1ZXN0UmVtYXRjaEgAQgkKB3BheWxvYWQiaAoHV2VsY29tZRISCgpzZXNzaW9uX2lkGAEgASgJEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIYChBwcm90b2NvbF92ZXJzaW9uGAMgASgNEhkKEWFjY2VwdGVkX25pY2tuYW1lGAQgASgJIiAKC1Jvb21DcmVhdGVkEhEKCXJvb21fY29kZRgBIAEoCSI2CgpSb29tSm9pbmVkEhEKCXJvb21fY29kZRgBIAEoCRIVCg1vcHBvbmVudF9uYW1lGAIgASgJIlsKClBsYXllZFdvcmQSDAoEd29yZBgBIAEoCRINCgVieV9tZRgCIAEoCBIOCgZwb2ludHMYAyABKA0SEQoJc3lsbGFibGVzGAQgASgNEg0KBXR5cGVkGAUgASgJIpEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDSLJAQoKVHVyblVwZGF0ZRIkCgZwbGF5ZWQYASABKAsyFC5ub2l0dS52MS5QbGF5ZWRXb3JkEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhAKCG15X3Njb3JlGAYgASgNEhYKDm9wcG9uZW50X3Njb3JlGAcgASgNEhQKDGNoYWluX2xlbmd0aBgIIAEoDSJWCgxNb3ZlUmVqZWN0ZWQSJgoGcmVhc29uGAEgASgOMhYubm9pdHUudjEuUmVqZWN0UmVhc29uEgwKBHdvcmQYAiABKAkSEAoIdHVybl9zZXEYAyABKA0iagoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIQCghteV9zY29yZRgDIAEoDRIUCgxjaGFpbl9sZW5ndGgYBCABKA0iNwoMT3Bwb25lbnRMZWZ0EhUKDWNhbl9yZWNvbm5lY3QYASABKAgSEAoIZ3JhY2VfbXMYAiABKA0iLAoLU2VydmVyRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjYKBFBvbmcSFgoOY2xpZW50X3RpbWVfbXMYASABKAMSFgoOc2VydmVyX3RpbWVfbXMYAiABKAMiVAoMUmVtYXRjaFN0YXRlEhIKCmlfYWNjZXB0ZWQYASABKAgSGQoRb3Bwb25lbnRfYWNjZXB0ZWQYAiABKAgSFQoNZXhwaXJlc19pbl9tcxgDIAEoDSL8AwoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDHJvb21fY3JlYXRlZBgCIAEoCzIVLm5vaXR1LnYxLlJvb21DcmVhdGVkSAASKwoLcm9vbV9qb2luZWQYAyABKAsyFC5ub2l0dS52MS5Sb29tSm9pbmVkSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABIvCg1vcHBvbmVudF9sZWZ0GAggASgLMhYubm9pdHUudjEuT3Bwb25lbnRMZWZ0SAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASLwoNcmVtYXRjaF9zdGF0ZRgLIAEoCzIWLm5vaXR1LnYxLlJlbWF0Y2hTdGF0ZUgAQgkKB3BheWxvYWQqaQoKRGlmZmljdWx0eRIaChZESUZGSUNVTFRZX1VOU1BFQ0lGSUVEEAASEwoPRElGRklDVUxUWV9FQVNZEAESFQoRRElGRklDVUxUWV9NRURJVU0QAhITCg9ESUZGSUNVTFRZX0hBUkQQAyqOAgoMUmVqZWN0UmVhc29uEh0KGVJFSkVDVF9SRUFTT05fVU5TUEVDSUZJRUQQABIjCh9SRUpFQ1RfUkVBU09OX1RPT19GRVdfU1lMTEFCTEVTEAESHAoYUkVKRUNUX1JFQVNPTl9XUk9OR19MSU5LEAISIwofUkVKRUNUX1JFQVNPTl9OT1RfSU5fRElDVElPTkFSWRADEh4KGlJFSkVDVF9SRUFTT05fQUxSRUFEWV9VU0VEEAQSHwobUkVKRUNUX1JFQVNPTl9OT1RfWU9VUl9UVVJOEAUSGQoVUkVKRUNUX1JFQVNPTl9USU1FT1VUEAYSGwoXUkVKRUNUX1JFQVNPTl9HQU1FX09WRVIQByqxAQoNR2FtZUVuZFJlYXNvbhIfChtHQU1FX0VORF9SRUFTT05fVU5TUEVDSUZJRUQQABIbChdHQU1FX0VORF9SRUFTT05fVElNRU9VVBABEiEKHUdBTUVfRU5EX1JFQVNPTl9OT19MRUdBTF9NT1ZFEAISIQodR0FNRV9FTkRfUkVBU09OX09QUE9ORU5UX0xFRlQQAxIcChhHQU1FX0VORF9SRUFTT05fUkVTSUdORUQQBEI6WjhnaXRodWIuY29tL3RpZW5ubTk5ZGV2L25vaXR1L3NlcnZlci9nZW4vbm9pdHUvdjE7bm9pdHV2MWIGcHJvdG8z");
|
||||
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIQCg5SZXF1ZXN0UmVtYXRjaCIeCgRQaW5nEhYKDmNsaWVudF90aW1lX21zGAEgASgDIuoCCg1DbGllbnRNZXNzYWdlEiAKBWhlbGxvGAEgASgLMg8ubm9pdHUudjEuSGVsbG9IABIwCg5zdGFydF9ib3RfZ2FtZRgCIAEoCzIWLm5vaXR1LnYxLlN0YXJ0Qm90R2FtZUgAEisKC2NyZWF0ZV9yb29tGAMgASgLMhQubm9pdHUudjEuQ3JlYXRlUm9vbUgAEicKCWpvaW5fcm9vbRgEIAEoCzISLm5vaXR1LnYxLkpvaW5Sb29tSAASKwoLc3VibWl0X3dvcmQYBSABKAsyFC5ub2l0dS52MS5TdWJtaXRXb3JkSAASIgoGcmVzaWduGAYgASgLMhAubm9pdHUudjEuUmVzaWduSAASHgoEcGluZxgHIAEoCzIOLm5vaXR1LnYxLlBpbmdIABIzCg9yZXF1ZXN0X3JlbWF0Y2gYCCABKAsyGC5ub2l0dS52MS5SZXF1ZXN0UmVtYXRjaEgAQgkKB3BheWxvYWQiaAoHV2VsY29tZRISCgpzZXNzaW9uX2lkGAEgASgJEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIYChBwcm90b2NvbF92ZXJzaW9uGAMgASgNEhkKEWFjY2VwdGVkX25pY2tuYW1lGAQgASgJIiAKC1Jvb21DcmVhdGVkEhEKCXJvb21fY29kZRgBIAEoCSI2CgpSb29tSm9pbmVkEhEKCXJvb21fY29kZRgBIAEoCRIVCg1vcHBvbmVudF9uYW1lGAIgASgJIlsKClBsYXllZFdvcmQSDAoEd29yZBgBIAEoCRINCgVieV9tZRgCIAEoCBIOCgZwb2ludHMYAyABKA0SEQoJc3lsbGFibGVzGAQgASgNEg0KBXR5cGVkGAUgASgJIpEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDSLJAQoKVHVyblVwZGF0ZRIkCgZwbGF5ZWQYASABKAsyFC5ub2l0dS52MS5QbGF5ZWRXb3JkEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhAKCG15X3Njb3JlGAYgASgNEhYKDm9wcG9uZW50X3Njb3JlGAcgASgNEhQKDGNoYWluX2xlbmd0aBgIIAEoDSJWCgxNb3ZlUmVqZWN0ZWQSJgoGcmVhc29uGAEgASgOMhYubm9pdHUudjEuUmVqZWN0UmVhc29uEgwKBHdvcmQYAiABKAkSEAoIdHVybl9zZXEYAyABKA0ifwoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIQCghteV9zY29yZRgDIAEoDRIUCgxjaGFpbl9sZW5ndGgYBCABKA0SEwoLc3VnZ2VzdGlvbnMYBSADKAkiNwoMT3Bwb25lbnRMZWZ0EhUKDWNhbl9yZWNvbm5lY3QYASABKAgSEAoIZ3JhY2VfbXMYAiABKA0iLAoLU2VydmVyRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjYKBFBvbmcSFgoOY2xpZW50X3RpbWVfbXMYASABKAMSFgoOc2VydmVyX3RpbWVfbXMYAiABKAMiVAoMUmVtYXRjaFN0YXRlEhIKCmlfYWNjZXB0ZWQYASABKAgSGQoRb3Bwb25lbnRfYWNjZXB0ZWQYAiABKAgSFQoNZXhwaXJlc19pbl9tcxgDIAEoDSL8AwoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDHJvb21fY3JlYXRlZBgCIAEoCzIVLm5vaXR1LnYxLlJvb21DcmVhdGVkSAASKwoLcm9vbV9qb2luZWQYAyABKAsyFC5ub2l0dS52MS5Sb29tSm9pbmVkSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABIvCg1vcHBvbmVudF9sZWZ0GAggASgLMhYubm9pdHUudjEuT3Bwb25lbnRMZWZ0SAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASLwoNcmVtYXRjaF9zdGF0ZRgLIAEoCzIWLm5vaXR1LnYxLlJlbWF0Y2hTdGF0ZUgAQgkKB3BheWxvYWQqaQoKRGlmZmljdWx0eRIaChZESUZGSUNVTFRZX1VOU1BFQ0lGSUVEEAASEwoPRElGRklDVUxUWV9FQVNZEAESFQoRRElGRklDVUxUWV9NRURJVU0QAhITCg9ESUZGSUNVTFRZX0hBUkQQAyqOAgoMUmVqZWN0UmVhc29uEh0KGVJFSkVDVF9SRUFTT05fVU5TUEVDSUZJRUQQABIjCh9SRUpFQ1RfUkVBU09OX1RPT19GRVdfU1lMTEFCTEVTEAESHAoYUkVKRUNUX1JFQVNPTl9XUk9OR19MSU5LEAISIwofUkVKRUNUX1JFQVNPTl9OT1RfSU5fRElDVElPTkFSWRADEh4KGlJFSkVDVF9SRUFTT05fQUxSRUFEWV9VU0VEEAQSHwobUkVKRUNUX1JFQVNPTl9OT1RfWU9VUl9UVVJOEAUSGQoVUkVKRUNUX1JFQVNPTl9USU1FT1VUEAYSGwoXUkVKRUNUX1JFQVNPTl9HQU1FX09WRVIQByqxAQoNR2FtZUVuZFJlYXNvbhIfChtHQU1FX0VORF9SRUFTT05fVU5TUEVDSUZJRUQQABIbChdHQU1FX0VORF9SRUFTT05fVElNRU9VVBABEiEKHUdBTUVfRU5EX1JFQVNPTl9OT19MRUdBTF9NT1ZFEAISIQodR0FNRV9FTkRfUkVBU09OX09QUE9ORU5UX0xFRlQQAxIcChhHQU1FX0VORF9SRUFTT05fUkVTSUdORUQQBEI6WjhnaXRodWIuY29tL3RpZW5ubTk5ZGV2L25vaXR1L3NlcnZlci9nZW4vbm9pdHUvdjE7bm9pdHV2MWIGcHJvdG8z");
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.Hello.
|
||||
|
||||
@@ -42,7 +42,14 @@ function initialState() {
|
||||
|
||||
/** @type {{ word: string, message: string } | null} */
|
||||
rejection: null,
|
||||
/** @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null} */
|
||||
/**
|
||||
* The finished game. `suggestions` is what the position still had to
|
||||
* offer and arrives only for the player who lost; empty on a loss
|
||||
* means the position was a dead end, which is a different thing to
|
||||
* say than "here is what you missed".
|
||||
*
|
||||
* @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number, suggestions: string[] } | null}
|
||||
*/
|
||||
result: null,
|
||||
/** @type {{ canReconnect: boolean, graceMs: number } | null} */
|
||||
opponentLeft: null,
|
||||
@@ -168,7 +175,8 @@ export function createGameStore() {
|
||||
iWon: value.iWon,
|
||||
reason: value.reason,
|
||||
myScore: value.myScore,
|
||||
chainLength: value.chainLength
|
||||
chainLength: value.chainLength,
|
||||
suggestions: value.suggestions ?? []
|
||||
};
|
||||
break;
|
||||
|
||||
|
||||
@@ -167,9 +167,36 @@ describe('gameOver', () => {
|
||||
iWon: true,
|
||||
reason: GameEndReason.NO_LEGAL_MOVE,
|
||||
myScore: 42,
|
||||
chainLength: 7
|
||||
chainLength: 7,
|
||||
suggestions: []
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the words the losing side could have played', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('gameOver', {
|
||||
iWon: false,
|
||||
reason: GameEndReason.TIMEOUT,
|
||||
myScore: 12,
|
||||
chainLength: 3,
|
||||
suggestions: ['sinh viên', 'sinh sôi']
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.result?.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
||||
});
|
||||
|
||||
it('reads an absent list as a position that had nothing left', () => {
|
||||
// A dead end and an older server that never sends the field arrive the
|
||||
// same way, and both mean "no words to offer" rather than undefined.
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.NO_LEGAL_MOVE }));
|
||||
|
||||
expect(store.state.result?.suggestions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error', () => {
|
||||
|
||||
@@ -71,6 +71,9 @@ describe('generated wire types', () => {
|
||||
expect(over.payload.case).toBe('gameOver');
|
||||
expect(over.payload.value.reason).toBe(GameEndReason.NO_LEGAL_MOVE);
|
||||
expect(over.payload.value.iWon).toBe(false);
|
||||
// The only repeated field in the contract, and the one the losing
|
||||
// player's screen is built from.
|
||||
expect(over.payload.value.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
||||
});
|
||||
|
||||
// The canonical/typed pair is what lets the UI show that the server
|
||||
|
||||
Reference in new issue
Block a user