feat(game): void unreplayable games as a draw instead of erroring
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s

A committed move that becomes illegal under a tightened rule (the single-word
connectivity fix) makes engine replay fail, which left such games unopenable —
an empty screen and an 'illegal play' error. Now the first open closes the game
gracefully as a draw (engine.EndAborted -> end_reason 'aborted', no winner),
preserves the journal, and surfaces an impersonal organizer note at the end of
the move history and in the GCG export.

- engine: EndAborted + Abort() (draw, no rack adjustment; winner -1).
- service: replay aborts on ErrIllegalPlay; liveGame persists the void once
  (lazy, on open); GameState re-reads for the settled view.
- store: VoidGame finishes the game and stamps a draw without a journal row.
- migration 00002: allow end_reason 'aborted'.
- ui: organizer note under the history grid; i18n en/ru.
- docs: ARCHITECTURE 6/9.1, FUNCTIONAL(+ru), PRERELEASE MW3.
This commit is contained in:
Ilia Denisov
2026-06-14 16:57:27 +02:00
parent ff87a3bf62
commit 222eaf730f
15 changed files with 299 additions and 2 deletions
+1
View File
@@ -27,6 +27,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** | | UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** | | MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
| MW2 | Single-word rule connectivity fix: the word must run along its own line through an existing tile (perpendicular-only contact no longer connects); single-tile direction picks the best legal word (engine v1.1.1) | owner ad-hoc | **done** | | MW2 | Single-word rule connectivity fix: the word must run along its own line through an existing tile (perpendicular-only contact no longer connects); single-tile direction picks the best legal word (engine v1.1.1) | owner ad-hoc | **done** |
| MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | owner ad-hoc | **done** |
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** | | OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** |
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | | DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
+31
View File
@@ -0,0 +1,31 @@
package engine
import "testing"
// TestAbortFinishesAsDrawWithoutAdjustment covers Abort, the graceful close used when a
// committed game can no longer be reconstructed from its journal. The game ends as a draw
// (no winner) regardless of the running scores, and the scores are left untouched (no
// end-game rack adjustment).
func TestAbortFinishesAsDrawWithoutAdjustment(t *testing.T) {
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 2, Seed: 1})
if err != nil {
t.Fatalf("new game: %v", err)
}
g.scores[0], g.scores[1] = 10, 5 // a clear leader, so a draw cannot come from equal scores
g.Abort()
if !g.Over() {
t.Error("aborted game should be over")
}
if g.Reason() != EndAborted {
t.Errorf("reason = %v, want EndAborted", g.Reason())
}
res := g.Result()
if res.Winner != -1 {
t.Errorf("winner = %d, want -1 (draw)", res.Winner)
}
if res.Scores[0] != 10 || res.Scores[1] != 5 {
t.Errorf("scores = %v, want [10 5] (no rack adjustment on abort)", res.Scores)
}
}
+20
View File
@@ -25,6 +25,10 @@ const (
EndScoreless EndScoreless
// EndResign fires when a player resigns. // EndResign fires when a player resigns.
EndResign EndResign
// EndAborted fires when a committed game can no longer be reconstructed from its
// journal — a recorded move became illegal under tightened rules — and is closed as a
// draw rather than left unopenable. See (*Game).Abort.
EndAborted
) )
// String renders the end reason for logs and diagnostics. // String renders the end reason for logs and diagnostics.
@@ -38,6 +42,8 @@ func (r EndReason) String() string {
return "scoreless" return "scoreless"
case EndResign: case EndResign:
return "resign" return "resign"
case EndAborted:
return "aborted"
} }
return "unknown" return "unknown"
} }
@@ -370,6 +376,17 @@ func (g *Game) finish(reason EndReason) {
g.applyEndAdjustment(reason) g.applyEndAdjustment(reason)
} }
// Abort closes a still-running game as a draw with EndAborted and no rack adjustment. The
// service calls it when a committed game can no longer be reconstructed from its journal —
// a recorded move became illegal under tightened rules — so the game ends gracefully
// instead of being left unopenable. It is a no-op on an already-finished game.
func (g *Game) Abort() {
if g.over {
return
}
g.finish(EndAborted)
}
// applyEndAdjustment settles the unplayed racks. When a player goes out (bag // applyEndAdjustment settles the unplayed racks. When a player goes out (bag
// empty, rack empty) they gain the sum of every opponent's rack value and each // empty, rack empty) they gain the sum of every opponent's rack value and each
// opponent loses their own. A scoreless stalemate forfeits each player's own // opponent loses their own. A scoreless stalemate forfeits each player's own
@@ -453,6 +470,9 @@ func (g *Game) winner() int {
if !g.over { if !g.over {
return -1 return -1
} }
if g.reason == EndAborted {
return -1 // an aborted game is a draw regardless of the running scores
}
best, tie := -1, false best, tie := -1, false
for i := range g.scores { for i := range g.scores {
if g.resigned[i] { if g.resigned[i] {
+5
View File
@@ -36,6 +36,11 @@ func writeGCG(g Game, names []string, moves []HistoryMove) string {
fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack) fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack)
} }
} }
// An aborted game ends in a draw because it could no longer be reconstructed; record it
// as an impersonal organizer note (free-text #note; GCG readers ignore pragmas).
if g.EndReason == "aborted" {
fmt.Fprintln(&b, "#note [organizer] game could not be continued and was ended in a draw")
}
return b.String() return b.String()
} }
+15
View File
@@ -61,6 +61,21 @@ func TestWriteGCG(t *testing.T) {
} }
} }
func TestWriteGCGAbortedNote(t *testing.T) {
g := Game{
ID: uuid.MustParse("00000000-0000-7000-8000-000000000002"),
Variant: engine.VariantErudit,
DictVersion: "v1",
Players: 2,
Status: StatusFinished,
EndReason: "aborted",
}
out := writeGCG(g, []string{"Alice", "Bob"}, nil)
if !strings.Contains(out, "#note [organizer]") {
t.Errorf("aborted game GCG missing the organizer note:\n%s", out)
}
}
func TestGCGTilesUppercasesCyrillic(t *testing.T) { func TestGCGTilesUppercasesCyrillic(t *testing.T) {
if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" { if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" {
t.Errorf("gcgTiles = %q, want КОТ?", got) t.Errorf("gcgTiles = %q, want КОТ?", got)
+42
View File
@@ -950,6 +950,12 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
if err != nil { if err != nil {
return StateView{}, err return StateView{}, err
} }
if g.Reason() == engine.EndAborted {
// liveGame voided the game; re-read so the view reflects the finished/aborted state.
if pre, err = svc.store.GetGame(ctx, gameID); err != nil {
return StateView{}, err
}
}
return StateView{ return StateView{
Game: pre, Game: pre,
Seat: seat, Seat: seat,
@@ -1088,6 +1094,13 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
if err != nil { if err != nil {
return nil, err return nil, err
} }
if g.Reason() == engine.EndAborted && pre.Status != StatusFinished {
// First open after the game became unreplayable: persist the void (a finished draw)
// so the lobby and later opens see it settled. Guarded so it runs exactly once.
if err := svc.voidGame(ctx, pre, g); err != nil {
return nil, err
}
}
if !g.Over() { if !g.Over() {
svc.cache.put(pre.ID, g, pre.Variant.String()) svc.cache.put(pre.ID, g, pre.Variant.String())
} }
@@ -1120,12 +1133,41 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
} }
for _, mv := range moves { for _, mv := range moves {
if err := replayMove(g, mv); err != nil { if err := replayMove(g, mv); err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
// A committed move is no longer legal under the current rules, so the game
// cannot be reconstructed past it: close it as a draw (liveGame persists the
// void) rather than leave it unopenable. Other errors are genuine and propagate.
g.Abort()
break
}
return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err) return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err)
} }
} }
return g, nil return g, nil
} }
// voidGame closes pre as a draw because its journal can no longer be replayed; g is the
// partial reconstruction, already Aborted. It persists the finish (end_reason 'aborted'),
// each seat's partial score as a draw, and the draw statistics for the non-guest seats. The
// journal is left intact.
func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) error {
scores := make([]int, g.Players())
for i := range scores {
scores[i] = g.Score(i)
}
statSeats, err := svc.nonGuestSeats(ctx, pre.Seats)
if err != nil {
return err
}
return svc.store.VoidGame(ctx, voidCommit{
gameID: pre.ID,
endReason: g.Reason().String(),
scores: scores,
now: svc.clock(),
stats: buildStats(g, statSeats),
})
}
// replayMove re-applies one journalled move to g through the decoded engine API. // replayMove re-applies one journalled move to g through the decoded engine API.
func replayMove(g *engine.Game, mv HistoryMove) error { func replayMove(g *engine.Game, mv HistoryMove) error {
switch mv.Action { switch mv.Action {
+39
View File
@@ -87,6 +87,17 @@ type commit struct {
stats []statDelta stats []statDelta
} }
// voidCommit is everything voiding an unreplayable game persists: the finish stamp with its
// end reason, each seat's partial score as a draw, and the draw statistics. It appends no
// journal row and leaves the move cursor untouched, so the journal is preserved.
type voidCommit struct {
gameID uuid.UUID
endReason string
scores []int
now time.Time
stats []statDelta
}
// activeGame is the sweeper's view of an in-progress game's turn clock. // activeGame is the sweeper's view of an in-progress game's turn clock.
type activeGame struct { type activeGame struct {
gameID uuid.UUID gameID uuid.UUID
@@ -571,6 +582,34 @@ func (s *Store) CommitMove(ctx context.Context, c commit) error {
}) })
} }
// VoidGame closes a game that can no longer be reconstructed from its journal: it stamps the
// finish (status 'finished', the end reason, finished_at), writes each seat's partial score
// as a draw (is_winner false for all) and upserts the draw statistics, in one transaction.
// Unlike CommitMove it appends no journal row and leaves the move cursor untouched.
func (s *Store) VoidGame(ctx context.Context, v voidCommit) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
gu := table.Games.UPDATE(
table.Games.Status, table.Games.EndReason, table.Games.UpdatedAt, table.Games.FinishedAt,
).SET(
postgres.String(StatusFinished), postgres.String(v.endReason), postgres.TimestampzT(v.now), postgres.TimestampzT(v.now),
).WHERE(table.Games.GameID.EQ(postgres.UUID(v.gameID)))
if _, err := gu.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("void game: %w", err)
}
for seat, score := range v.scores {
if err := updateSeatScore(ctx, tx, v.gameID, seat, score, true, false); err != nil {
return fmt.Errorf("void seat %d: %w", seat, err)
}
}
for _, d := range v.stats {
if err := upsertStats(ctx, tx, d, v.now); err != nil {
return err
}
}
return nil
})
}
// updateSeatScore writes a seat's running score, also stamping is_winner when the // updateSeatScore writes a seat's running score, also stamping is_winner when the
// game has finished. // game has finished.
func updateSeatScore(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, seat, score int, finished, isWinner bool) error { func updateSeatScore(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, seat, score int, finished, isWinner bool) error {
+98
View File
@@ -0,0 +1,98 @@
//go:build integration
package inttest
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// TestGameStateVoidsUnreplayableGame proves the lazy-void path: a game whose journal holds a
// move the current rules reject — here an illegal off-centre first move, standing in for a
// move that was legal when made but became illegal under tightened rules — is, on open,
// finished as a draw with end_reason 'aborted' instead of surfacing an "illegal play" error.
func TestGameStateVoidsUnreplayableGame(t *testing.T) {
ctx := context.Background()
svc := newGameService()
acc0, acc1 := provisionAccount(t), provisionAccount(t)
const seed = 1
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{acc0, acc1}, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Seat 0's deterministic opening rack; pick two non-blank tiles for the crafted move.
mirror, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
if err != nil {
t.Fatalf("mirror: %v", err)
}
var letters []string
for _, l := range mirror.Hand(0) {
if l != "?" {
letters = append(letters, l)
}
if len(letters) == 2 {
break
}
}
if len(letters) < 2 {
t.Fatal("need two non-blank opening tiles")
}
// Insert an illegal first move (off-centre, so the engine rejects it) as the only journal row.
payload, err := json.Marshal(map[string]any{
"rack": mirror.Hand(0),
"dir": "H",
"tiles": []map[string]any{
{"row": 0, "col": 0, "letter": letters[0]},
{"row": 0, "col": 1, "letter": letters[1]},
},
"words": []string{letters[0] + letters[1]},
})
if err != nil {
t.Fatalf("payload: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`INSERT INTO backend.game_moves (game_id, seq, seat, action, score, running_total, exchanged_count, payload)
VALUES ($1, 0, 0, 'play', 0, 0, 0, $2)`, g.ID, string(payload)); err != nil {
t.Fatalf("insert crafted move: %v", err)
}
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET move_count = 1 WHERE game_id = $1`, g.ID); err != nil {
t.Fatalf("bump move_count: %v", err)
}
// Open through a fresh service so its live-game cache is cold and the crafted journal is
// replayed (the real scenario: a player opening the game in a new session). It must not
// error; the game comes back voided as a draw.
svc2 := newGameService()
view, err := svc2.GameState(ctx, g.ID, acc0)
if err != nil {
t.Fatalf("GameState on an unreplayable game should not error, got: %v", err)
}
if view.Game.Status != game.StatusFinished {
t.Errorf("status = %q, want %q", view.Game.Status, game.StatusFinished)
}
if view.Game.EndReason != "aborted" {
t.Errorf("end_reason = %q, want aborted", view.Game.EndReason)
}
for _, s := range view.Game.Seats {
if s.IsWinner {
t.Errorf("seat %d marked winner; an aborted game is a draw", s.Seat)
}
}
// Idempotent: a second open (fresh cold cache) also succeeds and stays voided.
if _, err := newGameService().GameState(ctx, g.ID, acc1); err != nil {
t.Fatalf("second GameState should not error: %v", err)
}
}
@@ -0,0 +1,16 @@
-- +goose Up
-- Allow end_reason = 'aborted': a game whose journal can no longer be reconstructed (a
-- recorded move became illegal under tightened rules) is closed as a draw rather than left
-- unopenable. See engine.EndAborted and Service.voidGame.
SET search_path = backend, pg_catalog;
ALTER TABLE games DROP CONSTRAINT games_end_reason_chk;
ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout', 'aborted')
);
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE games DROP CONSTRAINT games_end_reason_chk;
ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
);
+10 -2
View File
@@ -321,7 +321,9 @@ Key points:
becomes an automatic resignation, applied by a background sweeper. The sweeper becomes an automatic resignation, applied by a background sweeper. The sweeper
honours each player's **away window** — a daily local-time sleep interval on the honours each player's **away window** — a daily local-time sleep interval on the
account (default 00:0007:00, midnight-cross aware) — so a player is never account (default 00:0007:00, midnight-cross aware) — so a player is never
timed out while asleep. timed out while asleep. A game whose journal can no longer be replayed — a
committed move made illegal by a later rule change — is instead closed as a
**draw** (`aborted`) on the next open, never left unopenable (§9.1).
- **Players**: auto-match is always 2 players; friend games are 24 players. - **Players**: auto-match is always 2 players; friend games are 24 players.
`backend` owns turn order and the bag for any player count. A resignation or `backend` owns turn order and the bag for any player count. A resignation or
timeout in a two-player game ends it with the other player winning. In a game timeout in a two-player game ends it with the other player winning. In a game
@@ -525,7 +527,13 @@ re-deriving it, so the rebuild matches the committed game) and to render history
dictionary**: the board for visual replay is reconstructed by applying placements dictionary**: the board for visual replay is reconstructed by applying placements
onto an empty grid, since moves were validated at play time and scores are onto an empty grid, since moves were validated at play time and scores are
stored. `variant` and `dict_version` are kept as **metadata only** (audit, stored. `variant` and `dict_version` are kept as **metadata only** (audit,
complaint review), never as a replay dependency. **GCG export** is derived from complaint review), never as a replay dependency. Engine replay re-validates each
move, so a committed move that **later becomes illegal** under a tightened rule
(e.g. the single-word connectivity rule) would make the rebuild fail; rather than
leave the game unopenable, the next open closes it gracefully as a **draw**
(`engine.EndAborted``end_reason='aborted'`, all seats marked non-winners),
preserves the journal intact, and surfaces an impersonal **organizer note** at the
end of the history and in the GCG export (a free-text `#note`). **GCG export** is derived from
the same rows and is likewise self-contained — we ship our own writer (the solver the same rows and is likewise self-contained — we ship our own writer (the solver
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon` exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
+4
View File
@@ -177,6 +177,10 @@ would leak the move journal), and the client shares the `.gcg` file where the
platform supports it, otherwise downloads it. Statistics (durable accounts only): platform supports it, otherwise downloads it. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus). best play, which already includes every word it formed plus the all-tiles bonus).
A game that can no longer be continued — because a rule changed and an earlier
move would now be illegal — is closed as a **draw** the moment someone opens it,
never left stuck on an error: the move history shows an impersonal organizer note
at its end (the game could not be continued), and the same note rides the GCG export.
### Administration ### Administration
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
+4
View File
@@ -182,6 +182,10 @@ UTC), суточного окна отсутствия (away; сетка по 10
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов): поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). ход, уже включающий все образованные им слова и бонус за все фишки).
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не
остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка
организатора (партию не удалось продолжить), и та же заметка попадает в экспорт GCG.
### Администрирование ### Администрирование
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
+12
View File
@@ -917,6 +917,9 @@
{/each} {/each}
</div> </div>
</div> </div>
{#if view.game.endReason === 'aborted'}
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
</div> </div>
{/if} {/if}
@@ -1174,6 +1177,15 @@
.hsc { .hsc {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* The impersonal organizer note closing an aborted (un-replayable) game, under the grid. */
.horganizer {
margin: 0;
padding: 8px var(--pad);
color: var(--text-muted);
font-size: 0.85rem;
font-style: italic;
text-align: center;
}
.boardwrap { .boardwrap {
padding: 6px; padding: 6px;
transition: transform 0.3s ease; transition: transform 0.3s ease;
+1
View File
@@ -81,6 +81,7 @@ export const en = {
'game.won': 'You won', 'game.won': 'You won',
'game.lost': 'You lost', 'game.lost': 'You lost',
'game.tied': 'Draw', 'game.tied': 'Draw',
'game.abortedNote': 'This game could not be continued by the organizer and was ended in a draw.',
'game.checkWordPrompt': 'Enter a word', 'game.checkWordPrompt': 'Enter a word',
'game.wordLegal': '“{word}” is valid', 'game.wordLegal': '“{word}” is valid',
'game.wordIllegal': '“{word}” is not valid', 'game.wordIllegal': '“{word}” is not valid',
+1
View File
@@ -82,6 +82,7 @@ export const ru: Record<MessageKey, string> = {
'game.won': 'Вы выиграли', 'game.won': 'Вы выиграли',
'game.lost': 'Вы проиграли', 'game.lost': 'Вы проиграли',
'game.tied': 'Ничья', 'game.tied': 'Ничья',
'game.abortedNote': 'Партия не может быть продолжена по вине организатора и завершена ничьёй.',
'game.checkWordPrompt': 'Введите слово', 'game.checkWordPrompt': 'Введите слово',
'game.wordLegal': '«{word}» допустимо', 'game.wordLegal': '«{word}» допустимо',
'game.wordIllegal': '«{word}» недопустимо', 'game.wordIllegal': '«{word}» недопустимо',