feat: add controlled scum player state patches
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-28
|
||||
goal: Provide safe authorized SCUM player attribute updates through the declared
|
||||
game-client bridge.
|
||||
@@ -0,0 +1,3 @@
|
||||
# add-scum-player-state-patch
|
||||
|
||||
SCUM player detail and approved version-scoped attribute patching
|
||||
@@ -0,0 +1,42 @@
|
||||
## Context
|
||||
|
||||
The prior `scum-game-player-intelligence` change owns server-local player identity, sessions, risk signals, and access control. The existing game-client bridge supplies declared commands, approval states, leases, audit references, and result fencing, but it does not itself constrain individual game-state fields or maintain a readable player-change history.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Permit only catalogued SCUM skills and character attributes for an explicitly declared server game version.
|
||||
- Verify player/server ownership, current state version, companion availability, declared maintenance/online safety state, and platform-admin approval before dispatch.
|
||||
- Record before/after value, reason, requester, approver, bridge command result, and read-after-write confirmation as an immutable patch audit.
|
||||
- Prevent unknown versions, fields, out-of-range values, stale versions, unsafe execution windows, and unknown/failed execution from being represented as applied.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- SQL or database access, raw JSON/INI write APIs, host paths, direct Run/game connections, OCR/input automation, player identity/risk-signal edits, bans, gifts, or map trails.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Catalog at the platform boundary.** `SCUMPlayerStateCatalog` maps a declared server game version to a small list of field keys and numeric ranges. No caller or plugin payload can introduce fields dynamically. The initial exact version is `0.9.700.90357` and supports `skills.running` and `attributes.strength` in range 0–10.
|
||||
|
||||
2. **Use a dedicated typed bridge operation.** The manifest declares only `game-state.patch`, with a schema requiring target player ID, game version, expected state version, safety-window token, reason, and a list of catalogued changes. The result carries a bounded per-field outcome and an immediate confirmed state version; it contains no raw storage/database material.
|
||||
|
||||
3. **Two-stage platform-admin approval.** A server-authorized requester creates a durable patch record in `pending-approval`. A platform admin approves it after revalidating authorization, catalog, snapshot version, and safety window. Approval queues the bridge command and remains auditable; a requester who is also platform admin still creates then approves the explicit record.
|
||||
|
||||
4. **Snapshot/state fencing and confirmation.** A current `player.state` snapshot is the source of before values and its `stateVersion` is copied into the requested patch. The companion must reject mismatches, apply only declared fields in a verified maintenance/offline safety window, then read state back before returning success. Platform marks a record `confirmed` only when each returned value exactly equals its requested value and the returned state version advances. Failed and unknown results stay readable terminal audit states.
|
||||
|
||||
5. **Ownership is revalidated at every transition.** Player record lookup checks server ownership before viewing, creating, approving, or reading a patch. The bridge command is scoped to the same server plugin and profile; the generic queue cannot be used as an alternate raw patch entry point because the dedicated service owns field/snapshot validation and records the audit before dispatch.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Companion has no exact version/state snapshot] → Disable the form and return a readable unsupported/unknown-version result.
|
||||
- [State changes between snapshot and execution] → The expected state version causes the companion to reject; record the conflict without claiming application.
|
||||
- [Companion times out or result is missing] → Keep the record `execution-unknown`; do not infer a write, and require a later confirmation read.
|
||||
- [Maintenance state becomes unsafe] → Approval and companion both reject dispatch; no write is attempted.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add model-first patch records, memory/file/MySQL repositories, and immutable transition helpers.
|
||||
2. Deploy the manifest schemas and companion version declaration; no version is implicitly supported.
|
||||
3. Deploy the API and console. Existing player profiles retain all read-only intelligence behavior.
|
||||
4. Roll back by disabling the command declaration and form; historical audit records remain readable.
|
||||
@@ -0,0 +1,22 @@
|
||||
## Why
|
||||
|
||||
SCUM player intelligence currently presents useful local identity and risk context, but administrators cannot safely correct the limited in-game state that the installed server version explicitly supports. A controlled patch workflow is required so changes remain reviewable, version-fenced, approved, and executed only through the companion command channel.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a SCUM version-scoped player-state field catalog for supported skills and character attributes only.
|
||||
- Add player-state read snapshots and an approved `game-state.patch` bridge operation with optimistic state-version checks, safe execution-window checks, and confirmation reads.
|
||||
- Persist an immutable patch audit record containing requested old/new values, reason, requester/approver, command result, and confirmation status.
|
||||
- Add authorized API contracts and Chinese console controls that show the editable diff, approval state, readable outcome, and player context.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `scum-player-state-patch`: Version-fenced, approved SCUM player skill and character-attribute changes via the game-client bridge.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affects Platform player domain/models/repositories/services/validation/API and durable metadata storage.
|
||||
- Extends the SCUM manifest with a bounded patch command and typed schemas, plus Platform Web contracts and console records.
|
||||
- Does not permit SQL, raw configuration/JSON/INI writing, direct game database access, unbounded state editing, automatic moderation, gifts, or map trails.
|
||||
@@ -0,0 +1,48 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Version-scoped player state catalog
|
||||
The system SHALL allow SCUM player state patches only for a declared exact game version and an explicit catalog of skill/attribute fields with numeric ranges.
|
||||
|
||||
#### Scenario: Unknown server version
|
||||
- **WHEN** an administrator requests a patch and the current server state reports an unknown game version
|
||||
- **THEN** the system SHALL disable the patch request and SHALL not queue a bridge command
|
||||
|
||||
#### Scenario: Out-of-range or unknown field
|
||||
- **WHEN** a request contains a field absent from the version catalog or a value outside its range
|
||||
- **THEN** the system SHALL reject the request before an audit approval or game-side command is created
|
||||
|
||||
### Requirement: Authorized and safe patch request
|
||||
The system SHALL ensure that the game player belongs to the target server, the requester has server access, the expected player-state version matches the current snapshot, and the snapshot declares a verified maintenance/offline safety window before creating a patch record.
|
||||
|
||||
#### Scenario: Stale player state
|
||||
- **WHEN** a patch carries an expected state version different from the current player-state snapshot
|
||||
- **THEN** the system SHALL reject it as a conflict and SHALL not dispatch a patch command
|
||||
|
||||
#### Scenario: Online server without a safe window
|
||||
- **WHEN** a state snapshot indicates the target player is online or maintenance is not verified
|
||||
- **THEN** the system SHALL reject the request with a readable safety status
|
||||
|
||||
### Requirement: Explicit administrator approval and immutable audit
|
||||
The system SHALL persist each accepted request with field-level before/after values, reason, requester, approval state, approver, and execution status. Only a platform administrator with server access MAY approve dispatch.
|
||||
|
||||
#### Scenario: Non-admin approval
|
||||
- **WHEN** a server-authorized non-platform-admin attempts to approve a pending patch
|
||||
- **THEN** the system SHALL deny approval and SHALL leave the patch pending
|
||||
|
||||
### Requirement: Typed game-state patch execution and confirmation
|
||||
The system SHALL dispatch only the declared `game-state.patch` command through the game-client bridge. A successful record SHALL require a typed per-field result and a read-after-write confirmation whose values equal the requested values and whose state version advances.
|
||||
|
||||
#### Scenario: Companion reports a failure
|
||||
- **WHEN** the companion returns a failed patch command result
|
||||
- **THEN** the patch record SHALL be terminal `execution-failed`, preserve the readable result summary, and SHALL not be reported as applied
|
||||
|
||||
#### Scenario: Result is missing or unconfirmable
|
||||
- **WHEN** the command expires, is cancelled, returns malformed state, or cannot confirm the requested values
|
||||
- **THEN** the record SHALL retain an explicit `execution-unknown` or `confirmation-failed` status and SHALL not be reported as applied
|
||||
|
||||
### Requirement: Readable SCUM console workflow
|
||||
The SCUM player console SHALL show player detail context and provide a Chinese patch form only for supported safe states. It SHALL show a textual field diff, reason, approval status, executor result, and confirmation status without relying only on color.
|
||||
|
||||
#### Scenario: Pending approval
|
||||
- **WHEN** a patch is awaiting approval
|
||||
- **THEN** the console SHALL label it as awaiting platform-administrator approval and display the old/new values and requester reason
|
||||
@@ -0,0 +1,21 @@
|
||||
## 1. Contracts, models, and persistence
|
||||
|
||||
- [x] 1.1 Add version-scoped SCUM state catalog, player-state snapshot/patch domain types, audit model/repositories/migration/store implementations, and field/safety validation.
|
||||
- [x] 1.2 Add service state machine for request, platform-admin approval, bridge-result reconciliation, and confirmation-read semantics with immutable audit evidence.
|
||||
- [x] 1.3 Add focused backend tests for unknown version, invalid field/range, server ownership, stale version, unsafe window, approval permission, execution failure/unknown, audit, and confirmation.
|
||||
|
||||
## 2. API and SCUM bridge contract
|
||||
|
||||
- [x] 2.1 Add named DTOs and authorized player-state/patch API routes and tests.
|
||||
- [x] 2.2 Declare `game-state.patch` and player-state schemas in the SCUM manifest, extend companion bridge validation fixtures/tests, and preserve the typed bounded command channel.
|
||||
|
||||
## 3. SCUM console
|
||||
|
||||
- [x] 3.1 Add frontend API/types and Chinese player-detail patch controls using shared console/theme primitives.
|
||||
- [x] 3.2 Render readable diff, reason, approval, execution, and confirmation histories; disable unsupported or unsafe writes.
|
||||
- [x] 3.3 Add frontend tests for field labels, diff/audit readability, and disabled safety states.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 Run strict OpenSpec validation, focused backend/plugin/frontend tests, and `scripts/check-structure.sh`.
|
||||
- [ ] 4.2 Stage only this task's files, commit on `main`, and push the configured remote.
|
||||
@@ -8,6 +8,88 @@ import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// serverGamePlayerState godoc
|
||||
// @Summary Get current SCUM player state
|
||||
// @Description Returns a version-scoped, browser-safe player-state snapshot; it never exposes a game database or raw storage fields.
|
||||
// @Tags game-players
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.GamePlayerStateResponse
|
||||
// @Router /api/v1/server-instances/{id}/game-players/{playerId}/state [get]
|
||||
func (h *coreHandlers) serverGamePlayerState(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
state, err := h.core.GetGamePlayerStateForSession(bearerToken(r), r.PathValue("playerId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if state.ServerInstanceID != r.PathValue("id") {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GamePlayerStateFromDomain(state))
|
||||
}
|
||||
|
||||
// serverGamePlayerStatePatches godoc
|
||||
// @Summary Request or list controlled SCUM player state patches
|
||||
// @Description Creates reviewable skill/attribute patch requests only through the typed game-client bridge channel.
|
||||
// @Tags game-players
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 202 {object} dto.GamePlayerStatePatchResponse
|
||||
// @Router /api/v1/server-instances/{id}/game-players/{playerId}/state-patches [get,post]
|
||||
func (h *coreHandlers) serverGamePlayerStatePatches(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
patches, err := h.core.ListGamePlayerStatePatchesForSession(bearerToken(r), r.PathValue("playerId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GamePlayerStatePatchesFromDomain(patches))
|
||||
case http.MethodPost:
|
||||
request, err := decodeJSON[dto.GamePlayerStatePatchRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
patch, err := h.core.RequestGamePlayerStatePatchForSession(bearerToken(r), r.PathValue("playerId"), request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.GamePlayerStatePatchFromDomain(patch))
|
||||
default:
|
||||
writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// serverGamePlayerStatePatchApprove godoc
|
||||
// @Summary Approve a controlled SCUM player state patch
|
||||
// @Description Requires a platform administrator with access to the target server and dispatches only the declared typed bridge command.
|
||||
// @Tags game-players
|
||||
// @Produce json
|
||||
// @Success 202 {object} dto.GamePlayerStatePatchResponse
|
||||
// @Router /api/v1/server-instances/{id}/game-players/{playerId}/state-patches/{patchId}/approve [post]
|
||||
func (h *coreHandlers) serverGamePlayerStatePatchApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
patch, err := h.core.ApproveGamePlayerStatePatchForSession(bearerToken(r), r.PathValue("patchId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if patch.ServerInstanceID != r.PathValue("id") || patch.GamePlayerRecordID != r.PathValue("playerId") {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.GamePlayerStatePatchFromDomain(patch))
|
||||
}
|
||||
|
||||
// serverGamePlayers godoc
|
||||
// @Summary List server-local game players
|
||||
// @Description Returns privacy-safe SCUM game player records visible to the current server operator.
|
||||
|
||||
@@ -95,6 +95,9 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-players", h.serverGamePlayers)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}", h.serverGamePlayerDetail)
|
||||
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}/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,114 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const SCUMPlayerStatePatchCommandType = "game-state.patch"
|
||||
const SCUMPlayerStateSnapshotType = "player.state"
|
||||
|
||||
type GamePlayerStateFieldKind string
|
||||
|
||||
const (
|
||||
GamePlayerStateFieldSkill GamePlayerStateFieldKind = "skill"
|
||||
GamePlayerStateFieldAttribute GamePlayerStateFieldKind = "attribute"
|
||||
)
|
||||
|
||||
type GamePlayerStateFieldDefinition struct {
|
||||
Key string
|
||||
Label string
|
||||
Kind GamePlayerStateFieldKind
|
||||
Minimum float64
|
||||
Maximum float64
|
||||
}
|
||||
type GamePlayerStateCatalog struct {
|
||||
GameVersion string
|
||||
Fields []GamePlayerStateFieldDefinition
|
||||
}
|
||||
|
||||
var SCUMPlayerStateCatalogs = []GamePlayerStateCatalog{{GameVersion: "0.9.700.90357", Fields: []GamePlayerStateFieldDefinition{{Key: "skills.running", Label: "跑步技能", Kind: GamePlayerStateFieldSkill, Minimum: 0, Maximum: 10}, {Key: "attributes.strength", Label: "力量属性", Kind: GamePlayerStateFieldAttribute, Minimum: 0, Maximum: 10}}}}
|
||||
|
||||
type GamePlayerStateSnapshot struct {
|
||||
ServerInstanceID string
|
||||
GamePlayerRecordID string
|
||||
GamePlayerID string
|
||||
GameVersion string
|
||||
StateVersion string
|
||||
SafetyWindow string
|
||||
MaintenanceVerified bool
|
||||
PlayerOnline bool
|
||||
Fields map[string]float64
|
||||
ObservedAt time.Time
|
||||
}
|
||||
type GamePlayerStatePatchStatus string
|
||||
|
||||
const (
|
||||
GamePlayerStatePatchPendingApproval GamePlayerStatePatchStatus = "pending-approval"
|
||||
GamePlayerStatePatchQueued GamePlayerStatePatchStatus = "queued"
|
||||
GamePlayerStatePatchExecutionFailed GamePlayerStatePatchStatus = "execution-failed"
|
||||
GamePlayerStatePatchExecutionUnknown GamePlayerStatePatchStatus = "execution-unknown"
|
||||
GamePlayerStatePatchConfirmationFailed GamePlayerStatePatchStatus = "confirmation-failed"
|
||||
GamePlayerStatePatchConfirmed GamePlayerStatePatchStatus = "confirmed"
|
||||
)
|
||||
|
||||
type GamePlayerStatePatchChange struct {
|
||||
FieldKey string
|
||||
Before float64
|
||||
After float64
|
||||
}
|
||||
type GamePlayerStatePatch struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
GamePlayerRecordID string
|
||||
GamePlayerID string
|
||||
GameVersion string
|
||||
ExpectedStateVersion string
|
||||
SafetyWindow string
|
||||
Changes []GamePlayerStatePatchChange
|
||||
Reason string
|
||||
RequesterID string
|
||||
ApproverID string
|
||||
Status GamePlayerStatePatchStatus
|
||||
BridgeCommandID string
|
||||
ExecutionSummary string
|
||||
ConfirmedStateVersion string
|
||||
CreatedAt time.Time
|
||||
ApprovedAt time.Time
|
||||
CompletedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
type GamePlayerStatePatchFilter struct {
|
||||
ServerInstanceID string
|
||||
GamePlayerRecordID string
|
||||
Limit int
|
||||
}
|
||||
type GamePlayerStatePatchRequest struct {
|
||||
GameVersion string
|
||||
ExpectedStateVersion string
|
||||
SafetyWindow string
|
||||
Changes []GamePlayerStatePatchChange
|
||||
Reason string
|
||||
}
|
||||
|
||||
func CopyGamePlayerStatePatch(value GamePlayerStatePatch) GamePlayerStatePatch {
|
||||
value.Changes = append([]GamePlayerStatePatchChange(nil), value.Changes...)
|
||||
return value
|
||||
}
|
||||
func SCUMPlayerStateCatalogForVersion(version string) (GamePlayerStateCatalog, bool) {
|
||||
for _, catalog := range SCUMPlayerStateCatalogs {
|
||||
if catalog.GameVersion == version {
|
||||
return catalog, true
|
||||
}
|
||||
}
|
||||
return GamePlayerStateCatalog{}, false
|
||||
}
|
||||
func GamePlayerStateFieldForVersion(version, key string) (GamePlayerStateFieldDefinition, bool) {
|
||||
catalog, ok := SCUMPlayerStateCatalogForVersion(version)
|
||||
if !ok {
|
||||
return GamePlayerStateFieldDefinition{}, false
|
||||
}
|
||||
for _, field := range catalog.Fields {
|
||||
if field.Key == key {
|
||||
return field, true
|
||||
}
|
||||
}
|
||||
return GamePlayerStateFieldDefinition{}, false
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"browser.local/platform/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GamePlayerStateFieldResponse struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
Minimum float64 `json:"minimum"`
|
||||
Maximum float64 `json:"maximum"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
type GamePlayerStateResponse struct {
|
||||
GameVersion string `json:"gameVersion"`
|
||||
StateVersion string `json:"stateVersion"`
|
||||
SafetyWindow string `json:"safetyWindow,omitempty"`
|
||||
MaintenanceVerified bool `json:"maintenanceVerified"`
|
||||
PlayerOnline bool `json:"playerOnline"`
|
||||
Supported bool `json:"supported"`
|
||||
Fields []GamePlayerStateFieldResponse `json:"fields"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
}
|
||||
type GamePlayerStatePatchChangeRequest struct {
|
||||
FieldKey string `json:"fieldKey"`
|
||||
Before float64 `json:"before"`
|
||||
After float64 `json:"after"`
|
||||
}
|
||||
type GamePlayerStatePatchRequest struct {
|
||||
GameVersion string `json:"gameVersion"`
|
||||
ExpectedStateVersion string `json:"expectedStateVersion"`
|
||||
SafetyWindow string `json:"safetyWindow"`
|
||||
Changes []GamePlayerStatePatchChangeRequest `json:"changes"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
type GamePlayerStatePatchChangeResponse struct {
|
||||
FieldKey string `json:"fieldKey"`
|
||||
Before float64 `json:"before"`
|
||||
After float64 `json:"after"`
|
||||
}
|
||||
type GamePlayerStatePatchResponse struct {
|
||||
ID string `json:"id"`
|
||||
GameVersion string `json:"gameVersion"`
|
||||
ExpectedStateVersion string `json:"expectedStateVersion"`
|
||||
Changes []GamePlayerStatePatchChangeResponse `json:"changes"`
|
||||
Reason string `json:"reason"`
|
||||
RequesterID string `json:"requesterId"`
|
||||
ApproverID string `json:"approverId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
BridgeCommandID string `json:"bridgeCommandId,omitempty"`
|
||||
ExecutionSummary string `json:"executionSummary,omitempty"`
|
||||
ConfirmedStateVersion string `json:"confirmedStateVersion,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ApprovedAt time.Time `json:"approvedAt,omitempty"`
|
||||
CompletedAt time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
type GamePlayerStatePatchListResponse struct {
|
||||
Items []GamePlayerStatePatchResponse `json:"items"`
|
||||
}
|
||||
|
||||
func (request GamePlayerStatePatchRequest) ToDomain() domain.GamePlayerStatePatchRequest {
|
||||
changes := make([]domain.GamePlayerStatePatchChange, len(request.Changes))
|
||||
for i, change := range request.Changes {
|
||||
changes[i] = domain.GamePlayerStatePatchChange{FieldKey: change.FieldKey, Before: change.Before, After: change.After}
|
||||
}
|
||||
return domain.GamePlayerStatePatchRequest{GameVersion: request.GameVersion, ExpectedStateVersion: request.ExpectedStateVersion, SafetyWindow: request.SafetyWindow, Changes: changes, Reason: request.Reason}
|
||||
}
|
||||
func GamePlayerStateFromDomain(value domain.GamePlayerStateSnapshot) GamePlayerStateResponse {
|
||||
catalog, supported := domain.SCUMPlayerStateCatalogForVersion(value.GameVersion)
|
||||
fields := make([]GamePlayerStateFieldResponse, 0, len(catalog.Fields))
|
||||
for _, field := range catalog.Fields {
|
||||
fields = append(fields, GamePlayerStateFieldResponse{Key: field.Key, Label: field.Label, Kind: string(field.Kind), Minimum: field.Minimum, Maximum: field.Maximum, Value: value.Fields[field.Key]})
|
||||
}
|
||||
return GamePlayerStateResponse{GameVersion: value.GameVersion, StateVersion: value.StateVersion, SafetyWindow: value.SafetyWindow, MaintenanceVerified: value.MaintenanceVerified, PlayerOnline: value.PlayerOnline, Supported: supported, Fields: fields, ObservedAt: value.ObservedAt}
|
||||
}
|
||||
func GamePlayerStatePatchFromDomain(value domain.GamePlayerStatePatch) GamePlayerStatePatchResponse {
|
||||
changes := make([]GamePlayerStatePatchChangeResponse, len(value.Changes))
|
||||
for i, change := range value.Changes {
|
||||
changes[i] = GamePlayerStatePatchChangeResponse{FieldKey: change.FieldKey, Before: change.Before, After: change.After}
|
||||
}
|
||||
return GamePlayerStatePatchResponse{ID: value.ID, GameVersion: value.GameVersion, ExpectedStateVersion: value.ExpectedStateVersion, Changes: changes, Reason: value.Reason, RequesterID: value.RequesterID, ApproverID: value.ApproverID, Status: string(value.Status), BridgeCommandID: value.BridgeCommandID, ExecutionSummary: value.ExecutionSummary, ConfirmedStateVersion: value.ConfirmedStateVersion, CreatedAt: value.CreatedAt, ApprovedAt: value.ApprovedAt, CompletedAt: value.CompletedAt}
|
||||
}
|
||||
func GamePlayerStatePatchesFromDomain(values []domain.GamePlayerStatePatch) GamePlayerStatePatchListResponse {
|
||||
items := make([]GamePlayerStatePatchResponse, len(values))
|
||||
for i, value := range values {
|
||||
items[i] = GamePlayerStatePatchFromDomain(value)
|
||||
}
|
||||
return GamePlayerStatePatchListResponse{Items: items}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// GamePlayerStatePatch is the model-first audit shape for one approved typed player-state change.
|
||||
type GamePlayerStatePatch struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"`
|
||||
GamePlayerID string `json:"gamePlayerId" db:"game_player_id"`
|
||||
GameVersion string `json:"gameVersion" db:"game_version"`
|
||||
ExpectedStateVersion string `json:"expectedStateVersion" db:"expected_state_version"`
|
||||
SafetyWindow string `json:"safetyWindow" db:"safety_window"`
|
||||
Reason string `json:"reason" db:"reason"`
|
||||
RequesterID string `json:"requesterId" db:"requester_id"`
|
||||
ApproverID string `json:"approverId" db:"approver_id"`
|
||||
Status string `json:"status" db:"status"`
|
||||
BridgeCommandID string `json:"bridgeCommandId" db:"bridge_command_id"`
|
||||
ExecutionSummary string `json:"executionSummary" db:"execution_summary"`
|
||||
ConfirmedStateVersion string `json:"confirmedStateVersion" db:"confirmed_state_version"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
ApprovedAt time.Time `json:"approvedAt" db:"approved_at"`
|
||||
CompletedAt time.Time `json:"completedAt" db:"completed_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (GamePlayerStatePatch) TableName() string { return "game_player_state_patches" }
|
||||
@@ -47,6 +47,7 @@ type StoreSnapshot struct {
|
||||
GamePlayerSessions []domain.GamePlayerSession `json:"gamePlayerSessions"`
|
||||
GameAccessAttempts []domain.GameAccessAttempt `json:"gameAccessAttempts"`
|
||||
GameSecuritySignals []domain.GameSecuritySignal `json:"gameSecuritySignals"`
|
||||
GamePlayerStatePatches []domain.GamePlayerStatePatch `json:"gamePlayerStatePatches"`
|
||||
}
|
||||
|
||||
type FileStore struct {
|
||||
@@ -210,6 +211,9 @@ func (store *FileStore) GameAccessAttempts() GameAccessAttemptRepository {
|
||||
func (store *FileStore) GameSecuritySignals() GameSecuritySignalRepository {
|
||||
return &persistentRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]{repository: store.MemoryStore.gameSecuritySignals, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
|
||||
return &persistentRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]{repository: store.MemoryStore.gamePlayerStatePatches, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) load() error {
|
||||
data, err := os.ReadFile(store.path)
|
||||
@@ -283,7 +287,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),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +326,7 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions)
|
||||
loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts)
|
||||
loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals)
|
||||
loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches)
|
||||
}
|
||||
|
||||
type mutableRepository[T any, F any] interface {
|
||||
|
||||
@@ -186,6 +186,9 @@ func (store *MySQLStore) GameAccessAttempts() GameAccessAttemptRepository {
|
||||
func (store *MySQLStore) GameSecuritySignals() GameSecuritySignalRepository {
|
||||
return &persistentRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]{repository: store.MemoryStore.gameSecuritySignals, persist: store.persist}
|
||||
}
|
||||
func (store *MySQLStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
|
||||
return &persistentRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]{repository: store.MemoryStore.gamePlayerStatePatches, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) initialize() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@@ -276,7 +279,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),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,4 +318,5 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions)
|
||||
loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts)
|
||||
loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals)
|
||||
loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches)
|
||||
}
|
||||
|
||||
+54
-39
@@ -259,6 +259,12 @@ type GameSecuritySignalRepository interface {
|
||||
Update(domain.GameSecuritySignal) error
|
||||
Delete(string) error
|
||||
}
|
||||
type GamePlayerStatePatchRepository interface {
|
||||
Create(domain.GamePlayerStatePatch) error
|
||||
Get(string) (domain.GamePlayerStatePatch, error)
|
||||
List(domain.GamePlayerStatePatchFilter) ([]domain.GamePlayerStatePatch, error)
|
||||
Update(domain.GamePlayerStatePatch) error
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Users() UserRepository
|
||||
@@ -295,43 +301,45 @@ type Store interface {
|
||||
GamePlayerSessions() GamePlayerSessionRepository
|
||||
GameAccessAttempts() GameAccessAttemptRepository
|
||||
GameSecuritySignals() GameSecuritySignalRepository
|
||||
GamePlayerStatePatches() GamePlayerStatePatchRepository
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
users *memoryRepository[domain.User, domain.UserFilter]
|
||||
authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]
|
||||
runSessions *memoryRepository[domain.RunControlSession, struct{}]
|
||||
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
|
||||
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
|
||||
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
|
||||
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
|
||||
jobs *memoryJobRepository
|
||||
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
|
||||
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
|
||||
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
|
||||
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
|
||||
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
|
||||
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
|
||||
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
|
||||
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
|
||||
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
|
||||
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
||||
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
||||
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
||||
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
|
||||
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
||||
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
|
||||
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
|
||||
gamePlayers *memoryRepository[domain.GamePlayer, domain.GamePlayerFilter]
|
||||
gamePlayerAliases *memoryRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter]
|
||||
gamePlayerSessions *memoryRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter]
|
||||
gameAccessAttempts *memoryRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter]
|
||||
gameSecuritySignals *memoryRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]
|
||||
users *memoryRepository[domain.User, domain.UserFilter]
|
||||
authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]
|
||||
runSessions *memoryRepository[domain.RunControlSession, struct{}]
|
||||
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
|
||||
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
|
||||
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
|
||||
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
|
||||
jobs *memoryJobRepository
|
||||
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
|
||||
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
|
||||
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
|
||||
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
|
||||
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
|
||||
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
|
||||
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
|
||||
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
|
||||
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
|
||||
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
||||
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
||||
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
||||
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
|
||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
|
||||
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
||||
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
|
||||
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
|
||||
gamePlayers *memoryRepository[domain.GamePlayer, domain.GamePlayerFilter]
|
||||
gamePlayerAliases *memoryRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter]
|
||||
gamePlayerSessions *memoryRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter]
|
||||
gameAccessAttempts *memoryRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter]
|
||||
gameSecuritySignals *memoryRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]
|
||||
gamePlayerStatePatches *memoryRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
@@ -469,11 +477,12 @@ func NewMemoryStore() *MemoryStore {
|
||||
domain.CopyGameClientBridgeSnapshotStream,
|
||||
matchGameClientBridgeSnapshotStream,
|
||||
),
|
||||
gamePlayers: newMemoryRepository(func(v domain.GamePlayer) string { return v.ID }, domain.CopyGamePlayer, matchGamePlayer),
|
||||
gamePlayerAliases: newMemoryRepository(func(v domain.GamePlayerAlias) string { return v.ID }, domain.CopyGamePlayerAlias, matchGamePlayerAlias),
|
||||
gamePlayerSessions: newMemoryRepository(func(v domain.GamePlayerSession) string { return v.ID }, domain.CopyGamePlayerSession, matchGamePlayerSession),
|
||||
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),
|
||||
gamePlayers: newMemoryRepository(func(v domain.GamePlayer) string { return v.ID }, domain.CopyGamePlayer, matchGamePlayer),
|
||||
gamePlayerAliases: newMemoryRepository(func(v domain.GamePlayerAlias) string { return v.ID }, domain.CopyGamePlayerAlias, matchGamePlayerAlias),
|
||||
gamePlayerSessions: newMemoryRepository(func(v domain.GamePlayerSession) string { return v.ID }, domain.CopyGamePlayerSession, matchGamePlayerSession),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,6 +548,9 @@ func (store *MemoryStore) GameAccessAttempts() GameAccessAttemptRepository {
|
||||
func (store *MemoryStore) GameSecuritySignals() GameSecuritySignalRepository {
|
||||
return store.gameSecuritySignals
|
||||
}
|
||||
func (store *MemoryStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
|
||||
return store.gamePlayerStatePatches
|
||||
}
|
||||
|
||||
type memoryRepository[T any, F any] struct {
|
||||
mu sync.RWMutex
|
||||
@@ -855,3 +867,6 @@ func matchGameAccessAttempt(v domain.GameAccessAttempt, f domain.GameAccessAttem
|
||||
func matchGameSecuritySignal(v domain.GameSecuritySignal, f domain.GameSecuritySignalFilter) bool {
|
||||
return (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID)
|
||||
}
|
||||
func matchGamePlayerStatePatch(v domain.GamePlayerStatePatch, f domain.GamePlayerStatePatchFilter) bool {
|
||||
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func (svc *CoreService) GetGamePlayerStateForSession(sessionID, playerID string) (domain.GamePlayerStateSnapshot, error) {
|
||||
player, err := svc.store.GamePlayers().Get(playerID)
|
||||
if err != nil {
|
||||
return domain.GamePlayerStateSnapshot{}, err
|
||||
}
|
||||
if err = svc.authorizeServerLifecycle(sessionID, player.ServerInstanceID); err != nil {
|
||||
return domain.GamePlayerStateSnapshot{}, err
|
||||
}
|
||||
return svc.currentGamePlayerState(player)
|
||||
}
|
||||
|
||||
func (svc *CoreService) RequestGamePlayerStatePatchForSession(sessionID, playerID string, request domain.GamePlayerStatePatchRequest) (domain.GamePlayerStatePatch, error) {
|
||||
player, err := svc.store.GamePlayers().Get(playerID)
|
||||
if err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
if err = svc.authorizeServerLifecycle(sessionID, player.ServerInstanceID); err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
state, err := svc.currentGamePlayerState(player)
|
||||
if err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
if err = validateGamePlayerStatePatch(state, request); err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
patch := domain.GamePlayerStatePatch{ID: fmt.Sprintf("game-player-state-patch-%d", stamp.UnixNano()), ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, GameVersion: state.GameVersion, ExpectedStateVersion: state.StateVersion, SafetyWindow: state.SafetyWindow, Changes: append([]domain.GamePlayerStatePatchChange(nil), request.Changes...), Reason: strings.TrimSpace(request.Reason), RequesterID: user.ID, Status: domain.GamePlayerStatePatchPendingApproval, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if err = svc.store.GamePlayerStatePatches().Create(patch); err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
if _, err = svc.recordAuditEventWithID(user.ID, "game-player-state.patch.request", "game-player-state-patch", patch.ID, domain.AuditResultQueued, "player state patch awaiting platform administrator approval"); err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
return domain.CopyGamePlayerStatePatch(patch), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ApproveGamePlayerStatePatchForSession(sessionID, patchID string) (domain.GamePlayerStatePatch, error) {
|
||||
patch, err := svc.store.GamePlayerStatePatches().Get(patchID)
|
||||
if err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
if !isPlatformAdmin(user) {
|
||||
return domain.GamePlayerStatePatch{}, ErrForbidden
|
||||
}
|
||||
if err = svc.authorizeServerLifecycle(sessionID, patch.ServerInstanceID); err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
if patch.Status != domain.GamePlayerStatePatchPendingApproval {
|
||||
return domain.GamePlayerStatePatch{}, validationError("player state patch is not awaiting approval")
|
||||
}
|
||||
player, err := svc.store.GamePlayers().Get(patch.GamePlayerRecordID)
|
||||
if err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
if player.ServerInstanceID != patch.ServerInstanceID {
|
||||
return domain.GamePlayerStatePatch{}, repo.ErrNotFound
|
||||
}
|
||||
state, err := svc.currentGamePlayerState(player)
|
||||
if err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
request := domain.GamePlayerStatePatchRequest{GameVersion: patch.GameVersion, ExpectedStateVersion: patch.ExpectedStateVersion, SafetyWindow: patch.SafetyWindow, Changes: patch.Changes, Reason: patch.Reason}
|
||||
if err = validateGamePlayerStatePatch(state, request); err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(patch.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
profileKey, ok := gameClientBridgeProfileKey(plugin)
|
||||
if !ok {
|
||||
return domain.GamePlayerStatePatch{}, validationError("SCUM controlled player state companion profile is unavailable")
|
||||
}
|
||||
command, err := svc.queueGameClientBridgeCommand(user.ID, domain.GameClientBridgeQueueRequest{ServerInstanceID: patch.ServerInstanceID, PluginID: instance.PluginID, ProfileKey: profileKey, CommandType: domain.SCUMPlayerStatePatchCommandType, Payload: gamePlayerStatePatchPayload(patch), IdempotencyKey: patch.ID, Priority: 10, ExpiresAt: svc.now().Add(2 * time.Minute)})
|
||||
if err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
patch.ApproverID = user.ID
|
||||
patch.ApprovedAt = stamp
|
||||
patch.UpdatedAt = stamp
|
||||
patch.Status = domain.GamePlayerStatePatchQueued
|
||||
patch.BridgeCommandID = command.ID
|
||||
if err = svc.store.GamePlayerStatePatches().Update(patch); err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
if _, err = svc.recordAuditEventWithID(user.ID, "game-player-state.patch.approve", "game-player-state-patch", patch.ID, domain.AuditResultQueued, "platform administrator approved typed player state patch"); err != nil {
|
||||
return domain.GamePlayerStatePatch{}, err
|
||||
}
|
||||
return domain.CopyGamePlayerStatePatch(patch), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListGamePlayerStatePatchesForSession(sessionID, playerID string) ([]domain.GamePlayerStatePatch, error) {
|
||||
player, err := svc.store.GamePlayers().Get(playerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = svc.authorizeServerLifecycle(sessionID, player.ServerInstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patches, err := svc.store.GamePlayerStatePatches().List(domain.GamePlayerStatePatchFilter{GamePlayerRecordID: player.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range patches {
|
||||
if err := svc.reconcileGamePlayerStatePatch(&patches[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Slice(patches, func(i, j int) bool { return patches[i].CreatedAt.After(patches[j].CreatedAt) })
|
||||
return patches, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) currentGamePlayerState(player domain.GamePlayer) (domain.GamePlayerStateSnapshot, error) {
|
||||
snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: player.ServerInstanceID, Type: domain.SCUMPlayerStateSnapshotType, Limit: 100})
|
||||
if err != nil {
|
||||
return domain.GamePlayerStateSnapshot{}, err
|
||||
}
|
||||
var latest domain.GameClientBridgeSnapshot
|
||||
found := false
|
||||
for _, snapshot := range snapshots {
|
||||
if strings.TrimSpace(stringValue(snapshot.Payload["playerId"])) != player.GamePlayerID {
|
||||
continue
|
||||
}
|
||||
if !found || snapshot.ObservedAt.After(latest.ObservedAt) {
|
||||
latest, found = snapshot, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return domain.GamePlayerStateSnapshot{}, validationError("current player state snapshot is unavailable")
|
||||
}
|
||||
fields, ok := numberMap(latest.Payload["fields"])
|
||||
if !ok {
|
||||
return domain.GamePlayerStateSnapshot{}, validationError("player state snapshot fields are invalid")
|
||||
}
|
||||
state := domain.GamePlayerStateSnapshot{ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, GameVersion: strings.TrimSpace(stringValue(latest.Payload["gameVersion"])), StateVersion: strings.TrimSpace(stringValue(latest.Payload["stateVersion"])), SafetyWindow: strings.TrimSpace(stringValue(latest.Payload["safetyWindow"])), MaintenanceVerified: boolValue(latest.Payload["maintenanceVerified"]), PlayerOnline: boolValue(latest.Payload["playerOnline"]), Fields: fields, ObservedAt: latest.ObservedAt}
|
||||
if state.GameVersion == "" || state.StateVersion == "" {
|
||||
return domain.GamePlayerStateSnapshot{}, validationError("player state snapshot is incomplete")
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func validateGamePlayerStatePatch(state domain.GamePlayerStateSnapshot, request domain.GamePlayerStatePatchRequest) error {
|
||||
if _, ok := domain.SCUMPlayerStateCatalogForVersion(state.GameVersion); !ok {
|
||||
return validationError("SCUM server game version does not support controlled player state patches")
|
||||
}
|
||||
if request.GameVersion != state.GameVersion {
|
||||
return validationError("player state patch game version conflicts with current server state")
|
||||
}
|
||||
if request.ExpectedStateVersion != state.StateVersion {
|
||||
return validationError("player state patch conflicts with current state version")
|
||||
}
|
||||
if !state.MaintenanceVerified || state.PlayerOnline || state.SafetyWindow == "" || request.SafetyWindow != state.SafetyWindow {
|
||||
return validationError("player state patch requires a verified maintenance/offline safety window")
|
||||
}
|
||||
if reason := strings.TrimSpace(request.Reason); len(reason) < 4 || len(reason) > 240 {
|
||||
return validationError("player state patch reason must be 4 to 240 characters")
|
||||
}
|
||||
if len(request.Changes) == 0 || len(request.Changes) > 8 {
|
||||
return validationError("player state patch must contain 1 to 8 changes")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, change := range request.Changes {
|
||||
field, ok := domain.GamePlayerStateFieldForVersion(state.GameVersion, change.FieldKey)
|
||||
if !ok || seen[change.FieldKey] {
|
||||
return validationError("player state patch field is not supported by the server version")
|
||||
}
|
||||
seen[change.FieldKey] = true
|
||||
before, exists := state.Fields[change.FieldKey]
|
||||
if !exists || before != change.Before || math.IsNaN(change.After) || math.IsInf(change.After, 0) || change.After < field.Minimum || change.After > field.Maximum {
|
||||
return validationError("player state patch has an invalid field value or stale before value")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func gamePlayerStatePatchPayload(patch domain.GamePlayerStatePatch) map[string]any {
|
||||
changes := make([]any, len(patch.Changes))
|
||||
for i, change := range patch.Changes {
|
||||
changes[i] = map[string]any{"fieldKey": change.FieldKey, "before": change.Before, "after": change.After}
|
||||
}
|
||||
return map[string]any{"playerId": patch.GamePlayerID, "gameVersion": patch.GameVersion, "expectedStateVersion": patch.ExpectedStateVersion, "safetyWindow": patch.SafetyWindow, "reason": patch.Reason, "changes": changes}
|
||||
}
|
||||
func gameClientBridgeProfileKey(plugin domain.GamePlugin) (string, bool) {
|
||||
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
||||
if containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
||||
return profile.Key, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
func (svc *CoreService) reconcileGamePlayerStatePatch(patch *domain.GamePlayerStatePatch) error {
|
||||
if patch.Status != domain.GamePlayerStatePatchQueued || patch.BridgeCommandID == "" {
|
||||
return nil
|
||||
}
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(patch.BridgeCommandID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stamp := svc.now()
|
||||
changed := false
|
||||
if command.State == domain.GameClientBridgeCommandFailed {
|
||||
patch.Status = domain.GamePlayerStatePatchExecutionFailed
|
||||
patch.ExecutionSummary = command.Result.Summary
|
||||
changed = true
|
||||
} else if command.State == domain.GameClientBridgeCommandExpired || command.State == domain.GameClientBridgeCommandCancelled {
|
||||
patch.Status = domain.GamePlayerStatePatchExecutionUnknown
|
||||
patch.ExecutionSummary = command.Result.Summary
|
||||
changed = true
|
||||
} else if command.State == domain.GameClientBridgeCommandSucceeded {
|
||||
version := strings.TrimSpace(stringValue(command.Result.Payload["confirmedStateVersion"]))
|
||||
fields, ok := numberMap(command.Result.Payload["confirmedFields"])
|
||||
if version == "" || version == patch.ExpectedStateVersion || !ok || !confirmedPatchFields(patch.Changes, fields) {
|
||||
patch.Status = domain.GamePlayerStatePatchConfirmationFailed
|
||||
patch.ExecutionSummary = command.Result.Summary
|
||||
changed = true
|
||||
} else {
|
||||
patch.Status = domain.GamePlayerStatePatchConfirmed
|
||||
patch.ConfirmedStateVersion = version
|
||||
patch.ExecutionSummary = command.Result.Summary
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
patch.CompletedAt = stamp
|
||||
patch.UpdatedAt = stamp
|
||||
if err = svc.store.GamePlayerStatePatches().Update(*patch); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = svc.recordAuditEventWithID("component:scum-client-manager", "game-player-state.patch.result", "game-player-state-patch", patch.ID, domain.AuditResultSuccess, "typed player state patch terminal result recorded")
|
||||
return err
|
||||
}
|
||||
func confirmedPatchFields(changes []domain.GamePlayerStatePatchChange, fields map[string]float64) bool {
|
||||
for _, change := range changes {
|
||||
if value, ok := fields[change.FieldKey]; !ok || value != change.After {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func stringValue(value any) string { text, _ := value.(string); return text }
|
||||
func boolValue(value any) bool { flag, _ := value.(bool); return flag }
|
||||
func numberMap(value any) (map[string]float64, bool) {
|
||||
raw, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := map[string]float64{}
|
||||
for key, value := range raw {
|
||||
number, ok := value.(float64)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out[key] = number
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestGamePlayerStatePatchRejectsUnknownVersionRangeAndUnsafeWindow(t *testing.T) {
|
||||
svc, _, player, request := gamePlayerStatePatchFixture(t)
|
||||
state, err := svc.currentGamePlayerState(player)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unknown := request
|
||||
unknown.GameVersion = "0.0.0"
|
||||
if err := validateGamePlayerStatePatch(state, unknown); err == nil {
|
||||
t.Fatal("unknown version was accepted")
|
||||
}
|
||||
outOfRange := request
|
||||
outOfRange.Changes[0].After = 11
|
||||
if err := validateGamePlayerStatePatch(state, outOfRange); err == nil {
|
||||
t.Fatal("out-of-range field was accepted")
|
||||
}
|
||||
unsafe := state
|
||||
unsafe.PlayerOnline = true
|
||||
if err := validateGamePlayerStatePatch(unsafe, request); err == nil {
|
||||
t.Fatal("online player patch was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGamePlayerStatePatchApprovalAndFailedExecutionRemainAuditable(t *testing.T) {
|
||||
svc, session, player, request := gamePlayerStatePatchFixture(t)
|
||||
patch, err := svc.RequestGamePlayerStatePatchForSession(session, player.ID, request)
|
||||
if err != nil || patch.Status != domain.GamePlayerStatePatchPendingApproval || patch.Changes[0].Before != 4 || patch.Changes[0].After != 6 {
|
||||
t.Fatalf("request=%+v err=%v", patch, err)
|
||||
}
|
||||
approved, err := svc.ApproveGamePlayerStatePatchForSession(session, patch.ID)
|
||||
if err != nil || approved.Status != domain.GamePlayerStatePatchQueued || approved.ApproverID == "" {
|
||||
t.Fatalf("approved=%+v err=%v", approved, err)
|
||||
}
|
||||
claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claimed=%+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: "maintenance check changed"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patches, err := svc.ListGamePlayerStatePatchesForSession(session, player.ID)
|
||||
if err != nil || len(patches) != 1 || patches[0].Status != domain.GamePlayerStatePatchExecutionFailed || patches[0].ExecutionSummary == "" {
|
||||
t.Fatalf("patches=%+v err=%v", patches, err)
|
||||
}
|
||||
audits, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: patch.ID})
|
||||
if err != nil || len(audits) < 3 {
|
||||
t.Fatalf("audits=%+v err=%v", audits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGamePlayerStatePatchRequiresReadAfterWriteConfirmation(t *testing.T) {
|
||||
svc, session, player, request := gamePlayerStatePatchFixture(t)
|
||||
patch, err := svc.RequestGamePlayerStatePatchForSession(session, player.ID, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = svc.ApproveGamePlayerStatePatchForSession(session, patch.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claimed=%+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: "confirmed after read", Payload: map[string]any{"confirmedStateVersion": "state-v2", "confirmedFields": map[string]any{"skills.running": float64(6)}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patches, err := svc.ListGamePlayerStatePatchesForSession(session, player.ID)
|
||||
if err != nil || len(patches) != 1 || patches[0].Status != domain.GamePlayerStatePatchConfirmed || patches[0].ConfirmedStateVersion != "state-v2" {
|
||||
t.Fatalf("patches=%+v err=%v", patches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func gamePlayerStatePatchFixture(t *testing.T) (*CoreService, string, domain.GamePlayer, domain.GamePlayerStatePatchRequest) {
|
||||
t.Helper()
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
plugin, _ := svc.store.GamePlugins().Get("game.scum")
|
||||
plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: domain.SCUMPlayerStatePatchCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 120, MaxPayloadBytes: 4096})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := domain.User{ID: "state-admin", DisplayName: "State Admin", Email: "state-admin@example.test", Roles: []string{"platform-admin"}, Status: domain.UserStatusActive, PasswordHash: "secret-password", 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: "player-record-1", ServerInstanceID: "server-1", GamePlayerID: "steam-1", DisplayName: "Moon"}
|
||||
if err = svc.store.GamePlayers().Create(player); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := domain.GameClientBridgeSnapshot{ID: "state-snapshot-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: domain.SCUMPlayerStateSnapshotType, ObservedAt: *clock, Payload: map[string]any{"playerId": "steam-1", "gameVersion": "0.9.700.90357", "stateVersion": "state-v1", "safetyWindow": "maintenance-1", "maintenanceVerified": true, "playerOnline": false, "fields": map[string]any{"skills.running": float64(4), "attributes.strength": float64(5)}}}
|
||||
if err = svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc, auth.SessionID, player, domain.GamePlayerStatePatchRequest{GameVersion: "0.9.700.90357", ExpectedStateVersion: "state-v1", SafetyWindow: "maintenance-1", Changes: []domain.GamePlayerStatePatchChange{{FieldKey: "skills.running", Before: 4, After: 6}}, Reason: "修正受审核的角色跑步技能"}
|
||||
}
|
||||
@@ -205,6 +205,10 @@ type Core interface {
|
||||
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
|
||||
ListGamePlayersForSession(string, domain.GamePlayerFilter) ([]domain.GamePlayer, error)
|
||||
GetGamePlayerProfileForSession(string, string) (domain.GamePlayerProfile, error)
|
||||
GetGamePlayerStateForSession(string, string) (domain.GamePlayerStateSnapshot, error)
|
||||
RequestGamePlayerStatePatchForSession(string, string, domain.GamePlayerStatePatchRequest) (domain.GamePlayerStatePatch, error)
|
||||
ApproveGamePlayerStatePatchForSession(string, string) (domain.GamePlayerStatePatch, error)
|
||||
ListGamePlayerStatePatchesForSession(string, string) ([]domain.GamePlayerStatePatch, error)
|
||||
CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
|
||||
GetAuditEvent(string) (domain.AuditEvent, error)
|
||||
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)
|
||||
|
||||
@@ -50,6 +50,10 @@ import type {
|
||||
GamePluginListResponse,
|
||||
GamePlayerListResponse,
|
||||
GamePlayerProfileResponse,
|
||||
GamePlayerStatePatchListResponse,
|
||||
GamePlayerStatePatchRequest,
|
||||
GamePlayerStatePatchResponse,
|
||||
GamePlayerStateResponse,
|
||||
HealthResponse,
|
||||
JobCreateRequest,
|
||||
JobListResponse,
|
||||
@@ -585,6 +589,11 @@ export class PlatformApiClient {
|
||||
return this.request<GamePlayerProfileResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}`);
|
||||
}
|
||||
|
||||
async getGamePlayerState(serverInstanceId: string, playerId: string): Promise<GamePlayerStateResponse> { return this.request<GamePlayerStateResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state`); }
|
||||
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 getBackup(id: string): Promise<BackupResponse> {
|
||||
return this.request<BackupResponse>(`/backups/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
@@ -304,6 +304,12 @@ export interface GameAccessAttemptResponse { id: string; occurredAt: string; out
|
||||
export interface GameSecuritySignalResponse { id: string; ruleKey: string; status: "open" | "review-required" | "expired"; evidenceCount: number; summary: string; firstObservedAt: string; lastObservedAt: string; }
|
||||
export interface GamePlayerProfileResponse { player: GamePlayerResponse; aliases: GamePlayerAliasResponse[]; sessions: GamePlayerSessionResponse[]; accessAttempts: GameAccessAttemptResponse[]; securitySignals: GameSecuritySignalResponse[]; }
|
||||
export interface GamePlayerListResponse { items: GamePlayerResponse[]; }
|
||||
export interface GamePlayerStateFieldResponse { key: string; label: string; kind: "skill" | "attribute"; minimum: number; maximum: number; value: number; }
|
||||
export interface GamePlayerStateResponse { gameVersion: string; stateVersion: string; safetyWindow?: string; maintenanceVerified: boolean; playerOnline: boolean; supported: boolean; fields: GamePlayerStateFieldResponse[]; observedAt: string; }
|
||||
export interface GamePlayerStatePatchChangeRequest { fieldKey: string; before: number; after: number; }
|
||||
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 RuntimeTransportProfileResponse {
|
||||
key: string;
|
||||
|
||||
@@ -5,6 +5,9 @@ describe("GamePlayerIntelligencePanel", () => {
|
||||
it("renders reviewable labels without raw network material or enforcement controls", () => {
|
||||
expect(source).toContain("需要人工审核");
|
||||
expect(source).toContain("失败尝试");
|
||||
expect(source).toContain("受控技能与角色属性修改");
|
||||
expect(source).toContain("等待平台管理员审批");
|
||||
expect(source).toContain("写后读取确认失败");
|
||||
expect(source).not.toContain("networkFingerprint");
|
||||
expect(source).not.toMatch(/\bban\b|\bkick\b/i);
|
||||
});
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { RefreshCw, ShieldAlert, UsersRound } from "lucide-react";
|
||||
import { CheckCircle2, RefreshCw, ShieldAlert, SlidersHorizontal, UsersRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GamePlayerProfileResponse, GamePlayerResponse } from "../api/types";
|
||||
import type { GamePlayerProfileResponse, GamePlayerResponse, GamePlayerStatePatchResponse, GamePlayerStateResponse } from "../api/types";
|
||||
import { ErrorState, LoadingState } from "./StateViews";
|
||||
|
||||
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; players: GamePlayerResponse[]; selected?: GamePlayerProfileResponse };
|
||||
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 };
|
||||
|
||||
export function GamePlayerIntelligencePanel({ serverInstanceId }: { serverInstanceId: string }) {
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const load = useCallback(async () => { setState({ status: "loading" }); try { const result = await platformApiClient.listGamePlayers(serverInstanceId); setState({ status: "ready", players: result.items }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "玩家档案读取失败。" }); } }, [serverInstanceId]);
|
||||
const load = useCallback(async () => { setState({ status: "loading" }); try { const result = await platformApiClient.listGamePlayers(serverInstanceId); setState({ status: "ready", players: result.items }); } catch (error) { setState({ status: "error", reason: message(error, "玩家档案读取失败。") }); } }, [serverInstanceId]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
async function select(player: GamePlayerResponse) { try { const selected = await platformApiClient.getGamePlayerProfile(serverInstanceId, player.id); setState((current) => current.status === "ready" ? { ...current, selected } : current); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "玩家档案读取失败。" }); } }
|
||||
async function select(player: GamePlayerResponse) { try { const [profile, stateSnapshot, patchList] = await Promise.all([platformApiClient.getGamePlayerProfile(serverInstanceId, player.id), platformApiClient.getGamePlayerState(serverInstanceId, player.id), platformApiClient.listGamePlayerStatePatches(serverInstanceId, player.id)]); const after = Object.fromEntries(stateSnapshot.fields.map((field) => [field.key, String(field.value)])); setState((current) => current.status === "ready" ? { ...current, selected: { profile, state: stateSnapshot, patches: patchList.items, reason: "", after } } : current); } catch (error) { setState((current) => current.status === "ready" ? { ...current, selected: { profile: { player, aliases: [], sessions: [], accessAttempts: [], securitySignals: [] }, patches: [], reason: "", after: {}, note: message(error, "玩家状态快照不可用,已禁用修改。") } } : { status: "error", reason: message(error, "玩家档案读取失败。") }); } }
|
||||
async function submitPatch() { if (state.status !== "ready") return; const selected = state.selected; if (!selected || !selected.state) return; const snapshot = selected.state; const changes = snapshot.fields.filter((field) => selected.after[field.key] !== String(field.value)).map((field) => ({ fieldKey: field.key, before: field.value, after: Number(selected.after[field.key]) })); try { const patch = await platformApiClient.requestGamePlayerStatePatch(serverInstanceId, selected.profile.player.id, { gameVersion: snapshot.gameVersion, expectedStateVersion: snapshot.stateVersion, safetyWindow: snapshot.safetyWindow || "", reason: selected.reason, changes }); setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, patches: [patch, ...current.selected.patches], note: "修改申请已保存,等待平台管理员审批。" } } : current); } catch (error) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, note: message(error, "修改申请未被接受。") } } : current); } }
|
||||
async function approvePatch(patch: GamePlayerStatePatchResponse) { if (state.status !== "ready" || !state.selected) return; try { const approved = await platformApiClient.approveGamePlayerStatePatch(serverInstanceId, state.selected.profile.player.id, patch.id); setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, patches: current.selected.patches.map((item) => item.id === approved.id ? approved : item), note: "已批准并通过受控游戏通道排队执行。" } } : current); } catch (error) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, note: message(error, "审批失败。") } } : current); } }
|
||||
if (state.status === "loading") return <LoadingState label="正在加载本地游戏玩家档案…" />;
|
||||
if (state.status === "error") return <ErrorState title="玩家档案不可用" reason={state.reason} onRetry={() => void load()} />;
|
||||
const profile = 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>{profile && <div className="console-module"><div className="panel-header"><h3>{profile.player.displayName} 的审阅记录</h3><span className="page-status">别名 {profile.aliases.length} · 会话 {profile.sessions.length}</span></div><div className="console-row-list"><div className="console-row"><strong>别名</strong><span>{profile.aliases.map((alias) => alias.alias).join(" / ") || "无"}</span></div>{profile.sessions.slice(0, 10).map((session) => <div className="console-row" key={session.id}><strong>会话</strong><span>{formatTime(session.startedAt)} → {session.endedAt ? formatTime(session.endedAt) : "仍在线"} {session.endReason ? `(${session.endReason})` : ""}</span></div>)}</div><div className="console-record-list">{profile.securitySignals.length ? profile.securitySignals.map((signal) => <div className="console-record" key={signal.id}><ShieldAlert size={15} aria-hidden="true" /><strong>{signal.ruleKey}</strong><span>{signal.status === "review-required" ? "需要人工审核" : signal.status}</span><small>{signal.summary} · 证据 {signal.evidenceCount} 条</small></div>) : <p className="page-status">未发现需要人工审核的风险信号。</p>}</div>{profile.accessAttempts.length > 0 && <p className="page-status">失败尝试:{profile.accessAttempts.length} 条(仅显示结果与时间,不显示网络标识)。</p>}</div>}</section>;
|
||||
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>;
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const writable = Boolean(selected.state?.supported && selected.state.maintenanceVerified && !selected.state.playerOnline && selected.state.safetyWindow);
|
||||
return <div className="console-module"><div className="panel-header"><h3>{selected.profile.player.displayName} 的审阅记录</h3><span className="page-status">别名 {selected.profile.aliases.length} · 会话 {selected.profile.sessions.length}</span></div><div className="console-row-list"><div className="console-row"><strong>别名</strong><span>{selected.profile.aliases.map((alias) => alias.alias).join(" / ") || "无"}</span></div>{selected.profile.sessions.slice(0, 10).map((session) => <div className="console-row" key={session.id}><strong>会话</strong><span>{formatTime(session.startedAt)} → {session.endedAt ? formatTime(session.endedAt) : "仍在线"} {session.endReason ? `(${session.endReason})` : ""}</span></div>)}</div><div className="console-record-list">{selected.profile.securitySignals.length ? selected.profile.securitySignals.map((signal) => <div className="console-record" key={signal.id}><ShieldAlert size={15} aria-hidden="true" /><strong>{signal.ruleKey}</strong><span>{signal.status === "review-required" ? "需要人工审核" : signal.status}</span><small>{signal.summary} · 证据 {signal.evidenceCount} 条</small></div>) : <p className="page-status">未发现需要人工审核的风险信号。</p>}</div>{selected.profile.accessAttempts.length > 0 && <p className="page-status">失败尝试:{selected.profile.accessAttempts.length} 条(仅显示结果与时间,不显示网络标识)。</p>}<div className="console-module"><div className="panel-header"><h3><SlidersHorizontal size={15} /> 受控技能与角色属性修改</h3><span className="page-status">{writable ? "已验证维护/离线安全窗口" : "未验证版本或安全窗口,已禁用修改"}</span></div>{selected.state && <><p className="provider-id">版本:{selected.state.gameVersion} · 状态版本:{selected.state.stateVersion}。仅显示此服务器版本明确支持的字段。</p><div className="console-row-list">{selected.state.fields.map((field) => <label className="console-row" key={field.key}><strong>{field.label}</strong><span>当前值 {field.value},范围 {field.minimum}–{field.maximum}</span><input aria-label={`${field.label} 修改后值`} type="number" min={field.minimum} max={field.maximum} step="0.1" disabled={!writable} value={selected.after[field.key] ?? ""} onChange={(event) => onAfter(field.key, event.target.value)} /></label>)}</div><label>修改原因<textarea value={selected.reason} disabled={!writable} maxLength={240} onChange={(event) => onReason(event.target.value)} placeholder="说明需要修改的运营原因(4–240 字)" /></label><button type="button" className="command-button" disabled={!writable} onClick={onSubmit}>提交修改申请</button></>}{selected.note && <p className="page-status">{selected.note}</p>}<div className="console-record-list">{selected.patches.map((patch) => <div className="console-record" key={patch.id}><strong>{patchStatus(patch.status)}</strong><span>{patch.changes.map((change) => `${change.fieldKey}: ${change.before} → ${change.after}`).join(";")}</span><small>原因:{patch.reason} · 申请人:{patch.requesterId}{patch.approverId ? ` · 审批人:${patch.approverId}` : ""}{patch.executionSummary ? ` · 执行结果:${patch.executionSummary}` : ""}{patch.confirmedStateVersion ? ` · 确认状态版本:${patch.confirmedStateVersion}` : ""}</small>{patch.status === "pending-approval" && <button type="button" className="icon-command" onClick={() => onApprove(patch)}><CheckCircle2 size={14} /><span>平台管理员审批</span></button>}</div>)}</div></div></div>;
|
||||
}
|
||||
function setSelected(setState: React.Dispatch<React.SetStateAction<State>>, reason: string) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, reason } } : current); }
|
||||
function setAfter(setState: React.Dispatch<React.SetStateAction<State>>, key: string, value: string) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, after: { ...current.selected.after, [key]: value } } } : current); }
|
||||
function patchStatus(status: GamePlayerStatePatchResponse["status"]) { return ({ "pending-approval": "等待平台管理员审批", queued: "已批准,等待游戏侧执行", "execution-failed": "游戏侧执行失败", "execution-unknown": "游戏侧执行结果未知", "confirmation-failed": "写后读取确认失败", confirmed: "已确认生效" })[status]; }
|
||||
function message(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; }
|
||||
function formatTime(value: string) { const date = new Date(value); return Number.isNaN(date.valueOf()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
|
||||
|
||||
@@ -169,6 +169,16 @@
|
||||
"resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 4096
|
||||
},
|
||||
{
|
||||
"type": "game-state.patch",
|
||||
"title": "Patch SCUM player state",
|
||||
"permission": "server.game-client.maintenance",
|
||||
"approvalLevel": "platform-admin",
|
||||
"payloadSchemaRef": "schemas/bridge/game-state-patch.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 4096
|
||||
}
|
||||
],
|
||||
"snapshots": [
|
||||
@@ -193,6 +203,13 @@
|
||||
"keepForSeconds": 86400,
|
||||
"maxRecords": 1000
|
||||
},
|
||||
{
|
||||
"type": "player.state",
|
||||
"schemaVersion": "1",
|
||||
"schemaRef": "schemas/bridge/player-state.snapshot.schema.json",
|
||||
"keepForSeconds": 86400,
|
||||
"maxRecords": 1000
|
||||
},
|
||||
{
|
||||
"type": "squads",
|
||||
"schemaVersion": "1",
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMGameStatePatchPayload",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "gameVersion", "expectedStateVersion", "safetyWindow", "reason", "changes"],
|
||||
"properties": {
|
||||
"playerId": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"gameVersion": { "const": "0.9.700.90357" },
|
||||
"expectedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"reason": { "type": "string", "minLength": 4, "maxLength": 240 },
|
||||
"changes": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "object", "additionalProperties": false, "required": ["fieldKey", "before", "after"], "properties": { "fieldKey": { "enum": ["skills.running", "attributes.strength"] }, "before": { "type": "number", "minimum": 0, "maximum": 10 }, "after": { "type": "number", "minimum": 0, "maximum": 10 } } } }
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMGameStatePatchResult",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "confirmedStateVersion", "confirmedFields"],
|
||||
"properties": {
|
||||
"status": { "enum": ["confirmed", "rejected", "failed"] },
|
||||
"confirmedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"confirmedFields": { "type": "object", "additionalProperties": false, "required": ["skills.running", "attributes.strength"], "properties": { "skills.running": { "type": "number", "minimum": 0, "maximum": 10 }, "attributes.strength": { "type": "number", "minimum": 0, "maximum": 10 } } },
|
||||
"message": { "type": "string", "maxLength": 200 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SCUMPlayerStateSnapshot",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "gameVersion", "stateVersion", "maintenanceVerified", "playerOnline", "fields"],
|
||||
"properties": {
|
||||
"playerId": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"gameVersion": { "type": "string", "maxLength": 64, "pattern": "^[0-9][0-9A-Za-z._-]{0,63}$" },
|
||||
"stateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"maintenanceVerified": { "type": "boolean" },
|
||||
"playerOnline": { "type": "boolean" },
|
||||
"fields": { "type": "object", "additionalProperties": false, "required": ["skills.running", "attributes.strength"], "properties": { "skills.running": { "type": "number", "minimum": 0, "maximum": 10 }, "attributes.strength": { "type": "number", "minimum": 0, "maximum": 10 } } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user