Merge pull request 'fix(engine): EvaluatePlay honors the single-word rule' (#48) from feature/single-word-evaluate-fix into development
This commit was merged in pull request #48.
This commit is contained in:
@@ -92,10 +92,12 @@ func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) {
|
||||
|
||||
// EvaluatePlay scores and validates a tentative play without committing it,
|
||||
// backing the unlimited "what would my next move score, and is it legal?" tool.
|
||||
// It infers the play's orientation from the tiles and the board exactly as
|
||||
// SubmitPlay does, then returns the decoded move (placed tiles, the words it
|
||||
// forms, its orientation and its score) or ErrIllegalPlay when the solver
|
||||
// rejects it. The board, racks, bag and turn are left untouched.
|
||||
// It infers the play's orientation from the tiles and the board and applies the
|
||||
// game's play options exactly as SubmitPlay does, so under the single-word rule
|
||||
// perpendicular cross-words are ignored: the preview's legality and score then
|
||||
// match what submitting the play would yield. It returns the decoded move (placed
|
||||
// tiles, the words it forms, its orientation and its score) or ErrIllegalPlay when
|
||||
// the solver rejects it. The board, racks, bag and turn are left untouched.
|
||||
func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
|
||||
if g.over {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
@@ -104,7 +106,7 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
|
||||
if err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
move, err := g.solver.ValidatePlay(g.board, resolveDirection(g.board, placements), placements)
|
||||
move, err := g.solver.ValidatePlayOpts(g.board, resolveDirection(g.board, placements), placements, g.playOpts())
|
||||
if err != nil {
|
||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The
|
||||
// single-word game ignores perpendicular cross-words, so move generation from a shared
|
||||
@@ -46,3 +51,115 @@ func TestSingleWordRuleWiring(t *testing.T) {
|
||||
t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves)
|
||||
}
|
||||
}
|
||||
|
||||
// setupSingleWordKran builds an Erudit position that reproduces the test-contour
|
||||
// bug. It replaces the bag-dealt rack with к/а/н and places the existing Р the play
|
||||
// bridges plus perpendicular neighbours (г, е, н) so that each of the three new
|
||||
// tiles of the vertical КРАН forms an *invalid* cross-word (гк, еа, нн). The
|
||||
// multipleWords argument selects the rule. It returns the game and the decoded КРАН
|
||||
// placement (the three new tiles К, А, Н around the existing Р).
|
||||
func setupSingleWordKran(t *testing.T, multipleWords bool) (*Game, []TileRecord) {
|
||||
t.Helper()
|
||||
g, err := New(testReg, Options{
|
||||
Variant: VariantErudit,
|
||||
Version: testVersion,
|
||||
Players: 2,
|
||||
Seed: 1,
|
||||
MultipleWordsPerTurn: multipleWords,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new erudit game: %v", err)
|
||||
}
|
||||
idx := func(s string) byte {
|
||||
i, err := g.rules.Alphabet.Index(s)
|
||||
if err != nil {
|
||||
t.Fatalf("index %q: %v", s, err)
|
||||
}
|
||||
return i
|
||||
}
|
||||
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
|
||||
{Row: 5, Col: 8, Letter: idx("р")}, // the existing tile the play bridges
|
||||
{Row: 4, Col: 7, Letter: idx("г")}, // left of К(4,8): cross-word "гк"
|
||||
{Row: 6, Col: 7, Letter: idx("е")}, // left of А(6,8): cross-word "еа"
|
||||
{Row: 7, Col: 7, Letter: idx("н")}, // left of Н(7,8): cross-word "нн"
|
||||
}})
|
||||
g.hands[0] = []byte{idx("к"), idx("а"), idx("н")}
|
||||
tiles := []TileRecord{
|
||||
{Row: 4, Col: 8, Letter: "к"},
|
||||
{Row: 6, Col: 8, Letter: "а"},
|
||||
{Row: 7, Col: 8, Letter: "н"},
|
||||
}
|
||||
return g, tiles
|
||||
}
|
||||
|
||||
// TestEvaluatePlayHonorsSingleWordRule is the regression for the contour bug: under
|
||||
// the single-word rule the EvaluatePlay preview (the "what would this score, and is
|
||||
// it legal?" tool) must honour the same rule as SubmitPlay and ignore perpendicular
|
||||
// cross-words, so a play whose only flaw is invalid cross-words is reported legal and
|
||||
// scored on its main word alone. Before the fix EvaluatePlay validated under standard
|
||||
// rules and wrongly rejected it.
|
||||
func TestEvaluatePlayHonorsSingleWordRule(t *testing.T) {
|
||||
// The main word must be a real Erudit word, so any rejection can only come from
|
||||
// the (ignored) cross-words rather than the main word itself.
|
||||
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)
|
||||
}
|
||||
|
||||
t.Run("single-word rule accepts the cross-invalid play", func(t *testing.T) {
|
||||
g, tiles := setupSingleWordKran(t, false)
|
||||
rec, err := g.EvaluatePlay(tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate under single-word rule: %v", err)
|
||||
}
|
||||
if len(rec.Words) != 1 || rec.Words[0] != "кран" {
|
||||
t.Errorf("words = %v, want [кран] only (cross-words ignored)", rec.Words)
|
||||
}
|
||||
if rec.Dir != Vertical {
|
||||
t.Errorf("dir = %v, want Vertical", rec.Dir)
|
||||
}
|
||||
if rec.Score <= 0 {
|
||||
t.Errorf("score = %d, want positive", rec.Score)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("standard rules reject the same play", func(t *testing.T) {
|
||||
g, tiles := setupSingleWordKran(t, true)
|
||||
if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) {
|
||||
t.Errorf("evaluate under standard rules = %v, want ErrIllegalPlay", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("evaluate agrees with submit under the single-word rule", func(t *testing.T) {
|
||||
g, tiles := setupSingleWordKran(t, false)
|
||||
if _, err := g.SubmitPlay(tiles); err != nil {
|
||||
t.Errorf("submit under single-word rule: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSingleWordRuleRobotCandidates proves the robot opponent never trips the same
|
||||
// cross-word check while searching for its move: its move source, Candidates ->
|
||||
// GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged
|
||||
// КРАН (whose cross-words are invalid) appears among the candidates the robot chooses
|
||||
// from; under standard rules it is correctly absent. The robot submits its pick through
|
||||
// SubmitPlay (covered above), so this holds both before and after the EvaluatePlay fix —
|
||||
// the robot never uses EvaluatePlay.
|
||||
func TestSingleWordRuleRobotCandidates(t *testing.T) {
|
||||
hasKran := func(cands []MoveRecord) bool {
|
||||
for _, c := range cands {
|
||||
if len(c.Words) > 0 && c.Words[0] == "кран" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
single, _ := setupSingleWordKran(t, false)
|
||||
if !hasKran(single.Candidates()) {
|
||||
t.Error("single-word candidates must include the bridged кран play the robot can pick")
|
||||
}
|
||||
std, _ := setupSingleWordKran(t, true)
|
||||
if hasKran(std.Candidates()) {
|
||||
t.Error("standard candidates must not include кран (its cross-words are invalid)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,8 +285,9 @@ Key points:
|
||||
- **Multiple words per turn (Russian games).** Russian variants carry a per-game
|
||||
**single-word rule**, chosen on New Game (default **off** = single word; on = standard
|
||||
Scrabble). Off, only the **main word** along the play direction is validated and scored —
|
||||
perpendicular cross-words are ignored, including in robot move generation; on, every
|
||||
cross-word must be a real word and is scored. The engine threads it as
|
||||
perpendicular cross-words are ignored, including in robot move generation and the
|
||||
unlimited move preview; on, every cross-word must be a real word and is scored. The
|
||||
engine threads it as
|
||||
`scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.0`); connectivity and the
|
||||
first-move centre rule are unaffected. The "Russian-only" limit is a **UI affordance**:
|
||||
the backend and engine are variant-agnostic about the flag, and English games always send
|
||||
|
||||
Reference in New Issue
Block a user