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

+4
View File
@@ -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
+7
View File
@@ -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 {
+2 -1
View File
@@ -1 +1,2 @@
:
: *
sinh viên* sinh sôi
+22 -7
View File
@@ -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" +
+7 -1
View File
@@ -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)
+7 -5
View File
@@ -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)
+50 -6
View File
@@ -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 {
+95 -10
View File
@@ -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)
}
}
+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,
+18
View File
@@ -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);
+55 -2
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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[];
};
/**
+1 -1
View File
@@ -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.
+10 -2
View File
@@ -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;
+28 -1
View File
@@ -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', () => {
+3
View File
@@ -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