Stage 8: UI social/account/history surfaces #9

Merged
developer merged 7 commits from feature/stage-8-social-account-history into master 2026-06-03 21:25:28 +00:00
126 changed files with 9000 additions and 221 deletions
+79 -1
View File
@@ -41,7 +41,7 @@ independent (see ARCHITECTURE §9.1).
| 5 | Robot opponent | **done** |
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** |
| 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** |
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | todo |
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** |
| 9 | Telegram integration (bot side-service, deep-link, push) | todo |
| 10 | Admin & dictionary ops (complaint review, version reload) | todo |
| 11 | Account linking & merge | todo |
@@ -538,6 +538,73 @@ Open details: deployment target/host; dashboards; load expectations.
(not a modal); word-check is alphabet/length-limited, cached and throttled. Design
details live in the new [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md).
- **Stage 8** (interview + implementation):
- **Scope = vertical slice continued**: the social/account/history operations were
opened end-to-end (UI → gateway transcode → backend REST → existing domain
services). The only new backend logic is `lobby.ListInvitations`,
`account.Store.GetStats`, a `game.SharedGame` seam (self-join on `game_players`),
the friend-code mechanism, and the friendships `declined`-status change.
- **Friends — two add paths** (interview, a deliberate plan change): **one-time
friend codes** (the player to be added issues a **6-digit numeric** code, 12 h TTL,
SHA-256-hashed like email codes, single active per issuer, single-use, redeem
rate-limited) and a **play-gated request** (`SendFriendRequest` now requires a
shared game — active or finished). An explicit **decline is permanent** (blocks
re-send), an **ignored request lazily expires after 30 days** and may be re-sent,
and a **code from the same person bypasses a prior decline**. This **supersedes
Stage 4's** "declining/cancelling deletes the row" (cancel by the requester still
deletes; decline now sets `status='declined'`). Migration **00006** widens
`friendships_status_chk` and adds **`friend_codes`** (jetgen regen). No public ID
or name search — discovery is codes + befriend-an-opponent.
- **Badges = poll + push** (interview): a new generic **`notify`** push event
(`notify.KindNotification`, sub-kinds friend_request/friend_added/invitation/
game_started) drives the lobby hamburger + "Friends" badge; emitted on friend-
request and invitation create and on the invitation's game start. The client polls
incoming requests + open invitations on lobby open and on focus (a missed push
while hidden), and re-polls on the `notify` event. Cursor-resume stays deferred
(single-instance MVP, §10).
- **Language single-control** (interview): the Settings language control writes
through to the durable account's `preferred_language` (`profile.update`); guests
keep only the client preference. Seeding the language from the platform/client on
first provider login is a **Stage 9** forward-note.
- **Guests = durable-only** (interview): friends/blocks/invitations/statistics and
history management are durable-account-only; a guest sees a sign-in prompt.
Binding an email to an existing guest (account linking) stays **Stage 11**.
- **GCG = finished-only + share** (interview): `game.ExportGCG` refuses an active
game (`game.ErrGameActive`) to avoid leaking the live journal mid-play; the client
exports via the **Web Share API** where available, else a **Blob download**
(`game-<id>.gcg`). Capacitor-native file save lands with the native wrapper.
- **IA = as the mockup** (interview): Friends (friends + blocks) is its own screen
from the lobby menu; Invitations is a lobby section + a "play with friends" mode in
New game; Stats is a lobby tab-bar button; profile editing is on Profile; history +
GCG stay in the game.
- **Wire/codegen**: new fbs tables (friends/blocks/invitations/profile-update/email-
bind/stats/gcg + `NotificationEvent`; `Profile` gained trailing away fields) in
`pkg/fbs`, regenerated to committed Go + TS; ~21 new gateway transcode ops; new
REST handlers under `/api/v1/user/{friends,blocks,invitations,profile,email,stats}`
and `…/games/:id/gcg`. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow
change (the Go and UI workflows already cover the new code).
- **UI polish (owner review follow-up)**: a copyable friend code (📋 + toast); the
lobby notification badge fixed (it had inherited the hamburger-bar style) and made
a proper count dot; Safari flex inputs given `min-width:0`; **profile-edit
validation on both UI and backend** — display-name format (letters + single
``/`.`/`_`, ≤ 32 runes), a **UTC-offset** timezone picker (`account.ResolveZone`
parses `±HH:MM` or IANA; DST is traded for the simple picker), a 10-minute away grid
capped at **12 h** (wrap-aware), email format — with Save disabled and invalid
fields red-bordered while any field is invalid; language stays in Settings; in a
game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge
became ⬆️/🛎️ icons; a **finished game** drops its last-word highlight, hides Check
word / Drop game, disables zoom, and draws an **inert footer** (greyed rack + tab
bar) instead of hiding it. Two **iPhone-simulator** passes then made the chat and
modals keyboard-aware (`dvh` plus a `visualViewport` listener that sizes the modal
backdrop to the area above the keyboard), reserved the rack height so a finished
footer does not collapse, and compacted the play-with-friends form (a searchable
bounded-scroll friend list, a pinned invite, and an explicit, **required game
type** — a smart default is TODO-6). On the owner's call, **every profile / new-game
picker is a native `<select>`** (the away window as hour + 10-minute selects, the
timezone as a UTC-offset select): native time/wheel inputs render differently per
OS and can't be forced to match, and a select also avoids the iOS "clear" button
that would empty a time field.
## Deferred TODOs (cross-stage)
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
@@ -570,3 +637,14 @@ Open details: deployment target/host; dashboards; load expectations.
value)` table so the UI stops duplicating it, and optionally moving tile exchange to
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
- **TODO-5 — QR / deep-link friend codes (owner's idea, Stage 8).** The one-time
friend code is entered by hand today. Once the Telegram/native deep-link scheme
exists (Stage 9), wrap a code in a deep link and render it as a QR so a friend can
add you by scanning rather than typing. The code semantics (12 h TTL, single use,
one active per issuer) stay as-is; only the delivery changes.
- **TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).**
The play-with-friends form has no preselected variant today (an empty, required
pick). Default it from the player's history (the variant they play most, from
`account_stats` or a games query), falling back to their interface language
(en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing
wrong.
+8 -3
View File
@@ -60,9 +60,14 @@ Stage 6 opens the backend to the edge. The route groups gain their first
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
slice of authenticated `/api/v1/user` operations (profile, submit play, game
state, lobby enqueue/poll, chat). A new `internal/notify` hub feeds a second
listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
live events (your-turn, opponent-moved, chat, nudge, match-found) to the gateway.
state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history
operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
gateway.
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
with no identity, excluded from statistics. The shared wire contracts live in the
sibling [`../pkg`](../pkg) module.
+1
View File
@@ -141,6 +141,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
matchmaker.SetNotifier(hub)
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
invitations.SetNotifier(hub)
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
srv := server.New(cfg.HTTPAddr, server.Deps{
+53 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
"unicode/utf8"
@@ -16,8 +17,18 @@ import (
"scrabble/backend/internal/postgres/jet/backend/table"
)
// maxDisplayName caps a display name's length in runes.
const maxDisplayName = 64
// maxDisplayName caps an editable display name's length in runes (the column itself
// is unbounded; auto-provisioned platform names bypass this editor validation).
const maxDisplayName = 32
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
const maxAwayWindow = 12 * time.Hour
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
// by a single space. No leading or trailing separator and no two adjacent separators,
// except "<dot|underscore> <space>". So "Name_P. Last" is valid, "Name P._Last" is not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
// ErrInvalidProfile is returned when a profile update carries an unacceptable
// field (an unknown language, an invalid timezone, or an over-long display name).
@@ -46,12 +57,15 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
return Account{}, fmt.Errorf("%w: preferred_language %q", ErrInvalidProfile, p.PreferredLanguage)
}
tz := strings.TrimSpace(p.TimeZone)
if _, err := time.LoadLocation(tz); err != nil {
return Account{}, fmt.Errorf("%w: time_zone %q: %v", ErrInvalidProfile, p.TimeZone, err)
if !validZone(tz) {
return Account{}, fmt.Errorf("%w: time_zone %q", ErrInvalidProfile, p.TimeZone)
}
name := strings.TrimSpace(p.DisplayName)
if utf8.RuneCountInString(name) > maxDisplayName {
return Account{}, fmt.Errorf("%w: display name exceeds %d characters", ErrInvalidProfile, maxDisplayName)
name, err := ValidateDisplayName(p.DisplayName)
if err != nil {
return Account{}, err
}
if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil {
return Account{}, err
}
stmt := table.Accounts.UPDATE(
@@ -74,3 +88,35 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
}
return modelToAccount(row), nil
}
// ValidateDisplayName trims surrounding whitespace and checks the editable
// display-name length (<= maxDisplayName runes) and format (displayNameRe),
// returning the cleaned name or ErrInvalidProfile. It is exported so the gateway
// boundary could reuse it; the UI mirrors the same rule.
func ValidateDisplayName(raw string) (string, error) {
name := strings.TrimSpace(raw)
if name == "" {
return "", fmt.Errorf("%w: display name is empty", ErrInvalidProfile)
}
if utf8.RuneCountInString(name) > maxDisplayName {
return "", fmt.Errorf("%w: display name exceeds %d characters", ErrInvalidProfile, maxDisplayName)
}
if !displayNameRe.MatchString(name) {
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
}
return name, nil
}
// validateAwayWindow checks that the daily away window's duration, wrapping across
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
// "no away time" and is allowed.
func validateAwayWindow(start, end time.Time) error {
mins := (end.Hour()*60 + end.Minute()) - (start.Hour()*60 + start.Minute())
if mins < 0 {
mins += 24 * 60
}
if time.Duration(mins)*time.Minute > maxAwayWindow {
return fmt.Errorf("%w: away window exceeds %s", ErrInvalidProfile, maxAwayWindow)
}
return nil
}
+8 -1
View File
@@ -5,22 +5,29 @@ import (
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
// TestUpdateProfileValidation checks that bad fields are rejected before any
// database access, so a nil-backed Store is enough to exercise the guards.
// database access, so a nil-backed Store is enough to exercise the guards. It also
// confirms UpdateProfile wires the Stage 8 validators (name format, away window,
// offset/IANA timezone), not just their unit tests in validate_test.go.
func TestUpdateProfileValidation(t *testing.T) {
s := &Store{}
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"}
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
tests := []struct {
name string
mut func(p *ProfileUpdate)
}{
{"unknown language", func(p *ProfileUpdate) { p.PreferredLanguage = "fr" }},
{"invalid timezone", func(p *ProfileUpdate) { p.TimeZone = "Mars/Olympus" }},
{"bad offset timezone", func(p *ProfileUpdate) { p.TimeZone = "+15:00" }},
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
{"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }},
{"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
+50
View File
@@ -0,0 +1,50 @@
package account
import (
"context"
"errors"
"fmt"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Stats is a durable account's lifetime record, written by the game domain on each
// finish and read for the player's statistics screen. MaxGamePoints is the best
// single game's total; MaxWordPoints is the best single move's score (which already
// includes every word it formed plus the all-tiles bonus).
type Stats struct {
Wins int
Losses int
Draws int
MaxGamePoints int
MaxWordPoints int
}
// GetStats returns the lifetime statistics for id. An account with no account_stats
// row yet — a guest, or a player who has not finished a game — yields the zero
// Stats (all counters zero) rather than an error.
func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
stmt := postgres.SELECT(table.AccountStats.AllColumns).
FROM(table.AccountStats).
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.AccountStats
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Stats{}, nil
}
return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err)
}
return Stats{
Wins: int(row.Wins),
Losses: int(row.Losses),
Draws: int(row.Draws),
MaxGamePoints: int(row.MaxGamePoints),
MaxWordPoints: int(row.MaxWordPoints),
}, nil
}
+56
View File
@@ -0,0 +1,56 @@
package account
import (
"regexp"
"strconv"
"time"
)
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
// Stage 8 profile editor stores (an offset dropdown rather than an IANA name).
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
// ok=false when name is not a well-formed offset within ±14:00.
func parseOffsetZone(name string) (*time.Location, bool) {
m := offsetZoneRe.FindStringSubmatch(name)
if m == nil {
return nil, false
}
h, _ := strconv.Atoi(m[2])
min, _ := strconv.Atoi(m[3])
if h > 14 || min > 59 || (h == 14 && min > 0) {
return nil, false
}
secs := h*3600 + min*60
if m[1] == "-" {
secs = -secs
}
return time.FixedZone(name, secs), true
}
// ResolveZone resolves a stored timezone — a fixed "±HH:MM" offset or an IANA name —
// to a *time.Location, falling back to UTC when it is empty or unrecognised, so a
// bad profile value never breaks the turn-timeout sweeper or the robot's sleep.
func ResolveZone(name string) *time.Location {
if name == "" {
return time.UTC
}
if loc, ok := parseOffsetZone(name); ok {
return loc
}
if loc, err := time.LoadLocation(name); err == nil {
return loc
}
return time.UTC
}
// validZone reports whether name is an acceptable timezone for a profile update —
// either a "±HH:MM" offset or a loadable IANA location.
func validZone(name string) bool {
if _, ok := parseOffsetZone(name); ok {
return true
}
_, err := time.LoadLocation(name)
return err == nil
}
+84
View File
@@ -0,0 +1,84 @@
package account
import (
"strings"
"testing"
"time"
)
func TestValidateDisplayName(t *testing.T) {
cases := map[string]struct {
in string
want string
ok bool
}{
"plain": {"Kaya", "Kaya", true},
"cyrillic": {"Кая", "Кая", true},
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
"single dot": {"Mr.Smith", "Mr.Smith", true},
"dot then space": {"Mr. Smith", "Mr. Smith", true},
"trim surrounding": {" Kaya ", "Kaya", true},
"adjacent specials": {"Name P._Last", "", false},
"two spaces": {"Name Last", "", false},
"leading special": {"_Name", "", false},
"trailing special": {"Name.", "", false},
"digit rejected": {"Name2", "", false},
"blank": {" ", "", false},
"too long": {strings.Repeat("a", 33), "", false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got, err := ValidateDisplayName(tc.in)
if tc.ok != (err == nil) || (tc.ok && got != tc.want) {
t.Fatalf("ValidateDisplayName(%q) = (%q, err=%v), want (%q, ok=%v)", tc.in, got, err, tc.want, tc.ok)
}
})
}
}
func TestValidateAwayWindow(t *testing.T) {
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
cases := map[string]struct {
start, end time.Time
ok bool
}{
"8h overnight": {hm(22, 0), hm(6, 0), true},
"12h exact": {hm(0, 0), hm(12, 0), true},
"13h daytime": {hm(8, 0), hm(21, 0), false},
"zero window": {hm(7, 0), hm(7, 0), true},
"13h wrap": {hm(20, 0), hm(9, 0), false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
if err := validateAwayWindow(tc.start, tc.end); tc.ok != (err == nil) {
t.Fatalf("validateAwayWindow = %v, want ok=%v", err, tc.ok)
}
})
}
}
func TestResolveAndValidZone(t *testing.T) {
offsetOf := func(name string) int {
_, off := time.Date(2024, 1, 1, 12, 0, 0, 0, ResolveZone(name)).Zone()
return off
}
if got := offsetOf("+03:00"); got != 3*3600 {
t.Errorf("+03:00 offset = %d, want 10800", got)
}
if got := offsetOf("-05:30"); got != -(5*3600 + 30*60) {
t.Errorf("-05:30 offset = %d", got)
}
if ResolveZone("nonsense-zone") != time.UTC {
t.Error("unknown zone should resolve to UTC")
}
for _, ok := range []string{"+05:45", "-12:00", "+14:00", "Europe/Moscow", "UTC"} {
if !validZone(ok) {
t.Errorf("validZone(%q) = false, want true", ok)
}
}
for _, bad := range []string{"+15:00", "03:00", "+3:00", "nope", "+05:99"} {
if validZone(bad) {
t.Errorf("validZone(%q) = true, want false", bad)
}
}
}
+16 -1
View File
@@ -566,6 +566,16 @@ func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.
return seats, g.ToMove, g.Status, nil
}
// SharedGame reports whether accounts a and b are seated together in any game
// (active or finished). It backs the social package's "befriend an opponent"
// request gate without exposing the games tables; a self-pair is never shared.
func (svc *Service) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) {
if a == b {
return false, nil
}
return svc.store.SharedGameExists(ctx, a, b)
}
// ListForAccount returns every game the account is seated in, newest first, for the
// lobby's active/finished lists. The live position is not loaded — the summaries come
// straight from the durable rows.
@@ -586,12 +596,17 @@ func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView,
return HistoryView{Game: g, Moves: moves}, nil
}
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
// is allowed only on a finished game: exporting an in-progress game would leak the
// full move journal mid-play, so an active game yields ErrGameActive.
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return "", err
}
if g.Status != StatusFinished {
return "", ErrGameActive
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return "", err
+18
View File
@@ -135,6 +135,24 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
return projectGame(grow, srows)
}
// SharedGameExists reports whether accounts a and b are both seated in at least
// one game (active or finished). It backs the social package's "befriend an
// opponent" gate via a self-join on game_players.
func (s *Store) SharedGameExists(ctx context.Context, a, b uuid.UUID) (bool, error) {
other := table.GamePlayers.AS("other")
stmt := postgres.SELECT(table.GamePlayers.GameID).
FROM(table.GamePlayers.INNER_JOIN(other, other.GameID.EQ(table.GamePlayers.GameID))).
WHERE(
table.GamePlayers.AccountID.EQ(postgres.UUID(a)).
AND(other.AccountID.EQ(postgres.UUID(b))),
).LIMIT(1)
var rows []model.GamePlayers
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return false, fmt.Errorf("game: shared game exists: %w", err)
}
return len(rows) > 0, nil
}
// ListGamesForAccount loads every game the account is seated in (active and
// finished), newest first, each joined with its ordered seats. It backs the lobby's
// "my games" lists.
+6 -10
View File
@@ -5,6 +5,8 @@ import (
"time"
"go.uber.org/zap"
"scrabble/backend/internal/account"
)
// effectiveDeadline is the instant a turn auto-resigns. It is the raw deadline
@@ -57,17 +59,11 @@ func minutesOfDay(t time.Time) int {
return t.Hour()*60 + t.Minute()
}
// loadLocation resolves an IANA timezone name, falling back to UTC when it is
// empty or unknown (so a bad profile value never breaks the sweeper).
// loadLocation resolves a stored timezone (an IANA name or a "±HH:MM" offset),
// falling back to UTC when it is empty or unknown (so a bad profile value never
// breaks the sweeper). It defers to account.ResolveZone, the single source of truth.
func loadLocation(name string) *time.Location {
if name == "" {
return time.UTC
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC
}
return loc
return account.ResolveZone(name)
}
// SweepTimeouts auto-resigns every active game whose current turn has exceeded
+3
View File
@@ -29,6 +29,9 @@ var (
ErrNotYourTurn = errors.New("game: not the player's turn")
// ErrFinished is returned when a transition is attempted on a finished game.
ErrFinished = errors.New("game: game is finished")
// ErrGameActive is returned when an operation allowed only on a finished game
// (such as a GCG export) is attempted while the game is still active.
ErrGameActive = errors.New("game: game is still active")
// ErrNotAPlayer is returned when an account is not seated in the game.
ErrNotAPlayer = errors.New("game: account is not a player in this game")
// ErrInvalidConfig is returned when CreateParams are not acceptable.
+15
View File
@@ -76,6 +76,21 @@ func TestAccountProvisionByIdentity(t *testing.T) {
}
}
// TestGetStatsZeroForFreshAccount checks that an account with no finished games
// reads back the zero statistics rather than an error (the Stage 8 stats screen).
func TestGetStatsZeroForFreshAccount(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
id := provisionAccount(t)
st, err := store.GetStats(ctx, id)
if err != nil {
t.Fatalf("get stats: %v", err)
}
if (st != account.Stats{}) {
t.Fatalf("fresh stats = %+v, want zero", st)
}
}
// identityConfirmed reads the confirmed flag for one identity directly.
func identityConfirmed(t *testing.T, kind, externalID string) bool {
t.Helper()
+24
View File
@@ -7,6 +7,7 @@ import (
"errors"
"regexp"
"testing"
"time"
"github.com/google/uuid"
@@ -157,3 +158,26 @@ func TestUpdateProfilePersists(t *testing.T) {
t.Errorf("profile did not persist: %+v", reloaded)
}
}
// TestUpdateProfileOffsetTimezone checks the Stage 8 UTC-offset timezone: it is
// accepted, persisted verbatim, and resolved to the right fixed offset.
func TestUpdateProfileOffsetTimezone(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc := provisionAccount(t)
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
DisplayName: "Kaya",
PreferredLanguage: "en",
TimeZone: "+03:00",
})
if err != nil {
t.Fatalf("update with offset timezone: %v", err)
}
if updated.TimeZone != "+03:00" {
t.Fatalf("timezone = %q, want +03:00", updated.TimeZone)
}
if _, off := time.Date(2024, 1, 1, 12, 0, 0, 0, account.ResolveZone(updated.TimeZone)).Zone(); off != 3*3600 {
t.Fatalf("ResolveZone offset = %d, want 10800", off)
}
}
+10
View File
@@ -555,3 +555,13 @@ func equalStrings(a, b []string) bool {
}
return true
}
// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export
// is allowed only once the game is over, so an active game leaks nothing mid-play.
func TestExportGCGRefusesActiveGame(t *testing.T) {
ctx := context.Background()
gameID, _ := newGameWithSeats(t, 2)
if _, err := newGameService().ExportGCG(ctx, gameID); !errors.Is(err, game.ErrGameActive) {
t.Fatalf("export of active game = %v, want ErrGameActive", err)
}
}
+28
View File
@@ -165,3 +165,31 @@ func TestInvitationCancelByInviter(t *testing.T) {
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err)
}
}
func TestListInvitations(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
inviter := provisionAccount(t)
invitee := provisionAccount(t)
inv, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
if err != nil {
t.Fatalf("create: %v", err)
}
// An open invitation appears for both the inviter and the invitee.
for _, who := range []uuid.UUID{inviter, invitee} {
list, err := svc.ListInvitations(ctx, who)
if err != nil {
t.Fatalf("list for %s: %v", who, err)
}
if len(list) != 1 || list[0].ID != inv.ID {
t.Fatalf("invitations for %s = %+v, want [%s]", who, list, inv.ID)
}
}
// Once accepted (the game starts), it is no longer an open invitation.
if _, err := svc.RespondInvitation(ctx, inv.ID, invitee, true); err != nil {
t.Fatalf("accept: %v", err)
}
if list, _ := svc.ListInvitations(ctx, inviter); len(list) != 0 {
t.Fatalf("started invitation still listed: %+v", list)
}
}
+114 -4
View File
@@ -43,7 +43,9 @@ func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
func TestFriendRequestLifecycle(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
a, b := provisionAccount(t), provisionAccount(t)
// A request is only allowed between players who share a game.
_, seats := newGameWithSeats(t, 2)
a, b := seats[0], seats[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
@@ -82,7 +84,7 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
// Toggle: the addressee does not accept friend requests.
a, b := provisionAccount(t), provisionAccount(t)
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil {
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil {
t.Fatalf("set toggle: %v", err)
}
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) {
@@ -102,7 +104,8 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
func TestBlockSeversFriendship(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
a, b := provisionAccount(t), provisionAccount(t)
_, seats := newGameWithSeats(t, 2)
a, b := seats[0], seats[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
}
@@ -117,6 +120,113 @@ func TestBlockSeversFriendship(t *testing.T) {
}
}
func TestFriendRequestRequiresSharedGame(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
a, b := provisionAccount(t), provisionAccount(t) // never played together
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrNoSharedGame) {
t.Fatalf("send without shared game = %v, want ErrNoSharedGame", err)
}
}
func TestFriendDeclineIsPermanentUntilCode(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
_, seats := newGameWithSeats(t, 2)
a, b := seats[0], seats[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
}
if err := svc.RespondFriendRequest(ctx, b, a, false); err != nil { // b declines a
t.Fatalf("decline: %v", err)
}
// An explicit decline is remembered: a cannot re-send.
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestDeclined) {
t.Fatalf("resend after decline = %v, want ErrRequestDeclined", err)
}
// But a one-time code from b bypasses the decline.
code, err := svc.IssueFriendCode(ctx, b)
if err != nil {
t.Fatalf("issue code: %v", err)
}
issuer, err := svc.RedeemFriendCode(ctx, a, code.Code)
if err != nil {
t.Fatalf("redeem: %v", err)
}
if issuer != b {
t.Fatalf("redeem issuer = %s, want b", issuer)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b {
t.Fatalf("friends of a after code = %v, want [b]", friends)
}
}
func TestFriendRequestResendAfterExpiry(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
_, seats := newGameWithSeats(t, 2)
a, b := seats[0], seats[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
}
// A request older than the 30-day window lazily expires: it leaves the incoming
// list and may be re-sent.
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, a, b); err != nil {
t.Fatalf("backdate: %v", err)
}
if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 0 {
t.Fatalf("expired request still incoming: %v", got)
}
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("resend after expiry: %v", err)
}
if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 1 || got[0] != a {
t.Fatalf("re-sent request not incoming: %v", got)
}
}
func TestFriendCodeSelfAndSingleUse(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
a := provisionAccount(t)
code, err := svc.IssueFriendCode(ctx, a)
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := svc.RedeemFriendCode(ctx, a, code.Code); !errors.Is(err, social.ErrSelfRelation) {
t.Fatalf("self redeem = %v, want ErrSelfRelation", err)
}
b := provisionAccount(t)
if _, err := svc.RedeemFriendCode(ctx, b, code.Code); err != nil {
t.Fatalf("redeem: %v", err)
}
// Single-use: redeeming the same code again fails.
if _, err := svc.RedeemFriendCode(ctx, provisionAccount(t), code.Code); !errors.Is(err, social.ErrFriendCodeInvalid) {
t.Fatalf("reused code = %v, want ErrFriendCodeInvalid", err)
}
if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a {
t.Fatalf("friends of b = %v, want [a]", friends)
}
}
func TestListBlocks(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
a, b := provisionAccount(t), provisionAccount(t)
if err := svc.Block(ctx, a, b); err != nil {
t.Fatalf("block: %v", err)
}
blocked, err := svc.ListBlocks(ctx, a)
if err != nil {
t.Fatalf("list blocks: %v", err)
}
if len(blocked) != 1 || blocked[0] != b {
t.Fatalf("blocks = %v, want [b]", blocked)
}
}
func TestChatPostListAndBlocks(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
@@ -147,7 +257,7 @@ func TestChatPostListAndBlocks(t *testing.T) {
if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil {
t.Fatalf("post 2: %v", err)
}
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil {
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil {
t.Fatalf("set block_chat: %v", err)
}
if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 {
+97 -1
View File
@@ -15,6 +15,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
@@ -76,6 +77,7 @@ type InvitationService struct {
games GameCreator
accounts *account.Store
blocker Blocker
pub notify.Publisher
now func() time.Time
}
@@ -88,10 +90,33 @@ func NewInvitationService(store *Store, games GameCreator, accounts *account.Sto
games: games,
accounts: accounts,
blocker: blocker,
pub: notify.Nop{},
now: func() time.Time { return time.Now().UTC() },
}
}
// SetNotifier installs the live-event publisher used to nudge invitees' lobby
// badges when an invitation arrives and to tell all seats when the game starts. It
// must be called during startup wiring; the default is notify.Nop (no live events,
// invitees still see the invitation on the next lobby poll).
func (svc *InvitationService) SetNotifier(p notify.Publisher) {
if p != nil {
svc.pub = p
}
}
// 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 {
return
}
intents := make([]notify.Intent, 0, len(userIDs))
for _, id := range userIDs {
intents = append(intents, notify.Notification(id, kind))
}
svc.pub.Publish(intents...)
}
// 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
@@ -147,7 +172,12 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil {
return Invitation{}, err
}
return svc.store.loadInvitation(ctx, id)
inv, err := svc.store.loadInvitation(ctx, id)
if err != nil {
return Invitation{}, err
}
svc.notify(notify.NotifyInvitation, inviteeIDs...)
return inv, nil
}
// RespondInvitation records accountID's accept or decline of an invitation. A
@@ -194,6 +224,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...)
return nil
}
@@ -207,6 +238,26 @@ func (svc *InvitationService) GetInvitation(ctx context.Context, invitationID uu
return svc.store.loadInvitation(ctx, invitationID)
}
// ListInvitations returns the open (pending, not yet expired) invitations that
// touch accountID, whether as the inviter or an invitee, newest first. Expired
// invitations are hidden here (lazy expiry); the row's transition to 'expired'
// happens on the next response or cancel.
func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uuid.UUID) ([]Invitation, error) {
ids, err := svc.store.listInvitationIDs(ctx, accountID, svc.now())
if err != nil {
return nil, err
}
out := make([]Invitation, 0, len(ids))
for _, id := range ids {
inv, err := svc.store.loadInvitation(ctx, id)
if err != nil {
return nil, err
}
out = append(out, inv)
}
return out, nil
}
// invitationInsert carries the immutable fields of a new invitation.
type invitationInsert struct {
id uuid.UUID
@@ -297,6 +348,51 @@ func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, e
return inv, nil
}
// listInvitationIDs returns the ids of every pending, still-live invitation that
// accountID is part of (as inviter or invitee), newest first. It runs two queries
// (one per role) and merges them, avoiding a correlated subquery.
func (s *Store) listInvitationIDs(ctx context.Context, accountID uuid.UUID, now time.Time) ([]uuid.UUID, error) {
live := table.GameInvitations.Status.EQ(postgres.String(invitationPending)).
AND(table.GameInvitations.ExpiresAt.GT(postgres.TimestampzT(now)))
var asInviter []model.GameInvitations
q1 := postgres.SELECT(table.GameInvitations.InvitationID, table.GameInvitations.CreatedAt).
FROM(table.GameInvitations).
WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(accountID)).AND(live))
if err := q1.QueryContext(ctx, s.db, &asInviter); err != nil {
return nil, fmt.Errorf("lobby: list invitations as inviter: %w", err)
}
var asInvitee []model.GameInvitations
q2 := postgres.SELECT(table.GameInvitations.InvitationID, table.GameInvitations.CreatedAt).
FROM(table.GameInvitations.INNER_JOIN(
table.GameInvitationInvitees,
table.GameInvitationInvitees.InvitationID.EQ(table.GameInvitations.InvitationID),
)).
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID)).AND(live))
if err := q2.QueryContext(ctx, s.db, &asInvitee); err != nil {
return nil, fmt.Errorf("lobby: list invitations as invitee: %w", err)
}
seen := make(map[uuid.UUID]bool, len(asInviter)+len(asInvitee))
merged := make([]model.GameInvitations, 0, len(asInviter)+len(asInvitee))
for _, r := range append(asInviter, asInvitee...) {
if seen[r.InvitationID] {
continue
}
seen[r.InvitationID] = true
merged = append(merged, r)
}
slices.SortFunc(merged, func(a, b model.GameInvitations) int {
return b.CreatedAt.Compare(a.CreatedAt)
})
out := make([]uuid.UUID, len(merged))
for i, r := range merged {
out[i] = r.InvitationID
}
return out, nil
}
// respondTx applies an invitee's response inside a row-locked transaction so
// concurrent responses serialise and exactly one accept can complete the set.
func (s *Store) respondTx(ctx context.Context, invitationID, accountID uuid.UUID, accept bool, now time.Time) (respondResult, error) {
+13
View File
@@ -83,6 +83,19 @@ func MatchFound(userID, gameID uuid.UUID) Intent {
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, NotifyInvitation, NotifyGameStarted) the client may use to
// scope its refresh.
func Notification(userID uuid.UUID, kind string) Intent {
b := flatbuffers.NewBuilder(32)
k := b.CreateString(kind)
fb.NotificationEventStart(b)
fb.NotificationEventAddKind(b, k)
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 {
+12
View File
@@ -24,6 +24,18 @@ const (
KindChatMessage = "chat_message"
KindNudge = "nudge"
KindMatchFound = "match_found"
// KindNotification is a lightweight "re-poll your lobby counters" signal
// (incoming friend requests, invitations) that drives the lobby badge.
KindNotification = "notify"
)
// Notification sub-kinds carried in a KindNotification event payload; the client
// re-fetches its lobby counters on any of them.
const (
NotifyFriendRequest = "friend_request"
NotifyFriendAdded = "friend_added"
NotifyInvitation = "invitation"
NotifyGameStarted = "game_started"
)
// Intent is one live event destined for a single user. Payload is the
+12
View File
@@ -98,3 +98,15 @@ func TestChatMessagePayloadRoundTrips(t *testing.T) {
t.Fatalf("decoded wrong chat message: %+v", ev)
}
}
func TestNotificationPayloadRoundTrips(t *testing.T) {
uid := uuid.New()
in := notify.Notification(uid, notify.NotifyFriendRequest)
if in.UserID != uid || in.Kind != notify.KindNotification || in.EventID == "" {
t.Fatalf("intent metadata wrong: %+v", in)
}
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
if got := string(ev.Kind()); got != notify.NotifyFriendRequest {
t.Fatalf("notification sub-kind = %q, want %q", got, notify.NotifyFriendRequest)
}
}
@@ -0,0 +1,22 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type FriendCodes struct {
CodeID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
CodeHash string
ExpiresAt time.Time
ConsumedAt *time.Time
CreatedAt time.Time
}
@@ -0,0 +1,93 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var FriendCodes = newFriendCodesTable("backend", "friend_codes", "")
type friendCodesTable struct {
postgres.Table
// Columns
CodeID postgres.ColumnString
AccountID postgres.ColumnString
CodeHash postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz
ConsumedAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type FriendCodesTable struct {
friendCodesTable
EXCLUDED friendCodesTable
}
// AS creates new FriendCodesTable with assigned alias
func (a FriendCodesTable) AS(alias string) *FriendCodesTable {
return newFriendCodesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new FriendCodesTable with assigned schema name
func (a FriendCodesTable) FromSchema(schemaName string) *FriendCodesTable {
return newFriendCodesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new FriendCodesTable with assigned table prefix
func (a FriendCodesTable) WithPrefix(prefix string) *FriendCodesTable {
return newFriendCodesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new FriendCodesTable with assigned table suffix
func (a FriendCodesTable) WithSuffix(suffix string) *FriendCodesTable {
return newFriendCodesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newFriendCodesTable(schemaName, tableName, alias string) *FriendCodesTable {
return &FriendCodesTable{
friendCodesTable: newFriendCodesTableImpl(schemaName, tableName, alias),
EXCLUDED: newFriendCodesTableImpl("", "excluded", ""),
}
}
func newFriendCodesTableImpl(schemaName, tableName, alias string) friendCodesTable {
var (
CodeIDColumn = postgres.StringColumn("code_id")
AccountIDColumn = postgres.StringColumn("account_id")
CodeHashColumn = postgres.StringColumn("code_hash")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{CodeIDColumn, AccountIDColumn, CodeHashColumn, ExpiresAtColumn, ConsumedAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, CodeHashColumn, ExpiresAtColumn, ConsumedAtColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return friendCodesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
CodeID: CodeIDColumn,
AccountID: AccountIDColumn,
CodeHash: CodeHashColumn,
ExpiresAt: ExpiresAtColumn,
ConsumedAt: ConsumedAtColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -16,6 +16,7 @@ func UseSchema(schema string) {
ChatMessages = ChatMessages.FromSchema(schema)
Complaints = Complaints.FromSchema(schema)
EmailConfirmations = EmailConfirmations.FromSchema(schema)
FriendCodes = FriendCodes.FromSchema(schema)
Friendships = Friendships.FromSchema(schema)
GameInvitationInvitees = GameInvitationInvitees.FromSchema(schema)
GameInvitations = GameInvitations.FromSchema(schema)
@@ -0,0 +1,45 @@
-- +goose Up
-- Stage 8 social UI: two changes to the friend graph.
--
-- 1. A declined friend request is now remembered permanently (status 'declined')
-- instead of deleting the row, so a recipient's explicit "no" blocks the same
-- requester from re-sending (anti-spam). An ignored request still lazily
-- expires (30 days, computed from created_at in Go) and can then be re-sent; a
-- one-time friend code from the same person bypasses a prior decline. This
-- widens friendships_status_chk; the Stage 4 "declining deletes the row" rule
-- is superseded (cancelling by the requester still deletes).
--
-- 2. friend_codes backs the code-redeem add-a-friend path: the player who wants to
-- be added issues a one-time 6-digit numeric code; whoever enters it becomes
-- their friend immediately. Only the hex-encoded SHA-256 of the code is stored
-- (the plaintext is never persisted, matching the session and email-code
-- models); expires_at bounds the 12h TTL and consumed_at marks single use. At
-- most one live code exists per issuer (issuing a new one clears the prior
-- unconsumed code, enforced in Go). This adds a table, so the generated jet code
-- is regenerated (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE friendships
DROP CONSTRAINT friendships_status_chk,
ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined'));
CREATE TABLE friend_codes (
code_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
code_hash text NOT NULL,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Backs "clear the issuer's prior live code" on issue.
CREATE INDEX friend_codes_account_idx ON friend_codes (account_id);
-- Backs the redeem lookup by code hash.
CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE friend_codes;
ALTER TABLE friendships
DROP CONSTRAINT friendships_status_chk,
ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted'));
+5 -10
View File
@@ -6,6 +6,7 @@ import (
"math"
"time"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
)
@@ -136,17 +137,11 @@ func asleep(opponentTZ string, drift time.Duration, now time.Time) bool {
return h >= sleepStartHour && h < sleepEndHour
}
// loadLocation resolves an IANA timezone name, falling back to UTC when it is
// empty or unknown (so a bad opponent profile never breaks the driver).
// loadLocation resolves a stored timezone (an IANA name or a "±HH:MM" offset),
// falling back to UTC when it is empty or unknown (so a bad opponent profile never
// breaks the driver). It defers to account.ResolveZone.
func loadLocation(name string) *time.Location {
if name == "" {
return time.UTC
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC
}
return loc
return account.ResolveZone(name)
}
// selectMove chooses the robot's action given the ranked candidate plays, the
+9 -1
View File
@@ -32,12 +32,15 @@ type resolveResponse struct {
UserID string `json:"user_id"`
}
// profileResponse is the authenticated account's own profile.
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
// are the daily away window's "HH:MM" local-time bounds (in TimeZone).
type profileResponse struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
AwayStart string `json:"away_start"`
AwayEnd string `json:"away_end"`
HintBalance int `json:"hint_balance"`
BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"`
@@ -149,6 +152,8 @@ func profileResponseFor(acc account.Account) profileResponse {
DisplayName: acc.DisplayName,
PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart.Format(awayTimeLayout),
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
HintBalance: acc.HintBalance,
BlockChat: acc.BlockChat,
BlockFriendRequests: acc.BlockFriendRequests,
@@ -156,6 +161,9 @@ func profileResponseFor(acc account.Account) profileResponse {
}
}
// awayTimeLayout is the "HH:MM" wire form of the daily away-window bounds.
const awayTimeLayout = "15:04"
// gameDTOFromGame projects a game.Game into its DTO.
func gameDTOFromGame(g game.Game) gameDTO {
seats := make([]seatDTO, 0, len(g.Seats))
+18
View File
@@ -96,6 +96,24 @@ func TestGameDTOFromGame(t *testing.T) {
}
}
func TestProfileResponseForAwayWindow(t *testing.T) {
acc := account.Account{
ID: uuid.New(),
DisplayName: "Kaya",
PreferredLanguage: "ru",
TimeZone: "Europe/Moscow",
AwayStart: time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC),
AwayEnd: time.Date(0, 1, 1, 7, 30, 0, 0, time.UTC),
}
dto := profileResponseFor(acc)
if dto.AwayStart != "00:00" || dto.AwayEnd != "07:30" {
t.Fatalf("away window = (%q, %q), want (00:00, 07:30)", dto.AwayStart, dto.AwayEnd)
}
if dto.PreferredLanguage != "ru" || dto.TimeZone != "Europe/Moscow" {
t.Fatalf("profile dto mismatch: %+v", dto)
}
}
func TestMoveRecordDTOFrom(t *testing.T) {
rec := engine.MoveRecord{
Player: 1,
+61
View File
@@ -35,6 +35,12 @@ func (s *Server) registerRoutes() {
u := s.user
if s.accounts != nil {
u.GET("/profile", s.handleProfile)
u.PUT("/profile", s.handleUpdateProfile)
u.GET("/stats", s.handleStats)
}
if s.emails != nil {
u.POST("/email/request", s.handleEmailBindRequest)
u.POST("/email/confirm", s.handleEmailBindConfirm)
}
if s.games != nil {
u.GET("/games", s.handleListGames)
@@ -48,15 +54,34 @@ func (s *Server) registerRoutes() {
u.GET("/games/:id/check_word", s.handleCheckWord)
u.POST("/games/:id/complaint", s.handleComplaint)
u.GET("/games/:id/history", s.handleHistory)
u.GET("/games/:id/gcg", s.handleExportGCG)
}
if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue)
u.GET("/lobby/poll", s.handlePoll)
}
if s.invitations != nil {
u.GET("/invitations", s.handleListInvitations)
u.POST("/invitations", s.handleCreateInvitation)
u.POST("/invitations/:id/accept", s.handleAcceptInvitation)
u.POST("/invitations/:id/decline", s.handleDeclineInvitation)
u.DELETE("/invitations/:id", s.handleCancelInvitation)
}
if s.social != nil {
u.POST("/games/:id/chat", s.handleChatPost)
u.GET("/games/:id/chat", s.handleChatList)
u.POST("/games/:id/nudge", s.handleNudge)
u.GET("/friends", s.handleListFriends)
u.GET("/friends/incoming", s.handleIncomingRequests)
u.POST("/friends/request", s.handleFriendRequest)
u.POST("/friends/respond", s.handleFriendRespond)
u.POST("/friends/cancel", s.handleFriendCancel)
u.DELETE("/friends/:id", s.handleUnfriend)
u.POST("/friends/code", s.handleIssueFriendCode)
u.POST("/friends/code/redeem", s.handleRedeemFriendCode)
u.GET("/blocks", s.handleListBlocks)
u.POST("/blocks", s.handleBlock)
u.DELETE("/blocks/:id", s.handleUnblock)
}
s.admin.GET("/ping", s.handleAdminPing)
}
@@ -117,8 +142,30 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "not_your_turn"
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
return http.StatusConflict, "game_finished"
case errors.Is(err, game.ErrGameActive):
return http.StatusConflict, "game_active"
case errors.Is(err, account.ErrInvalidProfile):
return http.StatusBadRequest, "invalid_profile"
case errors.Is(err, account.ErrAlreadyConfirmed):
return http.StatusConflict, "already_confirmed"
case errors.Is(err, lobby.ErrAlreadyQueued):
return http.StatusConflict, "already_queued"
case errors.Is(err, lobby.ErrInvalidInvitation):
return http.StatusBadRequest, "invalid_invitation"
case errors.Is(err, lobby.ErrInvitationBlocked):
return http.StatusForbidden, "invitation_blocked"
case errors.Is(err, lobby.ErrInvitationNotFound):
return http.StatusNotFound, "invitation_not_found"
case errors.Is(err, lobby.ErrInvitationNotPending):
return http.StatusConflict, "invitation_not_pending"
case errors.Is(err, lobby.ErrInvitationExpired):
return http.StatusConflict, "invitation_expired"
case errors.Is(err, lobby.ErrNotInvited):
return http.StatusForbidden, "not_invited"
case errors.Is(err, lobby.ErrAlreadyResponded):
return http.StatusConflict, "already_responded"
case errors.Is(err, lobby.ErrNotInviter):
return http.StatusForbidden, "not_inviter"
case errors.Is(err, game.ErrInvalidConfig):
return http.StatusBadRequest, "invalid_config"
case errors.Is(err, game.ErrNoHintAvailable):
@@ -142,6 +189,20 @@ func statusForError(err error) (int, string) {
errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent),
errors.Is(err, social.ErrNudgeTooSoon):
return http.StatusUnprocessableEntity, "chat_rejected"
case errors.Is(err, social.ErrSelfRelation):
return http.StatusBadRequest, "self_relation"
case errors.Is(err, social.ErrRequestExists):
return http.StatusConflict, "request_exists"
case errors.Is(err, social.ErrRequestBlocked):
return http.StatusForbidden, "request_blocked"
case errors.Is(err, social.ErrRequestNotFound):
return http.StatusNotFound, "request_not_found"
case errors.Is(err, social.ErrNoSharedGame):
return http.StatusForbidden, "no_shared_game"
case errors.Is(err, social.ErrRequestDeclined):
return http.StatusConflict, "request_declined"
case errors.Is(err, social.ErrFriendCodeInvalid):
return http.StatusUnprocessableEntity, "friend_code_invalid"
default:
return http.StatusInternalServerError, "internal"
}
+157
View File
@@ -0,0 +1,157 @@
package server
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"scrabble/backend/internal/account"
)
// The /api/v1/user account handlers wire profile editing, email binding and the
// statistics read (Stage 8). They follow handlers_user.go: X-User-ID identity, a
// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
// client sends the complete desired profile.
// updateProfileRequest is the full editable profile. away_start/away_end are
// "HH:MM" local-time bounds of the daily away window.
type updateProfileRequest struct {
DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
AwayStart string `json:"away_start"`
AwayEnd string `json:"away_end"`
BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"`
}
// statsDTO is a durable account's lifetime statistics (the derived games-played and
// win-rate are computed client-side).
type statsDTO struct {
Wins int `json:"wins"`
Losses int `json:"losses"`
Draws int `json:"draws"`
MaxGamePoints int `json:"max_game_points"`
MaxWordPoints int `json:"max_word_points"`
}
// emailBindRequestBody starts binding an email to the caller's account.
type emailBindRequestBody struct {
Email string `json:"email"`
}
// emailBindConfirmBody completes binding an email with its confirm code.
type emailBindConfirmBody struct {
Email string `json:"email"`
Code string `json:"code"`
}
// parseAwayTime parses an "HH:MM" away-window bound.
func parseAwayTime(s string) (time.Time, bool) {
t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s))
if err != nil {
return time.Time{}, false
}
return t, true
}
// handleUpdateProfile overwrites the caller's editable profile fields.
func (s *Server) handleUpdateProfile(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req updateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
awayStart, ok := parseAwayTime(req.AwayStart)
if !ok {
abortBadRequest(c, "away_start must be HH:MM")
return
}
awayEnd, ok := parseAwayTime(req.AwayEnd)
if !ok {
abortBadRequest(c, "away_end must be HH:MM")
return
}
acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{
DisplayName: req.DisplayName,
PreferredLanguage: req.PreferredLanguage,
TimeZone: req.TimeZone,
AwayStart: awayStart,
AwayEnd: awayEnd,
BlockChat: req.BlockChat,
BlockFriendRequests: req.BlockFriendRequests,
})
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, profileResponseFor(acc))
}
// handleStats returns the caller's lifetime statistics.
func (s *Server) handleStats(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
st, err := s.accounts.GetStats(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, statsDTO{
Wins: st.Wins,
Losses: st.Losses,
Draws: st.Draws,
MaxGamePoints: st.MaxGamePoints,
MaxWordPoints: st.MaxWordPoints,
})
}
// handleEmailBindRequest issues a confirm code to bind an email to the caller.
func (s *Server) handleEmailBindRequest(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req emailBindRequestBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if err := s.emails.RequestCode(c.Request.Context(), uid, req.Email); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleEmailBindConfirm verifies the code and binds the email, returning the
// updated profile.
func (s *Server) handleEmailBindConfirm(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req emailBindConfirmBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
acc, err := s.emails.ConfirmCode(c.Request.Context(), uid, req.Email, req.Code)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, profileResponseFor(acc))
}
@@ -0,0 +1,76 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// The /api/v1/user/blocks/* handlers wire the per-user block list (Stage 8). A block
// is mutual in effect (the social checks apply it both ways) and severs any
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
// account-ref resolution.
// blockListDTO is the accounts the caller has blocked.
type blockListDTO struct {
Blocked []accountRefDTO `json:"blocked"`
}
// handleBlock blocks the body-supplied account.
func (s *Server) handleBlock(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req targetIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
target, ok := parseUUIDField(req.AccountID)
if !ok {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.Block(c.Request.Context(), uid, target); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleUnblock removes the caller's block on the :id account.
func (s *Server) handleUnblock(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
target, err := uuid.Parse(c.Param("id"))
if err != nil {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.Unblock(c.Request.Context(), uid, target); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleListBlocks returns the accounts the caller has blocked.
func (s *Server) handleListBlocks(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
ids, err := s.social.ListBlocks(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, blockListDTO{Blocked: s.accountRefs(c.Request.Context(), ids)})
}
+254
View File
@@ -0,0 +1,254 @@
package server
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// The /api/v1/user/friends/* handlers wire the social friend graph (Stage 8): the
// befriend-an-opponent request flow, the one-time friend-code path, and the
// friends/incoming lists. They follow handlers_user.go: X-User-ID identity, a domain
// call, a JSON DTO. Account ids are projected to {id, display_name} refs resolved
// from the account store, mirroring fillSeatNames.
// accountRefDTO is a referenced account with its display name resolved for the UI.
type accountRefDTO struct {
AccountID string `json:"account_id"`
DisplayName string `json:"display_name"`
}
// friendListDTO is the caller's accepted friends.
type friendListDTO struct {
Friends []accountRefDTO `json:"friends"`
}
// incomingListDTO is the friend requests awaiting the caller's response.
type incomingListDTO struct {
Requests []accountRefDTO `json:"requests"`
}
// friendCodeDTO is a freshly issued one-time friend code (returned once).
type friendCodeDTO struct {
Code string `json:"code"`
ExpiresAtUnix int64 `json:"expires_at_unix"`
}
// redeemResultDTO reports the new friend gained by redeeming a code.
type redeemResultDTO struct {
Friend accountRefDTO `json:"friend"`
}
// targetIDRequest carries a single counterpart account id.
type targetIDRequest struct {
AccountID string `json:"account_id"`
}
// friendRespondRequest accepts or declines a pending request from a requester.
type friendRespondRequest struct {
RequesterID string `json:"requester_id"`
Accept bool `json:"accept"`
}
// redeemCodeRequest carries a friend code to redeem.
type redeemCodeRequest struct {
Code string `json:"code"`
}
// namedRef resolves a single account id into its display-name ref, caching the
// lookup in memo so a caller can share it across many refs in one response.
func (s *Server) namedRef(ctx context.Context, id uuid.UUID, memo map[string]string) accountRefDTO {
key := id.String()
name, ok := memo[key]
if !ok {
if acc, err := s.accounts.GetByID(ctx, id); err == nil {
name = acc.DisplayName
}
memo[key] = name
}
return accountRefDTO{AccountID: key, DisplayName: name}
}
// accountRefs resolves a list of account ids into display-name refs, memoising
// lookups within the call.
func (s *Server) accountRefs(ctx context.Context, ids []uuid.UUID) []accountRefDTO {
memo := map[string]string{}
out := make([]accountRefDTO, 0, len(ids))
for _, id := range ids {
out = append(out, s.namedRef(ctx, id, memo))
}
return out
}
// accountRef resolves a single account id into its display-name ref.
func (s *Server) accountRef(ctx context.Context, id uuid.UUID) accountRefDTO {
return s.namedRef(ctx, id, map[string]string{})
}
// parseUUIDField parses a body-supplied account id, trimming whitespace.
func parseUUIDField(s string) (uuid.UUID, bool) {
id, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
return uuid.UUID{}, false
}
return id, true
}
// handleFriendRequest sends a friend request to an opponent the caller has played.
func (s *Server) handleFriendRequest(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req targetIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
target, ok := parseUUIDField(req.AccountID)
if !ok {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.SendFriendRequest(c.Request.Context(), uid, target); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleFriendRespond accepts or declines a pending incoming request.
func (s *Server) handleFriendRespond(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req friendRespondRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
requester, ok := parseUUIDField(req.RequesterID)
if !ok {
abortBadRequest(c, "invalid requester id")
return
}
if err := s.social.RespondFriendRequest(c.Request.Context(), uid, requester, req.Accept); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleFriendCancel withdraws the caller's own pending request.
func (s *Server) handleFriendCancel(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req targetIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
target, ok := parseUUIDField(req.AccountID)
if !ok {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.CancelFriendRequest(c.Request.Context(), uid, target); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleUnfriend removes a friendship with the :id account.
func (s *Server) handleUnfriend(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
other, err := uuid.Parse(c.Param("id"))
if err != nil {
abortBadRequest(c, "invalid account id")
return
}
if err := s.social.Unfriend(c.Request.Context(), uid, other); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleListFriends returns the caller's accepted friends.
func (s *Server) handleListFriends(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
ids, err := s.social.ListFriends(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, friendListDTO{Friends: s.accountRefs(c.Request.Context(), ids)})
}
// handleIncomingRequests returns the friend requests awaiting the caller.
func (s *Server) handleIncomingRequests(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
ids, err := s.social.ListIncomingRequests(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
}
// handleIssueFriendCode issues a one-time add-a-friend code for the caller.
func (s *Server) handleIssueFriendCode(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
code, err := s.social.IssueFriendCode(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, friendCodeDTO{Code: code.Code, ExpiresAtUnix: code.ExpiresAt.Unix()})
}
// handleRedeemFriendCode redeems a friend code, befriending its issuer.
func (s *Server) handleRedeemFriendCode(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req redeemCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
issuer, err := s.social.RedeemFriendCode(c.Request.Context(), uid, strings.TrimSpace(req.Code))
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, redeemResultDTO{Friend: s.accountRef(c.Request.Context(), issuer)})
}
+26
View File
@@ -243,6 +243,32 @@ func (s *Server) handleHistory(c *gin.Context) {
c.JSON(http.StatusOK, historyDTO{GameID: gameID.String(), Moves: moves})
}
// gcgDTO is a game's GCG export: a suggested filename plus the GCG text.
type gcgDTO struct {
GameID string `json:"game_id"`
Filename string `json:"filename"`
Content string `json:"content"`
}
// handleExportGCG returns a finished game's GCG transcript for download/share. The
// service refuses an active game (ErrGameActive) to avoid leaking the live journal.
func (s *Server) handleExportGCG(c *gin.Context) {
_, gameID, ok := s.userGame(c)
if !ok {
return
}
gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, gcgDTO{
GameID: gameID.String(),
Filename: "game-" + gameID.String() + ".gcg",
Content: gcg,
})
}
// handleListGames returns the caller's active and finished games for the lobby.
func (s *Server) handleListGames(c *gin.Context) {
uid, ok := userID(c)
@@ -0,0 +1,205 @@
package server
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/lobby"
)
// The /api/v1/user/invitations/* handlers wire friend-game invitations (Stage 8):
// create a 2-4 player invitation, accept/decline as an invitee, cancel as the
// inviter, and list the open invitations touching the caller. Display names for the
// inviter and invitees are resolved from the account store.
// invitationInviteeDTO is one invitee's seat and response with their name resolved.
type invitationInviteeDTO struct {
AccountID string `json:"account_id"`
DisplayName string `json:"display_name"`
Seat int `json:"seat"`
Response string `json:"response"`
}
// invitationDTO is a friend-game invitation with its settings and invitees.
type invitationDTO struct {
ID string `json:"id"`
Inviter accountRefDTO `json:"inviter"`
Invitees []invitationInviteeDTO `json:"invitees"`
Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"`
Status string `json:"status"`
GameID string `json:"game_id,omitempty"`
ExpiresAtUnix int64 `json:"expires_at_unix"`
}
// invitationListDTO is the caller's open invitations.
type invitationListDTO struct {
Invitations []invitationDTO `json:"invitations"`
}
// createInvitationRequest proposes a friend game to the named invitees.
type createInvitationRequest struct {
InviteeIDs []string `json:"invitee_ids"`
Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"`
}
// invitationDTOFrom projects a lobby invitation, resolving names through memo.
func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, memo map[string]string) invitationDTO {
dto := invitationDTO{
ID: inv.ID.String(),
Inviter: s.namedRef(ctx, inv.InviterID, memo),
Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)),
Variant: inv.Settings.Variant.String(),
TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()),
HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles.String(),
Status: inv.Status,
ExpiresAtUnix: inv.ExpiresAt.Unix(),
}
if inv.GameID != nil {
dto.GameID = inv.GameID.String()
}
for _, iv := range inv.Invitees {
ref := s.namedRef(ctx, iv.AccountID, memo)
dto.Invitees = append(dto.Invitees, invitationInviteeDTO{
AccountID: ref.AccountID,
DisplayName: ref.DisplayName,
Seat: iv.Seat,
Response: iv.Response,
})
}
return dto
}
// handleCreateInvitation records a new friend-game invitation from the caller.
func (s *Server) handleCreateInvitation(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req createInvitationRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
variant, err := engine.ParseVariant(req.Variant)
if err != nil {
abortBadRequest(c, "unknown variant")
return
}
settings := lobby.InvitationSettings{
Variant: variant,
HintsAllowed: req.HintsAllowed,
HintsPerPlayer: req.HintsPerPlayer,
}
if req.TurnTimeoutSecs > 0 {
settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second
}
if req.DropoutTiles != "" {
dropout, err := engine.ParseDropoutTiles(req.DropoutTiles)
if err != nil {
abortBadRequest(c, "unknown dropout_tiles")
return
}
settings.DropoutTiles = dropout
}
inviteeIDs := make([]uuid.UUID, 0, len(req.InviteeIDs))
for _, raw := range req.InviteeIDs {
id, ok := parseUUIDField(raw)
if !ok {
abortBadRequest(c, "invalid invitee id")
return
}
inviteeIDs = append(inviteeIDs, id)
}
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.invitationDTOFrom(c.Request.Context(), inv, map[string]string{}))
}
// handleAcceptInvitation records the caller's acceptance, starting the game when it
// completes the set.
func (s *Server) handleAcceptInvitation(c *gin.Context) {
s.respondInvitation(c, true)
}
// handleDeclineInvitation records the caller's decline, cancelling the invitation.
func (s *Server) handleDeclineInvitation(c *gin.Context) {
s.respondInvitation(c, false)
}
// respondInvitation applies the caller's accept/decline to the :id invitation.
func (s *Server) respondInvitation(c *gin.Context, accept bool) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
abortBadRequest(c, "invalid invitation id")
return
}
inv, err := s.invitations.RespondInvitation(c.Request.Context(), id, uid, accept)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.invitationDTOFrom(c.Request.Context(), inv, map[string]string{}))
}
// handleCancelInvitation withdraws the caller's own pending invitation.
func (s *Server) handleCancelInvitation(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
abortBadRequest(c, "invalid invitation id")
return
}
if err := s.invitations.CancelInvitation(c.Request.Context(), id, uid); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleListInvitations returns the open invitations touching the caller.
func (s *Server) handleListInvitations(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
invs, err := s.invitations.ListInvitations(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
memo := map[string]string{}
out := make([]invitationDTO, 0, len(invs))
for _, inv := range invs {
out = append(out, s.invitationDTOFrom(c.Request.Context(), inv, memo))
}
c.JSON(http.StatusOK, invitationListDTO{Invitations: out})
}
+209
View File
@@ -0,0 +1,209 @@
package social
import (
"context"
crand "crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"math/big"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
const (
// friendCodeTTL bounds how long an issued friend code stays redeemable.
friendCodeTTL = 12 * time.Hour
// friendCodeIssueRetries caps regeneration attempts when a freshly generated
// code collides (by hash) with another account's still-live code.
friendCodeIssueRetries = 5
)
// FriendCode is a freshly issued one-time add-a-friend code. The plaintext Code is
// returned exactly once (only its hash is persisted); the issuer shares it out of
// band and whoever redeems it becomes their friend immediately.
type FriendCode struct {
Code string
ExpiresAt time.Time
}
// IssueFriendCode issues a fresh one-time friend code for accountID, replacing the
// account's prior live code (at most one is redeemable per issuer at a time). Only
// the hash is stored; the returned plaintext is the only copy. A collision with
// another account's live code triggers a regeneration so the redeem lookup stays
// unambiguous.
func (svc *Service) IssueFriendCode(ctx context.Context, accountID uuid.UUID) (FriendCode, error) {
expiresAt := svc.now().Add(friendCodeTTL)
for range friendCodeIssueRetries {
code, hash, err := generateFriendCode()
if err != nil {
return FriendCode{}, err
}
inserted, err := svc.store.replaceFriendCode(ctx, accountID, hash, expiresAt, svc.now())
if err != nil {
return FriendCode{}, err
}
if inserted {
return FriendCode{Code: code, ExpiresAt: expiresAt}, nil
}
}
return FriendCode{}, fmt.Errorf("social: could not issue a unique friend code after %d tries", friendCodeIssueRetries)
}
// RedeemFriendCode makes redeemerID a friend of the account that issued code,
// consuming the code. It returns the issuer's account id on success, or
// ErrFriendCodeInvalid (unknown/used/expired), ErrSelfRelation (own code), or
// ErrRequestBlocked (a block stands between the pair). A redeem bypasses any prior
// decline between the two: it clears the old row and writes a fresh friendship.
func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, code string) (uuid.UUID, error) {
issuerID, codeID, err := svc.store.liveFriendCodeByHash(ctx, hashFriendCode(code), svc.now())
if err != nil {
return uuid.UUID{}, err
}
if issuerID == redeemerID {
return uuid.UUID{}, ErrSelfRelation
}
blocked, err := svc.store.isBlocked(ctx, redeemerID, issuerID)
if err != nil {
return uuid.UUID{}, err
}
if blocked {
return uuid.UUID{}, ErrRequestBlocked
}
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))
return issuerID, nil
}
// replaceFriendCode clears accountID's prior live code and inserts a fresh one,
// inside one transaction. It reports false (without inserting) when codeHash
// collides with another still-live code, so the caller regenerates.
func (s *Store) replaceFriendCode(ctx context.Context, accountID uuid.UUID, codeHash string, expiresAt, now time.Time) (bool, error) {
id, err := uuid.NewV7()
if err != nil {
return false, fmt.Errorf("social: new friend code id: %w", err)
}
inserted := false
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
del := table.FriendCodes.DELETE().WHERE(
table.FriendCodes.AccountID.EQ(postgres.UUID(accountID)).
AND(table.FriendCodes.ConsumedAt.IS_NULL()),
)
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("clear prior friend codes: %w", err)
}
var live []model.FriendCodes
sel := postgres.SELECT(table.FriendCodes.CodeID).
FROM(table.FriendCodes).
WHERE(
table.FriendCodes.CodeHash.EQ(postgres.String(codeHash)).
AND(table.FriendCodes.ConsumedAt.IS_NULL()).
AND(table.FriendCodes.ExpiresAt.GT(postgres.TimestampzT(now))),
).LIMIT(1)
if err := sel.QueryContext(ctx, tx, &live); err != nil {
return fmt.Errorf("check friend code collision: %w", err)
}
if len(live) > 0 {
return nil // collision: leave inserted false so the caller retries
}
ins := table.FriendCodes.INSERT(
table.FriendCodes.CodeID, table.FriendCodes.AccountID, table.FriendCodes.CodeHash, table.FriendCodes.ExpiresAt,
).VALUES(id, accountID, codeHash, expiresAt)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert friend code: %w", err)
}
inserted = true
return nil
})
if err != nil {
return false, err
}
return inserted, nil
}
// liveFriendCodeByHash returns the issuer and code id of the live (unconsumed,
// unexpired) code with codeHash, or ErrFriendCodeInvalid when none matches.
func (s *Store) liveFriendCodeByHash(ctx context.Context, codeHash string, now time.Time) (issuerID, codeID uuid.UUID, err error) {
stmt := postgres.SELECT(table.FriendCodes.CodeID, table.FriendCodes.AccountID).
FROM(table.FriendCodes).
WHERE(
table.FriendCodes.CodeHash.EQ(postgres.String(codeHash)).
AND(table.FriendCodes.ConsumedAt.IS_NULL()).
AND(table.FriendCodes.ExpiresAt.GT(postgres.TimestampzT(now))),
).LIMIT(1)
var row model.FriendCodes
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return uuid.UUID{}, uuid.UUID{}, ErrFriendCodeInvalid
}
return uuid.UUID{}, uuid.UUID{}, fmt.Errorf("social: load friend code: %w", err)
}
return row.AccountID, row.CodeID, nil
}
// redeemFriendCode consumes the code and writes an accepted friendship between
// issuer and redeemer, inside one transaction. It clears any prior pending/declined
// row between the pair first, so a code overrides an earlier decline. A code already
// consumed by a concurrent redeem yields ErrFriendCodeInvalid (rolling back).
func (s *Store) redeemFriendCode(ctx context.Context, codeID, issuer, redeemer uuid.UUID, now time.Time) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
upd := table.FriendCodes.
UPDATE(table.FriendCodes.ConsumedAt).
SET(postgres.TimestampzT(now)).
WHERE(
table.FriendCodes.CodeID.EQ(postgres.UUID(codeID)).
AND(table.FriendCodes.ConsumedAt.IS_NULL()),
)
res, err := upd.ExecContext(ctx, tx)
if err != nil {
return fmt.Errorf("consume friend code: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("consume friend code rows: %w", err)
}
if n == 0 {
return ErrFriendCodeInvalid
}
del := table.Friendships.DELETE().WHERE(edgeEither(issuer, redeemer))
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("clear friendship before code accept: %w", err)
}
ins := table.Friendships.INSERT(
table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status,
table.Friendships.CreatedAt, table.Friendships.RespondedAt,
).VALUES(issuer, redeemer, friendAccepted, now, now)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert friendship from code: %w", err)
}
return nil
})
}
// generateFriendCode returns a random 6-digit numeric code and its hex SHA-256 hash.
func generateFriendCode() (code, hash string, err error) {
n, err := crand.Int(crand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", "", fmt.Errorf("social: generate friend code: %w", err)
}
code = fmt.Sprintf("%06d", n.Int64())
return code, hashFriendCode(code), nil
}
// hashFriendCode returns the hex-encoded SHA-256 of a friend code; the plaintext is
// never persisted, matching the session and email-code models.
func hashFriendCode(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
+113 -33
View File
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
@@ -19,12 +20,22 @@ import (
const (
friendPending = "pending"
friendAccepted = "accepted"
friendDeclined = "declined"
)
// friendRequestTTL is how long an unanswered (ignored) friend request stays
// pending before it lazily expires and may be re-sent. An explicit decline is
// remembered permanently (status 'declined') instead and is not subject to this
// window; a one-time friend code from the addressee bypasses a decline.
const friendRequestTTL = 30 * 24 * time.Hour
// SendFriendRequest records a pending friend request from requesterID to
// addresseeID. It refuses a self-request, a request blocked by either a per-user
// block or the addressee's block_friend_requests toggle, and a duplicate of an
// existing request or friendship in either direction.
// 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
// the addressee's block_friend_requests toggle, a duplicate of a live request or an
// existing friendship, and a re-send after an explicit decline (ErrRequestDeclined).
// An ignored request that has lazily expired (friendRequestTTL) may be re-sent and
// reopens the existing row with a fresh clock.
func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
if requesterID == addresseeID {
return ErrSelfRelation
@@ -43,32 +54,69 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
if blocked || addressee.BlockFriendRequests {
return ErrRequestBlocked
}
exists, err := svc.store.friendshipExists(ctx, requesterID, addresseeID)
shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID)
if err != nil {
return err
}
if exists {
return ErrRequestExists
if !shared {
return ErrNoSharedGame
}
if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID); err != nil {
edges, err := svc.store.loadEdges(ctx, requesterID, addresseeID)
if err != nil {
return err
}
cutoff := svc.now().Add(-friendRequestTTL)
for _, e := range edges {
// Already friends, or the addressee already has a live request awaiting the
// requester — in both cases there is nothing to (re-)send.
if e.Status == friendAccepted {
return ErrRequestExists
}
if e.RequesterID == addresseeID && e.Status == friendPending && e.CreatedAt.After(cutoff) {
return ErrRequestExists
}
}
for _, e := range edges {
if e.RequesterID != requesterID {
continue
}
switch e.Status {
case friendDeclined:
return ErrRequestDeclined
case friendPending:
if e.CreatedAt.After(cutoff) {
return ErrRequestExists
}
// An ignored request that has expired — reopen it with a fresh clock.
if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
return err
}
svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest))
return nil
}
}
if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
if isUniqueViolation(err) {
return ErrRequestExists
}
return err
}
svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest))
return nil
}
// RespondFriendRequest lets addresseeID accept or decline the pending request
// from requesterID. Accepting flips it to a friendship; declining deletes it.
// Either way ErrRequestNotFound is returned when no pending request matches.
// from requesterID. Accepting flips it to a friendship; declining records a
// permanent 'declined' status (so the same requester cannot re-send), rather than
// deleting the row. Either way ErrRequestNotFound is returned when no pending
// request matches.
func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, requesterID uuid.UUID, accept bool) error {
var ok bool
var err error
if accept {
ok, err = svc.store.acceptFriendRequest(ctx, requesterID, addresseeID, svc.now())
} else {
ok, err = svc.store.deletePendingRequest(ctx, requesterID, addresseeID)
ok, err = svc.store.declineFriendRequest(ctx, requesterID, addresseeID, svc.now())
}
if err != nil {
return err
@@ -102,34 +150,31 @@ func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uui
return svc.store.listFriends(ctx, accountID)
}
// ListIncomingRequests returns the account IDs that have a pending friend request
// awaiting accountID's response.
// ListIncomingRequests returns the account IDs that have a live (not yet expired)
// pending friend request awaiting accountID's response.
func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listIncomingRequests(ctx, accountID)
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
}
// friendshipExists reports whether any friendship row (pending or accepted) exists
// between a and b in either direction.
func (s *Store) friendshipExists(ctx context.Context, a, b uuid.UUID) (bool, error) {
stmt := postgres.SELECT(table.Friendships.Status).
// loadEdges returns every friendship row between a and b in either direction (at
// most one per direction). It feeds SendFriendRequest's re-send classification.
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
stmt := postgres.SELECT(table.Friendships.AllColumns).
FROM(table.Friendships).
WHERE(edgeEither(a, b)).
LIMIT(1)
var row model.Friendships
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("social: friendship exists: %w", err)
WHERE(edgeEither(a, b))
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: load friendship edges: %w", err)
}
return true, nil
return rows, nil
}
// insertFriendRequest inserts a pending request from requester to addressee.
func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID) error {
// insertFriendRequest inserts a pending request from requester to addressee,
// stamping created_at so the lazy-expiry clock is deterministic under a fake now.
func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) error {
stmt := table.Friendships.INSERT(
table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status,
).VALUES(requester, addressee, friendPending)
table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status, table.Friendships.CreatedAt,
).VALUES(requester, addressee, friendPending, now)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: insert friend request: %w", err)
}
@@ -151,6 +196,7 @@ func (s *Store) acceptFriendRequest(ctx context.Context, requester, addressee uu
}
// deletePendingRequest removes a pending request and reports whether a row matched.
// It backs the requester's own cancel (which leaves no trace, unlike a decline).
func (s *Store) deletePendingRequest(ctx context.Context, requester, addressee uuid.UUID) (bool, error) {
stmt := table.Friendships.DELETE().WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
@@ -160,6 +206,38 @@ func (s *Store) deletePendingRequest(ctx context.Context, requester, addressee u
return execAffected(ctx, s.db, stmt, "social: delete friend request")
}
// declineFriendRequest marks a pending request from requester to addressee as
// permanently declined (so the requester cannot re-send) and reports whether a row
// matched.
func (s *Store) declineFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) (bool, error) {
stmt := table.Friendships.
UPDATE(table.Friendships.Status, table.Friendships.RespondedAt).
SET(postgres.String(friendDeclined), postgres.TimestampzT(now)).
WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
return execAffected(ctx, s.db, stmt, "social: decline friend request")
}
// refreshFriendRequest resets an expired pending request's created_at so it counts
// as freshly sent again.
func (s *Store) refreshFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) error {
stmt := table.Friendships.
UPDATE(table.Friendships.CreatedAt).
SET(postgres.TimestampzT(now)).
WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: refresh friend request: %w", err)
}
return nil
}
// deleteFriendship removes an accepted friendship in either direction.
func (s *Store) deleteFriendship(ctx context.Context, a, b uuid.UUID) error {
stmt := table.Friendships.DELETE().WHERE(
@@ -195,13 +273,15 @@ func (s *Store) listFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UU
return out, nil
}
// listIncomingRequests returns the requesters of every pending request to accountID.
func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
// listIncomingRequests returns the requesters of every live (created after cutoff)
// pending request to accountID; lazily expired requests are hidden.
func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Friendships.RequesterID).
FROM(table.Friendships).
WHERE(
table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
AND(table.Friendships.Status.EQ(postgres.String(friendPending))).
AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))),
)
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
+17 -3
View File
@@ -19,11 +19,15 @@ import (
)
// GameReader is the slice of the game domain the social package needs: the seated
// accounts in seat order, the seat index whose turn it is, and the game status.
// game.Service satisfies it, so chat and nudge gate on game state without a
// dependency on the engine or the game's private state.
// accounts in seat order, the seat index whose turn it is, and the game status, plus
// a shared-game test. game.Service satisfies it, so chat, nudge and the
// befriend-an-opponent gate work without a dependency on the engine or the game's
// private state.
type GameReader interface {
Participants(ctx context.Context, gameID uuid.UUID) (seats []uuid.UUID, toMove int, status string, err error)
// SharedGame reports whether two accounts are seated together in any game
// (active or finished); it gates the "befriend an opponent" request path.
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
}
// Sentinel errors returned by the service.
@@ -38,6 +42,16 @@ var (
ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests")
// ErrRequestNotFound is returned when no pending friend request matches.
ErrRequestNotFound = errors.New("social: no pending friend request")
// ErrNoSharedGame is returned when a friend request targets someone the
// requester has never shared a game with (the befriend-an-opponent gate).
ErrNoSharedGame = errors.New("social: you can only request someone you have played with")
// ErrRequestDeclined is returned when the addressee has previously declined a
// request from this requester; a re-send is refused (a one-time friend code
// from the addressee bypasses this).
ErrRequestDeclined = errors.New("social: this person has declined your friend request")
// ErrFriendCodeInvalid is returned when a redeemed friend code is unknown,
// already used, or expired.
ErrFriendCodeInvalid = errors.New("social: friend code is invalid or expired")
// ErrNotParticipant is returned when an account is not seated in the game.
ErrNotParticipant = errors.New("social: account is not a player in this game")
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
+46 -18
View File
@@ -269,10 +269,17 @@ requires (there is no DM surface; chat is per-game).
robot (§7) and starts the game. On a pairing or substitution the matchmaker
emits a **match-found** notification (§10), delivered over the live stream;
`Poll` remains as a fallback for a client that is not currently streaming.
- **Friends**: a **request → accept** graph (one `friendships` table) — add by
friend list or internal ID now, by platform deep-link with Stage 9. Declining or
cancelling removes the pending request; blocking someone severs an existing
friendship.
- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
rate-limited) is redeemed by the other player to become friends immediately.
Alternatively a **request → accept** is sent to someone you **share a game with**
(active or finished); the recipient may accept, ignore (the pending row lazily
expires after **30 days** and may be re-sent), or **decline** — a decline is
remembered (`status='declined'`) and blocks further requests from that sender,
unless they hand them a code, which overrides it. The requester's own cancel still
deletes the row; blocking someone severs an existing friendship. (Discovery by
friend list or platform deep-link arrives with Stage 9 / TODO-5.)
- **Block**: two independent **global** account toggles (`block_chat`,
`block_friend_requests`) **plus** a **per-user block list**. A per-user block is
applied mutually: it hides the pair's chat from each other and refuses friend
@@ -291,11 +298,15 @@ requires (there is no DM surface; chat is per-game).
the opponent may nudge **once per hour per game**; it is not allowed on one's own
turn. The platform-native delivery is wired with the gateway / platform
side-service (Stage 6 / 8).
- **Profile**: `preferred_language` (en/ru), display name, email
(confirm-code binding, see §4), **timezone** (drives the away window and the
robot's sleep; user-editable), the daily **away window** and the block toggles —
all editable through `account.UpdateProfile`. Linked platform accounts and merge
are Stage 11.
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window** and the
block toggles — all editable through `account.UpdateProfile`, which validates them
(Stage 8): a display name is Unicode letters joined by single ` `/`.`/`_`
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone`
for the sweeper and the robot's sleep (a fixed offset trades DST for a simple
picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform
accounts and merge are Stage 11.
## 9. Persistence
@@ -316,8 +327,9 @@ requires (there is no DM surface; chat is per-game).
Stage 4 social/lobby tables `friendships` (the request/accept graph), `blocks`
(per-user blocks), `chat_messages` (per-game chat and nudges), `email_confirmations`
(pending confirm-codes) and `game_invitations` / `game_invitation_invitees`
(friend-game invitations). The matchmaking pool is **in-memory** and persists
nothing.
(friend-game invitations). Stage 8's migration `00006` widened the `friendships`
status to admit `declined` and added `friend_codes` (one-time add-a-friend codes).
The matchmaking pool is **in-memory** and persists nothing.
- **Active games are event-sourced.** A game is a `games` row (pinned
`variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised
turn cursor) plus an append-only, decoded move journal (`game_moves`); the live
@@ -352,7 +364,9 @@ the same rows and is likewise self-contained — we ship our own writer (the sol
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
exchanges), plus `#note` lines for resignations and timeouts, which the standard
does not cover.
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
otherwise, Stage 8), so an in-progress journal is never leaked mid-play; the client
shares the `.gcg` file via the Web Share API where available, else downloads it.
## 10. Notifications
@@ -365,12 +379,17 @@ services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge**
(from the social service), and **match-found** (from the matchmaker, §8). 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. Out-of-app platform push (your-turn, nudge) is wired in Stage 9;
session-revocation events and cursor-based stream resume are deferred
(single-instance MVP).
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, invitation or game-started; emitted on a friend-request and invitation
create and on an invitation's game start). 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
missed while the app was hidden. Out-of-app platform push (your-turn, nudge) is
wired in Stage 9; session-revocation events and cursor-based stream resume are
deferred (single-instance MVP).
A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md).
It is a client-side **mock** rotation today; a server-driven source (operational notices,
@@ -408,6 +427,15 @@ This is an explicit, accepted MVP risk: compromise of the gateway↔backend
network segment defeats backend authentication. Mitigated by network isolation;
mutual auth is a future hardening step.
**Short numeric codes** (email confirm-codes and Stage 8 friend codes) are stored
only as SHA-256 hashes and are short-lived and single-use. The unauthenticated
email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem**
is authenticated, so it rides the per-user limit (120 / min) and is further bounded
by the code's 12 h TTL, single use, and **one live code per issuer** (which caps the
valid-code population). Brute-forcing a 6-digit friend code within these limits is an
accepted MVP risk with low blast radius (an unwanted friendship is removable/blockable);
a dedicated redeem sub-limit or a longer code is the hardening step if abuse appears.
## 13. Deployment (informational)
Single public origin, path-routed: the UI, the gateway public surface and the
+26 -18
View File
@@ -15,10 +15,10 @@ The web/app client (Svelte + Vite) realizes these stories. The **playable slice*
auto-match, playing the board (place tiles by drag or tap, pass, exchange, resign),
the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge,
real-time in-app updates, switching interface language (en/ru) and theme, and a
read-only profile. Managing friends and blocks, creating friend games (invitations),
editing the profile, the statistics screen and the history/GCG viewer arrive in
Stage 8. Settings also pick the board's bonus-label style (beginner / classic /
none). A hint **lays the suggested tiles on the board** for the player to confirm and
read-only profile. **Stage 8** adds managing friends (including one-time friend
codes) and blocks, friend-game invitations, editing the profile and binding an
email, the statistics screen, and the in-game history viewer with GCG export.
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats.
@@ -42,10 +42,10 @@ account (stats summed, games/friends transferred).
Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins a
per-variant pool and is paired with the next waiting human; after 10 s with no
human the robot substitutes (the robot arrives in Stage 5). Friend games (24) are
formed by inviting players from the friend list or by internal ID (deep-link
invites arrive with the platform integration): the inviter chooses the settings
and the game starts once every invitee has accepted — any decline cancels it, and
an unanswered invitation expires after seven days.
formed by inviting players from the friend list (deep-link invites arrive with the
platform integration): the inviter chooses the settings and the game starts once
every invitee has accepted — any decline cancels it, and an unanswered invitation
expires after seven days.
### Playing a game *(Stage 3)*
Place tiles, pass, exchange, or resign. A play is validated against the game's
@@ -74,9 +74,13 @@ one, and a night-time pause that tracks the player's own day. It answers a nudge
within a few minutes and nudges back when the player has been away a long time. It
carries a human-like name and neither chats nor accepts friend requests.
### Social: friends, block, chat, nudge *(Stage 4)*
Send a friend request and have it accepted (decline or cancel withdraws it,
unfriending removes the friendship). Block globally — switch off incoming chat
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
Become friends in two ways: redeem a **one-time code** the other player issues (six
digits, valid for twelve hours), or send a **request to someone you have played
with** — they accept, ignore it (a request lapses after thirty days and can then be
re-sent), or decline (a decline blocks further requests from you until they hand you
a code). Cancelling your own pending request withdraws it; unfriending removes the
friendship. Block globally — switch off incoming chat
and/or friend requests — and block individual players (a per-user block hides that
person's chat and stops requests and game invitations both ways; it also ends any
existing friendship). Per-game chat is for quick reactions: messages are short
@@ -84,18 +88,22 @@ existing friendship). Per-game chat is for quick reactions: messages are short
even disguised. Nudge the player whose turn is awaited at most once per hour (the
nudge is part of the game chat); the out-of-app push is delivered via the platform.
### Profile & settings *(Stage 4)*
Edit language (en/ru), display name, timezone, the daily away window and the block
toggles, and bind an email by confirm-code: the backend emails a short code that,
### Profile & settings *(Stage 4 / 8)*
Edit the display name (letters joined by single space / "." / "_" separators, up to
32 characters), the timezone (chosen as a UTC offset), the daily away window (on a
10-minute grid, at most 12 hours, wrapping midnight) and the block toggles, and bind
an email by confirm-code: the backend emails a short code that,
once entered, attaches the email to the account (an email already confirmed by
another account cannot be taken — that is a merge, a later stage). Linked platform
accounts and merge arrive in Stage 11.
### History & statistics *(Stage 3)*
### History & statistics *(Stage 3 / 8)*
Finished games are archived in a dictionary-independent form and exportable to
GCG. Statistics (durable accounts only): wins, losses, draws, max points in a
game, and max points for a single move (the best play, which already includes
every word it formed plus the all-tiles bonus).
GCG; the export is offered **only once a game is finished** (exporting a live game
would leak the move journal), and the client shares the `.gcg` file where the
platform supports it, otherwise downloads it. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus).
### Administration *(Stage 10)*
Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary
+28 -18
View File
@@ -14,9 +14,10 @@
игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача),
top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии,
обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и
профиль только для чтения. Управление друзьями и блоками, создание дружеских игр
(приглашения), редактирование профиля, экран статистики и просмотр истории/GCG
появятся в Stage 8. В настройках также выбирается стиль подписей бонус-клеток
профиль только для чтения. **Stage 8** добавляет управление друзьями (в т.ч.
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
редактирование профиля и привязку email, экран статистики и просмотр истории
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
@@ -42,10 +43,10 @@ session-токен; backend сопоставляет его с внутренн
Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей или по внутреннему ID
(приглашения по deep-link появятся с платформенной интеграцией): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой
отказ отменяет приглашение, а без ответа приглашение протухает через семь дней.
формируются приглашением игроков из списка друзей (приглашения по deep-link
появятся с платформенной интеграцией): инициатор выбирает настройки, и партия
стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней.
### Игровой процесс *(Stage 3)*
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
@@ -74,9 +75,14 @@ session-токен; backend сопоставляет его с внутренн
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается
в чате и не принимает заявки в друзья.
### Социальное: друзья, блок, чат, nudge *(Stage 4)*
Заявка в друзья и её принятие (отклонение или отмена снимают заявку, удаление —
расторгает дружбу). Глобальная блокировка — отключить входящие чат и/или заявки —
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие
чат и/или заявки —
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
@@ -84,19 +90,23 @@ session-токен; backend сопоставляет его с внутренн
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
push доставляется через платформу.
### Профиль и настройки *(Stage 4)*
Редактирование языка (en/ru), отображаемого имени, таймзоны, суточного окна
отсутствия (away) и переключателей блокировок, а также привязка email по
confirm-коду: backend шлёт на почту короткий код, и после ввода email
### Профиль и настройки *(Stage 4 / 8)*
Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» /
«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и
переключателей блокировок, а также привязка email по confirm-коду: backend шлёт на
почту короткий код, и после ввода email
привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
слияние появятся в Stage 11.
### История и статистика *(Stage 3)*
### История и статистика *(Stage 3 / 8)*
Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи,
макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все
образованные им слова и бонус за все фишки).
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки).
### Администрирование *(Stage 10)*
Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями
+14 -3
View File
@@ -15,7 +15,12 @@ tests or touching CI.
`go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded
by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow.
- **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain.
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Stage 8 adds Vitest for
the new FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/download choice, plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the GCG export's
finished-only visibility.
- **Engine** *(Stage 2+)* — correctness of scoring and move generation is owned
by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on
top of the embedded solver: per-variant smoke tests (load all three committed
@@ -48,7 +53,11 @@ tests or touching CI.
content and block-visibility rules, the nudge turn/rate-limit rules, the
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
email, expiry and attempt-cap) with a fixture mailer.
email, expiry and attempt-cap) with a fixture mailer. Stage 8 adds the
**befriend-an-opponent** gate (a request needs a shared game), the **permanent
decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem,
self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
the GCG **finished-only** gate.
- **Robot** *(Stage 5+)*`backend/internal/robot` unit-tests the pure strategy:
the ≈ 40% play-to-win split over many seeds, the right-skewed move-delay
(bounds, ~10-min median, determinism), the margin selection (win/lose, in-band
@@ -70,7 +79,9 @@ tests or touching CI.
(guest auth, unauthenticated rejection, unknown message type). The backend gains
the **guest** lifecycle (a guest plays an auto-match to a natural end yet accrues
no statistics) and the **email-as-login** flow (request/verify, returning user)
in `inttest`.
in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account
operations (friends list, friend code issue/redeem, invitation create, stats, GCG,
the profile-update away round-trip) and a `notify`-event constructor round-trip.
## Principles
+37 -1
View File
@@ -22,7 +22,11 @@ Login uses `Screen`.
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
`.chev`) — lighter than a glyph.
- **Hamburger**: a CSS three-bar (`Menu.svelte`), deliberately larger; opens a dropdown
of items (lobby: Profile/Settings/About; game: History/Chat/Check word/Drop game).
of items (lobby: Friends/Profile/Settings/About; game: History/Chat/Check word, plus
*Export GCG* on a finished game and *Add to friends* per opponent, then Drop game). A
red count **badge** rides the hamburger (and the lobby *Friends* item) for pending
incoming friend requests + invitations; the same dot style serves any future
notification count.
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
@@ -77,6 +81,38 @@ Lobby rows show two lines (opponents, then result + score) with a large place-ba
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 34-player games II 🥈 / III 🥉 /
IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌.
## Social, account & history surfaces (Stage 8)
- **Friends** (`screens/Friends.svelte`, from the lobby menu): an "add a friend" block
pairing a code **input** with a **Show my code** action that reveals a large 6-digit
code + its expiry; then the incoming **requests** (Accept / Decline), the **friends**
list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a
guest sees a sign-in prompt.
- **Invitations**: a lobby **section** (a 💌 row per open invitation) with Accept /
Decline for an invitee and a waiting/Cancel state for the inviter; creating one is the
**"Play with friends"** mode in `NewGame.svelte` (pick invitees, then variant / move
time / hints).
- **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat
cards (wins / losses / draws / games / win-rate / best game / best move) — pure
numbers, no charts.
- **Profile editing** (`screens/Profile.svelte`): an inline form — display name, a
**UTC-offset** timezone dropdown (defaulting to the browser's offset), the away
window as hour + 10-minute dropdowns (24-hour, ≤ 12 h), and block toggles — plus an
email-binding sub-flow (enter email → enter the confirm code on a numeric field).
Invalid fields show a **red border** (no message) and **Save stays disabled** until
every field is valid. Interface language stays in **Settings** (it writes through to
the account for durable users).
- **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or
the icon copies it. Flex text inputs carry `min-width:0` so they shrink instead of
overflowing in Safari.
- **History / GCG**: the in-game slide-down history gains the running total per move;
*Export GCG* shares or downloads the `.gcg` file and appears only once the game is
finished.
- **Finished game**: the board keeps no last-word highlight and no zoom; the menu drops
*Check word* and *Drop game*; and the footer (rack + tab bar) is **drawn but inert**
(greyed, non-interactive) rather than hidden, so the layout does not jump. Chat
send / nudge are the ⬆️ / 🛎️ icons.
## Caveat
Emoji are rendered by the platform's system emoji font, so their exact look varies across
+6 -2
View File
@@ -42,8 +42,12 @@ failures become Connect error codes.
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`. Further
operations follow the same transcode pattern (added in Stage 7).
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found`. 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`,
`email.bind.*`, `stats.get`, `game.gcg`, and the `notify` live event — all via the
identical transcode pattern (`transcode_social.go`).
## Configuration
+2
View File
@@ -24,6 +24,8 @@ type ProfileResp struct {
DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
AwayStart string `json:"away_start"`
AwayEnd string `json:"away_end"`
HintBalance int `json:"hint_balance"`
BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"`
@@ -0,0 +1,256 @@
package backendclient
import (
"context"
"net/http"
"net/url"
)
// The Stage 8 response structs and client methods mirror the backend's social,
// account and history JSON DTOs. The transcode layer maps them to FlatBuffers.
// AccountRefResp is a referenced account with its display name resolved.
type AccountRefResp struct {
AccountID string `json:"account_id"`
DisplayName string `json:"display_name"`
}
// FriendListResp is the caller's accepted friends.
type FriendListResp struct {
Friends []AccountRefResp `json:"friends"`
}
// IncomingListResp is the friend requests awaiting the caller.
type IncomingListResp struct {
Requests []AccountRefResp `json:"requests"`
}
// FriendCodeResp is a freshly issued one-time friend code.
type FriendCodeResp struct {
Code string `json:"code"`
ExpiresAtUnix int64 `json:"expires_at_unix"`
}
// RedeemResultResp reports the friend gained by redeeming a code.
type RedeemResultResp struct {
Friend AccountRefResp `json:"friend"`
}
// BlockListResp is the accounts the caller has blocked.
type BlockListResp struct {
Blocked []AccountRefResp `json:"blocked"`
}
// StatsResp is a durable account's lifetime statistics.
type StatsResp struct {
Wins int `json:"wins"`
Losses int `json:"losses"`
Draws int `json:"draws"`
MaxGamePoints int `json:"max_game_points"`
MaxWordPoints int `json:"max_word_points"`
}
// InvitationInviteeResp is one invitee's seat and response with their name.
type InvitationInviteeResp struct {
AccountID string `json:"account_id"`
DisplayName string `json:"display_name"`
Seat int `json:"seat"`
Response string `json:"response"`
}
// InvitationResp is a friend-game invitation with its settings and invitees.
type InvitationResp struct {
ID string `json:"id"`
Inviter AccountRefResp `json:"inviter"`
Invitees []InvitationInviteeResp `json:"invitees"`
Variant string `json:"variant"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
HintsAllowed bool `json:"hints_allowed"`
HintsPerPlayer int `json:"hints_per_player"`
DropoutTiles string `json:"dropout_tiles"`
Status string `json:"status"`
GameID string `json:"game_id"`
ExpiresAtUnix int64 `json:"expires_at_unix"`
}
// InvitationListResp is the caller's open invitations.
type InvitationListResp struct {
Invitations []InvitationResp `json:"invitations"`
}
// GcgResp is a finished game's GCG export.
type GcgResp struct {
GameID string `json:"game_id"`
Filename string `json:"filename"`
Content string `json:"content"`
}
// InvitationParams are the settings the inviter chooses for a friend game.
type InvitationParams struct {
InviteeIDs []string
Variant string
TurnTimeoutSecs int
HintsAllowed bool
HintsPerPlayer int
DropoutTiles string
}
// --- friends ---
// SendFriendRequest sends a friend request to a played opponent.
func (c *Client) SendFriendRequest(ctx context.Context, userID, targetID string) error {
return c.do(ctx, http.MethodPost, "/api/v1/user/friends/request", userID, "",
map[string]string{"account_id": targetID}, nil)
}
// RespondFriendRequest accepts or declines an incoming request.
func (c *Client) RespondFriendRequest(ctx context.Context, userID, requesterID string, accept bool) error {
return c.do(ctx, http.MethodPost, "/api/v1/user/friends/respond", userID, "",
map[string]any{"requester_id": requesterID, "accept": accept}, nil)
}
// CancelFriendRequest withdraws the caller's own pending request.
func (c *Client) CancelFriendRequest(ctx context.Context, userID, targetID string) error {
return c.do(ctx, http.MethodPost, "/api/v1/user/friends/cancel", userID, "",
map[string]string{"account_id": targetID}, nil)
}
// Unfriend removes a friendship.
func (c *Client) Unfriend(ctx context.Context, userID, targetID string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/user/friends/"+url.PathEscape(targetID), userID, "", nil, nil)
}
// ListFriends returns the caller's accepted friends.
func (c *Client) ListFriends(ctx context.Context, userID string) (FriendListResp, error) {
var out FriendListResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/friends", userID, "", nil, &out)
return out, err
}
// ListIncoming returns the friend requests awaiting the caller.
func (c *Client) ListIncoming(ctx context.Context, userID string) (IncomingListResp, error) {
var out IncomingListResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/friends/incoming", userID, "", nil, &out)
return out, err
}
// IssueFriendCode issues a one-time friend code for the caller.
func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) {
var out FriendCodeResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/friends/code", userID, "", struct{}{}, &out)
return out, err
}
// RedeemFriendCode redeems a friend code, befriending its issuer.
func (c *Client) RedeemFriendCode(ctx context.Context, userID, code string) (RedeemResultResp, error) {
var out RedeemResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/friends/code/redeem", userID, "",
map[string]string{"code": code}, &out)
return out, err
}
// --- blocks ---
// Block blocks an account.
func (c *Client) Block(ctx context.Context, userID, targetID string) error {
return c.do(ctx, http.MethodPost, "/api/v1/user/blocks", userID, "",
map[string]string{"account_id": targetID}, nil)
}
// Unblock removes a block.
func (c *Client) Unblock(ctx context.Context, userID, targetID string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/user/blocks/"+url.PathEscape(targetID), userID, "", nil, nil)
}
// ListBlocks returns the accounts the caller has blocked.
func (c *Client) ListBlocks(ctx context.Context, userID string) (BlockListResp, error) {
var out BlockListResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/blocks", userID, "", nil, &out)
return out, err
}
// --- invitations ---
// CreateInvitation proposes a friend game to the named invitees.
func (c *Client) CreateInvitation(ctx context.Context, userID string, p InvitationParams) (InvitationResp, error) {
var out InvitationResp
body := map[string]any{
"invitee_ids": p.InviteeIDs,
"variant": p.Variant,
"turn_timeout_secs": p.TurnTimeoutSecs,
"hints_allowed": p.HintsAllowed,
"hints_per_player": p.HintsPerPlayer,
"dropout_tiles": p.DropoutTiles,
}
err := c.do(ctx, http.MethodPost, "/api/v1/user/invitations", userID, "", body, &out)
return out, err
}
// RespondInvitation accepts or declines an invitation by id.
func (c *Client) RespondInvitation(ctx context.Context, userID, invitationID string, accept bool) (InvitationResp, error) {
var out InvitationResp
action := "/decline"
if accept {
action = "/accept"
}
err := c.do(ctx, http.MethodPost, "/api/v1/user/invitations/"+url.PathEscape(invitationID)+action, userID, "", struct{}{}, &out)
return out, err
}
// CancelInvitation withdraws the caller's own invitation.
func (c *Client) CancelInvitation(ctx context.Context, userID, invitationID string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/user/invitations/"+url.PathEscape(invitationID), userID, "", nil, nil)
}
// ListInvitations returns the caller's open invitations.
func (c *Client) ListInvitations(ctx context.Context, userID string) (InvitationListResp, error) {
var out InvitationListResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/invitations", userID, "", nil, &out)
return out, err
}
// --- profile, email, stats, gcg ---
// UpdateProfile overwrites the caller's editable profile and returns it.
func (c *Client) UpdateProfile(ctx context.Context, userID string, p ProfileResp) (ProfileResp, error) {
var out ProfileResp
body := map[string]any{
"display_name": p.DisplayName,
"preferred_language": p.PreferredLanguage,
"time_zone": p.TimeZone,
"away_start": p.AwayStart,
"away_end": p.AwayEnd,
"block_chat": p.BlockChat,
"block_friend_requests": p.BlockFriendRequests,
}
err := c.do(ctx, http.MethodPut, "/api/v1/user/profile", userID, "", body, &out)
return out, err
}
// EmailBindRequest asks the backend to mail a confirm-code binding email.
func (c *Client) EmailBindRequest(ctx context.Context, userID, email string) error {
return c.do(ctx, http.MethodPost, "/api/v1/user/email/request", userID, "",
map[string]string{"email": email}, nil)
}
// EmailBindConfirm verifies the code and binds the email, returning the profile.
func (c *Client) EmailBindConfirm(ctx context.Context, userID, email, code string) (ProfileResp, error) {
var out ProfileResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/email/confirm", userID, "",
map[string]string{"email": email, "code": code}, &out)
return out, err
}
// Stats returns the caller's lifetime statistics.
func (c *Client) Stats(ctx context.Context, userID string) (StatsResp, error) {
var out StatsResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/stats", userID, "", nil, &out)
return out, err
}
// ExportGCG returns a finished game's GCG transcript.
func (c *Client) ExportGCG(ctx context.Context, userID, gameID string) (GcgResp, error) {
var out GcgResp
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/gcg"), userID, "", nil, &out)
return out, err
}
+4
View File
@@ -43,6 +43,8 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
name := b.CreateString(p.DisplayName)
lang := b.CreateString(p.PreferredLanguage)
tz := b.CreateString(p.TimeZone)
awayStart := b.CreateString(p.AwayStart)
awayEnd := b.CreateString(p.AwayEnd)
fb.ProfileStart(b)
fb.ProfileAddUserId(b, uid)
fb.ProfileAddDisplayName(b, name)
@@ -52,6 +54,8 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
fb.ProfileAddBlockChat(b, p.BlockChat)
fb.ProfileAddBlockFriendRequests(b, p.BlockFriendRequests)
fb.ProfileAddIsGuest(b, p.IsGuest)
fb.ProfileAddAwayStart(b, awayStart)
fb.ProfileAddAwayEnd(b, awayEnd)
b.Finish(fb.ProfileEnd(b))
return b.FinishedBytes()
}
+179
View File
@@ -0,0 +1,179 @@
package transcode
import (
flatbuffers "github.com/google/flatbuffers/go"
"scrabble/gateway/internal/backendclient"
fb "scrabble/pkg/fbs/scrabblefb"
)
// Stage 8 encoders: friends, blocks, invitations, statistics and GCG. They follow
// encode.go's bottom-up rule (build every string/child vector before the table).
// buildAccountRef builds an AccountRef table and returns its offset.
func buildAccountRef(b *flatbuffers.Builder, r backendclient.AccountRefResp) flatbuffers.UOffsetT {
id := b.CreateString(r.AccountID)
name := b.CreateString(r.DisplayName)
fb.AccountRefStart(b)
fb.AccountRefAddAccountId(b, id)
fb.AccountRefAddDisplayName(b, name)
return fb.AccountRefEnd(b)
}
// buildAccountRefVector builds a [AccountRef] vector using the table-specific
// StartXVector function and returns the vector offset.
func buildAccountRefVector(b *flatbuffers.Builder, refs []backendclient.AccountRefResp, start func(*flatbuffers.Builder, int) flatbuffers.UOffsetT) flatbuffers.UOffsetT {
offs := make([]flatbuffers.UOffsetT, len(refs))
for i, r := range refs {
offs[i] = buildAccountRef(b, r)
}
start(b, len(offs))
for i := len(offs) - 1; i >= 0; i-- {
b.PrependUOffsetT(offs[i])
}
return b.EndVector(len(offs))
}
// encodeFriendList builds a FriendList payload.
func encodeFriendList(r backendclient.FriendListResp) []byte {
b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Friends, fb.FriendListStartFriendsVector)
fb.FriendListStart(b)
fb.FriendListAddFriends(b, v)
b.Finish(fb.FriendListEnd(b))
return b.FinishedBytes()
}
// encodeIncomingList builds an IncomingRequestList payload.
func encodeIncomingList(r backendclient.IncomingListResp) []byte {
b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Requests, fb.IncomingRequestListStartRequestsVector)
fb.IncomingRequestListStart(b)
fb.IncomingRequestListAddRequests(b, v)
b.Finish(fb.IncomingRequestListEnd(b))
return b.FinishedBytes()
}
// encodeBlockList builds a BlockList payload.
func encodeBlockList(r backendclient.BlockListResp) []byte {
b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Blocked, fb.BlockListStartBlockedVector)
fb.BlockListStart(b)
fb.BlockListAddBlocked(b, v)
b.Finish(fb.BlockListEnd(b))
return b.FinishedBytes()
}
// encodeFriendCode builds a FriendCode payload.
func encodeFriendCode(r backendclient.FriendCodeResp) []byte {
b := flatbuffers.NewBuilder(64)
code := b.CreateString(r.Code)
fb.FriendCodeStart(b)
fb.FriendCodeAddCode(b, code)
fb.FriendCodeAddExpiresAtUnix(b, r.ExpiresAtUnix)
b.Finish(fb.FriendCodeEnd(b))
return b.FinishedBytes()
}
// encodeRedeemResult builds a RedeemResult payload.
func encodeRedeemResult(r backendclient.RedeemResultResp) []byte {
b := flatbuffers.NewBuilder(128)
friend := buildAccountRef(b, r.Friend)
fb.RedeemResultStart(b)
fb.RedeemResultAddFriend(b, friend)
b.Finish(fb.RedeemResultEnd(b))
return b.FinishedBytes()
}
// encodeStats builds a StatsView payload.
func encodeStats(r backendclient.StatsResp) []byte {
b := flatbuffers.NewBuilder(64)
fb.StatsViewStart(b)
fb.StatsViewAddWins(b, int32(r.Wins))
fb.StatsViewAddLosses(b, int32(r.Losses))
fb.StatsViewAddDraws(b, int32(r.Draws))
fb.StatsViewAddMaxGamePoints(b, int32(r.MaxGamePoints))
fb.StatsViewAddMaxWordPoints(b, int32(r.MaxWordPoints))
b.Finish(fb.StatsViewEnd(b))
return b.FinishedBytes()
}
// buildInvitation builds an Invitation table and returns its offset.
func buildInvitation(b *flatbuffers.Builder, inv backendclient.InvitationResp) flatbuffers.UOffsetT {
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))
inviter := buildAccountRef(b, inv.Inviter)
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)
}
// encodeInvitation builds an Invitation payload.
func encodeInvitation(inv backendclient.InvitationResp) []byte {
b := flatbuffers.NewBuilder(512)
b.Finish(buildInvitation(b, inv))
return b.FinishedBytes()
}
// encodeInvitationList builds an InvitationList payload.
func encodeInvitationList(r backendclient.InvitationListResp) []byte {
b := flatbuffers.NewBuilder(1024)
offs := make([]flatbuffers.UOffsetT, len(r.Invitations))
for i, inv := range r.Invitations {
offs[i] = buildInvitation(b, inv)
}
fb.InvitationListStartInvitationsVector(b, len(offs))
for i := len(offs) - 1; i >= 0; i-- {
b.PrependUOffsetT(offs[i])
}
v := b.EndVector(len(offs))
fb.InvitationListStart(b)
fb.InvitationListAddInvitations(b, v)
b.Finish(fb.InvitationListEnd(b))
return b.FinishedBytes()
}
// encodeGcg builds a GcgExport payload.
func encodeGcg(r backendclient.GcgResp) []byte {
b := flatbuffers.NewBuilder(1024)
gid := b.CreateString(r.GameID)
fn := b.CreateString(r.Filename)
content := b.CreateString(r.Content)
fb.GcgExportStart(b)
fb.GcgExportAddGameId(b, gid)
fb.GcgExportAddFilename(b, fn)
fb.GcgExportAddContent(b, content)
b.Finish(fb.GcgExportEnd(b))
return b.FinishedBytes()
}
+1
View File
@@ -91,6 +91,7 @@ func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Regi
r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true}
r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true}
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
registerStage8(r, backend)
return r
}
@@ -0,0 +1,302 @@
package transcode
import (
"context"
"scrabble/gateway/internal/backendclient"
fb "scrabble/pkg/fbs/scrabblefb"
)
// Stage 8 message types: friends (incl. the one-time code path), per-user blocks,
// friend-game invitations, profile editing + email binding, statistics and GCG
// export. All are authenticated. Registered by registerStage8 from NewRegistry.
const (
MsgFriendsList = "friends.list"
MsgFriendsIncoming = "friends.incoming"
MsgFriendRequest = "friends.request"
MsgFriendRespond = "friends.respond"
MsgFriendCancel = "friends.cancel"
MsgFriendUnfriend = "friends.unfriend"
MsgFriendCodeIssue = "friends.code.issue"
MsgFriendCodeRedeem = "friends.code.redeem"
MsgBlocksList = "blocks.list"
MsgBlockAdd = "blocks.add"
MsgBlockRemove = "blocks.remove"
MsgInvitationsList = "invitation.list"
MsgInvitationCreate = "invitation.create"
MsgInvitationAccept = "invitation.accept"
MsgInvitationDecline = "invitation.decline"
MsgInvitationCancel = "invitation.cancel"
MsgProfileUpdate = "profile.update"
MsgEmailBindReq = "email.bind.request"
MsgEmailBindConfirm = "email.bind.confirm"
MsgStatsGet = "stats.get"
MsgGameGCG = "game.gcg"
)
// registerStage8 adds the Stage 8 social, account and history operations to the
// registry (all authenticated; the email-bind ops carry the costly-email flag).
func registerStage8(r *Registry, backend *backendclient.Client) {
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true}
r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true}
r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true}
r.ops[MsgFriendUnfriend] = Op{Handler: friendUnfriendHandler(backend), Auth: true}
r.ops[MsgFriendCodeIssue] = Op{Handler: friendCodeIssueHandler(backend), Auth: true}
r.ops[MsgFriendCodeRedeem] = Op{Handler: friendCodeRedeemHandler(backend), Auth: true}
r.ops[MsgBlocksList] = Op{Handler: blocksListHandler(backend), Auth: true}
r.ops[MsgBlockAdd] = Op{Handler: blockAddHandler(backend), Auth: true}
r.ops[MsgBlockRemove] = Op{Handler: blockRemoveHandler(backend), Auth: true}
r.ops[MsgInvitationsList] = Op{Handler: invitationsListHandler(backend), Auth: true}
r.ops[MsgInvitationCreate] = Op{Handler: invitationCreateHandler(backend), Auth: true}
r.ops[MsgInvitationAccept] = Op{Handler: invitationRespondHandler(backend, true), Auth: true}
r.ops[MsgInvitationDecline] = Op{Handler: invitationRespondHandler(backend, false), Auth: true}
r.ops[MsgInvitationCancel] = Op{Handler: invitationCancelHandler(backend), Auth: true}
r.ops[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true}
r.ops[MsgEmailBindReq] = Op{Handler: emailBindRequestHandler(backend), Auth: true, Email: true}
r.ops[MsgEmailBindConfirm] = Op{Handler: emailBindConfirmHandler(backend), Auth: true, Email: true}
r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true}
r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true}
}
// --- friends ---
func friendsListHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.ListFriends(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeFriendList(res), nil
}
}
func friendsIncomingHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.ListIncoming(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeIncomingList(res), nil
}
}
func friendRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.SendFriendRequest(ctx, req.UserID, string(in.AccountId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func friendRespondHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsFriendRespondRequest(req.Payload, 0)
if err := backend.RespondFriendRequest(ctx, req.UserID, string(in.RequesterId()), in.Accept()); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func friendCancelHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.CancelFriendRequest(ctx, req.UserID, string(in.AccountId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func friendUnfriendHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.Unfriend(ctx, req.UserID, string(in.AccountId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func friendCodeIssueHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.IssueFriendCode(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeFriendCode(res), nil
}
}
func friendCodeRedeemHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsRedeemCodeRequest(req.Payload, 0)
res, err := backend.RedeemFriendCode(ctx, req.UserID, string(in.Code()))
if err != nil {
return nil, err
}
return encodeRedeemResult(res), nil
}
}
// --- blocks ---
func blocksListHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.ListBlocks(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeBlockList(res), nil
}
}
func blockAddHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.Block(ctx, req.UserID, string(in.AccountId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func blockRemoveHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.Unblock(ctx, req.UserID, string(in.AccountId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
// --- invitations ---
func invitationsListHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.ListInvitations(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeInvitationList(res), nil
}
}
func invitationCreateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsCreateInvitationRequest(req.Payload, 0)
params := backendclient.InvitationParams{
InviteeIDs: decodeInviteeIDs(in),
Variant: string(in.Variant()),
TurnTimeoutSecs: int(in.TurnTimeoutSecs()),
HintsAllowed: in.HintsAllowed(),
HintsPerPlayer: int(in.HintsPerPlayer()),
DropoutTiles: string(in.DropoutTiles()),
}
res, err := backend.CreateInvitation(ctx, req.UserID, params)
if err != nil {
return nil, err
}
return encodeInvitation(res), nil
}
}
func invitationRespondHandler(backend *backendclient.Client, accept bool) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsInvitationActionRequest(req.Payload, 0)
res, err := backend.RespondInvitation(ctx, req.UserID, string(in.InvitationId()), accept)
if err != nil {
return nil, err
}
return encodeInvitation(res), nil
}
}
func invitationCancelHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsInvitationActionRequest(req.Payload, 0)
if err := backend.CancelInvitation(ctx, req.UserID, string(in.InvitationId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
// --- profile, email, stats, gcg ---
func profileUpdateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsUpdateProfileRequest(req.Payload, 0)
p := backendclient.ProfileResp{
DisplayName: string(in.DisplayName()),
PreferredLanguage: string(in.PreferredLanguage()),
TimeZone: string(in.TimeZone()),
AwayStart: string(in.AwayStart()),
AwayEnd: string(in.AwayEnd()),
BlockChat: in.BlockChat(),
BlockFriendRequests: in.BlockFriendRequests(),
}
out, err := backend.UpdateProfile(ctx, req.UserID, p)
if err != nil {
return nil, err
}
return encodeProfile(out), nil
}
}
func emailBindRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailBindRequest(req.Payload, 0)
if err := backend.EmailBindRequest(ctx, req.UserID, string(in.Email())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func emailBindConfirmHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailConfirmRequest(req.Payload, 0)
out, err := backend.EmailBindConfirm(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil {
return nil, err
}
return encodeProfile(out), nil
}
}
func statsHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.Stats(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeStats(res), nil
}
}
func gcgHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.ExportGCG(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeGcg(res), nil
}
}
// decodeInviteeIDs reads the invitee id vector from a CreateInvitationRequest.
func decodeInviteeIDs(in *fb.CreateInvitationRequest) []string {
n := in.InviteeIdsLength()
out := make([]string, 0, n)
for i := range n {
out = append(out, string(in.InviteeIds(i)))
}
return out
}
@@ -0,0 +1,238 @@
package transcode_test
import (
"context"
"net/http"
"testing"
flatbuffers "github.com/google/flatbuffers/go"
"scrabble/gateway/internal/transcode"
fb "scrabble/pkg/fbs/scrabblefb"
)
// targetPayload builds a TargetRequest payload (friend request/cancel, block).
func targetPayload(accountID string) []byte {
b := flatbuffers.NewBuilder(32)
id := b.CreateString(accountID)
fb.TargetRequestStart(b)
fb.TargetRequestAddAccountId(b, id)
b.Finish(fb.TargetRequestEnd(b))
return b.FinishedBytes()
}
func TestFriendsListRoundTripDecodesNames(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-User-ID"); got != "u-1" {
t.Errorf("X-User-ID = %q, want u-1", got)
}
if r.URL.Path != "/api/v1/user/friends" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"friends":[{"account_id":"a-1","display_name":"Ann"},{"account_id":"a-2","display_name":"Bob"}]}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, ok := reg.Lookup(transcode.MsgFriendsList)
if !ok {
t.Fatal("friends.list not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
if err != nil {
t.Fatalf("handler: %v", err)
}
fl := fb.GetRootAsFriendList(payload, 0)
if fl.FriendsLength() != 2 {
t.Fatalf("friends length = %d, want 2", fl.FriendsLength())
}
var f fb.AccountRef
fl.Friends(&f, 1)
if string(f.AccountId()) != "a-2" || string(f.DisplayName()) != "Bob" {
t.Fatalf("friend[1] = (%q, %q), want (a-2, Bob)", f.AccountId(), f.DisplayName())
}
}
func TestFriendRequestForwardsTarget(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-User-ID"); got != "u-1" {
t.Errorf("X-User-ID = %q, want u-1", got)
}
if r.URL.Path != "/api/v1/user/friends/request" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"ok":true}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgFriendRequest)
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: targetPayload("b-1")})
if err != nil {
t.Fatalf("handler: %v", err)
}
if ack := fb.GetRootAsAck(payload, 0); !ack.Ok() {
t.Fatal("ack not ok")
}
}
func TestFriendCodeIssueAndRedeem(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/user/friends/code":
_, _ = w.Write([]byte(`{"code":"123456","expires_at_unix":1717000000}`))
case "/api/v1/user/friends/code/redeem":
_, _ = w.Write([]byte(`{"friend":{"account_id":"a-7","display_name":"Kaya"}}`))
default:
t.Errorf("unexpected path %q", r.URL.Path)
}
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
issue, _ := reg.Lookup(transcode.MsgFriendCodeIssue)
p1, err := issue.Handler(context.Background(), transcode.Request{UserID: "u-1"})
if err != nil {
t.Fatalf("issue: %v", err)
}
fc := fb.GetRootAsFriendCode(p1, 0)
if string(fc.Code()) != "123456" || fc.ExpiresAtUnix() != 1717000000 {
t.Fatalf("friend code = (%q, %d)", fc.Code(), fc.ExpiresAtUnix())
}
b := flatbuffers.NewBuilder(32)
code := b.CreateString("123456")
fb.RedeemCodeRequestStart(b)
fb.RedeemCodeRequestAddCode(b, code)
b.Finish(fb.RedeemCodeRequestEnd(b))
redeem, _ := reg.Lookup(transcode.MsgFriendCodeRedeem)
p2, err := redeem.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()})
if err != nil {
t.Fatalf("redeem: %v", err)
}
rr := fb.GetRootAsRedeemResult(p2, 0)
if f := rr.Friend(nil); f == nil || string(f.AccountId()) != "a-7" || string(f.DisplayName()) != "Kaya" {
t.Fatalf("redeem friend decoded wrong: %+v", f)
}
}
func TestInvitationCreateRoundTrip(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user/invitations" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"id":"i-1","inviter":{"account_id":"u-1","display_name":"Me"},"invitees":[{"account_id":"inv-1","display_name":"Friend","seat":1,"response":"pending"}],"variant":"english","turn_timeout_secs":86400,"hints_allowed":true,"hints_per_player":1,"dropout_tiles":"remove","status":"pending","expires_at_unix":42}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgInvitationCreate)
b := flatbuffers.NewBuilder(128)
inviteeID := b.CreateString("inv-1")
fb.CreateInvitationRequestStartInviteeIdsVector(b, 1)
b.PrependUOffsetT(inviteeID)
ids := b.EndVector(1)
variant := b.CreateString("english")
dropout := b.CreateString("remove")
fb.CreateInvitationRequestStart(b)
fb.CreateInvitationRequestAddInviteeIds(b, ids)
fb.CreateInvitationRequestAddVariant(b, variant)
fb.CreateInvitationRequestAddTurnTimeoutSecs(b, 86400)
fb.CreateInvitationRequestAddHintsAllowed(b, true)
fb.CreateInvitationRequestAddHintsPerPlayer(b, 1)
fb.CreateInvitationRequestAddDropoutTiles(b, dropout)
b.Finish(fb.CreateInvitationRequestEnd(b))
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()})
if err != nil {
t.Fatalf("handler: %v", err)
}
inv := fb.GetRootAsInvitation(payload, 0)
if string(inv.Id()) != "i-1" || inv.InviteesLength() != 1 || string(inv.Variant()) != "english" {
t.Fatalf("invitation decoded wrong: id=%q invitees=%d variant=%q", inv.Id(), inv.InviteesLength(), inv.Variant())
}
if iv := inv.Inviter(nil); iv == nil || string(iv.DisplayName()) != "Me" {
t.Fatalf("inviter decoded wrong: %+v", iv)
}
}
func TestStatsRoundTrip(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user/stats" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"wins":5,"losses":3,"draws":1,"max_game_points":420,"max_word_points":90}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgStatsGet)
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
if err != nil {
t.Fatalf("handler: %v", err)
}
st := fb.GetRootAsStatsView(payload, 0)
if st.Wins() != 5 || st.Losses() != 3 || st.Draws() != 1 || st.MaxGamePoints() != 420 || st.MaxWordPoints() != 90 {
t.Fatalf("stats decoded wrong: %+v", st)
}
}
func TestGcgRoundTrip(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user/games/g-1/gcg" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"game_id":"g-1","filename":"game-g-1.gcg","content":"#character-encoding UTF-8\n"}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgGameGCG)
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-1")})
if err != nil {
t.Fatalf("handler: %v", err)
}
gcg := fb.GetRootAsGcgExport(payload, 0)
if string(gcg.Filename()) != "game-g-1.gcg" || len(gcg.Content()) == 0 {
t.Fatalf("gcg decoded wrong: filename=%q content=%q", gcg.Filename(), gcg.Content())
}
}
func TestProfileUpdateRoundTripAway(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/profile" {
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
}
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"ru","time_zone":"Europe/Moscow","away_start":"00:00","away_end":"07:30"}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgProfileUpdate)
b := flatbuffers.NewBuilder(128)
name := b.CreateString("Kaya")
lang := b.CreateString("ru")
tz := b.CreateString("Europe/Moscow")
as := b.CreateString("00:00")
ae := b.CreateString("07:30")
fb.UpdateProfileRequestStart(b)
fb.UpdateProfileRequestAddDisplayName(b, name)
fb.UpdateProfileRequestAddPreferredLanguage(b, lang)
fb.UpdateProfileRequestAddTimeZone(b, tz)
fb.UpdateProfileRequestAddAwayStart(b, as)
fb.UpdateProfileRequestAddAwayEnd(b, ae)
b.Finish(fb.UpdateProfileRequestEnd(b))
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()})
if err != nil {
t.Fatalf("handler: %v", err)
}
p := fb.GetRootAsProfile(payload, 0)
if string(p.AwayStart()) != "00:00" || string(p.AwayEnd()) != "07:30" || string(p.PreferredLanguage()) != "ru" {
t.Fatalf("profile away round-trip wrong: start=%q end=%q lang=%q", p.AwayStart(), p.AwayEnd(), p.PreferredLanguage())
}
}
+148 -1
View File
@@ -103,7 +103,8 @@ table Ack {
// --- profile (authenticated) ---
// Profile is the authenticated account's own profile view.
// Profile is the authenticated account's own profile view. away_start/away_end are
// the "HH:MM" daily away-window bounds (added trailing — backward-compatible).
table Profile {
user_id:string;
display_name:string;
@@ -113,6 +114,8 @@ table Profile {
block_chat:bool;
block_friend_requests:bool;
is_guest:bool;
away_start:string;
away_end:string;
}
// --- game (authenticated) ---
@@ -242,6 +245,142 @@ table ChatList {
messages:[ChatMessage];
}
// --- Stage 8: account, statistics, friends, blocks, invitations, history ---
// AccountRef is a referenced account with its display name resolved — the shared
// shape for friends, blocked users and invitation participants.
table AccountRef {
account_id:string;
display_name:string;
}
// UpdateProfileRequest overwrites the full editable profile (the client sends the
// complete desired profile). away_start/away_end are "HH:MM" bounds.
table UpdateProfileRequest {
display_name:string;
preferred_language:string;
time_zone:string;
away_start:string;
away_end:string;
block_chat:bool;
block_friend_requests:bool;
}
// EmailBindRequest asks the backend to send a confirm-code binding email to the
// caller's account.
table EmailBindRequest {
email:string;
}
// EmailConfirmRequest verifies the code and binds the email (returns Profile).
table EmailConfirmRequest {
email:string;
code:string;
}
// StatsView is a durable account's lifetime statistics (games-played and win-rate
// are derived client-side).
table StatsView {
wins:int;
losses:int;
draws:int;
max_game_points:int;
max_word_points:int;
}
// TargetRequest names a single counterpart account (friend request/cancel/unfriend,
// block/unblock).
table TargetRequest {
account_id:string;
}
// FriendRespondRequest accepts or declines a pending request from a requester.
table FriendRespondRequest {
requester_id:string;
accept:bool;
}
// FriendList is the caller's accepted friends.
table FriendList {
friends:[AccountRef];
}
// IncomingRequestList is the friend requests awaiting the caller's response.
table IncomingRequestList {
requests:[AccountRef];
}
// FriendCode is a freshly issued one-time add-a-friend code (returned once).
table FriendCode {
code:string;
expires_at_unix:long;
}
// RedeemCodeRequest redeems a friend code, befriending its issuer.
table RedeemCodeRequest {
code:string;
}
// RedeemResult reports the new friend gained by redeeming a code.
table RedeemResult {
friend:AccountRef;
}
// BlockList is the accounts the caller has blocked.
table BlockList {
blocked:[AccountRef];
}
// InvitationInvitee is one invitee's seat and response, name resolved.
table InvitationInvitee {
account_id:string;
display_name:string;
seat:int;
response:string;
}
// Invitation is a friend-game invitation with its settings and invitees.
table Invitation {
id:string;
inviter:AccountRef;
invitees:[InvitationInvitee];
variant:string;
turn_timeout_secs:int;
hints_allowed:bool;
hints_per_player:int;
dropout_tiles:string;
status:string;
game_id:string;
expires_at_unix:long;
}
// CreateInvitationRequest proposes a 2-4 player friend game to the named invitees.
table CreateInvitationRequest {
invitee_ids:[string];
variant:string;
turn_timeout_secs:int;
hints_allowed:bool;
hints_per_player:int;
dropout_tiles:string;
}
// InvitationActionRequest accepts / declines / cancels an invitation by id.
table InvitationActionRequest {
invitation_id:string;
}
// InvitationList is the caller's open invitations.
table InvitationList {
invitations:[Invitation];
}
// GcgExport is a finished game's GCG transcript: a suggested filename and the text.
table GcgExport {
game_id:string;
filename:string;
content:string;
}
// --- push event payloads ---
// YourTurnEvent signals that it is now the recipient's turn.
@@ -271,3 +410,11 @@ table NudgeEvent {
table MatchFoundEvent {
game_id:string;
}
// 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", "invitation", "game_started");
// the client re-fetches its lobby counters on any of them.
table NotificationEvent {
kind:string;
}
+71
View File
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type AccountRef struct {
_tab flatbuffers.Table
}
func GetRootAsAccountRef(buf []byte, offset flatbuffers.UOffsetT) *AccountRef {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &AccountRef{}
x.Init(buf, n+offset)
return x
}
func FinishAccountRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsAccountRef(buf []byte, offset flatbuffers.UOffsetT) *AccountRef {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &AccountRef{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedAccountRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *AccountRef) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *AccountRef) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *AccountRef) AccountId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *AccountRef) DisplayName() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func AccountRefStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func AccountRefAddAccountId(builder *flatbuffers.Builder, accountId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(accountId), 0)
}
func AccountRefAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(displayName), 0)
}
func AccountRefEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type BlockList struct {
_tab flatbuffers.Table
}
func GetRootAsBlockList(buf []byte, offset flatbuffers.UOffsetT) *BlockList {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &BlockList{}
x.Init(buf, n+offset)
return x
}
func FinishBlockListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsBlockList(buf []byte, offset flatbuffers.UOffsetT) *BlockList {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &BlockList{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedBlockListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *BlockList) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *BlockList) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *BlockList) Blocked(obj *AccountRef, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *BlockList) BlockedLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func BlockListStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func BlockListAddBlocked(builder *flatbuffers.Builder, blocked flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(blocked), 0)
}
func BlockListStartBlockedVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func BlockListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,139 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type CreateInvitationRequest struct {
_tab flatbuffers.Table
}
func GetRootAsCreateInvitationRequest(buf []byte, offset flatbuffers.UOffsetT) *CreateInvitationRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &CreateInvitationRequest{}
x.Init(buf, n+offset)
return x
}
func FinishCreateInvitationRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsCreateInvitationRequest(buf []byte, offset flatbuffers.UOffsetT) *CreateInvitationRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &CreateInvitationRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedCreateInvitationRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *CreateInvitationRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *CreateInvitationRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *CreateInvitationRequest) InviteeIds(j int) []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
a := rcv._tab.Vector(o)
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
}
return nil
}
func (rcv *CreateInvitationRequest) InviteeIdsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func (rcv *CreateInvitationRequest) Variant() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *CreateInvitationRequest) TurnTimeoutSecs() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *CreateInvitationRequest) MutateTurnTimeoutSecs(n int32) bool {
return rcv._tab.MutateInt32Slot(8, n)
}
func (rcv *CreateInvitationRequest) HintsAllowed() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *CreateInvitationRequest) MutateHintsAllowed(n bool) bool {
return rcv._tab.MutateBoolSlot(10, n)
}
func (rcv *CreateInvitationRequest) HintsPerPlayer() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *CreateInvitationRequest) MutateHintsPerPlayer(n int32) bool {
return rcv._tab.MutateInt32Slot(12, n)
}
func (rcv *CreateInvitationRequest) DropoutTiles() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func CreateInvitationRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(6)
}
func CreateInvitationRequestAddInviteeIds(builder *flatbuffers.Builder, inviteeIds flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(inviteeIds), 0)
}
func CreateInvitationRequestStartInviteeIdsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func CreateInvitationRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(variant), 0)
}
func CreateInvitationRequestAddTurnTimeoutSecs(builder *flatbuffers.Builder, turnTimeoutSecs int32) {
builder.PrependInt32Slot(2, turnTimeoutSecs, 0)
}
func CreateInvitationRequestAddHintsAllowed(builder *flatbuffers.Builder, hintsAllowed bool) {
builder.PrependBoolSlot(3, hintsAllowed, false)
}
func CreateInvitationRequestAddHintsPerPlayer(builder *flatbuffers.Builder, hintsPerPlayer int32) {
builder.PrependInt32Slot(4, hintsPerPlayer, 0)
}
func CreateInvitationRequestAddDropoutTiles(builder *flatbuffers.Builder, dropoutTiles flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(dropoutTiles), 0)
}
func CreateInvitationRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type EmailBindRequest struct {
_tab flatbuffers.Table
}
func GetRootAsEmailBindRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailBindRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &EmailBindRequest{}
x.Init(buf, n+offset)
return x
}
func FinishEmailBindRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsEmailBindRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailBindRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &EmailBindRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedEmailBindRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *EmailBindRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *EmailBindRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *EmailBindRequest) Email() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func EmailBindRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func EmailBindRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
}
func EmailBindRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+71
View File
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type EmailConfirmRequest struct {
_tab flatbuffers.Table
}
func GetRootAsEmailConfirmRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &EmailConfirmRequest{}
x.Init(buf, n+offset)
return x
}
func FinishEmailConfirmRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsEmailConfirmRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &EmailConfirmRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedEmailConfirmRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *EmailConfirmRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *EmailConfirmRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *EmailConfirmRequest) Email() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *EmailConfirmRequest) Code() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func EmailConfirmRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func EmailConfirmRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
}
func EmailConfirmRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(code), 0)
}
func EmailConfirmRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type FriendCode struct {
_tab flatbuffers.Table
}
func GetRootAsFriendCode(buf []byte, offset flatbuffers.UOffsetT) *FriendCode {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &FriendCode{}
x.Init(buf, n+offset)
return x
}
func FinishFriendCodeBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsFriendCode(buf []byte, offset flatbuffers.UOffsetT) *FriendCode {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &FriendCode{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedFriendCodeBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *FriendCode) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *FriendCode) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *FriendCode) Code() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *FriendCode) ExpiresAtUnix() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *FriendCode) MutateExpiresAtUnix(n int64) bool {
return rcv._tab.MutateInt64Slot(6, n)
}
func FriendCodeStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func FriendCodeAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0)
}
func FriendCodeAddExpiresAtUnix(builder *flatbuffers.Builder, expiresAtUnix int64) {
builder.PrependInt64Slot(1, expiresAtUnix, 0)
}
func FriendCodeEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type FriendList struct {
_tab flatbuffers.Table
}
func GetRootAsFriendList(buf []byte, offset flatbuffers.UOffsetT) *FriendList {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &FriendList{}
x.Init(buf, n+offset)
return x
}
func FinishFriendListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsFriendList(buf []byte, offset flatbuffers.UOffsetT) *FriendList {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &FriendList{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedFriendListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *FriendList) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *FriendList) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *FriendList) Friends(obj *AccountRef, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *FriendList) FriendsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func FriendListStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func FriendListAddFriends(builder *flatbuffers.Builder, friends flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(friends), 0)
}
func FriendListStartFriendsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func FriendListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type FriendRespondRequest struct {
_tab flatbuffers.Table
}
func GetRootAsFriendRespondRequest(buf []byte, offset flatbuffers.UOffsetT) *FriendRespondRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &FriendRespondRequest{}
x.Init(buf, n+offset)
return x
}
func FinishFriendRespondRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsFriendRespondRequest(buf []byte, offset flatbuffers.UOffsetT) *FriendRespondRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &FriendRespondRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedFriendRespondRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *FriendRespondRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *FriendRespondRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *FriendRespondRequest) RequesterId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *FriendRespondRequest) Accept() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *FriendRespondRequest) MutateAccept(n bool) bool {
return rcv._tab.MutateBoolSlot(6, n)
}
func FriendRespondRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func FriendRespondRequestAddRequesterId(builder *flatbuffers.Builder, requesterId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requesterId), 0)
}
func FriendRespondRequestAddAccept(builder *flatbuffers.Builder, accept bool) {
builder.PrependBoolSlot(1, accept, false)
}
func FriendRespondRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+82
View File
@@ -0,0 +1,82 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type GcgExport struct {
_tab flatbuffers.Table
}
func GetRootAsGcgExport(buf []byte, offset flatbuffers.UOffsetT) *GcgExport {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &GcgExport{}
x.Init(buf, n+offset)
return x
}
func FinishGcgExportBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsGcgExport(buf []byte, offset flatbuffers.UOffsetT) *GcgExport {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &GcgExport{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedGcgExportBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *GcgExport) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *GcgExport) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *GcgExport) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *GcgExport) Filename() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *GcgExport) Content() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func GcgExportStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func GcgExportAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func GcgExportAddFilename(builder *flatbuffers.Builder, filename flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(filename), 0)
}
func GcgExportAddContent(builder *flatbuffers.Builder, content flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(content), 0)
}
func GcgExportEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type IncomingRequestList struct {
_tab flatbuffers.Table
}
func GetRootAsIncomingRequestList(buf []byte, offset flatbuffers.UOffsetT) *IncomingRequestList {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &IncomingRequestList{}
x.Init(buf, n+offset)
return x
}
func FinishIncomingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsIncomingRequestList(buf []byte, offset flatbuffers.UOffsetT) *IncomingRequestList {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &IncomingRequestList{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedIncomingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *IncomingRequestList) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *IncomingRequestList) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *IncomingRequestList) Requests(obj *AccountRef, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *IncomingRequestList) RequestsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func IncomingRequestListStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func IncomingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requests), 0)
}
func IncomingRequestListStartRequestsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func IncomingRequestListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+206
View File
@@ -0,0 +1,206 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type Invitation struct {
_tab flatbuffers.Table
}
func GetRootAsInvitation(buf []byte, offset flatbuffers.UOffsetT) *Invitation {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &Invitation{}
x.Init(buf, n+offset)
return x
}
func FinishInvitationBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsInvitation(buf []byte, offset flatbuffers.UOffsetT) *Invitation {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &Invitation{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedInvitationBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *Invitation) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *Invitation) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *Invitation) Id() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *Invitation) Inviter(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 *Invitation) Invitees(obj *InvitationInvitee, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *Invitation) InviteesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func (rcv *Invitation) Variant() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *Invitation) TurnTimeoutSecs() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *Invitation) MutateTurnTimeoutSecs(n int32) bool {
return rcv._tab.MutateInt32Slot(12, n)
}
func (rcv *Invitation) HintsAllowed() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *Invitation) MutateHintsAllowed(n bool) bool {
return rcv._tab.MutateBoolSlot(14, n)
}
func (rcv *Invitation) HintsPerPlayer() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *Invitation) MutateHintsPerPlayer(n int32) bool {
return rcv._tab.MutateInt32Slot(16, n)
}
func (rcv *Invitation) DropoutTiles() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *Invitation) Status() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(20))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *Invitation) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(22))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *Invitation) ExpiresAtUnix() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(24))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *Invitation) MutateExpiresAtUnix(n int64) bool {
return rcv._tab.MutateInt64Slot(24, n)
}
func InvitationStart(builder *flatbuffers.Builder) {
builder.StartObject(11)
}
func InvitationAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
}
func InvitationAddInviter(builder *flatbuffers.Builder, inviter flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(inviter), 0)
}
func InvitationAddInvitees(builder *flatbuffers.Builder, invitees flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(invitees), 0)
}
func InvitationStartInviteesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func InvitationAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(variant), 0)
}
func InvitationAddTurnTimeoutSecs(builder *flatbuffers.Builder, turnTimeoutSecs int32) {
builder.PrependInt32Slot(4, turnTimeoutSecs, 0)
}
func InvitationAddHintsAllowed(builder *flatbuffers.Builder, hintsAllowed bool) {
builder.PrependBoolSlot(5, hintsAllowed, false)
}
func InvitationAddHintsPerPlayer(builder *flatbuffers.Builder, hintsPerPlayer int32) {
builder.PrependInt32Slot(6, hintsPerPlayer, 0)
}
func InvitationAddDropoutTiles(builder *flatbuffers.Builder, dropoutTiles flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(dropoutTiles), 0)
}
func InvitationAddStatus(builder *flatbuffers.Builder, status flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(status), 0)
}
func InvitationAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(gameId), 0)
}
func InvitationAddExpiresAtUnix(builder *flatbuffers.Builder, expiresAtUnix int64) {
builder.PrependInt64Slot(10, expiresAtUnix, 0)
}
func InvitationEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type InvitationActionRequest struct {
_tab flatbuffers.Table
}
func GetRootAsInvitationActionRequest(buf []byte, offset flatbuffers.UOffsetT) *InvitationActionRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &InvitationActionRequest{}
x.Init(buf, n+offset)
return x
}
func FinishInvitationActionRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsInvitationActionRequest(buf []byte, offset flatbuffers.UOffsetT) *InvitationActionRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &InvitationActionRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedInvitationActionRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *InvitationActionRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *InvitationActionRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *InvitationActionRequest) InvitationId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func InvitationActionRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func InvitationActionRequestAddInvitationId(builder *flatbuffers.Builder, invitationId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(invitationId), 0)
}
func InvitationActionRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+97
View File
@@ -0,0 +1,97 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type InvitationInvitee struct {
_tab flatbuffers.Table
}
func GetRootAsInvitationInvitee(buf []byte, offset flatbuffers.UOffsetT) *InvitationInvitee {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &InvitationInvitee{}
x.Init(buf, n+offset)
return x
}
func FinishInvitationInviteeBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsInvitationInvitee(buf []byte, offset flatbuffers.UOffsetT) *InvitationInvitee {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &InvitationInvitee{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedInvitationInviteeBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *InvitationInvitee) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *InvitationInvitee) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *InvitationInvitee) AccountId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *InvitationInvitee) DisplayName() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *InvitationInvitee) Seat() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *InvitationInvitee) MutateSeat(n int32) bool {
return rcv._tab.MutateInt32Slot(8, n)
}
func (rcv *InvitationInvitee) Response() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func InvitationInviteeStart(builder *flatbuffers.Builder) {
builder.StartObject(4)
}
func InvitationInviteeAddAccountId(builder *flatbuffers.Builder, accountId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(accountId), 0)
}
func InvitationInviteeAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(displayName), 0)
}
func InvitationInviteeAddSeat(builder *flatbuffers.Builder, seat int32) {
builder.PrependInt32Slot(2, seat, 0)
}
func InvitationInviteeAddResponse(builder *flatbuffers.Builder, response flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(response), 0)
}
func InvitationInviteeEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type InvitationList struct {
_tab flatbuffers.Table
}
func GetRootAsInvitationList(buf []byte, offset flatbuffers.UOffsetT) *InvitationList {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &InvitationList{}
x.Init(buf, n+offset)
return x
}
func FinishInvitationListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsInvitationList(buf []byte, offset flatbuffers.UOffsetT) *InvitationList {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &InvitationList{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedInvitationListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *InvitationList) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *InvitationList) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *InvitationList) Invitations(obj *Invitation, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *InvitationList) InvitationsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func InvitationListStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func InvitationListAddInvitations(builder *flatbuffers.Builder, invitations flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(invitations), 0)
}
func InvitationListStartInvitationsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func InvitationListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type NotificationEvent struct {
_tab flatbuffers.Table
}
func GetRootAsNotificationEvent(buf []byte, offset flatbuffers.UOffsetT) *NotificationEvent {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &NotificationEvent{}
x.Init(buf, n+offset)
return x
}
func FinishNotificationEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsNotificationEvent(buf []byte, offset flatbuffers.UOffsetT) *NotificationEvent {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &NotificationEvent{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedNotificationEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *NotificationEvent) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *NotificationEvent) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *NotificationEvent) Kind() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func NotificationEventStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func NotificationEventAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(kind), 0)
}
func NotificationEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+23 -1
View File
@@ -121,8 +121,24 @@ func (rcv *Profile) MutateIsGuest(n bool) bool {
return rcv._tab.MutateBoolSlot(18, n)
}
func (rcv *Profile) AwayStart() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(20))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *Profile) AwayEnd() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(22))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func ProfileStart(builder *flatbuffers.Builder) {
builder.StartObject(8)
builder.StartObject(10)
}
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
@@ -148,6 +164,12 @@ func ProfileAddBlockFriendRequests(builder *flatbuffers.Builder, blockFriendRequ
func ProfileAddIsGuest(builder *flatbuffers.Builder, isGuest bool) {
builder.PrependBoolSlot(7, isGuest, false)
}
func ProfileAddAwayStart(builder *flatbuffers.Builder, awayStart flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(awayStart), 0)
}
func ProfileAddAwayEnd(builder *flatbuffers.Builder, awayEnd flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(awayEnd), 0)
}
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type RedeemCodeRequest struct {
_tab flatbuffers.Table
}
func GetRootAsRedeemCodeRequest(buf []byte, offset flatbuffers.UOffsetT) *RedeemCodeRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &RedeemCodeRequest{}
x.Init(buf, n+offset)
return x
}
func FinishRedeemCodeRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsRedeemCodeRequest(buf []byte, offset flatbuffers.UOffsetT) *RedeemCodeRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &RedeemCodeRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedRedeemCodeRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *RedeemCodeRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *RedeemCodeRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *RedeemCodeRequest) Code() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func RedeemCodeRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func RedeemCodeRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0)
}
func RedeemCodeRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+65
View File
@@ -0,0 +1,65 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type RedeemResult struct {
_tab flatbuffers.Table
}
func GetRootAsRedeemResult(buf []byte, offset flatbuffers.UOffsetT) *RedeemResult {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &RedeemResult{}
x.Init(buf, n+offset)
return x
}
func FinishRedeemResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsRedeemResult(buf []byte, offset flatbuffers.UOffsetT) *RedeemResult {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &RedeemResult{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedRedeemResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *RedeemResult) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *RedeemResult) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *RedeemResult) Friend(obj *AccountRef) *AccountRef {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
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 RedeemResultStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func RedeemResultAddFriend(builder *flatbuffers.Builder, friend flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(friend), 0)
}
func RedeemResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+124
View File
@@ -0,0 +1,124 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type StatsView struct {
_tab flatbuffers.Table
}
func GetRootAsStatsView(buf []byte, offset flatbuffers.UOffsetT) *StatsView {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &StatsView{}
x.Init(buf, n+offset)
return x
}
func FinishStatsViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsStatsView(buf []byte, offset flatbuffers.UOffsetT) *StatsView {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &StatsView{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedStatsViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *StatsView) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *StatsView) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *StatsView) Wins() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *StatsView) MutateWins(n int32) bool {
return rcv._tab.MutateInt32Slot(4, n)
}
func (rcv *StatsView) Losses() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *StatsView) MutateLosses(n int32) bool {
return rcv._tab.MutateInt32Slot(6, n)
}
func (rcv *StatsView) Draws() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *StatsView) MutateDraws(n int32) bool {
return rcv._tab.MutateInt32Slot(8, n)
}
func (rcv *StatsView) MaxGamePoints() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *StatsView) MutateMaxGamePoints(n int32) bool {
return rcv._tab.MutateInt32Slot(10, n)
}
func (rcv *StatsView) MaxWordPoints() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *StatsView) MutateMaxWordPoints(n int32) bool {
return rcv._tab.MutateInt32Slot(12, n)
}
func StatsViewStart(builder *flatbuffers.Builder) {
builder.StartObject(5)
}
func StatsViewAddWins(builder *flatbuffers.Builder, wins int32) {
builder.PrependInt32Slot(0, wins, 0)
}
func StatsViewAddLosses(builder *flatbuffers.Builder, losses int32) {
builder.PrependInt32Slot(1, losses, 0)
}
func StatsViewAddDraws(builder *flatbuffers.Builder, draws int32) {
builder.PrependInt32Slot(2, draws, 0)
}
func StatsViewAddMaxGamePoints(builder *flatbuffers.Builder, maxGamePoints int32) {
builder.PrependInt32Slot(3, maxGamePoints, 0)
}
func StatsViewAddMaxWordPoints(builder *flatbuffers.Builder, maxWordPoints int32) {
builder.PrependInt32Slot(4, maxWordPoints, 0)
}
func StatsViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type TargetRequest struct {
_tab flatbuffers.Table
}
func GetRootAsTargetRequest(buf []byte, offset flatbuffers.UOffsetT) *TargetRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &TargetRequest{}
x.Init(buf, n+offset)
return x
}
func FinishTargetRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsTargetRequest(buf []byte, offset flatbuffers.UOffsetT) *TargetRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &TargetRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedTargetRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *TargetRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *TargetRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *TargetRequest) AccountId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func TargetRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func TargetRequestAddAccountId(builder *flatbuffers.Builder, accountId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(accountId), 0)
}
func TargetRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+134
View File
@@ -0,0 +1,134 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type UpdateProfileRequest struct {
_tab flatbuffers.Table
}
func GetRootAsUpdateProfileRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateProfileRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &UpdateProfileRequest{}
x.Init(buf, n+offset)
return x
}
func FinishUpdateProfileRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsUpdateProfileRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateProfileRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &UpdateProfileRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedUpdateProfileRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *UpdateProfileRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *UpdateProfileRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *UpdateProfileRequest) DisplayName() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *UpdateProfileRequest) PreferredLanguage() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *UpdateProfileRequest) TimeZone() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *UpdateProfileRequest) AwayStart() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *UpdateProfileRequest) AwayEnd() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *UpdateProfileRequest) BlockChat() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *UpdateProfileRequest) MutateBlockChat(n bool) bool {
return rcv._tab.MutateBoolSlot(14, n)
}
func (rcv *UpdateProfileRequest) BlockFriendRequests() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *UpdateProfileRequest) MutateBlockFriendRequests(n bool) bool {
return rcv._tab.MutateBoolSlot(16, n)
}
func UpdateProfileRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(7)
}
func UpdateProfileRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(displayName), 0)
}
func UpdateProfileRequestAddPreferredLanguage(builder *flatbuffers.Builder, preferredLanguage flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(preferredLanguage), 0)
}
func UpdateProfileRequestAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(timeZone), 0)
}
func UpdateProfileRequestAddAwayStart(builder *flatbuffers.Builder, awayStart flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(awayStart), 0)
}
func UpdateProfileRequestAddAwayEnd(builder *flatbuffers.Builder, awayEnd flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(awayEnd), 0)
}
func UpdateProfileRequestAddBlockChat(builder *flatbuffers.Builder, blockChat bool) {
builder.PrependBoolSlot(5, blockChat, false)
}
func UpdateProfileRequestAddBlockFriendRequests(builder *flatbuffers.Builder, blockFriendRequests bool) {
builder.PrependBoolSlot(6, blockFriendRequests, false)
}
func UpdateProfileRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+8 -6
View File
@@ -7,8 +7,10 @@ platform webviews and packageable to native via Capacitor.
Stage 7 ships the **playable slice**: sign in (guest / email), the "my games" lobby,
auto-match, the board (place tiles by drag or tap, pass, exchange, resign), hint,
word-check + complaint, per-game chat and nudge, the live in-app stream, i18n (en/ru),
theme, and a read-only profile. Friends/blocks, friend-game invitations, profile
editing, the stats screen and the history/GCG viewer are Stage 8.
theme, and the profile. **Stage 8** adds friends/blocks (with one-time friend codes),
friend-game invitations, profile editing + email binding, the statistics screen, the
lobby notification badge, and the in-game history + GCG export (share or download,
finished games only).
## Scripts
@@ -60,10 +62,10 @@ runtime; the Telegram SDK itself is wired in the Telegram stage.
```
src/
lib/ model, client facade, transport (+ mock), codec, board replay,
placement state machine, premiums, i18n, theme, session, router, app store
components/ Header, Modal, Toast
screens/ Login, Lobby, NewGame, Profile, Settings, About
placement state machine, premiums, stats, share, i18n, theme, session, router, app store
components/ Header, Menu (+ badge), Modal, Toast, TabBar, Screen
screens/ Login, Lobby, NewGame, Profile, Settings, About, Friends, Stats
game/ Game, Board, Rack, Controls, MakeMove, Chat
gen/ committed edge codegen (FlatBuffers + Connect)
e2e/ Playwright smoke (mock)
e2e/ Playwright smoke + social specs (mock)
```
+1 -1
View File
@@ -64,7 +64,7 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => {
test('dropping the game ends it and shows the result', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.locator('.dropdown button').nth(3).click(); // Drop game
await page.getByRole('button', { name: 'Drop game' }).click(); // robust against menu growth
await page.locator('button.danger').click(); // confirm in the modal
await expect(page.locator('.status .over')).toBeVisible();
});
+150
View File
@@ -0,0 +1,150 @@
import { expect, test, type Page } from '@playwright/test';
// Stage 8 social / account / history surfaces against the mock transport (no backend).
// The mock profile is a durable account, so friends, invitations, stats and the GCG
// export are reachable from the seeded fixture.
async function loginLobby(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Active games')).toBeVisible();
}
async function openFriends(page: Page): Promise<void> {
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Friends/ }).click();
}
test('friends: issue a code, accept an incoming request, redeem a code', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
// Issue a one-time code — it is shown to share, with a copy control.
await page.getByRole('button', { name: /Show my code/i }).click();
await expect(page.getByTestId('friend-code')).toContainText('246813');
await expect(page.getByRole('button', { name: 'Copy' })).toBeVisible();
// The seeded incoming request (Rick) can be accepted; the requests section clears.
await expect(page.getByText('Friend requests')).toBeVisible();
await page.getByRole('button', { name: /^Accept$/ }).click();
await expect(page.getByText('Friend requests')).toBeHidden();
// Redeeming a code adds a new friend to the list.
await page.locator('.codein').fill('111111');
await page.getByRole('button', { name: /^Add$/ }).click();
await expect(page.locator('.who', { hasText: 'Friend 111111' })).toBeVisible();
});
test('invitations: the lobby shows an invitation and accepting clears it', async ({ page }) => {
await loginLobby(page);
await expect(page.getByText('Invitations')).toBeVisible();
await expect(page.getByText(/From Kaya/)).toBeVisible();
await page.getByRole('button', { name: /^Accept$/ }).click();
await expect(page.getByText(/From Kaya/)).toBeHidden();
});
test('stats screen shows the metrics', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Stats/ }).click();
await expect(page.getByText('Win rate')).toBeVisible();
await expect(page.getByText('Best move')).toBeVisible();
});
test('profile edit saves a new display name', async ({ page }) => {
await loginLobby(page);
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Profile/ }).click();
await page.getByRole('button', { name: /Edit profile/ }).click();
await page.locator('.edit input').first().fill('Kaya Test');
await page.getByRole('button', { name: /^Save$/ }).click();
await expect(page.locator('.name')).toHaveText('Kaya Test');
});
test('GCG export appears only for a finished game', async ({ page }) => {
await loginLobby(page);
// The finished game vs Kaya exposes the export; the menu carries the item.
await page.getByRole('button', { name: /Kaya/ }).click();
await page.locator('.burger').first().click();
await expect(page.getByRole('button', { name: /Export GCG/ })).toBeVisible();
});
test('GCG export is hidden for an active game', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click();
await page.locator('.burger').first().click();
await expect(page.getByRole('button', { name: /Export GCG/ })).toHaveCount(0);
});
test('finished game draws an inert footer and trims the live-only menu', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya
await expect(page.locator('[data-cell]').first()).toBeVisible();
// The footer (tab bar) is drawn but its controls are disabled in a finished game.
await expect(page.locator('.tab').first()).toBeDisabled();
// The menu drops Check word and Drop game once the game is over.
await page.locator('.burger').first().click();
await expect(page.getByRole('button', { name: 'Check word' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0);
});
test('lobby hamburger shows the pending notification count', async ({ page }) => {
await loginLobby(page);
// One incoming friend request (Rick) + one invitation (Kaya) = 2.
await expect(page.getByTestId('menu-badge')).toHaveText('2');
});
test('play with friends: a game type is required to send an invitation', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar
await page.getByRole('button', { name: 'Play with friends' }).click();
const send = page.getByRole('button', { name: 'Send invitation' });
await expect(send).toBeDisabled();
await page.getByRole('checkbox').first().check(); // pick a friend
await expect(send).toBeDisabled(); // still no game type
await page.locator('.field select').first().selectOption('english');
await expect(send).toBeEnabled();
await send.click(); // the mock creates it and returns to the lobby
await expect(page.getByText('Active games')).toBeVisible();
});
test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Add to friends: Ann/ }).click();
// Reopening the menu shows the item as a disabled "request sent".
await page.locator('.burger').first().click();
const sent = page.getByRole('button', { name: 'Request sent' });
await expect(sent).toBeVisible();
await expect(sent).toBeDisabled();
});
test('profile edit disables Save and flags an invalid display name', async ({ page }) => {
await loginLobby(page);
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Profile/ }).click();
await page.getByRole('button', { name: /Edit profile/ }).click();
const name = page.locator('.edit input').first();
const save = page.getByRole('button', { name: /^Save$/ });
await name.fill('Bad__Name'); // adjacent specials — invalid
await expect(save).toBeDisabled();
await expect(name).toHaveClass(/invalid/);
await name.fill('Good Name');
await expect(save).toBeEnabled();
});
test('chat send and nudge are icon buttons', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click();
await page.locator('.burger').first().click();
await page.getByRole('button', { name: 'Chat' }).click();
// Icon-only controls expose their action through the aria-label.
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Nudge' })).toBeVisible();
});
+2 -1
View File
@@ -1,6 +1,7 @@
// Bundle-size budget gate. Sums the gzipped size of the built app JS and fails if it
// exceeds the budget — a guard against an accidental heavy dependency. The real
// transport build is ~69 KB gzip today; the budget leaves headroom.
// transport build is ~82 KB gzip after the Stage 8 social/account/history surfaces;
// the budget leaves headroom.
import { readdirSync, readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
+6
View File
@@ -10,6 +10,8 @@
import Profile from './screens/Profile.svelte';
import Settings from './screens/Settings.svelte';
import About from './screens/About.svelte';
import Friends from './screens/Friends.svelte';
import Stats from './screens/Stats.svelte';
import Game from './game/Game.svelte';
onMount(() => {
@@ -31,6 +33,10 @@
<Settings />
{:else if router.route.name === 'about'}
<About />
{:else if router.route.name === 'friends'}
<Friends />
{:else if router.route.name === 'stats'}
<Stats />
{:else}
<Lobby />
{/if}
+53 -5
View File
@@ -1,6 +1,14 @@
<script lang="ts">
// The header hamburger + dropdown, shared by the lobby and game screens.
let { items }: { items: { label: string; onclick: () => void }[] } = $props();
// The header hamburger + dropdown, shared by the lobby and game screens. An item
// may carry a numeric badge; the hamburger shows the total via the `badge` prop so
// a pending count is visible while the menu is closed.
interface MenuItem {
label: string;
onclick: () => void;
badge?: number;
disabled?: boolean;
}
let { items, badge = 0 }: { items: MenuItem[]; badge?: number } = $props();
let open = $state(false);
function pick(fn: () => void) {
@@ -12,6 +20,7 @@
<div class="menu">
<button class="burger" onclick={() => (open = !open)} aria-label="Menu">
<span></span><span></span><span></span>
{#if badge > 0}<span class="dot" data-testid="menu-badge">{badge}</span>{/if}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -19,7 +28,10 @@
<div class="backdrop" onclick={() => (open = false)}></div>
<div class="dropdown">
{#each items as it (it.label)}
<button onclick={() => pick(it.onclick)}>{it.label}</button>
<button onclick={() => pick(it.onclick)} disabled={it.disabled}>
<span>{it.label}</span>
{#if it.badge}<span class="idot">{it.badge}</span>{/if}
</button>
{/each}
</div>
{/if}
@@ -31,6 +43,7 @@
display: inline-flex;
}
.burger {
position: relative;
background: none;
border: none;
width: 44px;
@@ -43,7 +56,34 @@
user-select: none;
-webkit-user-select: none;
}
.burger span {
.dot {
position: absolute;
top: -2px;
right: 0;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 999px;
background: var(--danger, #c0392b);
color: #fff;
font-size: 0.72rem;
line-height: 18px;
text-align: center;
font-weight: 700;
}
.idot {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--danger, #c0392b);
color: #fff;
font-size: 0.72rem;
line-height: 18px;
text-align: center;
font-weight: 700;
}
.burger span:not(.dot) {
display: block;
height: 3px;
background: var(--text);
@@ -69,6 +109,10 @@
overflow: hidden;
}
.dropdown button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
text-align: left;
background: none;
@@ -77,7 +121,11 @@
user-select: none;
-webkit-user-select: none;
}
.dropdown button:hover {
.dropdown button:hover:not(:disabled) {
background: var(--surface-2);
}
.dropdown button:disabled {
color: var(--text-muted);
opacity: 0.6;
}
</style>
+38 -2
View File
@@ -6,11 +6,38 @@
onclose,
children,
}: { title?: string; onclose?: () => void; children?: Snippet } = $props();
// Track the visual viewport so the backdrop covers only the area above an open
// mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport
// backdrop still centres it behind the keyboard. Sizing the backdrop to
// visualViewport keeps the sheet (and the start of a chat) fully on screen.
let vh = $state(0);
let top = $state(0);
$effect(() => {
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
if (!vv) return;
const update = () => {
vh = vv.height;
top = vv.offsetTop;
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => onclose?.()}>
<div
class="backdrop"
style:height={vh ? `${vh}px` : null}
style:top={vh ? `${top}px` : null}
onclick={() => onclose?.()}
>
<div class="sheet" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if title}<h2>{title}</h2>{/if}
{@render children?.()}
@@ -20,7 +47,13 @@
<style>
.backdrop {
position: fixed;
inset: 0;
left: 0;
right: 0;
top: 0;
/* Base fallback; overridden inline to the visual-viewport height/top so the
backdrop (and the centred sheet) stay above an open mobile keyboard. */
height: 100dvh;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
@@ -36,7 +69,10 @@
box-shadow: var(--shadow);
padding: var(--pad);
width: min(94vw, 420px);
/* dvh tracks the dynamic viewport, so the sheet shrinks above an open mobile
keyboard instead of being scrolled off the top (vh fallback first). */
max-height: 86vh;
max-height: 86dvh;
overflow: auto;
}
h2 {
+11 -5
View File
@@ -46,8 +46,8 @@
bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()}
/>
<button onclick={send} disabled={busy}>{t('chat.send')}</button>
<button class="nudge" onclick={onnudge} disabled={busy}>{t('chat.nudge')}</button>
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy} aria-label={t('chat.nudge')}>🛎️</button>
</div>
</div>
@@ -56,7 +56,10 @@
display: flex;
flex-direction: column;
gap: 10px;
/* dvh so the chat shrinks with an open keyboard, keeping the start of the
conversation on screen instead of pushed above the fold (vh fallback). */
height: 56vh;
height: 56dvh;
}
.list {
flex: 1;
@@ -95,18 +98,21 @@
}
.input input {
flex: 1;
min-width: 0;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
}
.input button {
padding: 10px 12px;
.iconbtn {
flex: 0 0 auto;
padding: 8px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 1.25rem;
line-height: 1;
}
</style>
+67 -22
View File
@@ -16,6 +16,7 @@
import { replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import {
BLANK,
newPlacement,
@@ -65,7 +66,7 @@
// Highlight the last word with a dark tile bg; while placing, only the pending tiles
// are highlighted. It flashes when the opponent just moved and it is now our turn.
const highlight = $derived(
placement.pending.length > 0 || !lastPlay
placement.pending.length > 0 || !lastPlay || (!!view && view.game.status !== 'active')
? new Set<string>()
: new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)),
);
@@ -369,11 +370,46 @@
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
async function exportGcg() {
try {
await shareOrDownloadGcg(await gateway.exportGcg(id));
} catch (e) {
handleError(e);
}
}
let requested = $state(new Set<string>());
const noop = () => {};
async function addFriend(accountId: string) {
try {
await gateway.friendRequest(accountId);
requested = new Set([...requested, accountId]);
showToast(t('friends.requestSent'));
} catch (e) {
handleError(e);
}
}
const opponents = $derived(
view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [],
);
// In a finished game the menu drops Check word and Drop game, gains Export GCG, and
// an "add to friends" item flips to a disabled "request sent" once tapped.
const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: openChat },
{ label: t('game.checkWord'), onclick: openCheck },
{ label: t('game.dropGame'), onclick: () => (resignOpen = true) },
...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: openCheck }]),
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
...(!app.profile?.isGuest
? opponents.map((s) =>
requested.has(s.accountId)
? { label: t('game.requestSent'), onclick: noop, disabled: true }
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
)
: []),
...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]),
]);
</script>
@@ -400,7 +436,7 @@
<li>
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
<span class="hs">{m.score}</span>
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
</li>
{/each}
{#if moves.length === 0}<li class="hempty"></li>{/if}
@@ -428,7 +464,7 @@
locale={app.locale}
{focus}
oncell={onCell}
ontogglezoom={() => (zoomed = !zoomed)}
ontogglezoom={() => { if (!gameOver) zoomed = !zoomed; }}
/>
</div>
</div>
@@ -445,28 +481,28 @@
</span>
</div>
{#if !gameOver}
<div class="rack-row">
<div class="rack-wrap">
<Rack {slots} {variant} {selected} ondown={onRackDown} />
</div>
{#if placement.pending.length > 0}
<HoldConfirm triggerClass="make" onhold={commit}>
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
{#snippet popover(close)}
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
{/snippet}
</HoldConfirm>
{/if}
<!-- The footer is drawn even when the game is over (rack + tab bar), but inert:
a finished game shows the final rack greyed out and the controls disabled. -->
<div class="rack-row" class:inert={gameOver}>
<div class="rack-wrap">
<Rack {slots} {variant} {selected} ondown={onRackDown} />
</div>
{/if}
{#if !gameOver && placement.pending.length > 0}
<HoldConfirm triggerClass="make" onhold={commit}>
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
{#snippet popover(close)}
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
{/snippet}
</HoldConfirm>
{/if}
</div>
{:else}
<p class="loading">{t('common.loading')}</p>
{/if}
{#snippet tabbar()}
{#if view && !gameOver}
{#if view}
<TabBar>
<button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
@@ -482,7 +518,7 @@
{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<button class="tab" disabled={busy || placement.pending.length > 0} onclick={shuffle}>
<button class="tab" disabled={busy || gameOver || placement.pending.length > 0} onclick={shuffle}>
<span class="sq">🔀</span>
</button>
</TabBar>
@@ -628,6 +664,11 @@
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.ht {
color: var(--text-muted);
font-weight: 400;
font-size: 0.85em;
}
.hempty {
justify-content: center;
color: var(--text-muted);
@@ -666,6 +707,10 @@
align-items: stretch;
padding: 0 var(--pad) 6px;
}
.rack-row.inert {
pointer-events: none;
opacity: 0.55;
}
.rack-wrap {
flex: 1;
min-width: 0;
+3
View File
@@ -40,6 +40,9 @@
display: flex;
gap: 5px;
align-items: center;
/* Reserve one tile's height so an empty rack (e.g. a finished game) keeps the
footer the same size as during play — no layout jump between states. */
min-height: min(12.5vw, 46px);
}
.tile {
position: relative;
+20
View File
@@ -1,36 +1,56 @@
// automatically generated by the FlatBuffers compiler, do not modify
export { AccountRef } from './scrabblefb/account-ref.js';
export { Ack } from './scrabblefb/ack.js';
export { BlockList } from './scrabblefb/block-list.js';
export { ChatList } from './scrabblefb/chat-list.js';
export { ChatMessage } from './scrabblefb/chat-message.js';
export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
export { ComplaintRequest } from './scrabblefb/complaint-request.js';
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
export { EmailBindRequest } from './scrabblefb/email-bind-request.js';
export { EmailConfirmRequest } from './scrabblefb/email-confirm-request.js';
export { EmailLoginRequest } from './scrabblefb/email-login-request.js';
export { EmailRequestRequest } from './scrabblefb/email-request-request.js';
export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
export { EvalRequest } from './scrabblefb/eval-request.js';
export { EvalResult } from './scrabblefb/eval-result.js';
export { ExchangeRequest } from './scrabblefb/exchange-request.js';
export { FriendCode } from './scrabblefb/friend-code.js';
export { FriendList } from './scrabblefb/friend-list.js';
export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js';
export { GameActionRequest } from './scrabblefb/game-action-request.js';
export { GameList } from './scrabblefb/game-list.js';
export { GameView } from './scrabblefb/game-view.js';
export { GcgExport } from './scrabblefb/gcg-export.js';
export { GuestLoginRequest } from './scrabblefb/guest-login-request.js';
export { HintResult } from './scrabblefb/hint-result.js';
export { History } from './scrabblefb/history.js';
export { IncomingRequestList } from './scrabblefb/incoming-request-list.js';
export { Invitation } from './scrabblefb/invitation.js';
export { InvitationActionRequest } from './scrabblefb/invitation-action-request.js';
export { InvitationInvitee } from './scrabblefb/invitation-invitee.js';
export { InvitationList } from './scrabblefb/invitation-list.js';
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
export { MatchResult } from './scrabblefb/match-result.js';
export { MoveRecord } from './scrabblefb/move-record.js';
export { MoveResult } from './scrabblefb/move-result.js';
export { NotificationEvent } from './scrabblefb/notification-event.js';
export { NudgeEvent } from './scrabblefb/nudge-event.js';
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
export { Profile } from './scrabblefb/profile.js';
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
export { RedeemResult } from './scrabblefb/redeem-result.js';
export { SeatView } from './scrabblefb/seat-view.js';
export { Session } from './scrabblefb/session.js';
export { StateRequest } from './scrabblefb/state-request.js';
export { StateView } from './scrabblefb/state-view.js';
export { StatsView } from './scrabblefb/stats-view.js';
export { SubmitPlayRequest } from './scrabblefb/submit-play-request.js';
export { TargetRequest } from './scrabblefb/target-request.js';
export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js';
export { TileRecord } from './scrabblefb/tile-record.js';
export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
export { WordCheckResult } from './scrabblefb/word-check-result.js';
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
+60
View File
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class AccountRef {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):AccountRef {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsAccountRef(bb:flatbuffers.ByteBuffer, obj?:AccountRef):AccountRef {
return (obj || new AccountRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsAccountRef(bb:flatbuffers.ByteBuffer, obj?:AccountRef):AccountRef {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new AccountRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
accountId():string|null
accountId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
accountId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
displayName():string|null
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
displayName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startAccountRef(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, accountIdOffset, 0);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, displayNameOffset, 0);
}
static endAccountRef(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createAccountRef(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
AccountRef.startAccountRef(builder);
AccountRef.addAccountId(builder, accountIdOffset);
AccountRef.addDisplayName(builder, displayNameOffset);
return AccountRef.endAccountRef(builder);
}
}
+66
View File
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
export class BlockList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):BlockList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsBlockList(bb:flatbuffers.ByteBuffer, obj?:BlockList):BlockList {
return (obj || new BlockList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsBlockList(bb:flatbuffers.ByteBuffer, obj?:BlockList):BlockList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new BlockList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
blocked(index: number, obj?:AccountRef):AccountRef|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
blockedLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startBlockList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addBlocked(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, blockedOffset, 0);
}
static createBlockedVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startBlockedVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endBlockList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createBlockList(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset):flatbuffers.Offset {
BlockList.startBlockList(builder);
BlockList.addBlocked(builder, blockedOffset);
return BlockList.endBlockList(builder);
}
}
@@ -0,0 +1,119 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class CreateInvitationRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):CreateInvitationRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsCreateInvitationRequest(bb:flatbuffers.ByteBuffer, obj?:CreateInvitationRequest):CreateInvitationRequest {
return (obj || new CreateInvitationRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsCreateInvitationRequest(bb:flatbuffers.ByteBuffer, obj?:CreateInvitationRequest):CreateInvitationRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new CreateInvitationRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
inviteeIds(index: number):string
inviteeIds(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
inviteeIds(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
inviteeIdsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
variant():string|null
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
variant(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
turnTimeoutSecs():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
hintsAllowed():boolean {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
hintsPerPlayer():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
dropoutTiles():string|null
dropoutTiles(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dropoutTiles(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startCreateInvitationRequest(builder:flatbuffers.Builder) {
builder.startObject(6);
}
static addInviteeIds(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, inviteeIdsOffset, 0);
}
static createInviteeIdsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startInviteeIdsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, variantOffset, 0);
}
static addTurnTimeoutSecs(builder:flatbuffers.Builder, turnTimeoutSecs:number) {
builder.addFieldInt32(2, turnTimeoutSecs, 0);
}
static addHintsAllowed(builder:flatbuffers.Builder, hintsAllowed:boolean) {
builder.addFieldInt8(3, +hintsAllowed, +false);
}
static addHintsPerPlayer(builder:flatbuffers.Builder, hintsPerPlayer:number) {
builder.addFieldInt32(4, hintsPerPlayer, 0);
}
static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, dropoutTilesOffset, 0);
}
static endCreateInvitationRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, turnTimeoutSecs:number, hintsAllowed:boolean, hintsPerPlayer:number, dropoutTilesOffset:flatbuffers.Offset):flatbuffers.Offset {
CreateInvitationRequest.startCreateInvitationRequest(builder);
CreateInvitationRequest.addInviteeIds(builder, inviteeIdsOffset);
CreateInvitationRequest.addVariant(builder, variantOffset);
CreateInvitationRequest.addTurnTimeoutSecs(builder, turnTimeoutSecs);
CreateInvitationRequest.addHintsAllowed(builder, hintsAllowed);
CreateInvitationRequest.addHintsPerPlayer(builder, hintsPerPlayer);
CreateInvitationRequest.addDropoutTiles(builder, dropoutTilesOffset);
return CreateInvitationRequest.endCreateInvitationRequest(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class EmailBindRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EmailBindRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEmailBindRequest(bb:flatbuffers.ByteBuffer, obj?:EmailBindRequest):EmailBindRequest {
return (obj || new EmailBindRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEmailBindRequest(bb:flatbuffers.ByteBuffer, obj?:EmailBindRequest):EmailBindRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EmailBindRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
email():string|null
email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
email(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEmailBindRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, emailOffset, 0);
}
static endEmailBindRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailBindRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailBindRequest.startEmailBindRequest(builder);
EmailBindRequest.addEmail(builder, emailOffset);
return EmailBindRequest.endEmailBindRequest(builder);
}
}
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class EmailConfirmRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EmailConfirmRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEmailConfirmRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmRequest):EmailConfirmRequest {
return (obj || new EmailConfirmRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEmailConfirmRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmRequest):EmailConfirmRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EmailConfirmRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
email():string|null
email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
email(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
code():string|null
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
code(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEmailConfirmRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, emailOffset, 0);
}
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, codeOffset, 0);
}
static endEmailConfirmRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailConfirmRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailConfirmRequest.startEmailConfirmRequest(builder);
EmailConfirmRequest.addEmail(builder, emailOffset);
EmailConfirmRequest.addCode(builder, codeOffset);
return EmailConfirmRequest.endEmailConfirmRequest(builder);
}
}
+58
View File
@@ -0,0 +1,58 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class FriendCode {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FriendCode {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFriendCode(bb:flatbuffers.ByteBuffer, obj?:FriendCode):FriendCode {
return (obj || new FriendCode()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFriendCode(bb:flatbuffers.ByteBuffer, obj?:FriendCode):FriendCode {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FriendCode()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
code():string|null
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
code(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
expiresAtUnix():bigint {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
}
static startFriendCode(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, codeOffset, 0);
}
static addExpiresAtUnix(builder:flatbuffers.Builder, expiresAtUnix:bigint) {
builder.addFieldInt64(1, expiresAtUnix, BigInt('0'));
}
static endFriendCode(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFriendCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset, expiresAtUnix:bigint):flatbuffers.Offset {
FriendCode.startFriendCode(builder);
FriendCode.addCode(builder, codeOffset);
FriendCode.addExpiresAtUnix(builder, expiresAtUnix);
return FriendCode.endFriendCode(builder);
}
}
+66
View File
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
export class FriendList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FriendList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFriendList(bb:flatbuffers.ByteBuffer, obj?:FriendList):FriendList {
return (obj || new FriendList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFriendList(bb:flatbuffers.ByteBuffer, obj?:FriendList):FriendList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FriendList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
friends(index: number, obj?:AccountRef):AccountRef|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
friendsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startFriendList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addFriends(builder:flatbuffers.Builder, friendsOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, friendsOffset, 0);
}
static createFriendsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startFriendsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endFriendList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFriendList(builder:flatbuffers.Builder, friendsOffset:flatbuffers.Offset):flatbuffers.Offset {
FriendList.startFriendList(builder);
FriendList.addFriends(builder, friendsOffset);
return FriendList.endFriendList(builder);
}
}
@@ -0,0 +1,58 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class FriendRespondRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FriendRespondRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFriendRespondRequest(bb:flatbuffers.ByteBuffer, obj?:FriendRespondRequest):FriendRespondRequest {
return (obj || new FriendRespondRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFriendRespondRequest(bb:flatbuffers.ByteBuffer, obj?:FriendRespondRequest):FriendRespondRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FriendRespondRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
requesterId():string|null
requesterId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
requesterId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
accept():boolean {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startFriendRespondRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addRequesterId(builder:flatbuffers.Builder, requesterIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, requesterIdOffset, 0);
}
static addAccept(builder:flatbuffers.Builder, accept:boolean) {
builder.addFieldInt8(1, +accept, +false);
}
static endFriendRespondRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFriendRespondRequest(builder:flatbuffers.Builder, requesterIdOffset:flatbuffers.Offset, accept:boolean):flatbuffers.Offset {
FriendRespondRequest.startFriendRespondRequest(builder);
FriendRespondRequest.addRequesterId(builder, requesterIdOffset);
FriendRespondRequest.addAccept(builder, accept);
return FriendRespondRequest.endFriendRespondRequest(builder);
}
}
+72
View File
@@ -0,0 +1,72 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class GcgExport {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):GcgExport {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsGcgExport(bb:flatbuffers.ByteBuffer, obj?:GcgExport):GcgExport {
return (obj || new GcgExport()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsGcgExport(bb:flatbuffers.ByteBuffer, obj?:GcgExport):GcgExport {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new GcgExport()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
filename():string|null
filename(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
filename(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
content():string|null
content(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
content(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startGcgExport(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addFilename(builder:flatbuffers.Builder, filenameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, filenameOffset, 0);
}
static addContent(builder:flatbuffers.Builder, contentOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, contentOffset, 0);
}
static endGcgExport(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGcgExport(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, filenameOffset:flatbuffers.Offset, contentOffset:flatbuffers.Offset):flatbuffers.Offset {
GcgExport.startGcgExport(builder);
GcgExport.addGameId(builder, gameIdOffset);
GcgExport.addFilename(builder, filenameOffset);
GcgExport.addContent(builder, contentOffset);
return GcgExport.endGcgExport(builder);
}
}
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
export class IncomingRequestList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):IncomingRequestList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsIncomingRequestList(bb:flatbuffers.ByteBuffer, obj?:IncomingRequestList):IncomingRequestList {
return (obj || new IncomingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsIncomingRequestList(bb:flatbuffers.ByteBuffer, obj?:IncomingRequestList):IncomingRequestList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new IncomingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
requests(index: number, obj?:AccountRef):AccountRef|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
requestsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startIncomingRequestList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, requestsOffset, 0);
}
static createRequestsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startRequestsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endIncomingRequestList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createIncomingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset):flatbuffers.Offset {
IncomingRequestList.startIncomingRequestList(builder);
IncomingRequestList.addRequests(builder, requestsOffset);
return IncomingRequestList.endIncomingRequestList(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class InvitationActionRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):InvitationActionRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsInvitationActionRequest(bb:flatbuffers.ByteBuffer, obj?:InvitationActionRequest):InvitationActionRequest {
return (obj || new InvitationActionRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsInvitationActionRequest(bb:flatbuffers.ByteBuffer, obj?:InvitationActionRequest):InvitationActionRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new InvitationActionRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
invitationId():string|null
invitationId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
invitationId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startInvitationActionRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addInvitationId(builder:flatbuffers.Builder, invitationIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, invitationIdOffset, 0);
}
static endInvitationActionRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createInvitationActionRequest(builder:flatbuffers.Builder, invitationIdOffset:flatbuffers.Offset):flatbuffers.Offset {
InvitationActionRequest.startInvitationActionRequest(builder);
InvitationActionRequest.addInvitationId(builder, invitationIdOffset);
return InvitationActionRequest.endInvitationActionRequest(builder);
}
}
@@ -0,0 +1,82 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class InvitationInvitee {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):InvitationInvitee {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsInvitationInvitee(bb:flatbuffers.ByteBuffer, obj?:InvitationInvitee):InvitationInvitee {
return (obj || new InvitationInvitee()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsInvitationInvitee(bb:flatbuffers.ByteBuffer, obj?:InvitationInvitee):InvitationInvitee {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new InvitationInvitee()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
accountId():string|null
accountId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
accountId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
displayName():string|null
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
displayName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
seat():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
response():string|null
response(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
response(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startInvitationInvitee(builder:flatbuffers.Builder) {
builder.startObject(4);
}
static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, accountIdOffset, 0);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, displayNameOffset, 0);
}
static addSeat(builder:flatbuffers.Builder, seat:number) {
builder.addFieldInt32(2, seat, 0);
}
static addResponse(builder:flatbuffers.Builder, responseOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, responseOffset, 0);
}
static endInvitationInvitee(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createInvitationInvitee(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, seat:number, responseOffset:flatbuffers.Offset):flatbuffers.Offset {
InvitationInvitee.startInvitationInvitee(builder);
InvitationInvitee.addAccountId(builder, accountIdOffset);
InvitationInvitee.addDisplayName(builder, displayNameOffset);
InvitationInvitee.addSeat(builder, seat);
InvitationInvitee.addResponse(builder, responseOffset);
return InvitationInvitee.endInvitationInvitee(builder);
}
}
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { Invitation } from '../scrabblefb/invitation.js';
export class InvitationList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):InvitationList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsInvitationList(bb:flatbuffers.ByteBuffer, obj?:InvitationList):InvitationList {
return (obj || new InvitationList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsInvitationList(bb:flatbuffers.ByteBuffer, obj?:InvitationList):InvitationList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new InvitationList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
invitations(index: number, obj?:Invitation):Invitation|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new Invitation()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
invitationsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startInvitationList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addInvitations(builder:flatbuffers.Builder, invitationsOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, invitationsOffset, 0);
}
static createInvitationsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startInvitationsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endInvitationList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createInvitationList(builder:flatbuffers.Builder, invitationsOffset:flatbuffers.Offset):flatbuffers.Offset {
InvitationList.startInvitationList(builder);
InvitationList.addInvitations(builder, invitationsOffset);
return InvitationList.endInvitationList(builder);
}
}
+162
View File
@@ -0,0 +1,162 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js';
import { InvitationInvitee } from '../scrabblefb/invitation-invitee.js';
export class Invitation {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):Invitation {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsInvitation(bb:flatbuffers.ByteBuffer, obj?:Invitation):Invitation {
return (obj || new Invitation()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsInvitation(bb:flatbuffers.ByteBuffer, obj?:Invitation):Invitation {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new Invitation()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
id():string|null
id(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
id(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
inviter(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;
}
invitees(index: number, obj?:InvitationInvitee):InvitationInvitee|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new InvitationInvitee()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
inviteesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
variant():string|null
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
variant(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
turnTimeoutSecs():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
hintsAllowed():boolean {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
hintsPerPlayer():number {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
dropoutTiles():string|null
dropoutTiles(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dropoutTiles(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
status():string|null
status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
status(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 20);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 22);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
expiresAtUnix():bigint {
const offset = this.bb!.__offset(this.bb_pos, 24);
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
}
static startInvitation(builder:flatbuffers.Builder) {
builder.startObject(11);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, idOffset, 0);
}
static addInviter(builder:flatbuffers.Builder, inviterOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, inviterOffset, 0);
}
static addInvitees(builder:flatbuffers.Builder, inviteesOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, inviteesOffset, 0);
}
static createInviteesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startInviteesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, variantOffset, 0);
}
static addTurnTimeoutSecs(builder:flatbuffers.Builder, turnTimeoutSecs:number) {
builder.addFieldInt32(4, turnTimeoutSecs, 0);
}
static addHintsAllowed(builder:flatbuffers.Builder, hintsAllowed:boolean) {
builder.addFieldInt8(5, +hintsAllowed, +false);
}
static addHintsPerPlayer(builder:flatbuffers.Builder, hintsPerPlayer:number) {
builder.addFieldInt32(6, hintsPerPlayer, 0);
}
static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(7, dropoutTilesOffset, 0);
}
static addStatus(builder:flatbuffers.Builder, statusOffset:flatbuffers.Offset) {
builder.addFieldOffset(8, statusOffset, 0);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(9, gameIdOffset, 0);
}
static addExpiresAtUnix(builder:flatbuffers.Builder, expiresAtUnix:bigint) {
builder.addFieldInt64(10, expiresAtUnix, BigInt('0'));
}
static endInvitation(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class NotificationEvent {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):NotificationEvent {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsNotificationEvent(bb:flatbuffers.ByteBuffer, obj?:NotificationEvent):NotificationEvent {
return (obj || new NotificationEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsNotificationEvent(bb:flatbuffers.ByteBuffer, obj?:NotificationEvent):NotificationEvent {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new NotificationEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
kind():string|null
kind(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
kind(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startNotificationEvent(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, kindOffset, 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);
}
}
+26 -2
View File
@@ -68,8 +68,22 @@ isGuest():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
awayStart():string|null
awayStart(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
awayStart(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 20);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
awayEnd():string|null
awayEnd(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
awayEnd(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 22);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startProfile(builder:flatbuffers.Builder) {
builder.startObject(8);
builder.startObject(10);
}
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -104,12 +118,20 @@ static addIsGuest(builder:flatbuffers.Builder, isGuest:boolean) {
builder.addFieldInt8(7, +isGuest, +false);
}
static addAwayStart(builder:flatbuffers.Builder, awayStartOffset:flatbuffers.Offset) {
builder.addFieldOffset(8, awayStartOffset, 0);
}
static addAwayEnd(builder:flatbuffers.Builder, awayEndOffset:flatbuffers.Offset) {
builder.addFieldOffset(9, awayEndOffset, 0);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean):flatbuffers.Offset {
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset):flatbuffers.Offset {
Profile.startProfile(builder);
Profile.addUserId(builder, userIdOffset);
Profile.addDisplayName(builder, displayNameOffset);
@@ -119,6 +141,8 @@ static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offse
Profile.addBlockChat(builder, blockChat);
Profile.addBlockFriendRequests(builder, blockFriendRequests);
Profile.addIsGuest(builder, isGuest);
Profile.addAwayStart(builder, awayStartOffset);
Profile.addAwayEnd(builder, awayEndOffset);
return Profile.endProfile(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class RedeemCodeRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):RedeemCodeRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsRedeemCodeRequest(bb:flatbuffers.ByteBuffer, obj?:RedeemCodeRequest):RedeemCodeRequest {
return (obj || new RedeemCodeRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsRedeemCodeRequest(bb:flatbuffers.ByteBuffer, obj?:RedeemCodeRequest):RedeemCodeRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new RedeemCodeRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
code():string|null
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
code(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startRedeemCodeRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, codeOffset, 0);
}
static endRedeemCodeRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createRedeemCodeRequest(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset):flatbuffers.Offset {
RedeemCodeRequest.startRedeemCodeRequest(builder);
RedeemCodeRequest.addCode(builder, codeOffset);
return RedeemCodeRequest.endRedeemCodeRequest(builder);
}
}

Some files were not shown because too many files have changed in this diff Show More