aa137e3558
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s
New scrabble/loadtest module (the pre-release stress harness): seeds 1000 guest + 10000 durable accounts with pre-created sessions directly in Postgres (token hash matches backend/internal/session), drives virtual players through the edge protocol (real 2-4p games assembled via invitations, mid-ranked legal moves generated locally by the embedded scrabble-solver — the edge carries no board, so the client replays history), plus nudge/chat/check-word/draft/profile/stats and a gateway-hammer that verifies the rate limiter. Prints a trip-report summary (per-op latency percentiles, result codes, live-event tally). Go unit tests cover the pure pieces; the DAWG-backed move test runs under BACKEND_DICT_DIR. Contour: add cAdvisor + postgres_exporter + a 'Scrabble - Resources' Grafana dashboard and the two Prometheus scrape jobs, for the R2/R7 stress-run resource baseline. CI: gate ./loadtest/... (path filter + vet/build/test). Docs: TESTING, ARCHITECTURE, project CLAUDE repo layout.
187 lines
4.3 KiB
Go
187 lines
4.3 KiB
Go
package edge
|
|
|
|
import (
|
|
fb "scrabble/pkg/fbs/scrabblefb"
|
|
)
|
|
|
|
// Game is the decoded non-private game summary the driver needs to decide a turn.
|
|
type Game struct {
|
|
ID string
|
|
Variant string
|
|
DictVer string
|
|
Status string
|
|
Players int
|
|
ToMove int
|
|
MoveCount int
|
|
Seats []string // account ids in seat order
|
|
}
|
|
|
|
// Active reports whether the game is still in progress.
|
|
func (g Game) Active() bool { return g.Status == "active" }
|
|
|
|
// SeatOf returns the seat index of accountID, or -1 if it is not seated.
|
|
func (g Game) SeatOf(accountID string) int {
|
|
for i, id := range g.Seats {
|
|
if id == accountID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// State is a player's private view: the shared game plus their seat, rack (alphabet
|
|
// indices; 255 a blank) and bag size.
|
|
type State struct {
|
|
Game Game
|
|
Seat int
|
|
Rack []byte
|
|
BagLen int
|
|
}
|
|
|
|
// Tile is one placed tile from a decoded history record (concrete letter, blank flag).
|
|
type Tile struct {
|
|
Row, Col int
|
|
Letter string
|
|
Blank bool
|
|
}
|
|
|
|
// Move is one decoded history record (a committed play carries Tiles; pass/exchange
|
|
// carry only Action).
|
|
type Move struct {
|
|
Action string
|
|
Dir string
|
|
Tiles []Tile
|
|
}
|
|
|
|
// Invitation is the decoded subset the assembler matches on.
|
|
type Invitation struct {
|
|
ID string
|
|
InviterID string
|
|
Status string
|
|
GameID string
|
|
}
|
|
|
|
func decodeGameView(gv *fb.GameView) Game {
|
|
g := Game{
|
|
ID: string(gv.Id()),
|
|
Variant: string(gv.Variant()),
|
|
DictVer: string(gv.DictVersion()),
|
|
Status: string(gv.Status()),
|
|
Players: int(gv.Players()),
|
|
ToMove: int(gv.ToMove()),
|
|
MoveCount: int(gv.MoveCount()),
|
|
}
|
|
n := gv.SeatsLength()
|
|
g.Seats = make([]string, n)
|
|
var sv fb.SeatView
|
|
for j := 0; j < n; j++ {
|
|
if gv.Seats(&sv, j) {
|
|
g.Seats[sv.Seat()] = string(sv.AccountId())
|
|
}
|
|
}
|
|
return g
|
|
}
|
|
|
|
// decodeState reads a StateView payload.
|
|
func decodeState(payload []byte) State {
|
|
sv := fb.GetRootAsStateView(payload, 0)
|
|
var gv fb.GameView
|
|
st := State{
|
|
Seat: int(sv.Seat()),
|
|
BagLen: int(sv.BagLen()),
|
|
Rack: append([]byte(nil), sv.RackBytes()...),
|
|
}
|
|
if g := sv.Game(&gv); g != nil {
|
|
st.Game = decodeGameView(g)
|
|
}
|
|
return st
|
|
}
|
|
|
|
// decodeHistory reads a History payload into the decoded move journal.
|
|
func decodeHistory(payload []byte) []Move {
|
|
h := fb.GetRootAsHistory(payload, 0)
|
|
n := h.MovesLength()
|
|
moves := make([]Move, 0, n)
|
|
var mr fb.MoveRecord
|
|
for j := 0; j < n; j++ {
|
|
if !h.Moves(&mr, j) {
|
|
continue
|
|
}
|
|
m := Move{Action: string(mr.Action()), Dir: string(mr.Dir())}
|
|
tn := mr.TilesLength()
|
|
m.Tiles = make([]Tile, 0, tn)
|
|
var tr fb.TileRecord
|
|
for k := 0; k < tn; k++ {
|
|
if mr.Tiles(&tr, k) {
|
|
m.Tiles = append(m.Tiles, Tile{
|
|
Row: int(tr.Row()), Col: int(tr.Col()),
|
|
Letter: string(tr.Letter()), Blank: tr.Blank(),
|
|
})
|
|
}
|
|
}
|
|
moves = append(moves, m)
|
|
}
|
|
return moves
|
|
}
|
|
|
|
// decodeMoveResultGame reads a MoveResult payload and returns its post-move game.
|
|
func decodeMoveResultGame(payload []byte) Game {
|
|
mr := fb.GetRootAsMoveResult(payload, 0)
|
|
var gv fb.GameView
|
|
if g := mr.Game(&gv); g != nil {
|
|
return decodeGameView(g)
|
|
}
|
|
return Game{}
|
|
}
|
|
|
|
// decodeGameList reads a GameList payload.
|
|
func decodeGameList(payload []byte) []Game {
|
|
gl := fb.GetRootAsGameList(payload, 0)
|
|
n := gl.GamesLength()
|
|
games := make([]Game, 0, n)
|
|
var gv fb.GameView
|
|
for j := 0; j < n; j++ {
|
|
if gl.Games(&gv, j) {
|
|
games = append(games, decodeGameView(&gv))
|
|
}
|
|
}
|
|
return games
|
|
}
|
|
|
|
// decodeInvitationList reads an InvitationList payload into the matched subset.
|
|
func decodeInvitationList(payload []byte) []Invitation {
|
|
il := fb.GetRootAsInvitationList(payload, 0)
|
|
n := il.InvitationsLength()
|
|
out := make([]Invitation, 0, n)
|
|
var inv fb.Invitation
|
|
var ref fb.AccountRef
|
|
for j := 0; j < n; j++ {
|
|
if !il.Invitations(&inv, j) {
|
|
continue
|
|
}
|
|
iv := Invitation{
|
|
ID: string(inv.Id()),
|
|
Status: string(inv.Status()),
|
|
GameID: string(inv.GameId()),
|
|
}
|
|
if r := inv.Inviter(&ref); r != nil {
|
|
iv.InviterID = string(r.AccountId())
|
|
}
|
|
out = append(out, iv)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// decodeMatch reads a MatchResult payload.
|
|
func decodeMatch(payload []byte) (matched bool, game Game) {
|
|
mr := fb.GetRootAsMatchResult(payload, 0)
|
|
if !mr.Matched() {
|
|
return false, Game{}
|
|
}
|
|
var gv fb.GameView
|
|
if g := mr.Game(&gv); g != nil {
|
|
return true, decodeGameView(g)
|
|
}
|
|
return true, Game{}
|
|
}
|