751e74b14f
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.
Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.
Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.
Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
79 lines
2.3 KiB
Go
79 lines
2.3 KiB
Go
package game
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestInAwayWindow(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
dl, start, end int
|
|
wantIn, wantToday bool
|
|
}{
|
|
{"non-crossing inside", 120, 0, 420, true, true},
|
|
{"non-crossing before", 500, 0, 420, false, false},
|
|
{"non-crossing at start", 0, 0, 420, true, true},
|
|
{"non-crossing at end excluded", 420, 0, 420, false, false},
|
|
{"crossing evening", 1380, 1320, 360, true, false},
|
|
{"crossing morning", 180, 1320, 360, true, true},
|
|
{"crossing daytime out", 720, 1320, 360, false, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
in, today := inAwayWindow(tc.dl, tc.start, tc.end)
|
|
if in != tc.wantIn || today != tc.wantToday {
|
|
t.Errorf("inAwayWindow(%d,%d,%d) = (%v,%v), want (%v,%v)",
|
|
tc.dl, tc.start, tc.end, in, today, tc.wantIn, tc.wantToday)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEffectiveDeadline(t *testing.T) {
|
|
utc := time.UTC
|
|
day := func(h, m int) time.Time { return time.Date(2026, 6, 2, h, m, 0, 0, utc) }
|
|
hour := time.Hour
|
|
|
|
cases := []struct {
|
|
name string
|
|
start time.Time
|
|
timeout time.Duration
|
|
awayStart int
|
|
awayEnd int
|
|
want time.Time
|
|
}{
|
|
{"no window", day(1, 0), hour, 0, 0, day(2, 0)},
|
|
{"outside window", day(8, 0), hour, 0, 420, day(9, 0)},
|
|
{"inside non-crossing pushed to end", day(1, 0), hour, 0, 420, day(7, 0)},
|
|
{"inside non-crossing at boundary", day(2, 30), 3 * hour, 0, 420, day(7, 0)},
|
|
{"crossing evening pushed to next day", day(22, 0), hour, 1320, 360, day(6, 0).AddDate(0, 0, 1)},
|
|
{"crossing morning pushed to today end", day(2, 0), hour, 1320, 360, day(6, 0)},
|
|
{"crossing daytime untouched", day(11, 0), hour, 1320, 360, day(12, 0)},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := effectiveDeadline(tc.start, tc.timeout, utc, tc.awayStart, tc.awayEnd)
|
|
if !got.Equal(tc.want) {
|
|
t.Errorf("effectiveDeadline = %s, want %s", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMinutesOfDay(t *testing.T) {
|
|
got := minutesOfDay(time.Date(1, 1, 1, 7, 30, 0, 0, time.UTC))
|
|
if got != 450 {
|
|
t.Errorf("minutesOfDay(07:30) = %d, want 450", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadLocationFallsBackToUTC(t *testing.T) {
|
|
if loadLocation("") != time.UTC {
|
|
t.Error("empty zone must fall back to UTC")
|
|
}
|
|
if loadLocation("Totally/Bogus") != time.UTC {
|
|
t.Error("unknown zone must fall back to UTC")
|
|
}
|
|
}
|