2a6dc5a304
Add the WalletOrderRequest / WalletOrderResponse FlatBuffers messages and the wallet.order Connect op: the gateway decodes the order request, forwards it to the backend POST /wallet/order and returns the created order id plus the provider launch URL the client opens. Regenerate the committed Go and TS FlatBuffers code.
681 lines
28 KiB
Go
681 lines
28 KiB
Go
package backendclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
)
|
|
|
|
// The structs below mirror the backend's JSON DTOs (backend/internal/server
|
|
// /dto.go). The transcode layer maps them to and from the FlatBuffers edge
|
|
// payloads.
|
|
|
|
// SessionResp is the credential minted by an auth operation.
|
|
type SessionResp struct {
|
|
Token string `json:"token"`
|
|
UserID string `json:"user_id"`
|
|
IsGuest bool `json:"is_guest"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
|
|
// ProfileResp is an account's own profile.
|
|
type ProfileResp 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"`
|
|
IsGuest bool `json:"is_guest"`
|
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
|
VariantPreferences []string `json:"variant_preferences"`
|
|
// Banner is the advertising-banner block, present only for a viewer eligible to
|
|
// see the banner. The gateway forwards it verbatim into the Profile payload.
|
|
Banner *BannerResp `json:"banner,omitempty"`
|
|
// Email is the confirmed email ("" when none); TelegramLinked/VkLinked report an
|
|
// attached platform identity — they drive the profile's link/unlink/change controls.
|
|
Email string `json:"email"`
|
|
TelegramLinked bool `json:"telegram_linked"`
|
|
VkLinked bool `json:"vk_linked"`
|
|
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
|
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
|
DictVersions []DictVersion `json:"dict_versions,omitempty"`
|
|
}
|
|
|
|
// DictVersion pairs a game variant's stable label with its current dictionary version.
|
|
type DictVersion struct {
|
|
Variant string `json:"variant"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
|
// campaigns to rotate and the global display timings the client rotator reads.
|
|
type BannerResp struct {
|
|
Campaigns []BannerCampaignResp `json:"campaigns"`
|
|
Timings BannerTimingsResp `json:"timings"`
|
|
}
|
|
|
|
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
|
|
// weight and its messages, in display order, already resolved to one language.
|
|
// The override_* colours are the optional per-campaign palette (empty when the
|
|
// campaign keeps the neutral theme tokens); they ride through to the client
|
|
// verbatim, mirroring the backend DTO.
|
|
type BannerCampaignResp struct {
|
|
Weight int `json:"weight"`
|
|
Messages []string `json:"messages"`
|
|
OverrideBg string `json:"override_bg,omitempty"`
|
|
OverrideFg string `json:"override_fg,omitempty"`
|
|
OverrideLink string `json:"override_link,omitempty"`
|
|
OverrideBgDark string `json:"override_bg_dark,omitempty"`
|
|
OverrideFgDark string `json:"override_fg_dark,omitempty"`
|
|
OverrideLinkDark string `json:"override_link_dark,omitempty"`
|
|
}
|
|
|
|
// BannerTimingsResp mirrors the backend's global display timings.
|
|
type BannerTimingsResp struct {
|
|
HoldMs int `json:"hold_ms"`
|
|
EdgePauseMs int `json:"edge_pause_ms"`
|
|
ScrollPxPerSec int `json:"scroll_px_per_sec"`
|
|
FadeOutMs int `json:"fade_out_ms"`
|
|
GapMs int `json:"gap_ms"`
|
|
FadeInMs int `json:"fade_in_ms"`
|
|
}
|
|
|
|
// LinkResultResp is the result of an account link/merge step. Status is
|
|
// "linked", "merge_required" (the secondary_* fields summarise the other account) or
|
|
// "merged". Token is a switched-session token (a guest initiator's durable
|
|
// counterpart won); Profile is the surviving/active account's profile.
|
|
type LinkResultResp struct {
|
|
Status string `json:"status"`
|
|
SecondaryUserID string `json:"secondary_user_id"`
|
|
SecondaryName string `json:"secondary_display_name"`
|
|
SecondaryGames int `json:"secondary_games"`
|
|
SecondaryFriends int `json:"secondary_friends"`
|
|
Token string `json:"token"`
|
|
Profile *ProfileResp `json:"profile"`
|
|
}
|
|
|
|
// TileJSON is one tile in a decoded move response (history, move result, hint); its Letter
|
|
// is a concrete character (the move journal is kept in letters).
|
|
type TileJSON struct {
|
|
Row int `json:"row"`
|
|
Col int `json:"col"`
|
|
Letter string `json:"letter"`
|
|
Blank bool `json:"blank"`
|
|
}
|
|
|
|
// PlayTileJSON is one inbound tile to place, addressed by alphabet index. For a
|
|
// blank, Letter is the designated letter's index and Blank is true.
|
|
type PlayTileJSON struct {
|
|
Row int `json:"row"`
|
|
Col int `json:"col"`
|
|
Letter int `json:"letter"`
|
|
Blank bool `json:"blank"`
|
|
}
|
|
|
|
// MoveRecordResp is a decoded move.
|
|
type MoveRecordResp struct {
|
|
Player int `json:"player"`
|
|
Action string `json:"action"`
|
|
Dir string `json:"dir"`
|
|
MainRow int `json:"main_row"`
|
|
MainCol int `json:"main_col"`
|
|
Tiles []TileJSON `json:"tiles"`
|
|
Words []string `json:"words"`
|
|
Count int `json:"count"`
|
|
Score int `json:"score"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// SeatResp is one seat's public standing.
|
|
type SeatResp struct {
|
|
Seat int `json:"seat"`
|
|
AccountID string `json:"account_id"`
|
|
DisplayName string `json:"display_name"`
|
|
Score int `json:"score"`
|
|
HintsUsed int `json:"hints_used"`
|
|
IsWinner bool `json:"is_winner"`
|
|
}
|
|
|
|
// GameResp is the shared game summary.
|
|
type GameResp struct {
|
|
ID string `json:"id"`
|
|
Variant string `json:"variant"`
|
|
DictVersion string `json:"dict_version"`
|
|
Status string `json:"status"`
|
|
Players int `json:"players"`
|
|
ToMove int `json:"to_move"`
|
|
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
|
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
|
VsAI bool `json:"vs_ai"`
|
|
MoveCount int `json:"move_count"`
|
|
EndReason string `json:"end_reason"`
|
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
|
Seats []SeatResp `json:"seats"`
|
|
UnreadChat bool `json:"unread_chat"`
|
|
UnreadMessages bool `json:"unread_messages"`
|
|
}
|
|
|
|
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
|
// wire alphabet indices and BagLen the bag size after the draw.
|
|
type MoveResultResp struct {
|
|
Move MoveRecordResp `json:"move"`
|
|
Game GameResp `json:"game"`
|
|
Rack []int `json:"rack"`
|
|
BagLen int `json:"bag_len"`
|
|
}
|
|
|
|
// AlphabetEntryJSON is one letter of a variant's alphabet (its index, concrete letter and
|
|
// tile value), present in StateResp only when the client requested it.
|
|
type AlphabetEntryJSON struct {
|
|
Index int `json:"index"`
|
|
Letter string `json:"letter"`
|
|
Value int `json:"value"`
|
|
}
|
|
|
|
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
|
// Alphabet is present only when the request asked for it.
|
|
type StateResp struct {
|
|
Game GameResp `json:"game"`
|
|
Seat int `json:"seat"`
|
|
Rack []int `json:"rack"`
|
|
BagLen int `json:"bag_len"`
|
|
HintsRemaining int `json:"hints_remaining"`
|
|
WalletBalance int `json:"wallet_balance"`
|
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
|
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
|
}
|
|
|
|
// MatchResp reports an auto-match outcome.
|
|
type MatchResp struct {
|
|
Matched bool `json:"matched"`
|
|
Game *GameResp `json:"game,omitempty"`
|
|
}
|
|
|
|
// ChatResp is a stored chat message.
|
|
type ChatResp struct {
|
|
ID string `json:"id"`
|
|
GameID string `json:"game_id"`
|
|
SenderID string `json:"sender_id"`
|
|
Kind string `json:"kind"`
|
|
Body string `json:"body"`
|
|
CreatedAtUnix int64 `json:"created_at_unix"`
|
|
}
|
|
|
|
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
|
|
// brand-new account's display name and language from the validated launch fields, its
|
|
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
|
|
// validated launch deep-link startParam, its variant preferences (first contact only).
|
|
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam, subtype string) (SessionResp, error) {
|
|
var out SessionResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
|
map[string]string{
|
|
"external_id": externalID,
|
|
"language_code": languageCode,
|
|
"username": username,
|
|
"first_name": firstName,
|
|
"browser_tz": browserTz,
|
|
"start_param": startParam,
|
|
"subtype": subtype,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// VKAuth provisions/finds the VK account and mints a session, seeding a brand-new
|
|
// account's preferred language from languageCode (the vk_language hint), its display
|
|
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
|
|
// name from the signed launch params) and its time zone from browserTz (the client's
|
|
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
|
|
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz, subtype string) (SessionResp, error) {
|
|
var out SessionResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
|
map[string]string{
|
|
"external_id": externalID,
|
|
"language_code": languageCode,
|
|
"display_name": displayName,
|
|
"browser_tz": browserTz,
|
|
"subtype": subtype,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// PushTargetResp is a recipient's out-of-app push routing data: their Telegram
|
|
// external_id (empty when they have no Telegram identity), preferred language, and
|
|
// whether they confined notifications to the in-app stream.
|
|
type PushTargetResp struct {
|
|
ExternalID string `json:"external_id"`
|
|
Language string `json:"language"`
|
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
|
}
|
|
|
|
// PushTarget resolves a user id to their out-of-app Telegram routing data (the
|
|
// gateway uses it to decide whether to deliver an event over platform push).
|
|
func (c *Client) PushTarget(ctx context.Context, userID string) (PushTargetResp, error) {
|
|
var out PushTargetResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/push-target", "", "",
|
|
map[string]string{"user_id": userID}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// ChatAccessResp is a user's moderated-chat write eligibility: ExternalID is their
|
|
// Telegram identity (empty when they have none, so the gateway has nothing to gate),
|
|
// Registered whether an account was found, and Eligible the final gate the bot applies
|
|
// (registered and neither admin-suspended nor chat-muted).
|
|
type ChatAccessResp struct {
|
|
ExternalID string `json:"external_id"`
|
|
Registered bool `json:"registered"`
|
|
Eligible bool `json:"eligible"`
|
|
}
|
|
|
|
// ChatEligibility resolves a Telegram identity to its moderated-chat write
|
|
// eligibility — the join path, when the bot sees a user enter the chat.
|
|
func (c *Client) ChatEligibility(ctx context.Context, externalID string) (ChatAccessResp, error) {
|
|
var out ChatAccessResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/chat-access", "", "",
|
|
map[string]string{"external_id": externalID}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// ChatAccessByUser resolves an account id to its Telegram identity and current
|
|
// moderated-chat write eligibility — the change path, for a chat-access-changed event.
|
|
func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAccessResp, error) {
|
|
var out ChatAccessResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/chat-access", "", "",
|
|
map[string]string{"user_id": userID}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// GuestAuth provisions a guest account and mints a session, seeding its time zone
|
|
// from browserTz (the client's detected "±HH:MM" UTC offset).
|
|
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype string) (SessionResp, error) {
|
|
var out SessionResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
|
|
map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// EmailRequest asks the backend to mail a login code, provisioning the account on
|
|
// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new
|
|
// account's time zone, since the email account is created here, not at login. pwa marks a
|
|
// request from an installed PWA, so the backend omits the one-tap confirm link from the email.
|
|
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string, pwa bool) error {
|
|
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
|
|
map[string]any{"email": email, "browser_tz": browserTz, "language": language, "pwa": pwa}, nil)
|
|
}
|
|
|
|
// EmailLogin verifies a login code and mints a session.
|
|
func (c *Client) EmailLogin(ctx context.Context, email, code, subtype string) (SessionResp, error) {
|
|
var out SessionResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/login", "", "",
|
|
map[string]string{"email": email, "code": code, "subtype": subtype}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// ConfirmLinkResp is the backend's outcome of a one-tap deeplink confirmation: for a
|
|
// login Session carries the minted credential; for a link Status is "confirmed" or
|
|
// "merge_required".
|
|
type ConfirmLinkResp struct {
|
|
Purpose string `json:"purpose"`
|
|
Status string `json:"status"`
|
|
Session *SessionResp `json:"session,omitempty"`
|
|
}
|
|
|
|
// EmailConfirmLink verifies a one-tap deeplink token; the token is the authorization.
|
|
func (c *Client) EmailConfirmLink(ctx context.Context, token string) (ConfirmLinkResp, error) {
|
|
var out ConfirmLinkResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/confirm-link", "", "",
|
|
map[string]string{"token": token}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// ResolveSession maps a token to its account id, guest flag, and trusted platform
|
|
// (gateway session-cache miss). The guest flag lets the edge gate guest-forbidden
|
|
// ops; the platform ("<kind>/<subtype>", empty for an untrusted session) is injected
|
|
// as X-Platform on the caller's backend requests.
|
|
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, string, error) {
|
|
var out struct {
|
|
UserID string `json:"user_id"`
|
|
IsGuest bool `json:"is_guest"`
|
|
PlatformKind string `json:"platform_kind"`
|
|
PlatformSubtype string `json:"platform_subtype"`
|
|
}
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
|
|
map[string]string{"token": token}, &out)
|
|
platform := ""
|
|
if out.PlatformKind != "" {
|
|
platform = out.PlatformKind + "/" + out.PlatformSubtype
|
|
}
|
|
return out.UserID, out.IsGuest, platform, err
|
|
}
|
|
|
|
// Profile returns the authenticated account's profile.
|
|
func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error) {
|
|
var out ProfileResp
|
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/profile", userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// WalletSegmentResp is one chip balance in the wallet: the funding source, the chip count, and
|
|
// whether it is spendable in the caller's current context.
|
|
type WalletSegmentResp struct {
|
|
Source string `json:"source"`
|
|
Chips int `json:"chips"`
|
|
Spendable bool `json:"spendable"`
|
|
}
|
|
|
|
// WalletResp is the caller's wallet: the context-visible chip segments and the context-applicable
|
|
// benefits (no-ads term end as unix millis / forever flag, and the available hints).
|
|
type WalletResp struct {
|
|
Segments []WalletSegmentResp `json:"segments"`
|
|
AdsForever bool `json:"ads_forever"`
|
|
AdsPaidUntil int64 `json:"ads_paid_until_ms"`
|
|
Hints int `json:"hints"`
|
|
}
|
|
|
|
// walletBuyBody is the chip-spend request body.
|
|
type walletBuyBody struct {
|
|
ProductID string `json:"product_id"`
|
|
}
|
|
|
|
// Wallet fetches the caller's wallet in their current execution context.
|
|
func (c *Client) Wallet(ctx context.Context, userID string) (WalletResp, error) {
|
|
var out WalletResp
|
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/wallet", userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// WalletBuy spends chips on a chip-priced value and returns the updated wallet.
|
|
func (c *Client) WalletBuy(ctx context.Context, userID, productID string) (WalletResp, error) {
|
|
var out WalletResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/buy", userID, "", walletBuyBody{ProductID: productID}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// walletOrderBody is the POST body of an order: the chip pack to fund.
|
|
type walletOrderBody struct {
|
|
ProductID string `json:"product_id"`
|
|
}
|
|
|
|
// WalletOrderResp is a created order: its id and the provider launch URL the client opens.
|
|
type WalletOrderResp struct {
|
|
OrderID string `json:"order_id"`
|
|
RedirectURL string `json:"redirect_url"`
|
|
}
|
|
|
|
// WalletOrder opens a pending order to fund a chip pack and returns the provider launch URL.
|
|
func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (WalletOrderResp, error) {
|
|
var out WalletOrderResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/order", userID, "", walletOrderBody{ProductID: productID}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity.
|
|
type CatalogAtomResp struct {
|
|
AtomType string `json:"atom_type"`
|
|
Quantity int `json:"quantity"`
|
|
}
|
|
|
|
// CatalogProductResp is one storefront product for the caller's context: a chip-priced value
|
|
// (chips set) or a chip pack priced in the context method (money_amount + money_currency).
|
|
type CatalogProductResp struct {
|
|
Kind string `json:"kind"`
|
|
ProductID string `json:"product_id"`
|
|
Title string `json:"title"`
|
|
Chips int `json:"chips"`
|
|
MoneyAmount int64 `json:"money_amount"`
|
|
MoneyCurrency string `json:"money_currency"`
|
|
Atoms []CatalogAtomResp `json:"atoms"`
|
|
}
|
|
|
|
// CatalogResp is the storefront: the products visible and purchasable in the caller's context.
|
|
type CatalogResp struct {
|
|
Products []CatalogProductResp `json:"products"`
|
|
}
|
|
|
|
// Catalog fetches the storefront in the caller's current execution context.
|
|
func (c *Client) Catalog(ctx context.Context, userID string) (CatalogResp, error) {
|
|
var out CatalogResp
|
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/wallet/catalog", userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// BlockStatusResp is the caller's current manual-block state. Until is an RFC3339 UTC instant for
|
|
// a temporary block, empty for a permanent one or when not blocked; Reason is resolved to the
|
|
// account's language, empty when none was cited.
|
|
type BlockStatusResp struct {
|
|
Blocked bool `json:"blocked"`
|
|
Permanent bool `json:"permanent"`
|
|
Until string `json:"until"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// BlockStatus fetches the caller's current block. It is the one user endpoint a blocked account
|
|
// can still reach (the backend's suspension gate exempts it), so the UI can render the blocked
|
|
// screen.
|
|
func (c *Client) BlockStatus(ctx context.Context, userID string) (BlockStatusResp, error) {
|
|
var out BlockStatusResp
|
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/block-status", userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet
|
|
// index.
|
|
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (MoveResultResp, error) {
|
|
var out MoveResultResp
|
|
body := map[string]any{"tiles": tiles}
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out)
|
|
return out, err
|
|
}
|
|
|
|
// GameState returns the player's view of a game. When includeAlphabet is set the backend
|
|
// embeds the variant's alphabet table; the client asks for it on a per-variant
|
|
// cache miss only.
|
|
func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAlphabet bool) (StateResp, error) {
|
|
var out StateResp
|
|
path := "/api/v1/user/games/" + url.PathEscape(gameID) + "/state"
|
|
if includeAlphabet {
|
|
path += "?include_alphabet=true"
|
|
}
|
|
err := c.do(ctx, http.MethodGet, path, userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Enqueue starts a quick game for a variant under a per-turn word rule (multipleWords
|
|
// true is standard Scrabble, false the single-word rule). vsAI true starts an honest-AI
|
|
// game against a robot instead of human auto-match.
|
|
func (c *Client) Enqueue(ctx context.Context, userID, variant string, multipleWords, vsAI bool) (MatchResp, error) {
|
|
var out MatchResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/lobby/enqueue", userID, "",
|
|
map[string]any{"variant": variant, "multiple_words_per_turn": multipleWords, "vs_ai": vsAI}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Poll reports whether the caller has been paired since queueing.
|
|
func (c *Client) Poll(ctx context.Context, userID string) (MatchResp, error) {
|
|
var out MatchResp
|
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/lobby/poll", userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Cancel removes the caller from the auto-match pool (idempotent; 204 No Content).
|
|
func (c *Client) Cancel(ctx context.Context, userID string) error {
|
|
return c.do(ctx, http.MethodPost, "/api/v1/user/lobby/cancel", userID, "", nil, nil)
|
|
}
|
|
|
|
// ChatPost stores a chat message, forwarding the client IP for moderation.
|
|
func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP string) (ChatResp, error) {
|
|
var out ChatResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/chat", userID, clientIP,
|
|
map[string]string{"body": body}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// HintResultResp is the top-ranked move plus the remaining hint budget.
|
|
type HintResultResp struct {
|
|
Move MoveRecordResp `json:"move"`
|
|
HintsRemaining int `json:"hints_remaining"`
|
|
WalletBalance int `json:"wallet_balance"`
|
|
}
|
|
|
|
// EvalResultResp is an unlimited move preview. Dir is the orientation the backend
|
|
// inferred ("H"/"V", empty when illegal); Words lists the words formed, main word first.
|
|
type EvalResultResp struct {
|
|
Legal bool `json:"legal"`
|
|
Score int `json:"score"`
|
|
Words []string `json:"words"`
|
|
Dir string `json:"dir"`
|
|
}
|
|
|
|
// WordCheckResp is a dictionary lookup outcome.
|
|
type WordCheckResp struct {
|
|
Word string `json:"word"`
|
|
Legal bool `json:"legal"`
|
|
}
|
|
|
|
// HistoryResp is a game's decoded move journal.
|
|
type HistoryResp struct {
|
|
GameID string `json:"game_id"`
|
|
Moves []MoveRecordResp `json:"moves"`
|
|
}
|
|
|
|
// GameListResp is the caller's games for the lobby. AtGameLimit is true when the caller
|
|
// has reached the simultaneous quick-game cap, so the lobby disables "New Game".
|
|
type GameListResp struct {
|
|
Games []GameResp `json:"games"`
|
|
AtGameLimit bool `json:"at_game_limit"`
|
|
}
|
|
|
|
// ChatListResp is a game's chat history.
|
|
type ChatListResp struct {
|
|
Messages []ChatResp `json:"messages"`
|
|
}
|
|
|
|
func (c *Client) gamePath(gameID, suffix string) string {
|
|
return "/api/v1/user/games/" + url.PathEscape(gameID) + suffix
|
|
}
|
|
|
|
// Pass forfeits the player's turn.
|
|
func (c *Client) Pass(ctx context.Context, userID, gameID string) (MoveResultResp, error) {
|
|
var out MoveResultResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/pass"), userID, "", struct{}{}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Exchange swaps the chosen rack tiles back into the bag. Tiles are wire alphabet indices
|
|
// (a blank is engine.BlankIndex).
|
|
func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []int) (MoveResultResp, error) {
|
|
var out MoveResultResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "",
|
|
map[string]any{"tiles": tiles}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Resign resigns the player from the game.
|
|
func (c *Client) Resign(ctx context.Context, userID, gameID string) (MoveResultResp, error) {
|
|
var out MoveResultResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/resign"), userID, "", struct{}{}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Hint reveals the top-ranked move and spends a hint.
|
|
func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultResp, error) {
|
|
var out HintResultResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hint"), userID, "", struct{}{}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// GetDraft returns the player's saved composition for a game as the backend's
|
|
// raw JSON body. The gateway forwards it verbatim, never interpreting its shape.
|
|
func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawMessage, error) {
|
|
var out json.RawMessage
|
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/draft"), userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// SaveDraft upserts the player's composition for a game. body is the client's
|
|
// {rack_order, board_tiles} JSON, forwarded verbatim — a json.RawMessage marshals as-is, so
|
|
// there is no double-encode.
|
|
func (c *Client) SaveDraft(ctx context.Context, userID, gameID string, body json.RawMessage) error {
|
|
return c.do(ctx, http.MethodPut, c.gamePath(gameID, "/draft"), userID, "", body, nil)
|
|
}
|
|
|
|
// HideGame hides a finished game from the caller's own games list. The action is
|
|
// per-account and irreversible; the game stays visible to the other players.
|
|
func (c *Client) HideGame(ctx context.Context, userID, gameID string) error {
|
|
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hide"), userID, "", struct{}{}, nil)
|
|
}
|
|
|
|
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
|
|
// alphabet index.
|
|
func (c *Client) Evaluate(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (EvalResultResp, error) {
|
|
var out EvalResultResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
|
|
map[string]any{"tiles": tiles}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// DictBytes fetches the raw serialized dictionary for a (variant, version) pair,
|
|
// backing the client-side local move preview. It returns the blob and the
|
|
// backend's Cache-Control header, forwarded so the immutable blob is cached hard.
|
|
func (c *Client) DictBytes(ctx context.Context, userID, variant, version string) ([]byte, string, error) {
|
|
return c.getRaw(ctx, "/api/v1/user/dict/"+url.PathEscape(variant)+"/"+url.PathEscape(version), userID, "Cache-Control")
|
|
}
|
|
|
|
// CheckWord looks a word up in the game's pinned dictionary. The word is carried as
|
|
// repeated ?idx= alphabet indices; the backend echoes the decoded concrete word.
|
|
func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) {
|
|
var out WordCheckResp
|
|
q := url.Values{}
|
|
for _, x := range word {
|
|
q.Add("idx", strconv.Itoa(x))
|
|
}
|
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/check_word")+"?"+q.Encode(), userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Complaint disputes a word-check result.
|
|
func (c *Client) Complaint(ctx context.Context, userID, gameID, word, note string) error {
|
|
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/complaint"), userID, "",
|
|
map[string]string{"word": word, "note": note}, nil)
|
|
}
|
|
|
|
// History returns a game's decoded move journal.
|
|
func (c *Client) History(ctx context.Context, userID, gameID string) (HistoryResp, error) {
|
|
var out HistoryResp
|
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/history"), userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// ChatList returns a game's chat history.
|
|
func (c *Client) ChatList(ctx context.Context, userID, gameID string) (ChatListResp, error) {
|
|
var out ChatListResp
|
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/chat"), userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Nudge posts a nudge to the player whose turn is awaited.
|
|
func (c *Client) Nudge(ctx context.Context, userID, gameID string) (ChatResp, error) {
|
|
var out ChatResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/nudge"), userID, "", struct{}{}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// MarkChatRead acknowledges that the caller has read the game's chat (sent when they open
|
|
// the move history or chat), so the backend clears their unread bits and records the
|
|
// publish-to-read latency.
|
|
func (c *Client) MarkChatRead(ctx context.Context, userID, gameID string) error {
|
|
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/chat/read"), userID, "", struct{}{}, nil)
|
|
}
|
|
|
|
// GamesList returns the caller's active and finished games.
|
|
func (c *Client) GamesList(ctx context.Context, userID string) (GameListResp, error) {
|
|
var out GameListResp
|
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/games", userID, "", nil, &out)
|
|
return out, err
|
|
}
|