From f496fb12ec44b1571c74f5120bcf153126c84029 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Tue, 28 Jul 2026 16:10:06 +0800 Subject: [PATCH] feat: add controlled scum player state patches --- .../.openspec.yaml | 4 + .../add-scum-player-state-patch/README.md | 3 + .../add-scum-player-state-patch/design.md | 42 +++ .../add-scum-player-state-patch/proposal.md | 22 ++ .../specs/scum-player-state-patch/spec.md | 48 +++ .../add-scum-player-state-patch/tasks.md | 21 ++ platform/api/game_player_handlers.go | 82 +++++ platform/api/resource_handlers.go | 3 + platform/domain/game_player_state_patch.go | 114 +++++++ platform/dto/game_player_state_patch.go | 91 ++++++ platform/model/game_player_state_patch.go | 27 ++ platform/repo/file_store.go | 7 +- platform/repo/mysql_store.go | 6 +- platform/repo/resources.go | 93 +++--- platform/service/game_player_state_patch.go | 284 ++++++++++++++++++ .../service/game_player_state_patch_test.go | 109 +++++++ platform/service/resources.go | 4 + platform_web/api/client.ts | 9 + platform_web/api/types.ts | 6 + .../GamePlayerIntelligencePanel.test.tsx | 3 + .../GamePlayerIntelligencePanel.tsx | 26 +- .../examples/scum-server-plugin/manifest.json | 17 ++ .../game-state-patch.payload.schema.json | 15 + .../game-state-patch.result.schema.json | 13 + .../bridge/player-state.snapshot.schema.json | 16 + 25 files changed, 1017 insertions(+), 48 deletions(-) create mode 100644 openspec/changes/add-scum-player-state-patch/.openspec.yaml create mode 100644 openspec/changes/add-scum-player-state-patch/README.md create mode 100644 openspec/changes/add-scum-player-state-patch/design.md create mode 100644 openspec/changes/add-scum-player-state-patch/proposal.md create mode 100644 openspec/changes/add-scum-player-state-patch/specs/scum-player-state-patch/spec.md create mode 100644 openspec/changes/add-scum-player-state-patch/tasks.md create mode 100644 platform/domain/game_player_state_patch.go create mode 100644 platform/dto/game_player_state_patch.go create mode 100644 platform/model/game_player_state_patch.go create mode 100644 platform/service/game_player_state_patch.go create mode 100644 platform/service/game_player_state_patch_test.go create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.payload.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/game-state-patch.result.schema.json create mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/player-state.snapshot.schema.json diff --git a/openspec/changes/add-scum-player-state-patch/.openspec.yaml b/openspec/changes/add-scum-player-state-patch/.openspec.yaml new file mode 100644 index 0000000..4cb23b4 --- /dev/null +++ b/openspec/changes/add-scum-player-state-patch/.openspec.yaml @@ -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. diff --git a/openspec/changes/add-scum-player-state-patch/README.md b/openspec/changes/add-scum-player-state-patch/README.md new file mode 100644 index 0000000..4bd21ba --- /dev/null +++ b/openspec/changes/add-scum-player-state-patch/README.md @@ -0,0 +1,3 @@ +# add-scum-player-state-patch + +SCUM player detail and approved version-scoped attribute patching diff --git a/openspec/changes/add-scum-player-state-patch/design.md b/openspec/changes/add-scum-player-state-patch/design.md new file mode 100644 index 0000000..ef01f96 --- /dev/null +++ b/openspec/changes/add-scum-player-state-patch/design.md @@ -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. diff --git a/openspec/changes/add-scum-player-state-patch/proposal.md b/openspec/changes/add-scum-player-state-patch/proposal.md new file mode 100644 index 0000000..2b22e65 --- /dev/null +++ b/openspec/changes/add-scum-player-state-patch/proposal.md @@ -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. diff --git a/openspec/changes/add-scum-player-state-patch/specs/scum-player-state-patch/spec.md b/openspec/changes/add-scum-player-state-patch/specs/scum-player-state-patch/spec.md new file mode 100644 index 0000000..4c1d72d --- /dev/null +++ b/openspec/changes/add-scum-player-state-patch/specs/scum-player-state-patch/spec.md @@ -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 diff --git a/openspec/changes/add-scum-player-state-patch/tasks.md b/openspec/changes/add-scum-player-state-patch/tasks.md new file mode 100644 index 0000000..5752f6d --- /dev/null +++ b/openspec/changes/add-scum-player-state-patch/tasks.md @@ -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. diff --git a/platform/api/game_player_handlers.go b/platform/api/game_player_handlers.go index 90e737c..d2033a9 100644 --- a/platform/api/game_player_handlers.go +++ b/platform/api/game_player_handlers.go @@ -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. diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 98acca2..5011449 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -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) diff --git a/platform/domain/game_player_state_patch.go b/platform/domain/game_player_state_patch.go new file mode 100644 index 0000000..9af4fc7 --- /dev/null +++ b/platform/domain/game_player_state_patch.go @@ -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 +} diff --git a/platform/dto/game_player_state_patch.go b/platform/dto/game_player_state_patch.go new file mode 100644 index 0000000..a1e8192 --- /dev/null +++ b/platform/dto/game_player_state_patch.go @@ -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} +} diff --git a/platform/model/game_player_state_patch.go b/platform/model/game_player_state_patch.go new file mode 100644 index 0000000..41d66f5 --- /dev/null +++ b/platform/model/game_player_state_patch.go @@ -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" } diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index c614b84..c2de7d8 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -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 { diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index de67551..fe5a4f3 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -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) } diff --git a/platform/repo/resources.go b/platform/repo/resources.go index d817b1e..416f339 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -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) +} diff --git a/platform/service/game_player_state_patch.go b/platform/service/game_player_state_patch.go new file mode 100644 index 0000000..25c0697 --- /dev/null +++ b/platform/service/game_player_state_patch.go @@ -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 +} diff --git a/platform/service/game_player_state_patch_test.go b/platform/service/game_player_state_patch_test.go new file mode 100644 index 0000000..1886838 --- /dev/null +++ b/platform/service/game_player_state_patch_test.go @@ -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: "修正受审核的角色跑步技能"} +} diff --git a/platform/service/resources.go b/platform/service/resources.go index 597ca12..f5365c4 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -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) diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 90a2eeb..9b9125c 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -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(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}`); } + async getGamePlayerState(serverInstanceId: string, playerId: string): Promise { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state`); } + async listGamePlayerStatePatches(serverInstanceId: string, playerId: string): Promise { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`); } + async requestGamePlayerStatePatch(serverInstanceId: string, playerId: string, request: GamePlayerStatePatchRequest): Promise { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`, { method: "POST", body: request }); } + async approveGamePlayerStatePatch(serverInstanceId: string, playerId: string, patchId: string): Promise { return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches/${encodeURIComponent(patchId)}/approve`, { method: "POST" }); } + async getBackup(id: string): Promise { return this.request(`/backups/${encodeURIComponent(id)}`); } diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index c616a5d..9906a86 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -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; diff --git a/platform_web/components/GamePlayerIntelligencePanel.test.tsx b/platform_web/components/GamePlayerIntelligencePanel.test.tsx index 88e70f5..0fe5dce 100644 --- a/platform_web/components/GamePlayerIntelligencePanel.test.tsx +++ b/platform_web/components/GamePlayerIntelligencePanel.test.tsx @@ -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); }); diff --git a/platform_web/components/GamePlayerIntelligencePanel.tsx b/platform_web/components/GamePlayerIntelligencePanel.tsx index f5aaa8b..e0e7707 100644 --- a/platform_web/components/GamePlayerIntelligencePanel.tsx +++ b/platform_web/components/GamePlayerIntelligencePanel.tsx @@ -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; 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({ 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 ; if (state.status === "error") return void load()} />; - const profile = state.selected; - return

