diff --git a/catalog/dictionary.go b/catalog/dictionary.go new file mode 100644 index 0000000..fdef7b5 --- /dev/null +++ b/catalog/dictionary.go @@ -0,0 +1,51 @@ +package catalog + +import ( + "encoding/json" + "errors" + "golang.org/x/text/language" + "strings" +) + +type Dictionary[T comparable] struct{ self map[string]T } + +func (dict *Dictionary[T]) Message(tag language.Tag) (zero T) { + msg, ok := dict.Lookup(tag.String()) + if !ok || msg == zero { + return dict.Neutral() + } + return msg +} + +func (dict *Dictionary[T]) Lookup(key string) (zero T, ok bool) { + for compare, msg := range dict.self { + if strings.EqualFold(compare, key) { + return msg, true + } + } + return zero, false +} + +const neutralKey = "NEUTRAL" + +func (dict *Dictionary[T]) Neutral() (zero T) { + for key, msg := range dict.self { + if strings.EqualFold(key, neutralKey) && msg != zero { + return msg + } + } + return +} + +func (dict *Dictionary[T]) Map() map[string]T { return dict.self } + +func (dict *Dictionary[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(dict.self) +} + +func (dict *Dictionary[T]) UnmarshalJSON(b []byte) error { + if dict == nil { + return errors.New("playfab/catalog: cannot unmarshal a nil *Dictionary") + } + return json.Unmarshal(b, &dict.self) +} diff --git a/catalog/item.go b/catalog/item.go new file mode 100644 index 0000000..e901eed --- /dev/null +++ b/catalog/item.go @@ -0,0 +1,298 @@ +package catalog + +import ( + "encoding/json" + "github.com/df-mc/go-playfab/entity" + "time" +) + +type Item struct { + // AlternateIDs is the alternate IDs associated with the Item. An alternate + // ID can be set to 'FriendlyId' or any of the supported marketplace names. + AlternateIDs []AlternateID `json:"AlternateIds,omitempty"` + // ContentType is the client-defined type of the Item. + ContentType string `json:"ContentType,omitempty"` + // Contents is the set of content/files associated with the Item. Up to 100 + // files can be added to an Item. In Minecraft, it includes a set of URL for 'XForge' + // where it contains a ZIP file containing a set of encrypted packs. + Contents []Content `json:"Contents,omitempty"` + // CreationDate is the date and time when the Item was created. + CreationDate time.Time `json:"CreationDate,omitempty"` + // CreatorEntity is the [entity.Key] of the creator of the Item. + CreatorEntity entity.Key `json:"CreatorEntity,omitempty"` + // DeepLinks is the set of platform specific deep links for the Item. + DeepLinks []DeepLink `json:"DeepLinks,omitempty"` + // DefaultStackID is the stack ID that will be used as default for the Item + // in inventory when an explicit one is not provided. The DefaultStackID can be + // a static stack ID or '{GUID}', which will generate a unique stack ID for the + // Item. If empty, inventory's default stack ID will be used. + DefaultStackID string `json:"DefaultStackId,omitempty"` + // Description is a Dictionary of localized descriptions. Descriptions have + // a 10000-character limit per country code. + Description Dictionary[string] `json:"Description,omitempty"` + // DisplayProperties is a game-specific properties for display purposes. It is + // an arbitrary JSON blob. The fields of DisplayProperties has a 10000-byte + // limit per Item. In Minecraft, it contains the name of creator, whether + // the Item is purchasable, a URL of video trailer, prices, address and port of + // server (If ContentType is '3PP' or '3PP_V2.0'). + DisplayProperties map[string]json.RawMessage `json:"DisplayProperties,omitempty"` + // DisplayVersion is the user-provided version of the Item for display + // purposes. It has a maximum character length of 50. + DisplayVersion string `json:"DisplayVersion,omitempty"` + // ETag is the current ETag value that can be used for optimistic + // concurrency in the 'If-None-Match' header. + ETag string `json:"ETag,omitempty"` + // EndDate is the date of when the Item will cease to be available. If left + // a zero [time.Time] then the product will be available indefinitely. + EndDate time.Time `json:"EndDate,omitempty"` + // ID is the unique ID of the Item. It can be specified to [Query.ID]. + ID string `json:"Id,omitempty"` + // Images is the images associated with the Item. Images can be thumbnails + // or screenshots. Up to 100 images can be added to an Item. Only .png, .jpg, + // .gif, and .bmp file types can be uploaded. + Images []Image `json:"Images,omitempty"` + // Hidden indicates if the Item is hidden. + Hidden bool `json:"IsHidden,omitempty"` + // ItemReferences is the item references associated with the Item. Every Item + // can have up to 50 item references. + ItemReferences []ItemReference `json:"ItemReferences,omitempty"` + // Keywords is a Dictionary of localized keywords. Keywords have a 50-character + // limit per keyword and up to 32 keywords can be added per country code. + Keywords Dictionary[*Keyword] `json:"Keywords,omitempty"` + // LastModifiedDate is the date and time the Item was last updated. + LastModifiedDate time.Time `json:"LastModifiedDate,omitempty"` + // Moderation is the moderation state for the Item. + Moderation ModerationState `json:"Moderation,omitempty"` + // Platforms is the platforms supported by the Item. + Platforms []string `json:"Platforms,omitempty"` + // PriceOptions is the prices the Item can be purchased for. + PriceOptions PriceOptions `json:"PriceOptions,omitempty"` + // Rating s the rating summary for the Item. + Rating Rating `json:"Rating,omitempty"` + // StartDate is the date of when the Item will be available. If left as + // a zero [time.Time] then the product will appear immediately. + StartDate time.Time `json:"StartDate,omitempty"` + // StoreDetails is an optional details for stores items. + StoreDetails StoreDetails `json:"StoreDetails,omitempty"` + // Tags is the list of tags that are associated with the Item. Up to 32 tags + // can be added to an Item. + Tags []string `json:"Tags,omitempty"` + // Title is a Dictionary of localized titles. Titles have a 512-character limit + // per country code. + Title Dictionary[string] `json:"Title,omitempty"` + // Type is the high-level type of the Item. It is one of constants defined below. + Type string `json:"Type,omitempty"` +} + +type StoreReference struct { + AlternateID AlternateID `json:"AlternateId,omitempty"` + ID string `json:"Id,omitempty"` +} + +type AlternateID struct { + Type string `json:"Type,omitempty"` + Value string `json:"Value,omitempty"` +} + +type Content struct { + // ID is the unique ID of the Content. + ID string `json:"Id,omitempty"` + // MaxClientVersion is the maximum client version that the Content is + // compatible with. Client Versions can be up to 3 segments separated + // by periods (.) and each segment can have a maximum value of 65535. + MaxClientVersion string `json:"MaxClientVersion,omitempty"` + // MinClientVersion is the minimum client version that the Content is + // compatible with. Client Versions can be up to 3 segments separated + // by periods (.) and each segment can have a maximum value of 65535. + MinClientVersion string `json:"MinClientVersion,omitempty"` + // Tags is the list of tags that are associated with the Content. Tags + // must be defined in the Catalog Config before being used in Content. + Tags []string `json:"Tags,omitempty"` + // Type is the client-defined type of the Content. Types must be defined + // in the Catalog Config before being used. + Type string `json:"Type,omitempty"` + // URL is the Azure CDN URL for retrieval of the Item binary content. + // In Minecraft (and some other games), It is a URL for XForge asset. + URL string `json:"Url,omitempty"` +} + +type DeepLink struct { + // Platform is the target platform for the DeepLink. + Platform string `json:"Platform,omitempty"` + // URL is the deep link for the Platform. + URL string `json:"Url,omitempty"` +} + +type Image struct { + // ID is the unique ID of the Image. + ID string `json:"Id,omitempty"` + // Tag is the client-defined tag associated with the Image. Tags must be + // in the Catalog Config before being used in Image. + Tag string `json:"Tag,omitempty"` + // Type is the type of the Image. It is one of constants defined below. + // There can only be one Image of ImageTypeThumbnail per Item. + Type string `json:"Type,omitempty"` + // URL is the URL for retrieval of the Image. + URL string `json:"Url,omitempty"` +} + +const ( + ImageTypeThumbnail = "thumbnail" + ImageTypeScreenshot = "screenshot" +) + +type ItemReference struct { + // Amount is the amount of the catalog Item. + Amount int `json:"Amount,omitempty"` + // ID is the unique ID of the catalog Item. + ID string `json:"Id,omitempty"` + // PriceOptions is the prices that the Item referenced in the + // ID can be purchased for. + PriceOptions PriceOptions `json:"PriceOptions,omitempty"` +} + +type PriceOptions []Price + +func (opts PriceOptions) MarshalJSON() ([]byte, error) { + type raw struct { + Prices []Price `json:"Prices,omitempty"` + } + return json.Marshal(raw{Prices: opts}) +} + +func (opts *PriceOptions) UnmarshalJSON(b []byte) error { + var raw struct { + Prices []Price `json:"Prices,omitempty"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + *opts = raw.Prices + return nil +} + +type Price struct { + // Amounts is the amounts of the Price. Each Price can have up to + // 15 amounts. + Amounts []PriceAmount `json:"Amounts,omitempty"` + // UnitAmount is the per-unit amount the Price can be used to purchase. + UnitAmount int `json:"UnitAmount,omitempty"` + // UnitDurationInSeconds is the per-unit duration the Price can be used + // to purchase. The maximum duration is 100 years. + UnitDurationInSeconds int `json:"UnitDurationInSeconds,omitempty"` +} + +type PriceAmount struct { + Amount int `json:"Amount,omitempty"` + ItemID string `json:"ItemId,omitempty"` +} + +type Keyword []string + +func (k *Keyword) MarshalJSON() ([]byte, error) { + type raw struct { + Values []string `json:"Values,omitempty"` + } + return json.Marshal(raw{Values: *k}) +} + +func (k *Keyword) UnmarshalJSON(b []byte) error { + var raw struct { + Values []string `json:"Values,omitempty"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + *k = raw.Values + return nil +} + +type ModerationState struct { + // LastModifiedDate is the date and time the ModerationState was last updated. + LastModifiedDate time.Time `json:"LastModifiedDate,omitempty"` + // Reason is the current stated reason for the associated Item being moderated. + Reason string `json:"Reason,omitempty"` + // Status is the current moderation status for the associated Item. + Status string `json:"Status,omitempty"` +} + +const ( + ModerationStatusApproved string = "Approved" + ModerationStatusAwaitingModeration string = "AwaitingModeration" + ModerationStatusRejected string = "Rejected" + ModerationStatusUnknown string = "Unknown" +) + +type Rating struct { + // Average is the average rating for the Item. + Average float32 `json:"Average,omitempty"` + // Count1Star is the total count of 1-star ratings for the Item. + Count1Star int `json:"Count1Star,omitempty"` + // Count2Star is the total count of 2-star ratings for the Item. + Count2Star int `json:"Count2Star,omitempty"` + // Count3Star is the total count of 3-star ratings for the Item. + Count3Star int `json:"Count3Star,omitempty"` + // Count4Star is the total count of 4-star ratings for the Item. + Count4Star int `json:"Count4Star,omitempty"` + // Count5Star is the total count of 5-star ratings for the Item. + Count5Star int `json:"Count5Star,omitempty"` + // TotalCount is the total count of ratings for the Item. + TotalCount int `json:"TotalCount,omitempty"` +} + +type StoreDetails struct { + // FilterOptions is the options for the filter in filter-based stores. + // There options are mutually exclusive with item references. + FilterOptions FilterOptions `json:"FilterOptions,omitempty"` + // PriceOptionsOverride is the global prices utilized in the store. These + // options are mutually exclusive with price options in ItemReference. + PriceOptionsOverride PriceOptionsOverride `json:"PriceOptionsOverride,omitempty"` +} + +type FilterOptions struct { + Filter string `json:"Filter,omitempty"` + IncludeAllItems bool `json:"IncludeAllItems,omitempty"` +} + +type PriceOptionsOverride []PriceOverride + +func (opts PriceOptionsOverride) MarshalJSON() ([]byte, error) { + type raw struct { + Prices []PriceOverride `json:"Prices,omitempty"` + } + return json.Marshal(raw{Prices: opts}) +} + +func (opts *PriceOptionsOverride) UnmarshalJSON(b []byte) error { + var raw struct { + Prices []PriceOverride `json:"Prices,omitempty"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + *opts = raw.Prices + return nil +} + +type PriceOverride struct { + // Amounts is the currency amounts utilized in the override for a singular price. + Amounts []PriceAmountOverride `json:"Amounts,omitempty"` +} + +type PriceAmountOverride struct { + // FixedValue is the exact value that should be utilized in the PriceAmountOverride. + FixedValue int `json:"FixedValue,omitempty"` + // ItemID is the ID of the Item the PriceAmountOverride should utilize. + ItemID string `json:"ItemId,omitempty"` + // Multiplier is the multiplier that will be applied to the base catalog value + // to determine what value should be utilized in the PriceAmountOverride. + Multiplier int `json:"Multiplier,omitempty"` +} + +const ( + ItemTypeBundle = "bundle" + ItemTypeCatalogItem = "catalogItem" + ItemTypeCurrency = "currency" + ItemTypeStore = "store" + ItemTypeUGC = "ugc" +) diff --git a/catalog/query.go b/catalog/query.go new file mode 100644 index 0000000..a29e3a2 --- /dev/null +++ b/catalog/query.go @@ -0,0 +1,41 @@ +package catalog + +import ( + "github.com/df-mc/go-playfab/entity" + "github.com/df-mc/go-playfab/internal" + "github.com/df-mc/go-playfab/title" +) + +type Query struct { + // AlternateID is an alternate ID associated with the Item being retrieved + // from [Query.Item]. + AlternateID *AlternateID `json:"AlternateId,omitempty"` + // CustomTags is the optional custom tags associated with the request sent + // from [Query.Item]. + CustomTags map[string]any `json:"CustomTags,omitempty"` + // Entity is an [entity.Key] to perform any actions using the Query. + // If left as nil and an [entity.Token] has been provided to [Query.Item], + // it will be filled from [entity.Token.Key]. + Entity *entity.Key `json:"Entity,omitempty"` + // ID is the unique ID of the Item being retrieved from [Query.Item]. + ID string `json:"Id,omitempty"` +} + +// Item retrieves an Item from the public catalog. It does not work off a cache of the catalog +// and should be used when trying to retrieve recent updates of Item. However, please note that +// Item references data is cached and may take few moments for changes to propagate. +func (q Query) Item(t title.Title, tok *entity.Token) (zero Item, err error) { + if q.Entity == nil && tok != nil { + q.Entity = &tok.Entity + } + + res, err := internal.Post[*queryResponse](t.URL().JoinPath("/Catalog/GetItem"), q, tok.SetAuthHeader) + if err != nil { + return zero, err + } + return res.Item, nil +} + +type queryResponse struct { + Item Item `json:"Item,omitempty"` +} diff --git a/catalog/search_items.go b/catalog/search_items.go new file mode 100644 index 0000000..129efad --- /dev/null +++ b/catalog/search_items.go @@ -0,0 +1,71 @@ +package catalog + +import ( + "fmt" + "github.com/df-mc/go-playfab/entity" + "github.com/df-mc/go-playfab/internal" + "github.com/df-mc/go-playfab/title" + "golang.org/x/text/language" + "net/http" +) + +type Filter struct { + // Count is the number of returned items included in the SearchResult. + // The maximum value is 50, and defaulted to 10 by the service-side. + Count int `json:"Count,omitempty"` + // ContinuationToken is the opaque token used for continuing the Search, + // if any are available. It is normally filled from [SearchResult.ContinuationToken]. + ContinuationToken string `json:"ContinuationToken,omitempty"` + // CustomTags is the optional properties associated with the request. + CustomTags map[string]any `json:"CustomTags,omitempty"` + // Entity is an [entity.Key] to perform any actions using the Filter. + // If left as nil and an [entity.Token] has been provided to [Filter.Search], + // it will be filled from [entity.Token.Key]. + Entity *entity.Key `json:"Entity,omitempty"` + // Filter is an OData query for filtering the items included in SearchResult. + // For example, " eq ''". + Filter string `json:"Filter,omitempty"` + // Language is the locale to be included in the dictionary of Items returned in + // the SearchResult. It is also used as an 'Accept-Language' header of the request + // sent from [SearchResult.Search]. + Language language.Tag `json:"Language,omitempty"` + // OrderBy is an OData sort query for sorting the index of SearchResult. Defaulted to relevance. + OrderBy string `json:"OrderBy,omitempty"` + // Term is the string terms to be searched. + Term string `json:"Search,omitempty"` + // Select is an OData selection query for filtering the fields of returned items included in the SearchResult. + Select string `json:"Select,omitempty"` + // Store ... + Store *StoreReference `json:"Store,omitempty"` +} + +// Search performs a search against the public catalog using the Filter and returns a set of +// paginated SearchResult. It uses a cache of the catalog with item updates taking up to few +// minutes to propagate. If trying to immediately retrieve recent Item updates, a Query should +// be used. More information about the Search API can be found here: +// https://learn.microsoft.com/en-us/gaming/playfab/features/economy-v2/catalog/search +func (f Filter) Search(t title.Title, tok *entity.Token) (*SearchResult, error) { + if f.Count > 50 { + return nil, fmt.Errorf("playfab/catalog: Filter: count must be <= 50, got %d", f.Count) + } + if f.Entity == nil && tok != nil { + f.Entity = &tok.Entity + } + + return internal.Post[*SearchResult](t.URL().JoinPath("/Catalog/SearchItems"), f, func(req *http.Request) { + if tok != nil { + tok.SetAuthHeader(req) + } + if f.Language != language.Und { + req.Header.Set("Accept-Language", f.Language.String()) + } + }) +} + +type SearchResult struct { + // ContinuationToken provides an opaque token for continuing the next page of + // SearchResult by specifying it to [Filter.ContinuationToken], if any are available. + ContinuationToken string `json:"ContinuationToken,omitempty"` + // Items is a paginated set of Item for the search query. + Items []Item `json:"Items,omitempty"` +} diff --git a/entity/exchange.go b/entity/exchange.go new file mode 100644 index 0000000..cf5e652 --- /dev/null +++ b/entity/exchange.go @@ -0,0 +1,23 @@ +package entity + +import ( + "github.com/df-mc/go-playfab/internal" + "github.com/df-mc/go-playfab/title" +) + +// Exchange exchanges a Token of TypeMasterPlayerAccount with the ID. +func (tok *Token) Exchange(t title.Title, id string) (_ *Token, err error) { + r := exchange{ + Entity: Key{ + Type: TypeMasterPlayerAccount, + ID: id, + }, + } + + return internal.Post[*Token](t.URL().JoinPath("/Authentication/GetEntityToken"), r, tok.SetAuthHeader) +} + +type exchange struct { + CustomTags map[string]any `json:"CustomTags,omitempty"` + Entity Key `json:"Entity,omitempty"` +} diff --git a/entity/token.go b/entity/token.go new file mode 100644 index 0000000..080151a --- /dev/null +++ b/entity/token.go @@ -0,0 +1,33 @@ +package entity + +import ( + "net/http" + "time" +) + +type Token struct { + Entity Key `json:"Entity,omitempty"` + Token string `json:"EntityToken,omitempty"` + Expiration time.Time `json:"TokenExpiration,omitempty"` +} + +func (tok *Token) Expired() bool { return time.Now().After(tok.Expiration) } +func (tok *Token) SetAuthHeader(req *http.Request) { req.Header.Set("X-EntityToken", tok.Token) } + +type Key struct { + // ID is the unique ID of the entity. + ID string `json:"Id,omitempty"` + // Type is the type of entity. It is one of constants defined below. + Type Type `json:"Type,omitempty"` +} + +type Type string + +const ( + TypeNamespace Type = "namespace" + TypeTitle Type = "title" + TypeMasterPlayerAccount Type = "master_player_account" + TypeTitlePlayerAccount Type = "title_player_account" + TypeCharacter Type = "character" + TypeGroup Type = "group" +) diff --git a/entity/token_source.go b/entity/token_source.go new file mode 100644 index 0000000..8277523 --- /dev/null +++ b/entity/token_source.go @@ -0,0 +1,69 @@ +package entity + +import ( + "context" + "fmt" + "github.com/df-mc/go-playfab/title" + "sync" + "time" +) + +type TokenSource interface { + Token() (*Token, error) +} + +func ExchangeTokenSource(ctx context.Context, tok *Token, t title.Title, masterID string) TokenSource { + src := &exchangeTokenSource{ + tok: tok, + + title: t, + masterID: masterID, + } + go src.background(ctx) + return src +} + +type exchangeTokenSource struct { + tok *Token + err error + + mux sync.Mutex + title title.Title + masterID string +} + +func (src *exchangeTokenSource) background(ctx context.Context) { + t := time.NewTicker(time.Minute * 15) + defer t.Stop() + for { + select { + case <-t.C: + src.mux.Lock() + src.tok, src.err = src.tok.Exchange(src.title, src.masterID) + if src.err != nil { + src.mux.Unlock() + return + } + src.mux.Unlock() + case <-ctx.Done(): + return + } + } +} + +func (src *exchangeTokenSource) Token() (tok *Token, err error) { + src.mux.Lock() + defer src.mux.Unlock() + if src.err != nil { + return nil, fmt.Errorf("exchange token in background: %w", err) + } + + if src.tok.Expired() || src.tok.Entity.Type != TypeMasterPlayerAccount { + tok, err = src.tok.Exchange(src.title, src.masterID) + if err != nil { + return nil, fmt.Errorf("exchange: %w", err) + } + src.tok = tok + } + return src.tok, nil +} diff --git a/go.mod b/go.mod index 843ff1f..dd72ef3 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module github.com/df-mc/go-playfab -go 1.22.0 +go 1.23.0 + +require ( + github.com/df-mc/go-xsapi v0.0.0-20240902102602-e7c4bffb955f // indirect + golang.org/x/text v0.17.0 // indirect +) + +replace github.com/df-mc/go-xsapi => github.com/lactyy/go-xsapi v0.0.0-20240902120723-5a844e61607e diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..02565fb --- /dev/null +++ b/go.sum @@ -0,0 +1,5 @@ +github.com/df-mc/go-xsapi v0.0.0-20240902102602-e7c4bffb955f h1:D+4m5W0ck5MIvzgUBF+tSU/yNkxnvydPfeojHRtmlW0= +github.com/df-mc/go-xsapi v0.0.0-20240902102602-e7c4bffb955f/go.mod h1:5M0+2xDLeLQ3cbM+w92R5/gYM1IaSuxoOle9UXN1HB0= +github.com/lactyy/go-xsapi v0.0.0-20240902120723-5a844e61607e/go.mod h1:yMOOWhg3JL/t3si+6jb+UZgYS/E2RU4A6oanupqk3iI= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= diff --git a/identity.go b/identity.go new file mode 100644 index 0000000..e32e5cb --- /dev/null +++ b/identity.go @@ -0,0 +1,418 @@ +package playfab + +import ( + "encoding/json" + "github.com/df-mc/go-playfab/entity" + "github.com/df-mc/go-playfab/title" + "time" +) + +// Identity a session identity that can subsequently be used for API which requires an authentication. +// It is generally returned as a result of [IdentityProvider.Login] or [LoginConfig.Login]. +type Identity struct { + // EntityToken is an [entity.Token] of [entity.TypeTitlePlayerAccount]. + // API requests will mostly require an [entity.Token] of [entity.TypeMasterPlayerAccount] + // so you may exchange it with [entity.Token.Exchange] with PlayFabID. + EntityToken *entity.Token `json:"EntityToken,omitempty"` + ResponseParameters ResponseParameters `json:"InfoResultPayload,omitempty"` + // LastLoginTime is the time of previous login. If there was no previous login, it is zero [time.Time]. + LastLoginTime time.Time `json:"LastLoginTime,omitempty"` + // NewlyCreated is true if the account was newly created on login. + NewlyCreated bool `json:"NewlyCreated,omitempty"` + // PlayFabID is the unique ID of player. It can be used for exchanging an [entity.Token] + // of [entity.TypeMasterPlayerAccount] with [entity.Token.Exchange]. + PlayFabID string `json:"PlayFabId,omitempty"` + // SessionTicket is a unique token authorizing the user and game at server level, for the + // current session. In Minecraft, it is used for authorizing with franchise API using a PlayFab token. + SessionTicket string `json:"SessionTicket,omitempty"` + // SettingsForUser is the settings specific to the user. + SettingsForUser UserSettings `json:"SettingsForUser,omitempty"` + TreatmentAssignment TreatmentAssignment `json:"TreatmentAssignment,omitempty"` +} + +// ResponseParameters is a set of parameters requested to be included in [RequestParameters], which is +// a part of [LoginConfig] as [LoginConfig.RequestParameters]. It includes an information of player/entity +// signed in at Identity. +type ResponseParameters struct { + Account UserAccount `json:"AccountInfo,omitempty"` + CharacterInventories []CharacterInventory `json:"CharacterInventories,omitempty"` + CharacterList []Character `json:"CharacterList,omitempty"` + PlayerProfile PlayerProfile `json:"PlayerProfile,omitempty"` + PlayerStatistics []StatisticValue `json:"PlayerStatistics,omitempty"` + TitleData map[string]json.RawMessage `json:"TitleData,omitempty"` + UserData UserDataRecord `json:"UserData,omitempty"` + UserDataVersion int `json:"UserDataVersion,omitempty"` + UserInventory []ItemInstance `json:"UserInventory,omitempty"` + UserReadOnlyData UserDataRecord `json:"UserReadOnlyData,omitempty"` + UserReadOnlyDataVersion int `json:"UserReadOnlyDataVersion,omitempty"` + UserVirtualCurrency map[string]json.RawMessage `json:"UserVirtualCurrency,omitempty"` + UserVirtualCurrencyRechargeTime VirtualCurrencyRechargeTime `json:"UserVirtualCurrencyRechargeTimes"` +} + +// UserAccount specifies an account information for several platforms that supports PlayFab as an identity provider. +type UserAccount struct { + AndroidDevice UserAndroidDevice `json:"AndroidDeviceInfo,omitempty"` + AppleAccount UserAppleAccount `json:"AppleAccountInfo,omitempty"` + Created time.Time `json:"Created,omitempty"` + CustomID UserCustomID `json:"CustomIdInfo,omitempty"` + Facebook UserFacebook `json:"FacebookInfo,omitempty"` + FacebookInstantGamesID UserFacebookInstantGamesID `json:"FacebookInstantGamesIdInfo,omitempty"` + GameCenter UserGameCenter `json:"GameCenterInfo,omitempty"` + Google UserGoogle `json:"GoogleInfo,omitempty"` + GooglePlayGames UserGooglePlayGames `json:"GooglePlayGamesInfo,omitempty"` + IOSDevice UserIOSDevice `json:"IosDeviceInfo,omitempty"` + Kongregate UserKongregate `json:"KongregateInfo,omitempty"` + NintendoSwitchAccount UserNintendoSwitchAccount `json:"NintendoSwitchAccountInfo,omitempty"` + NintendoSwitchDeviceID UserNintendoSwitchDeviceID `json:"NintendoSwitchDeviceIdInfo,omitempty"` + OpenID UserOpenID `json:"OpenIdInfo,omitempty"` + PlayFabID string `json:"PlayFabId,omitempty"` + Private UserPrivate `json:"PrivateInfo,omitempty"` + PSN UserPSN `json:"PsnInfo,omitempty"` + Steam UserSteam `json:"SteamInfo,omitempty"` + Title UserTitle `json:"TitleInfo,omitempty"` + Twitch UserTwitch `json:"TwitchInfo,omitempty"` + Username string `json:"Username,omitempty"` + Xbox UserXbox `json:"Xbox,omitempty"` +} + +type UserAndroidDevice struct { + DeviceID string `json:"AndroidDeviceId,omitempty"` +} + +type UserAppleAccount struct { + SubjectID string `json:"AppleSubjectId,omitempty"` +} + +type UserCustomID struct { + ID string `json:"CustomId,omitempty"` +} + +type UserFacebook struct { + ID string `json:"FacebookId,omitempty"` + FullName string `json:"FullName,omitempty"` +} + +type UserFacebookInstantGamesID struct { + ID string `json:"FacebookInstantGamesId,omitempty"` +} + +type UserGameCenter struct { + ID string `json:"GameCenterId,omitempty"` +} + +type UserGoogle struct { + Email string `json:"GoogleEmail,omitempty"` + Gender string `json:"GoogleGender,omitempty"` + ID string `json:"GoogleId,omitempty"` + Locale string `json:"GoogleLocale,omitempty"` + Name string `json:"GoogleName,omitempty"` +} + +type UserGooglePlayGames struct { + PlayerAvatarImageURL string `json:"GooglePlayGamesPlayerAvatarImageUrl,omitempty"` + PlayerDisplayName string `json:"GooglePlayGamesPlayerDisplayName,omitempty"` + PlayerID string `json:"GooglePlayGamesPlayerId,omitempty"` +} + +type UserIOSDevice struct { + ID string `json:"IosDeviceId,omitempty"` +} + +type UserKongregate struct { + ID string `json:"KongregateId,omitempty"` + Name string `json:"KongregateName,omitempty"` +} + +type UserNintendoSwitchAccount struct { + SubjectID string `json:"NintendoSwitchAccountSubjectId,omitempty"` +} + +type UserNintendoSwitchDeviceID struct { + ID string `json:"NintendoSwitchDeviceId,omitempty"` +} + +type UserOpenID struct { + ConnectionID string `json:"ConnectionId,omitempty"` + Issuer string `json:"Issuer,omitempty"` + Subject string `json:"Subject,omitempty"` +} + +type UserPrivate struct { + Email string `json:"Email,omitempty"` +} + +type UserPSN struct { + AccountID string `json:"PsnAccountId,omitempty"` + OnlineID string `json:"PsnOnlineId,omitempty"` +} + +type UserSteam struct { + ActivationStatus string `json:"SteamActivationStatus,omitempty"` + Country string `json:"SteamCountry,omitempty"` + Currency string `json:"Currency,omitempty"` + ID string `json:"SteamId,omitempty"` + Name string `json:"SteamName,omitempty"` +} + +const ( + TitleActivationStatusActivatedSteam = "ActivatedSteam" + TitleActivationStatusActivatedTitleKey = "ActivatedTitleKey" + TitleActivationStatusNone = "None" + TitleActivationStatusPendingSteam = "PendingSteam" + TitleActivationStatusRevokedSteam = "RevokedSteam" +) + +type UserTitle struct { + AvatarURL string `json:"AvatarUrl,omitempty"` + Created time.Time `json:"Created,omitempty"` + DisplayName string `json:"DisplayName,omitempty"` + FirstLogin time.Time `json:"FirstLogin,omitempty"` + LastLogin time.Time `json:"LastLogin,omitempty"` + Origination string `json:"Origination,omitempty"` + TitlePlayerAccount entity.Key `json:"TitlePlayerAccount,omitempty"` + Banned bool `json:"isBanned,omitempty"` +} + +const ( + UserOriginationAmazon = "Amazon" + UserOriginationAndroid = "Android" + UserOriginationApple = "Apple" + UserOriginationCustomID = "CustomId" + UserOriginationFacebook = "Facebook" + UserOriginationFacebookInstantGamesID = "FacebookInstantGamesId" + UserOriginationGameCenter = "GameCenter" + UserOriginationGamersFirst = "GamersFirst" + UserOriginationGoogle = "Google" + UserOriginationGooglePlayGames = "GooglePlayGames" + UserOriginationIOS = "IOS" + UserOriginationKongregate = "Kongregate" + UserOriginationLoadTest = "LoadTest" + UserOriginationNintendoSwitchAccount = "NintendoSwitchAccount" + UserOriginationNintendoSwitchDeviceID = "NintendoSwitchDeviceID" + UserOriginationOpenIDConnect = "OpenIdConnect" + UserOriginationOrganic = "Organic" + UserOriginationPSN = "PSN" + UserOriginationParse = "Parse" + UserOriginationServerCustomID = "ServerCustomId" + UserOriginationSteam = "Steam" + UserOriginationTwitch = "Twitch" + UserOriginationUnknown = "Unknown" + UserOriginationXboxLive = "XboxLive" +) + +type UserTwitch struct { + ID string `json:"TwitchId,omitempty"` + UserName string `json:"TwitchUserName,omitempty"` +} + +type UserXbox struct { + UserID string `json:"XboxUserId,omitempty"` + UserSandbox string `json:"XboxUserSandbox,omitempty"` +} + +type CharacterInventory struct { + ID string `json:"CharacterId,omitempty"` + Inventory []ItemInstance `json:"Inventory,omitempty"` +} + +type ItemInstance struct { + Annotation string `json:"Annotation,omitempty"` + BundleContents []string `json:"BundleContents,omitempty"` + BundleParent string `json:"BundleParent,omitempty"` + CatalogVersion string `json:"CatalogVersion,omitempty"` + CustomData map[string]json.RawMessage `json:"CustomData,omitempty"` + DisplayName string `json:"DisplayName,omitempty"` + Expiration time.Time `json:"Expiration,omitempty"` + Class string `json:"ItemClass,omitempty"` + ID string `json:"ItemId,omitempty"` + InstanceID string `json:"ItemInstanceId,omitempty"` + PurchaseDate time.Time `json:"PurchaseDate,omitempty"` + RemainingUses int `json:"RemainingUses,omitempty"` + UnitCurrency string `json:"UnitCurrency,omitempty"` + UnitPrice int `json:"UnitPrice,omitempty"` + UsesIncrementedBy int `json:"UsesIncrementedBy,omitempty"` +} + +type Character struct { + ID string `json:"CharacterId,omitempty"` + Name string `json:"CharacterName,omitempty"` + Type string `json:"CharacterType,omitempty"` +} + +type PlayerProfile struct { + AdCampaignAttributions []AdCampaignAttribution `json:"AdCampaignAttributions,omitempty"` + AvatarURL string `json:"AvatarUrl,omitempty"` + BannedUntil time.Time `json:"BannedUntil,omitempty"` + ContactEmailAddresses []ContactEmailAddress `json:"ContactEmailAddresses,omitempty"` + Created time.Time `json:"Created,omitempty"` + DisplayName string `json:"DisplayName,omitempty"` + ExperimentVariants []string `json:"ExperimentVariants,omitempty"` + LastLogin time.Time `json:"LastLogin,omitempty"` + LinkedAccounts []LinkedPlatformAccount `json:"LinkedAccounts,omitempty"` + Locations []Location `json:"Locations,omitempty"` + Memberships []Membership `json:"Memberships,omitempty"` + Origination string `json:"Origination,omitempty"` + PlayerID string `json:"PlayerId,omitempty"` + PublisherID string `json:"PublisherId,omitempty"` + PushNotificationRegistrations []PushNotificationRegistration `json:"PushNotificationRegistrations,omitempty"` + Statistics []Statistic `json:"Statistics,omitempty"` + Tags []Tag `json:"Tags,omitempty"` + Title title.Title `json:"TitleId,omitempty"` + TotalValueToDateInUSD int `json:"TotalValueToDateInUSD,omitempty"` + ValuesToDates []ValuesToDate `json:"ValuesToDate,omitempty"` +} + +type AdCampaignAttribution struct { + AttributedAt time.Time `json:"AttributedAt,omitempty"` + CampaignID string `json:"CampaignId,omitempty"` + Platform string `json:"Platform,omitempty"` +} + +type ContactEmailAddress struct { + Address string `json:"EmailAddress,omitempty"` + Name string `json:"Name,omitempty"` + VerificationStatus EmailVerificationStatus `json:"VerificationStatus,omitempty"` +} + +type EmailVerificationStatus string + +const ( + EmailVerificationStatusConfirmed EmailVerificationStatus = "Confirmed" + EmailVerificationStatusPending EmailVerificationStatus = "Pending" + EmailVerificationStatusUnverified EmailVerificationStatus = "Unverified" +) + +type LinkedPlatformAccount struct { + Email string `json:"Email,omitempty"` + Platform string `json:"Platform,omitempty"` + PlatformUserID string `json:"PlatformUserId,omitempty"` + Username string `json:"Username,omitempty"` +} + +const ( + IdentityProviderAndroidDevice = "AndroidDevice" + IdentityProviderApple = "Apple" + IdentityProviderCustom = "Custom" + IdentityProviderCustomServer = "CustomServer" + IdentityProviderFacebook = "Facebook" + IdentityProviderFacebookInstantGames = "FacebookInstantGames" + IdentityProviderGameCenter = "GameCenter" + IdentityProviderGameServer = "GameServer" + IdentityProviderGooglePlay = "GooglePlay" + IdentityProviderGooglePlayGames = "GooglePlayerGames" + IdentityProviderIOSDevice = "IOSDevice" + IdentityProviderKongregate = "Kongregate" + IdentityProviderNintendoSwitch = "NintendoSwitch" + IdentityProviderNintendoSwitchAccount = "NintendoSwitchAccount" + IdentityProviderOpenIDConnect = "OpenIdConnect" + IdentityProviderPSN = "PSN" + IdentityProviderPlayFab = "PlayFab" + IdentityProviderSteam = "Steam" + IdentityProviderTwitch = "Twitch" + IdentityProviderUnknown = "Unknown" + IdentityProviderWindowsHello = "WindowsHello" + IdentityProviderXboxLive = "XBoxLive" +) + +type Location struct { + City string `json:"City,omitempty"` + ContinentCode string `json:"ContinentCode,omitempty"` + CountryCode string `json:"CountryCode,omitempty"` + Latitude int `json:"Latitude,omitempty"` + Longitude int `json:"Longitude,omitempty"` +} + +type Membership struct { + Active bool `json:"IsActive,omitempty"` + Expiration time.Time `json:"MembershipExpiration,omitempty"` + ID string `json:"MembershipId,omitempty"` + OverrideExpiration time.Time `json:"OverrideExpiration,omitempty"` + OverrideSet bool `json:"OverrideIsSet,omitempty"` + Subscriptions []Subscription `json:"Subscriptions,omitempty"` +} + +type Subscription struct { + Expiration time.Time `json:"Expiration,omitempty"` + InitialSubscriptionTime time.Time `json:"InitialSubscriptionTime,omitempty"` + Active bool `json:"IsActive,omitempty"` + Status string `json:"Status,omitempty"` + ID string `json:"SubscriptionId,omitempty"` + ItemID string `json:"SubscriptionItemId,omitempty"` + Provider string `json:"SubscriptionProvider,omitempty"` +} + +const ( + SubscriptionStatusBillingError = "BillingError" + SubscriptionStatusCancelled = "Cancelled" + SubscriptionStatusCustomerDidNotAcceptPriceChange = "CustomerDidNotAcceptPriceChange" + SubscriptionStatusFreeTrial = "FreeTrial" + SubscriptionStatusNoError = "NoError" + SubscriptionStatusPaymentPending = "PaymentPending" + SubscriptionStatusProductUnavailable = "ProductUnavailable" + SubscriptionStatusUnknownError = "UnknownError" +) + +type PushNotificationRegistration struct { + NotificationEndpointARN string `json:"NotificationEndpointARN,omitempty"` + Platform string `json:"Platform,omitempty"` +} + +const ( + PushNotificationPlatformApplePushNotificationService = "ApplePushNotificationService" + PushNotificationPlatformGoogleCloudMessaging = "GoogleCloudMessaging" +) + +type Statistic struct { + Name string `json:"Name,omitempty"` + Value int `json:"Value,omitempty"` + Version int `json:"Version,omitempty"` +} + +type Tag struct { + Value string `json:"TagValue,omitempty"` +} + +type ValuesToDate struct { + Currency string `json:"Currency,omitempty"` + TotalValue int `json:"TotalValue,omitempty"` + TotalValueAsDecimal string `json:"TotalValueAsDecimal,omitempty"` +} + +type StatisticValue struct { + Name string `json:"StatisticName"` + Value int `json:"Value,omitempty"` + Version int `json:"Version,omitempty"` +} + +type UserDataRecord struct { + LastUpdated time.Time `json:"LastUpdated,omitempty"` + Permission string `json:"Permission,omitempty"` + Value string `json:"Value,omitempty"` +} + +const ( + UserDataPermissionPrivate = "Private" + UserDataPermissionPublic = "Public" +) + +type VirtualCurrencyRechargeTime struct { + Max int `json:"RechargeMax,omitempty"` + Time time.Time `json:"RechargeTime,omitempty"` + SecondsToRecharge int `json:"SecondsToRecharge,omitempty"` +} + +type UserSettings struct { + GatherDevice bool `json:"GatherDeviceInfo,omitempty"` + GatherFocus bool `json:"GatherFocusInfo,omitempty"` + NeedsAttribution bool `json:"NeedsAttribution,omitempty"` +} + +type TreatmentAssignment struct { + Variables []Variable `json:"Variables,omitempty"` + Variants []string `json:"Variants,omitempty"` +} + +type Variable struct { + Name string `json:"Name,omitempty"` + Value string `json:"Value,omitempty"` +} diff --git a/internal/body.go b/internal/body.go new file mode 100644 index 0000000..a86e691 --- /dev/null +++ b/internal/body.go @@ -0,0 +1,43 @@ +package internal + +import ( + "go/token" + "strconv" + "strings" +) + +type Result[T any] struct { + StatusCode int `json:"code,omitempty"` + Data T `json:"data,omitempty"` + Status string `json:"status,omitempty"` +} + +type Error struct { + StatusCode int `json:"code,omitempty"` + Type string `json:"error,omitempty"` + Code int `json:"errorCode,omitempty"` + Details map[string][]string `json:"errorDetails,omitempty"` + Message string `json:"errorMessage,omitempty"` + Status string `json:"status,omitempty"` +} + +func (err Error) Error() string { + b := &strings.Builder{} + b.WriteString("playfab:") + + b.WriteByte(' ') + b.WriteString(strconv.Itoa(err.Code)) + + if err.Type != "" { + b.WriteByte(' ') + b.WriteByte(byte(token.LPAREN)) + b.WriteString(err.Type) + b.WriteByte(byte(token.RPAREN)) + } + if err.Message != "" && err.Message != err.Type { + b.WriteByte(byte(token.COLON)) + b.WriteByte(' ') + b.WriteString(strconv.Quote(err.Message)) + } + return b.String() +} diff --git a/internal/http.go b/internal/http.go new file mode 100644 index 0000000..77266d8 --- /dev/null +++ b/internal/http.go @@ -0,0 +1,48 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +func Post[T any](u *url.URL, r any, hooks ...func(req *http.Request)) (zero T, err error) { + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(r); err != nil { + return zero, fmt.Errorf("encode: %w", err) + } + req, err := http.NewRequest(http.MethodPost, u.String(), buf) + if err != nil { + return zero, fmt.Errorf("make request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + for _, hook := range hooks { + hook(req) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return zero, fmt.Errorf("POST %s: %w", u, err) + } + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated: + var body Result[T] + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return zero, fmt.Errorf("decode: %w", err) + } + return body.Data, nil + default: + b, err := io.ReadAll(resp.Body) + if err != nil { + return zero, fmt.Errorf("POST %s: %s", u, resp.Status) + } + var body Error + if err := json.Unmarshal(b, &body); err != nil { + return zero, fmt.Errorf("POST %s: %s: %s (%w)", u, resp.Status, b, err) + } + return zero, body + } +} diff --git a/login.go b/login.go new file mode 100644 index 0000000..13331da --- /dev/null +++ b/login.go @@ -0,0 +1,87 @@ +package playfab + +import ( + "errors" + "github.com/df-mc/go-playfab/internal" + "github.com/df-mc/go-playfab/title" +) + +// LoginConfig represents a base structure used for signing in to the PlayFab through a method. +// An implementation of IdentityProvider may embed LoginConfig with additional fields required +// to fill in. +type LoginConfig struct { + Title title.Title `json:"TitleId,omitempty"` + // CreateAccount specifies to automatically create a PlayFab account if + // one is not currently linked to the ID. + CreateAccount bool `json:"CreateAccount,omitempty"` + // CustomTags is the optional custom tags associated with the request. + CustomTags map[string]any `json:"CustomTags,omitempty"` + // EncryptedRequest is a base64-encoded body that is encrypted with the + // Title's public RSA key (Enterprise Only). + EncryptedRequest []byte `json:"EncryptedRequest,omitempty"` + RequestParameters *RequestParameters `json:"InfoRequestParameters,omitempty"` + // PlayerSecret that is used to verify API request signatures (Enterprise Only). + PlayerSecret string `json:"PlayerSecret,omitempty"` +} + +// IdentityProvider implements a Login method, which signs in to the PlayFab using [LoginConfig.Login] with a LoginConfig +// with additional fields required to fill in, through the path '/Client/LoginWithXXX', where X is normally the method to +// sign in. IdentityProvider is implemented by several platforms which supports signing in to the PlayFab with their identity. +type IdentityProvider interface { + Login(config LoginConfig) (*Identity, error) +} + +// RequestParameters is a set of requested parameters included in ResponseParameters, which can be retrieved +// through [Identity.ResponseParameters]. Users may set RequestParameters as a part of LoginConfig to include +// additional parameters while signing in to PlayFab. +type RequestParameters struct { + CharacterInventories bool `json:"GetCharacterInventories,omitempty"` + CharacterList bool `json:"GetCharacterList,omitempty"` + PlayerProfile bool `json:"GetPlayerProfile,omitempty"` + PlayerStatistics bool `json:"GetPlayerStatistics,omitempty"` + TitleData bool `json:"GetTitleData,omitempty"` + UserAccountInfo bool `json:"GetUserAccountInfo,omitempty"` + UserData bool `json:"GetUserData,omitempty"` + UserInventory bool `json:"GetUserInventory,omitempty"` + UserReadOnlyData bool `json:"GetUserReadOnlyData,omitempty"` + UserVirtualCurrency bool `json:"GetUserVirtualCurrency,omitempty"` + PlayerStatisticNames []string `json:"PlayerStatisticNames,omitempty"` + ProfileConstraints ProfileConstraints `json:"ProfileConstraints,omitempty"` + TitleDataKeys []string `json:"TitleDataKeys,omitempty"` + UserDataKeys []string `json:"UserDataKeys,omitempty"` + UserReadOnlyDataKeys []string `json:"UserReadOnlyDataKeys,omitempty"` +} + +// ProfileConstraints specifies the properties to return from the player profile, it is included as +// [RequestParameters.ProfileConstraints] to request some of the properties specified on the fields +// as [ResponseParameters.PlayerProfile]. +type ProfileConstraints struct { + ShowAvatarURL bool `json:"ShowAvatarUrl,omitempty"` + ShowBannedUntil bool `json:"ShowBannedUntil,omitempty"` + ShowCampaignAttributions bool `json:"ShowCampaignAttributions,omitempty"` + ShowContactEmailAddresses bool `json:"ShowContactEmailAddresses,omitempty"` + ShowCreated bool `json:"ShowCreated,omitempty"` + ShowDisplayName bool `json:"ShowDisplayName,omitempty"` + ShowExperimentVariants bool `json:"ShowExperimentVariants,omitempty"` + ShowLastLogin bool `json:"ShowLastLogin,omitempty"` + ShowLinkedAccounts bool `json:"ShowLinkedAccounts,omitempty"` + ShowLocations bool `json:"ShowLocations,omitempty"` + ShowMemberships bool `json:"ShowMemberships,omitempty"` + ShowOrigination bool `json:"ShowOrigination,omitempty"` + ShowPushNotificationRegistrations bool `json:"ShowPushNotificationRegistrations,omitempty"` + ShowStatistics bool `json:"ShowStatistics,omitempty"` + ShowTags bool `json:"ShowTags,omitempty"` + ShowTotalValueToDateInUSD bool `json:"ShowTotalValueToDateInUsd,omitempty"` + ShowValuesToDate bool `json:"ShowValuesToDate,omitempty"` +} + +// Login signs in to PlayFab using the request body and the path. The path normally follows the format +// 'Client/LoginWithXXX' where X typically is the method for signing in. The request body is generally +// a structure that may embed LoginConfig with additional fields required for the specific path, such +// as a token of IdentityProvider. +func (l LoginConfig) Login(path string, body any) (*Identity, error) { + if l.Title == "" { + return nil, errors.New("playfab: LoginConfig: Title not set") + } + return internal.Post[*Identity](l.Title.URL().JoinPath(path), body) +} diff --git a/playfab.go b/playfab.go new file mode 100644 index 0000000..d901565 --- /dev/null +++ b/playfab.go @@ -0,0 +1,25 @@ +package playfab + +import "github.com/df-mc/go-playfab/internal" + +type Result[T any] internal.Result[T] + +type Error = internal.Error + +const ( + ErrorCodeEncryptionKeyMissing = 1290 + ErrorCodeEvaluationModePlayerCountExceeded = 1490 + ErrorCodeExpiredXboxLiveToken = 1189 + ErrorCodeInvalidXboxLiveToken = 1188 + ErrorCodeRequestViewConstraintParamsNotAllowed = 1303 + ErrorCodeSignedRequestNotAllowed = 1302 + ErrorCodeXboxInaccessible = 1339 + ErrorCodeXboxRejectedXSTSExchangeRequest = 1343 + ErrorCodeXboxXASSExchangeFailure = 1306 +) + +const ( + ErrorCodeDatabaseThroughputExceeded = 1113 + ErrorCodeItemNotFound = 1047 + ErrorCodeNotImplemented = 1515 +) diff --git a/title/title.go b/title/title.go new file mode 100644 index 0000000..4e60dcc --- /dev/null +++ b/title/title.go @@ -0,0 +1,19 @@ +package title + +import ( + "net/url" + "strings" +) + +// Title represents a PlayFab title. The string itself is a hexadecimal ID of the title. +type Title string + +// URL returns an [url.URL] of the Title. It is generally called for sending a request to +// the API for the Title. It follows the format 'https://XXX.playfabapi.com', where X is +// the lowercase ID of title. +func (t Title) URL() *url.URL { + return &url.URL{ + Scheme: "https", + Host: strings.ToLower(string(t)) + ".playfabapi.com", + } +} diff --git a/xbox_live.go b/xbox_live.go new file mode 100644 index 0000000..279a3db --- /dev/null +++ b/xbox_live.go @@ -0,0 +1,43 @@ +package playfab + +import ( + "errors" + "fmt" + "github.com/df-mc/go-xsapi" +) + +// XBLIdentityProvider implements IdentityProvider for signing in to PlayFab using the path +// '/Client/LoginWithXbox' with a [xsapi.Token] that relies on the party 'http://playfab.xboxlive.com/'. +type XBLIdentityProvider struct { + // TokenSource is used for obtaining a [xsapi.Token] that relies on the party defined in + // the constant below (see RelyingParty). + TokenSource xsapi.TokenSource +} + +// Login signs in to PlayFab using a [xsapi.Token] obtained from the TokenSource that relies on the +// party 'http://playfab.xboxlive.com/'. It returns an Identity by calling the [LoginConfig.Login] method +// with an additional field named 'XboxToken' that specifies a string obtained from [xsapi.Token.String] +// and the path '/Client/LoginWithXbox'. +func (prov XBLIdentityProvider) Login(config LoginConfig) (*Identity, error) { + if prov.TokenSource == nil { + return nil, errors.New("playfab: XBLIdentityProvider: TokenSource is nil") + } + + tok, err := prov.TokenSource.Token() + if err != nil { + return nil, fmt.Errorf("request xbox live token: %w", err) + } + + type loginConfig struct { + LoginConfig + XboxToken string `json:"XboxToken"` + } + return config.Login("/Client/LoginWithXbox", loginConfig{ + LoginConfig: config, + XboxToken: tok.String(), + }) +} + +// RelyingParty is the party that a [xsapi.Token] obtained from [XBLIdentityProvider.TokenSource] should rely +// on. Using a [xsapi.Token] that relies on other party may cause an error related to "decrypting token body". +const RelyingParty = "http://playfab.xboxlive.com/"