feat(scum): add versioned gift grants
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// serverGameGiftCatalogs godoc
|
||||
// @Summary List or create SCUM versioned gift catalogs
|
||||
// @Description Manages bounded drafts that contain catalog references only, never raw game item commands.
|
||||
// @Tags game-gifts
|
||||
// @Produce json
|
||||
// @Router /api/v1/server-instances/{id}/game-gifts [get,post]
|
||||
func (h *coreHandlers) serverGameGiftCatalogs(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
values, err := h.core.ListGameGiftCatalogsForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameGiftCatalogsFromDomain(values))
|
||||
case http.MethodPost:
|
||||
request, err := decodeJSON[dto.GameGiftCatalogRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
value, err := h.core.SaveGameGiftCatalogForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, dto.GameGiftCatalogFromDomain(value))
|
||||
default:
|
||||
writeMethodNotAllowed(w, "GET, POST")
|
||||
}
|
||||
}
|
||||
|
||||
// serverGameGiftCatalogPublish godoc
|
||||
// @Summary Publish immutable SCUM gift revision
|
||||
// @Description Freezes a validated version-fenced gift draft.
|
||||
// @Tags game-gifts
|
||||
// @Produce json
|
||||
// @Router /api/v1/server-instances/{id}/game-gifts/{catalogId}/publish [post]
|
||||
func (h *coreHandlers) serverGameGiftCatalogPublish(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
value, err := h.core.PublishGameGiftCatalogForSession(bearerToken(r), r.PathValue("catalogId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if value.ServerInstanceID != r.PathValue("id") {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, dto.GameGiftRevisionFromDomain(value))
|
||||
}
|
||||
|
||||
// serverGameGiftCatalogRevisions godoc
|
||||
// @Summary List immutable SCUM gift revisions
|
||||
// @Tags game-gifts
|
||||
// @Produce json
|
||||
// @Router /api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions [get]
|
||||
func (h *coreHandlers) serverGameGiftCatalogRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
values, err := h.core.ListGameGiftRevisionsForSession(bearerToken(r), r.PathValue("catalogId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
for _, v := range values {
|
||||
if v.ServerInstanceID != r.PathValue("id") {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameGiftRevisionsFromDomain(values))
|
||||
}
|
||||
|
||||
// serverGameGiftGrants godoc
|
||||
// @Summary List or request directed SCUM gift grants
|
||||
// @Description Creates frozen local-player grants for platform-admin approval without raw commands or item codes.
|
||||
// @Tags game-gifts
|
||||
// @Produce json
|
||||
// @Router /api/v1/server-instances/{id}/game-gift-grants [get,post]
|
||||
func (h *coreHandlers) serverGameGiftGrants(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
values, err := h.core.ListGameGiftGrantsForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameGiftGrantsFromDomain(values))
|
||||
case http.MethodPost:
|
||||
request, err := decodeJSON[dto.GameGiftGrantRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
value, err := h.core.RequestGameGiftGrantForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.GameGiftGrantFromDomain(value))
|
||||
default:
|
||||
writeMethodNotAllowed(w, "GET, POST")
|
||||
}
|
||||
}
|
||||
|
||||
// serverGameGiftGrantApprove godoc
|
||||
// @Summary Approve a frozen directed SCUM gift grant
|
||||
// @Description Requires a platform administrator and dispatches only the declared typed reward command.
|
||||
// @Tags game-gifts
|
||||
// @Produce json
|
||||
// @Router /api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve [post]
|
||||
func (h *coreHandlers) serverGameGiftGrantApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
value, err := h.core.ApproveGameGiftGrantForSession(bearerToken(r), r.PathValue("grantId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if value.ServerInstanceID != r.PathValue("id") {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.GameGiftGrantFromDomain(value))
|
||||
}
|
||||
@@ -98,6 +98,11 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state", h.serverGamePlayerState)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state-patches", h.serverGamePlayerStatePatches)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state-patches/{patchId}/approve", h.serverGamePlayerStatePatchApprove)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts", h.serverGameGiftCatalogs)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/publish", h.serverGameGiftCatalogPublish)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const SCUMRewardDeliverCommandType = "reward.deliver"
|
||||
const SCUMGiftNotificationCommandType = "player.notify"
|
||||
|
||||
type SCUMGiftItemDefinition struct {
|
||||
Key string
|
||||
Label string
|
||||
MaximumQuantity int
|
||||
}
|
||||
type SCUMGiftItemCatalog struct {
|
||||
GameVersion string
|
||||
Items []SCUMGiftItemDefinition
|
||||
}
|
||||
|
||||
var SCUMGiftItemCatalogs = []SCUMGiftItemCatalog{{GameVersion: "0.9.700.90357", Items: []SCUMGiftItemDefinition{{Key: "bandage", Label: "绷带", MaximumQuantity: 20}, {Key: "water-bottle", Label: "饮用水", MaximumQuantity: 10}, {Key: "improvised-spear", Label: "简易长矛", MaximumQuantity: 2}}}}
|
||||
|
||||
type GameGiftItem struct {
|
||||
CatalogItemKey string
|
||||
Label string
|
||||
Quantity int
|
||||
}
|
||||
type GameGiftCatalog struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
Name string
|
||||
GameVersion string
|
||||
DraftItems []GameGiftItem
|
||||
LatestRevisionID string
|
||||
CreatedBy string
|
||||
UpdatedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
type GameGiftRevision struct {
|
||||
ID string
|
||||
CatalogID string
|
||||
ServerInstanceID string
|
||||
Revision int
|
||||
GameVersion string
|
||||
Items []GameGiftItem
|
||||
PublishedBy string
|
||||
PublishedAt time.Time
|
||||
}
|
||||
type GameGiftGrantStatus string
|
||||
|
||||
const (
|
||||
GameGiftGrantPendingApproval GameGiftGrantStatus = "pending-approval"
|
||||
GameGiftGrantQueued GameGiftGrantStatus = "queued"
|
||||
GameGiftGrantDelivered GameGiftGrantStatus = "delivered"
|
||||
GameGiftGrantNotificationFailed GameGiftGrantStatus = "notification_failed"
|
||||
GameGiftGrantFailed GameGiftGrantStatus = "failed"
|
||||
GameGiftGrantUnknown GameGiftGrantStatus = "unknown"
|
||||
)
|
||||
|
||||
type GameGiftGrant struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
CatalogID string
|
||||
RevisionID string
|
||||
RevisionNumber int
|
||||
GameVersion string
|
||||
Items []GameGiftItem
|
||||
GamePlayerRecordID string
|
||||
GamePlayerID string
|
||||
PlayerDisplayName string
|
||||
Notice string
|
||||
IdempotencyKey string
|
||||
RequesterID string
|
||||
ApproverID string
|
||||
Status GameGiftGrantStatus
|
||||
DeliveryCommandID string
|
||||
NotificationCommandID string
|
||||
DeliverySummary string
|
||||
NotificationSummary string
|
||||
CreatedAt time.Time
|
||||
ApprovedAt time.Time
|
||||
CompletedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
type GameGiftCatalogFilter struct {
|
||||
ServerInstanceID string
|
||||
Limit int
|
||||
}
|
||||
type GameGiftRevisionFilter struct {
|
||||
CatalogID string
|
||||
ServerInstanceID string
|
||||
Limit int
|
||||
}
|
||||
type GameGiftGrantFilter struct {
|
||||
ServerInstanceID string
|
||||
GamePlayerRecordID string
|
||||
IdempotencyKey string
|
||||
Limit int
|
||||
}
|
||||
type GameGiftCatalogRequest struct {
|
||||
ID string
|
||||
Name string
|
||||
GameVersion string
|
||||
Items []GameGiftItem
|
||||
}
|
||||
type GameGiftGrantRequest struct {
|
||||
RevisionID string
|
||||
GamePlayerRecordID string
|
||||
Notice string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
func CopyGameGiftItems(items []GameGiftItem) []GameGiftItem {
|
||||
return append([]GameGiftItem(nil), items...)
|
||||
}
|
||||
func CopyGameGiftCatalog(value GameGiftCatalog) GameGiftCatalog {
|
||||
value.DraftItems = CopyGameGiftItems(value.DraftItems)
|
||||
return value
|
||||
}
|
||||
func CopyGameGiftRevision(value GameGiftRevision) GameGiftRevision {
|
||||
value.Items = CopyGameGiftItems(value.Items)
|
||||
return value
|
||||
}
|
||||
func CopyGameGiftGrant(value GameGiftGrant) GameGiftGrant {
|
||||
value.Items = CopyGameGiftItems(value.Items)
|
||||
return value
|
||||
}
|
||||
func SCUMGiftCatalogForVersion(version string) (SCUMGiftItemCatalog, bool) {
|
||||
for _, catalog := range SCUMGiftItemCatalogs {
|
||||
if catalog.GameVersion == version {
|
||||
return catalog, true
|
||||
}
|
||||
}
|
||||
return SCUMGiftItemCatalog{}, false
|
||||
}
|
||||
func SCUMGiftItemForVersion(version, key string) (SCUMGiftItemDefinition, bool) {
|
||||
catalog, ok := SCUMGiftCatalogForVersion(version)
|
||||
if !ok {
|
||||
return SCUMGiftItemDefinition{}, false
|
||||
}
|
||||
for _, item := range catalog.Items {
|
||||
if item.Key == key {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return SCUMGiftItemDefinition{}, false
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"browser.local/platform/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GameGiftItemRequest struct {
|
||||
CatalogItemKey string `json:"catalogItemKey"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
type GameGiftItemResponse struct {
|
||||
CatalogItemKey string `json:"catalogItemKey"`
|
||||
Label string `json:"label"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
type GameGiftCatalogRequest struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
GameVersion string `json:"gameVersion"`
|
||||
Items []GameGiftItemRequest `json:"items"`
|
||||
}
|
||||
type GameGiftCatalogResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
GameVersion string `json:"gameVersion"`
|
||||
DraftItems []GameGiftItemResponse `json:"draftItems"`
|
||||
LatestRevisionID string `json:"latestRevisionId,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
type GameGiftCatalogListResponse struct {
|
||||
Items []GameGiftCatalogResponse `json:"items"`
|
||||
}
|
||||
type GameGiftRevisionResponse struct {
|
||||
ID string `json:"id"`
|
||||
CatalogID string `json:"catalogId"`
|
||||
Revision int `json:"revision"`
|
||||
GameVersion string `json:"gameVersion"`
|
||||
Items []GameGiftItemResponse `json:"items"`
|
||||
PublishedBy string `json:"publishedBy"`
|
||||
PublishedAt time.Time `json:"publishedAt"`
|
||||
}
|
||||
type GameGiftRevisionListResponse struct {
|
||||
Items []GameGiftRevisionResponse `json:"items"`
|
||||
}
|
||||
type GameGiftGrantRequest struct {
|
||||
RevisionID string `json:"revisionId"`
|
||||
GamePlayerRecordID string `json:"gamePlayerRecordId"`
|
||||
Notice string `json:"notice"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
type GameGiftGrantResponse struct {
|
||||
ID string `json:"id"`
|
||||
RevisionID string `json:"revisionId"`
|
||||
RevisionNumber int `json:"revisionNumber"`
|
||||
GameVersion string `json:"gameVersion"`
|
||||
Items []GameGiftItemResponse `json:"items"`
|
||||
GamePlayerRecordID string `json:"gamePlayerRecordId"`
|
||||
PlayerDisplayName string `json:"playerDisplayName"`
|
||||
Notice string `json:"notice"`
|
||||
RequesterID string `json:"requesterId"`
|
||||
ApproverID string `json:"approverId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
DeliverySummary string `json:"deliverySummary,omitempty"`
|
||||
NotificationSummary string `json:"notificationSummary,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ApprovedAt time.Time `json:"approvedAt,omitempty"`
|
||||
CompletedAt time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
type GameGiftGrantListResponse struct {
|
||||
Items []GameGiftGrantResponse `json:"items"`
|
||||
}
|
||||
|
||||
func (r GameGiftCatalogRequest) ToDomain() domain.GameGiftCatalogRequest {
|
||||
items := make([]domain.GameGiftItem, len(r.Items))
|
||||
for i, item := range r.Items {
|
||||
def, _ := domain.SCUMGiftItemForVersion(r.GameVersion, item.CatalogItemKey)
|
||||
items[i] = domain.GameGiftItem{CatalogItemKey: item.CatalogItemKey, Label: def.Label, Quantity: item.Quantity}
|
||||
}
|
||||
return domain.GameGiftCatalogRequest{ID: r.ID, Name: r.Name, GameVersion: r.GameVersion, Items: items}
|
||||
}
|
||||
func (r GameGiftGrantRequest) ToDomain() domain.GameGiftGrantRequest {
|
||||
return domain.GameGiftGrantRequest{RevisionID: r.RevisionID, GamePlayerRecordID: r.GamePlayerRecordID, Notice: r.Notice, IdempotencyKey: r.IdempotencyKey}
|
||||
}
|
||||
func giftItems(items []domain.GameGiftItem) []GameGiftItemResponse {
|
||||
out := make([]GameGiftItemResponse, len(items))
|
||||
for i, item := range items {
|
||||
out[i] = GameGiftItemResponse{CatalogItemKey: item.CatalogItemKey, Label: item.Label, Quantity: item.Quantity}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func GameGiftCatalogFromDomain(v domain.GameGiftCatalog) GameGiftCatalogResponse {
|
||||
return GameGiftCatalogResponse{ID: v.ID, Name: v.Name, GameVersion: v.GameVersion, DraftItems: giftItems(v.DraftItems), LatestRevisionID: v.LatestRevisionID, UpdatedAt: v.UpdatedAt}
|
||||
}
|
||||
func GameGiftCatalogsFromDomain(v []domain.GameGiftCatalog) GameGiftCatalogListResponse {
|
||||
out := make([]GameGiftCatalogResponse, len(v))
|
||||
for i, x := range v {
|
||||
out[i] = GameGiftCatalogFromDomain(x)
|
||||
}
|
||||
return GameGiftCatalogListResponse{Items: out}
|
||||
}
|
||||
func GameGiftRevisionFromDomain(v domain.GameGiftRevision) GameGiftRevisionResponse {
|
||||
return GameGiftRevisionResponse{ID: v.ID, CatalogID: v.CatalogID, Revision: v.Revision, GameVersion: v.GameVersion, Items: giftItems(v.Items), PublishedBy: v.PublishedBy, PublishedAt: v.PublishedAt}
|
||||
}
|
||||
func GameGiftRevisionsFromDomain(v []domain.GameGiftRevision) GameGiftRevisionListResponse {
|
||||
out := make([]GameGiftRevisionResponse, len(v))
|
||||
for i, x := range v {
|
||||
out[i] = GameGiftRevisionFromDomain(x)
|
||||
}
|
||||
return GameGiftRevisionListResponse{Items: out}
|
||||
}
|
||||
func GameGiftGrantFromDomain(v domain.GameGiftGrant) GameGiftGrantResponse {
|
||||
return GameGiftGrantResponse{ID: v.ID, RevisionID: v.RevisionID, RevisionNumber: v.RevisionNumber, GameVersion: v.GameVersion, Items: giftItems(v.Items), GamePlayerRecordID: v.GamePlayerRecordID, PlayerDisplayName: v.PlayerDisplayName, Notice: v.Notice, RequesterID: v.RequesterID, ApproverID: v.ApproverID, Status: string(v.Status), DeliverySummary: v.DeliverySummary, NotificationSummary: v.NotificationSummary, CreatedAt: v.CreatedAt, ApprovedAt: v.ApprovedAt, CompletedAt: v.CompletedAt}
|
||||
}
|
||||
func GameGiftGrantsFromDomain(v []domain.GameGiftGrant) GameGiftGrantListResponse {
|
||||
out := make([]GameGiftGrantResponse, len(v))
|
||||
for i, x := range v {
|
||||
out[i] = GameGiftGrantFromDomain(x)
|
||||
}
|
||||
return GameGiftGrantListResponse{Items: out}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// GameGiftCatalog is the model-first editable, server-version-fenced gift draft.
|
||||
type GameGiftCatalog struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
GameVersion string `json:"gameVersion" db:"game_version"`
|
||||
LatestRevisionID string `json:"latestRevisionId" db:"latest_revision_id"`
|
||||
CreatedBy string `json:"createdBy" db:"created_by"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (GameGiftCatalog) TableName() string { return "game_gift_catalogs" }
|
||||
|
||||
// GameGiftRevision is an immutable gift item snapshot.
|
||||
type GameGiftRevision struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
CatalogID string `json:"catalogId" db:"catalog_id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
Revision int `json:"revision" db:"revision"`
|
||||
GameVersion string `json:"gameVersion" db:"game_version"`
|
||||
PublishedBy string `json:"publishedBy" db:"published_by"`
|
||||
PublishedAt time.Time `json:"publishedAt" db:"published_at"`
|
||||
}
|
||||
|
||||
func (GameGiftRevision) TableName() string { return "game_gift_revisions" }
|
||||
|
||||
// GameGiftGrant records a directed frozen gift lifecycle without raw game commands.
|
||||
type GameGiftGrant struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
RevisionID string `json:"revisionId" db:"revision_id"`
|
||||
GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"`
|
||||
Status string `json:"status" db:"status"`
|
||||
DeliveryCommandID string `json:"deliveryCommandId" db:"delivery_command_id"`
|
||||
NotificationCommandID string `json:"notificationCommandId" db:"notification_command_id"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (GameGiftGrant) TableName() string { return "game_gift_grants" }
|
||||
@@ -48,6 +48,9 @@ type StoreSnapshot struct {
|
||||
GameAccessAttempts []domain.GameAccessAttempt `json:"gameAccessAttempts"`
|
||||
GameSecuritySignals []domain.GameSecuritySignal `json:"gameSecuritySignals"`
|
||||
GamePlayerStatePatches []domain.GamePlayerStatePatch `json:"gamePlayerStatePatches"`
|
||||
GameGiftCatalogs []domain.GameGiftCatalog `json:"gameGiftCatalogs"`
|
||||
GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"`
|
||||
GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"`
|
||||
}
|
||||
|
||||
type FileStore struct {
|
||||
@@ -214,6 +217,15 @@ func (store *FileStore) GameSecuritySignals() GameSecuritySignalRepository {
|
||||
func (store *FileStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
|
||||
return &persistentRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]{repository: store.MemoryStore.gamePlayerStatePatches, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) GameGiftCatalogs() GameGiftCatalogRepository {
|
||||
return &persistentRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]{repository: store.MemoryStore.gameGiftCatalogs, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) GameGiftRevisions() GameGiftRevisionRepository {
|
||||
return &persistentRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]{repository: store.MemoryStore.gameGiftRevisions, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) GameGiftGrants() GameGiftGrantRepository {
|
||||
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) load() error {
|
||||
data, err := os.ReadFile(store.path)
|
||||
@@ -287,7 +299,7 @@ func (store *FileStore) snapshot() StoreSnapshot {
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +339,9 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts)
|
||||
loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals)
|
||||
loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches)
|
||||
loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs)
|
||||
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
|
||||
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
|
||||
}
|
||||
|
||||
type mutableRepository[T any, F any] interface {
|
||||
|
||||
@@ -189,6 +189,15 @@ func (store *MySQLStore) GameSecuritySignals() GameSecuritySignalRepository {
|
||||
func (store *MySQLStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
|
||||
return &persistentRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]{repository: store.MemoryStore.gamePlayerStatePatches, persist: store.persist}
|
||||
}
|
||||
func (store *MySQLStore) GameGiftCatalogs() GameGiftCatalogRepository {
|
||||
return &persistentRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]{repository: store.MemoryStore.gameGiftCatalogs, persist: store.persist}
|
||||
}
|
||||
func (store *MySQLStore) GameGiftRevisions() GameGiftRevisionRepository {
|
||||
return &persistentRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]{repository: store.MemoryStore.gameGiftRevisions, persist: store.persist}
|
||||
}
|
||||
func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository {
|
||||
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) initialize() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@@ -279,7 +288,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches),
|
||||
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,4 +328,7 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts)
|
||||
loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals)
|
||||
loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches)
|
||||
loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs)
|
||||
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
|
||||
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
|
||||
}
|
||||
|
||||
@@ -265,6 +265,23 @@ type GamePlayerStatePatchRepository interface {
|
||||
List(domain.GamePlayerStatePatchFilter) ([]domain.GamePlayerStatePatch, error)
|
||||
Update(domain.GamePlayerStatePatch) error
|
||||
}
|
||||
type GameGiftCatalogRepository interface {
|
||||
Create(domain.GameGiftCatalog) error
|
||||
Get(string) (domain.GameGiftCatalog, error)
|
||||
List(domain.GameGiftCatalogFilter) ([]domain.GameGiftCatalog, error)
|
||||
Update(domain.GameGiftCatalog) error
|
||||
}
|
||||
type GameGiftRevisionRepository interface {
|
||||
Create(domain.GameGiftRevision) error
|
||||
Get(string) (domain.GameGiftRevision, error)
|
||||
List(domain.GameGiftRevisionFilter) ([]domain.GameGiftRevision, error)
|
||||
}
|
||||
type GameGiftGrantRepository interface {
|
||||
Create(domain.GameGiftGrant) error
|
||||
Get(string) (domain.GameGiftGrant, error)
|
||||
List(domain.GameGiftGrantFilter) ([]domain.GameGiftGrant, error)
|
||||
Update(domain.GameGiftGrant) error
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Users() UserRepository
|
||||
@@ -302,6 +319,9 @@ type Store interface {
|
||||
GameAccessAttempts() GameAccessAttemptRepository
|
||||
GameSecuritySignals() GameSecuritySignalRepository
|
||||
GamePlayerStatePatches() GamePlayerStatePatchRepository
|
||||
GameGiftCatalogs() GameGiftCatalogRepository
|
||||
GameGiftRevisions() GameGiftRevisionRepository
|
||||
GameGiftGrants() GameGiftGrantRepository
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
@@ -340,6 +360,9 @@ type MemoryStore struct {
|
||||
gameAccessAttempts *memoryRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter]
|
||||
gameSecuritySignals *memoryRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]
|
||||
gamePlayerStatePatches *memoryRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]
|
||||
gameGiftCatalogs *memoryRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]
|
||||
gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]
|
||||
gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
@@ -483,6 +506,9 @@ func NewMemoryStore() *MemoryStore {
|
||||
gameAccessAttempts: newMemoryRepository(func(v domain.GameAccessAttempt) string { return v.ID }, domain.CopyGameAccessAttempt, matchGameAccessAttempt),
|
||||
gameSecuritySignals: newMemoryRepository(func(v domain.GameSecuritySignal) string { return v.ID }, domain.CopyGameSecuritySignal, matchGameSecuritySignal),
|
||||
gamePlayerStatePatches: newMemoryRepository(func(v domain.GamePlayerStatePatch) string { return v.ID }, domain.CopyGamePlayerStatePatch, matchGamePlayerStatePatch),
|
||||
gameGiftCatalogs: newMemoryRepository(func(v domain.GameGiftCatalog) string { return v.ID }, domain.CopyGameGiftCatalog, matchGameGiftCatalog),
|
||||
gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision),
|
||||
gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,6 +577,11 @@ func (store *MemoryStore) GameSecuritySignals() GameSecuritySignalRepository {
|
||||
func (store *MemoryStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
|
||||
return store.gamePlayerStatePatches
|
||||
}
|
||||
func (store *MemoryStore) GameGiftCatalogs() GameGiftCatalogRepository { return store.gameGiftCatalogs }
|
||||
func (store *MemoryStore) GameGiftRevisions() GameGiftRevisionRepository {
|
||||
return store.gameGiftRevisions
|
||||
}
|
||||
func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return store.gameGiftGrants }
|
||||
|
||||
type memoryRepository[T any, F any] struct {
|
||||
mu sync.RWMutex
|
||||
@@ -870,3 +901,12 @@ func matchGameSecuritySignal(v domain.GameSecuritySignal, f domain.GameSecurityS
|
||||
func matchGamePlayerStatePatch(v domain.GamePlayerStatePatch, f domain.GamePlayerStatePatchFilter) bool {
|
||||
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID)
|
||||
}
|
||||
func matchGameGiftCatalog(v domain.GameGiftCatalog, f domain.GameGiftCatalogFilter) bool {
|
||||
return f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID
|
||||
}
|
||||
func matchGameGiftRevision(v domain.GameGiftRevision, f domain.GameGiftRevisionFilter) bool {
|
||||
return (f.CatalogID == "" || v.CatalogID == f.CatalogID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID)
|
||||
}
|
||||
func matchGameGiftGrant(v domain.GameGiftGrant, f domain.GameGiftGrantFilter) bool {
|
||||
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (svc *CoreService) ListGameGiftCatalogsForSession(sessionID, serverID string) ([]domain.GameGiftCatalog, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.store.GameGiftCatalogs().List(domain.GameGiftCatalogFilter{ServerInstanceID: serverID})
|
||||
}
|
||||
func (svc *CoreService) SaveGameGiftCatalogForSession(sessionID, serverID string, request domain.GameGiftCatalogRequest) (domain.GameGiftCatalog, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.GameGiftCatalog{}, err
|
||||
}
|
||||
if err = svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
|
||||
return domain.GameGiftCatalog{}, err
|
||||
}
|
||||
if err = validateGiftItems(request.GameVersion, request.Items); err != nil {
|
||||
return domain.GameGiftCatalog{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
catalog := domain.GameGiftCatalog{ID: fmt.Sprintf("game-gift-catalog-%d", stamp.UnixNano()), ServerInstanceID: serverID, Name: strings.TrimSpace(request.Name), GameVersion: request.GameVersion, DraftItems: domain.CopyGameGiftItems(request.Items), CreatedBy: user.ID, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if request.ID != "" {
|
||||
existing, getErr := svc.store.GameGiftCatalogs().Get(request.ID)
|
||||
if getErr != nil || existing.ServerInstanceID != serverID {
|
||||
return domain.GameGiftCatalog{}, repo.ErrNotFound
|
||||
}
|
||||
catalog.ID, catalog.CreatedBy, catalog.CreatedAt, catalog.LatestRevisionID = existing.ID, existing.CreatedBy, existing.CreatedAt, existing.LatestRevisionID
|
||||
}
|
||||
if len(catalog.Name) < 2 || len(catalog.Name) > 80 {
|
||||
return domain.GameGiftCatalog{}, validationError("gift catalog name must be 2 to 80 characters")
|
||||
}
|
||||
if request.ID == "" {
|
||||
if err = svc.store.GameGiftCatalogs().Create(catalog); err != nil {
|
||||
return domain.GameGiftCatalog{}, err
|
||||
}
|
||||
} else if err = svc.store.GameGiftCatalogs().Update(catalog); err != nil {
|
||||
return domain.GameGiftCatalog{}, err
|
||||
}
|
||||
_, err = svc.recordAuditEventWithID(user.ID, "game-gift.catalog.save", "game-gift-catalog", catalog.ID, domain.AuditResultSuccess, "version-fenced gift draft saved")
|
||||
return domain.CopyGameGiftCatalog(catalog), err
|
||||
}
|
||||
func (svc *CoreService) PublishGameGiftCatalogForSession(sessionID, catalogID string) (domain.GameGiftRevision, error) {
|
||||
catalog, err := svc.store.GameGiftCatalogs().Get(catalogID)
|
||||
if err != nil {
|
||||
return domain.GameGiftRevision{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.GameGiftRevision{}, err
|
||||
}
|
||||
if err = svc.authorizeServerLifecycle(sessionID, catalog.ServerInstanceID); err != nil {
|
||||
return domain.GameGiftRevision{}, err
|
||||
}
|
||||
if err = validateGiftItems(catalog.GameVersion, catalog.DraftItems); err != nil {
|
||||
return domain.GameGiftRevision{}, err
|
||||
}
|
||||
revisions, err := svc.store.GameGiftRevisions().List(domain.GameGiftRevisionFilter{CatalogID: catalog.ID})
|
||||
if err != nil {
|
||||
return domain.GameGiftRevision{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
revision := domain.GameGiftRevision{ID: fmt.Sprintf("game-gift-revision-%d", stamp.UnixNano()), CatalogID: catalog.ID, ServerInstanceID: catalog.ServerInstanceID, Revision: len(revisions) + 1, GameVersion: catalog.GameVersion, Items: domain.CopyGameGiftItems(catalog.DraftItems), PublishedBy: user.ID, PublishedAt: stamp}
|
||||
if err = svc.store.GameGiftRevisions().Create(revision); err != nil {
|
||||
return domain.GameGiftRevision{}, err
|
||||
}
|
||||
catalog.LatestRevisionID, catalog.UpdatedAt = revision.ID, stamp
|
||||
if err = svc.store.GameGiftCatalogs().Update(catalog); err != nil {
|
||||
return domain.GameGiftRevision{}, err
|
||||
}
|
||||
_, err = svc.recordAuditEventWithID(user.ID, "game-gift.catalog.publish", "game-gift-revision", revision.ID, domain.AuditResultSuccess, "immutable gift revision published")
|
||||
return domain.CopyGameGiftRevision(revision), err
|
||||
}
|
||||
func (svc *CoreService) ListGameGiftRevisionsForSession(sessionID, catalogID string) ([]domain.GameGiftRevision, error) {
|
||||
catalog, err := svc.store.GameGiftCatalogs().Get(catalogID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = svc.authorizeServerLifecycle(sessionID, catalog.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.store.GameGiftRevisions().List(domain.GameGiftRevisionFilter{CatalogID: catalogID})
|
||||
}
|
||||
func (svc *CoreService) RequestGameGiftGrantForSession(sessionID, serverID string, request domain.GameGiftGrantRequest) (domain.GameGiftGrant, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
if err = svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 {
|
||||
return domain.GameGiftGrant{}, validationError("gift grant idempotency key is required")
|
||||
}
|
||||
existing, err := svc.store.GameGiftGrants().List(domain.GameGiftGrantFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey})
|
||||
if err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
return domain.CopyGameGiftGrant(existing[0]), nil
|
||||
}
|
||||
revision, err := svc.store.GameGiftRevisions().Get(request.RevisionID)
|
||||
if err != nil || revision.ServerInstanceID != serverID {
|
||||
return domain.GameGiftGrant{}, repo.ErrNotFound
|
||||
}
|
||||
if err = validateGiftItems(revision.GameVersion, revision.Items); err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
player, err := svc.store.GamePlayers().Get(request.GamePlayerRecordID)
|
||||
if err != nil || player.ServerInstanceID != serverID {
|
||||
return domain.GameGiftGrant{}, repo.ErrNotFound
|
||||
}
|
||||
notice := strings.TrimSpace(request.Notice)
|
||||
if len(notice) < 1 || len(notice) > 200 {
|
||||
return domain.GameGiftGrant{}, validationError("gift notification must be 1 to 200 characters")
|
||||
}
|
||||
stamp := svc.now()
|
||||
grant := domain.GameGiftGrant{ID: fmt.Sprintf("game-gift-grant-%d", stamp.UnixNano()), ServerInstanceID: serverID, CatalogID: revision.CatalogID, RevisionID: revision.ID, RevisionNumber: revision.Revision, GameVersion: revision.GameVersion, Items: domain.CopyGameGiftItems(revision.Items), GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, PlayerDisplayName: player.DisplayName, Notice: notice, IdempotencyKey: request.IdempotencyKey, RequesterID: user.ID, Status: domain.GameGiftGrantPendingApproval, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if err = svc.store.GameGiftGrants().Create(grant); err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
_, err = svc.recordAuditEventWithID(user.ID, "game-gift.grant.request", "game-gift-grant", grant.ID, domain.AuditResultQueued, "frozen gift grant awaiting platform administrator approval")
|
||||
return domain.CopyGameGiftGrant(grant), err
|
||||
}
|
||||
func (svc *CoreService) ApproveGameGiftGrantForSession(sessionID, grantID string) (domain.GameGiftGrant, error) {
|
||||
grant, err := svc.store.GameGiftGrants().Get(grantID)
|
||||
if err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
if !isPlatformAdmin(user) {
|
||||
return domain.GameGiftGrant{}, ErrForbidden
|
||||
}
|
||||
if err = svc.authorizeServerLifecycle(sessionID, grant.ServerInstanceID); err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
if grant.Status != domain.GameGiftGrantPendingApproval {
|
||||
return domain.GameGiftGrant{}, validationError("gift grant is not awaiting approval")
|
||||
}
|
||||
if err = validateGiftItems(grant.GameVersion, grant.Items); err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: grant.GamePlayerRecordID, OpenOnly: true})
|
||||
if err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
if len(sessions) == 0 {
|
||||
return domain.GameGiftGrant{}, validationError("gift target player is offline")
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(grant.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
profile, ok := gameClientBridgeProfileKey(plugin)
|
||||
if !ok {
|
||||
return domain.GameGiftGrant{}, validationError("SCUM gift companion profile is unavailable")
|
||||
}
|
||||
command, err := svc.queueGameClientBridgeCommand(user.ID, domain.GameClientBridgeQueueRequest{ServerInstanceID: grant.ServerInstanceID, PluginID: instance.PluginID, ProfileKey: profile, CommandType: domain.SCUMRewardDeliverCommandType, Payload: giftDeliveryPayload(grant), IdempotencyKey: grant.ID, Priority: 10, ExpiresAt: svc.now().Add(2 * time.Minute)})
|
||||
if err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
grant.Status, grant.ApproverID, grant.ApprovedAt, grant.UpdatedAt, grant.DeliveryCommandID = domain.GameGiftGrantQueued, user.ID, stamp, stamp, command.ID
|
||||
if err = svc.store.GameGiftGrants().Update(grant); err != nil {
|
||||
return domain.GameGiftGrant{}, err
|
||||
}
|
||||
_, err = svc.recordAuditEventWithID(user.ID, "game-gift.grant.approve", "game-gift-grant", grant.ID, domain.AuditResultQueued, "platform administrator approved frozen gift grant")
|
||||
return domain.CopyGameGiftGrant(grant), err
|
||||
}
|
||||
func (svc *CoreService) ListGameGiftGrantsForSession(sessionID, serverID string) ([]domain.GameGiftGrant, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grants, err := svc.store.GameGiftGrants().List(domain.GameGiftGrantFilter{ServerInstanceID: serverID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range grants {
|
||||
if err = svc.reconcileGameGiftGrant(&grants[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Slice(grants, func(i, j int) bool { return grants[i].CreatedAt.After(grants[j].CreatedAt) })
|
||||
return grants, nil
|
||||
}
|
||||
func validateGiftItems(version string, items []domain.GameGiftItem) error {
|
||||
if _, ok := domain.SCUMGiftCatalogForVersion(version); !ok {
|
||||
return validationError("SCUM game version has no verified gift item catalog")
|
||||
}
|
||||
if len(items) == 0 || len(items) > 8 {
|
||||
return validationError("gift requires 1 to 8 catalog items")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range items {
|
||||
def, ok := domain.SCUMGiftItemForVersion(version, item.CatalogItemKey)
|
||||
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity || item.Label != def.Label {
|
||||
return validationError("gift item is not valid for this SCUM version")
|
||||
}
|
||||
seen[item.CatalogItemKey] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func giftDeliveryPayload(grant domain.GameGiftGrant) map[string]any {
|
||||
items := make([]any, len(grant.Items))
|
||||
for i, item := range grant.Items {
|
||||
items[i] = map[string]any{"catalogItemKey": item.CatalogItemKey, "quantity": item.Quantity}
|
||||
}
|
||||
return map[string]any{"grantId": grant.ID, "playerId": grant.GamePlayerID, "items": items}
|
||||
}
|
||||
func (svc *CoreService) reconcileGameGiftGrant(grant *domain.GameGiftGrant) error {
|
||||
if grant.Status == domain.GameGiftGrantQueued {
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(grant.DeliveryCommandID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.State == domain.GameClientBridgeCommandFailed {
|
||||
grant.Status = domain.GameGiftGrantFailed
|
||||
grant.DeliverySummary = command.Result.Summary
|
||||
return svc.finishGiftGrant(grant)
|
||||
}
|
||||
if command.State == domain.GameClientBridgeCommandExpired || command.State == domain.GameClientBridgeCommandCancelled {
|
||||
grant.Status = domain.GameGiftGrantUnknown
|
||||
grant.DeliverySummary = command.Result.Summary
|
||||
return svc.finishGiftGrant(grant)
|
||||
}
|
||||
if command.State == domain.GameClientBridgeCommandSucceeded {
|
||||
grant.Status = domain.GameGiftGrantDelivered
|
||||
grant.DeliverySummary = command.Result.Summary
|
||||
instance, err := svc.store.ServerInstances().Get(grant.ServerInstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profile, ok := gameClientBridgeProfileKey(plugin)
|
||||
if !ok {
|
||||
return validationError("SCUM gift companion profile is unavailable")
|
||||
}
|
||||
notification, err := svc.queueGameClientBridgeCommand("component:gift-lifecycle", domain.GameClientBridgeQueueRequest{ServerInstanceID: grant.ServerInstanceID, PluginID: instance.PluginID, ProfileKey: profile, CommandType: domain.SCUMGiftNotificationCommandType, Payload: map[string]any{"playerId": grant.GamePlayerID, "message": grant.Notice}, IdempotencyKey: grant.ID + ":notify", Priority: 10, ExpiresAt: svc.now().Add(time.Minute)})
|
||||
if err != nil {
|
||||
grant.Status = domain.GameGiftGrantNotificationFailed
|
||||
grant.NotificationSummary = "targeted notification could not be queued"
|
||||
} else {
|
||||
grant.NotificationCommandID = notification.ID
|
||||
}
|
||||
return svc.finishGiftGrant(grant)
|
||||
}
|
||||
}
|
||||
if grant.Status == domain.GameGiftGrantDelivered && grant.NotificationCommandID != "" {
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(grant.NotificationCommandID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.State == domain.GameClientBridgeCommandFailed || command.State == domain.GameClientBridgeCommandExpired || command.State == domain.GameClientBridgeCommandCancelled {
|
||||
grant.Status = domain.GameGiftGrantNotificationFailed
|
||||
grant.NotificationSummary = command.Result.Summary
|
||||
return svc.finishGiftGrant(grant)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (svc *CoreService) finishGiftGrant(grant *domain.GameGiftGrant) error {
|
||||
stamp := svc.now()
|
||||
grant.UpdatedAt = stamp
|
||||
if grant.Status == domain.GameGiftGrantFailed || grant.Status == domain.GameGiftGrantUnknown || grant.Status == domain.GameGiftGrantNotificationFailed {
|
||||
grant.CompletedAt = stamp
|
||||
}
|
||||
if err := svc.store.GameGiftGrants().Update(*grant); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := svc.recordAuditEventWithID("component:gift-lifecycle", "game-gift.grant.result", "game-gift-grant", grant.ID, domain.AuditResultSuccess, "gift delivery lifecycle result recorded")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"browser.local/platform/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGameGiftGrantFreezesRevisionAndIsIdempotent(t *testing.T) {
|
||||
svc, session, player := gameGiftFixture(t, true)
|
||||
catalog, err := svc.SaveGameGiftCatalogForSession(session, "server-1", domain.GameGiftCatalogRequest{Name: "月光补给", GameVersion: "0.9.700.90357", Items: []domain.GameGiftItem{{CatalogItemKey: "bandage", Label: "绷带", Quantity: 2}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revision, err := svc.PublishGameGiftCatalogForSession(session, catalog.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, err := svc.RequestGameGiftGrantForSession(session, "server-1", domain.GameGiftGrantRequest{RevisionID: revision.ID, GamePlayerRecordID: player.ID, Notice: "请查收补给", IdempotencyKey: "gift-once"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate, err := svc.RequestGameGiftGrantForSession(session, "server-1", domain.GameGiftGrantRequest{RevisionID: revision.ID, GamePlayerRecordID: player.ID, Notice: "changed", IdempotencyKey: "gift-once"})
|
||||
if err != nil || duplicate.ID != grant.ID {
|
||||
t.Fatalf("idempotency=%+v err=%v", duplicate, err)
|
||||
}
|
||||
catalog.DraftItems[0].Quantity = 9
|
||||
if err = svc.store.GameGiftCatalogs().Update(catalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := svc.store.GameGiftGrants().Get(grant.ID)
|
||||
if stored.Items[0].Quantity != 2 || stored.PlayerDisplayName != "Moon" {
|
||||
t.Fatalf("grant was not frozen: %+v", stored)
|
||||
}
|
||||
if _, err = svc.SaveGameGiftCatalogForSession(session, "server-1", domain.GameGiftCatalogRequest{Name: "坏礼包", GameVersion: "0.9.700.90357", Items: []domain.GameGiftItem{{CatalogItemKey: "not-verified", Label: "bad", Quantity: 1}}}); err == nil {
|
||||
t.Fatal("invalid catalog item accepted")
|
||||
}
|
||||
}
|
||||
func TestGameGiftApprovalOfflineAndNotificationFailureAreSafe(t *testing.T) {
|
||||
offlineSvc, offlineSession, offlinePlayer := gameGiftFixture(t, false)
|
||||
grant := giftGrantForTest(t, offlineSvc, offlineSession, offlinePlayer)
|
||||
if _, err := offlineSvc.ApproveGameGiftGrantForSession(offlineSession, grant.ID); err == nil {
|
||||
t.Fatal("offline player was dispatched")
|
||||
}
|
||||
svc, session, player := gameGiftFixture(t, true)
|
||||
grant = giftGrantForTest(t, svc, session, player)
|
||||
approved, err := svc.ApproveGameGiftGrantForSession(session, grant.ID)
|
||||
if err != nil || approved.Status != domain.GameGiftGrantQueued {
|
||||
t.Fatalf("approve=%+v err=%v", approved, err)
|
||||
}
|
||||
claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim delivery=%+v err=%v", claimed, err)
|
||||
}
|
||||
if _, err = svc.completeGameClientBridgeCommand(bridgeComponent(), domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants, err := svc.ListGameGiftGrantsForSession(session, "server-1")
|
||||
if err != nil || grants[0].Status != domain.GameGiftGrantDelivered {
|
||||
t.Fatalf("delivery result=%+v err=%v", grants, err)
|
||||
}
|
||||
claimed, err = svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim notification=%+v err=%v", claimed, err)
|
||||
}
|
||||
if _, err = svc.completeGameClientBridgeCommand(bridgeComponent(), domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "chat unavailable"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants, err = svc.ListGameGiftGrantsForSession(session, "server-1")
|
||||
if err != nil || grants[0].Status != domain.GameGiftGrantNotificationFailed {
|
||||
t.Fatalf("notification failure=%+v err=%v", grants, err)
|
||||
}
|
||||
commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||
if len(commands) != 2 {
|
||||
t.Fatalf("notification failure redelivered item: %d commands", len(commands))
|
||||
}
|
||||
}
|
||||
func TestGameGiftUnknownIsTerminalAndNeverRetried(t *testing.T) {
|
||||
svc, session, player := gameGiftFixture(t, true)
|
||||
grant := giftGrantForTest(t, svc, session, player)
|
||||
approved, err := svc.ApproveGameGiftGrantForSession(session, grant.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command, _ := svc.store.GameClientBridgeCommands().Get(approved.DeliveryCommandID)
|
||||
command.State = domain.GameClientBridgeCommandExpired
|
||||
if err = svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, err := svc.ListGameGiftGrantsForSession(session, "server-1")
|
||||
if err != nil || values[0].Status != domain.GameGiftGrantUnknown {
|
||||
t.Fatalf("unknown=%+v err=%v", values, err)
|
||||
}
|
||||
commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("unknown result retried: %d", len(commands))
|
||||
}
|
||||
}
|
||||
func gameGiftFixture(t *testing.T, online bool) (*CoreService, string, domain.GamePlayer) {
|
||||
t.Helper()
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
plugin, _ := svc.store.GamePlugins().Get("game.scum")
|
||||
plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{Type: domain.SCUMRewardDeliverCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 120, MaxPayloadBytes: 4096}, {Type: domain.SCUMGiftNotificationCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 60, MaxPayloadBytes: 2048}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := domain.User{ID: "gift-admin", DisplayName: "Gift Admin", Email: "gift@example.test", Roles: []string{"platform-admin"}, Status: domain.UserStatusActive, PasswordHash: "secret", CreatedAt: *clock, UpdatedAt: *clock}
|
||||
if err := svc.store.Users().Create(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := svc.issueAuthSession(user, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player := domain.GamePlayer{ID: "gift-player", ServerInstanceID: "server-1", GamePlayerID: "steam-1", DisplayName: "Moon"}
|
||||
if err = svc.store.GamePlayers().Create(player); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if online {
|
||||
if err = svc.store.GamePlayerSessions().Create(domain.GamePlayerSession{ID: "gift-online", ServerInstanceID: "server-1", GamePlayerRecordID: player.ID, StartedAt: *clock}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return svc, auth.SessionID, player
|
||||
}
|
||||
func giftGrantForTest(t *testing.T, svc *CoreService, session string, player domain.GamePlayer) domain.GameGiftGrant {
|
||||
t.Helper()
|
||||
catalog, err := svc.SaveGameGiftCatalogForSession(session, "server-1", domain.GameGiftCatalogRequest{Name: "月光补给", GameVersion: "0.9.700.90357", Items: []domain.GameGiftItem{{CatalogItemKey: "bandage", Label: "绷带", Quantity: 2}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revision, err := svc.PublishGameGiftCatalogForSession(session, catalog.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, err := svc.RequestGameGiftGrantForSession(session, "server-1", domain.GameGiftGrantRequest{RevisionID: revision.ID, GamePlayerRecordID: player.ID, Notice: "请查收补给", IdempotencyKey: "request-" + catalog.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return grant
|
||||
}
|
||||
@@ -209,6 +209,13 @@ type Core interface {
|
||||
RequestGamePlayerStatePatchForSession(string, string, domain.GamePlayerStatePatchRequest) (domain.GamePlayerStatePatch, error)
|
||||
ApproveGamePlayerStatePatchForSession(string, string) (domain.GamePlayerStatePatch, error)
|
||||
ListGamePlayerStatePatchesForSession(string, string) ([]domain.GamePlayerStatePatch, error)
|
||||
ListGameGiftCatalogsForSession(string, string) ([]domain.GameGiftCatalog, error)
|
||||
SaveGameGiftCatalogForSession(string, string, domain.GameGiftCatalogRequest) (domain.GameGiftCatalog, error)
|
||||
PublishGameGiftCatalogForSession(string, string) (domain.GameGiftRevision, error)
|
||||
ListGameGiftRevisionsForSession(string, string) ([]domain.GameGiftRevision, error)
|
||||
RequestGameGiftGrantForSession(string, string, domain.GameGiftGrantRequest) (domain.GameGiftGrant, error)
|
||||
ApproveGameGiftGrantForSession(string, string) (domain.GameGiftGrant, error)
|
||||
ListGameGiftGrantsForSession(string, string) ([]domain.GameGiftGrant, error)
|
||||
CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
|
||||
GetAuditEvent(string) (domain.AuditEvent, error)
|
||||
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)
|
||||
|
||||
Reference in New Issue
Block a user