Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 8s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 8s

internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.

Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.

Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.

Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
This commit is contained in:
Ilia Denisov
2026-06-02 17:33:49 +02:00
parent f36f3df748
commit 751e74b14f
45 changed files with 4220 additions and 103 deletions
+29 -15
View File
@@ -58,18 +58,31 @@ type TileRecord struct {
// carries only the action; an exchange carries the number of tiles swapped. The
// game domain adds timestamps and persistence around these values.
type MoveRecord struct {
Player int
Action ActionKind
Tiles []TileRecord // ActionPlay only
Words []string // ActionPlay only: the main word first, then cross words
Count int // ActionExchange only: number of tiles swapped
Score int // points scored this turn (0 for non-plays)
Total int // the player's running total after this turn
Player int
Action ActionKind
Dir Direction // ActionPlay only: orientation of the main word (H/V)
MainRow, MainCol int // ActionPlay only: the main word's first-letter coordinate
Tiles []TileRecord // ActionPlay only
Words []string // ActionPlay only: the main word first, then cross words
Count int // ActionExchange only: number of tiles swapped
Score int // points scored this turn (0 for non-plays)
Total int // the player's running total after this turn
}
// recordPlay decodes a scored move into a dictionary-independent MoveRecord for
// the given player.
// recordPlay decodes a scored, committed move into a dictionary-independent
// MoveRecord for the given player, stamping the player and their running total.
func (g *Game) recordPlay(player int, m scrabble.Move) MoveRecord {
rec := g.decodeMove(m)
rec.Player = player
rec.Total = g.scores[player]
return rec
}
// decodeMove decodes a scored move's placements and words into a
// dictionary-independent MoveRecord, without the player or running total (which
// only a committed play has). It backs both recordPlay and the non-committing
// previews HintView and EvaluatePlay.
func (g *Game) decodeMove(m scrabble.Move) MoveRecord {
tiles := make([]TileRecord, len(m.Tiles))
for i, p := range m.Tiles {
tiles[i] = TileRecord{Row: p.Row, Col: p.Col, Letter: g.letter(p.Letter), Blank: p.Blank}
@@ -80,12 +93,13 @@ func (g *Game) recordPlay(player int, m scrabble.Move) MoveRecord {
words = append(words, g.word(cw))
}
return MoveRecord{
Player: player,
Action: ActionPlay,
Tiles: tiles,
Words: words,
Score: m.Score,
Total: g.scores[player],
Action: ActionPlay,
Dir: fromScrabbleDir(m.Dir),
MainRow: m.Main.Row,
MainCol: m.Main.Col,
Tiles: tiles,
Words: words,
Score: m.Score,
}
}
+157
View File
@@ -0,0 +1,157 @@
package engine
import (
"fmt"
"scrabble-solver/scrabble"
)
// blankLetter is how a blank tile is written in the decoded, domain-facing API:
// in a rack it is an undesignated blank, and in an exchange it is a blank being
// swapped. A blank placed on the board is a TileRecord with Blank set and Letter
// holding the concrete letter it stands for.
const blankLetter = "?"
// Direction is the orientation of a play as seen by the game domain. It decouples
// the domain from the solver's own Direction type: internal/engine is the only
// backend package that imports scrabble-solver (see docs/ARCHITECTURE.md §5), so
// the engine accepts and returns decoded, solver-free values.
type Direction uint8
const (
// Horizontal lays a word left to right along a row.
Horizontal Direction = iota
// Vertical lays a word top to bottom down a column.
Vertical
)
// String renders the direction as "H" or "V", the form the move journal and GCG
// export use.
func (d Direction) String() string {
if d == Vertical {
return "V"
}
return "H"
}
// scrabbleDir maps the domain Direction to the solver's Direction.
func (d Direction) scrabbleDir() scrabble.Direction {
if d == Vertical {
return scrabble.Vertical
}
return scrabble.Horizontal
}
// fromScrabbleDir maps the solver's Direction to the domain Direction.
func fromScrabbleDir(d scrabble.Direction) Direction {
if d == scrabble.Vertical {
return Vertical
}
return Horizontal
}
// SubmitPlay validates and applies the current player's play described in decoded
// terms: each TileRecord carries a concrete letter (the letter a blank stands for
// when Blank is set) and a board coordinate. It encodes the tiles through the
// ruleset alphabet and delegates to Play, so it returns the same errors
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
// letter is outside the variant's alphabet.
func (g *Game) SubmitPlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
placements, err := g.placements(tiles)
if err != nil {
return MoveRecord{}, err
}
return g.Play(dir.scrabbleDir(), placements)
}
// SubmitExchange swaps the current player's tiles, named in decoded terms: a
// concrete letter per tile, or "?" for a blank. It encodes them and delegates to
// Exchange, returning the same errors plus ErrTilesNotOnRack when a letter is
// outside the variant's alphabet.
func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) {
raw, err := g.encodeTiles(tiles)
if err != nil {
return MoveRecord{}, err
}
return g.Exchange(raw)
}
// 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 returns the decoded move (placed tiles, the words it forms and its score)
// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn
// are left untouched.
func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
placements, err := g.placements(tiles)
if err != nil {
return MoveRecord{}, err
}
move, err := g.solver.ValidatePlay(g.board, dir.scrabbleDir(), placements)
if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
}
return g.decodeMove(move), nil
}
// HintView returns the highest-scoring legal play for the current player as a
// decoded MoveRecord and true, or a zero record and false when there is none. It
// is the one-per-game hint's top-1 move in domain-facing form.
func (g *Game) HintView() (MoveRecord, bool) {
move, ok := g.Hint()
if !ok {
return MoveRecord{}, false
}
return g.decodeMove(move), true
}
// Hand returns the player's current rack decoded to concrete letters, with "?"
// for each undesignated blank. The order mirrors the internal hand. It supplies
// the GCG rack field and the per-player game-state view.
func (g *Game) Hand(player int) []string {
hand := g.hands[player]
out := make([]string, len(hand))
for i, t := range hand {
if t == blankTile {
out[i] = blankLetter
continue
}
out[i] = g.letter(t)
}
return out
}
// placements encodes decoded tiles into solver placements via the ruleset
// alphabet, wrapping a bad letter as ErrIllegalPlay.
func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
out := make([]scrabble.Placement, len(tiles))
for i, t := range tiles {
idx, err := g.rules.Alphabet.Index(t.Letter)
if err != nil {
return nil, fmt.Errorf("%w: letter %q at (%d,%d): %v", ErrIllegalPlay, t.Letter, t.Row, t.Col, err)
}
out[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank}
}
return out, nil
}
// 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).
func (g *Game) encodeTiles(tiles []string) ([]byte, error) {
raw := make([]byte, len(tiles))
for i, t := range tiles {
if t == blankLetter {
raw[i] = blankTile
continue
}
idx, err := g.rules.Alphabet.Index(t)
if err != nil {
return nil, fmt.Errorf("%w: tile %q: %v", ErrTilesNotOnRack, t, err)
}
raw[i] = idx
}
return raw, nil
}
+180
View File
@@ -0,0 +1,180 @@
package engine
import (
"errors"
"slices"
"testing"
)
// TestDirectionString covers the H/V rendering used by the journal and GCG.
func TestDirectionString(t *testing.T) {
if Horizontal.String() != "H" {
t.Errorf("Horizontal = %q, want H", Horizontal.String())
}
if Vertical.String() != "V" {
t.Errorf("Vertical = %q, want V", Vertical.String())
}
}
// TestSubmitPlayMatchesHint plays the decoded top-1 move through SubmitPlay and
// checks it scores and advances exactly like the underlying solver move, proving
// the decode→encode round trip.
func TestSubmitPlayMatchesHint(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("submit play: %v", err)
}
if rec.Score != hint.Score {
t.Errorf("played score = %d, want hint score %d", rec.Score, hint.Score)
}
if rec.Action != ActionPlay {
t.Errorf("action = %v, want play", rec.Action)
}
if g.Score(0) != hint.Score {
t.Errorf("player 0 score = %d, want %d", g.Score(0), hint.Score)
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1 after a play", g.ToMove())
}
}
// TestEvaluatePlayDoesNotCommit checks the preview scores like a real play but
// leaves the board, scores, turn and bag untouched.
func TestEvaluatePlayDoesNotCommit(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
boardBefore := g.BoardClone()
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
rec, err := g.EvaluatePlay(hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("evaluate play: %v", err)
}
if rec.Score != hint.Score {
t.Errorf("evaluated score = %d, want %d", rec.Score, hint.Score)
}
if !boardsEqual(boardBefore, g.BoardClone()) {
t.Error("evaluate must not mutate the board")
}
if g.Score(0) != scoreBefore || g.ToMove() != toMoveBefore || g.BagLen() != bagBefore {
t.Errorf("evaluate mutated state: score %d->%d, toMove %d->%d, bag %d->%d",
scoreBefore, g.Score(0), toMoveBefore, g.ToMove(), bagBefore, g.BagLen())
}
}
// TestEvaluatePlayRejectsIllegal reports ErrIllegalPlay for a play the solver
// rejects (a single off-centre opening tile) without committing.
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
g := newEnglishGame(t, 1)
letter := g.Hand(0)[0]
_, err := g.EvaluatePlay(Horizontal, []TileRecord{{Row: 0, Col: 0, Letter: letter}})
if !errors.Is(err, ErrIllegalPlay) {
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
}
}
// TestSubmitExchangeWithBlank exchanges a full rack that includes a blank,
// exercising the "?" encoding path, and checks the turn advances.
func TestSubmitExchangeWithBlank(t *testing.T) {
g := gameWithBlankInHand(t)
hand := g.Hand(0)
if !slices.Contains(hand, blankLetter) {
t.Fatalf("hand %v has no blank", hand)
}
rec, err := g.SubmitExchange(hand)
if err != nil {
t.Fatalf("submit exchange: %v", err)
}
if rec.Action != ActionExchange || rec.Count != len(hand) {
t.Errorf("exchange record = %+v, want action exchange count %d", rec, len(hand))
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1 after an exchange", g.ToMove())
}
}
// TestHandDecodesBlank checks Hand returns concrete letters and "?" for a blank,
// agreeing with the internal hand.
func TestHandDecodesBlank(t *testing.T) {
g := gameWithBlankInHand(t)
hand := g.Hand(0)
if len(hand) != g.rules.RackSize {
t.Fatalf("hand size = %d, want %d", len(hand), g.rules.RackSize)
}
var blanks int
for _, s := range hand {
if s == "" {
t.Errorf("hand %v has an empty letter", hand)
}
if s == blankLetter {
blanks++
}
}
var want int
for _, t := range g.hands[0] {
if t == blankTile {
want++
}
}
if blanks != want {
t.Errorf("decoded blanks = %d, want %d", blanks, want)
}
}
// TestRegistryLookup covers word-check membership and its error taxonomy.
func TestRegistryLookup(t *testing.T) {
cases := []struct {
name string
variant Variant
word string
want bool
}{
{"english hit", VariantEnglish, "cat", true},
{"english miss", VariantEnglish, "zzzz", false},
{"russian hit", VariantRussianScrabble, "кот", true},
{"erudit hit", VariantErudit, "кот", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := testReg.Lookup(tc.variant, testVersion, tc.word)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got != tc.want {
t.Errorf("lookup %q = %v, want %v", tc.word, got, tc.want)
}
})
}
if _, err := testReg.Lookup(VariantEnglish, "missing", "cat"); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("unknown version = %v, want ErrUnknownVersion", err)
}
if _, err := NewRegistry().Lookup(VariantEnglish, testVersion, "cat"); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("empty registry = %v, want ErrUnknownVariant", err)
}
if _, err := testReg.Lookup(VariantEnglish, testVersion, "кот"); err == nil {
t.Error("out-of-alphabet lookup must error")
}
}
// gameWithBlankInHand returns a two-player English game whose player 0 holds at
// least one blank, searching a deterministic range of seeds.
func gameWithBlankInHand(t *testing.T) *Game {
t.Helper()
for seed := int64(1); seed <= 200; seed++ {
g := newEnglishGame(t, seed)
if slices.Contains(g.Hand(0), blankLetter) {
return g
}
}
t.Fatal("no opening rack with a blank found in seeds 1..200")
return nil
}
+12
View File
@@ -66,6 +66,18 @@ 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.
+37 -22
View File
@@ -72,6 +72,7 @@ type Game struct {
scorelessRun int
over bool
reason EndReason
resignedSeat int // seat that resigned, or -1; excludes the resigner from winning
log []MoveRecord
}
@@ -98,14 +99,15 @@ func New(reg *Registry, opts Options) (*Game, error) {
rs := solver.Rules()
g := &Game{
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resignedSeat: -1,
}
for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -193,14 +195,19 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
return rec, nil
}
// Resign ends the game on the current player's turn (EndReason EndResign). In a
// two-player match this is the only resignation case; richer multi-player
// handling belongs to the game domain in a later stage.
// Resign ends the game on the current player's turn (EndReason EndResign). The
// resigner always forfeits the win and keeps their accumulated score (it is
// neither zeroed nor docked a rack adjustment); the win goes to the highest
// score among the remaining seats — in a two-player match, unconditionally to
// the other player. A missed-turn timeout reuses Resign in the game domain, so
// it inherits this win/loss. Richer multi-player drop-out handling belongs to
// the game domain in a later stage.
func (g *Game) Resign() (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
player := g.toMove
g.resignedSeat = player
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
g.log = append(g.log, rec)
g.finish(EndResign)
@@ -288,10 +295,13 @@ func (g *Game) finish(reason EndReason) {
// applyEndAdjustment settles the unplayed racks. When a player goes out (bag
// empty, rack empty) they gain the sum of every opponent's rack value and each
// opponent loses their own; otherwise (scoreless stalemate or resignation) each
// player simply forfeits their own rack value.
// opponent loses their own. A scoreless stalemate forfeits each player's own
// rack value. A resignation freezes the scores: the win is decided by winner
// (which excludes the resigner), so no rack adjustment is applied and the
// resigner keeps their accumulated score.
func (g *Game) applyEndAdjustment(reason EndReason) {
if reason == EndOutOfTiles {
switch reason {
case EndOutOfTiles:
out := g.toMove
var bonus int
for i := range g.hands {
@@ -303,10 +313,10 @@ func (g *Game) applyEndAdjustment(reason EndReason) {
bonus += v
}
g.scores[out] += bonus
return
}
for i := range g.hands {
g.scores[i] -= g.rackValue(i)
case EndScoreless:
for i := range g.hands {
g.scores[i] -= g.rackValue(i)
}
}
}
@@ -324,15 +334,20 @@ func (g *Game) endTurnAfterScoreless() {
func (g *Game) advance() { g.toMove = (g.toMove + 1) % len(g.hands) }
// winner returns the index of the single highest-scoring player, or -1 on a tie
// for the lead or while the game is unfinished.
// for the lead or while the game is unfinished. After a resignation the resigner
// is excluded, so a two-player game returns the remaining player even when the
// resigner led on score.
func (g *Game) winner() int {
if !g.over {
return -1
}
best, tie := 0, false
for i := 1; i < len(g.scores); i++ {
best, tie := -1, false
for i := range g.scores {
if g.reason == EndResign && i == g.resignedSeat {
continue
}
switch {
case g.scores[i] > g.scores[best]:
case best == -1 || g.scores[i] > g.scores[best]:
best, tie = i, false
case g.scores[i] == g.scores[best]:
tie = true
+23
View File
@@ -135,6 +135,29 @@ func (r *Registry) Versions(v Variant) []string {
return versions
}
// Lookup reports whether word is present in the (variant, version) dictionary,
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
// ErrUnknownVersion when that dictionary is not resident, and an error when word
// contains a character outside the variant's alphabet. The word is matched as
// given; callers normalise case to the variant's alphabet first.
func (r *Registry) Lookup(v Variant, version, word string) (bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
versions, ok := r.entries[v]
if !ok {
return false, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
}
e, ok := versions[version]
if !ok {
return false, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
}
idx, err := e.finder.IndexOf(word)
if err != nil {
return false, fmt.Errorf("engine: lookup %q in %s/%s: %w", word, v, version, err)
}
return idx >= 0, nil
}
// Close releases every resident dictionary and empties the registry. It is safe
// to call more than once; the first close error is returned after all finders
// have been closed.
+81
View File
@@ -0,0 +1,81 @@
package engine
import "testing"
// TestResignLeadingPlayerStillLoses is the core of the resignation fix: a player
// who resigns loses even when leading on score, the remaining player wins, and
// the resigner's score is frozen (no end-game rack adjustment).
func TestResignLeadingPlayerStillLoses(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
played, err := g.SubmitPlay(hint.Dir, hint.Tiles)
if err != nil {
t.Fatalf("player 0 play: %v", err)
}
if played.Score == 0 {
t.Fatal("opening play scored 0; pick a different seed")
}
if _, err := g.Pass(); err != nil { // player 1
t.Fatalf("player 1 pass: %v", err)
}
// Player 0 is now on turn and leads 0:played.Score; resigning must still lose.
if _, err := g.Resign(); err != nil {
t.Fatalf("player 0 resign: %v", err)
}
if !g.Over() || g.Reason() != EndResign {
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
}
res := g.Result()
if res.Winner != 1 {
t.Errorf("winner = %d, want 1 (the non-resigner) despite the resigner leading", res.Winner)
}
if g.Score(0) != played.Score {
t.Errorf("resigner score = %d, want frozen at %d (no rack adjustment)", g.Score(0), played.Score)
}
if g.Score(1) != 0 {
t.Errorf("opponent score = %d, want 0", g.Score(1))
}
if g.Score(0) <= g.Score(1) {
t.Fatal("test precondition: resigner should lead on raw score")
}
}
// TestResignTrailingPlayerLoses covers the ordinary case: the trailing player
// resigns and the leader wins.
func TestResignTrailingPlayerLoses(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 scores
t.Fatalf("player 0 play: %v", err)
}
// Player 1 (trailing 0 points) resigns.
if _, err := g.Resign(); err != nil {
t.Fatalf("player 1 resign: %v", err)
}
if res := g.Result(); res.Winner != 0 {
t.Errorf("winner = %d, want 0", res.Winner)
}
}
// TestResignOnFinishedGame rejects a second transition.
func TestResignOnFinishedGame(t *testing.T) {
g := newEnglishGame(t, 1)
if _, err := g.Resign(); err != nil {
t.Fatalf("first resign: %v", err)
}
if _, err := g.Resign(); err == nil {
t.Error("resign on a finished game must error")
}
}