fix(engine): single-word rule connects along the play line
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s

Bumps the engine to scrabble-solver v1.1.1, where a single-word-per-turn play
must form its word along its own line through an existing tile: a multi-tile play
that touches the board only perpendicular to itself (the contour РЮМ/КЕД/ОР cases)
no longer connects. For a single tile that abuts the board on both axes the engine
now plays the higher-scoring legal orientation instead of the geometrically longer
one (playDirection), so a real word is never rejected in favour of a non-word.

Reworks the single-word solver/engine tests for the corrected rule (no longer a
superset of standard play) and updates ARCHITECTURE/FUNCTIONAL/PRERELEASE.
This commit is contained in:
Ilia Denisov
2026-06-14 14:49:16 +02:00
parent c7e177f911
commit ff87a3bf62
9 changed files with 194 additions and 16 deletions
+29 -2
View File
@@ -62,7 +62,7 @@ func (g *Game) SubmitPlay(tiles []TileRecord) (MoveRecord, error) {
if err != nil {
return MoveRecord{}, err
}
return g.Play(resolveDirection(g.board, placements), placements)
return g.Play(g.playDirection(placements), placements)
}
// SubmitPlayDir is SubmitPlay with the orientation supplied rather than inferred.
@@ -106,7 +106,7 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
if err != nil {
return MoveRecord{}, err
}
move, err := g.solver.ValidatePlayOpts(g.board, resolveDirection(g.board, placements), placements, g.playOpts())
move, err := g.solver.ValidatePlayOpts(g.board, g.playDirection(placements), placements, g.playOpts())
if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
}
@@ -169,6 +169,33 @@ func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
return out, nil
}
// playDirection resolves the orientation for a live play. resolveDirection infers it from
// geometry alone, preferring the longer word when a single tile abuts the board on both
// axes; under the single-word rule that can pick an orientation whose word is not in the
// dictionary while the other orientation's is. So for a single tile under that rule the
// engine tries both orientations through the solver and keeps the higher-scoring legal one
// (horizontal breaks a tie). Multi-tile plays, and every play under the standard rule, keep
// the geometric resolution: a multi-tile play's orientation is fixed by the line its tiles
// share, and under the standard rule every word the play forms must be valid regardless of
// which one is named the main word.
func (g *Game) playDirection(placements []scrabble.Placement) scrabble.Direction {
geo := resolveDirection(g.board, placements)
if len(placements) != 1 || g.multipleWords {
return geo
}
best, found, bestScore := geo, false, 0
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
m, err := g.solver.ValidatePlayOpts(g.board, dir, placements, g.playOpts())
if err != nil {
continue
}
if !found || m.Score > bestScore {
best, found, bestScore = dir, true, m.Score
}
}
return best
}
// encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a
// concrete letter) into the internal byte form, wrapping a bad letter as
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).