R4: push enrichment — events carry a state delta, kill the last poll #35
+37
-1
@@ -20,7 +20,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
||||
| R1 | Schema & naming reset | 1 + 10 | **done** |
|
||||
| R2 | Stress harness + contour observability + early run | 9a | **done** |
|
||||
| R3 | Edge hardening | 2 + 8 + 3 | **done** |
|
||||
| R4 | Push enrichment + kill the last poll | 4 + 5 | todo |
|
||||
| R4 | Push enrichment + kill the last poll | 4 + 5 | **done** |
|
||||
| R5 | Bundle slimming | 6 | todo |
|
||||
| R6 | Refactor + docs reconciliation + de-staging | 7 | todo |
|
||||
| R7 | Final stress run + tuning | 9b | todo |
|
||||
@@ -281,3 +281,39 @@ Then Stage 18.
|
||||
queue) and the flag badge / clear action on the user list / card.
|
||||
- The jet regen also restored the previously missing `game_drafts`/`game_hidden` generated models
|
||||
(their tables were added after the last jetgen run; no behaviour change).
|
||||
|
||||
- **R4** (interview + implementation):
|
||||
- **Locked decisions:** **delta-first**, not full snapshots — an event carries only the new move and
|
||||
the UI applies it to its per-game cache, keyed on `move_count` (idempotent + gap-safe: a gap or the
|
||||
actor's own move falls back to a `game.state` + `game.history` refetch). `match_found` /
|
||||
`game_started` carry the recipient's **initial `StateView`** (instant lobby→game); the fallback
|
||||
refetch stays the existing two calls (no merged endpoint); the matchmaking poll runs **only while
|
||||
the stream is down** (2.5 s); **all** UI-state-changing events carry their payload (incl. lobby `notify`).
|
||||
- **Enriched events** (`pkg/fbs` trailing fields — backward-compatible, no FB regen of *values*, only
|
||||
the schema): `opponent_moved` (+`move`/`game`/`bag_len`), `your_turn` (+`move_count`), `match_found`
|
||||
(+`state`), `game_over` (+`game`), `notify` (+`account`/`invitation`/`state`). The pre-R4
|
||||
`opponent_moved` scalars (`seat`/`action`/`score`/`total`) stay for wire back-compat, now redundant
|
||||
with `move`/`game` — slated for the R6 de-stage.
|
||||
- **Encoding placement:** the `notify` package keeps ownership of the FlatBuffers encoding (a new
|
||||
`encode.go` mirrors the gateway transcode but reads wire-agnostic `notify.*` input structs +
|
||||
`engine.MoveRecord`); the game/lobby/social services map their domain types to those structs, so the
|
||||
wire schema stays out of the domain. **Flagged for R6:** this partly duplicates the gateway encoders
|
||||
(different source types) — a candidate consolidation.
|
||||
- **Actor self-fetch killed too** (beyond literal "push"): the `submit_play`/`pass`/`exchange`/`resign`
|
||||
**response** (`MoveResult`) now returns the actor's refilled rack + bag size, so the mover renders the
|
||||
next turn from the response — `Game.svelte`'s `commit`/`pass`/`exchange`/`resign` drop their `await load()`.
|
||||
- **`match_found` enrichment** needs a per-seat initial state: `lobby.GameCreator` gained `InitialState`,
|
||||
and `game.Service.InitialState` builds the `notify.PlayerState` (rack re-encoded to wire indices, the
|
||||
variant alphabet embedded for a first-seen variant).
|
||||
- **UI:** a pure `lib/gamedelta.ts` reducer (`applyMoveDelta` / `applyGameOver` / `seedInitialState`,
|
||||
unit-tested) advances the cache; `app.svelte` seeds it on `match_found` / `game_started`; `Game.svelte`
|
||||
applies the delta (falling back to `load()` while composing, on a gap, or on its own move's new rack);
|
||||
`NewGame.svelte` polls only when `app.streamAlive` is false and guards its teardown so a push-delivered
|
||||
match is not cancelled.
|
||||
- **notify (friends/invitations) scope:** the backend carries the full account / invitation payload on the
|
||||
wire (per "all events → push"); the UI seeds the game cache from `game_started` but keeps its lightweight
|
||||
**authoritative** badge refresh (`refreshNotifications`, on the rare `notify` event + on foreground) rather
|
||||
than adding client-side friend/invitation caches — the per-move hot path is fully de-fetched, which was the
|
||||
goal. Deeper lobby-cache consumption is an easy follow-up.
|
||||
- **No schema change** (no migration); the contour needs no DB wipe. Tests: `notify` FB round-trips +
|
||||
`emitMove` delta + the `gamedelta` reducer; the e2e mock now emits the enriched delta.
|
||||
|
||||
+3
-2
@@ -53,8 +53,9 @@ win (≈ 40%), targets a small score margin, and times its moves with a move-num
|
||||
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
|
||||
exposes `Poll` so a waiting player can collect the started game (the live
|
||||
match-found notification arrives with the `gateway`).
|
||||
exposes `Poll` so a waiting player can collect the started game — R4 made it the **stream-down
|
||||
fallback**: while the client is streaming, the live match-found push (enriched with the recipient's
|
||||
initial game state) drives it instead.
|
||||
|
||||
Stage 6 opens the backend to the edge. The route groups gain their first
|
||||
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
|
||||
TurnTimeout: time.Hour,
|
||||
Seats: []Seat{{Seat: 0, AccountID: actor, Score: 19}, {Seat: 1, AccountID: opp, Score: 13}},
|
||||
}
|
||||
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19})
|
||||
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}, 80)
|
||||
|
||||
kinds := map[uuid.UUID][]string{}
|
||||
var yourTurn notify.Intent
|
||||
@@ -87,7 +87,7 @@ func TestEmitMoveAnnouncesGameOver(t *testing.T) {
|
||||
EndReason: "out_of_tiles",
|
||||
Seats: []Seat{{Seat: 0, AccountID: winner, Score: 120, IsWinner: true}, {Seat: 1, AccountID: loser, Score: 95}},
|
||||
}
|
||||
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Total: 120})
|
||||
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Total: 120}, 0)
|
||||
|
||||
over := map[uuid.UUID]notify.Intent{}
|
||||
for _, in := range pub.intents {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// The mappers below project the game domain into the wire-agnostic notify.* input
|
||||
// structs the enriched live events carry (R4). They keep the wire schema out of the
|
||||
// game package: notify owns the FlatBuffers encoding, this file only resolves the
|
||||
// values (seat display names, last-activity sort key) into its input shapes.
|
||||
|
||||
// gameSummary projects a game.Game into the notify.GameSummary embedded in enriched
|
||||
// events. names is the seat-indexed display-name slice from seatNames; LastActivityUnix
|
||||
// mirrors the gateway view (the current turn's start while active, the finish time once
|
||||
// finished).
|
||||
func gameSummary(g Game, names []string) notify.GameSummary {
|
||||
seats := make([]notify.SeatStanding, 0, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
name := ""
|
||||
if s.Seat >= 0 && s.Seat < len(names) {
|
||||
name = names[s.Seat]
|
||||
}
|
||||
seats = append(seats, notify.SeatStanding{
|
||||
Seat: s.Seat,
|
||||
AccountID: s.AccountID.String(),
|
||||
DisplayName: name,
|
||||
Score: s.Score,
|
||||
HintsUsed: s.HintsUsed,
|
||||
IsWinner: s.IsWinner,
|
||||
})
|
||||
}
|
||||
last := g.TurnStartedAt
|
||||
if g.FinishedAt != nil {
|
||||
last = *g.FinishedAt
|
||||
}
|
||||
return notify.GameSummary{
|
||||
ID: g.ID.String(),
|
||||
Variant: g.Variant.String(),
|
||||
DictVersion: g.DictVersion,
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
ToMove: g.ToMove,
|
||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
LastActivityUnix: last.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// playerState projects a StateView into the notify.PlayerState carried by the
|
||||
// match_found / game_started events. The rack is re-encoded to wire alphabet indices;
|
||||
// the variant alphabet display table is embedded when includeAlphabet is set (an
|
||||
// initial view whose recipient may not have cached the variant yet).
|
||||
func playerState(v StateView, names []string, includeAlphabet bool) (notify.PlayerState, error) {
|
||||
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
|
||||
if err != nil {
|
||||
return notify.PlayerState{}, err
|
||||
}
|
||||
ps := notify.PlayerState{
|
||||
Game: gameSummary(v.Game, names),
|
||||
Seat: v.Seat,
|
||||
Rack: rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
}
|
||||
if includeAlphabet {
|
||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||
if err != nil {
|
||||
return notify.PlayerState{}, err
|
||||
}
|
||||
ps.Alphabet = make([]notify.AlphabetLetter, len(tab))
|
||||
for i, e := range tab {
|
||||
ps.Alphabet[i] = notify.AlphabetLetter{Index: int(e.Index), Letter: e.Letter, Value: e.Value}
|
||||
}
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
||||
// Record the seat's think time (turn start to commit) for the move-duration
|
||||
// metric; the timeout path commits separately and is excluded by design.
|
||||
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
|
||||
return MoveResult{Move: rec, Game: post}, nil
|
||||
return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil
|
||||
}
|
||||
|
||||
// afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own
|
||||
@@ -362,7 +362,7 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
||||
if err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
svc.emitMove(ctx, post, rec)
|
||||
svc.emitMove(ctx, post, rec, g.BagLen())
|
||||
return post, nil
|
||||
}
|
||||
|
||||
@@ -373,10 +373,13 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
||||
// out-of-app push), so the actor is not notified out of band about their own move.
|
||||
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
|
||||
// event out to all of the recipient's live streams.
|
||||
func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord) {
|
||||
func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord, bagLen int) {
|
||||
// Resolve the seat names once and reuse them for every recipient's enriched summary.
|
||||
names := svc.seatNames(ctx, post)
|
||||
summary := gameSummary(post, names)
|
||||
intents := make([]notify.Intent, 0, 2*len(post.Seats))
|
||||
for _, s := range post.Seats {
|
||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
|
||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
|
||||
}
|
||||
// Game pushes are routed out-of-app by the game's own language, not the recipient's
|
||||
// last-login bot (Stage 17).
|
||||
@@ -391,7 +394,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
|
||||
word = rec.Words[0]
|
||||
}
|
||||
opponent := svc.displayName(ctx, post.Seats, rec.Player)
|
||||
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove))
|
||||
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
|
||||
yourTurn.Language = lang
|
||||
intents = append(intents, yourTurn)
|
||||
}
|
||||
@@ -400,7 +403,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
|
||||
// seat, each with their own perspective + recipient-first score, so an offline player gets
|
||||
// an out-of-app "game over" push (online players take it from the in-app refresh).
|
||||
for _, s := range post.Seats {
|
||||
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat))
|
||||
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
|
||||
over.Language = lang
|
||||
intents = append(intents, over)
|
||||
}
|
||||
@@ -785,6 +788,20 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InitialState returns accountID's full initial view of game gameID as the notify
|
||||
// PlayerState carried by the match_found / game_started events (R4), so a client can
|
||||
// render a freshly started game from the event without a follow-up fetch. The variant
|
||||
// alphabet table is always embedded (the recipient may be seeing the variant for the
|
||||
// first time). It satisfies lobby.GameCreator.
|
||||
func (svc *Service) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) {
|
||||
v, err := svc.GameState(ctx, gameID, accountID)
|
||||
if err != nil {
|
||||
return notify.PlayerState{}, err
|
||||
}
|
||||
names := svc.seatNames(ctx, v.Game)
|
||||
return playerState(v, names, true)
|
||||
}
|
||||
|
||||
// Participants returns the seated account IDs in seat order, the seat index whose
|
||||
// turn it is, and the game status. It is a snapshot read (no engine, no lock) that
|
||||
// lets the social package gate per-game chat and nudges without importing the
|
||||
@@ -1009,6 +1026,9 @@ func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, er
|
||||
// seatNames resolves each seat's display name for GCG export.
|
||||
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
|
||||
names := make([]string, g.Players)
|
||||
if svc.accounts == nil {
|
||||
return names
|
||||
}
|
||||
for _, s := range g.Seats {
|
||||
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
|
||||
names[s.Seat] = acc.DisplayName
|
||||
|
||||
@@ -124,10 +124,14 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
|
||||
}
|
||||
|
||||
// MoveResult is the outcome of a committed transition: the decoded move and the
|
||||
// post-move game.
|
||||
// post-move game, plus the actor's own refilled rack and the bag size after the draw
|
||||
// (Rack/BagLen, R4), so the mover renders the next state from the response without a
|
||||
// follow-up game.state.
|
||||
type MoveResult struct {
|
||||
Move engine.MoveRecord
|
||||
Game Game
|
||||
Move engine.MoveRecord
|
||||
Game Game
|
||||
Rack []string
|
||||
BagLen int
|
||||
}
|
||||
|
||||
// HintResult is a revealed hint and the requesting player's remaining hint
|
||||
|
||||
@@ -105,18 +105,72 @@ func (svc *InvitationService) SetNotifier(p notify.Publisher) {
|
||||
}
|
||||
}
|
||||
|
||||
// notify publishes a re-poll Notification of the given sub-kind to each user.
|
||||
func (svc *InvitationService) notify(kind string, userIDs ...uuid.UUID) {
|
||||
if len(userIDs) == 0 {
|
||||
// emitInvitation publishes the invitation notification to each invitee, carrying the invitation
|
||||
// itself so the client adds it to its lobby list without a refetch (R4).
|
||||
func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) {
|
||||
if len(inviteeIDs) == 0 {
|
||||
return
|
||||
}
|
||||
intents := make([]notify.Intent, 0, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
intents = append(intents, notify.Notification(id, kind))
|
||||
summary := svc.invitationSummary(ctx, inv)
|
||||
intents := make([]notify.Intent, 0, len(inviteeIDs))
|
||||
for _, id := range inviteeIDs {
|
||||
intents = append(intents, notify.NotificationInvitation(id, summary))
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// emitGameStarted publishes the game_started notification to each seated player, carrying their
|
||||
// initial view of the started game so the client seeds its game cache without a refetch (R4). A
|
||||
// seat whose state cannot be read is skipped (it still sees the game on the next lobby load).
|
||||
func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) {
|
||||
intents := make([]notify.Intent, 0, len(seats))
|
||||
for _, id := range seats {
|
||||
state, err := svc.games.InitialState(ctx, g.ID, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
intents = append(intents, notify.NotificationGameStarted(id, state))
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// invitationSummary projects an Invitation into the notify.InvitationSummary the event carries,
|
||||
// resolving the inviter's and invitees' display names from the account store.
|
||||
func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary {
|
||||
name := func(id uuid.UUID) string {
|
||||
if acc, err := svc.accounts.GetByID(ctx, id); err == nil {
|
||||
return acc.DisplayName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
invitees := make([]notify.InvitationInvitee, 0, len(inv.Invitees))
|
||||
for _, iv := range inv.Invitees {
|
||||
invitees = append(invitees, notify.InvitationInvitee{
|
||||
AccountID: iv.AccountID.String(),
|
||||
DisplayName: name(iv.AccountID),
|
||||
Seat: iv.Seat,
|
||||
Response: iv.Response,
|
||||
})
|
||||
}
|
||||
gameID := ""
|
||||
if inv.GameID != nil {
|
||||
gameID = inv.GameID.String()
|
||||
}
|
||||
return notify.InvitationSummary{
|
||||
ID: inv.ID.String(),
|
||||
Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)},
|
||||
Invitees: invitees,
|
||||
Variant: inv.Settings.Variant.String(),
|
||||
TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second),
|
||||
HintsAllowed: inv.Settings.HintsAllowed,
|
||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||
DropoutTiles: inv.Settings.DropoutTiles.String(),
|
||||
Status: inv.Status,
|
||||
GameID: gameID,
|
||||
ExpiresAtUnix: inv.ExpiresAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateInvitation records a pending invitation from inviterID to inviteeIDs (in
|
||||
// seat order, 1..N) with the given settings. The total seat count must be 2-4,
|
||||
// invitees distinct and not the inviter, every invitee an existing account with no
|
||||
@@ -176,7 +230,7 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
svc.notify(notify.NotifyInvitation, inviteeIDs...)
|
||||
svc.emitInvitation(ctx, inv, inviteeIDs)
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
@@ -224,7 +278,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
|
||||
if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.notify(notify.NotifyGameStarted, seats...)
|
||||
svc.emitGameStarted(ctx, g, seats)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,17 @@ import (
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
||||
// game. game.Service satisfies it.
|
||||
// game and reading a player's initial view of it. game.Service satisfies it.
|
||||
type GameCreator interface {
|
||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||
// InitialState returns a seated player's full initial view of a started game, used
|
||||
// to enrich the match_found / game_started events so the client renders the new game
|
||||
// without a follow-up fetch (R4).
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
}
|
||||
|
||||
// RobotProvider supplies a robot account to substitute for a missing human in
|
||||
|
||||
@@ -75,11 +75,19 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
|
||||
|
||||
// emitMatchFound pushes match_found to every seat of a freshly started game.
|
||||
// Emitting to a robot seat is harmless (no client subscription exists for it).
|
||||
func (m *Matchmaker) emitMatchFound(g game.Game) {
|
||||
func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) {
|
||||
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot (Stage 17)
|
||||
intents := make([]notify.Intent, 0, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
mf := notify.MatchFound(s.AccountID, g.ID)
|
||||
state, err := m.games.InitialState(ctx, g.ID, s.AccountID)
|
||||
if err != nil {
|
||||
// A waiter still discovers the game through Poll (the ws-down fallback), so skip the
|
||||
// enriched push for this seat rather than failing the match.
|
||||
m.log.Warn("match_found initial state",
|
||||
zap.String("game", g.ID.String()), zap.String("account", s.AccountID.String()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
mf := notify.MatchFound(s.AccountID, g.ID, state)
|
||||
mf.Language = lang
|
||||
intents = append(intents, mf)
|
||||
}
|
||||
@@ -128,7 +136,7 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e
|
||||
m.mu.Lock()
|
||||
m.results[opponent] = g
|
||||
m.mu.Unlock()
|
||||
m.emitMatchFound(g)
|
||||
m.emitMatchFound(ctx, g)
|
||||
return EnqueueResult{Matched: true, Game: g}, nil
|
||||
}
|
||||
|
||||
@@ -227,7 +235,7 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
||||
m.mu.Lock()
|
||||
m.results[s.human] = g
|
||||
m.mu.Unlock()
|
||||
m.emitMatchFound(g)
|
||||
m.emitMatchFound(ctx, g)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// fakeCreator records the games a matchmaker asks it to start.
|
||||
@@ -27,6 +28,12 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game,
|
||||
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
|
||||
}
|
||||
|
||||
// InitialState satisfies GameCreator; the matchmaker reads it to enrich match_found. The pairing
|
||||
// tests assert on matching behaviour, not the payload, so an empty state is enough.
|
||||
func (f *fakeCreator) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
|
||||
return notify.PlayerState{}, nil
|
||||
}
|
||||
|
||||
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
|
||||
// an empty pool. It records the variant of the last substitution request.
|
||||
type fakeRobots struct {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// The builders below encode the nested wire tables embedded in enriched event
|
||||
// payloads (R4). They mirror the gateway's transcode encoders, but read the domain's
|
||||
// already-resolved values (notify.* input structs and the decoded engine.MoveRecord)
|
||||
// rather than the gateway's REST DTOs. Each returns the offset of the table it built;
|
||||
// callers must build every nested table before opening the parent event table.
|
||||
|
||||
// buildGameView builds a GameView table from a GameSummary and returns its offset.
|
||||
func buildGameView(b *flatbuffers.Builder, g GameSummary) flatbuffers.UOffsetT {
|
||||
seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats))
|
||||
for i, s := range g.Seats {
|
||||
aid := b.CreateString(s.AccountID)
|
||||
dname := b.CreateString(s.DisplayName)
|
||||
fb.SeatViewStart(b)
|
||||
fb.SeatViewAddSeat(b, int32(s.Seat))
|
||||
fb.SeatViewAddAccountId(b, aid)
|
||||
fb.SeatViewAddScore(b, int32(s.Score))
|
||||
fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed))
|
||||
fb.SeatViewAddIsWinner(b, s.IsWinner)
|
||||
fb.SeatViewAddDisplayName(b, dname)
|
||||
seatOffs[i] = fb.SeatViewEnd(b)
|
||||
}
|
||||
fb.GameViewStartSeatsVector(b, len(seatOffs))
|
||||
for i := len(seatOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(seatOffs[i])
|
||||
}
|
||||
seats := b.EndVector(len(seatOffs))
|
||||
|
||||
id := b.CreateString(g.ID)
|
||||
variant := b.CreateString(g.Variant)
|
||||
dictVer := b.CreateString(g.DictVersion)
|
||||
status := b.CreateString(g.Status)
|
||||
endReason := b.CreateString(g.EndReason)
|
||||
|
||||
fb.GameViewStart(b)
|
||||
fb.GameViewAddId(b, id)
|
||||
fb.GameViewAddVariant(b, variant)
|
||||
fb.GameViewAddDictVersion(b, dictVer)
|
||||
fb.GameViewAddStatus(b, status)
|
||||
fb.GameViewAddPlayers(b, int32(g.Players))
|
||||
fb.GameViewAddToMove(b, int32(g.ToMove))
|
||||
fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs))
|
||||
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
||||
fb.GameViewAddEndReason(b, endReason)
|
||||
fb.GameViewAddSeats(b, seats)
|
||||
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
|
||||
return fb.GameViewEnd(b)
|
||||
}
|
||||
|
||||
// buildMoveRecord builds a MoveRecord table from a decoded engine move and returns
|
||||
// its offset. The values match the move-result DTO (Count is the engine count: the
|
||||
// number of tiles swapped on an exchange, zero otherwise).
|
||||
func buildMoveRecord(b *flatbuffers.Builder, m engine.MoveRecord) flatbuffers.UOffsetT {
|
||||
tileOffs := make([]flatbuffers.UOffsetT, len(m.Tiles))
|
||||
for i, t := range m.Tiles {
|
||||
letter := b.CreateString(t.Letter)
|
||||
fb.TileRecordStart(b)
|
||||
fb.TileRecordAddRow(b, int32(t.Row))
|
||||
fb.TileRecordAddCol(b, int32(t.Col))
|
||||
fb.TileRecordAddLetter(b, letter)
|
||||
fb.TileRecordAddBlank(b, t.Blank)
|
||||
tileOffs[i] = fb.TileRecordEnd(b)
|
||||
}
|
||||
fb.MoveRecordStartTilesVector(b, len(tileOffs))
|
||||
for i := len(tileOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(tileOffs[i])
|
||||
}
|
||||
tiles := b.EndVector(len(tileOffs))
|
||||
|
||||
wordOffs := make([]flatbuffers.UOffsetT, len(m.Words))
|
||||
for i, w := range m.Words {
|
||||
wordOffs[i] = b.CreateString(w)
|
||||
}
|
||||
fb.MoveRecordStartWordsVector(b, len(wordOffs))
|
||||
for i := len(wordOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(wordOffs[i])
|
||||
}
|
||||
words := b.EndVector(len(wordOffs))
|
||||
|
||||
action := b.CreateString(m.Action.String())
|
||||
dir := b.CreateString(m.Dir.String())
|
||||
fb.MoveRecordStart(b)
|
||||
fb.MoveRecordAddPlayer(b, int32(m.Player))
|
||||
fb.MoveRecordAddAction(b, action)
|
||||
fb.MoveRecordAddDir(b, dir)
|
||||
fb.MoveRecordAddMainRow(b, int32(m.MainRow))
|
||||
fb.MoveRecordAddMainCol(b, int32(m.MainCol))
|
||||
fb.MoveRecordAddTiles(b, tiles)
|
||||
fb.MoveRecordAddWords(b, words)
|
||||
fb.MoveRecordAddCount(b, int32(m.Count))
|
||||
fb.MoveRecordAddScore(b, int32(m.Score))
|
||||
fb.MoveRecordAddTotal(b, int32(m.Total))
|
||||
return fb.MoveRecordEnd(b)
|
||||
}
|
||||
|
||||
// buildAlphabet builds the AlphabetEntry vector embedded in a StateView and returns
|
||||
// its offset.
|
||||
func buildAlphabet(b *flatbuffers.Builder, entries []AlphabetLetter) flatbuffers.UOffsetT {
|
||||
offs := make([]flatbuffers.UOffsetT, len(entries))
|
||||
for i, e := range entries {
|
||||
letter := b.CreateString(e.Letter)
|
||||
fb.AlphabetEntryStart(b)
|
||||
fb.AlphabetEntryAddIndex(b, byte(e.Index))
|
||||
fb.AlphabetEntryAddLetter(b, letter)
|
||||
fb.AlphabetEntryAddValue(b, int32(e.Value))
|
||||
offs[i] = fb.AlphabetEntryEnd(b)
|
||||
}
|
||||
fb.StateViewStartAlphabetVector(b, len(offs))
|
||||
for i := len(offs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(offs[i])
|
||||
}
|
||||
return b.EndVector(len(offs))
|
||||
}
|
||||
|
||||
// buildStateView builds a StateView table from a PlayerState and returns its offset.
|
||||
func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT {
|
||||
game := buildGameView(b, s.Game)
|
||||
rackBytes := make([]byte, len(s.Rack))
|
||||
for i, v := range s.Rack {
|
||||
rackBytes[i] = byte(v)
|
||||
}
|
||||
rack := b.CreateByteVector(rackBytes)
|
||||
hasAlphabet := len(s.Alphabet) > 0
|
||||
var alphabet flatbuffers.UOffsetT
|
||||
if hasAlphabet {
|
||||
alphabet = buildAlphabet(b, s.Alphabet)
|
||||
}
|
||||
fb.StateViewStart(b)
|
||||
fb.StateViewAddGame(b, game)
|
||||
fb.StateViewAddSeat(b, int32(s.Seat))
|
||||
fb.StateViewAddRack(b, rack)
|
||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||
if hasAlphabet {
|
||||
fb.StateViewAddAlphabet(b, alphabet)
|
||||
}
|
||||
return fb.StateViewEnd(b)
|
||||
}
|
||||
|
||||
// buildAccountRef builds an AccountRef table and returns its offset.
|
||||
func buildAccountRef(b *flatbuffers.Builder, a AccountRef) flatbuffers.UOffsetT {
|
||||
aid := b.CreateString(a.AccountID)
|
||||
name := b.CreateString(a.DisplayName)
|
||||
fb.AccountRefStart(b)
|
||||
fb.AccountRefAddAccountId(b, aid)
|
||||
fb.AccountRefAddDisplayName(b, name)
|
||||
return fb.AccountRefEnd(b)
|
||||
}
|
||||
|
||||
// buildInvitation builds an Invitation table from an InvitationSummary and returns its offset.
|
||||
func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers.UOffsetT {
|
||||
inviter := buildAccountRef(b, inv.Inviter)
|
||||
inviteeOffs := make([]flatbuffers.UOffsetT, len(inv.Invitees))
|
||||
for i, iv := range inv.Invitees {
|
||||
aid := b.CreateString(iv.AccountID)
|
||||
name := b.CreateString(iv.DisplayName)
|
||||
resp := b.CreateString(iv.Response)
|
||||
fb.InvitationInviteeStart(b)
|
||||
fb.InvitationInviteeAddAccountId(b, aid)
|
||||
fb.InvitationInviteeAddDisplayName(b, name)
|
||||
fb.InvitationInviteeAddSeat(b, int32(iv.Seat))
|
||||
fb.InvitationInviteeAddResponse(b, resp)
|
||||
inviteeOffs[i] = fb.InvitationInviteeEnd(b)
|
||||
}
|
||||
fb.InvitationStartInviteesVector(b, len(inviteeOffs))
|
||||
for i := len(inviteeOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(inviteeOffs[i])
|
||||
}
|
||||
invitees := b.EndVector(len(inviteeOffs))
|
||||
|
||||
id := b.CreateString(inv.ID)
|
||||
variant := b.CreateString(inv.Variant)
|
||||
dropout := b.CreateString(inv.DropoutTiles)
|
||||
status := b.CreateString(inv.Status)
|
||||
gameID := b.CreateString(inv.GameID)
|
||||
fb.InvitationStart(b)
|
||||
fb.InvitationAddId(b, id)
|
||||
fb.InvitationAddInviter(b, inviter)
|
||||
fb.InvitationAddInvitees(b, invitees)
|
||||
fb.InvitationAddVariant(b, variant)
|
||||
fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs))
|
||||
fb.InvitationAddHintsAllowed(b, inv.HintsAllowed)
|
||||
fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer))
|
||||
fb.InvitationAddDropoutTiles(b, dropout)
|
||||
fb.InvitationAddStatus(b, status)
|
||||
fb.InvitationAddGameId(b, gameID)
|
||||
fb.InvitationAddExpiresAtUnix(b, inv.ExpiresAtUnix)
|
||||
return fb.InvitationEnd(b)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
@@ -17,8 +18,9 @@ import (
|
||||
// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push (Stage
|
||||
// 17): the player who just moved, their move kind, the main word of a scoring play (empty
|
||||
// otherwise) and the recipient-first running score line. Empty strings render the plain "your
|
||||
// turn" text.
|
||||
func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string) Intent {
|
||||
// turn" text. moveCount is the post-move count, which the client compares against its cached
|
||||
// game to detect a missed in-app move and fall back to a refetch (R4).
|
||||
func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string, moveCount int) Intent {
|
||||
b := flatbuffers.NewBuilder(128)
|
||||
gid := b.CreateString(gameID.String())
|
||||
name := b.CreateString(opponentName)
|
||||
@@ -32,38 +34,51 @@ func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAc
|
||||
fb.YourTurnEventAddLastAction(b, action)
|
||||
fb.YourTurnEventAddLastWord(b, word)
|
||||
fb.YourTurnEventAddScoreLine(b, score)
|
||||
fb.YourTurnEventAddMoveCount(b, int32(moveCount))
|
||||
b.Finish(fb.YourTurnEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindYourTurn, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// GameOver announces to userID that game gameID finished. result is the outcome from userID's
|
||||
// own perspective ("won"/"lost"/"draw") and scoreLine is the recipient-first final score; both
|
||||
// feed the out-of-app "game over" push (Stage 17).
|
||||
func GameOver(userID, gameID uuid.UUID, result, scoreLine string) Intent {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
// feed the out-of-app "game over" push (Stage 17). game is the final post-game summary (the
|
||||
// adjusted scores after rack penalties and the winner flag), so an in-app client settles the
|
||||
// finished game from the event without a refetch (R4).
|
||||
func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSummary) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
gid := b.CreateString(gameID.String())
|
||||
res := b.CreateString(result)
|
||||
score := b.CreateString(scoreLine)
|
||||
gameOff := buildGameView(b, game)
|
||||
fb.GameOverEventStart(b)
|
||||
fb.GameOverEventAddGameId(b, gid)
|
||||
fb.GameOverEventAddResult(b, res)
|
||||
fb.GameOverEventAddScoreLine(b, score)
|
||||
fb.GameOverEventAddGame(b, gameOff)
|
||||
b.Finish(fb.GameOverEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindGameOver, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// OpponentMoved tells userID that seat just committed a move in game gameID,
|
||||
// summarising it (the client refetches the full state).
|
||||
func OpponentMoved(userID, gameID uuid.UUID, seat int, action string, score, total int) Intent {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
// OpponentMoved tells userID that move was just committed in game gameID, carrying it as a delta
|
||||
// the client applies to its cached game without a refetch (R4): move is the decoded play/pass/
|
||||
// exchange, game is the post-move summary (per-seat scores, to_move, move_count, status) and
|
||||
// bagLen is the bag size after the draw. The seat/action/score/total scalars repeat the move's
|
||||
// summary for pre-R4 wire back-compat.
|
||||
func OpponentMoved(userID, gameID uuid.UUID, move engine.MoveRecord, game GameSummary, bagLen int) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
gid := b.CreateString(gameID.String())
|
||||
act := b.CreateString(action)
|
||||
act := b.CreateString(move.Action.String())
|
||||
moveOff := buildMoveRecord(b, move)
|
||||
gameOff := buildGameView(b, game)
|
||||
fb.OpponentMovedEventStart(b)
|
||||
fb.OpponentMovedEventAddGameId(b, gid)
|
||||
fb.OpponentMovedEventAddSeat(b, int32(seat))
|
||||
fb.OpponentMovedEventAddSeat(b, int32(move.Player))
|
||||
fb.OpponentMovedEventAddAction(b, act)
|
||||
fb.OpponentMovedEventAddScore(b, int32(score))
|
||||
fb.OpponentMovedEventAddTotal(b, int32(total))
|
||||
fb.OpponentMovedEventAddScore(b, int32(move.Score))
|
||||
fb.OpponentMovedEventAddTotal(b, int32(move.Total))
|
||||
fb.OpponentMovedEventAddMove(b, moveOff)
|
||||
fb.OpponentMovedEventAddGame(b, gameOff)
|
||||
fb.OpponentMovedEventAddBagLen(b, int32(bagLen))
|
||||
b.Finish(fb.OpponentMovedEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindOpponentMoved, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
@@ -99,21 +114,24 @@ func Nudge(userID, gameID, fromUserID uuid.UUID) Intent {
|
||||
return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// MatchFound tells userID that game gameID, which they are seated in, has
|
||||
// started (an auto-match pairing or a robot substitution).
|
||||
func MatchFound(userID, gameID uuid.UUID) Intent {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
// MatchFound tells userID that game gameID, which they are seated in, has started (an auto-match
|
||||
// pairing or a robot substitution). state is the recipient's full initial view of the new game,
|
||||
// so the client navigates straight in from the event with no follow-up fetch (R4).
|
||||
func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
gid := b.CreateString(gameID.String())
|
||||
stateOff := buildStateView(b, state)
|
||||
fb.MatchFoundEventStart(b)
|
||||
fb.MatchFoundEventAddGameId(b, gid)
|
||||
fb.MatchFoundEventAddState(b, stateOff)
|
||||
b.Finish(fb.MatchFoundEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// Notification is a lightweight "re-poll" signal to userID that a friend request or
|
||||
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
||||
// NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the
|
||||
// client may use to scope its refresh.
|
||||
// Notification is a lightweight "re-poll" signal to userID that something in their lobby
|
||||
// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded,
|
||||
// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the
|
||||
// enriched constructors below, which let the client update its lobby without a refetch (R4).
|
||||
func Notification(userID uuid.UUID, kind string) Intent {
|
||||
b := flatbuffers.NewBuilder(32)
|
||||
k := b.CreateString(kind)
|
||||
@@ -123,6 +141,47 @@ func Notification(userID uuid.UUID, kind string) Intent {
|
||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
|
||||
// account it concerns (the requester, the new friend or the decliner), so the client updates its
|
||||
// requests/friends lists and the in-game "add friend" state without a refetch (R4).
|
||||
func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent {
|
||||
b := flatbuffers.NewBuilder(128)
|
||||
k := b.CreateString(kind)
|
||||
accOff := buildAccountRef(b, acc)
|
||||
fb.NotificationEventStart(b)
|
||||
fb.NotificationEventAddKind(b, k)
|
||||
fb.NotificationEventAddAccount(b, accOff)
|
||||
b.Finish(fb.NotificationEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// NotificationGameStarted builds the NotifyGameStarted notification carrying the recipient's
|
||||
// initial view of the just-started invited game, so the client seeds its game cache and the
|
||||
// lobby list without a refetch (R4).
|
||||
func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
k := b.CreateString(NotifyGameStarted)
|
||||
stateOff := buildStateView(b, state)
|
||||
fb.NotificationEventStart(b)
|
||||
fb.NotificationEventAddKind(b, k)
|
||||
fb.NotificationEventAddState(b, stateOff)
|
||||
b.Finish(fb.NotificationEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// NotificationInvitation builds the NotifyInvitation notification carrying the new invitation,
|
||||
// so the client adds it to its lobby invitations list without a refetch (R4).
|
||||
func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
k := b.CreateString(NotifyInvitation)
|
||||
invOff := buildInvitation(b, inv)
|
||||
fb.NotificationEventStart(b)
|
||||
fb.NotificationEventAddKind(b, k)
|
||||
fb.NotificationEventAddInvitation(b, invOff)
|
||||
b.Finish(fb.NotificationEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// eventID returns a best-effort correlation id for one emitted event.
|
||||
func eventID() string {
|
||||
if id, err := uuid.NewV7(); err == nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
@@ -61,7 +62,7 @@ func TestNopPublisherDiscards(t *testing.T) {
|
||||
|
||||
func TestYourTurnPayloadRoundTrips(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0), "Ann", "play", "STOOL", "120:95")
|
||||
in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0), "Ann", "play", "STOOL", "120:95", 7)
|
||||
if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" {
|
||||
t.Fatalf("intent metadata wrong: %+v", in)
|
||||
}
|
||||
@@ -72,6 +73,9 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) {
|
||||
if got := ev.DeadlineUnix(); got != 1717000000 {
|
||||
t.Fatalf("deadline = %d, want 1717000000", got)
|
||||
}
|
||||
if got := ev.MoveCount(); got != 7 {
|
||||
t.Fatalf("move_count = %d, want 7", got)
|
||||
}
|
||||
if string(ev.OpponentName()) != "Ann" || string(ev.LastAction()) != "play" ||
|
||||
string(ev.LastWord()) != "STOOL" || string(ev.ScoreLine()) != "120:95" {
|
||||
t.Fatalf("enriched fields wrong: name=%q action=%q word=%q score=%q",
|
||||
@@ -81,7 +85,8 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) {
|
||||
|
||||
func TestGameOverPayloadRoundTrips(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
in := notify.GameOver(uid, gid, "won", "120:95:80")
|
||||
summary := notify.GameSummary{ID: gid.String(), Status: "finished", MoveCount: 18, Seats: []notify.SeatStanding{{Seat: 0, Score: 120, IsWinner: true}}}
|
||||
in := notify.GameOver(uid, gid, "won", "120:95:80", summary)
|
||||
if in.UserID != uid || in.Kind != notify.KindGameOver || in.EventID == "" {
|
||||
t.Fatalf("intent metadata wrong: %+v", in)
|
||||
}
|
||||
@@ -89,19 +94,106 @@ func TestGameOverPayloadRoundTrips(t *testing.T) {
|
||||
if string(ev.GameId()) != gid.String() || string(ev.Result()) != "won" || string(ev.ScoreLine()) != "120:95:80" {
|
||||
t.Fatalf("game_over fields wrong: game=%q result=%q score=%q", ev.GameId(), ev.Result(), ev.ScoreLine())
|
||||
}
|
||||
g := ev.Game(nil)
|
||||
if g == nil || string(g.Id()) != gid.String() || g.MoveCount() != 18 || g.SeatsLength() != 1 {
|
||||
t.Fatalf("final game summary wrong: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
in := notify.OpponentMoved(uid, gid, 1, "play", 24, 130)
|
||||
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
|
||||
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
||||
in := notify.OpponentMoved(uid, gid, move, summary, 42)
|
||||
if in.Kind != notify.KindOpponentMoved {
|
||||
t.Fatalf("kind = %q", in.Kind)
|
||||
}
|
||||
ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0)
|
||||
// The pre-R4 summary scalars repeat the move.
|
||||
if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 {
|
||||
t.Fatalf("decoded wrong: game=%q seat=%d action=%q score=%d total=%d",
|
||||
t.Fatalf("scalars wrong: game=%q seat=%d action=%q score=%d total=%d",
|
||||
ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total())
|
||||
}
|
||||
// The R4 delta: the move, the post-move summary and the bag size.
|
||||
if ev.BagLen() != 42 {
|
||||
t.Fatalf("bag_len = %d, want 42", ev.BagLen())
|
||||
}
|
||||
m := ev.Move(nil)
|
||||
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
|
||||
t.Fatalf("move wrong: %+v", m)
|
||||
}
|
||||
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 {
|
||||
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchFoundCarriesInitialState(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
state := notify.PlayerState{
|
||||
Game: notify.GameSummary{ID: gid.String(), Variant: "scrabble_en", Seats: []notify.SeatStanding{{Seat: 0, DisplayName: "Ann"}}},
|
||||
Seat: 0,
|
||||
Rack: []int{0, 1, 2, 255},
|
||||
BagLen: 86,
|
||||
}
|
||||
in := notify.MatchFound(uid, gid, state)
|
||||
if in.UserID != uid || in.Kind != notify.KindMatchFound {
|
||||
t.Fatalf("intent metadata wrong: %+v", in)
|
||||
}
|
||||
ev := fb.GetRootAsMatchFoundEvent(in.Payload, 0)
|
||||
if string(ev.GameId()) != gid.String() {
|
||||
t.Fatalf("game id = %q", ev.GameId())
|
||||
}
|
||||
st := ev.State(nil)
|
||||
if st == nil || st.Seat() != 0 || st.BagLen() != 86 || st.RackLength() != 4 || st.Rack(3) != 255 {
|
||||
t.Fatalf("initial state wrong: %+v", st)
|
||||
}
|
||||
if g := st.Game(nil); g == nil || string(g.Variant()) != "scrabble_en" {
|
||||
t.Fatalf("state game wrong: %+v", st.Game(nil))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationInvitationCarriesInvitation(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
inv := notify.InvitationSummary{
|
||||
ID: "inv-1",
|
||||
Inviter: notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"},
|
||||
Invitees: []notify.InvitationInvitee{{AccountID: "b-1", DisplayName: "Bob", Seat: 1, Response: "pending"}},
|
||||
Variant: "erudit_ru",
|
||||
TurnTimeoutSecs: 86400,
|
||||
Status: "pending",
|
||||
}
|
||||
in := notify.NotificationInvitation(uid, inv)
|
||||
if in.Kind != notify.KindNotification {
|
||||
t.Fatalf("kind = %q", in.Kind)
|
||||
}
|
||||
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
|
||||
if string(ev.Kind()) != notify.NotifyInvitation {
|
||||
t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitation)
|
||||
}
|
||||
got := ev.Invitation(nil)
|
||||
if got == nil || string(got.Id()) != "inv-1" || string(got.Variant()) != "erudit_ru" || got.InviteesLength() != 1 {
|
||||
t.Fatalf("invitation wrong: %+v", got)
|
||||
}
|
||||
var iv fb.InvitationInvitee
|
||||
if !got.Invitees(&iv, 0) || string(iv.DisplayName()) != "Bob" || iv.Seat() != 1 {
|
||||
t.Fatalf("invitee wrong")
|
||||
}
|
||||
if inviter := got.Inviter(nil); inviter == nil || string(inviter.DisplayName()) != "Ann" {
|
||||
t.Fatalf("inviter wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationAccountCarriesAccount(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"})
|
||||
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
|
||||
if string(ev.Kind()) != notify.NotifyFriendRequest {
|
||||
t.Fatalf("sub-kind = %q", ev.Kind())
|
||||
}
|
||||
acc := ev.Account(nil)
|
||||
if acc == nil || string(acc.AccountId()) != "a-1" || string(acc.DisplayName()) != "Ann" {
|
||||
t.Fatalf("account wrong: %+v", acc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePayloadRoundTrips(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package notify
|
||||
|
||||
// The structs below are the wire-agnostic inputs the domain services hand to the
|
||||
// enriched event constructors. Keeping them here — rather than importing the wire
|
||||
// schema into game/lobby/social — preserves the package boundary: notify owns the
|
||||
// FlatBuffers encoding, while the domain only fills in already-resolved values (seat
|
||||
// display names, alphabet-index racks). Each mirrors the matching scrabblefb table.
|
||||
|
||||
// SeatStanding is one seat's public standing inside a GameSummary (mirrors
|
||||
// scrabblefb.SeatView).
|
||||
type SeatStanding struct {
|
||||
Seat int
|
||||
AccountID string
|
||||
DisplayName string
|
||||
Score int
|
||||
HintsUsed int
|
||||
IsWinner bool
|
||||
}
|
||||
|
||||
// GameSummary is the shared, non-private game state embedded in enriched events
|
||||
// (mirrors scrabblefb.GameView). LastActivityUnix is the lobby sort key: the current
|
||||
// turn's start for an active game, the finish time once finished.
|
||||
type GameSummary struct {
|
||||
ID string
|
||||
Variant string
|
||||
DictVersion string
|
||||
Status string
|
||||
Players int
|
||||
ToMove int
|
||||
TurnTimeoutSecs int
|
||||
MoveCount int
|
||||
EndReason string
|
||||
Seats []SeatStanding
|
||||
LastActivityUnix int64
|
||||
}
|
||||
|
||||
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
||||
// initial PlayerState so a client seeing a variant for the first time can render its
|
||||
// rack (mirrors scrabblefb.AlphabetEntry).
|
||||
type AlphabetLetter struct {
|
||||
Index int
|
||||
Letter string
|
||||
Value int
|
||||
}
|
||||
|
||||
// PlayerState is a player's full initial view of a game — the shared summary plus
|
||||
// their private rack and budgets (mirrors scrabblefb.StateView). Rack carries wire
|
||||
// alphabet indices (a blank is the sentinel index 255). Alphabet is set only when the
|
||||
// recipient may not have cached the variant yet (match_found / game_started).
|
||||
type PlayerState struct {
|
||||
Game GameSummary
|
||||
Seat int
|
||||
Rack []int
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
Alphabet []AlphabetLetter
|
||||
}
|
||||
|
||||
// AccountRef is a referenced account with its display name resolved (mirrors
|
||||
// scrabblefb.AccountRef).
|
||||
type AccountRef struct {
|
||||
AccountID string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// InvitationInvitee is one invited player's seat and response inside an
|
||||
// InvitationSummary (mirrors scrabblefb.InvitationInvitee).
|
||||
type InvitationInvitee struct {
|
||||
AccountID string
|
||||
DisplayName string
|
||||
Seat int
|
||||
Response string
|
||||
}
|
||||
|
||||
// InvitationSummary is a friend-game invitation carried by the NotifyInvitation event so
|
||||
// the client adds it to its lobby list without a refetch (mirrors scrabblefb.Invitation).
|
||||
type InvitationSummary struct {
|
||||
ID string
|
||||
Inviter AccountRef
|
||||
Invitees []InvitationInvitee
|
||||
Variant string
|
||||
TurnTimeoutSecs int
|
||||
HintsAllowed bool
|
||||
HintsPerPlayer int
|
||||
DropoutTiles string
|
||||
Status string
|
||||
GameID string
|
||||
ExpiresAtUnix int64
|
||||
}
|
||||
@@ -98,10 +98,14 @@ type gameDTO struct {
|
||||
Seats []seatDTO `json:"seats"`
|
||||
}
|
||||
|
||||
// moveResultDTO is the outcome of a committed move.
|
||||
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||
// wire alphabet indices and BagLen the bag size after the draw (R4), so the mover renders the
|
||||
// next state from the response without a follow-up state fetch.
|
||||
type moveResultDTO struct {
|
||||
Move moveRecordDTO `json:"move"`
|
||||
Game gameDTO `json:"game"`
|
||||
Move moveRecordDTO `json:"move"`
|
||||
Game gameDTO `json:"game"`
|
||||
Rack []int `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
}
|
||||
|
||||
// alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and
|
||||
@@ -231,9 +235,19 @@ func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO {
|
||||
}
|
||||
}
|
||||
|
||||
// moveResultDTOFrom projects a committed move result into its DTO.
|
||||
func moveResultDTOFrom(r game.MoveResult) moveResultDTO {
|
||||
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
|
||||
// moveResultDTOFrom projects a committed move result into its DTO, encoding the actor's rack as
|
||||
// wire alphabet indices (Stage 13; R4).
|
||||
func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) {
|
||||
rack, err := engine.EncodeRack(r.Game.Variant, r.Rack)
|
||||
if err != nil {
|
||||
return moveResultDTO{}, err
|
||||
}
|
||||
return moveResultDTO{
|
||||
Move: moveRecordDTOFrom(r.Move),
|
||||
Game: gameDTOFromGame(r.Game),
|
||||
Rack: rack,
|
||||
BagLen: r.BagLen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire
|
||||
|
||||
@@ -458,7 +458,11 @@ func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
|
||||
|
||||
// writeMoveResult emits a committed move with seat display names filled in.
|
||||
func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
|
||||
dto := moveResultDTOFrom(res)
|
||||
dto, err := moveResultDTOFrom(res)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
||||
c.JSON(http.StatusOK, dto)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID,
|
||||
if err := svc.store.redeemFriendCode(ctx, codeID, issuerID, redeemerID, svc.now()); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
svc.pub.Publish(notify.Notification(issuerID, notify.NotifyFriendAdded))
|
||||
svc.pub.Publish(notify.NotificationAccount(issuerID, notify.NotifyFriendAdded, svc.accountRef(ctx, redeemerID)))
|
||||
return issuerID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@ const (
|
||||
// window; a one-time friend code from the addressee bypasses a decline.
|
||||
const friendRequestTTL = 30 * 24 * time.Hour
|
||||
|
||||
// accountRef resolves accountID into a notify.AccountRef (the display name from the account
|
||||
// store, empty on a lookup failure), for enriching the friend_* live events so the client
|
||||
// updates its requests/friends state without a refetch (R4).
|
||||
func (svc *Service) accountRef(ctx context.Context, accountID uuid.UUID) notify.AccountRef {
|
||||
ref := notify.AccountRef{AccountID: accountID.String()}
|
||||
if acc, err := svc.accounts.GetByID(ctx, accountID); err == nil {
|
||||
ref.DisplayName = acc.DisplayName
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// SendFriendRequest records a pending friend request from requesterID to
|
||||
// addresseeID — the "befriend an opponent" path. It requires the two to share a
|
||||
// game (active or finished) and refuses a self-request, a request across a block or
|
||||
@@ -91,7 +102,7 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest))
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -101,7 +112,7 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
}
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest))
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -128,9 +139,9 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque
|
||||
// this opponent re-derives its "add to friends" state (accepted -> friends, declined
|
||||
// -> stays "request sent").
|
||||
if accept {
|
||||
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded))
|
||||
svc.pub.Publish(notify.NotificationAccount(requesterID, notify.NotifyFriendAdded, svc.accountRef(ctx, addresseeID)))
|
||||
} else {
|
||||
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined))
|
||||
svc.pub.Publish(notify.NotificationAccount(requesterID, notify.NotifyFriendDeclined, svc.accountRef(ctx, addresseeID)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+16
-5
@@ -510,11 +510,22 @@ seat from the same game commit when a game finishes — any path: a closing play
|
||||
resign or timeout) and **enriched your-turn** so the out-of-app push reads in full: it now
|
||||
also carries the mover's display name, their last action and the main word of a scoring play,
|
||||
and a **recipient-first** running score line (e.g. `120:95:80`, the reader's score first).
|
||||
Event payloads are FlatBuffers-encoded by
|
||||
the backend and forwarded verbatim. A client that is not currently streaming falls
|
||||
back to the matchmaker's `Poll` for match-found and, for the lobby **notification
|
||||
badge** (incoming friend requests + open invitations), the client polls on lobby
|
||||
open and on focus as well as re-polling on the `notify` event — covering a push
|
||||
**R4 enriched the in-app stream into a delta channel** so the client renders from the event
|
||||
without a follow-up `game.state`: **opponent-moved** carries the committed move plus the post-move
|
||||
summary (per-seat scores, whose turn, move count, status) and the bag size, which the client
|
||||
applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move
|
||||
echo is a no-op) and gap-safe (a missed move falls back to a `game.state` + `game.history`
|
||||
refetch); **your-turn** carries that move count as a consistency check; **match-found** and the
|
||||
**game-started** notify carry the recipient's full **initial `StateView`**, so opening a freshly
|
||||
started game is instant; **game-over** carries the final summary; the lobby **notify** sub-kinds
|
||||
carry the changed account / invitation. The move-commit **response** (`submit_play` / `pass` /
|
||||
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
|
||||
renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding
|
||||
(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload
|
||||
verbatim. A client that is not currently streaming falls back to the matchmaker's `Poll` for
|
||||
match-found — the client polls **only while the stream is down**, since a live stream delivers
|
||||
match-found itself; for the lobby **notification badge** (incoming friend requests + open
|
||||
invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push
|
||||
missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback
|
||||
the **gateway** routes from the same firehose: for an event whose recipient has **no
|
||||
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
|
||||
|
||||
+2
-1
@@ -42,7 +42,8 @@ language, not whichever bot the player signed in through last. Guests are sessio
|
||||
(auto-match only; no friends, stats or history); an abandoned guest that never
|
||||
joined a game and has been idle past the retention window is garbage-collected. While the app is open the client
|
||||
keeps a live stream and receives in-app updates in real time — the opponent's move,
|
||||
your turn, chat, nudges and a found match. When the app is **closed**, the chosen
|
||||
your turn, chat, nudges and a found match. Each update lands as the event itself, applied in place
|
||||
with no reload, so the board refreshes seamlessly and a found or invited game opens instantly. When the app is **closed**, the chosen
|
||||
out-of-app events (your turn, game over, nudge, a found match, an invitation or friend
|
||||
request) arrive as a **Telegram notification** instead — unless the player keeps
|
||||
notifications in the app only (a profile setting, **on by default**). The "your turn"
|
||||
|
||||
@@ -43,7 +43,8 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
|
||||
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
|
||||
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
|
||||
чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие
|
||||
чат, nudge и найденный матч. Каждое обновление приходит самим событием и применяется на месте без
|
||||
перезагрузки — доска обновляется бесшовно, а найденная или приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
|
||||
события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья)
|
||||
приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил
|
||||
уведомления только в приложении (настройка профиля, **включена по умолчанию**).
|
||||
|
||||
+3
-1
@@ -53,7 +53,9 @@ out-of-app push to that connector for recipients with no live in-app stream
|
||||
The Stage 6 message-type slice: `auth.telegram`, `auth.guest`,
|
||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events
|
||||
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found`. Stage 7
|
||||
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (R4 enriched the game events —
|
||||
and `game_over`/`notify` — to carry the state delta the client applies without a `game.state`
|
||||
refetch). Stage 7
|
||||
added the play-loop ops; **Stage 8** added the social/account/history ops —
|
||||
`friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem),
|
||||
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
|
||||
|
||||
@@ -106,10 +106,13 @@ type GameResp struct {
|
||||
Seats []SeatResp `json:"seats"`
|
||||
}
|
||||
|
||||
// MoveResultResp is the outcome of a committed move.
|
||||
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||
// wire alphabet indices and BagLen the bag size after the draw (R4).
|
||||
type MoveResultResp struct {
|
||||
Move MoveRecordResp `json:"move"`
|
||||
Game GameResp `json:"game"`
|
||||
Move MoveRecordResp `json:"move"`
|
||||
Game GameResp `json:"game"`
|
||||
Rack []int `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
}
|
||||
|
||||
// AlphabetEntryJSON is one letter of a variant's alphabet (its index, concrete letter and
|
||||
|
||||
@@ -123,9 +123,16 @@ func encodeMoveResult(r backendclient.MoveResultResp) []byte {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
move := buildMoveRecord(b, r.Move)
|
||||
game := buildGameView(b, r.Game)
|
||||
rackBytes := make([]byte, len(r.Rack))
|
||||
for i, v := range r.Rack {
|
||||
rackBytes[i] = byte(v)
|
||||
}
|
||||
rack := b.CreateByteVector(rackBytes)
|
||||
fb.MoveResultStart(b)
|
||||
fb.MoveResultAddMove(b, move)
|
||||
fb.MoveResultAddGame(b, game)
|
||||
fb.MoveResultAddRack(b, rack)
|
||||
fb.MoveResultAddBagLen(b, int32(r.BagLen))
|
||||
b.Finish(fb.MoveResultEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
+35
-7
@@ -159,10 +159,16 @@ table SubmitPlayRequest {
|
||||
tiles:[PlayTile];
|
||||
}
|
||||
|
||||
// MoveResult is the outcome of a committed move: the move and the post-move game.
|
||||
// MoveResult is the outcome of a committed move: the move and the post-move game. rack and
|
||||
// bag_len carry the actor's own post-move private state — their refilled rack (alphabet indices,
|
||||
// Stage 13; a blank is the sentinel index 255) and the bag size after drawing — so the mover
|
||||
// renders the next state straight from this response without a follow-up game.state (R4; added
|
||||
// trailing — backward-compatible).
|
||||
table MoveResult {
|
||||
move:MoveRecord;
|
||||
game:GameView;
|
||||
rack:[ubyte];
|
||||
bag_len:int;
|
||||
}
|
||||
|
||||
// StateRequest asks for the requesting player's view of a game. include_alphabet asks the
|
||||
@@ -477,6 +483,8 @@ table GcgExport {
|
||||
// move kind ("play"/"pass"/"exchange"/...), last_word is the main word of a scoring play (empty
|
||||
// otherwise), and score_line is the recipient-first running score (e.g. "120:95:80"). They are
|
||||
// appended (FlatBuffers-optional), so an older reader that only needs game_id/deadline is unaffected.
|
||||
// move_count is the post-move count (matching the opponent_moved GameView): the client uses it to
|
||||
// tell whether its cached game already reflects the move, falling back to a refetch on a gap (R4).
|
||||
table YourTurnEvent {
|
||||
game_id:string;
|
||||
deadline_unix:long;
|
||||
@@ -484,25 +492,35 @@ table YourTurnEvent {
|
||||
last_action:string;
|
||||
last_word:string;
|
||||
score_line:string;
|
||||
move_count:int;
|
||||
}
|
||||
|
||||
// GameOverEvent signals that a game the recipient is seated in has finished, driving the
|
||||
// out-of-app "game over" push (Stage 17). result is the outcome from the recipient's own
|
||||
// perspective ("won"/"lost"/"draw"); score_line is the recipient-first final score.
|
||||
// perspective ("won"/"lost"/"draw"); score_line is the recipient-first final score. game is the
|
||||
// final post-game summary (adjusted scores after rack penalties + the winner flag), so the client
|
||||
// settles the finished game from the event without a refetch (R4; added trailing).
|
||||
table GameOverEvent {
|
||||
game_id:string;
|
||||
result:string;
|
||||
score_line:string;
|
||||
game:GameView;
|
||||
}
|
||||
|
||||
// OpponentMovedEvent summarises a move another seat just committed; the client
|
||||
// refetches the full state.
|
||||
// OpponentMovedEvent carries a move another seat just committed as a delta the client applies to
|
||||
// its cached game without a refetch (R4): move is the decoded play/pass/exchange (the same record
|
||||
// game.history returns), game is the post-move summary (per-seat scores, to_move, move_count,
|
||||
// status) and bag_len is the bag size after the draw. The leading seat/action/score/total scalars
|
||||
// are the pre-R4 summary, now redundant with move/game and kept only for wire back-compat.
|
||||
table OpponentMovedEvent {
|
||||
game_id:string;
|
||||
seat:int;
|
||||
action:string;
|
||||
score:int;
|
||||
total:int;
|
||||
move:MoveRecord;
|
||||
game:GameView;
|
||||
bag_len:int;
|
||||
}
|
||||
|
||||
// NudgeEvent signals that a player nudged the recipient.
|
||||
@@ -511,17 +529,27 @@ table NudgeEvent {
|
||||
from_user_id:string;
|
||||
}
|
||||
|
||||
// MatchFoundEvent signals that an auto-match pairing (or robot substitution)
|
||||
// started a game the recipient is seated in.
|
||||
// MatchFoundEvent signals that an auto-match pairing (or robot substitution) started a game the
|
||||
// recipient is seated in. state is the recipient's full initial view of the new game (empty board,
|
||||
// dealt rack), so the client navigates straight in from the event with no follow-up fetch (R4;
|
||||
// added trailing — an older reader still reads just game_id).
|
||||
table MatchFoundEvent {
|
||||
game_id:string;
|
||||
state:StateView;
|
||||
}
|
||||
|
||||
// NotificationEvent is a lightweight "something changed, re-poll" signal that
|
||||
// drives the lobby badge (incoming friend requests, invitations). kind is a sub-
|
||||
// discriminator ("friend_request", "friend_added", "friend_declined", "invitation",
|
||||
// "game_started"); the client re-fetches its lobby counters (and, for a requester
|
||||
// watching a game, its friend state) on any of them.
|
||||
// watching a game, its friend state) on any of them. To let the client update its lobby without a
|
||||
// follow-up fetch (R4), each event also carries the payload its kind changed: account for the
|
||||
// friend_* kinds (the requester/friend), invitation for "invitation" (the new invitation) and
|
||||
// state for "game_started" (the started game's initial view, like match_found). Only the field
|
||||
// matching kind is set (all added trailing — backward-compatible).
|
||||
table NotificationEvent {
|
||||
kind:string;
|
||||
account:AccountRef;
|
||||
invitation:Invitation;
|
||||
state:StateView;
|
||||
}
|
||||
|
||||
@@ -65,8 +65,21 @@ func (rcv *GameOverEvent) ScoreLine() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *GameOverEvent) Game(obj *GameView) *GameView {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(GameView)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GameOverEventStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(3)
|
||||
builder.StartObject(4)
|
||||
}
|
||||
func GameOverEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
||||
@@ -77,6 +90,9 @@ func GameOverEventAddResult(builder *flatbuffers.Builder, result flatbuffers.UOf
|
||||
func GameOverEventAddScoreLine(builder *flatbuffers.Builder, scoreLine flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(scoreLine), 0)
|
||||
}
|
||||
func GameOverEventAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(game), 0)
|
||||
}
|
||||
func GameOverEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -49,12 +49,28 @@ func (rcv *MatchFoundEvent) GameId() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *MatchFoundEvent) State(obj *StateView) *StateView {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(StateView)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MatchFoundEventStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(1)
|
||||
builder.StartObject(2)
|
||||
}
|
||||
func MatchFoundEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
||||
}
|
||||
func MatchFoundEventAddState(builder *flatbuffers.Builder, state flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(state), 0)
|
||||
}
|
||||
func MatchFoundEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -67,8 +67,54 @@ func (rcv *MoveResult) Game(obj *GameView) *GameView {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *MoveResult) Rack(j int) byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
a := rcv._tab.Vector(o)
|
||||
return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *MoveResult) RackLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *MoveResult) RackBytes() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *MoveResult) MutateRack(j int, n byte) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
a := rcv._tab.Vector(o)
|
||||
return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *MoveResult) BagLen() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *MoveResult) MutateBagLen(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(10, n)
|
||||
}
|
||||
|
||||
func MoveResultStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
builder.StartObject(4)
|
||||
}
|
||||
func MoveResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(move), 0)
|
||||
@@ -76,6 +122,15 @@ func MoveResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT)
|
||||
func MoveResultAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(game), 0)
|
||||
}
|
||||
func MoveResultAddRack(builder *flatbuffers.Builder, rack flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(rack), 0)
|
||||
}
|
||||
func MoveResultStartRackVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(1, numElems, 1)
|
||||
}
|
||||
func MoveResultAddBagLen(builder *flatbuffers.Builder, bagLen int32) {
|
||||
builder.PrependInt32Slot(3, bagLen, 0)
|
||||
}
|
||||
func MoveResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -49,12 +49,60 @@ func (rcv *NotificationEvent) Kind() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *NotificationEvent) Account(obj *AccountRef) *AccountRef {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(AccountRef)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *NotificationEvent) Invitation(obj *Invitation) *Invitation {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(Invitation)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *NotificationEvent) State(obj *StateView) *StateView {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(StateView)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NotificationEventStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(1)
|
||||
builder.StartObject(4)
|
||||
}
|
||||
func NotificationEventAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(kind), 0)
|
||||
}
|
||||
func NotificationEventAddAccount(builder *flatbuffers.Builder, account flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(account), 0)
|
||||
}
|
||||
func NotificationEventAddInvitation(builder *flatbuffers.Builder, invitation flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(invitation), 0)
|
||||
}
|
||||
func NotificationEventAddState(builder *flatbuffers.Builder, state flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(state), 0)
|
||||
}
|
||||
func NotificationEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -93,8 +93,46 @@ func (rcv *OpponentMovedEvent) MutateTotal(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(12, n)
|
||||
}
|
||||
|
||||
func (rcv *OpponentMovedEvent) Move(obj *MoveRecord) *MoveRecord {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(MoveRecord)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *OpponentMovedEvent) Game(obj *GameView) *GameView {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(GameView)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *OpponentMovedEvent) BagLen() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *OpponentMovedEvent) MutateBagLen(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(18, n)
|
||||
}
|
||||
|
||||
func OpponentMovedEventStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(5)
|
||||
builder.StartObject(8)
|
||||
}
|
||||
func OpponentMovedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
||||
@@ -111,6 +149,15 @@ func OpponentMovedEventAddScore(builder *flatbuffers.Builder, score int32) {
|
||||
func OpponentMovedEventAddTotal(builder *flatbuffers.Builder, total int32) {
|
||||
builder.PrependInt32Slot(4, total, 0)
|
||||
}
|
||||
func OpponentMovedEventAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(move), 0)
|
||||
}
|
||||
func OpponentMovedEventAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(game), 0)
|
||||
}
|
||||
func OpponentMovedEventAddBagLen(builder *flatbuffers.Builder, bagLen int32) {
|
||||
builder.PrependInt32Slot(7, bagLen, 0)
|
||||
}
|
||||
func OpponentMovedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -93,8 +93,20 @@ func (rcv *YourTurnEvent) ScoreLine() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *YourTurnEvent) MoveCount() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *YourTurnEvent) MutateMoveCount(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(16, n)
|
||||
}
|
||||
|
||||
func YourTurnEventStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(6)
|
||||
builder.StartObject(7)
|
||||
}
|
||||
func YourTurnEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
||||
@@ -114,6 +126,9 @@ func YourTurnEventAddLastWord(builder *flatbuffers.Builder, lastWord flatbuffers
|
||||
func YourTurnEventAddScoreLine(builder *flatbuffers.Builder, scoreLine flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(scoreLine), 0)
|
||||
}
|
||||
func YourTurnEventAddMoveCount(builder *flatbuffers.Builder, moveCount int32) {
|
||||
builder.PrependInt32Slot(6, moveCount, 0)
|
||||
}
|
||||
func YourTurnEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
+3
-1
@@ -40,7 +40,9 @@ The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||
A single Connect `Execute(message_type, payload)` carries every unary op; the request
|
||||
and response bodies are **FlatBuffers** tables (`pkg/fbs/scrabble.fbs`) in `payload`.
|
||||
The session token rides in `Authorization: Bearer`; a domain failure comes back in
|
||||
`result_code`. `Subscribe` is the live event stream. `lib/transport.ts` is the real
|
||||
`result_code`. `Subscribe` is the live event stream; R4 made its game events carry a state **delta**
|
||||
that `lib/gamedelta.ts` applies to the per-game cache (`lib/gamecache.ts`), so a move renders without
|
||||
a follow-up `game.state` (a gap falls back to a refetch). `lib/transport.ts` is the real
|
||||
client; `lib/mock/` is an in-memory fake selected by `MODE === 'mock'` (and tree-shaken
|
||||
out of production). Both speak the plain `lib/model.ts` types via `lib/codec.ts`.
|
||||
|
||||
|
||||
+52
-17
@@ -13,13 +13,14 @@
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { GatewayError } from '../lib/client';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
|
||||
import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||
import { replay } from '../lib/board';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { variantNameKey } from '../lib/variants';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
import { getCachedGame, setCachedGame } from '../lib/gamecache';
|
||||
import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache';
|
||||
import { applyGameOver, applyMoveDelta, type DeltaResult } from '../lib/gamedelta';
|
||||
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
|
||||
import {
|
||||
BLANK,
|
||||
@@ -154,17 +155,42 @@
|
||||
void loadFriends();
|
||||
});
|
||||
|
||||
// cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers.
|
||||
function cacheSnapshot(): CachedGame | undefined {
|
||||
return view ? { view, moves } : undefined;
|
||||
}
|
||||
// applyDelta adopts a reducer result: an advanced cache renders the move with no fetch; a
|
||||
// flagged refetch falls back to a full load() (a gap, our own move's new rack, or a missing
|
||||
// payload — see lib/gamedelta).
|
||||
function applyDelta(res: DeltaResult): void {
|
||||
if (res.cache) {
|
||||
view = res.cache.view;
|
||||
moves = res.cache.moves;
|
||||
setCachedGame(id, view, moves);
|
||||
recompute();
|
||||
} else if (res.refetch) {
|
||||
void load();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const e = app.lastEvent;
|
||||
if (!e) return;
|
||||
if (e.kind === 'opponent_moved' && e.gameId === id) {
|
||||
// Skip the echo of my own move (the backend now notifies the actor too, for the
|
||||
// player's other devices): this device already reloaded after the submit.
|
||||
if (e.seat !== view?.seat) void load();
|
||||
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
|
||||
// A request the player sent was answered (accepted -> now friends; declined -> stays
|
||||
// "request sent"): re-derive the in-game friend state.
|
||||
else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends();
|
||||
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
||||
// the move as a delta with no fetch (R4).
|
||||
if (placement.pending.length > 0) void load();
|
||||
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
||||
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
||||
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
|
||||
// only if we missed the move (our cached count trails the event's).
|
||||
if (view && e.moveCount > view.game.moveCount) void load();
|
||||
} else if (e.kind === 'game_over' && e.gameId === id) {
|
||||
applyDelta(applyGameOver(cacheSnapshot(), e.game));
|
||||
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
|
||||
// A request the player sent was answered: re-derive the in-game "add friend" state.
|
||||
void loadFriends();
|
||||
}
|
||||
});
|
||||
|
||||
function isCoarse(): boolean {
|
||||
@@ -446,15 +472,27 @@
|
||||
}, 250);
|
||||
}
|
||||
|
||||
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
|
||||
// post-move game and the refilled rack — without a follow-up game.state + game.history (R4).
|
||||
function applyMoveResult(r: MoveResult) {
|
||||
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
|
||||
moves = [...moves, r.move];
|
||||
setCachedGame(id, view, moves);
|
||||
rackIds = r.rack.map((_, i) => i);
|
||||
placement = newPlacement(r.rack);
|
||||
selected = null;
|
||||
dirOverride = undefined;
|
||||
recompute();
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
const sub = toSubmit(placement, dirOverride);
|
||||
if (!sub) return;
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
|
||||
applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant));
|
||||
telegramHaptic('success');
|
||||
zoomed = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
@@ -472,8 +510,7 @@
|
||||
async function doPass() {
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.pass(id);
|
||||
await load();
|
||||
applyMoveResult(await gateway.pass(id));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
@@ -484,8 +521,7 @@
|
||||
resignOpen = false;
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.resign(id);
|
||||
await load();
|
||||
applyMoveResult(await gateway.resign(id));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
@@ -553,8 +589,7 @@
|
||||
exchangeOpen = false;
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.exchange(id, tiles, variant);
|
||||
await load();
|
||||
applyMoveResult(await gateway.exchange(id, tiles, variant));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { GameView } from '../scrabblefb/game-view.js';
|
||||
|
||||
|
||||
export class GameOverEvent {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
@@ -41,8 +44,13 @@ scoreLine(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
game(obj?:GameView):GameView|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
static startGameOverEvent(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
@@ -57,16 +65,13 @@ static addScoreLine(builder:flatbuffers.Builder, scoreLineOffset:flatbuffers.Off
|
||||
builder.addFieldOffset(2, scoreLineOffset, 0);
|
||||
}
|
||||
|
||||
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(3, gameOffset, 0);
|
||||
}
|
||||
|
||||
static endGameOverEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createGameOverEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, resultOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
GameOverEvent.startGameOverEvent(builder);
|
||||
GameOverEvent.addGameId(builder, gameIdOffset);
|
||||
GameOverEvent.addResult(builder, resultOffset);
|
||||
GameOverEvent.addScoreLine(builder, scoreLineOffset);
|
||||
return GameOverEvent.endGameOverEvent(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { StateView } from '../scrabblefb/state-view.js';
|
||||
|
||||
|
||||
export class MatchFoundEvent {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
@@ -27,22 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
state(obj?:StateView):StateView|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? (obj || new StateView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
static startMatchFoundEvent(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, gameIdOffset, 0);
|
||||
}
|
||||
|
||||
static addState(builder:flatbuffers.Builder, stateOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, stateOffset, 0);
|
||||
}
|
||||
|
||||
static endMatchFoundEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createMatchFoundEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
MatchFoundEvent.startMatchFoundEvent(builder);
|
||||
MatchFoundEvent.addGameId(builder, gameIdOffset);
|
||||
return MatchFoundEvent.endMatchFoundEvent(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,28 @@ game(obj?:GameView):GameView|null {
|
||||
return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
rack(index: number):number|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
|
||||
}
|
||||
|
||||
rackLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
rackArray():Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
|
||||
}
|
||||
|
||||
bagLen():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startMoveResult(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
|
||||
@@ -46,6 +66,26 @@ static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, gameOffset, 0);
|
||||
}
|
||||
|
||||
static addRack(builder:flatbuffers.Builder, rackOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, rackOffset, 0);
|
||||
}
|
||||
|
||||
static createRackVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
|
||||
builder.startVector(1, data.length, 1);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addInt8(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startRackVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(1, numElems, 1);
|
||||
}
|
||||
|
||||
static addBagLen(builder:flatbuffers.Builder, bagLen:number) {
|
||||
builder.addFieldInt32(3, bagLen, 0);
|
||||
}
|
||||
|
||||
static endMoveResult(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { AccountRef } from '../scrabblefb/account-ref.js';
|
||||
import { Invitation } from '../scrabblefb/invitation.js';
|
||||
import { StateView } from '../scrabblefb/state-view.js';
|
||||
|
||||
|
||||
export class NotificationEvent {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
@@ -27,22 +32,44 @@ kind(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
account(obj?:AccountRef):AccountRef|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
invitation(obj?:Invitation):Invitation|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? (obj || new Invitation()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
state(obj?:StateView):StateView|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? (obj || new StateView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
static startNotificationEvent(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, kindOffset, 0);
|
||||
}
|
||||
|
||||
static addAccount(builder:flatbuffers.Builder, accountOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, accountOffset, 0);
|
||||
}
|
||||
|
||||
static addInvitation(builder:flatbuffers.Builder, invitationOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, invitationOffset, 0);
|
||||
}
|
||||
|
||||
static addState(builder:flatbuffers.Builder, stateOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(3, stateOffset, 0);
|
||||
}
|
||||
|
||||
static endNotificationEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createNotificationEvent(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
NotificationEvent.startNotificationEvent(builder);
|
||||
NotificationEvent.addKind(builder, kindOffset);
|
||||
return NotificationEvent.endNotificationEvent(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { GameView } from '../scrabblefb/game-view.js';
|
||||
import { MoveRecord } from '../scrabblefb/move-record.js';
|
||||
|
||||
|
||||
export class OpponentMovedEvent {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
@@ -49,8 +53,23 @@ total():number {
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
move(obj?:MoveRecord):MoveRecord|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||
return offset ? (obj || new MoveRecord()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
game(obj?:GameView):GameView|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||
return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
bagLen():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 18);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startOpponentMovedEvent(builder:flatbuffers.Builder) {
|
||||
builder.startObject(5);
|
||||
builder.startObject(8);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
@@ -73,18 +92,21 @@ static addTotal(builder:flatbuffers.Builder, total:number) {
|
||||
builder.addFieldInt32(4, total, 0);
|
||||
}
|
||||
|
||||
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(5, moveOffset, 0);
|
||||
}
|
||||
|
||||
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(6, gameOffset, 0);
|
||||
}
|
||||
|
||||
static addBagLen(builder:flatbuffers.Builder, bagLen:number) {
|
||||
builder.addFieldInt32(7, bagLen, 0);
|
||||
}
|
||||
|
||||
static endOpponentMovedEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createOpponentMovedEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, seat:number, actionOffset:flatbuffers.Offset, score:number, total:number):flatbuffers.Offset {
|
||||
OpponentMovedEvent.startOpponentMovedEvent(builder);
|
||||
OpponentMovedEvent.addGameId(builder, gameIdOffset);
|
||||
OpponentMovedEvent.addSeat(builder, seat);
|
||||
OpponentMovedEvent.addAction(builder, actionOffset);
|
||||
OpponentMovedEvent.addScore(builder, score);
|
||||
OpponentMovedEvent.addTotal(builder, total);
|
||||
return OpponentMovedEvent.endOpponentMovedEvent(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +60,13 @@ scoreLine(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
moveCount():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startYourTurnEvent(builder:flatbuffers.Builder) {
|
||||
builder.startObject(6);
|
||||
builder.startObject(7);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
@@ -88,12 +93,16 @@ static addScoreLine(builder:flatbuffers.Builder, scoreLineOffset:flatbuffers.Off
|
||||
builder.addFieldOffset(5, scoreLineOffset, 0);
|
||||
}
|
||||
|
||||
static addMoveCount(builder:flatbuffers.Builder, moveCount:number) {
|
||||
builder.addFieldInt32(6, moveCount, 0);
|
||||
}
|
||||
|
||||
static endYourTurnEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint, opponentNameOffset:flatbuffers.Offset, lastActionOffset:flatbuffers.Offset, lastWordOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint, opponentNameOffset:flatbuffers.Offset, lastActionOffset:flatbuffers.Offset, lastWordOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset, moveCount:number):flatbuffers.Offset {
|
||||
YourTurnEvent.startYourTurnEvent(builder);
|
||||
YourTurnEvent.addGameId(builder, gameIdOffset);
|
||||
YourTurnEvent.addDeadlineUnix(builder, deadlineUnix);
|
||||
@@ -101,6 +110,7 @@ static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers
|
||||
YourTurnEvent.addLastAction(builder, lastActionOffset);
|
||||
YourTurnEvent.addLastWord(builder, lastWordOffset);
|
||||
YourTurnEvent.addScoreLine(builder, scoreLineOffset);
|
||||
YourTurnEvent.addMoveCount(builder, moveCount);
|
||||
return YourTurnEvent.endYourTurnEvent(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { parseStartParam } from './deeplink';
|
||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||
import { isConnectionCode } from './retry';
|
||||
import { clearGameCache } from './gamecache';
|
||||
import { clearGameCache, setCachedGame } from './gamecache';
|
||||
import { clearLobby } from './lobbycache';
|
||||
import type { BoardLabelMode } from './boardlabels';
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface Toast {
|
||||
|
||||
export const app = $state<{
|
||||
ready: boolean;
|
||||
/** Whether the live-event stream is connected; drives the matchmaking poll fallback (R4). */
|
||||
streamAlive: boolean;
|
||||
session: Session | null;
|
||||
profile: Profile | null;
|
||||
toast: Toast | null;
|
||||
@@ -53,6 +55,7 @@ export const app = $state<{
|
||||
chatUnread: Record<string, number>;
|
||||
}>({
|
||||
ready: false,
|
||||
streamAlive: false,
|
||||
session: null,
|
||||
profile: null,
|
||||
toast: null,
|
||||
@@ -68,7 +71,6 @@ export const app = $state<{
|
||||
});
|
||||
|
||||
let unsubscribeStream: (() => void) | null = null;
|
||||
let streamAlive = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -100,7 +102,7 @@ function goForeground(): void {
|
||||
backgrounded = false;
|
||||
foregroundedAt = Date.now();
|
||||
if (!app.session) return;
|
||||
if (!streamAlive) openStream(); // silently re-establish a stream dropped while away
|
||||
if (!app.streamAlive) openStream(); // silently re-establish a stream dropped while away
|
||||
void refreshNotifications();
|
||||
}
|
||||
|
||||
@@ -131,7 +133,7 @@ export function handleError(err: unknown): void {
|
||||
|
||||
function openStream(): void {
|
||||
closeStream();
|
||||
streamAlive = true;
|
||||
app.streamAlive = true;
|
||||
unsubscribeStream = gateway.subscribe(
|
||||
(e) => {
|
||||
reportOnline(); // a delivered event proves the gateway is reachable
|
||||
@@ -151,13 +153,19 @@ function openStream(): void {
|
||||
} else if (e.kind === 'your_turn') {
|
||||
showToast(t('game.yourTurn'), 'info');
|
||||
} else if (e.kind === 'match_found') {
|
||||
// Seed the cache from the event's initial state so the game renders instantly on arrival,
|
||||
// then navigate (R4).
|
||||
if (e.state) setCachedGame(e.state.game.id, e.state, []);
|
||||
navigate(`/game/${e.gameId}`);
|
||||
} else if (e.kind === 'notify') {
|
||||
// A started invited game seeds its cache so opening it is instant; the lobby badge stays
|
||||
// on the authoritative refresh (R4).
|
||||
if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []);
|
||||
void refreshNotifications();
|
||||
}
|
||||
},
|
||||
() => {
|
||||
streamAlive = false;
|
||||
app.streamAlive = false;
|
||||
// A background suspend drops the single-shot stream. Keep the indicator hidden while
|
||||
// backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the
|
||||
// reachability watcher and this scheduled retry recover it). Always schedule a retry.
|
||||
@@ -173,7 +181,7 @@ function scheduleReconnect(): void {
|
||||
if (reconnectTimer || !app.session) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream();
|
||||
if (app.session && !app.streamAlive && !backgrounded && !documentHidden()) openStream();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
@@ -205,7 +213,7 @@ function closeStream(): void {
|
||||
}
|
||||
unsubscribeStream?.();
|
||||
unsubscribeStream = null;
|
||||
streamAlive = false;
|
||||
app.streamAlive = false;
|
||||
}
|
||||
|
||||
async function adoptSession(s: Session): Promise<void> {
|
||||
|
||||
+51
-9
@@ -322,12 +322,12 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeStateView(buf: Uint8Array): StateView {
|
||||
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
|
||||
// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the
|
||||
// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index
|
||||
// rack to display letters with it (Stage 13).
|
||||
function decodeStateViewTable(v: fb.StateView): StateView {
|
||||
const g = v.game();
|
||||
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
|
||||
// Cache the alphabet table when the server included it (a per-variant cache miss), then
|
||||
// decode the index rack to display letters with it (Stage 13).
|
||||
if (v.alphabetLength() > 0) {
|
||||
const entries: AlphabetEntryWire[] = [];
|
||||
for (let i = 0; i < v.alphabetLength(); i++) {
|
||||
@@ -347,11 +347,24 @@ export function decodeStateView(buf: Uint8Array): StateView {
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeStateView(buf: Uint8Array): StateView {
|
||||
return decodeStateViewTable(fb.StateView.getRootAsStateView(new ByteBuffer(buf)));
|
||||
}
|
||||
|
||||
export function decodeMoveResult(buf: Uint8Array): MoveResult {
|
||||
const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf));
|
||||
const m = r.move();
|
||||
const g = r.game();
|
||||
return { move: m ? decodeMove(m) : emptyMove(), game: g ? decodeGameView(g) : emptyGame() };
|
||||
// The actor's refilled rack rides back as alphabet indices (R4); decode it with the game's variant.
|
||||
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
|
||||
const rack: string[] = [];
|
||||
for (let i = 0; i < r.rackLength(); i++) rack.push(letterForIndex(variant, r.rack(i) ?? 0));
|
||||
return {
|
||||
move: m ? decodeMove(m) : emptyMove(),
|
||||
game: g ? decodeGameView(g) : emptyGame(),
|
||||
rack,
|
||||
bagLen: r.bagLen(),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeHintResult(buf: Uint8Array): HintResult {
|
||||
@@ -424,11 +437,30 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
||||
switch (kind) {
|
||||
case 'your_turn': {
|
||||
const e = fb.YourTurnEvent.getRootAsYourTurnEvent(bb);
|
||||
return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()) };
|
||||
return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()), moveCount: e.moveCount() };
|
||||
}
|
||||
case 'opponent_moved': {
|
||||
const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb);
|
||||
return { kind: 'opponent_moved', gameId: s(e.gameId()), seat: e.seat(), action: s(e.action()), score: e.score(), total: e.total() };
|
||||
const m = e.move();
|
||||
const g = e.game();
|
||||
return {
|
||||
kind: 'opponent_moved',
|
||||
gameId: s(e.gameId()),
|
||||
move: m ? decodeMove(m) : undefined,
|
||||
game: g ? decodeGameView(g) : undefined,
|
||||
bagLen: e.bagLen(),
|
||||
};
|
||||
}
|
||||
case 'game_over': {
|
||||
const e = fb.GameOverEvent.getRootAsGameOverEvent(bb);
|
||||
const g = e.game();
|
||||
return {
|
||||
kind: 'game_over',
|
||||
gameId: s(e.gameId()),
|
||||
result: s(e.result()),
|
||||
scoreLine: s(e.scoreLine()),
|
||||
game: g ? decodeGameView(g) : undefined,
|
||||
};
|
||||
}
|
||||
case 'chat_message':
|
||||
return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) };
|
||||
@@ -438,11 +470,21 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
||||
}
|
||||
case 'match_found': {
|
||||
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
|
||||
return { kind: 'match_found', gameId: s(e.gameId()) };
|
||||
const st = e.state();
|
||||
return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
|
||||
}
|
||||
case 'notify': {
|
||||
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
|
||||
return { kind: 'notify', sub: s(e.kind()) };
|
||||
const acc = e.account();
|
||||
const inv = e.invitation();
|
||||
const st = e.state();
|
||||
return {
|
||||
kind: 'notify',
|
||||
sub: s(e.kind()),
|
||||
account: acc ? decodeAccountRef(acc) : undefined,
|
||||
invitation: inv ? decodeInvitationTable(inv) : undefined,
|
||||
state: st ? decodeStateViewTable(st) : undefined,
|
||||
};
|
||||
}
|
||||
case 'heartbeat':
|
||||
return { kind: 'heartbeat' };
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { MoveRecord, StateView } from './model';
|
||||
|
||||
interface CachedGame {
|
||||
export interface CachedGame {
|
||||
view: StateView;
|
||||
moves: MoveRecord[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta';
|
||||
import type { CachedGame } from './gamecache';
|
||||
import type { GameView, MoveRecord, StateView } from './model';
|
||||
|
||||
function gameView(moveCount: number, over = false): GameView {
|
||||
return {
|
||||
id: 'g1',
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: 'v1',
|
||||
status: over ? 'finished' : 'active',
|
||||
players: 2,
|
||||
toMove: 1,
|
||||
turnTimeoutSecs: 300,
|
||||
moveCount,
|
||||
endReason: over ? 'standard' : '',
|
||||
lastActivityUnix: 0,
|
||||
seats: [],
|
||||
};
|
||||
}
|
||||
|
||||
function move(player: number): MoveRecord {
|
||||
return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 };
|
||||
}
|
||||
|
||||
function cache(moveCount: number, seat = 0, over = false): CachedGame {
|
||||
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 };
|
||||
return { view, moves: [] };
|
||||
}
|
||||
|
||||
function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
|
||||
return { move: move(player), game: gameView(moveCount), bagLen };
|
||||
}
|
||||
|
||||
describe('seedInitialState', () => {
|
||||
it('wraps an initial view with an empty journal', () => {
|
||||
const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 };
|
||||
expect(seedInitialState(view)).toEqual({ view, moves: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyMoveDelta', () => {
|
||||
it('ignores a delta for a game not in the cache', () => {
|
||||
expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false });
|
||||
});
|
||||
|
||||
it('refetches when the payload carries no delta (pre-R4 peer / dropped payload)', () => {
|
||||
expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true });
|
||||
});
|
||||
|
||||
it('is a no-op for an already-applied move count (idempotent / own echo)', () => {
|
||||
expect(applyMoveDelta(cache(3), delta(3, 1))).toEqual({ refetch: false });
|
||||
expect(applyMoveDelta(cache(3), delta(2, 1))).toEqual({ refetch: false });
|
||||
});
|
||||
|
||||
it('refetches on a gap (an intermediate move was missed)', () => {
|
||||
expect(applyMoveDelta(cache(3), delta(5, 1))).toEqual({ refetch: true });
|
||||
});
|
||||
|
||||
it("refetches the actor's own move (the new rack is not in the delta)", () => {
|
||||
// seat 0 is this device; the move's player is 0 -> our own move, our rack changed.
|
||||
expect(applyMoveDelta(cache(3, 0), delta(4, 0))).toEqual({ refetch: true });
|
||||
});
|
||||
|
||||
it("applies an opponent's next move, preserving the rack and appending the move", () => {
|
||||
const before = cache(3, 0);
|
||||
const res = applyMoveDelta(before, delta(4, 1, 45));
|
||||
expect(res.refetch).toBe(false);
|
||||
expect(res.cache?.view.game.moveCount).toBe(4);
|
||||
expect(res.cache?.view.bagLen).toBe(45);
|
||||
expect(res.cache?.view.rack).toEqual(['a', 'b']); // unchanged by an opponent move
|
||||
expect(res.cache?.view.seat).toBe(0);
|
||||
expect(res.cache?.moves).toHaveLength(1);
|
||||
expect(res.cache?.moves[0].player).toBe(1);
|
||||
expect(before.moves).toHaveLength(0); // input not mutated
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyGameOver', () => {
|
||||
it('ignores a finished game not in the cache', () => {
|
||||
expect(applyGameOver(undefined, gameView(10, true))).toEqual({ refetch: false });
|
||||
});
|
||||
|
||||
it('refetches a missing final summary only when the game is not already finished', () => {
|
||||
expect(applyGameOver(cache(10, 0, false), undefined)).toEqual({ refetch: true });
|
||||
expect(applyGameOver(cache(10, 0, true), undefined)).toEqual({ refetch: false });
|
||||
});
|
||||
|
||||
it('refetches when the cached board is behind the final move count', () => {
|
||||
expect(applyGameOver(cache(9), gameView(10, true))).toEqual({ refetch: true });
|
||||
});
|
||||
|
||||
it('settles the final summary when the board is current', () => {
|
||||
const res = applyGameOver(cache(10), gameView(10, true));
|
||||
expect(res.refetch).toBe(false);
|
||||
expect(res.cache?.view.game.status).toBe('finished');
|
||||
expect(res.cache?.view.game.moveCount).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// Pure reducers that advance the per-game cache from live events (R4), so the UI renders a move
|
||||
// from the event without a follow-up game.state + game.history fetch. They never touch the network
|
||||
// or the cache store — the stream handler applies the returned cache and the game screen acts on
|
||||
// `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation.
|
||||
|
||||
import type { CachedGame } from './gamecache';
|
||||
import type { GameView, MoveRecord, StateView } from './model';
|
||||
|
||||
/** The fields an opponent_moved delta carries that advance a cached game. */
|
||||
export interface MoveDelta {
|
||||
move?: MoveRecord;
|
||||
game?: GameView;
|
||||
bagLen: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeltaResult is the outcome of applying an event: the advanced cache (set only when it changed)
|
||||
* and whether the caller must fall back to a full game.state + game.history fetch — a gap, a
|
||||
* missing payload, or the actor's own move on a device that has not drawn its new rack yet.
|
||||
*/
|
||||
export interface DeltaResult {
|
||||
cache?: CachedGame;
|
||||
refetch: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* seedInitialState builds a fresh cache entry from a started game's initial view (match_found /
|
||||
* game_started). A freshly started game has no moves, so the board replays from an empty journal.
|
||||
*/
|
||||
export function seedInitialState(state: StateView): CachedGame {
|
||||
return { view: state, moves: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* applyMoveDelta advances cached by one move from an opponent_moved delta, keyed on the post-move
|
||||
* count so it is idempotent (a re-delivered move, or the echo of one's own move, is a no-op) and
|
||||
* gap-safe (a missed move asks for a refetch). An opponent's move leaves the recipient's rack
|
||||
* unchanged, so it is preserved; the actor's own move drew a new rack the delta does not carry, so
|
||||
* a device still behind on its own move refetches to pick it up.
|
||||
*/
|
||||
export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): DeltaResult {
|
||||
// Nothing cached to advance (the game was never opened on this device): ignore it; the next open
|
||||
// cold-loads the game.
|
||||
if (!cached) return { refetch: false };
|
||||
// A pre-R4 peer, or a dropped payload, carried no delta: the open game must refetch.
|
||||
if (!d.move || !d.game) return { refetch: true };
|
||||
const have = cached.view.game.moveCount;
|
||||
const next = d.game.moveCount;
|
||||
if (next <= have) return { refetch: false }; // already applied (idempotent / own echo)
|
||||
if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed
|
||||
// The actor's own move changed their rack (a draw), which opponent_moved does not carry.
|
||||
if (d.move.player === cached.view.seat) return { refetch: true };
|
||||
const view: StateView = { ...cached.view, game: d.game, bagLen: d.bagLen };
|
||||
return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* applyGameOver settles a finished game from a game_over event's final summary (the adjusted
|
||||
* scores after rack penalties and the winner). It refetches when the cached board is behind the
|
||||
* final move count — the closing move was missed — so history is repaired, and is a harmless
|
||||
* re-settle when the closing opponent_moved already finished the game.
|
||||
*/
|
||||
export function applyGameOver(cached: CachedGame | undefined, game: GameView | undefined): DeltaResult {
|
||||
if (!cached) return { refetch: false };
|
||||
if (!game) return { refetch: cached.view.game.status !== 'finished' };
|
||||
if (cached.view.game.moveCount < game.moveCount) return { refetch: true };
|
||||
const view: StateView = { ...cached.view, game };
|
||||
return { cache: { view, moves: cached.moves }, refetch: false };
|
||||
}
|
||||
@@ -235,7 +235,7 @@ export class MockGateway implements GatewayClient {
|
||||
g.view.toMove = (seat + 1) % g.view.players;
|
||||
this.drafts.delete(gameId);
|
||||
this.scheduleOpponentReply(gameId);
|
||||
return { move: structuredClone(move), game: structuredClone(g.view) };
|
||||
return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen };
|
||||
}
|
||||
|
||||
private async simpleAction(
|
||||
@@ -276,7 +276,7 @@ export class MockGateway implements GatewayClient {
|
||||
g.view.toMove = (seat + 1) % g.view.players;
|
||||
this.scheduleOpponentReply(gameId);
|
||||
}
|
||||
return { move: structuredClone(move), game: structuredClone(g.view) };
|
||||
return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen };
|
||||
}
|
||||
|
||||
pass(gameId: string): Promise<MoveResult> {
|
||||
@@ -546,8 +546,8 @@ export class MockGateway implements GatewayClient {
|
||||
g.view.seats[opp].score = move.total;
|
||||
g.view.moveCount += 1;
|
||||
g.view.toMove = this.mySeat(g);
|
||||
this.emit({ kind: 'opponent_moved', gameId, seat: opp, action: 'play', score: 6, total: move.total });
|
||||
this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400 });
|
||||
this.emit({ kind: 'opponent_moved', gameId, move: structuredClone(move), game: structuredClone(g.view), bagLen: g.bagLen });
|
||||
this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400, moveCount: g.view.moveCount });
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
|
||||
+15
-5
@@ -70,6 +70,9 @@ export interface StateView {
|
||||
export interface MoveResult {
|
||||
move: MoveRecord;
|
||||
game: GameView;
|
||||
/** The actor's refilled rack after the move (R4), so the mover renders the next state without a refetch. */
|
||||
rack: string[];
|
||||
bagLen: number;
|
||||
}
|
||||
|
||||
export interface HintResult {
|
||||
@@ -223,12 +226,19 @@ export interface GameList {
|
||||
games: GameView[];
|
||||
}
|
||||
|
||||
/** A live event delivered over the Subscribe stream. */
|
||||
/**
|
||||
* A live event delivered over the Subscribe stream. The game events carry the move as a
|
||||
* delta — move plus the post-move summary (and the bag size) — the client applies to its
|
||||
* cached game without a refetch; match_found / game_started carry the recipient's initial
|
||||
* StateView; notify carries the changed lobby payload (R4). The enriched fields are optional
|
||||
* so a client falls back to a refetch when a payload is absent (a gap, or a pre-R4 peer).
|
||||
*/
|
||||
export type PushEvent =
|
||||
| { kind: 'your_turn'; gameId: string; deadlineUnix: number }
|
||||
| { kind: 'opponent_moved'; gameId: string; seat: number; action: string; score: number; total: number }
|
||||
| { kind: 'your_turn'; gameId: string; deadlineUnix: number; moveCount: number }
|
||||
| { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number }
|
||||
| { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView }
|
||||
| { kind: 'chat_message'; message: ChatMessage }
|
||||
| { kind: 'nudge'; gameId: string; fromUserId: string }
|
||||
| { kind: 'match_found'; gameId: string }
|
||||
| { kind: 'notify'; sub: string }
|
||||
| { kind: 'match_found'; gameId: string; state?: StateView }
|
||||
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
|
||||
| { kind: 'heartbeat' };
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
|
||||
// --- auto-match ---
|
||||
let searching = $state(false);
|
||||
// matched guards the teardown: once a match arrives (immediately, via the match_found push, or
|
||||
// via the fallback poll) onDestroy must not dequeue the game we just got.
|
||||
let matched = $state(false);
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stop() {
|
||||
@@ -35,6 +38,24 @@
|
||||
poll = null;
|
||||
}
|
||||
}
|
||||
// startPoll is the matchmaking fallback used only while the live stream is down: with the stream
|
||||
// up the match_found push drives navigation (R4). It polls lobby.poll every 2.5s.
|
||||
function startPoll() {
|
||||
if (poll) return;
|
||||
poll = setInterval(async () => {
|
||||
try {
|
||||
const p = await gateway.lobbyPoll();
|
||||
if (p.matched && p.game) {
|
||||
matched = true;
|
||||
searching = false;
|
||||
stop();
|
||||
navigate(`/game/${p.game.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
// cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match
|
||||
// is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the
|
||||
// reaper later substitutes a robot for a game the player abandoned (Stage 17 fix).
|
||||
@@ -47,31 +68,37 @@
|
||||
|
||||
async function find(v: Variant) {
|
||||
searching = true;
|
||||
matched = false;
|
||||
try {
|
||||
const r = await gateway.lobbyEnqueue(v);
|
||||
if (r.matched && r.game) {
|
||||
matched = true;
|
||||
searching = false;
|
||||
navigate(`/game/${r.game.id}`);
|
||||
return;
|
||||
}
|
||||
poll = setInterval(async () => {
|
||||
try {
|
||||
const p = await gateway.lobbyPoll();
|
||||
if (p.matched && p.game) {
|
||||
stop();
|
||||
searching = false;
|
||||
navigate(`/game/${p.game.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, 2500);
|
||||
} catch (e) {
|
||||
searching = false;
|
||||
handleError(e);
|
||||
}
|
||||
// No immediate match: wait for the match_found push; the effect below polls only when the
|
||||
// stream is down.
|
||||
}
|
||||
|
||||
// Poll for the match only while searching and the stream is down (the push cannot reach us);
|
||||
// stop once the stream is back or the search ends.
|
||||
$effect(() => {
|
||||
if (searching && !app.streamAlive) startPoll();
|
||||
else stop();
|
||||
});
|
||||
// match_found is handled globally (app.svelte navigates); end the search here too so onDestroy
|
||||
// does not cancel the match we just received.
|
||||
$effect(() => {
|
||||
if (app.lastEvent?.kind === 'match_found' && searching) {
|
||||
matched = true;
|
||||
searching = false;
|
||||
}
|
||||
});
|
||||
|
||||
// --- friend game ---
|
||||
let friends = $state<AccountRef[]>([]);
|
||||
let selected = $state<string[]>([]);
|
||||
@@ -120,8 +147,9 @@
|
||||
|
||||
onDestroy(() => {
|
||||
stop();
|
||||
// Abandoned mid-search (navigated away without Cancel): dequeue so we don't linger.
|
||||
if (searching) void gateway.lobbyCancel().catch(() => {});
|
||||
// Abandoned mid-search without a match (navigated away without Cancel): dequeue so we don't
|
||||
// linger. A received match (matched) must not be cancelled.
|
||||
if (searching && !matched) void gateway.lobbyCancel().catch(() => {});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user