Files
scrabble-game/backend/internal/engine/engine.go
T
Ilia Denisov 8881214213 R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
2026-06-10 16:56:03 +02:00

127 lines
4.8 KiB
Go

// Package engine is the backend's in-process bridge to the scrabble-solver
// library. It catalogues the playable variants, loads versioned dictionaries
// into a registry of solvers, and exposes a pure rules engine (the in-memory
// Game) that drives a match through legal plays, passes, exchanges and
// resignations while detecting the end of the game.
//
// Two invariants shape the package. First, the solver speaks alphabet-index
// bytes that are meaningful only alongside the matching ruleset; every value
// that leaves the engine for persistence or display is decoded to concrete
// characters (see decode.go and docs/ARCHITECTURE.md §9.1), so archived games
// replay independently of any dictionary. Second, the engine owns rules and
// scoring only: turn scheduling, the 24-hour timeout, persistence and transport
// belong to the game domain in a later stage.
package engine
import (
"errors"
"fmt"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// Variant identifies a Scrabble variant the backend offers. Each maps to a
// scrabble-solver ruleset and a committed dictionary.
type Variant uint8
const (
// VariantEnglish is standard English Scrabble (the SOWPODS dictionary).
VariantEnglish Variant = iota
// VariantRussianScrabble is Russian Scrabble.
VariantRussianScrabble
// VariantErudit is the Russian "Эрудит" variant.
VariantErudit
)
// String returns the variant's stable identifier, used in logs and as a metadata
// label on persisted games.
func (v Variant) String() string {
switch v {
case VariantEnglish:
return "scrabble_en"
case VariantRussianScrabble:
return "scrabble_ru"
case VariantErudit:
return "erudit_ru"
}
return "unknown"
}
// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for
// the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the
// matching per-language Telegram bot — by the game, not the recipient's last-login bot.
func (v Variant) Language() string {
if v == VariantEnglish {
return "en"
}
return "ru"
}
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
// (nil, false) for an unrecognised variant.
func (v Variant) ruleset() (*rules.Ruleset, bool) {
switch v {
case VariantEnglish:
return rules.English(), true
case VariantRussianScrabble:
return rules.RussianScrabble(), true
case VariantErudit:
return rules.Erudit(), true
}
return nil, false
}
// Variants returns the variants the backend offers, in catalogue order.
func Variants() []Variant {
return []Variant{VariantEnglish, VariantRussianScrabble, VariantErudit}
}
// ParseVariant maps a stable label produced by Variant.String back to its
// Variant, or returns ErrUnknownVariant. It is the inverse the game domain uses
// to read a persisted variant.
func ParseVariant(s string) (Variant, error) {
for _, v := range Variants() {
if v.String() == s {
return v, nil
}
}
return 0, fmt.Errorf("%w: %q", ErrUnknownVariant, s)
}
// Ruleset returns the scrabble-solver ruleset for variant. It needs no
// dictionary, so it supports dictionary-independent board replay (see
// ReplayBoard) from a finished game's variant metadata alone.
func Ruleset(v Variant) (*rules.Ruleset, error) {
rs, ok := v.ruleset()
if !ok {
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
return rs, nil
}
// Sentinel errors returned across the engine. Callers match them with
// errors.Is; the wrapped detail carries the offending value.
var (
// ErrUnknownVariant is returned for a variant the engine does not recognise.
ErrUnknownVariant = errors.New("engine: unknown variant")
// ErrUnknownVersion is returned when no dictionary is registered for a
// (variant, version) pair.
ErrUnknownVersion = errors.New("engine: unknown dictionary version")
// ErrUnknownDropoutTiles is returned by ParseDropoutTiles for a label that is
// neither "remove" nor "return".
ErrUnknownDropoutTiles = errors.New("engine: unknown drop-out tile disposition")
// ErrIllegalPlay wraps a solver validation failure: off-board geometry, a
// word absent from the dictionary, or a play that does not connect.
ErrIllegalPlay = errors.New("engine: illegal play")
// ErrTilesNotOnRack is returned when a play or exchange references tiles the
// acting player does not hold.
ErrTilesNotOnRack = errors.New("engine: tiles not on the player's rack")
// ErrNotEnoughTilesToExchange is returned when an exchange is attempted while
// the bag holds fewer tiles than a full rack.
ErrNotEnoughTilesToExchange = errors.New("engine: not enough tiles in the bag to exchange")
// ErrNothingToExchange is returned for an exchange of zero tiles.
ErrNothingToExchange = errors.New("engine: exchange requires at least one tile")
// ErrGameOver is returned when a transition is attempted on a finished game.
ErrGameOver = errors.New("engine: game is over")
)