Stage 8: UI social/account/history surfaces
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode -> backend REST -> existing domain services): friends (incl. one-time friend codes), per-user blocks, friend-game invitations, profile editing + email binding, the statistics screen, and the in-game history + GCG export. Friends gain two add paths (interview decision, a deliberate plan change): one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited redeem); and play-gated requests (shared game required) where an explicit decline is permanent, an ignored request lapses after 30 days, and a code bypasses a decline. Migration 00006 widens friendships_status_chk and adds friend_codes. Lobby notification badge is poll + push: a new generic `notify` event drives it live; the client polls on open/focus. Language stays a single Settings control that writes through to the durable account's preferred_language. GCG export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file. Tests: backend unit + inttest (friend gate/decline/code, ListInvitations, GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN (Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, TESTING, module READMEs.
This commit is contained in:
@@ -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,52 @@ 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).
|
||||
|
||||
## Deferred TODOs (cross-stage)
|
||||
|
||||
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
|
||||
@@ -570,3 +616,8 @@ 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.
|
||||
|
||||
+8
-3
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+28
-13
@@ -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
|
||||
@@ -316,8 +323,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 +360,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 +375,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,
|
||||
|
||||
+21
-15
@@ -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 (2–4) 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
|
||||
@@ -91,11 +95,13 @@ 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
|
||||
|
||||
+22
-14
@@ -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 символов) и не должны
|
||||
@@ -92,11 +98,13 @@ confirm-коду: backend шлёт на почту короткий код, и
|
||||
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
|
||||
слияние появятся в Stage 11.
|
||||
|
||||
### История и статистика *(Stage 3)*
|
||||
### История и статистика *(Stage 3 / 8)*
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
в GCG. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи,
|
||||
макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все
|
||||
образованные им слова и бонус за все фишки).
|
||||
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
|
||||
раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это
|
||||
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||
ход, уже включающий все образованные им слова и бонус за все фишки).
|
||||
|
||||
### Администрирование *(Stage 10)*
|
||||
Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями
|
||||
|
||||
+14
-3
@@ -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
|
||||
|
||||
|
||||
+27
-1
@@ -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,28 @@ Lobby rows show two lines (opponents, then result + score) with a large place-ba
|
||||
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-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, timezone,
|
||||
the away-window time pickers, block toggles) and an email-binding sub-flow (enter email
|
||||
→ enter the confirm code). Interface language stays in **Settings** (it writes through
|
||||
to the account for durable users).
|
||||
- **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.
|
||||
|
||||
## Caveat
|
||||
|
||||
Emoji are rendered by the platform's system emoji font, so their exact look varies across
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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.
|
||||
await page.getByRole('button', { name: /Show my code/i }).click();
|
||||
await expect(page.getByTestId('friend-code')).toContainText('246813');
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<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;
|
||||
}
|
||||
let { items, badge = 0 }: { items: MenuItem[]; badge?: number } = $props();
|
||||
let open = $state(false);
|
||||
|
||||
function pick(fn: () => void) {
|
||||
@@ -12,6 +19,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 +27,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)}>
|
||||
<span>{it.label}</span>
|
||||
{#if it.badge}<span class="idot">{it.badge}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -31,6 +42,7 @@
|
||||
display: inline-flex;
|
||||
}
|
||||
.burger {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 44px;
|
||||
@@ -43,6 +55,33 @@
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.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 {
|
||||
display: block;
|
||||
height: 3px;
|
||||
@@ -69,6 +108,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
.dropdown button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
|
||||
+32
-1
@@ -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,
|
||||
@@ -369,10 +370,35 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function addFriend(accountId: string) {
|
||||
try {
|
||||
await gateway.friendRequest(accountId);
|
||||
showToast(t('friends.requestSent'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
const opponents = $derived(
|
||||
view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [],
|
||||
);
|
||||
|
||||
const menuItems = $derived([
|
||||
{ label: t('game.history'), onclick: () => (historyOpen = true) },
|
||||
{ label: t('game.chat'), onclick: openChat },
|
||||
{ label: t('game.checkWord'), onclick: openCheck },
|
||||
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
|
||||
...(!app.profile?.isGuest
|
||||
? opponents.map((s) => ({ label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) }))
|
||||
: []),
|
||||
{ label: t('game.dropGame'), onclick: () => (resignOpen = true) },
|
||||
]);
|
||||
</script>
|
||||
@@ -400,7 +426,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}
|
||||
@@ -628,6 +654,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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { AccountRef } from '../scrabblefb/account-ref.js';
|
||||
|
||||
|
||||
export class RedeemResult {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):RedeemResult {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsRedeemResult(bb:flatbuffers.ByteBuffer, obj?:RedeemResult):RedeemResult {
|
||||
return (obj || new RedeemResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsRedeemResult(bb:flatbuffers.ByteBuffer, obj?:RedeemResult):RedeemResult {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new RedeemResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
friend(obj?:AccountRef):AccountRef|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||
}
|
||||
|
||||
static startRedeemResult(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
}
|
||||
|
||||
static addFriend(builder:flatbuffers.Builder, friendOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, friendOffset, 0);
|
||||
}
|
||||
|
||||
static endRedeemResult(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createRedeemResult(builder:flatbuffers.Builder, friendOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
RedeemResult.startRedeemResult(builder);
|
||||
RedeemResult.addFriend(builder, friendOffset);
|
||||
return RedeemResult.endRedeemResult(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class StatsView {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):StatsView {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsStatsView(bb:flatbuffers.ByteBuffer, obj?:StatsView):StatsView {
|
||||
return (obj || new StatsView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsStatsView(bb:flatbuffers.ByteBuffer, obj?:StatsView):StatsView {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new StatsView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
wins():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
losses():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
draws():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
maxGamePoints():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
maxWordPoints():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startStatsView(builder:flatbuffers.Builder) {
|
||||
builder.startObject(5);
|
||||
}
|
||||
|
||||
static addWins(builder:flatbuffers.Builder, wins:number) {
|
||||
builder.addFieldInt32(0, wins, 0);
|
||||
}
|
||||
|
||||
static addLosses(builder:flatbuffers.Builder, losses:number) {
|
||||
builder.addFieldInt32(1, losses, 0);
|
||||
}
|
||||
|
||||
static addDraws(builder:flatbuffers.Builder, draws:number) {
|
||||
builder.addFieldInt32(2, draws, 0);
|
||||
}
|
||||
|
||||
static addMaxGamePoints(builder:flatbuffers.Builder, maxGamePoints:number) {
|
||||
builder.addFieldInt32(3, maxGamePoints, 0);
|
||||
}
|
||||
|
||||
static addMaxWordPoints(builder:flatbuffers.Builder, maxWordPoints:number) {
|
||||
builder.addFieldInt32(4, maxWordPoints, 0);
|
||||
}
|
||||
|
||||
static endStatsView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number):flatbuffers.Offset {
|
||||
StatsView.startStatsView(builder);
|
||||
StatsView.addWins(builder, wins);
|
||||
StatsView.addLosses(builder, losses);
|
||||
StatsView.addDraws(builder, draws);
|
||||
StatsView.addMaxGamePoints(builder, maxGamePoints);
|
||||
StatsView.addMaxWordPoints(builder, maxWordPoints);
|
||||
return StatsView.endStatsView(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class TargetRequest {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):TargetRequest {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsTargetRequest(bb:flatbuffers.ByteBuffer, obj?:TargetRequest):TargetRequest {
|
||||
return (obj || new TargetRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsTargetRequest(bb:flatbuffers.ByteBuffer, obj?:TargetRequest):TargetRequest {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new TargetRequest()).__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;
|
||||
}
|
||||
|
||||
static startTargetRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
}
|
||||
|
||||
static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, accountIdOffset, 0);
|
||||
}
|
||||
|
||||
static endTargetRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createTargetRequest(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
TargetRequest.startTargetRequest(builder);
|
||||
TargetRequest.addAccountId(builder, accountIdOffset);
|
||||
return TargetRequest.endTargetRequest(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class UpdateProfileRequest {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):UpdateProfileRequest {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsUpdateProfileRequest(bb:flatbuffers.ByteBuffer, obj?:UpdateProfileRequest):UpdateProfileRequest {
|
||||
return (obj || new UpdateProfileRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsUpdateProfileRequest(bb:flatbuffers.ByteBuffer, obj?:UpdateProfileRequest):UpdateProfileRequest {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new UpdateProfileRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
displayName():string|null
|
||||
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
displayName(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;
|
||||
}
|
||||
|
||||
preferredLanguage():string|null
|
||||
preferredLanguage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
preferredLanguage(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;
|
||||
}
|
||||
|
||||
timeZone():string|null
|
||||
timeZone(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
timeZone(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;
|
||||
}
|
||||
|
||||
awayStart():string|null
|
||||
awayStart(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
awayStart(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;
|
||||
}
|
||||
|
||||
awayEnd():string|null
|
||||
awayEnd(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
awayEnd(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
blockChat():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
blockFriendRequests():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
static startUpdateProfileRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(7);
|
||||
}
|
||||
|
||||
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, displayNameOffset, 0);
|
||||
}
|
||||
|
||||
static addPreferredLanguage(builder:flatbuffers.Builder, preferredLanguageOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, preferredLanguageOffset, 0);
|
||||
}
|
||||
|
||||
static addTimeZone(builder:flatbuffers.Builder, timeZoneOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, timeZoneOffset, 0);
|
||||
}
|
||||
|
||||
static addAwayStart(builder:flatbuffers.Builder, awayStartOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(3, awayStartOffset, 0);
|
||||
}
|
||||
|
||||
static addAwayEnd(builder:flatbuffers.Builder, awayEndOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(4, awayEndOffset, 0);
|
||||
}
|
||||
|
||||
static addBlockChat(builder:flatbuffers.Builder, blockChat:boolean) {
|
||||
builder.addFieldInt8(5, +blockChat, +false);
|
||||
}
|
||||
|
||||
static addBlockFriendRequests(builder:flatbuffers.Builder, blockFriendRequests:boolean) {
|
||||
builder.addFieldInt8(6, +blockFriendRequests, +false);
|
||||
}
|
||||
|
||||
static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean):flatbuffers.Offset {
|
||||
UpdateProfileRequest.startUpdateProfileRequest(builder);
|
||||
UpdateProfileRequest.addDisplayName(builder, displayNameOffset);
|
||||
UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset);
|
||||
UpdateProfileRequest.addTimeZone(builder, timeZoneOffset);
|
||||
UpdateProfileRequest.addAwayStart(builder, awayStartOffset);
|
||||
UpdateProfileRequest.addAwayEnd(builder, awayEndOffset);
|
||||
UpdateProfileRequest.addBlockChat(builder, blockChat);
|
||||
UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests);
|
||||
return UpdateProfileRequest.endUpdateProfileRequest(builder);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ export const app = $state<{
|
||||
reduceMotion: boolean;
|
||||
boardLabels: BoardLabelMode;
|
||||
localeLocked: boolean;
|
||||
/** Pending incoming friend requests + invitations, for the lobby badge. */
|
||||
notifications: number;
|
||||
}>({
|
||||
ready: false,
|
||||
session: null,
|
||||
@@ -39,6 +41,7 @@ export const app = $state<{
|
||||
reduceMotion: false,
|
||||
boardLabels: 'beginner',
|
||||
localeLocked: false,
|
||||
notifications: 0,
|
||||
});
|
||||
|
||||
let unsubscribeStream: (() => void) | null = null;
|
||||
@@ -76,12 +79,35 @@ function openStream(): void {
|
||||
showToast(t('game.yourTurn'), 'info');
|
||||
} else if (e.kind === 'match_found') {
|
||||
navigate(`/game/${e.gameId}`);
|
||||
} else if (e.kind === 'notify') {
|
||||
void refreshNotifications();
|
||||
}
|
||||
},
|
||||
() => showToast(t('error.unavailable'), 'error'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshNotifications recomputes the lobby badge count (incoming friend requests
|
||||
* plus open invitations). Authoritative poll, complementing the live 'notify' push.
|
||||
* Guests have no social surfaces, so it is a no-op for them.
|
||||
*/
|
||||
export async function refreshNotifications(): Promise<void> {
|
||||
if (!app.session || app.profile?.isGuest) {
|
||||
app.notifications = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [incoming, invitations] = await Promise.all([
|
||||
gateway.friendsIncoming(),
|
||||
gateway.invitationsList(),
|
||||
]);
|
||||
app.notifications = incoming.length + invitations.length;
|
||||
} catch {
|
||||
// Best-effort; leave the previous count on a transient failure.
|
||||
}
|
||||
}
|
||||
|
||||
function closeStream(): void {
|
||||
unsubscribeStream?.();
|
||||
unsubscribeStream = null;
|
||||
@@ -98,6 +124,7 @@ async function adoptSession(s: Session): Promise<void> {
|
||||
handleError(err);
|
||||
}
|
||||
openStream();
|
||||
void refreshNotifications();
|
||||
}
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
@@ -186,6 +213,30 @@ export function setLocalePref(locale: Locale): void {
|
||||
app.localeLocked = true;
|
||||
setLocale(locale);
|
||||
persistPrefs();
|
||||
void persistLanguageToServer(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* persistLanguageToServer writes the chosen interface language through to the
|
||||
* durable account's preferred_language, so the single Settings control is the
|
||||
* source of truth (guests keep only the client preference). Best-effort.
|
||||
*/
|
||||
async function persistLanguageToServer(locale: Locale): Promise<void> {
|
||||
const p = app.profile;
|
||||
if (!p || p.isGuest || p.preferredLanguage === locale) return;
|
||||
try {
|
||||
app.profile = await gateway.profileUpdate({
|
||||
displayName: p.displayName,
|
||||
preferredLanguage: locale,
|
||||
timeZone: p.timeZone,
|
||||
awayStart: p.awayStart,
|
||||
awayEnd: p.awayEnd,
|
||||
blockChat: p.blockChat,
|
||||
blockFriendRequests: p.blockFriendRequests,
|
||||
});
|
||||
} catch {
|
||||
// The client locale already changed; the server sync is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
export function setReduceMotion(on: boolean): void {
|
||||
@@ -198,3 +249,11 @@ export function setBoardLabels(mode: BoardLabelMode): void {
|
||||
app.boardLabels = mode;
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
// Refresh the lobby badge when the app returns to the foreground — a push 'notify'
|
||||
// may have been missed while the client was hidden/closed (poll + push, see §10).
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && app.session) void refreshNotifications();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,18 +5,25 @@
|
||||
// message.
|
||||
|
||||
import type {
|
||||
AccountRef,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
FriendCode,
|
||||
GameList,
|
||||
GameView,
|
||||
GcgExport,
|
||||
History,
|
||||
HintResult,
|
||||
Invitation,
|
||||
InvitationSettings,
|
||||
MatchResult,
|
||||
MoveResult,
|
||||
Profile,
|
||||
ProfileUpdate,
|
||||
PushEvent,
|
||||
Session,
|
||||
StateView,
|
||||
Stats,
|
||||
Tile,
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
@@ -74,6 +81,35 @@ export interface GatewayClient {
|
||||
chatList(gameId: string): Promise<ChatMessage[]>;
|
||||
nudge(gameId: string): Promise<ChatMessage>;
|
||||
|
||||
// --- friends (Stage 8) ---
|
||||
friendsList(): Promise<AccountRef[]>;
|
||||
friendsIncoming(): Promise<AccountRef[]>;
|
||||
friendRequest(accountId: string): Promise<void>;
|
||||
friendRespond(requesterId: string, accept: boolean): Promise<void>;
|
||||
friendCancel(accountId: string): Promise<void>;
|
||||
unfriend(accountId: string): Promise<void>;
|
||||
friendCodeIssue(): Promise<FriendCode>;
|
||||
friendCodeRedeem(code: string): Promise<AccountRef>;
|
||||
|
||||
// --- blocks (Stage 8) ---
|
||||
blocksList(): Promise<AccountRef[]>;
|
||||
block(accountId: string): Promise<void>;
|
||||
unblock(accountId: string): Promise<void>;
|
||||
|
||||
// --- invitations (Stage 8) ---
|
||||
invitationsList(): Promise<Invitation[]>;
|
||||
invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise<Invitation>;
|
||||
invitationAccept(invitationId: string): Promise<Invitation>;
|
||||
invitationDecline(invitationId: string): Promise<Invitation>;
|
||||
invitationCancel(invitationId: string): Promise<void>;
|
||||
|
||||
// --- profile / stats / history (Stage 8) ---
|
||||
profileUpdate(p: ProfileUpdate): Promise<Profile>;
|
||||
emailBindRequest(email: string): Promise<void>;
|
||||
emailBindConfirm(email: string, code: string): Promise<Profile>;
|
||||
statsGet(): Promise<Stats>;
|
||||
exportGcg(gameId: string): Promise<GcgExport>;
|
||||
|
||||
// --- live stream ---
|
||||
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { Builder, ByteBuffer } from 'flatbuffers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fb from '../gen/fbs/scrabblefb';
|
||||
import { decodeGameList, decodeSession, encodeSubmitPlay } from './codec';
|
||||
import {
|
||||
decodeFriendList,
|
||||
decodeGameList,
|
||||
decodeInvitation,
|
||||
decodeSession,
|
||||
decodeStats,
|
||||
encodeSubmitPlay,
|
||||
encodeTarget,
|
||||
} from './codec';
|
||||
|
||||
describe('codec', () => {
|
||||
it('encodes a SubmitPlayRequest the gateway can read', () => {
|
||||
@@ -77,4 +85,88 @@ describe('codec', () => {
|
||||
expect(gl.games[0].seats[0].displayName).toBe('Ann');
|
||||
expect(gl.games[0].seats[0].score).toBe(13);
|
||||
});
|
||||
|
||||
it('encodes a TargetRequest', () => {
|
||||
const r = fb.TargetRequest.getRootAsTargetRequest(new ByteBuffer(encodeTarget('a-1')));
|
||||
expect(r.accountId()).toBe('a-1');
|
||||
});
|
||||
|
||||
it('decodes a StatsView', () => {
|
||||
const b = new Builder(64);
|
||||
fb.StatsView.startStatsView(b);
|
||||
fb.StatsView.addWins(b, 7);
|
||||
fb.StatsView.addLosses(b, 4);
|
||||
fb.StatsView.addDraws(b, 1);
|
||||
fb.StatsView.addMaxGamePoints(b, 420);
|
||||
fb.StatsView.addMaxWordPoints(b, 90);
|
||||
b.finish(fb.StatsView.endStatsView(b));
|
||||
expect(decodeStats(b.asUint8Array())).toEqual({
|
||||
wins: 7,
|
||||
losses: 4,
|
||||
draws: 1,
|
||||
maxGamePoints: 420,
|
||||
maxWordPoints: 90,
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes a FriendList of account refs', () => {
|
||||
const b = new Builder(128);
|
||||
const id = b.createString('a-1');
|
||||
const dn = b.createString('Ann');
|
||||
fb.AccountRef.startAccountRef(b);
|
||||
fb.AccountRef.addAccountId(b, id);
|
||||
fb.AccountRef.addDisplayName(b, dn);
|
||||
const ref = fb.AccountRef.endAccountRef(b);
|
||||
const vec = fb.FriendList.createFriendsVector(b, [ref]);
|
||||
fb.FriendList.startFriendList(b);
|
||||
fb.FriendList.addFriends(b, vec);
|
||||
b.finish(fb.FriendList.endFriendList(b));
|
||||
expect(decodeFriendList(b.asUint8Array())).toEqual([{ accountId: 'a-1', displayName: 'Ann' }]);
|
||||
});
|
||||
|
||||
it('decodes an Invitation with inviter and invitees', () => {
|
||||
const b = new Builder(256);
|
||||
const iid = b.createString('u-1');
|
||||
const idn = b.createString('Me');
|
||||
fb.AccountRef.startAccountRef(b);
|
||||
fb.AccountRef.addAccountId(b, iid);
|
||||
fb.AccountRef.addDisplayName(b, idn);
|
||||
const inviter = fb.AccountRef.endAccountRef(b);
|
||||
|
||||
const aid = b.createString('inv-1');
|
||||
const adn = b.createString('Friend');
|
||||
const resp = b.createString('pending');
|
||||
fb.InvitationInvitee.startInvitationInvitee(b);
|
||||
fb.InvitationInvitee.addAccountId(b, aid);
|
||||
fb.InvitationInvitee.addDisplayName(b, adn);
|
||||
fb.InvitationInvitee.addSeat(b, 1);
|
||||
fb.InvitationInvitee.addResponse(b, resp);
|
||||
const invitee = fb.InvitationInvitee.endInvitationInvitee(b);
|
||||
const invitees = fb.Invitation.createInviteesVector(b, [invitee]);
|
||||
|
||||
const id = b.createString('i-1');
|
||||
const variant = b.createString('english');
|
||||
const dropout = b.createString('remove');
|
||||
const status = b.createString('pending');
|
||||
const gid = b.createString('');
|
||||
fb.Invitation.startInvitation(b);
|
||||
fb.Invitation.addId(b, id);
|
||||
fb.Invitation.addInviter(b, inviter);
|
||||
fb.Invitation.addInvitees(b, invitees);
|
||||
fb.Invitation.addVariant(b, variant);
|
||||
fb.Invitation.addTurnTimeoutSecs(b, 86400);
|
||||
fb.Invitation.addHintsAllowed(b, true);
|
||||
fb.Invitation.addHintsPerPlayer(b, 1);
|
||||
fb.Invitation.addDropoutTiles(b, dropout);
|
||||
fb.Invitation.addStatus(b, status);
|
||||
fb.Invitation.addGameId(b, gid);
|
||||
b.finish(fb.Invitation.endInvitation(b));
|
||||
|
||||
const inv = decodeInvitation(b.asUint8Array());
|
||||
expect(inv.id).toBe('i-1');
|
||||
expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' });
|
||||
expect(inv.invitees).toHaveLength(1);
|
||||
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
|
||||
expect(inv.variant).toBe('english');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,20 +7,28 @@ import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
|
||||
import * as fb from '../gen/fbs/scrabblefb';
|
||||
import type { PlacedTile } from './client';
|
||||
import type {
|
||||
AccountRef,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
FriendCode,
|
||||
GameList,
|
||||
GameView,
|
||||
GcgExport,
|
||||
History,
|
||||
HintResult,
|
||||
Invitation,
|
||||
InvitationInvitee,
|
||||
InvitationSettings,
|
||||
MatchResult,
|
||||
MoveRecord,
|
||||
MoveResult,
|
||||
Profile,
|
||||
ProfileUpdate,
|
||||
PushEvent,
|
||||
Seat,
|
||||
Session,
|
||||
StateView,
|
||||
Stats,
|
||||
Tile,
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
@@ -250,6 +258,8 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
displayName: s(p.displayName()),
|
||||
preferredLanguage: s(p.preferredLanguage()),
|
||||
timeZone: s(p.timeZone()),
|
||||
awayStart: s(p.awayStart()),
|
||||
awayEnd: s(p.awayEnd()),
|
||||
hintBalance: p.hintBalance(),
|
||||
blockChat: p.blockChat(),
|
||||
blockFriendRequests: p.blockFriendRequests(),
|
||||
@@ -357,6 +367,10 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
||||
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
|
||||
return { kind: 'match_found', gameId: s(e.gameId()) };
|
||||
}
|
||||
case 'notify': {
|
||||
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
|
||||
return { kind: 'notify', sub: s(e.kind()) };
|
||||
}
|
||||
case 'heartbeat':
|
||||
return { kind: 'heartbeat' };
|
||||
default:
|
||||
@@ -364,6 +378,199 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stage 8 encoders ---
|
||||
|
||||
export function encodeTarget(accountId: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const id = b.createString(accountId);
|
||||
fb.TargetRequest.startTargetRequest(b);
|
||||
fb.TargetRequest.addAccountId(b, id);
|
||||
return finish(b, fb.TargetRequest.endTargetRequest(b));
|
||||
}
|
||||
|
||||
export function encodeFriendRespond(requesterId: string, accept: boolean): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const id = b.createString(requesterId);
|
||||
fb.FriendRespondRequest.startFriendRespondRequest(b);
|
||||
fb.FriendRespondRequest.addRequesterId(b, id);
|
||||
fb.FriendRespondRequest.addAccept(b, accept);
|
||||
return finish(b, fb.FriendRespondRequest.endFriendRespondRequest(b));
|
||||
}
|
||||
|
||||
export function encodeRedeemCode(code: string): Uint8Array {
|
||||
const b = new Builder(32);
|
||||
const c = b.createString(code);
|
||||
fb.RedeemCodeRequest.startRedeemCodeRequest(b);
|
||||
fb.RedeemCodeRequest.addCode(b, c);
|
||||
return finish(b, fb.RedeemCodeRequest.endRedeemCodeRequest(b));
|
||||
}
|
||||
|
||||
export function encodeCreateInvitation(inviteeIds: string[], st: InvitationSettings): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const idOffs = inviteeIds.map((id) => b.createString(id));
|
||||
const ids = fb.CreateInvitationRequest.createInviteeIdsVector(b, idOffs);
|
||||
const variant = b.createString(st.variant);
|
||||
const dropout = b.createString(st.dropoutTiles);
|
||||
fb.CreateInvitationRequest.startCreateInvitationRequest(b);
|
||||
fb.CreateInvitationRequest.addInviteeIds(b, ids);
|
||||
fb.CreateInvitationRequest.addVariant(b, variant);
|
||||
fb.CreateInvitationRequest.addTurnTimeoutSecs(b, st.turnTimeoutSecs);
|
||||
fb.CreateInvitationRequest.addHintsAllowed(b, st.hintsAllowed);
|
||||
fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer);
|
||||
fb.CreateInvitationRequest.addDropoutTiles(b, dropout);
|
||||
return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b));
|
||||
}
|
||||
|
||||
export function encodeInvitationAction(invitationId: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const id = b.createString(invitationId);
|
||||
fb.InvitationActionRequest.startInvitationActionRequest(b);
|
||||
fb.InvitationActionRequest.addInvitationId(b, id);
|
||||
return finish(b, fb.InvitationActionRequest.endInvitationActionRequest(b));
|
||||
}
|
||||
|
||||
export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const name = b.createString(p.displayName);
|
||||
const lang = b.createString(p.preferredLanguage);
|
||||
const tz = b.createString(p.timeZone);
|
||||
const as = b.createString(p.awayStart);
|
||||
const ae = b.createString(p.awayEnd);
|
||||
fb.UpdateProfileRequest.startUpdateProfileRequest(b);
|
||||
fb.UpdateProfileRequest.addDisplayName(b, name);
|
||||
fb.UpdateProfileRequest.addPreferredLanguage(b, lang);
|
||||
fb.UpdateProfileRequest.addTimeZone(b, tz);
|
||||
fb.UpdateProfileRequest.addAwayStart(b, as);
|
||||
fb.UpdateProfileRequest.addAwayEnd(b, ae);
|
||||
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
|
||||
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
|
||||
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
|
||||
}
|
||||
|
||||
export function encodeEmailBind(email: string): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
const e = b.createString(email);
|
||||
fb.EmailBindRequest.startEmailBindRequest(b);
|
||||
fb.EmailBindRequest.addEmail(b, e);
|
||||
return finish(b, fb.EmailBindRequest.endEmailBindRequest(b));
|
||||
}
|
||||
|
||||
export function encodeEmailConfirm(email: string, code: string): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
const e = b.createString(email);
|
||||
const c = b.createString(code);
|
||||
fb.EmailConfirmRequest.startEmailConfirmRequest(b);
|
||||
fb.EmailConfirmRequest.addEmail(b, e);
|
||||
fb.EmailConfirmRequest.addCode(b, c);
|
||||
return finish(b, fb.EmailConfirmRequest.endEmailConfirmRequest(b));
|
||||
}
|
||||
|
||||
// --- Stage 8 decoders ---
|
||||
|
||||
function decodeAccountRef(r: fb.AccountRef): AccountRef {
|
||||
return { accountId: s(r.accountId()), displayName: s(r.displayName()) };
|
||||
}
|
||||
|
||||
export function decodeFriendList(buf: Uint8Array): AccountRef[] {
|
||||
const l = fb.FriendList.getRootAsFriendList(new ByteBuffer(buf));
|
||||
const out: AccountRef[] = [];
|
||||
for (let i = 0; i < l.friendsLength(); i++) {
|
||||
const r = l.friends(i);
|
||||
if (r) out.push(decodeAccountRef(r));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
|
||||
const l = fb.IncomingRequestList.getRootAsIncomingRequestList(new ByteBuffer(buf));
|
||||
const out: AccountRef[] = [];
|
||||
for (let i = 0; i < l.requestsLength(); i++) {
|
||||
const r = l.requests(i);
|
||||
if (r) out.push(decodeAccountRef(r));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeBlockList(buf: Uint8Array): AccountRef[] {
|
||||
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
|
||||
const out: AccountRef[] = [];
|
||||
for (let i = 0; i < l.blockedLength(); i++) {
|
||||
const r = l.blocked(i);
|
||||
if (r) out.push(decodeAccountRef(r));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeFriendCode(buf: Uint8Array): FriendCode {
|
||||
const c = fb.FriendCode.getRootAsFriendCode(new ByteBuffer(buf));
|
||||
return { code: s(c.code()), expiresAtUnix: Number(c.expiresAtUnix()) };
|
||||
}
|
||||
|
||||
export function decodeRedeemResult(buf: Uint8Array): AccountRef {
|
||||
const r = fb.RedeemResult.getRootAsRedeemResult(new ByteBuffer(buf));
|
||||
const f = r.friend();
|
||||
return f ? decodeAccountRef(f) : { accountId: '', displayName: '' };
|
||||
}
|
||||
|
||||
export function decodeStats(buf: Uint8Array): Stats {
|
||||
const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf));
|
||||
return {
|
||||
wins: v.wins(),
|
||||
losses: v.losses(),
|
||||
draws: v.draws(),
|
||||
maxGamePoints: v.maxGamePoints(),
|
||||
maxWordPoints: v.maxWordPoints(),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeInvitationTable(i: fb.Invitation): Invitation {
|
||||
const inviter = i.inviter();
|
||||
const invitees: InvitationInvitee[] = [];
|
||||
for (let k = 0; k < i.inviteesLength(); k++) {
|
||||
const iv = i.invitees(k);
|
||||
if (iv) {
|
||||
invitees.push({
|
||||
accountId: s(iv.accountId()),
|
||||
displayName: s(iv.displayName()),
|
||||
seat: iv.seat(),
|
||||
response: s(iv.response()),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: s(i.id()),
|
||||
inviter: inviter ? decodeAccountRef(inviter) : { accountId: '', displayName: '' },
|
||||
invitees,
|
||||
variant: s(i.variant()) as Variant,
|
||||
turnTimeoutSecs: i.turnTimeoutSecs(),
|
||||
hintsAllowed: i.hintsAllowed(),
|
||||
hintsPerPlayer: i.hintsPerPlayer(),
|
||||
dropoutTiles: s(i.dropoutTiles()),
|
||||
status: s(i.status()),
|
||||
gameId: s(i.gameId()),
|
||||
expiresAtUnix: Number(i.expiresAtUnix()),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeInvitation(buf: Uint8Array): Invitation {
|
||||
return decodeInvitationTable(fb.Invitation.getRootAsInvitation(new ByteBuffer(buf)));
|
||||
}
|
||||
|
||||
export function decodeInvitationList(buf: Uint8Array): Invitation[] {
|
||||
const l = fb.InvitationList.getRootAsInvitationList(new ByteBuffer(buf));
|
||||
const out: Invitation[] = [];
|
||||
for (let i = 0; i < l.invitationsLength(); i++) {
|
||||
const inv = l.invitations(i);
|
||||
if (inv) out.push(decodeInvitationTable(inv));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeGcg(buf: Uint8Array): GcgExport {
|
||||
const g = fb.GcgExport.getRootAsGcgExport(new ByteBuffer(buf));
|
||||
return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
|
||||
}
|
||||
|
||||
function emptyGame(): GameView {
|
||||
return {
|
||||
id: '',
|
||||
|
||||
+92
-1
@@ -102,7 +102,21 @@ export const en = {
|
||||
'profile.timezone': 'Time zone',
|
||||
'profile.hintBalance': 'Hint balance',
|
||||
'profile.guest': 'Guest account',
|
||||
'profile.readonly': 'Editing your profile arrives in a later update.',
|
||||
'profile.edit': 'Edit profile',
|
||||
'profile.displayName': 'Display name',
|
||||
'profile.awayWindow': 'Away window',
|
||||
'profile.awayHint': 'You are not auto-resigned during these hours.',
|
||||
'profile.from': 'From',
|
||||
'profile.to': 'To',
|
||||
'profile.blockChat': 'Disable chat',
|
||||
'profile.blockFriendRequests': 'Disable friend requests',
|
||||
'profile.email': 'Email',
|
||||
'profile.bindEmail': 'Bind email',
|
||||
'profile.emailCode': 'Confirmation code',
|
||||
'profile.emailSent': 'We sent a code to {email}.',
|
||||
'profile.emailBound': 'Email confirmed.',
|
||||
'profile.saved': 'Profile saved.',
|
||||
'profile.guestLocked': 'Sign in with email to manage your profile.',
|
||||
|
||||
'settings.title': 'Settings',
|
||||
'settings.theme': 'Theme',
|
||||
@@ -143,6 +157,83 @@ export const en = {
|
||||
'error.unavailable': 'Connection problem. Retrying…',
|
||||
'error.internal': 'Something went wrong.',
|
||||
'error.generic': 'Something went wrong.',
|
||||
|
||||
'lobby.invitations': 'Invitations',
|
||||
'lobby.friends': 'Friends',
|
||||
|
||||
'friends.title': 'Friends',
|
||||
'friends.yours': 'Your friends',
|
||||
'friends.none': 'No friends yet.',
|
||||
'friends.incoming': 'Friend requests',
|
||||
'friends.accept': 'Accept',
|
||||
'friends.decline': 'Decline',
|
||||
'friends.unfriend': 'Remove',
|
||||
'friends.block': 'Block',
|
||||
'friends.add': 'Add a friend',
|
||||
'friends.addFromGame': 'Add to friends',
|
||||
'friends.requestSent': 'Friend request sent.',
|
||||
'friends.getCode': 'Show my code',
|
||||
'friends.codeHint': 'Give this code to a friend within 12 hours.',
|
||||
'friends.codeExpires': 'Expires at {time}',
|
||||
'friends.enterCode': 'Have a code? Add a friend',
|
||||
'friends.codePlaceholder': '6-digit code',
|
||||
'friends.redeem': 'Add',
|
||||
'friends.added': 'Added {name}.',
|
||||
'friends.blockedList': 'Blocked players',
|
||||
'friends.unblock': 'Unblock',
|
||||
'friends.noneBlocked': 'No blocked players.',
|
||||
|
||||
'invitations.none': 'No invitations.',
|
||||
'invitations.from': 'From {name}',
|
||||
'invitations.with': 'With {names}',
|
||||
'invitations.accept': 'Accept',
|
||||
'invitations.decline': 'Decline',
|
||||
'invitations.cancel': 'Cancel',
|
||||
'invitations.waiting': 'Waiting for replies',
|
||||
|
||||
'new.auto': 'Quick match',
|
||||
'new.withFriends': 'Play with friends',
|
||||
'new.pickFriends': 'Choose who to invite',
|
||||
'new.invite': 'Send invitation',
|
||||
'new.moveTime': 'Move time',
|
||||
'new.hintsPerPlayer': 'Hints per player',
|
||||
'new.invited': 'Invitation sent.',
|
||||
'new.noFriends': 'Add friends first to invite them.',
|
||||
|
||||
'stats.title': 'Statistics',
|
||||
'stats.wins': 'Wins',
|
||||
'stats.losses': 'Losses',
|
||||
'stats.draws': 'Draws',
|
||||
'stats.played': 'Games',
|
||||
'stats.winRate': 'Win rate',
|
||||
'stats.maxGame': 'Best game',
|
||||
'stats.maxWord': 'Best move',
|
||||
'stats.guestHint': 'Sign in to track your statistics.',
|
||||
|
||||
'game.exportGcg': 'Export GCG',
|
||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||
|
||||
'time.minutes': '{n} min',
|
||||
'time.hours': '{n} h',
|
||||
|
||||
'error.self_relation': 'You cannot do that to yourself.',
|
||||
'error.request_exists': 'A request or friendship already exists.',
|
||||
'error.request_blocked': 'This player is not accepting requests.',
|
||||
'error.request_not_found': 'No matching friend request.',
|
||||
'error.no_shared_game': 'You can only add someone you have played with.',
|
||||
'error.request_declined': 'This player declined your request.',
|
||||
'error.friend_code_invalid': 'That friend code is invalid or expired.',
|
||||
'error.invalid_invitation': 'Invalid invitation.',
|
||||
'error.invitation_blocked': 'You cannot invite this player.',
|
||||
'error.invitation_not_found': 'Invitation not found.',
|
||||
'error.invitation_not_pending': 'This invitation is no longer open.',
|
||||
'error.invitation_expired': 'This invitation has expired.',
|
||||
'error.not_invited': 'You were not invited.',
|
||||
'error.already_responded': 'You already responded.',
|
||||
'error.not_inviter': 'Only the inviter can do that.',
|
||||
'error.game_active': 'Available only after the game is finished.',
|
||||
'error.invalid_profile': 'Some profile fields are invalid.',
|
||||
'error.already_confirmed': 'This email is already confirmed.',
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
|
||||
+92
-1
@@ -103,7 +103,21 @@ export const ru: Record<MessageKey, string> = {
|
||||
'profile.timezone': 'Часовой пояс',
|
||||
'profile.hintBalance': 'Баланс подсказок',
|
||||
'profile.guest': 'Гостевой аккаунт',
|
||||
'profile.readonly': 'Редактирование профиля появится в следующем обновлении.',
|
||||
'profile.edit': 'Редактировать профиль',
|
||||
'profile.displayName': 'Отображаемое имя',
|
||||
'profile.awayWindow': 'Окно отсутствия',
|
||||
'profile.awayHint': 'В эти часы вам не засчитывают автопоражение.',
|
||||
'profile.from': 'С',
|
||||
'profile.to': 'До',
|
||||
'profile.blockChat': 'Отключить чат',
|
||||
'profile.blockFriendRequests': 'Отключить заявки в друзья',
|
||||
'profile.email': 'Эл. почта',
|
||||
'profile.bindEmail': 'Привязать почту',
|
||||
'profile.emailCode': 'Код подтверждения',
|
||||
'profile.emailSent': 'Мы отправили код на {email}.',
|
||||
'profile.emailBound': 'Почта подтверждена.',
|
||||
'profile.saved': 'Профиль сохранён.',
|
||||
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
|
||||
|
||||
'settings.title': 'Настройки',
|
||||
'settings.theme': 'Тема',
|
||||
@@ -144,4 +158,81 @@ export const ru: Record<MessageKey, string> = {
|
||||
'error.unavailable': 'Проблема соединения. Повторяем…',
|
||||
'error.internal': 'Что-то пошло не так.',
|
||||
'error.generic': 'Что-то пошло не так.',
|
||||
|
||||
'lobby.invitations': 'Приглашения',
|
||||
'lobby.friends': 'Друзья',
|
||||
|
||||
'friends.title': 'Друзья',
|
||||
'friends.yours': 'Ваши друзья',
|
||||
'friends.none': 'Друзей пока нет.',
|
||||
'friends.incoming': 'Заявки в друзья',
|
||||
'friends.accept': 'Принять',
|
||||
'friends.decline': 'Отклонить',
|
||||
'friends.unfriend': 'Удалить',
|
||||
'friends.block': 'Заблокировать',
|
||||
'friends.add': 'Добавить друга',
|
||||
'friends.addFromGame': 'В друзья',
|
||||
'friends.requestSent': 'Заявка в друзья отправлена.',
|
||||
'friends.getCode': 'Показать мой код',
|
||||
'friends.codeHint': 'Передайте этот код другу в течение 12 часов.',
|
||||
'friends.codeExpires': 'Истекает в {time}',
|
||||
'friends.enterCode': 'Есть код? Добавить друга',
|
||||
'friends.codePlaceholder': 'Код из 6 цифр',
|
||||
'friends.redeem': 'Добавить',
|
||||
'friends.added': 'Добавлен(а) {name}.',
|
||||
'friends.blockedList': 'Заблокированные',
|
||||
'friends.unblock': 'Разблокировать',
|
||||
'friends.noneBlocked': 'Заблокированных нет.',
|
||||
|
||||
'invitations.none': 'Приглашений нет.',
|
||||
'invitations.from': 'От {name}',
|
||||
'invitations.with': 'С {names}',
|
||||
'invitations.accept': 'Принять',
|
||||
'invitations.decline': 'Отклонить',
|
||||
'invitations.cancel': 'Отменить',
|
||||
'invitations.waiting': 'Ожидаем ответы',
|
||||
|
||||
'new.auto': 'Быстрая игра',
|
||||
'new.withFriends': 'Игра с друзьями',
|
||||
'new.pickFriends': 'Кого пригласить',
|
||||
'new.invite': 'Отправить приглашение',
|
||||
'new.moveTime': 'Время на ход',
|
||||
'new.hintsPerPlayer': 'Подсказок на игрока',
|
||||
'new.invited': 'Приглашение отправлено.',
|
||||
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
|
||||
|
||||
'stats.title': 'Статистика',
|
||||
'stats.wins': 'Победы',
|
||||
'stats.losses': 'Поражения',
|
||||
'stats.draws': 'Ничьи',
|
||||
'stats.played': 'Игр',
|
||||
'stats.winRate': 'Доля побед',
|
||||
'stats.maxGame': 'Лучшая игра',
|
||||
'stats.maxWord': 'Лучший ход',
|
||||
'stats.guestHint': 'Войдите, чтобы вести статистику.',
|
||||
|
||||
'game.exportGcg': 'Экспорт GCG',
|
||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||
|
||||
'time.minutes': '{n} мин',
|
||||
'time.hours': '{n} ч',
|
||||
|
||||
'error.self_relation': 'Нельзя сделать это с самим собой.',
|
||||
'error.request_exists': 'Заявка или дружба уже существует.',
|
||||
'error.request_blocked': 'Игрок не принимает заявки.',
|
||||
'error.request_not_found': 'Подходящей заявки нет.',
|
||||
'error.no_shared_game': 'Можно добавить только того, с кем вы играли.',
|
||||
'error.request_declined': 'Игрок отклонил вашу заявку.',
|
||||
'error.friend_code_invalid': 'Код недействителен или истёк.',
|
||||
'error.invalid_invitation': 'Неверное приглашение.',
|
||||
'error.invitation_blocked': 'Нельзя пригласить этого игрока.',
|
||||
'error.invitation_not_found': 'Приглашение не найдено.',
|
||||
'error.invitation_not_pending': 'Приглашение больше не открыто.',
|
||||
'error.invitation_expired': 'Приглашение истекло.',
|
||||
'error.not_invited': 'Вас не приглашали.',
|
||||
'error.already_responded': 'Вы уже ответили.',
|
||||
'error.not_inviter': 'Только пригласивший может это сделать.',
|
||||
'error.game_active': 'Доступно только после завершения игры.',
|
||||
'error.invalid_profile': 'Некоторые поля профиля некорректны.',
|
||||
'error.already_confirmed': 'Эта почта уже подтверждена.',
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user