feat(game): official first-move tile draw + admin step-by-step replay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Decide who moves first by the official rule: each seated player draws one tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader remains. Each draw uses honest per-draw crypto/rand entropy (not the deterministic bag seed), so the recorded draw — not a seed — is the only account of the outcome. The leader takes seat 0, so the engine and journal replay are unchanged. The draw is recorded with the game (game_setup_draws, migration 00013) for future tournaments, designed as a discrete "player N draws a tile" step. Friend/AI games draw at creation. Auto-match draws when the game opens, against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are back-filled to the real opponent on join — so the opener's seat is fixed up front and the existing open-game pre-move is preserved with no reseating. Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with A-O/1-15 headers and highlighted premium squares, placed letters with their tile value as a subscript, rack panels around the board (seat 0 top, 1 bottom, 2 left, 3 right) with the current player highlighted, and a per-move log with the tiles drawn and the bag remainder. Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package engine
|
||||
|
||||
import "fmt"
|
||||
|
||||
// SetupTile is one tile of a variant's full bag, decoded for the first-move draw
|
||||
// (docs/ARCHITECTURE.md §6): its concrete letter (or the blank marker), a blank
|
||||
// flag, and its draw rank. Lower rank wins the draw — a blank ranks above every
|
||||
// letter, and letters rank by alphabet index, so the tile closest to the start of
|
||||
// the alphabet ("A") wins. It is dictionary-independent, built from the variant's
|
||||
// solver ruleset alone.
|
||||
type SetupTile struct {
|
||||
// Letter is the concrete character (the case the solver ruleset emits), or
|
||||
// the blank marker "?" for a blank.
|
||||
Letter string
|
||||
// Blank reports whether the tile is a blank.
|
||||
Blank bool
|
||||
// Rank orders the draw: BlankRank for a blank (best), else the letter's
|
||||
// alphabet index (0 = closest to "A").
|
||||
Rank int
|
||||
}
|
||||
|
||||
// BlankRank is the first-move draw rank of a blank: below every letter index, so a
|
||||
// blank always beats a lettered tile, matching the official rule that a blank
|
||||
// supersedes all letters.
|
||||
const BlankRank = -1
|
||||
|
||||
// SetupBag returns variant's full tile bag — every lettered tile expanded by its
|
||||
// count, plus one entry per blank — decoded for the first-move seeding draw. The
|
||||
// order is deterministic (alphabet order, blanks last); callers shuffle it with
|
||||
// their own entropy. It needs no dictionary, so it is built from the variant's
|
||||
// ruleset alone and reports ErrUnknownVariant for an unrecognised variant.
|
||||
func SetupBag(v Variant) ([]SetupTile, error) {
|
||||
rs, ok := v.ruleset()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
bag := make([]SetupTile, 0, 128)
|
||||
for i, n := range rs.Counts {
|
||||
ch, err := rs.Alphabet.Character(byte(i))
|
||||
if err != nil {
|
||||
// An offered variant's alphabet never yields a bad index; skip defensively.
|
||||
continue
|
||||
}
|
||||
for range n {
|
||||
bag = append(bag, SetupTile{Letter: ch, Rank: i})
|
||||
}
|
||||
}
|
||||
for range rs.Blanks {
|
||||
bag = append(bag, SetupTile{Letter: blankLetter, Blank: true, Rank: BlankRank})
|
||||
}
|
||||
return bag, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetupBagEnglish(t *testing.T) {
|
||||
bag, err := SetupBag(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("SetupBag: %v", err)
|
||||
}
|
||||
// English Scrabble: 98 lettered tiles + 2 blanks = 100.
|
||||
if len(bag) != 100 {
|
||||
t.Fatalf("bag size = %d, want 100", len(bag))
|
||||
}
|
||||
blanks, aCount := 0, 0
|
||||
for _, tl := range bag {
|
||||
switch {
|
||||
case tl.Blank:
|
||||
blanks++
|
||||
if tl.Rank != BlankRank {
|
||||
t.Errorf("blank rank = %d, want %d", tl.Rank, BlankRank)
|
||||
}
|
||||
if tl.Letter != blankLetter {
|
||||
t.Errorf("blank letter = %q, want %q", tl.Letter, blankLetter)
|
||||
}
|
||||
case tl.Letter == "a":
|
||||
aCount++
|
||||
if tl.Rank != 0 {
|
||||
t.Errorf("'a' rank = %d, want 0 (closest to A)", tl.Rank)
|
||||
}
|
||||
}
|
||||
}
|
||||
if blanks != 2 {
|
||||
t.Errorf("blanks = %d, want 2", blanks)
|
||||
}
|
||||
if aCount != 9 {
|
||||
t.Errorf("'a' count = %d, want 9", aCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupBagUnknownVariant(t *testing.T) {
|
||||
if _, err := SetupBag(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Fatalf("err = %v, want ErrUnknownVariant", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user