feat(scum): add versioned gift grants
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-28
|
||||
@@ -0,0 +1,3 @@
|
||||
# add-scum-versioned-gift-catalog
|
||||
|
||||
SCUM versioned gift catalog and directed delivery
|
||||
@@ -0,0 +1,47 @@
|
||||
## Context
|
||||
|
||||
The SCUM plugin already declares `reward.deliver`, and the prior game-player and player-state work owns server-local identity plus a durable approval/audit pattern. Neither establishes a platform-owned item catalog, immutable gift definition, or a reliable distinction between game delivery and player notification. This change adds those controls without extending browser access to raw game commands or connection material.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Persist server-version-fenced gift catalogs, revisions, and grants in model-first storage with memory/file/MySQL implementations.
|
||||
- Permit only validated item catalog references in a gift revision; freeze the revision contents and game-player identity at grant creation.
|
||||
- Require a platform administrator to approve delivery, dispatch only typed `reward.deliver`, and record delivery and notification outcomes separately.
|
||||
- Give the Chinese SCUM console shared-theme workflows for draft editing, preview, player selection, confirmation, approval, and readable histories.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Commerce, shops, payment, offline inventory/database editing, raw item codes or generator syntax in browser requests, arbitrary RCON text, bulk grants, map trails, auto-retry of unknown delivery, or auto-redelivery after notification failure.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Catalog ownership and version fencing.** Platform defines a small verified SCUM item catalog keyed by exact game version. A revision stores item-catalog keys, labels, quantities, and an item-catalog version; service validation rejects unknown versions, absent items, duplicate lines, and quantities beyond catalog bounds. This is safer than exposing game item IDs in page forms or accepting a client-provided command payload.
|
||||
|
||||
2. **Draft then immutable revision.** A `gift_catalog` carries editable metadata and a draft revision; publishing creates an immutable `gift_revision` with a monotonic revision number. A `gift_grant` copies the selected revision ID plus a deep-frozen item snapshot and game-player record/ID/display-name snapshot. Editing or publishing later revisions therefore cannot change a queued grant.
|
||||
|
||||
3. **Explicit approval and idempotency.** Any server-authorized operator creates a `pending-approval` grant with a required idempotency key. A platform administrator with the same server access revalidates target, revision, version catalog, and online eligibility before dispatch. The grant ID is the bridge idempotency key, and a duplicate create request returns the original grant rather than producing another command.
|
||||
|
||||
4. **Delivery and notification are separate terminal facts.** `reward.deliver` success becomes `delivered`; command failure becomes `failed`; expiry/cancellation/missing result becomes `unknown`. Only after a confirmed delivery does Platform queue the declared targeted notification command. A notification error produces `notification_failed` while preserving the delivery result. No terminal grant, especially `unknown` or `notification_failed`, is automatically retried or redelivered.
|
||||
|
||||
5. **No raw command or secret boundary crossing.** The frontend posts catalog keys, revision IDs, player record IDs, a bounded notice template, and idempotency key only. Platform composes typed bridge payloads from durable records, and browser/API projections omit run credentials, RCON strings, item codes, raw bridge payloads, and host paths.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [A server version has no verified item catalog] → Editing, publication, and grant approval are rejected with an explicit unsupported-version result.
|
||||
- [A selected player is offline] → Grant approval is denied before delivery; the pending record remains auditable and does not dispatch.
|
||||
- [The bridge reports delivery success but notification fails] → Preserve `notification_failed` and do not attempt another item delivery.
|
||||
- [The bridge result is lost] → Persist terminal `unknown`, do not infer success, and require a deliberate later operator workflow rather than automatic retry.
|
||||
- [Two callers repeat a request] → Enforce a server/requester/idempotency-key uniqueness check and reuse the prior grant.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add model-first records and repositories, then wire file/MySQL snapshots with no existing data migration required.
|
||||
2. Deploy typed plugin catalog/delivery/notification schemas and Platform service/API support together; versions without a verified item catalog stay unsupported.
|
||||
3. Deploy the console after APIs are available; hide all raw command details behind the API contracts.
|
||||
4. Roll back by disabling the console/command declarations; immutable revision and grant history remains readable and no automatic replay is performed.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The initial SCUM version and item list are deliberately small and can be extended through a future reviewed catalog revision.
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
SCUM operators can invoke a one-off reward bridge command but cannot safely curate reusable gifts, freeze what was approved, or establish whether a targeted player received an item and its notification exactly once. A version-fenced gift workflow turns that raw capability into an auditable, least-privilege server-management operation.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add editable SCUM gift catalogs with immutable published revisions and items sourced only from the currently verified server-version item catalog.
|
||||
- Add durable, approval-gated targeted gift grants that freeze the selected revision and game-player identity before dispatching the declared `reward.deliver` command.
|
||||
- Track gift delivery and directed-notification results independently, including visible notification failures and terminal unknown delivery outcomes that are never retried automatically.
|
||||
- Add safe Platform APIs, SCUM plugin schemas, and Chinese console workflows for catalog editing, revision preview, player selection, confirmation, approvals, and grant history.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `scum-versioned-gift-catalog`: Version-scoped SCUM gift definition, immutable revisions, approval-gated directed delivery, and safe operational history.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affects Platform domain/model/repository/service/validation/API layers and durable metadata persistence.
|
||||
- Extends the SCUM plugin's typed client-bridge declarations and schemas, plus Platform Web API contracts and SCUM player console.
|
||||
- Does not introduce commerce, direct RCON/game connections, arbitrary item/generator commands, raw game credentials, or raw database writes.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Version-fenced gift catalog and revisions
|
||||
The system SHALL let authorized SCUM server operators create and edit gift catalog drafts whose items reference only the Platform-verified item catalog for the server's exact game version. Publishing SHALL create an immutable, monotonically versioned revision, and the system MUST reject unknown versions, stale or invalid items, duplicate items, and out-of-range quantities.
|
||||
|
||||
#### Scenario: Publish validated draft
|
||||
- **WHEN** an authorized operator publishes a draft containing only verified items for the current SCUM version
|
||||
- **THEN** the system creates an immutable revision with its frozen item list and exposes it for preview and granting
|
||||
|
||||
#### Scenario: Reject invalid item
|
||||
- **WHEN** a draft references an item absent from the verified item catalog for the selected SCUM version
|
||||
- **THEN** the system rejects the change without creating a publishable revision
|
||||
|
||||
### Requirement: Frozen and idempotent targeted grant
|
||||
The system SHALL create a durable grant from a selected published revision and local game-player record, freezing the revision contents and target identity before approval. A repeated request with the same scoped idempotency key MUST return the original grant and MUST NOT create another delivery command.
|
||||
|
||||
#### Scenario: Later catalog edit does not alter a grant
|
||||
- **WHEN** an operator creates a grant and subsequently edits or publishes the gift catalog
|
||||
- **THEN** the existing grant retains its original revision, item snapshot, and target identity snapshot
|
||||
|
||||
#### Scenario: Duplicate grant request
|
||||
- **WHEN** a requester submits the same target/revision grant request again with the same idempotency key
|
||||
- **THEN** the system returns the original grant and queues no duplicate delivery
|
||||
|
||||
### Requirement: Approved bounded game delivery
|
||||
The system SHALL require a platform administrator with target-server access to approve a pending grant and SHALL dispatch only the declared typed `reward.deliver` bridge command assembled from the frozen grant. Approval MUST reject offline targets, invalidated catalog/revision data, and unauthorized callers.
|
||||
|
||||
#### Scenario: Offline player is not dispatched
|
||||
- **WHEN** a platform administrator attempts to approve a grant for a player not present in the current online-player snapshot
|
||||
- **THEN** approval is rejected and no delivery command is queued
|
||||
|
||||
#### Scenario: Authorized approval queues delivery
|
||||
- **WHEN** a platform administrator approves a valid grant for an online local game player
|
||||
- **THEN** the system records the approver and queues one typed delivery command using the grant identity as its idempotency key
|
||||
|
||||
### Requirement: Delivery and notification lifecycle safety
|
||||
The system SHALL expose queued, delivered, notification_failed, failed, and unknown grant outcomes with readable audit evidence. A succeeded delivery followed by notification failure MUST remain visible as `notification_failed`; unknown delivery outcomes and notification failures MUST NOT automatically retry or redeliver items.
|
||||
|
||||
#### Scenario: Notification failure after delivered item
|
||||
- **WHEN** the delivery command succeeds and the targeted-notification command fails
|
||||
- **THEN** the grant is retained as `notification_failed` with the successful delivery evidence and failed notification evidence
|
||||
|
||||
#### Scenario: Unknown delivery is terminal
|
||||
- **WHEN** a queued delivery command expires, is cancelled, or has no conclusive result
|
||||
- **THEN** the grant becomes `unknown` and the system queues neither a retry nor another item delivery
|
||||
|
||||
### Requirement: Safe console and API projections
|
||||
The system SHALL provide Chinese shared-console workflows for draft/version editing, item preview, local player selection, grant confirmation, approval, and history. Browser requests and responses MUST NOT contain raw game item codes, generator commands, arbitrary RCON text, run credentials, host paths, or direct game connection data.
|
||||
|
||||
#### Scenario: Safe grant submission
|
||||
- **WHEN** an operator confirms a gift grant in the console
|
||||
- **THEN** the browser submits only bounded catalog/revision, player-record, notice, and idempotency references and renders the returned readable lifecycle record
|
||||
@@ -0,0 +1,21 @@
|
||||
## 1. Versioned catalog and grant lifecycle
|
||||
|
||||
- [x] 1.1 Add verified SCUM item catalog, gift catalog/revision/grant domain and model types, repositories, migrations, and memory/file/MySQL persistence.
|
||||
- [x] 1.2 Implement validation and service transitions for editable drafts, immutable revisions, frozen/idempotent grants, server/player ownership, online eligibility, and platform-admin approval/audit.
|
||||
- [x] 1.3 Reconcile typed delivery and targeted notification results without automatic replay; retain queued, delivered, notification_failed, failed, and unknown outcomes.
|
||||
- [x] 1.4 Add backend tests for frozen revisions, item/version validation, duplicate idempotency, offline targets, authorization/approval, notification failure, unknown results, and safe projections.
|
||||
|
||||
## 2. API and plugin contract
|
||||
|
||||
- [x] 2.1 Add named safe DTOs, authorized handlers/routes, and API tests for catalogs, revisions, grants, approval, and histories.
|
||||
- [x] 2.2 Add SCUM item-catalog, reward-delivery, and targeted-notification typed bridge schemas/declarations and plugin validation tests.
|
||||
|
||||
## 3. SCUM console
|
||||
|
||||
- [x] 3.1 Add typed frontend API/contracts and shared-console Chinese catalog, revision preview, player selection, confirmation, approval, and history workflows.
|
||||
- [x] 3.2 Add frontend tests for readable status/result rendering and absence of raw command/item/credential leakage.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 Run strict OpenSpec validation, focused backend, plugin, and frontend test suites, plus `scripts/check-structure.sh`.
|
||||
- [x] 4.2 Stage only this task's files, commit on `main`, and push the configured remote.
|
||||
@@ -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)
|
||||
|
||||
@@ -54,6 +54,14 @@ import type {
|
||||
GamePlayerStatePatchRequest,
|
||||
GamePlayerStatePatchResponse,
|
||||
GamePlayerStateResponse,
|
||||
GameGiftCatalogListResponse,
|
||||
GameGiftCatalogRequest,
|
||||
GameGiftCatalogResponse,
|
||||
GameGiftRevisionListResponse,
|
||||
GameGiftRevisionResponse,
|
||||
GameGiftGrantListResponse,
|
||||
GameGiftGrantRequest,
|
||||
GameGiftGrantResponse,
|
||||
HealthResponse,
|
||||
JobCreateRequest,
|
||||
JobListResponse,
|
||||
@@ -593,6 +601,13 @@ export class PlatformApiClient {
|
||||
async listGamePlayerStatePatches(serverInstanceId: string, playerId: string): Promise<GamePlayerStatePatchListResponse> { return this.request<GamePlayerStatePatchListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`); }
|
||||
async requestGamePlayerStatePatch(serverInstanceId: string, playerId: string, request: GamePlayerStatePatchRequest): Promise<GamePlayerStatePatchResponse> { return this.request<GamePlayerStatePatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`, { method: "POST", body: request }); }
|
||||
async approveGamePlayerStatePatch(serverInstanceId: string, playerId: string, patchId: string): Promise<GamePlayerStatePatchResponse> { return this.request<GamePlayerStatePatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches/${encodeURIComponent(patchId)}/approve`, { method: "POST" }); }
|
||||
async listGameGiftCatalogs(serverInstanceId: string): Promise<GameGiftCatalogListResponse> { return this.request<GameGiftCatalogListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`); }
|
||||
async saveGameGiftCatalog(serverInstanceId: string, request: GameGiftCatalogRequest): Promise<GameGiftCatalogResponse> { return this.request<GameGiftCatalogResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`, { method: "POST", body: request }); }
|
||||
async publishGameGiftCatalog(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionResponse> { return this.request<GameGiftRevisionResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/publish`, { method: "POST" }); }
|
||||
async listGameGiftRevisions(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionListResponse> { return this.request<GameGiftRevisionListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/revisions`); }
|
||||
async listGameGiftGrants(serverInstanceId: string): Promise<GameGiftGrantListResponse> { return this.request<GameGiftGrantListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`); }
|
||||
async requestGameGiftGrant(serverInstanceId: string, request: GameGiftGrantRequest): Promise<GameGiftGrantResponse> { return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`, { method: "POST", body: request }); }
|
||||
async approveGameGiftGrant(serverInstanceId: string, grantId: string): Promise<GameGiftGrantResponse> { return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants/${encodeURIComponent(grantId)}/approve`, { method: "POST" }); }
|
||||
|
||||
async getBackup(id: string): Promise<BackupResponse> {
|
||||
return this.request<BackupResponse>(`/backups/${encodeURIComponent(id)}`);
|
||||
|
||||
@@ -310,6 +310,16 @@ export interface GamePlayerStatePatchChangeRequest { fieldKey: string; before: n
|
||||
export interface GamePlayerStatePatchRequest { gameVersion: string; expectedStateVersion: string; safetyWindow: string; changes: GamePlayerStatePatchChangeRequest[]; reason: string; }
|
||||
export interface GamePlayerStatePatchResponse { id: string; gameVersion: string; expectedStateVersion: string; changes: GamePlayerStatePatchChangeRequest[]; reason: string; requesterId: string; approverId?: string; status: "pending-approval" | "queued" | "execution-failed" | "execution-unknown" | "confirmation-failed" | "confirmed"; bridgeCommandId?: string; executionSummary?: string; confirmedStateVersion?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
|
||||
export interface GamePlayerStatePatchListResponse { items: GamePlayerStatePatchResponse[]; }
|
||||
export interface GameGiftItemRequest { catalogItemKey: string; quantity: number; }
|
||||
export interface GameGiftItemResponse extends GameGiftItemRequest { label: string; }
|
||||
export interface GameGiftCatalogRequest { id?: string; name: string; gameVersion: string; items: GameGiftItemRequest[]; }
|
||||
export interface GameGiftCatalogResponse { id: string; name: string; gameVersion: string; draftItems: GameGiftItemResponse[]; latestRevisionId?: string; updatedAt: string; }
|
||||
export interface GameGiftCatalogListResponse { items: GameGiftCatalogResponse[]; }
|
||||
export interface GameGiftRevisionResponse { id: string; catalogId: string; revision: number; gameVersion: string; items: GameGiftItemResponse[]; publishedBy: string; publishedAt: string; }
|
||||
export interface GameGiftRevisionListResponse { items: GameGiftRevisionResponse[]; }
|
||||
export interface GameGiftGrantRequest { revisionId: string; gamePlayerRecordId: string; notice: string; idempotencyKey: string; }
|
||||
export interface GameGiftGrantResponse { id: string; revisionId: string; revisionNumber: number; gameVersion: string; items: GameGiftItemResponse[]; gamePlayerRecordId: string; playerDisplayName: string; notice: string; requesterId: string; approverId?: string; status: "pending-approval" | "queued" | "delivered" | "notification_failed" | "failed" | "unknown"; deliverySummary?: string; notificationSummary?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
|
||||
export interface GameGiftGrantListResponse { items: GameGiftGrantResponse[]; }
|
||||
|
||||
export interface RuntimeTransportProfileResponse {
|
||||
key: string;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import source from "./GameGiftCatalogPanel.tsx?raw";
|
||||
|
||||
describe("GameGiftCatalogPanel", () => {
|
||||
it("keeps lifecycle state readable and avoids raw game command surfaces", () => {
|
||||
expect(source).toContain("版本化礼包目录与定向发放");
|
||||
expect(source).toContain("通知失败");
|
||||
expect(source).toContain("绝不自动重试");
|
||||
expect(source).not.toContain("rawCommand");
|
||||
expect(source).not.toContain("sendSourceRCONCommand");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CheckCircle2, Gift, RefreshCw, Send } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GameGiftCatalogResponse, GameGiftGrantResponse, GameGiftRevisionResponse, GamePlayerResponse } from "../api/types";
|
||||
import { ErrorState, LoadingState } from "./StateViews";
|
||||
|
||||
type Ready = { catalogs: GameGiftCatalogResponse[]; revisions: GameGiftRevisionResponse[]; grants: GameGiftGrantResponse[]; players: GamePlayerResponse[]; selectedCatalog?: string; selectedRevision?: string; selectedPlayer?: string; name: string; notice: string; item: "bandage" | "water-bottle" | "improvised-spear"; quantity: number; note?: string };
|
||||
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; value: Ready };
|
||||
const itemLabels = { bandage: "绷带", "water-bottle": "饮用水", "improvised-spear": "简易长矛" } as const;
|
||||
|
||||
export function GameGiftCatalogPanel({ serverInstanceId }: { serverInstanceId: string }) {
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const load = useCallback(async () => { setState({ status: "loading" }); try { const [catalogs, grants, players] = await Promise.all([platformApiClient.listGameGiftCatalogs(serverInstanceId), platformApiClient.listGameGiftGrants(serverInstanceId), platformApiClient.listGamePlayers(serverInstanceId)]); const selectedCatalog = catalogs.items[0]?.id; const revisions = selectedCatalog ? await platformApiClient.listGameGiftRevisions(serverInstanceId, selectedCatalog) : { items: [] }; setState({ status: "ready", value: { catalogs: catalogs.items, revisions: revisions.items, grants: grants.items, players: players.items, selectedCatalog, selectedRevision: revisions.items[0]?.id, selectedPlayer: players.items[0]?.id, name: "新手补给", notice: "已向你发放运营礼包,请查收。", item: "bandage", quantity: 2 } }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "礼包目录读取失败" }); } }, [serverInstanceId]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
const edit = (patch: Partial<Ready>) => setState((current) => current.status === "ready" ? { ...current, value: { ...current.value, ...patch } } : current);
|
||||
async function createDraft() { if (state.status !== "ready") return; const v = state.value; try { const saved = await platformApiClient.saveGameGiftCatalog(serverInstanceId, { id: v.selectedCatalog, name: v.name, gameVersion: "0.9.700.90357", items: [{ catalogItemKey: v.item, quantity: v.quantity }] }); edit({ catalogs: [saved, ...v.catalogs.filter((catalog) => catalog.id !== saved.id)], selectedCatalog: saved.id, revisions: [], selectedRevision: undefined, note: "草稿已保存;发布后才可定向发放。" }); } catch (error) { edit({ note: error instanceof Error ? error.message : "草稿保存失败" }); } }
|
||||
async function publish() { if (state.status !== "ready" || !state.value.selectedCatalog) return; try { const revision = await platformApiClient.publishGameGiftCatalog(serverInstanceId, state.value.selectedCatalog); edit({ revisions: [revision, ...state.value.revisions], selectedRevision: revision.id, note: "已发布不可变版本,可预览并选择目标玩家。" }); } catch (error) { edit({ note: error instanceof Error ? error.message : "发布失败" }); } }
|
||||
async function requestGrant() { if (state.status !== "ready") return; const v=state.value; if (!v.selectedRevision || !v.selectedPlayer) return; try { const grant=await platformApiClient.requestGameGiftGrant(serverInstanceId,{revisionId:v.selectedRevision,gamePlayerRecordId:v.selectedPlayer,notice:v.notice,idempotencyKey:`gift-${v.selectedRevision}-${v.selectedPlayer}`}); edit({grants:[grant,...v.grants],note:"发放申请已冻结礼包版本和目标身份,等待平台管理员审批。"}); } catch(error){edit({note:error instanceof Error?error.message:"发放申请失败"});} }
|
||||
async function approve(grant: GameGiftGrantResponse) { if(state.status!=="ready")return;try{const next=await platformApiClient.approveGameGiftGrant(serverInstanceId,grant.id);edit({grants:state.value.grants.map((item)=>item.id===next.id?next:item),note:"已通过受控游戏通道排队发放。"});}catch(error){edit({note:error instanceof Error?error.message:"审批失败"});} }
|
||||
if(state.status==="loading")return <LoadingState label="正在加载版本化 SCUM 礼包目录…"/>;
|
||||
if(state.status==="error")return <ErrorState title="礼包目录不可用" reason={state.reason} onRetry={()=>void load()}/>;
|
||||
const v=state.value; const selectedRevision=v.revisions.find((item)=>item.id===v.selectedRevision);
|
||||
return <section className="console-panel" aria-label="SCUM 版本化礼包目录与定向发放"><div className="panel-header"><div><h2><Gift size={16}/> 版本化礼包目录与定向发放</h2><p className="provider-id">仅可选择此 SCUM 版本已验证的目录项;网页不提交游戏生成指令、物品代码或 RCON 文本。</p></div><button className="icon-command" type="button" onClick={()=>void load()}><RefreshCw size={14}/><span>刷新</span></button></div><div className="console-module"><h3>礼包草稿与版本</h3><div className="console-row-list"><label className="console-row"><strong>礼包名称</strong><input value={v.name} maxLength={80} onChange={(event)=>edit({name:event.target.value})}/></label><label className="console-row"><strong>已验证物品</strong><select value={v.item} onChange={(event)=>edit({item:event.target.value as Ready["item"]})}>{Object.entries(itemLabels).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></label><label className="console-row"><strong>数量</strong><input type="number" min={1} max={20} value={v.quantity} onChange={(event)=>edit({quantity:Number(event.target.value)})}/></label></div><div className="action-strip"><button className="command-button" type="button" onClick={()=>void createDraft()}>保存草稿</button><select aria-label="选择礼包草稿" value={v.selectedCatalog||""} onChange={async(event)=>{const catalogId=event.target.value;const response=await platformApiClient.listGameGiftRevisions(serverInstanceId,catalogId);edit({selectedCatalog:catalogId,revisions:response.items,selectedRevision:response.items[0]?.id});}}><option value="">选择草稿</option>{v.catalogs.map((catalog)=><option key={catalog.id} value={catalog.id}>{catalog.name} · {catalog.gameVersion}</option>)}</select><button className="primary-command" type="button" disabled={!v.selectedCatalog} onClick={()=>void publish()}>发布不可变版本</button></div></div><div className="console-module"><h3>预览、玩家选择与发放确认</h3><p className="page-status">{selectedRevision ? `版本 #${selectedRevision.revision}:${selectedRevision.items.map((item)=>`${item.label} ×${item.quantity}`).join("、")}` : "请先发布一个礼包版本。"}</p><div className="console-row-list"><label className="console-row"><strong>目标本地玩家</strong><select value={v.selectedPlayer||""} onChange={(event)=>edit({selectedPlayer:event.target.value})}>{v.players.map((player)=><option key={player.id} value={player.id}>{player.displayName}({player.gamePlayerId})</option>)}</select></label><label className="console-row"><strong>定向通知</strong><input maxLength={200} value={v.notice} onChange={(event)=>edit({notice:event.target.value})}/></label></div><button className="primary-command" type="button" disabled={!selectedRevision||!v.selectedPlayer} onClick={()=>void requestGrant()}><Send size={14}/>确认并提交发放申请</button></div>{v.note&&<p className="page-status">{v.note}</p>}<div className="console-record-list">{v.grants.map((grant)=><div className="console-record" key={grant.id}><strong>{grantStatus(grant.status)}</strong><span>{grant.playerDisplayName} · {grant.items.map((item)=>`${item.label} ×${item.quantity}`).join("、")}</span><small>版本 #{grant.revisionNumber} · {grant.deliverySummary||"等待投递结果"}{grant.notificationSummary?` · 通知:${grant.notificationSummary}`:""}</small>{grant.status==="pending-approval"&&<button className="icon-command" type="button" onClick={()=>void approve(grant)}><CheckCircle2 size={14}/><span>平台管理员审批</span></button>}</div>)}</div></section>;
|
||||
}
|
||||
function grantStatus(status: GameGiftGrantResponse["status"]) { return ({"pending-approval":"等待平台管理员审批",queued:"已排队投递",delivered:"物品已投递,通知处理中",notification_failed:"物品已投递,定向通知失败",failed:"投递失败",unknown:"投递结果未知,绝不自动重试"})[status]; }
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GamePlayerProfileResponse, GamePlayerResponse, GamePlayerStatePatchResponse, GamePlayerStateResponse } from "../api/types";
|
||||
import { ErrorState, LoadingState } from "./StateViews";
|
||||
import { GameGiftCatalogPanel } from "./GameGiftCatalogPanel";
|
||||
|
||||
type Selected = { profile: GamePlayerProfileResponse; state?: GamePlayerStateResponse; patches: GamePlayerStatePatchResponse[]; reason: string; after: Record<string, string>; note?: string };
|
||||
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; players: GamePlayerResponse[]; selected?: Selected };
|
||||
@@ -18,7 +19,7 @@ export function GamePlayerIntelligencePanel({ serverInstanceId }: { serverInstan
|
||||
if (state.status === "loading") return <LoadingState label="正在加载本地游戏玩家档案…" />;
|
||||
if (state.status === "error") return <ErrorState title="玩家档案不可用" reason={state.reason} onRetry={() => void load()} />;
|
||||
const selected = state.selected;
|
||||
return <section className="console-panel" aria-label="SCUM 本地玩家档案、风险信号与受控属性修改"><div className="panel-header"><div><h2><UsersRound size={16} /> 本地玩家档案与登录轨迹</h2><p className="provider-id">游戏身份独立于平台账号;网络数据仅用于服务器内不可逆关联,绝不展示或持久化原始值。</p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span>刷新</span></button></div><div className="console-record-list">{state.players.length ? state.players.map((player) => <button type="button" className="console-record" key={player.id} onClick={() => void select(player)}><strong>{player.displayName}</strong><span>游戏 ID:{player.gamePlayerId}</span><small>最近登录:{formatTime(player.lastSeenAt)}</small></button>) : <p className="page-status">暂无已投影的成功登录记录。</p>}</div>{selected && <PlayerDetail selected={selected} onReason={(reason) => setSelected(setState, reason)} onAfter={(key, value) => setAfter(setState, key, value)} onSubmit={() => void submitPatch()} onApprove={(patch) => void approvePatch(patch)} />}</section>;
|
||||
return <><section className="console-panel" aria-label="SCUM 本地玩家档案、风险信号与受控属性修改"><div className="panel-header"><div><h2><UsersRound size={16} /> 本地玩家档案与登录轨迹</h2><p className="provider-id">游戏身份独立于平台账号;网络数据仅用于服务器内不可逆关联,绝不展示或持久化原始值。</p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span>刷新</span></button></div><div className="console-record-list">{state.players.length ? state.players.map((player) => <button type="button" className="console-record" key={player.id} onClick={() => void select(player)}><strong>{player.displayName}</strong><span>游戏 ID:{player.gamePlayerId}</span><small>最近登录:{formatTime(player.lastSeenAt)}</small></button>) : <p className="page-status">暂无已投影的成功登录记录。</p>}</div>{selected && <PlayerDetail selected={selected} onReason={(reason) => setSelected(setState, reason)} onAfter={(key, value) => setAfter(setState, key, value)} onSubmit={() => void submitPatch()} onApprove={(patch) => void approvePatch(patch)} />}</section><GameGiftCatalogPanel serverInstanceId={serverInstanceId}/></>;
|
||||
}
|
||||
|
||||
function PlayerDetail({ selected, onReason, onAfter, onSubmit, onApprove }: { selected: Selected; onReason: (value: string) => void; onAfter: (key: string, value: string) => void; onSubmit: () => void; onApprove: (patch: GamePlayerStatePatchResponse) => void }) {
|
||||
|
||||
@@ -140,6 +140,16 @@
|
||||
"timeoutSeconds": 60,
|
||||
"maxPayloadBytes": 4096
|
||||
},
|
||||
{
|
||||
"type": "player.notify",
|
||||
"title": "Notify SCUM player about approved gift",
|
||||
"permission": "server.game-client.command",
|
||||
"approvalLevel": "operator",
|
||||
"payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/player-notify.result.schema.json",
|
||||
"timeoutSeconds": 60,
|
||||
"maxPayloadBytes": 2048
|
||||
},
|
||||
{
|
||||
"type": "event.start",
|
||||
"title": "Start SCUM event",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerGiftNotificationPayload",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "message"],
|
||||
"properties": {
|
||||
"playerId": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"message": { "type": "string", "minLength": 1, "maxLength": 200 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerGiftNotificationResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["accepted"],
|
||||
"properties": { "accepted": { "type": "boolean" }, "message": { "type": "string", "maxLength": 200 } }
|
||||
}
|
||||
+15
-11
@@ -3,27 +3,31 @@
|
||||
"title": "SCUMRewardDeliverPayload",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "itemId", "quantity"],
|
||||
"required": ["grantId", "playerId", "items"],
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"maxLength": 96,
|
||||
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
|
||||
},
|
||||
"itemId": {
|
||||
"grantId": {
|
||||
"type": "string",
|
||||
"maxLength": 96,
|
||||
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
"items": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["catalogItemKey", "quantity"],
|
||||
"properties": {
|
||||
"catalogItemKey": { "type": "string", "maxLength": 64, "pattern": "^[a-z0-9-]{1,64}$" },
|
||||
"quantity": { "type": "integer", "minimum": 1, "maximum": 100 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user