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
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:
@@ -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 90–180 s) | owner ad-hoc | **done** |
|
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 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) |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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:00–07:00, midnight-cross aware) — so a player is never
|
account (default 00:00–07: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 2–4 players.
|
- **Players**: auto-match is always 2 players; friend games are 2–4 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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -182,6 +182,10 @@ UTC), суточного окна отсутствия (away; сетка по 10
|
|||||||
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
||||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||||
ход, уже включающий все образованные им слова и бонус за все фишки).
|
ход, уже включающий все образованные им слова и бонус за все фишки).
|
||||||
|
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
|
||||||
|
стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не
|
||||||
|
остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка
|
||||||
|
организатора (партию не удалось продолжить), и та же заметка попадает в экспорт GCG.
|
||||||
|
|
||||||
### Администрирование
|
### Администрирование
|
||||||
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
|
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}» недопустимо',
|
||||||
|
|||||||
Reference in New Issue
Block a user