本地玩家档案与登录轨迹

游戏身份独立于平台账号;网络数据仅用于服务器内不可逆关联,绝不展示或持久化原始值。

{state.players.length ? state.players.map((player) => ) :

暂无已投影的成功登录记录。

}
{profile &&

{profile.player.displayName} 的审阅记录

别名 {profile.aliases.length} · 会话 {profile.sessions.length}
别名{profile.aliases.map((alias) => alias.alias).join(" / ") || "无"}
{profile.sessions.slice(0, 10).map((session) =>
会话{formatTime(session.startedAt)} → {session.endedAt ? formatTime(session.endedAt) : "仍在线"} {session.endReason ? `(${session.endReason})` : ""}
)}
{profile.securitySignals.length ? profile.securitySignals.map((signal) =>
) :

未发现需要人工审核的风险信号。

}
{profile.accessAttempts.length > 0 &&

失败尝试:{profile.accessAttempts.length} 条(仅显示结果与时间,不显示网络标识)。

}
}
; + const selected = state.selected; + return

本地玩家档案与登录轨迹

游戏身份独立于平台账号;网络数据仅用于服务器内不可逆关联,绝不展示或持久化原始值。

{state.players.length ? state.players.map((player) => ) :

暂无已投影的成功登录记录。

}
{selected && setSelected(setState, reason)} onAfter={(key, value) => setAfter(setState, key, value)} onSubmit={() => void submitPatch()} onApprove={(patch) => void approvePatch(patch)} />}
; } + +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

{selected.profile.player.displayName} 的审阅记录

别名 {selected.profile.aliases.length} · 会话 {selected.profile.sessions.length}
别名{selected.profile.aliases.map((alias) => alias.alias).join(" / ") || "无"}
{selected.profile.sessions.slice(0, 10).map((session) =>
会话{formatTime(session.startedAt)} → {session.endedAt ? formatTime(session.endedAt) : "仍在线"} {session.endReason ? `(${session.endReason})` : ""}
)}
{selected.profile.securitySignals.length ? selected.profile.securitySignals.map((signal) =>
) :

未发现需要人工审核的风险信号。

}
{selected.profile.accessAttempts.length > 0 &&

失败尝试:{selected.profile.accessAttempts.length} 条(仅显示结果与时间,不显示网络标识)。

}

受控技能与角色属性修改

{writable ? "已验证维护/离线安全窗口" : "未验证版本或安全窗口,已禁用修改"}
{selected.state && <>

版本:{selected.state.gameVersion} · 状态版本:{selected.state.stateVersion}。仅显示此服务器版本明确支持的字段。

{selected.state.fields.map((field) => )}