fix(offline): keep the local composition; feat(rules): no repeated words in Erudit #287
@@ -23,6 +23,13 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
|
|||||||
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
|
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
|
||||||
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
|
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
|
||||||
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
|
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
|
||||||
|
- **The UI job's last step is a size gate, not a test:** `cd ui && node scripts/bundle-size.mjs`
|
||||||
|
(gzip budgets per entry, `BUDGET` in that file). `pnpm build` succeeding says nothing about it, and
|
||||||
|
the app entry usually sits within a few hundred bytes of its cap, so *any* feature touching an
|
||||||
|
always-loaded screen can fail CI green-on-everything-else. Run it, and **rebuild first** — the
|
||||||
|
script measures whatever is in `dist/`, so a stale build silently measures the wrong branch. Put
|
||||||
|
new logic behind an existing lazy import where it belongs; raising `BUDGET` is allowed but the
|
||||||
|
file's header comment records the reason for every past raise — keep that up, and ask the owner.
|
||||||
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
|
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
|
||||||
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
|
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
|
||||||
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
|
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
|
||||||
@@ -90,6 +97,11 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
|
|||||||
both the FBS schema and the backend DTO.
|
both the FBS schema and the backend DTO.
|
||||||
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
|
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
|
||||||
DTOs. Change both or they drift.
|
DTOs. Change both or they drift.
|
||||||
|
- **The client codec UPPER-CASES history words** (`lib/codec.ts` `decodeMove`) for display, while the
|
||||||
|
engine/evaluator decodes candidate words to lower. Anything comparing the two must case-fold, or it
|
||||||
|
silently never matches — this is how the Erudit no-repeat rule shipped dead on the online path
|
||||||
|
while working offline (offline compares alphabet-index keys, so it has no case at all). A test that
|
||||||
|
hand-builds such a set instead of deriving it the way the screen does will pass vacuously.
|
||||||
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
|
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
|
||||||
then bumps it from the live event. Don't expect it on the shared broadcast.
|
then bumps it from the live event. Don't expect it on the shared broadcast.
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||||
}
|
}
|
||||||
|
move, err = g.noRepeat(move, g.playedWords())
|
||||||
|
if err != nil {
|
||||||
|
return MoveRecord{}, err
|
||||||
|
}
|
||||||
return g.decodeMove(move), nil
|
return g.decodeMove(move), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ type Options struct {
|
|||||||
// perpendicular cross-words are ignored. Callers always set this explicitly; the
|
// perpendicular cross-words are ignored. Callers always set this explicitly; the
|
||||||
// zero value (false) is the single-word rule.
|
// zero value (false) is the single-word rule.
|
||||||
MultipleWordsPerTurn bool
|
MultipleWordsPerTurn bool
|
||||||
|
// NoRepeatWords enables the "Эрудит" rule under which a word already laid on the
|
||||||
|
// board cannot be laid again (see norepeat.go). It is pinned per game rather than
|
||||||
|
// derived from the variant, so a game started before the rule existed replays and
|
||||||
|
// scores unchanged; the zero value (false) is the unrestricted rule.
|
||||||
|
NoRepeatWords bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game is the in-memory state of a single match and the pure rules engine over
|
// Game is the in-memory state of a single match and the pure rules engine over
|
||||||
@@ -127,6 +132,7 @@ type Game struct {
|
|||||||
resigned []bool // per seat; a resigned seat is skipped and cannot win
|
resigned []bool // per seat; a resigned seat is skipped and cannot win
|
||||||
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
|
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
|
||||||
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
|
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
|
||||||
|
noRepeatWords bool // true = a word already on the board cannot be laid again
|
||||||
log []MoveRecord
|
log []MoveRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +170,7 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
|||||||
resigned: make([]bool, opts.Players),
|
resigned: make([]bool, opts.Players),
|
||||||
dropoutTiles: opts.DropoutTiles,
|
dropoutTiles: opts.DropoutTiles,
|
||||||
multipleWords: opts.MultipleWordsPerTurn,
|
multipleWords: opts.MultipleWordsPerTurn,
|
||||||
|
noRepeatWords: opts.NoRepeatWords,
|
||||||
}
|
}
|
||||||
for i := range g.hands {
|
for i := range g.hands {
|
||||||
g.hands[i] = g.bag.Draw(rs.RackSize)
|
g.hands[i] = g.bag.Draw(rs.RackSize)
|
||||||
@@ -195,6 +202,10 @@ func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRec
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||||
}
|
}
|
||||||
|
move, err = g.noRepeat(move, g.playedWords())
|
||||||
|
if err != nil {
|
||||||
|
return MoveRecord{}, err
|
||||||
|
}
|
||||||
|
|
||||||
scrabble.Apply(g.board, move)
|
scrabble.Apply(g.board, move)
|
||||||
g.removeFromHand(player, placementTiles(tiles))
|
g.removeFromHand(player, placementTiles(tiles))
|
||||||
@@ -300,7 +311,7 @@ func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
|
|||||||
// GenerateMoves returns every legal play for the current player's rack, ranked
|
// GenerateMoves returns every legal play for the current player's rack, ranked
|
||||||
// by descending score. It is empty when the player has no legal play.
|
// by descending score. It is empty when the player has no legal play.
|
||||||
func (g *Game) GenerateMoves() []scrabble.Move {
|
func (g *Game) GenerateMoves() []scrabble.Move {
|
||||||
return g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
|
return g.noRepeatFilter(g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hint returns the highest-scoring legal play for the current player and true,
|
// Hint returns the highest-scoring legal play for the current player and true,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The no-repeat-words rule of Russian "Эрудит": a word laid on the board belongs to the game
|
||||||
|
// from then on and cannot be laid again. It is a rule of that variant only — official Scrabble
|
||||||
|
// places no such restriction — and it is pinned per game (Options.NoRepeatWords) rather than
|
||||||
|
// derived from the variant, so a game started before the rule existed keeps replaying and
|
||||||
|
// scoring exactly as it did when it was played.
|
||||||
|
//
|
||||||
|
// The rule reads the game's own move log, which is the record of every word ever formed, and
|
||||||
|
// applies in two different ways:
|
||||||
|
//
|
||||||
|
// - the play's main word must not already be there — such a play is illegal, and is neither
|
||||||
|
// accepted nor offered by the move generator;
|
||||||
|
// - a perpendicular cross-word that is already there is allowed, because it is incidental to
|
||||||
|
// laying the main word, but it scores nothing.
|
||||||
|
//
|
||||||
|
// The rule is deliberately kept out of the solver, which stays a stateless, standard-rules
|
||||||
|
// engine: only this package knows a game's history.
|
||||||
|
|
||||||
|
// playedWords returns the set of words already formed in this game, as the decoded strings the
|
||||||
|
// move log records — the main word and every cross-word of every play. Both this set and the
|
||||||
|
// words a candidate move is checked against come from the same decoder, so they compare exactly.
|
||||||
|
func (g *Game) playedWords() map[string]struct{} {
|
||||||
|
played := make(map[string]struct{})
|
||||||
|
for _, rec := range g.log {
|
||||||
|
if rec.Action != ActionPlay {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, w := range rec.Words {
|
||||||
|
played[w] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return played
|
||||||
|
}
|
||||||
|
|
||||||
|
// noRepeat applies the no-repeat-words rule to an already validated move, given the set of words
|
||||||
|
// already played (see playedWords). It returns ErrIllegalPlay when the move's main word is one of
|
||||||
|
// them, and otherwise the move with every already-played cross-word scored down to zero and the
|
||||||
|
// total reduced to match. A game without the rule gets its move back untouched.
|
||||||
|
func (g *Game) noRepeat(m scrabble.Move, played map[string]struct{}) (scrabble.Move, error) {
|
||||||
|
if !g.noRepeatWords {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if main := g.word(m.Main); isPlayed(played, main) {
|
||||||
|
return scrabble.Move{}, fmt.Errorf("%w: word %q is already on the board", ErrIllegalPlay, main)
|
||||||
|
}
|
||||||
|
for i, cw := range m.Cross {
|
||||||
|
if !isPlayed(played, g.word(cw)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Score -= cw.Score
|
||||||
|
m.Cross[i].Score = 0
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlayed reports whether word is in the played set. The empty string is never played: it is
|
||||||
|
// what the decoder yields for a malformed letter index, and such a word must not silently match.
|
||||||
|
func isPlayed(played map[string]struct{}, word string) bool {
|
||||||
|
if word == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := played[word]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// noRepeatFilter applies the rule across a generated move list: it drops the plays whose main
|
||||||
|
// word is already on the board, rescores the rest, and re-ranks them by the adjusted score. The
|
||||||
|
// sort is stable, so plays the solver ranked equally keep its order. A game without the rule
|
||||||
|
// gets its list back untouched.
|
||||||
|
func (g *Game) noRepeatFilter(moves []scrabble.Move) []scrabble.Move {
|
||||||
|
if !g.noRepeatWords {
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
played := g.playedWords()
|
||||||
|
out := moves[:0]
|
||||||
|
for _, m := range moves {
|
||||||
|
adjusted, err := g.noRepeat(m, played)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, adjusted)
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEruditNoRepeat builds a two-player Erudit game with the no-repeat-words rule set either
|
||||||
|
// way, plus a helper resolving a letter to its alphabet index.
|
||||||
|
func newEruditNoRepeat(t *testing.T, noRepeat bool) (*Game, func(string) byte) {
|
||||||
|
t.Helper()
|
||||||
|
g, err := New(testReg, Options{
|
||||||
|
Variant: VariantErudit,
|
||||||
|
Version: testVersion,
|
||||||
|
Players: 2,
|
||||||
|
Seed: 1,
|
||||||
|
MultipleWordsPerTurn: true,
|
||||||
|
NoRepeatWords: noRepeat,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new erudit game: %v", err)
|
||||||
|
}
|
||||||
|
return g, func(s string) byte {
|
||||||
|
i, err := g.rules.Alphabet.Index(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("index %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// playKotAtCentre commits the opening horizontal КОТ across the centre square, so that the word
|
||||||
|
// is genuinely in the game's move log rather than only on the board.
|
||||||
|
func playKotAtCentre(t *testing.T, g *Game, idx func(string) byte) (row, col int) {
|
||||||
|
t.Helper()
|
||||||
|
row, col = centre(g.rules)
|
||||||
|
g.hands[0] = []byte{idx("к"), idx("о"), idx("т")}
|
||||||
|
if _, err := g.Play(scrabble.Horizontal, placementsForWord(t, g.rules, row, col, scrabble.Horizontal, "кот")); err != nil {
|
||||||
|
t.Fatalf("opening кот: %v", err)
|
||||||
|
}
|
||||||
|
return row, col
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoRepeatRejectsAReplayedMainWord is the rule's headline case: КОТ laid once may not be laid
|
||||||
|
// again, here as a vertical play hooking the opening word's К. Without the rule the very same play
|
||||||
|
// is legal, which pins the rejection on the rule and not on the position.
|
||||||
|
func TestNoRepeatRejectsAReplayedMainWord(t *testing.T) {
|
||||||
|
if ok, err := testReg.Lookup(VariantErudit, testVersion, "кот"); err != nil || !ok {
|
||||||
|
t.Fatalf("precondition: кот must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
|
||||||
|
}
|
||||||
|
// The second play reuses the opening К and adds О and Т below it, forming КОТ downwards.
|
||||||
|
// Neither new tile has a horizontal neighbour, so the main word is the only word formed.
|
||||||
|
replay := func(t *testing.T, g *Game, idx func(string) byte, row, col int) error {
|
||||||
|
t.Helper()
|
||||||
|
g.hands[g.toMove] = []byte{idx("о"), idx("т")}
|
||||||
|
_, err := g.Play(scrabble.Vertical, []scrabble.Placement{
|
||||||
|
{Row: row + 1, Col: col, Letter: idx("о")},
|
||||||
|
{Row: row + 2, Col: col, Letter: idx("т")},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("with the rule the replayed word is illegal", func(t *testing.T) {
|
||||||
|
g, idx := newEruditNoRepeat(t, true)
|
||||||
|
row, col := playKotAtCentre(t, g, idx)
|
||||||
|
if err := replay(t, g, idx, row, col); !errors.Is(err, ErrIllegalPlay) {
|
||||||
|
t.Fatalf("replaying кот: err = %v, want ErrIllegalPlay", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("without the rule the same play is accepted", func(t *testing.T) {
|
||||||
|
g, idx := newEruditNoRepeat(t, false)
|
||||||
|
row, col := playKotAtCentre(t, g, idx)
|
||||||
|
if err := replay(t, g, idx, row, col); err != nil {
|
||||||
|
t.Fatalf("replaying кот without the rule: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoRepeatPreviewRejectsAReplayedMainWord pins the preview to the same answer as submitting:
|
||||||
|
// EvaluatePlay must reject what Play rejects, or the player would be shown a score for a move the
|
||||||
|
// server will refuse.
|
||||||
|
func TestNoRepeatPreviewRejectsAReplayedMainWord(t *testing.T) {
|
||||||
|
g, idx := newEruditNoRepeat(t, true)
|
||||||
|
row, col := playKotAtCentre(t, g, idx)
|
||||||
|
g.hands[g.toMove] = []byte{idx("о"), idx("т")}
|
||||||
|
_, err := g.EvaluatePlay([]TileRecord{
|
||||||
|
{Row: row + 1, Col: col, Letter: "о"},
|
||||||
|
{Row: row + 2, Col: col, Letter: "т"},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrIllegalPlay) {
|
||||||
|
t.Fatalf("previewing a replayed кот: err = %v, want ErrIllegalPlay", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoRepeatDropsTheScoreOfAReplayedCrossWord covers the other half of the rule: a play whose
|
||||||
|
// perpendicular cross-word is already on the board stays legal — the cross-word is incidental to
|
||||||
|
// laying the main word — but that cross-word scores nothing. The position places РОТ so that its
|
||||||
|
// Т completes a standing КО downwards into a second КОТ.
|
||||||
|
func TestNoRepeatDropsTheScoreOfAReplayedCrossWord(t *testing.T) {
|
||||||
|
if ok, err := testReg.Lookup(VariantErudit, testVersion, "рот"); err != nil || !ok {
|
||||||
|
t.Fatalf("precondition: рот must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
|
||||||
|
}
|
||||||
|
// The main word РОТ runs along row 12; only its last tile has vertical neighbours, so КОТ
|
||||||
|
// (К and О standing at rows 10 and 11 of column 5) is the play's single cross-word.
|
||||||
|
evaluate := func(t *testing.T, noRepeat bool) MoveRecord {
|
||||||
|
t.Helper()
|
||||||
|
g, idx := newEruditNoRepeat(t, noRepeat)
|
||||||
|
playKotAtCentre(t, g, idx)
|
||||||
|
applyScaffold(t, g, idx)
|
||||||
|
g.hands[g.toMove] = []byte{idx("р"), idx("о"), idx("т")}
|
||||||
|
rec, err := g.EvaluatePlay([]TileRecord{
|
||||||
|
{Row: 12, Col: 3, Letter: "р"},
|
||||||
|
{Row: 12, Col: 4, Letter: "о"},
|
||||||
|
{Row: 12, Col: 5, Letter: "т"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluating рот (noRepeat=%v): %v", noRepeat, err)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
unrestricted, restricted := evaluate(t, false), evaluate(t, true)
|
||||||
|
// Exact totals, not merely "less": the client port scores this very position in
|
||||||
|
// ui/src/lib/dict/eval.norepeat.test.ts and is pinned to the same two numbers, so a scoring
|
||||||
|
// divergence between the two engines breaks one of the two tests.
|
||||||
|
if unrestricted.Score != 10 {
|
||||||
|
t.Errorf("score without the rule = %d, want 10 (РОТ plus the КОТ cross-word)", unrestricted.Score)
|
||||||
|
}
|
||||||
|
if restricted.Score != 5 {
|
||||||
|
t.Errorf("score with the rule = %d, want 5 (РОТ alone; the repeated cross-word earns nothing)", restricted.Score)
|
||||||
|
}
|
||||||
|
// The word is still formed and still reported — it simply earns nothing.
|
||||||
|
want := []string{"рот", "кот"}
|
||||||
|
if len(restricted.Words) != len(want) || restricted.Words[0] != want[0] || restricted.Words[1] != want[1] {
|
||||||
|
t.Errorf("words with the rule = %v, want %v", restricted.Words, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoRepeatFiltersGeneratedMoves keeps the robot and the hint honest: a play the rule forbids
|
||||||
|
// must never be generated, or the engine would offer a move it would then refuse.
|
||||||
|
func TestNoRepeatFiltersGeneratedMoves(t *testing.T) {
|
||||||
|
g, idx := newEruditNoRepeat(t, true)
|
||||||
|
playKotAtCentre(t, g, idx)
|
||||||
|
g.hands[g.toMove] = []byte{idx("к"), idx("о"), idx("т"), idx("а"), idx("н"), idx("с"), idx("л")}
|
||||||
|
|
||||||
|
moves := g.GenerateMoves()
|
||||||
|
if len(moves) == 0 {
|
||||||
|
t.Fatal("no legal replies generated; the position cannot exercise the filter")
|
||||||
|
}
|
||||||
|
for _, m := range moves {
|
||||||
|
if w := g.word(m.Main); w == "кот" {
|
||||||
|
t.Fatalf("generated a play of кот, which is already on the board")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The list stays ranked by the adjusted score, which the hint relies on.
|
||||||
|
for i := 1; i < len(moves); i++ {
|
||||||
|
if moves[i-1].Score < moves[i].Score {
|
||||||
|
t.Fatalf("generated moves not ranked: move %d scores %d, move %d scores %d",
|
||||||
|
i-1, moves[i-1].Score, i, moves[i].Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoRepeatOffLeavesTheGameUnchanged is the compatibility guard for every game started before
|
||||||
|
// the rule existed (and for both Scrabble variants, which never take it): nothing is filtered,
|
||||||
|
// nothing is rescored.
|
||||||
|
func TestNoRepeatOffLeavesTheGameUnchanged(t *testing.T) {
|
||||||
|
g, idx := newEruditNoRepeat(t, false)
|
||||||
|
playKotAtCentre(t, g, idx)
|
||||||
|
g.hands[g.toMove] = []byte{idx("к"), idx("о"), idx("т"), idx("а"), idx("н"), idx("с"), idx("л")}
|
||||||
|
|
||||||
|
generated := g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
|
||||||
|
if got, want := len(g.GenerateMoves()), len(generated); got != want {
|
||||||
|
t.Errorf("generated %d moves, want the solver's %d untouched", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyScaffold stands КО down column 5 above row 12, so a play across row 12 completes КОТ.
|
||||||
|
func applyScaffold(t *testing.T, g *Game, idx func(string) byte) {
|
||||||
|
t.Helper()
|
||||||
|
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
|
||||||
|
{Row: 10, Col: 5, Letter: idx("к")},
|
||||||
|
{Row: 11, Col: 5, Letter: idx("о")},
|
||||||
|
}})
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
|
|||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
LastActivityUnix: last.Unix(),
|
LastActivityUnix: last.Unix(),
|
||||||
|
NoRepeatWords: g.NoRepeatWords,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (Repla
|
|||||||
Seed: seed,
|
Seed: seed,
|
||||||
DropoutTiles: pre.DropoutTiles,
|
DropoutTiles: pre.DropoutTiles,
|
||||||
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
|
||||||
|
NoRepeatWords: pre.NoRepeatWords,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ReplayTimelineView{}, err
|
return ReplayTimelineView{}, err
|
||||||
|
|||||||
@@ -284,6 +284,13 @@ func (svc *Service) versionResident(version string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noRepeatWordsFor reports whether a game of variant v is created with the no-repeat-words rule
|
||||||
|
// (see engine/norepeat.go): a word already on the board cannot be laid again. It is a rule of
|
||||||
|
// Russian "Эрудит" alone — both Scrabble variants let a word be played as often as a player can
|
||||||
|
// manage. The answer is pinned into games.no_repeat_words at creation and read back from there
|
||||||
|
// afterwards, never re-derived, so games created before the rule existed keep their own answer.
|
||||||
|
func noRepeatWordsFor(v engine.Variant) bool { return v == engine.VariantErudit }
|
||||||
|
|
||||||
// Create starts and persists a new game seating the given accounts in turn order
|
// Create starts and persists a new game seating the given accounts in turn order
|
||||||
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
|
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
|
||||||
// the player count (2–4), the move clock, the hint allowance and that every seat
|
// the player count (2–4), the move clock, the hint allowance and that every seat
|
||||||
@@ -359,6 +366,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
|||||||
Seed: seed,
|
Seed: seed,
|
||||||
DropoutTiles: params.DropoutTiles,
|
DropoutTiles: params.DropoutTiles,
|
||||||
MultipleWordsPerTurn: params.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||||
|
NoRepeatWords: noRepeatWordsFor(params.Variant),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
|
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
|
||||||
@@ -382,6 +390,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
|||||||
hintsPerPlayer: params.HintsPerPlayer,
|
hintsPerPlayer: params.HintsPerPlayer,
|
||||||
dropoutTiles: params.DropoutTiles.String(),
|
dropoutTiles: params.DropoutTiles.String(),
|
||||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||||
|
noRepeatWords: noRepeatWordsFor(params.Variant),
|
||||||
vsAI: params.VsAI,
|
vsAI: params.VsAI,
|
||||||
kind: params.Kind,
|
kind: params.Kind,
|
||||||
}
|
}
|
||||||
@@ -445,6 +454,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
|||||||
hintsPerPlayer: params.HintsPerPlayer,
|
hintsPerPlayer: params.HintsPerPlayer,
|
||||||
dropoutTiles: params.DropoutTiles.String(),
|
dropoutTiles: params.DropoutTiles.String(),
|
||||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||||
|
noRepeatWords: noRepeatWordsFor(params.Variant),
|
||||||
status: StatusOpen,
|
status: StatusOpen,
|
||||||
openDeadline: &deadline,
|
openDeadline: &deadline,
|
||||||
kind: gamelimits.KindRandom,
|
kind: gamelimits.KindRandom,
|
||||||
@@ -1584,6 +1594,7 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
|
|||||||
Seed: seed,
|
Seed: seed,
|
||||||
DropoutTiles: pre.DropoutTiles,
|
DropoutTiles: pre.DropoutTiles,
|
||||||
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
|
||||||
|
NoRepeatWords: pre.NoRepeatWords,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ type gameInsert struct {
|
|||||||
dropoutTiles string
|
dropoutTiles string
|
||||||
// multipleWordsPerTurn false selects the single-word rule for the game.
|
// multipleWordsPerTurn false selects the single-word rule for the game.
|
||||||
multipleWordsPerTurn bool
|
multipleWordsPerTurn bool
|
||||||
|
// noRepeatWords true forbids laying a word that is already on the board (games.no_repeat_words).
|
||||||
|
noRepeatWords bool
|
||||||
// vsAI marks an honest-AI game (games.vs_ai).
|
// vsAI marks an honest-AI game (games.vs_ai).
|
||||||
vsAI bool
|
vsAI bool
|
||||||
// kind tags the game's origin (games.game_kind) for the active-game limits.
|
// kind tags the game's origin (games.game_kind) for the active-game limits.
|
||||||
@@ -173,8 +175,10 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
|
|||||||
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
||||||
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
||||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
|
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
|
||||||
|
table.Games.NoRepeatWords,
|
||||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
||||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind))
|
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind),
|
||||||
|
ins.noRepeatWords)
|
||||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("insert game: %w", err)
|
return fmt.Errorf("insert game: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1355,6 +1359,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
|||||||
UpdatedAt: g.UpdatedAt,
|
UpdatedAt: g.UpdatedAt,
|
||||||
}
|
}
|
||||||
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
||||||
|
out.NoRepeatWords = g.NoRepeatWords
|
||||||
out.VsAI = g.VsAi
|
out.VsAI = g.VsAi
|
||||||
out.Kind = gamelimits.Kind(g.GameKind)
|
out.Kind = gamelimits.Kind(g.GameKind)
|
||||||
if g.EndReason != nil {
|
if g.EndReason != nil {
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ type Game struct {
|
|||||||
FinishedAt *time.Time
|
FinishedAt *time.Time
|
||||||
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
|
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
|
||||||
MultipleWordsPerTurn bool
|
MultipleWordsPerTurn bool
|
||||||
|
// NoRepeatWords true forbids laying a word that is already on the board (the "Эрудит" rule,
|
||||||
|
// see engine/norepeat.go). It is pinned at creation from the variant rather than derived on
|
||||||
|
// read, so a game started before the rule existed keeps replaying and scoring unchanged.
|
||||||
|
NoRepeatWords bool
|
||||||
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
|
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
|
||||||
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
|
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
|
||||||
VsAI bool
|
VsAI bool
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTwoSeatGame starts a plain two-human game of the given variant.
|
||||||
|
func createTwoSeatGame(t *testing.T, svc *game.Service, variant engine.Variant) game.Game {
|
||||||
|
t.Helper()
|
||||||
|
g, err := svc.Create(context.Background(), game.CreateParams{
|
||||||
|
Variant: variant,
|
||||||
|
Seats: []uuid.UUID{provisionAccount(t), provisionAccount(t)},
|
||||||
|
HintsAllowed: true,
|
||||||
|
HintsPerPlayer: 1,
|
||||||
|
MultipleWordsPerTurn: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create %s game: %v", variant, err)
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoRepeatWordsPinnedFromTheVariant checks the rule is written into games.no_repeat_words at
|
||||||
|
// creation from the variant — on for Erudit, off for both Scrabble variants — and read back with
|
||||||
|
// the game rather than re-derived.
|
||||||
|
func TestNoRepeatWordsPinnedFromTheVariant(t *testing.T) {
|
||||||
|
svc := newGameService()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
variant engine.Variant
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{engine.VariantErudit, true},
|
||||||
|
{engine.VariantRussianScrabble, false},
|
||||||
|
{engine.VariantEnglish, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.variant.String(), func(t *testing.T) {
|
||||||
|
created := createTwoSeatGame(t, svc, tc.variant)
|
||||||
|
if created.NoRepeatWords != tc.want {
|
||||||
|
t.Errorf("created game NoRepeatWords = %v, want %v", created.NoRepeatWords, tc.want)
|
||||||
|
}
|
||||||
|
// Re-read it: the value must come from the row, not from the create call.
|
||||||
|
reloaded, err := svc.GameByID(context.Background(), created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get game: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.NoRepeatWords != tc.want {
|
||||||
|
t.Errorf("reloaded game NoRepeatWords = %v, want %v", reloaded.NoRepeatWords, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoRepeatWordsHonoursThePinNotTheVariant is the compatibility guard for every Erudit game
|
||||||
|
// that existed before the rule: those rows carry no_repeat_words false (the migration's default),
|
||||||
|
// and such a game must keep playing unrestricted. Flipping the stored flag stands in for one.
|
||||||
|
func TestNoRepeatWordsHonoursThePinNotTheVariant(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newGameService()
|
||||||
|
g := createTwoSeatGame(t, svc, engine.VariantErudit)
|
||||||
|
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
`UPDATE backend.games SET no_repeat_words = false WHERE game_id = $1`, g.ID); err != nil {
|
||||||
|
t.Fatalf("clear the pin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded, err := svc.GameByID(ctx, g.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get game: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.NoRepeatWords {
|
||||||
|
t.Fatal("an Erudit game whose row has the rule off must report it off")
|
||||||
|
}
|
||||||
|
// The rule reaches the engine from the row, so the reconstructed game plays unrestricted:
|
||||||
|
// its generated moves are the solver's full list, none filtered away.
|
||||||
|
if _, err := svc.Hint(ctx, reloaded.ID, reloaded.Seats[reloaded.ToMove].AccountID); err != nil {
|
||||||
|
t.Fatalf("hint on an unrestricted Erudit game: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ func toWireGame(g GameSummary) wire.GameView {
|
|||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
LastActivityUnix: g.LastActivityUnix,
|
LastActivityUnix: g.LastActivityUnix,
|
||||||
|
NoRepeatWords: g.NoRepeatWords,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ type GameSummary struct {
|
|||||||
// Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends);
|
// Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends);
|
||||||
// it rides live events so a lobby patch keeps the per-kind count correct.
|
// it rides live events so a lobby patch keeps the per-kind count correct.
|
||||||
Kind int
|
Kind int
|
||||||
|
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule, pinned
|
||||||
|
// per game); it rides live events so the client's local move preview scores like the server.
|
||||||
|
NoRepeatWords bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
||||||
|
|||||||
@@ -34,4 +34,5 @@ type Games struct {
|
|||||||
MultipleWordsPerTurn bool
|
MultipleWordsPerTurn bool
|
||||||
VsAi bool
|
VsAi bool
|
||||||
GameKind int16
|
GameKind int16
|
||||||
|
NoRepeatWords bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type gamesTable struct {
|
|||||||
MultipleWordsPerTurn postgres.ColumnBool
|
MultipleWordsPerTurn postgres.ColumnBool
|
||||||
VsAi postgres.ColumnBool
|
VsAi postgres.ColumnBool
|
||||||
GameKind postgres.ColumnInteger
|
GameKind postgres.ColumnInteger
|
||||||
|
NoRepeatWords postgres.ColumnBool
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -100,9 +101,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
|||||||
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
|
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
|
||||||
VsAiColumn = postgres.BoolColumn("vs_ai")
|
VsAiColumn = postgres.BoolColumn("vs_ai")
|
||||||
GameKindColumn = postgres.IntegerColumn("game_kind")
|
GameKindColumn = postgres.IntegerColumn("game_kind")
|
||||||
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
NoRepeatWordsColumn = postgres.BoolColumn("no_repeat_words")
|
||||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn, NoRepeatWordsColumn}
|
||||||
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn, NoRepeatWordsColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn, NoRepeatWordsColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return gamesTable{
|
return gamesTable{
|
||||||
@@ -130,6 +132,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
|||||||
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
|
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
|
||||||
VsAi: VsAiColumn,
|
VsAi: VsAiColumn,
|
||||||
GameKind: GameKindColumn,
|
GameKind: GameKindColumn,
|
||||||
|
NoRepeatWords: NoRepeatWordsColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- The "Эрудит" no-repeat-words rule: a word laid on the board cannot be laid again. It is pinned
|
||||||
|
-- per game rather than derived from the variant, because a game is replayed from its journal on
|
||||||
|
-- every open — a rule applied retroactively would make an already-played repeat illegal (voiding
|
||||||
|
-- the game) and would rescore a play whose cross-word repeats an earlier word. Every existing game
|
||||||
|
-- therefore keeps the unrestricted rule (DEFAULT false, no backfill) and only new erudit_ru games
|
||||||
|
-- are created with it on. Additive column — applies forward via goose with no data rewrite (no
|
||||||
|
-- contour wipe); an image rollback ignores the column.
|
||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE backend.games ADD COLUMN no_repeat_words boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE backend.games DROP COLUMN no_repeat_words;
|
||||||
@@ -148,6 +148,9 @@ type gameDTO struct {
|
|||||||
ToMove int `json:"to_move"`
|
ToMove int `json:"to_move"`
|
||||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||||
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
||||||
|
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule, pinned
|
||||||
|
// per game). The client needs it to score its local move preview the way the server does.
|
||||||
|
NoRepeatWords bool `json:"no_repeat_words"`
|
||||||
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
||||||
// are suppressed in the client.
|
// are suppressed in the client.
|
||||||
VsAI bool `json:"vs_ai"`
|
VsAI bool `json:"vs_ai"`
|
||||||
@@ -296,6 +299,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
|||||||
ToMove: g.ToMove,
|
ToMove: g.ToMove,
|
||||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
|
NoRepeatWords: g.NoRepeatWords,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
Kind: int(g.Kind),
|
Kind: int(g.Kind),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
|
|||||||
@@ -561,6 +561,24 @@ Key points:
|
|||||||
it on (standard). For auto-match the rule is part of the matchmaking key, so only players
|
it on (standard). For auto-match the rule is part of the matchmaking key, so only players
|
||||||
who chose the same rule are paired (the rule field rides every create/enqueue request, so
|
who chose the same rule are paired (the rule field rides every create/enqueue request, so
|
||||||
matchmaking stays one uniform path).
|
matchmaking stays one uniform path).
|
||||||
|
- **No repeated words (Erudit).** A word laid on the board cannot be laid again — the "Эрудит"
|
||||||
|
rule; official Scrabble has no such restriction, so both Scrabble variants never take it. It
|
||||||
|
applies in two ways: a play whose **main word** is already on the board is illegal, and a play
|
||||||
|
whose **cross-word** is already there stands (it is incidental to laying the main word) but that
|
||||||
|
cross-word scores nothing. The set of played words is the game's own **move journal** — the main
|
||||||
|
word and every cross-word of every play, compared decoded, so a word spelled with a blank is the
|
||||||
|
same word. The rule lives in the **game layer**, not the solver: `backend/internal/engine`
|
||||||
|
(`norepeat.go`) and its client port `ui/src/lib/dict/norepeat.ts` apply it at submit, at the move
|
||||||
|
preview and over generated moves — filtering and re-ranking them, so neither the robot nor the
|
||||||
|
hint can offer a play the engine would then refuse. The solver stays stateless and
|
||||||
|
standard-rules; only a game knows its history.
|
||||||
|
It is **pinned per game** (`games.no_repeat_words`, set from the variant at creation and read
|
||||||
|
back thereafter, never re-derived) rather than keyed on the variant, because a game is replayed
|
||||||
|
from its journal on every open: applied retroactively it would make an already-played repeat
|
||||||
|
illegal — closing that game as a draw (§9.1) — and would rescore a play whose cross-word repeats
|
||||||
|
an earlier word. Games created before the rule therefore keep playing without it. The flag rides
|
||||||
|
the wire (`GameView.no_repeat_words`) because the client's on-device move preview must score the
|
||||||
|
way the server does; offline games pin the same answer in their own record.
|
||||||
- **End of game**: the bag is empty **and** a player empties their rack, **or**
|
- **End of game**: the bag is empty **and** a player empties their rack, **or**
|
||||||
**6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
|
**6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
|
||||||
a missed turn. The **per-game turn timeout** is chosen at creation
|
a missed turn. The **per-game turn timeout** is chosen at creation
|
||||||
|
|||||||
+13
-1
@@ -196,7 +196,19 @@ the simplified **single-word rule** — only the word laid along the player's li
|
|||||||
real word (and it must still cross or extend letters already on the board along that line),
|
real word (and it must still cross or extend letters already on the board along that line),
|
||||||
and any incidental perpendicular words are ignored and not scored — while on is
|
and any incidental perpendicular words are ignored and not scored — while on is
|
||||||
standard Scrabble. English games are always standard and show no such toggle. In auto-match
|
standard Scrabble. English games are always standard and show no such toggle. In auto-match
|
||||||
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are
|
the choice joins the pairing key, so a player only meets opponents who picked the same rule.
|
||||||
|
|
||||||
|
**Erudite forbids repeating a word.** A word laid on the board belongs to the game: it cannot be laid
|
||||||
|
again, and the AI never plays one either. A play whose main word is already on the board is rejected
|
||||||
|
like any illegal move — the tiles read invalid on the board and the move cannot be sent, and because
|
||||||
|
the word itself is a perfectly good one the caption above the scores names it, reading
|
||||||
|
"WORD: already used" rather than the usual score. That verdict is reached on the device, before the
|
||||||
|
move is offered to the server, in an online game exactly as in an offline one; a play that only forms an already-played word **across** its main word stands —
|
||||||
|
that word is incidental to the move — but scores nothing. The rule is not a choice: every Erudite
|
||||||
|
game takes it, and both Scrabble variants (where the official rules place no such restriction) never
|
||||||
|
do, so the variant's one-line description on **New Game** reads "no repeated words". Games started
|
||||||
|
before the rule existed keep playing without it, so a game in progress never changes rules under its
|
||||||
|
players. Friend games (2–4) are
|
||||||
formed by inviting players from the friend list (an invitation, like a friend code,
|
formed by inviting players from the friend list (an invitation, like a friend code,
|
||||||
is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the
|
is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the
|
||||||
Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the
|
Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the
|
||||||
|
|||||||
+13
-1
@@ -202,7 +202,19 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются;
|
этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются;
|
||||||
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
|
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
|
||||||
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
||||||
кто выбрал то же правило. Игры с друзьями (2–4)
|
кто выбрал то же правило.
|
||||||
|
|
||||||
|
**В «Эрудите» слово нельзя повторять.** Выставленное на доску слово принадлежит партии: выставить
|
||||||
|
его ещё раз нельзя, и робот такого хода тоже не делает. Ход, чьё основное слово уже стоит на доске,
|
||||||
|
отклоняется как любой недопустимый: фишки на доске подсвечиваются как недопустимые и ход не отправить,
|
||||||
|
а поскольку само слово вполне настоящее, подпись над счётом называет его — «СЛОВО: уже использовано»
|
||||||
|
вместо обычных очков. Это решение принимается на устройстве, до того как ход предложен серверу,
|
||||||
|
одинаково в онлайн- и в оффлайн-партии; ход, который лишь образует уже выставленное слово **поперёк**
|
||||||
|
основного, засчитывается — такое слово побочно для хода, — но очков не приносит. Правило не
|
||||||
|
выбирается: его берёт каждая партия «Эрудита», и никогда — оба варианта скрэббла (в официальных
|
||||||
|
правилах такого ограничения нет), поэтому однострочное описание варианта на экране **новой игры**
|
||||||
|
содержит «слова без повтора». Партии, начатые до появления правила, продолжают играть без него,
|
||||||
|
так что правила идущей партии не меняются у игроков на ходу. Игры с друзьями (2–4)
|
||||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||||
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App
|
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App
|
||||||
никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор
|
никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ type GameResp struct {
|
|||||||
UnreadChat bool `json:"unread_chat"`
|
UnreadChat bool `json:"unread_chat"`
|
||||||
UnreadMessages bool `json:"unread_messages"`
|
UnreadMessages bool `json:"unread_messages"`
|
||||||
Kind int `json:"kind"`
|
Kind int `json:"kind"`
|
||||||
|
NoRepeatWords bool `json:"no_repeat_words"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||||
|
|||||||
@@ -638,6 +638,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
|
|||||||
UnreadChat: g.UnreadChat,
|
UnreadChat: g.UnreadChat,
|
||||||
UnreadMessages: g.UnreadMessages,
|
UnreadMessages: g.UnreadMessages,
|
||||||
Kind: g.Kind,
|
Kind: g.Kind,
|
||||||
|
NoRepeatWords: g.NoRepeatWords,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ table GameView {
|
|||||||
// caller's per-kind limits (Profile.game_limits) to lock a capped New-Game start (added
|
// caller's per-kind limits (Profile.game_limits) to lock a capped New-Game start (added
|
||||||
// trailing — backward-compatible).
|
// trailing — backward-compatible).
|
||||||
kind:int;
|
kind:int;
|
||||||
|
// no_repeat_words true forbids laying a word that is already on the board — the "Эрудит"
|
||||||
|
// rule. It is pinned per game at creation rather than derived from the variant, so a game
|
||||||
|
// started before the rule existed keeps playing without it; the client needs it to score its
|
||||||
|
// local move preview the same way the server does (added trailing — backward-compatible).
|
||||||
|
no_repeat_words:bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
||||||
|
|||||||
@@ -221,8 +221,20 @@ func (rcv *GameView) MutateKind(n int32) bool {
|
|||||||
return rcv._tab.MutateInt32Slot(34, n)
|
return rcv._tab.MutateInt32Slot(34, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *GameView) NoRepeatWords() bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameView) MutateNoRepeatWords(n bool) bool {
|
||||||
|
return rcv._tab.MutateBoolSlot(36, n)
|
||||||
|
}
|
||||||
|
|
||||||
func GameViewStart(builder *flatbuffers.Builder) {
|
func GameViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(16)
|
builder.StartObject(17)
|
||||||
}
|
}
|
||||||
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
||||||
@@ -275,6 +287,9 @@ func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool
|
|||||||
func GameViewAddKind(builder *flatbuffers.Builder, kind int32) {
|
func GameViewAddKind(builder *flatbuffers.Builder, kind int32) {
|
||||||
builder.PrependInt32Slot(15, kind, 0)
|
builder.PrependInt32Slot(15, kind, 0)
|
||||||
}
|
}
|
||||||
|
func GameViewAddNoRepeatWords(builder *flatbuffers.Builder, noRepeatWords bool) {
|
||||||
|
builder.PrependBoolSlot(16, noRepeatWords, false)
|
||||||
|
}
|
||||||
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ type GameView struct {
|
|||||||
// Kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game), 1 vs_ai,
|
// Kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game), 1 vs_ai,
|
||||||
// 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start.
|
// 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start.
|
||||||
Kind int
|
Kind int
|
||||||
|
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule). Pinned
|
||||||
|
// per game, so a game started before the rule existed keeps playing without it.
|
||||||
|
NoRepeatWords bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
|
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
|
||||||
@@ -177,6 +180,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
|
|||||||
fb.GameViewAddUnreadChat(b, g.UnreadChat)
|
fb.GameViewAddUnreadChat(b, g.UnreadChat)
|
||||||
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
|
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
|
||||||
fb.GameViewAddKind(b, int32(g.Kind))
|
fb.GameViewAddKind(b, int32(g.Kind))
|
||||||
|
fb.GameViewAddNoRepeatWords(b, g.NoRepeatWords)
|
||||||
return fb.GameViewEnd(b)
|
return fb.GameViewEnd(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,12 @@ const DIST = 'dist';
|
|||||||
// + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded
|
// + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded
|
||||||
// transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The
|
// transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The
|
||||||
// heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy
|
// heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy
|
||||||
// chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
// chunks. Raised to 131 for the Erudit no-repeat-words rule: the per-game flag rides the decoded
|
||||||
const BUDGET = { app: 130, shared: 31, landing: 5 };
|
// GameView (codec + model), and the always-loaded game screen has to name the word its preview
|
||||||
|
// rejected, since a repeated word is a real word and the board alone cannot say why the play is
|
||||||
|
// refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in
|
||||||
|
// the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||||
|
const BUDGET = { app: 131, shared: 31, landing: 5 };
|
||||||
|
|
||||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||||
|
|||||||
+16
-3
@@ -419,6 +419,11 @@
|
|||||||
// opponent_moved and game_over self-heal via the next event's move-count gap check, but
|
// opponent_moved and game_over self-heal via the next event's move-count gap check, but
|
||||||
// opponent_joined has no follow-up event, so the open-game wait needs its own recovery. Two
|
// opponent_joined has no follow-up event, so the open-game wait needs its own recovery. Two
|
||||||
// fallbacks, mirroring the matchmaking poll PR #51 moved in here from the lobby:
|
// fallbacks, mirroring the matchmaking poll PR #51 moved in here from the lobby:
|
||||||
|
//
|
||||||
|
// All three recover from the live stream, which a local (offline) game does not use at all — its
|
||||||
|
// events come from the source itself (see onMount). Reacting to the stream there would refetch
|
||||||
|
// the state for no reason, so (A) and (C) are gated on the game being a network one; (B) needs no
|
||||||
|
// gate, as a local game is never 'open' and so never waits for an opponent.
|
||||||
|
|
||||||
// (A) On a stream reconnect (alive false -> true) refetch once to catch up on anything missed
|
// (A) On a stream reconnect (alive false -> true) refetch once to catch up on anything missed
|
||||||
// while it was down. The common case is a mobile suspend that drops the stream, the opponent
|
// while it was down. The common case is a mobile suspend that drops the stream, the opponent
|
||||||
@@ -426,7 +431,7 @@
|
|||||||
let wasStreamAlive = app.streamAlive;
|
let wasStreamAlive = app.streamAlive;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const alive = app.streamAlive;
|
const alive = app.streamAlive;
|
||||||
if (alive && !wasStreamAlive) void load();
|
if (alive && !wasStreamAlive && !isLocalGameId(id)) void load();
|
||||||
wasStreamAlive = alive;
|
wasStreamAlive = alive;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -446,7 +451,7 @@
|
|||||||
const r = app.resync;
|
const r = app.resync;
|
||||||
if (r !== lastResync) {
|
if (r !== lastResync) {
|
||||||
lastResync = r;
|
lastResync = r;
|
||||||
void load();
|
if (!isLocalGameId(id)) void load();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -828,7 +833,10 @@
|
|||||||
const reader = d.peekDawg(v.game.variant, v.game.dictVersion);
|
const reader = d.peekDawg(v.game.variant, v.game.dictVersion);
|
||||||
if (reader && hasAlphabet(v.game.variant)) {
|
if (reader && hasAlphabet(v.game.variant)) {
|
||||||
try {
|
try {
|
||||||
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn);
|
// Under the no-repeat-words rule the evaluator also needs the words this game has
|
||||||
|
// already formed; both it and that set live behind the same lazy import.
|
||||||
|
const played = v.game.noRepeatWords ? d.playedWordsFromHistory(moves) : undefined;
|
||||||
|
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn, played);
|
||||||
notePreviewLocal();
|
notePreviewLocal();
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1563,6 +1571,11 @@
|
|||||||
{resultText()}
|
{resultText()}
|
||||||
{:else if preview?.legal && !recallOverRack}
|
{:else if preview?.legal && !recallOverRack}
|
||||||
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
|
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
|
||||||
|
{:else if preview?.repeatedWord && !recallOverRack}
|
||||||
|
<!-- A real word, rejected only because this game has already used it (the Erudit
|
||||||
|
no-repeat rule): the tiles read invalid, and the caption says which word and why —
|
||||||
|
otherwise the player is left staring at a word they know is in the dictionary. -->
|
||||||
|
{preview.repeatedWord.toUpperCase()}{': '}{t('game.wordAlreadyUsed')}
|
||||||
{:else}
|
{:else}
|
||||||
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
|
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -118,8 +118,13 @@ kind():number {
|
|||||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
noRepeatWords():boolean {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 36);
|
||||||
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
|
}
|
||||||
|
|
||||||
static startGameView(builder:flatbuffers.Builder) {
|
static startGameView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(16);
|
builder.startObject(17);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
||||||
@@ -198,12 +203,16 @@ static addKind(builder:flatbuffers.Builder, kind:number) {
|
|||||||
builder.addFieldInt32(15, kind, 0);
|
builder.addFieldInt32(15, kind, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addNoRepeatWords(builder:flatbuffers.Builder, noRepeatWords:boolean) {
|
||||||
|
builder.addFieldInt8(16, +noRepeatWords, +false);
|
||||||
|
}
|
||||||
|
|
||||||
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean, kind:number):flatbuffers.Offset {
|
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean, kind:number, noRepeatWords:boolean):flatbuffers.Offset {
|
||||||
GameView.startGameView(builder);
|
GameView.startGameView(builder);
|
||||||
GameView.addId(builder, idOffset);
|
GameView.addId(builder, idOffset);
|
||||||
GameView.addVariant(builder, variantOffset);
|
GameView.addVariant(builder, variantOffset);
|
||||||
@@ -221,6 +230,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
|
|||||||
GameView.addUnreadChat(builder, unreadChat);
|
GameView.addUnreadChat(builder, unreadChat);
|
||||||
GameView.addUnreadMessages(builder, unreadMessages);
|
GameView.addUnreadMessages(builder, unreadMessages);
|
||||||
GameView.addKind(builder, kind);
|
GameView.addKind(builder, kind);
|
||||||
|
GameView.addNoRepeatWords(builder, noRepeatWords);
|
||||||
return GameView.endGameView(builder);
|
return GameView.endGameView(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ describe('codec', () => {
|
|||||||
fb.GameView.addVsAi(b, true);
|
fb.GameView.addVsAi(b, true);
|
||||||
fb.GameView.addUnreadChat(b, true);
|
fb.GameView.addUnreadChat(b, true);
|
||||||
fb.GameView.addKind(b, 2);
|
fb.GameView.addKind(b, 2);
|
||||||
|
fb.GameView.addNoRepeatWords(b, true);
|
||||||
const game = fb.GameView.endGameView(b);
|
const game = fb.GameView.endGameView(b);
|
||||||
const games = fb.GameList.createGamesVector(b, [game]);
|
const games = fb.GameList.createGamesVector(b, [game]);
|
||||||
fb.GameList.startGameList(b);
|
fb.GameList.startGameList(b);
|
||||||
@@ -441,6 +442,9 @@ describe('codec', () => {
|
|||||||
expect(gl.games[0].vsAi).toBe(true);
|
expect(gl.games[0].vsAi).toBe(true);
|
||||||
expect(gl.games[0].unreadChat).toBe(true);
|
expect(gl.games[0].unreadChat).toBe(true);
|
||||||
expect(gl.games[0].kind).toBe(2);
|
expect(gl.games[0].kind).toBe(2);
|
||||||
|
// The Erudit no-repeat rule is pinned per game and rides the view: the on-device move
|
||||||
|
// preview needs it to score the way the server does.
|
||||||
|
expect(gl.games[0].noRepeatWords).toBe(true);
|
||||||
expect(gl.atGameLimit).toBe(true);
|
expect(gl.atGameLimit).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ function decodeGameView(g: fb.GameView): GameView {
|
|||||||
toMove: g.toMove(),
|
toMove: g.toMove(),
|
||||||
turnTimeoutSecs: g.turnTimeoutSecs(),
|
turnTimeoutSecs: g.turnTimeoutSecs(),
|
||||||
multipleWordsPerTurn: g.multipleWordsPerTurn(),
|
multipleWordsPerTurn: g.multipleWordsPerTurn(),
|
||||||
|
noRepeatWords: g.noRepeatWords(),
|
||||||
moveCount: g.moveCount(),
|
moveCount: g.moveCount(),
|
||||||
endReason: s(g.endReason()),
|
endReason: s(g.endReason()),
|
||||||
lastActivityUnix: Number(g.lastActivityUnix()),
|
lastActivityUnix: Number(g.lastActivityUnix()),
|
||||||
@@ -1143,6 +1144,7 @@ function emptyGame(): GameView {
|
|||||||
toMove: 0,
|
toMove: 0,
|
||||||
turnTimeoutSecs: 0,
|
turnTimeoutSecs: 0,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: 0,
|
lastActivityUnix: 0,
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { evaluateLocal } from './eval';
|
||||||
|
import { playedWordsFromHistory } from './norepeat';
|
||||||
|
import { seedMockAlphabets } from '../mock/alphabet';
|
||||||
|
import { emptyBoard, type Board } from '../board';
|
||||||
|
import type { Dict } from './validate';
|
||||||
|
import type { PlacedTile } from '../client';
|
||||||
|
import type { MoveRecord } from '../model';
|
||||||
|
|
||||||
|
// The no-repeat-words rule as the game screen sees it: through evaluateLocal, the on-device move
|
||||||
|
// preview, which decides the rule before a play ever reaches the server. The dictionary is stubbed
|
||||||
|
// to accept every word, so what these tests pin is the rule and the letter<->index adapter around
|
||||||
|
// it, not the word list (the Go/JS agreement on plain scoring is pinned by the parity suites).
|
||||||
|
const anyWord: Dict = { indexOf: () => 0 };
|
||||||
|
|
||||||
|
// place fills a board from "row,col,letter" triples.
|
||||||
|
function place(board: Board, cells: [number, number, string][]): Board {
|
||||||
|
for (const [row, col, letter] of cells) board[row][col] = { letter, blank: false };
|
||||||
|
return board;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tile(row: number, col: number, letter: string): PlacedTile {
|
||||||
|
return { row, col, letter, blank: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// playRecord is one history entry as the client holds it — note the UPPER-CASE words, which is how
|
||||||
|
// the codec decodes them for display (lib/codec decodeMove). Building the played set from records
|
||||||
|
// in this shape is what the game screen actually does, so these tests go through it rather than
|
||||||
|
// hand-writing the set; comparing the codec's case against the evaluator's would otherwise silently
|
||||||
|
// never match and the rule would never fire.
|
||||||
|
function playRecord(...words: string[]): MoveRecord {
|
||||||
|
return {
|
||||||
|
player: 0,
|
||||||
|
action: 'play',
|
||||||
|
dir: 'H',
|
||||||
|
mainRow: 0,
|
||||||
|
mainCol: 0,
|
||||||
|
tiles: [],
|
||||||
|
words: words.map((w) => w.toUpperCase()),
|
||||||
|
count: 0,
|
||||||
|
score: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// passRecord is a non-play entry, which contributes no words.
|
||||||
|
function passRecord(): MoveRecord {
|
||||||
|
return { player: 0, action: 'pass', dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => seedMockAlphabets());
|
||||||
|
|
||||||
|
describe('evaluateLocal under the no-repeat-words rule', () => {
|
||||||
|
// The position is a real one from the test contour, where the rule rejected the play on the
|
||||||
|
// server while the composition still read as legal on the board. БРА was laid on move 4 (row 5),
|
||||||
|
// and this play hooks the Р of ПРИНТ to spell it again downwards in column 3.
|
||||||
|
function contourBoard(): Board {
|
||||||
|
return place(emptyBoard(), [
|
||||||
|
[0, 7, 'о'], [1, 7, 'в'], [2, 7, 'е'],
|
||||||
|
[2, 5, 'д'], [2, 6, 'ж'], [2, 8, 'б'],
|
||||||
|
[3, 7, 'н'], [3, 8, 'а'], [3, 9, 'е'], [3, 10, 'м'],
|
||||||
|
[4, 10, 'а'],
|
||||||
|
[5, 3, 'п'], [5, 4, 'о'], [5, 5, 'й'], [5, 6, 'к'], [5, 7, 'а'], [5, 9, 'б'], [5, 10, 'р'], [5, 11, 'а'],
|
||||||
|
[6, 0, 'п'], [6, 3, 'у'], [6, 6, 'о'], [6, 10, 'а'],
|
||||||
|
[7, 0, 'л'], [7, 1, 'и'], [7, 2, 'с'], [7, 3, 'т'], [7, 6, 'т'], [7, 7, 'р'], [7, 8, 'а'], [7, 9, 'в'], [7, 10, 'л'], [7, 11, 'я'],
|
||||||
|
[8, 0, 'у'], [8, 2, 'м'], [8, 3, 'ы'], [8, 4, 'с'], [8, 5, 'и'], [8, 6, 'к'],
|
||||||
|
[9, 0, 'г'], [9, 4, 'у'],
|
||||||
|
[10, 4, 'ш'],
|
||||||
|
[11, 2, 'п'], [11, 3, 'р'], [11, 4, 'и'], [11, 5, 'н'], [11, 6, 'т'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// Б above the standing Р and А below it spell БРА downwards.
|
||||||
|
const bra: PlacedTile[] = [tile(10, 3, 'б'), tile(12, 3, 'а')];
|
||||||
|
const history: MoveRecord[] = [
|
||||||
|
playRecord('травля'), playRecord('марал'), playRecord('кот'), playRecord('наем'), playRecord('бра'),
|
||||||
|
playRecord('пойка'), playRecord('овен'), playRecord('джеб'), playRecord('путы'), playRecord('мысик'),
|
||||||
|
playRecord('суши'), playRecord('лист'), playRecord('плуг'), playRecord('принт'), passRecord(),
|
||||||
|
];
|
||||||
|
const played = playedWordsFromHistory(history);
|
||||||
|
|
||||||
|
it('scores the play when the game does not take the rule', () => {
|
||||||
|
// The control: the same play in a game without the rule is a perfectly ordinary six-pointer.
|
||||||
|
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false);
|
||||||
|
expect(res.legal).toBe(true);
|
||||||
|
expect(res.words).toEqual(['бра']);
|
||||||
|
expect(res.score).toBe(6);
|
||||||
|
expect(res.repeatedWord).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the play and names the word when the game has already used it', () => {
|
||||||
|
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false, played);
|
||||||
|
expect(res.legal).toBe(false);
|
||||||
|
expect(res.score).toBe(0);
|
||||||
|
// Named, so the caption can read "БРА: уже использовано" instead of the bare turn label.
|
||||||
|
expect(res.repeatedWord).toBe('бра');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the play alone when the rule is in force but the word is new', () => {
|
||||||
|
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false, playedWordsFromHistory([playRecord('кот')]));
|
||||||
|
expect(res.legal).toBe(true);
|
||||||
|
expect(res.score).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluateLocal under the rule with several words per turn', () => {
|
||||||
|
// The same position the server engine scores in backend/internal/engine/norepeat_test.go
|
||||||
|
// (TestNoRepeatDropsTheScoreOfAReplayedCrossWord), pinned to the same two totals: КОТ stands
|
||||||
|
// across the centre and again down column 5, and РОТ laid across row 12 completes the second
|
||||||
|
// one as a cross-word. Both engines must agree to the point, or one of the two tests breaks.
|
||||||
|
function crossBoard(): Board {
|
||||||
|
return place(emptyBoard(), [
|
||||||
|
[7, 7, 'к'], [7, 8, 'о'], [7, 9, 'т'], // the opening КОТ, away from the play
|
||||||
|
[10, 5, 'к'], [11, 5, 'о'], // the standing КО the play completes
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const rot: PlacedTile[] = [tile(12, 3, 'р'), tile(12, 4, 'о'), tile(12, 5, 'т')];
|
||||||
|
|
||||||
|
it('keeps the play legal when only a cross-word repeats, and stops scoring that word', () => {
|
||||||
|
const free = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true);
|
||||||
|
expect(free.legal).toBe(true);
|
||||||
|
expect(free.words).toEqual(['рот', 'кот']);
|
||||||
|
expect(free.score).toBe(10); // РОТ plus the КОТ cross-word
|
||||||
|
|
||||||
|
const restricted = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, playedWordsFromHistory([playRecord('кот')]));
|
||||||
|
expect(restricted.legal).toBe(true);
|
||||||
|
expect(restricted.repeatedWord).toBeUndefined();
|
||||||
|
// The cross-word is still formed and still reported — it just earns nothing.
|
||||||
|
expect(restricted.words).toEqual(['рот', 'кот']);
|
||||||
|
expect(restricted.score).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the play when the main word repeats, whatever its cross-words do', () => {
|
||||||
|
const res = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, playedWordsFromHistory([playRecord('рот', 'кот')]));
|
||||||
|
expect(res.legal).toBe(false);
|
||||||
|
expect(res.repeatedWord).toBe('рот');
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-4
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate';
|
import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate';
|
||||||
import { playDirection } from './direction';
|
import { playDirection } from './direction';
|
||||||
|
import { noRepeatScore } from './norepeat';
|
||||||
import type { Board as ClientBoard } from '../board';
|
import type { Board as ClientBoard } from '../board';
|
||||||
import type { PlacedTile } from '../client';
|
import type { PlacedTile } from '../client';
|
||||||
import type { EvalResult, Variant } from '../model';
|
import type { EvalResult, Variant } from '../model';
|
||||||
@@ -64,7 +65,7 @@ function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
|
|||||||
|
|
||||||
// decodeWord turns a word's alphabet indices back into a lower-cased string, the
|
// decodeWord turns a word's alphabet indices back into a lower-cased string, the
|
||||||
// form the network EvalResult carries (the caption renders it directly).
|
// form the network EvalResult carries (the caption renders it directly).
|
||||||
function decodeWord(variant: Variant, letters: number[]): string {
|
function decodeWord(variant: Variant, letters: readonly number[]): string {
|
||||||
let s = '';
|
let s = '';
|
||||||
for (const i of letters) s += letterForIndex(variant, i);
|
for (const i of letters) s += letterForIndex(variant, i);
|
||||||
return s.toLowerCase();
|
return s.toLowerCase();
|
||||||
@@ -74,8 +75,12 @@ function decodeWord(variant: Variant, letters: number[]): string {
|
|||||||
* evaluateLocal computes the move preview for tiles placed on board in the given
|
* evaluateLocal computes the move preview for tiles placed on board in the given
|
||||||
* game, returning the same shape as the network `evaluate`. dict is the loaded
|
* game, returning the same shape as the network `evaluate`. dict is the loaded
|
||||||
* dictionary reader for the game's (variant, version); multipleWords is the game's
|
* dictionary reader for the game's (variant, version); multipleWords is the game's
|
||||||
* cross-word rule. It may throw if the variant's alphabet table is not loaded — the
|
* cross-word rule. playedWords is the game's already-played words (decoded, as the
|
||||||
* caller guards with hasAlphabet and falls back to the network on any failure.
|
* history carries them) when the game takes the no-repeat-words rule, and absent
|
||||||
|
* otherwise — with it a play repeating a word is reported illegal and an already-played
|
||||||
|
* cross-word scores nothing, exactly as the server decides. It may throw if the
|
||||||
|
* variant's alphabet table is not loaded — the caller guards with hasAlphabet and falls
|
||||||
|
* back to the network on any failure.
|
||||||
*/
|
*/
|
||||||
export function evaluateLocal(
|
export function evaluateLocal(
|
||||||
dict: Dict,
|
dict: Dict,
|
||||||
@@ -83,6 +88,7 @@ export function evaluateLocal(
|
|||||||
board: ClientBoard,
|
board: ClientBoard,
|
||||||
tiles: PlacedTile[],
|
tiles: PlacedTile[],
|
||||||
multipleWords: boolean,
|
multipleWords: boolean,
|
||||||
|
playedWords?: ReadonlySet<string>,
|
||||||
): EvalResult {
|
): EvalResult {
|
||||||
const vboard = buildBoard(variant, board);
|
const vboard = buildBoard(variant, board);
|
||||||
const rs = buildRuleset(variant, multipleWords);
|
const rs = buildRuleset(variant, multipleWords);
|
||||||
@@ -100,5 +106,11 @@ export function evaluateLocal(
|
|||||||
}
|
}
|
||||||
const m = res.move;
|
const m = res.move;
|
||||||
const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters));
|
const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters));
|
||||||
return { legal: true, score: m.score, words, dir: dir === Horizontal ? 'H' : 'V' };
|
const score = playedWords ? noRepeatScore(m, playedWords, (l) => decodeWord(variant, l)) : m.score;
|
||||||
|
if (score === null) {
|
||||||
|
// A real word, but one this game has already used: illegal, and named so the caption can say
|
||||||
|
// why rather than leaving the player staring at a word they know is in the dictionary.
|
||||||
|
return { legal: false, score: 0, words: [], dir: '', repeatedWord: words[0] };
|
||||||
|
}
|
||||||
|
return { legal: true, score, words, dir: dir === Horizontal ? 'H' : 'V' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
// of the initial app bundle — it is a progressive enhancement over the network
|
// of the initial app bundle — it is a progressive enhancement over the network
|
||||||
// preview, which remains the fallback.
|
// preview, which remains the fallback.
|
||||||
export { evaluateLocal } from './eval';
|
export { evaluateLocal } from './eval';
|
||||||
|
export { playedWordsFromHistory } from './norepeat';
|
||||||
export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader';
|
export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader';
|
||||||
export { clearDictCache, listCachedDicts } from './store';
|
export { clearDictCache, listCachedDicts } from './store';
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { wordKey, playedWordKeys, noRepeatScore } from './norepeat';
|
||||||
|
import type { Direction, Move, Word } from './validate';
|
||||||
|
import { Horizontal, Vertical } from './validate';
|
||||||
|
|
||||||
|
// word builds a scored Word; only its letters and score matter to the rule.
|
||||||
|
function word(letters: number[], score: number, dir: Direction = Horizontal): Word {
|
||||||
|
return { row: 0, col: 0, dir, letters, blanks: letters.map(() => false), score };
|
||||||
|
}
|
||||||
|
|
||||||
|
// move builds a scored play from a main word and its cross-words, with the total the engine
|
||||||
|
// would have computed (main + cross, no bingo).
|
||||||
|
function move(main: Word, cross: Word[] = []): Move {
|
||||||
|
return {
|
||||||
|
dir: Horizontal,
|
||||||
|
tiles: [],
|
||||||
|
main,
|
||||||
|
cross,
|
||||||
|
bonus: 0,
|
||||||
|
score: main.score + cross.reduce((sum, w) => sum + w.score, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('no-repeat-words rule', () => {
|
||||||
|
it('keys a word by its letters, so a blank spells the same word as a real tile', () => {
|
||||||
|
// The letters are alphabet indices and the blank flag rides separately, so it never enters
|
||||||
|
// the key — matching the server, which compares the words decoded.
|
||||||
|
const withBlank: Word = { ...word([2, 14, 19], 5), blanks: [false, true, false] };
|
||||||
|
expect(wordKey(withBlank.letters)).toBe(wordKey([2, 14, 19]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the played set from the journal words, ignoring empty ones', () => {
|
||||||
|
const played = playedWordKeys([[2, 14, 19], [], [3, 4]]);
|
||||||
|
expect(played.has(wordKey([2, 14, 19]))).toBe(true);
|
||||||
|
expect(played.has(wordKey([3, 4]))).toBe(true);
|
||||||
|
expect(played.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a play whose main word is already on the board', () => {
|
||||||
|
const played = playedWordKeys([[2, 14, 19]]);
|
||||||
|
expect(noRepeatScore(move(word([2, 14, 19], 7)), played)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a play whose main word is new, at its full score', () => {
|
||||||
|
const played = playedWordKeys([[2, 14, 19]]);
|
||||||
|
expect(noRepeatScore(move(word([17, 14, 19], 6)), played)).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a play whose cross-word repeats, but scores that cross-word at nothing', () => {
|
||||||
|
const played = playedWordKeys([[2, 14, 19]]);
|
||||||
|
const m = move(word([17, 14, 19], 6), [word([2, 14, 19], 4, Vertical), word([8, 9], 3, Vertical)]);
|
||||||
|
expect(m.score).toBe(13);
|
||||||
|
expect(noRepeatScore(m, played)).toBe(9); // the repeated cross-word's 4 points are dropped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a caller-supplied key, so a decoded history can be compared directly', () => {
|
||||||
|
// The online preview reads the words the server already decoded, so it keys on the decoded
|
||||||
|
// form rather than on alphabet indices.
|
||||||
|
const letters = ['', 'a', 'b', 'c'];
|
||||||
|
const decode = (l: readonly number[]) => l.map((i) => letters[i]).join('');
|
||||||
|
const played = new Set(['abc']);
|
||||||
|
expect(noRepeatScore(move(word([1, 2, 3], 7)), played, decode)).toBeNull();
|
||||||
|
expect(noRepeatScore(move(word([3, 2, 1], 7)), played, decode)).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// The no-repeat-words rule of Russian "Эрудит": a word laid on the board belongs to the game from
|
||||||
|
// then on and cannot be laid again. This is the client-side port of the server's rule
|
||||||
|
// (backend/internal/engine/norepeat.go) and must stay in step with it — it decides both what the
|
||||||
|
// offline engine accepts and what the online move preview scores.
|
||||||
|
//
|
||||||
|
// The rule applies in two different ways:
|
||||||
|
//
|
||||||
|
// - the play's main word must not already be there — such a play is illegal, and is neither
|
||||||
|
// accepted nor offered by the move generator;
|
||||||
|
// - a perpendicular cross-word that is already there is allowed, because it is incidental to
|
||||||
|
// laying the main word, but it scores nothing.
|
||||||
|
//
|
||||||
|
// It is a rule of that variant only, and it is pinned per game rather than derived from the
|
||||||
|
// variant, so a game started before the rule existed plays on without it.
|
||||||
|
|
||||||
|
import type { Move } from './validate';
|
||||||
|
import type { MoveRecord } from '../model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wordKey identifies a word for the rule. Words are alphabet-index letters, so the key ignores
|
||||||
|
* which tile carried a letter: a word spelled with a blank is the same word as one spelled with
|
||||||
|
* real tiles, exactly as the server sees it (there the words are compared decoded).
|
||||||
|
*/
|
||||||
|
export function wordKey(letters: readonly number[]): string {
|
||||||
|
return letters.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* playedWordKeys builds the rule's lookup set from a game's move journal — pass every play's
|
||||||
|
* words (the main word and its cross-words). A game with the rule off never needs it.
|
||||||
|
*/
|
||||||
|
export function playedWordKeys(words: Iterable<readonly number[]>): Set<string> {
|
||||||
|
const played = new Set<string>();
|
||||||
|
for (const w of words) {
|
||||||
|
if (w.length > 0) played.add(wordKey(w));
|
||||||
|
}
|
||||||
|
return played;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* playedWordsFromHistory is the rule's lookup set for a game whose history the server decoded for
|
||||||
|
* us: every word its plays have formed, main words and cross-words alike. It lives here, with the
|
||||||
|
* rest of the rule and behind the same lazy import as the evaluator, rather than in the game screen.
|
||||||
|
*
|
||||||
|
* The history is case-folded to lower, because the codec upper-cases those words for display
|
||||||
|
* (lib/codec decodeMove) while evaluateLocal decodes a candidate word to lower — comparing the two
|
||||||
|
* raw would never match, and the rule would silently never fire.
|
||||||
|
*/
|
||||||
|
export function playedWordsFromHistory(moves: readonly MoveRecord[]): Set<string> {
|
||||||
|
const played = new Set<string>();
|
||||||
|
for (const m of moves) {
|
||||||
|
if (m.action === 'play') for (const w of m.words) played.add(w.toLowerCase());
|
||||||
|
}
|
||||||
|
return played;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* noRepeatScore applies the rule to an already validated move against the set of words already
|
||||||
|
* played. It returns null when the move's main word is one of them — the play is illegal — and
|
||||||
|
* otherwise the move's score with every already-played cross-word dropped from the total.
|
||||||
|
*
|
||||||
|
* keyOf turns a candidate word's letters into the key the played set is built with. The offline
|
||||||
|
* engine keeps its journal in alphabet-index space and uses the default; the online preview reads
|
||||||
|
* the history the server sent, whose words are already decoded, and passes a decoding key instead.
|
||||||
|
*/
|
||||||
|
export function noRepeatScore(
|
||||||
|
m: Move,
|
||||||
|
played: ReadonlySet<string>,
|
||||||
|
keyOf: (letters: readonly number[]) => string = wordKey,
|
||||||
|
): number | null {
|
||||||
|
if (played.has(keyOf(m.main.letters))) return null;
|
||||||
|
let score = m.score;
|
||||||
|
for (const cw of m.cross) {
|
||||||
|
if (played.has(keyOf(cw.letters))) score -= cw.score;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ function gameView(id: string): GameView {
|
|||||||
toMove: 0,
|
toMove: 0,
|
||||||
turnTimeoutSecs: 300,
|
turnTimeoutSecs: 300,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: 0,
|
lastActivityUnix: 0,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function gameView(moveCount: number, over = false): GameView {
|
|||||||
toMove: 1,
|
toMove: 1,
|
||||||
turnTimeoutSecs: 300,
|
turnTimeoutSecs: 300,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount,
|
moveCount,
|
||||||
endReason: over ? 'standard' : '',
|
endReason: over ? 'standard' : '',
|
||||||
lastActivityUnix: 0,
|
lastActivityUnix: 0,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function game(seats: Seat[], over = true): GameView {
|
|||||||
toMove: 0,
|
toMove: 0,
|
||||||
turnTimeoutSecs: 300,
|
turnTimeoutSecs: 300,
|
||||||
multipleWordsPerTurn: false,
|
multipleWordsPerTurn: false,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: over ? 'standard' : '',
|
endReason: over ? 'standard' : '',
|
||||||
lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC)
|
lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function game(kind: number, status: string, extra: Partial<GameView> = {}): Game
|
|||||||
toMove: 0,
|
toMove: 0,
|
||||||
turnTimeoutSecs: 0,
|
turnTimeoutSecs: 0,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: 0,
|
lastActivityUnix: 0,
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ function load(): Promise<LocalSource> {
|
|||||||
* local-only create() and the robot-reply event subscription events().
|
* local-only create() and the robot-reply event subscription events().
|
||||||
*/
|
*/
|
||||||
export const localSource = {
|
export const localSource = {
|
||||||
// Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate
|
// Offline scoring is self-contained, so the local source ignores includeAlphabet and the evaluate
|
||||||
// abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full
|
// abort signal; the proxy accepts the full gateway signatures but forwards only what the local
|
||||||
// gateway signatures but forwards only what the local source uses.
|
// source uses.
|
||||||
gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)),
|
gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)),
|
||||||
gameHistory: (id) => load().then((s) => s.gameHistory(id)),
|
gameHistory: (id) => load().then((s) => s.gameHistory(id)),
|
||||||
submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)),
|
submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)),
|
||||||
@@ -36,8 +36,8 @@ export const localSource = {
|
|||||||
hint: (id) => load().then((s) => s.hint(id)),
|
hint: (id) => load().then((s) => s.hint(id)),
|
||||||
evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)),
|
evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)),
|
||||||
checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)),
|
checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)),
|
||||||
draftGet: (_id) => load().then((s) => s.draftGet()),
|
draftGet: (id) => load().then((s) => s.draftGet(id)),
|
||||||
draftSave: (_id, _json) => load().then((s) => s.draftSave()),
|
draftSave: (id, json) => load().then((s) => s.draftSave(id, json)),
|
||||||
create: (opts) => load().then((s) => s.create(opts)),
|
create: (opts) => load().then((s) => s.create(opts)),
|
||||||
list: () => load().then((s) => s.list()),
|
list: () => load().then((s) => s.list()),
|
||||||
delete: (id) => load().then((s) => s.delete(id)),
|
delete: (id) => load().then((s) => s.delete(id)),
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const en = {
|
|||||||
'new.searching': 'Looking for an opponent…',
|
'new.searching': 'Looking for an opponent…',
|
||||||
'new.rulesEnglish': '100 tiles · bingo +50',
|
'new.rulesEnglish': '100 tiles · bingo +50',
|
||||||
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
|
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
|
||||||
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
|
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15 · no repeated words',
|
||||||
'new.moveLimit': 'Move time: {n} h 00 min',
|
'new.moveLimit': 'Move time: {n} h 00 min',
|
||||||
'new.searchHint':
|
'new.searchHint':
|
||||||
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
|
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
|
||||||
@@ -99,6 +99,7 @@ export const en = {
|
|||||||
'game.bagCount': 'In the bag: {n}',
|
'game.bagCount': 'In the bag: {n}',
|
||||||
'game.bagEmpty': 'Bag is empty',
|
'game.bagEmpty': 'Bag is empty',
|
||||||
'game.hints': 'Hints {n}',
|
'game.hints': 'Hints {n}',
|
||||||
|
'game.wordAlreadyUsed': 'already used',
|
||||||
'game.yourTurn': 'Your turn',
|
'game.yourTurn': 'Your turn',
|
||||||
'game.yourTurnBy': '{name}: Your turn!',
|
'game.yourTurnBy': '{name}: Your turn!',
|
||||||
'game.opponentsTurn': "Opponent's turn",
|
'game.opponentsTurn': "Opponent's turn",
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.searching': 'Ищем соперника…',
|
'new.searching': 'Ищем соперника…',
|
||||||
'new.rulesEnglish': '100 фишек · бинго +50',
|
'new.rulesEnglish': '100 фишек · бинго +50',
|
||||||
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
|
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
|
||||||
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
|
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15 · слова без повтора',
|
||||||
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
|
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
|
||||||
'new.searchHint':
|
'new.searchHint':
|
||||||
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
|
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
|
||||||
@@ -99,6 +99,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.bagCount': 'В мешке: {n}',
|
'game.bagCount': 'В мешке: {n}',
|
||||||
'game.bagEmpty': 'Мешок пуст',
|
'game.bagEmpty': 'Мешок пуст',
|
||||||
'game.hints': 'Подсказки {n}',
|
'game.hints': 'Подсказки {n}',
|
||||||
|
'game.wordAlreadyUsed': 'уже использовано',
|
||||||
'game.yourTurn': 'Ваш ход',
|
'game.yourTurn': 'Ваш ход',
|
||||||
'game.yourTurnBy': '{name}: Ваш ход!',
|
'game.yourTurnBy': '{name}: Ваш ход!',
|
||||||
'game.opponentsTurn': 'Ход соперника',
|
'game.opponentsTurn': 'Ход соперника',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function invitation(id: string, status: string): Invitation {
|
|||||||
hintsAllowed: true,
|
hintsAllowed: true,
|
||||||
hintsPerPlayer: 0,
|
hintsPerPlayer: 0,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
dropoutTiles: 'remove',
|
dropoutTiles: 'remove',
|
||||||
status,
|
status,
|
||||||
gameId: '',
|
gameId: '',
|
||||||
expiresAtUnix: 0,
|
expiresAtUnix: 0,
|
||||||
@@ -33,6 +33,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
|
|||||||
toMove,
|
toMove,
|
||||||
turnTimeoutSecs: 300,
|
turnTimeoutSecs: 300,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: 0,
|
lastActivityUnix: 0,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
|
|||||||
toMove,
|
toMove,
|
||||||
turnTimeoutSecs: 0,
|
turnTimeoutSecs: 0,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix,
|
lastActivityUnix,
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Dawg } from '../dict/dawg';
|
||||||
|
import { RACK_SIZE } from '../premiums';
|
||||||
|
import { LocalGame } from './engine';
|
||||||
|
import { wordKey } from '../dict/norepeat';
|
||||||
|
import { decide } from '../robot/strategy';
|
||||||
|
import type { Move } from '../dict/validate';
|
||||||
|
|
||||||
|
// The no-repeat-words rule driven through the whole offline engine: two robots self-play a game
|
||||||
|
// with the rule on, and no word may ever be laid twice. This covers both halves of the wiring at
|
||||||
|
// once — generateMoves must not offer a forbidden play, and play must not accept one — over a long
|
||||||
|
// run of real positions rather than a hand-built one. The pinned semantics live in
|
||||||
|
// dict/norepeat.test.ts; the same rule on the server is engine/norepeat_test.go.
|
||||||
|
const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||||
|
|
||||||
|
function bestOpponentScore(game: LocalGame, seat: number): number {
|
||||||
|
let best = 0;
|
||||||
|
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfPlay runs a whole game of robot turns, returning every main word laid in order. The sample
|
||||||
|
// dictionary emits a one-letter word the validator rejects, so candidates are filtered the way
|
||||||
|
// the robot's own turn does (see engine.test.ts).
|
||||||
|
function selfPlay(noRepeatWords: boolean, seed: bigint): string[] {
|
||||||
|
const game = new LocalGame({
|
||||||
|
variant: 'scrabble_en',
|
||||||
|
version: 'sample',
|
||||||
|
seed,
|
||||||
|
players: 2,
|
||||||
|
dawg,
|
||||||
|
multipleWords: true,
|
||||||
|
noRepeatWords,
|
||||||
|
});
|
||||||
|
const mains: string[] = [];
|
||||||
|
for (let turn = 0; turn < 500 && !game.isOver; turn++) {
|
||||||
|
const seat = game.currentPlayer;
|
||||||
|
const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2);
|
||||||
|
const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength);
|
||||||
|
if (dec.kind === 'play') {
|
||||||
|
mains.push(wordKey(dec.move.main.letters));
|
||||||
|
game.play(dec.move.dir, dec.move.tiles);
|
||||||
|
} else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) {
|
||||||
|
game.exchange(dec.exchange);
|
||||||
|
} else {
|
||||||
|
game.pass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mains;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('offline engine under the no-repeat-words rule', () => {
|
||||||
|
it('never lays the same word twice, where the same run without the rule does', () => {
|
||||||
|
// Both halves of the wiring at once: generateMoves must not offer a forbidden play and play
|
||||||
|
// must not accept one. Running the same seed with the rule off is the anti-vacuity guard —
|
||||||
|
// it proves this position sequence really does offer repeats.
|
||||||
|
const seed = 20260727n;
|
||||||
|
const withRule = selfPlay(true, seed);
|
||||||
|
const withoutRule = selfPlay(false, seed);
|
||||||
|
|
||||||
|
expect(withoutRule.length).toBeGreaterThan(0);
|
||||||
|
expect(new Set(withoutRule).size).toBeLessThan(withoutRule.length); // repeats are on offer...
|
||||||
|
expect(new Set(withRule).size).toBe(withRule.length); // ...and the rule keeps every one out
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import { Bag } from './bag';
|
|||||||
import { RULESETS } from './ruleset';
|
import { RULESETS } from './ruleset';
|
||||||
import { generateMoves, GenRack, Both } from '../dict/generate';
|
import { generateMoves, GenRack, Both } from '../dict/generate';
|
||||||
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
|
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
|
||||||
|
import { noRepeatScore, playedWordKeys } from '../dict/norepeat';
|
||||||
import { playDirection } from '../dict/direction';
|
import { playDirection } from '../dict/direction';
|
||||||
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
|
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
|
||||||
import { BLANK_INDEX } from '../alphabet';
|
import { BLANK_INDEX } from '../alphabet';
|
||||||
@@ -154,6 +155,10 @@ export interface LocalGameOptions {
|
|||||||
players: number;
|
players: number;
|
||||||
dawg: Dawg;
|
dawg: Dawg;
|
||||||
multipleWords: boolean;
|
multipleWords: boolean;
|
||||||
|
/** Forbids laying a word that is already on the board (the Erudit rule, see dict/norepeat).
|
||||||
|
* Pinned per game, so a game started before the rule existed replays and scores unchanged;
|
||||||
|
* absent reads as off. */
|
||||||
|
noRepeatWords?: boolean;
|
||||||
dropoutTiles?: DropoutTiles;
|
dropoutTiles?: DropoutTiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +180,7 @@ export class LocalGame {
|
|||||||
private readonly rackSize: number;
|
private readonly rackSize: number;
|
||||||
private readonly dawg: Dawg;
|
private readonly dawg: Dawg;
|
||||||
private readonly multipleWords: boolean;
|
private readonly multipleWords: boolean;
|
||||||
|
private readonly noRepeatWords: boolean;
|
||||||
private readonly dropoutTiles: DropoutTiles;
|
private readonly dropoutTiles: DropoutTiles;
|
||||||
|
|
||||||
private readonly board: LocalBoard;
|
private readonly board: LocalBoard;
|
||||||
@@ -197,6 +203,7 @@ export class LocalGame {
|
|||||||
this.seed = opts.seed;
|
this.seed = opts.seed;
|
||||||
this.dawg = opts.dawg;
|
this.dawg = opts.dawg;
|
||||||
this.multipleWords = opts.multipleWords;
|
this.multipleWords = opts.multipleWords;
|
||||||
|
this.noRepeatWords = opts.noRepeatWords ?? false;
|
||||||
this.dropoutTiles = opts.dropoutTiles ?? 'remove';
|
this.dropoutTiles = opts.dropoutTiles ?? 'remove';
|
||||||
this.vrs = buildRuleset(opts.variant, opts.multipleWords);
|
this.vrs = buildRuleset(opts.variant, opts.multipleWords);
|
||||||
this.values = RULESETS[opts.variant].values;
|
this.values = RULESETS[opts.variant].values;
|
||||||
@@ -250,28 +257,60 @@ export class LocalGame {
|
|||||||
}
|
}
|
||||||
/** config returns the rule settings the game was created with — the bits, alongside the seed and
|
/** config returns the rule settings the game was created with — the bits, alongside the seed and
|
||||||
* journal, needed to reconstruct it (see localgame/serialize). */
|
* journal, needed to reconstruct it (see localgame/serialize). */
|
||||||
get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } {
|
get config(): { multipleWords: boolean; noRepeatWords: boolean; dropoutTiles: DropoutTiles } {
|
||||||
return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles };
|
return { multipleWords: this.multipleWords, noRepeatWords: this.noRepeatWords, dropoutTiles: this.dropoutTiles };
|
||||||
}
|
}
|
||||||
/** history returns a copy of the move log. */
|
/** history returns a copy of the move log. */
|
||||||
get history(): LocalMove[] {
|
get history(): LocalMove[] {
|
||||||
return this.log.map((m) => ({ ...m }));
|
return this.log.map((m) => ({ ...m }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** generateMoves returns every legal play for the current player, ranked by descending score. */
|
/** generateMoves returns every legal play for the current player, ranked by descending score.
|
||||||
|
* Under the no-repeat-words rule the plays it forbids are dropped and the rest are rescored and
|
||||||
|
* re-ranked, so neither the robot nor the hint can offer a move the engine would then refuse. */
|
||||||
generateMoves(): Move[] {
|
generateMoves(): Move[] {
|
||||||
return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
|
const moves = generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
|
||||||
|
if (!this.noRepeatWords) return moves;
|
||||||
|
const played = this.playedKeys();
|
||||||
|
const out: Move[] = [];
|
||||||
|
for (const m of moves) {
|
||||||
|
const score = noRepeatScore(m, played);
|
||||||
|
if (score === null) continue;
|
||||||
|
out.push(score === m.score ? m : { ...m, score });
|
||||||
|
}
|
||||||
|
// A stable sort, so plays the generator ranked equally keep its order.
|
||||||
|
return out.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** playedKeys is the no-repeat-words rule's lookup set: every word this game has already
|
||||||
|
* formed, main words and cross-words alike (see dict/norepeat). */
|
||||||
|
private playedKeys(): ReadonlySet<string> {
|
||||||
|
const words: number[][] = [];
|
||||||
|
for (const m of this.log) {
|
||||||
|
if (m.action === 'play' && m.words) words.push(...m.words);
|
||||||
|
}
|
||||||
|
return playedWordKeys(words);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** evaluatePlay scores a candidate placement for the current position without committing it,
|
/** evaluatePlay scores a candidate placement for the current position without committing it,
|
||||||
* returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index
|
* returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index
|
||||||
* arrays, main first) and the inferred direction. Backs the local move preview. */
|
* arrays, main first) and the inferred direction. Backs the local move preview. */
|
||||||
evaluatePlay(tiles: Placement[]): { legal: boolean; score: number; words: number[][]; dir: Direction } {
|
evaluatePlay(tiles: Placement[]): {
|
||||||
|
legal: boolean;
|
||||||
|
score: number;
|
||||||
|
words: number[][];
|
||||||
|
dir: Direction;
|
||||||
|
/** The main word when the no-repeat-words rule is what rejects the play: a real word the game
|
||||||
|
* has already used. Absent on every other outcome. */
|
||||||
|
repeated?: number[];
|
||||||
|
} {
|
||||||
const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
|
const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
|
||||||
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
||||||
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
|
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
|
||||||
const m = res.move;
|
const m = res.move;
|
||||||
return { legal: true, score: m.score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
|
const score = this.noRepeatWords ? noRepeatScore(m, this.playedKeys()) : m.score;
|
||||||
|
if (score === null) return { legal: false, score: 0, words: [], dir, repeated: m.main.letters };
|
||||||
|
return { legal: true, score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */
|
/** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */
|
||||||
@@ -299,10 +338,12 @@ export class LocalGame {
|
|||||||
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
||||||
if (!res.legal || !res.move) throw new GameError('illegal_play');
|
if (!res.legal || !res.move) throw new GameError('illegal_play');
|
||||||
const move = res.move;
|
const move = res.move;
|
||||||
|
const score = this.noRepeatWords ? noRepeatScore(move, this.playedKeys()) : move.score;
|
||||||
|
if (score === null) throw new GameError('illegal_play');
|
||||||
|
|
||||||
for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
|
for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
|
||||||
this.removeFromHand(player, used);
|
this.removeFromHand(player, used);
|
||||||
this.scores[player] += move.score;
|
this.scores[player] += score;
|
||||||
this.refill(player);
|
this.refill(player);
|
||||||
this.scorelessRun = 0;
|
this.scorelessRun = 0;
|
||||||
|
|
||||||
@@ -314,7 +355,7 @@ export class LocalGame {
|
|||||||
mainRow: move.main.row,
|
mainRow: move.main.row,
|
||||||
mainCol: move.main.col,
|
mainCol: move.main.col,
|
||||||
words: [move.main.letters, ...move.cross.map((w) => w.letters)],
|
words: [move.main.letters, ...move.cross.map((w) => w.letters)],
|
||||||
score: move.score,
|
score,
|
||||||
total: this.scores[player],
|
total: this.scores[player],
|
||||||
};
|
};
|
||||||
this.log.push(rec);
|
this.log.push(rec);
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export interface LocalGameRecord {
|
|||||||
seed: string;
|
seed: string;
|
||||||
players: number;
|
players: number;
|
||||||
multipleWords: boolean;
|
multipleWords: boolean;
|
||||||
|
/** Forbids laying a word that is already on the board (the Erudit rule). Pinned at creation from
|
||||||
|
* the variant, so a game started before the rule existed (where it is absent, read as false)
|
||||||
|
* replays and scores exactly as it was played. */
|
||||||
|
noRepeatWords?: boolean;
|
||||||
dropoutTiles: DropoutTiles;
|
dropoutTiles: DropoutTiles;
|
||||||
seats: Seat[];
|
seats: Seat[];
|
||||||
/** The dictionary-independent, alphabet-index-space move journal. */
|
/** The dictionary-independent, alphabet-index-space move journal. */
|
||||||
@@ -53,6 +57,13 @@ export interface LocalGameRecord {
|
|||||||
* read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it —
|
* read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it —
|
||||||
* see lib/hints. */
|
* see lib/hints. */
|
||||||
hintUnlockAtMs: number;
|
hintUnlockAtMs: number;
|
||||||
|
/** The seated player's in-progress composition (rack order + pending board tiles) as the draft
|
||||||
|
* JSON the game screen serialises, or absent when there is none. A local game keeps it here —
|
||||||
|
* there is no server to hold it — so a reload of the game state restores the arrangement instead
|
||||||
|
* of dropping the tiles back into the rack. It belongs to the turn: the source clears it wherever
|
||||||
|
* the turn advances (a committed move, a resignation, a host skip), so a hotseat seat never
|
||||||
|
* inherits the previous player's arrangement. */
|
||||||
|
draft?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The record fields the caller owns (the engine supplies the rest). */
|
/** The record fields the caller owns (the engine supplies the rest). */
|
||||||
@@ -64,6 +75,7 @@ export interface RecordMeta {
|
|||||||
createdAtUnix: number;
|
createdAtUnix: number;
|
||||||
updatedAtUnix: number;
|
updatedAtUnix: number;
|
||||||
hintUnlockAtMs: number;
|
hintUnlockAtMs: number;
|
||||||
|
draft?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +91,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
|
|||||||
seed: game.seed.toString(),
|
seed: game.seed.toString(),
|
||||||
players: game.playerCount,
|
players: game.playerCount,
|
||||||
multipleWords: cfg.multipleWords,
|
multipleWords: cfg.multipleWords,
|
||||||
|
noRepeatWords: cfg.noRepeatWords || undefined,
|
||||||
dropoutTiles: cfg.dropoutTiles,
|
dropoutTiles: cfg.dropoutTiles,
|
||||||
seats: meta.seats,
|
seats: meta.seats,
|
||||||
journal: game.history,
|
journal: game.history,
|
||||||
@@ -88,6 +101,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
|
|||||||
createdAtUnix: meta.createdAtUnix,
|
createdAtUnix: meta.createdAtUnix,
|
||||||
updatedAtUnix: meta.updatedAtUnix,
|
updatedAtUnix: meta.updatedAtUnix,
|
||||||
hintUnlockAtMs: meta.hintUnlockAtMs,
|
hintUnlockAtMs: meta.hintUnlockAtMs,
|
||||||
|
draft: meta.draft,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +118,7 @@ export function replayGame(record: LocalGameRecord, dawg: Dawg): LocalGame {
|
|||||||
players: record.players,
|
players: record.players,
|
||||||
dawg,
|
dawg,
|
||||||
multipleWords: record.multipleWords,
|
multipleWords: record.multipleWords,
|
||||||
|
noRepeatWords: record.noRepeatWords,
|
||||||
dropoutTiles: record.dropoutTiles,
|
dropoutTiles: record.dropoutTiles,
|
||||||
};
|
};
|
||||||
const game = new LocalGame(opts);
|
const game = new LocalGame(opts);
|
||||||
|
|||||||
@@ -97,6 +97,22 @@ describe('LocalSource', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists the draft, so reloading the state keeps the composition', async () => {
|
||||||
|
// The game screen refetches the state on several triggers (a stream reconnect, a resume). With
|
||||||
|
// no stored draft every one of those wiped the player's arrangement back into the rack.
|
||||||
|
const src = await newSource('local:gDraft', 42n);
|
||||||
|
expect(await src.draftGet('local:gDraft')).toBe('');
|
||||||
|
await src.draftSave('local:gDraft', '{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
|
||||||
|
expect(await src.draftGet('local:gDraft')).toBe('{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the draft when the turn advances, so a hotseat seat never inherits it', async () => {
|
||||||
|
const src = await newSource('local:gDraft2', 42n);
|
||||||
|
await src.draftSave('local:gDraft2', '{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
|
||||||
|
await src.pass('local:gDraft2');
|
||||||
|
expect(await src.draftGet('local:gDraft2')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
it('drives a whole local game to completion', async () => {
|
it('drives a whole local game to completion', async () => {
|
||||||
const src = await newSource('local:g5', 2024n);
|
const src = await newSource('local:g5', 2024n);
|
||||||
src.events('local:g5', () => {});
|
src.events('local:g5', () => {});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { HINT_GATE_MS } from '../hints';
|
|||||||
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
|
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
|
||||||
import { decide } from '../robot/strategy';
|
import { decide } from '../robot/strategy';
|
||||||
import { verifyPin, type PinLock } from '../pin';
|
import { verifyPin, type PinLock } from '../pin';
|
||||||
|
import { noRepeatWordsFor } from '../variants';
|
||||||
import { GatewayError, type PlacedTile } from '../client';
|
import { GatewayError, type PlacedTile } from '../client';
|
||||||
import type {
|
import type {
|
||||||
EvalResult,
|
EvalResult,
|
||||||
@@ -122,6 +123,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
players: opts.seats.length,
|
players: opts.seats.length,
|
||||||
dawg,
|
dawg,
|
||||||
multipleWords: opts.multipleWords,
|
multipleWords: opts.multipleWords,
|
||||||
|
noRepeatWords: noRepeatWordsFor(opts.variant),
|
||||||
});
|
});
|
||||||
const now = nowUnix();
|
const now = nowUnix();
|
||||||
const record = serializeGame(game, {
|
const record = serializeGame(game, {
|
||||||
@@ -195,6 +197,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
const seat = entry.game.currentPlayer;
|
const seat = entry.game.currentPlayer;
|
||||||
const move = entry.game.resign();
|
const move = entry.game.resign();
|
||||||
entry.unlockedSeat = null;
|
entry.unlockedSeat = null;
|
||||||
|
entry.record.draft = undefined; // the turn is over; the abandoned composition goes with it
|
||||||
await this.persist(entry);
|
await this.persist(entry);
|
||||||
return this.moveResult(entry, move, seat);
|
return this.moveResult(entry, move, seat);
|
||||||
}
|
}
|
||||||
@@ -251,6 +254,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
if (action === 'skip') entry.game.pass();
|
if (action === 'skip') entry.game.pass();
|
||||||
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
|
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
|
||||||
entry.unlockedSeat = null;
|
entry.unlockedSeat = null;
|
||||||
|
entry.record.draft = undefined; // the host advanced the turn; the seat's composition is stale
|
||||||
await this.persist(entry);
|
await this.persist(entry);
|
||||||
return this.stateView(entry);
|
return this.stateView(entry);
|
||||||
}
|
}
|
||||||
@@ -268,11 +272,13 @@ export class LocalSource implements GameLoopSource {
|
|||||||
async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> {
|
async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> {
|
||||||
const { game } = await this.load(gameId);
|
const { game } = await this.load(gameId);
|
||||||
const r = game.evaluatePlay(encodePlacements(variant, tiles));
|
const r = game.evaluatePlay(encodePlacements(variant, tiles));
|
||||||
|
const decode = (w: readonly number[]) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase();
|
||||||
return {
|
return {
|
||||||
legal: r.legal,
|
legal: r.legal,
|
||||||
score: r.score,
|
score: r.score,
|
||||||
words: r.words.map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()),
|
words: r.words.map(decode),
|
||||||
dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '',
|
dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '',
|
||||||
|
repeatedWord: r.repeated ? decode(r.repeated) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,13 +288,18 @@ export class LocalSource implements GameLoopSource {
|
|||||||
return { word, legal: game.dictionaryHas(idx) };
|
return { word, legal: game.dictionaryHas(idx) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drafts (the in-progress composition) are not persisted for local games — the arrangement is
|
// Drafts (the in-progress composition: rack order + pending board tiles) live in the game's own
|
||||||
// rebuilt from the rack on reopen. The screen tolerates an empty draft.
|
// record — a local game has no server to hold them. Persisting them keeps every reload of the
|
||||||
async draftGet(): Promise<string> {
|
// state (the game screen refetches on several triggers) from dropping the arrangement back into
|
||||||
return '';
|
// the rack, and carries it across leaving and reopening the app.
|
||||||
|
async draftGet(gameId: string): Promise<string> {
|
||||||
|
const entry = await this.load(gameId);
|
||||||
|
return entry.record.draft ?? '';
|
||||||
}
|
}
|
||||||
async draftSave(): Promise<void> {
|
async draftSave(gameId: string, json: string): Promise<void> {
|
||||||
/* no-op offline */
|
const entry = await this.load(gameId);
|
||||||
|
entry.record.draft = json || undefined;
|
||||||
|
await this.persist(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** events subscribes to a local game's push events (the robot's reply, game over). Returns an
|
/** events subscribes to a local game's push events (the robot's reply, game over). Returns an
|
||||||
@@ -325,6 +336,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
const seat = entry.game.currentPlayer;
|
const seat = entry.game.currentPlayer;
|
||||||
const move = act(entry.game);
|
const move = act(entry.game);
|
||||||
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
|
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
|
||||||
|
entry.record.draft = undefined; // the composition was committed (or swapped away) with the turn
|
||||||
const robotMoves = this.runRobots(entry);
|
const robotMoves = this.runRobots(entry);
|
||||||
await this.persist(entry);
|
await this.persist(entry);
|
||||||
const result = this.moveResult(entry, move, seat);
|
const result = this.moveResult(entry, move, seat);
|
||||||
@@ -378,6 +390,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
createdAtUnix: entry.record.createdAtUnix,
|
createdAtUnix: entry.record.createdAtUnix,
|
||||||
updatedAtUnix: nowUnix(),
|
updatedAtUnix: nowUnix(),
|
||||||
hintUnlockAtMs: entry.record.hintUnlockAtMs,
|
hintUnlockAtMs: entry.record.hintUnlockAtMs,
|
||||||
|
draft: entry.record.draft,
|
||||||
});
|
});
|
||||||
await saveLocalGame(entry.record);
|
await saveLocalGame(entry.record);
|
||||||
}
|
}
|
||||||
@@ -474,6 +487,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
toMove: game.currentPlayer,
|
toMove: game.currentPlayer,
|
||||||
turnTimeoutSecs: 0,
|
turnTimeoutSecs: 0,
|
||||||
multipleWordsPerTurn: record.multipleWords,
|
multipleWordsPerTurn: record.multipleWords,
|
||||||
|
noRepeatWords: !!record.noRepeatWords,
|
||||||
moveCount: game.moveCount,
|
moveCount: game.moveCount,
|
||||||
endReason: game.isOver ? game.endReason : '',
|
endReason: game.isOver ? game.endReason : '',
|
||||||
lastActivityUnix: record.updatedAtUnix,
|
lastActivityUnix: record.updatedAtUnix,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import type {
|
|||||||
Catalog,
|
Catalog,
|
||||||
} from '../model';
|
} from '../model';
|
||||||
import { valueForLetter } from '../alphabet';
|
import { valueForLetter } from '../alphabet';
|
||||||
|
import { noRepeatWordsFor } from '../variants';
|
||||||
import { seedMockAlphabets } from './alphabet';
|
import { seedMockAlphabets } from './alphabet';
|
||||||
import {
|
import {
|
||||||
ME,
|
ME,
|
||||||
@@ -292,6 +293,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
toMove: 0,
|
toMove: 0,
|
||||||
turnTimeoutSecs: 604800,
|
turnTimeoutSecs: 604800,
|
||||||
multipleWordsPerTurn: multipleWords,
|
multipleWordsPerTurn: multipleWords,
|
||||||
|
noRepeatWords: noRepeatWordsFor(variant),
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: Math.floor(Date.now() / 1000),
|
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||||
@@ -326,6 +328,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
toMove: 0,
|
toMove: 0,
|
||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
multipleWordsPerTurn: multipleWords,
|
multipleWordsPerTurn: multipleWords,
|
||||||
|
noRepeatWords: noRepeatWordsFor(variant),
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: Math.floor(Date.now() / 1000),
|
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function mockInvitations(): Invitation[] {
|
|||||||
hintsAllowed: true,
|
hintsAllowed: true,
|
||||||
hintsPerPlayer: 1,
|
hintsPerPlayer: 1,
|
||||||
multipleWordsPerTurn: false,
|
multipleWordsPerTurn: false,
|
||||||
dropoutTiles: 'remove',
|
dropoutTiles: 'remove',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
gameId: '',
|
gameId: '',
|
||||||
expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
|
expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
|
||||||
@@ -198,6 +198,7 @@ function activeGame(): MockGame {
|
|||||||
toMove: 0,
|
toMove: 0,
|
||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: G1_MOVES.length,
|
moveCount: G1_MOVES.length,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
|
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
|
||||||
@@ -237,6 +238,7 @@ function finishedG2(): MockGame {
|
|||||||
toMove: 0,
|
toMove: 0,
|
||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: 2,
|
moveCount: 2,
|
||||||
endReason: 'normal',
|
endReason: 'normal',
|
||||||
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
|
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
|
||||||
@@ -277,6 +279,7 @@ function finishedG3(): MockGame {
|
|||||||
toMove: 0,
|
toMove: 0,
|
||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
multipleWordsPerTurn: false,
|
multipleWordsPerTurn: false,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: 1,
|
moveCount: 1,
|
||||||
endReason: 'resignation',
|
endReason: 'resignation',
|
||||||
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
|
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ export interface GameView {
|
|||||||
turnTimeoutSecs: number;
|
turnTimeoutSecs: number;
|
||||||
/** true = standard Scrabble; false = the single-word rule (Russian games). */
|
/** true = standard Scrabble; false = the single-word rule (Russian games). */
|
||||||
multipleWordsPerTurn: boolean;
|
multipleWordsPerTurn: boolean;
|
||||||
|
/** true = a word already on the board cannot be laid again (the Erudit rule). Pinned per game at
|
||||||
|
* creation, so a game started before the rule existed reports false. The local move preview needs
|
||||||
|
* it to score the way the server does. */
|
||||||
|
noRepeatWords: boolean;
|
||||||
moveCount: number;
|
moveCount: number;
|
||||||
endReason: string;
|
endReason: string;
|
||||||
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
|
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
|
||||||
@@ -125,6 +129,11 @@ export interface EvalResult {
|
|||||||
words: string[];
|
words: string[];
|
||||||
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
|
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
|
||||||
dir: string;
|
dir: string;
|
||||||
|
/** The play's main word when it is a real word the game has already used, so the rule (and not
|
||||||
|
* the dictionary) is what makes the play illegal — the composition reads as invalid but the
|
||||||
|
* caption says so in those words. Set only by the on-device evaluator, which decides the rule
|
||||||
|
* before the play ever reaches the server; absent on every other outcome. */
|
||||||
|
repeatedWord?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WordCheckResult {
|
export interface WordCheckResult {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
|
|||||||
toMove: 0,
|
toMove: 0,
|
||||||
turnTimeoutSecs: 300,
|
turnTimeoutSecs: 300,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: 0,
|
lastActivityUnix: 0,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
|||||||
toMove,
|
toMove,
|
||||||
turnTimeoutSecs: 0,
|
turnTimeoutSecs: 0,
|
||||||
multipleWordsPerTurn: true,
|
multipleWordsPerTurn: true,
|
||||||
|
noRepeatWords: false,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: 0,
|
lastActivityUnix: 0,
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export function variantNameKey(v: Variant): MessageKey {
|
|||||||
return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english';
|
return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noRepeatWordsFor reports whether a new game of variant v takes the no-repeat-words rule (see
|
||||||
|
// lib/dict/norepeat): a word already on the board cannot be laid again. It is a rule of Erudit
|
||||||
|
// alone — both Scrabble variants let a word be played as often as a player can manage. It mirrors
|
||||||
|
// the server's noRepeatWordsFor and is only ever consulted when a game is created; an existing
|
||||||
|
// game carries its own pinned answer (GameView.noRepeatWords / LocalGameRecord.noRepeatWords).
|
||||||
|
export function noRepeatWordsFor(v: Variant): boolean {
|
||||||
|
return v === 'erudit_ru';
|
||||||
|
}
|
||||||
|
|
||||||
// VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game
|
// VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game
|
||||||
// buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
|
// buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
|
||||||
export const VARIANT_RULES: Record<Variant, MessageKey> = {
|
export const VARIANT_RULES: Record<Variant, MessageKey> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user