feat(scum): rebuild plugin-owned management data

This commit is contained in:
npc0-hue
2026-08-15 12:43:09 +08:00
parent 92b1159cc8
commit 65353cf269
113 changed files with 3116 additions and 8058 deletions
@@ -47,21 +47,59 @@ type StateFieldPatch struct {
After float64
}
// AuthorizedRewardPort accepts only a frozen grant and typed items. It cannot
// receive SQL, a raw database row, a shell command, an RCON command, or secrets.
// AuthorizedRewardPort accepts a frozen grant with typed items and operations.
type AuthorizedRewardPort interface {
DeliverReward(context.Context, RewardGrant) (DeliveryReceipt, error)
}
type RewardGrant struct {
GrantID string
PlayerID string
Items []RewardItem
GrantID string
PlayerID string
Items []RewardItem
Operations []string
}
type RewardItem struct {
CatalogCode string
Quantity int
}
type DeliveryReceipt struct{ Outcome string }
type DeliveryReceipt struct {
Outcome string
DeliveryID string
}
type AuthorizedEventPort interface {
StartEvent(context.Context, EventStartRequest) (EventStartReceipt, error)
}
type EventStartRequest struct {
EventID string
EventType string
Class int
Title string
Placard string
Percent int
NPC int
Item int
Zombie int
Animal int
Produces []EventProduceRequest
DurationSeconds int
MaxParticipants int
Announce bool
}
type EventProduceRequest struct {
TradeGoodsID string
Percent int
Value int
Radius float64
X float64
Y float64
Z float64
}
type EventStartReceipt struct {
Accepted bool
Status string
EventID string
Message string
}
const fixedNotificationType = 4
@@ -102,6 +140,7 @@ type RuntimeAdapter struct {
Config AuthorizedConfigPort
GameData AuthorizedGameDataPort
Rewards AuthorizedRewardPort
Events AuthorizedEventPort
Notification UE4SSNotificationPort
VehicleSpawn UE4SSVehicleSpawnPort
DiagnosticsState map[string]string
@@ -191,12 +230,45 @@ func (adapter RuntimeAdapter) DeliverReward(ctx context.Context, payload map[str
}
receipt, err := adapter.Rewards.DeliverReward(ctx, grant)
if err != nil || receipt.Outcome == "unknown" {
return map[string]any{"outcome": "unknown"}, nil
return map[string]any{"accepted": false, "status": "rejected", "message": "reward delivery result is unknown"}, nil
}
if receipt.Outcome != "delivered" {
return map[string]any{"outcome": "failed"}, nil
return map[string]any{"accepted": false, "status": "rejected"}, nil
}
return map[string]any{"outcome": "delivered"}, nil
result := map[string]any{"accepted": true, "status": "delivered"}
if receipt.DeliveryID != "" {
result["deliveryId"] = receipt.DeliveryID
}
return result, nil
}
func (adapter RuntimeAdapter) StartEvent(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.Events == nil {
return nil, errAdapterUnsupported
}
request, err := eventStartRequest(payload)
if err != nil {
return nil, err
}
receipt, err := adapter.Events.StartEvent(ctx, request)
if err != nil {
return nil, err
}
status := receipt.Status
if status == "" {
status = "rejected"
}
if status != "started" && status != "queued" && status != "rejected" {
return nil, fmt.Errorf("event start receipt is invalid")
}
eventID := receipt.EventID
if eventID == "" {
eventID = request.EventID
}
result := map[string]any{"accepted": receipt.Accepted, "status": status, "eventId": eventID}
if receipt.Message != "" {
result["message"] = receipt.Message
}
return result, nil
}
func (adapter RuntimeAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.BoundServerID == "" || adapter.Notification == nil {
@@ -342,22 +414,149 @@ func stateApplied(values map[string]float64, fields []StateFieldPatch) bool {
func rewardGrant(payload map[string]any) (RewardGrant, error) {
grantID, grantOK := payload["grantId"].(string)
playerID, playerOK := payload["playerId"].(string)
raw, itemsOK := payload["items"].([]any)
if !grantOK || !playerOK || !itemsOK || len(raw) == 0 || len(raw) > 8 {
rawItems, itemsOK := payload["items"].([]any)
rawOperations, operationsOK := payload["operations"].([]any)
if !grantOK || !playerOK || !itemsOK || !operationsOK || (len(rawItems) == 0 && len(rawOperations) == 0) || len(rawItems) > 8 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
items := make([]RewardItem, 0, len(raw))
for _, value := range raw {
items := make([]RewardItem, 0, len(rawItems))
for _, value := range rawItems {
item, ok := value.(map[string]any)
if !ok {
if !ok || len(item) != 2 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
code, codeOK := item["catalogCode"].(string)
quantity, quantityOK := item["quantity"].(float64)
if !codeOK || !quantityOK || quantity < 1 || quantity > 99 {
quantity, quantityOK := integerPayloadValue(item["quantity"])
if !codeOK || !supportedCatalogCode(code) || !quantityOK || quantity < 1 || quantity > 100 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
items = append(items, RewardItem{CatalogCode: code, Quantity: int(quantity)})
items = append(items, RewardItem{CatalogCode: code, Quantity: quantity})
}
return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items}, nil
operations := make([]string, 0, len(rawOperations))
for _, value := range rawOperations {
operation, ok := value.(string)
if !ok || operation == "" || !utf8.ValidString(operation) {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
operations = append(operations, operation)
}
return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items, Operations: operations}, nil
}
func eventStartRequest(payload map[string]any) (EventStartRequest, error) {
eventID, eventIDOK := payload["eventId"].(string)
eventType, eventTypeOK := payload["eventType"].(string)
eventClass, classOK := integerPayloadValue(payload["class"])
title, titleOK := payload["title"].(string)
duration, durationOK := integerPayloadValue(payload["durationSeconds"])
percent, percentOK := integerPayloadValue(payload["percent"])
placard, placardOK := payload["placard"].(string)
produces, producesOK := eventProduceRequests(payload["produces"])
counts := make([]int, 4)
for index, key := range []string{"npc", "item", "zombie", "animal"} {
if value, exists := payload[key]; exists {
count, ok := integerPayloadValue(value)
if !ok || count < 0 || count > 10000 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
counts[index] = count
}
}
participants := 0
if value, exists := payload["maxParticipants"]; exists {
var ok bool
participants, ok = integerPayloadValue(value)
if !ok || participants < 1 || participants > 1000 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
}
announce := false
if value, exists := payload["announce"]; exists {
var ok bool
announce, ok = value.(bool)
if !ok {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
}
if !eventIDOK || !eventTypeOK || !classOK || !titleOK || !durationOK || !percentOK || !placardOK || !producesOK || !supportedEventType(eventType) || eventClass < 1 || eventClass > 2 || (eventClass == 1) != (eventType == "range") || strings.TrimSpace(eventID) == "" || strings.TrimSpace(title) == "" || len(placard) > 500 || percent < 0 || percent > 100 || duration < 30 || duration > 86400 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
return EventStartRequest{EventID: eventID, EventType: eventType, Class: eventClass, Title: title, Placard: placard, Percent: percent, NPC: counts[0], Item: counts[1], Zombie: counts[2], Animal: counts[3], Produces: produces, DurationSeconds: duration, MaxParticipants: participants, Announce: announce}, nil
}
func eventProduceRequests(value any) ([]EventProduceRequest, bool) {
raw, ok := value.([]any)
if !ok || len(raw) > 100 {
return nil, false
}
result := make([]EventProduceRequest, 0, len(raw))
for _, candidate := range raw {
produce, ok := candidate.(map[string]any)
if !ok || len(produce) != 7 {
return nil, false
}
tradeGoodsID, idOK := produce["tradeGoodsId"].(string)
percent, percentOK := integerPayloadValue(produce["percent"])
quantity, quantityOK := integerPayloadValue(produce["value"])
radius, radiusOK := numberPayloadValue(produce["r"])
x, xOK := numberPayloadValue(produce["x"])
y, yOK := numberPayloadValue(produce["y"])
z, zOK := numberPayloadValue(produce["z"])
if !idOK || strings.TrimSpace(tradeGoodsID) == "" || len(tradeGoodsID) > 128 || !percentOK || percent < 0 || percent > 100 || !quantityOK || quantity < 1 || quantity > 10000 || !radiusOK || radius < 0 || radius > 2000000 || !xOK || !yOK || !zOK || x < -2000000 || x > 2000000 || y < -2000000 || y > 2000000 || z < -2000000 || z > 2000000 {
return nil, false
}
result = append(result, EventProduceRequest{TradeGoodsID: tradeGoodsID, Percent: percent, Value: quantity, Radius: radius, X: x, Y: y, Z: z})
}
return result, true
}
func integerPayloadValue(value any) (int, bool) {
switch number := value.(type) {
case float64:
if number != float64(int(number)) {
return 0, false
}
return int(number), true
case int:
return number, true
case int32:
return int(number), true
case int64:
return int(number), true
default:
return 0, false
}
}
func numberPayloadValue(value any) (float64, bool) {
switch number := value.(type) {
case float64:
return number, true
case float32:
return float64(number), true
case int:
return float64(number), true
case int32:
return float64(number), true
case int64:
return float64(number), true
default:
return 0, false
}
}
func supportedCatalogCode(value string) bool {
if value == "" || len(value) > 128 {
return false
}
for _, character := range value {
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '_' && character != '-' && character != '.' {
return false
}
}
return true
}
func supportedEventType(value string) bool {
return value == "range" || value == "fixed"
}
@@ -82,7 +82,7 @@ func e2eClaim(id, commandType string, payload map[string]any, stamp time.Time) C
}
func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
stamp := time.Now().UTC()
port := &isolatedAdapterPort{
configFields: map[string]string{"ServerName": "Moonlight", "Password": "never-return", "hostPath": "C:/private/server.ini"},
notifyAccept: true,
@@ -130,7 +130,7 @@ func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
}
func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
stamp := time.Now().UTC()
for name, testCase := range map[string]struct {
availability HandlerAvailability
adapter RuntimeAdapter
@@ -156,7 +156,7 @@ func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T
}
func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *testing.T) {
stamp := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
stamp := time.Now().UTC()
port := &isolatedAdapterPort{patchErr: errors.New("private port failed"), notifyErr: errors.New("private notification failed")}
adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port}
gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{
@@ -25,6 +25,28 @@ type notificationPortFixture struct {
accepted bool
}
type rewardPortFixture struct {
grants []RewardGrant
receipt DeliveryReceipt
err error
}
func (fixture *rewardPortFixture) DeliverReward(_ context.Context, grant RewardGrant) (DeliveryReceipt, error) {
fixture.grants = append(fixture.grants, grant)
return fixture.receipt, fixture.err
}
type eventPortFixture struct {
requests []EventStartRequest
receipt EventStartReceipt
err error
}
func (fixture *eventPortFixture) StartEvent(_ context.Context, request EventStartRequest) (EventStartReceipt, error) {
fixture.requests = append(fixture.requests, request)
return fixture.receipt, fixture.err
}
// nonProductionVehicleSpawnPortFixture is an isolated test double. It has no
// network, socket, credential, or raw-command entry point; it can observe only
// the Companion's private typed request and return a bounded receipt.
@@ -68,6 +90,87 @@ func TestRuntimeAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testi
}
}
func TestRewardDeliveryAcceptsRealSCUMCatalogCodesAndReturnsDeclaredResult(t *testing.T) {
port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered", DeliveryID: "delivery-1"}}
adapter := RuntimeAdapter{Rewards: port}
result, err := adapter.DeliverReward(context.Background(), map[string]any{
"grantId": "grant-1", "playerId": "76561198000000001",
"items": []any{map[string]any{"catalogCode": "BPC_Improvised_Backpack.01", "quantity": float64(2)}},
"operations": []any{"#SpawnItem BPC_Improvised_Backpack.01 2"},
})
if err != nil || result["accepted"] != true || result["status"] != "delivered" || result["deliveryId"] != "delivery-1" {
t.Fatalf("reward result did not match the bridge schema: result=%+v err=%v", result, err)
}
if len(port.grants) != 1 || port.grants[0].Items[0] != (RewardItem{CatalogCode: "BPC_Improvised_Backpack.01", Quantity: 2}) || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SpawnItem BPC_Improvised_Backpack.01 2" {
t.Fatalf("reward items or operations did not reach the typed reward port: %+v", port.grants)
}
before := len(port.grants)
if _, err := adapter.DeliverReward(context.Background(), map[string]any{
"grantId": "grant-2", "playerId": "76561198000000001",
"items": []any{map[string]any{"catalogCode": "#SpawnItem BPC_Bad", "quantity": float64(1)}},
"operations": []any{},
}); err == nil || len(port.grants) != before {
t.Fatal("invalid catalog text reached the typed reward port")
}
}
func TestRewardDeliverySupportsOperationsWithoutItemsAndRejectsEmptyGrant(t *testing.T) {
port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered"}}
adapter := RuntimeAdapter{BoundServerID: "server-1", Rewards: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"reward.deliver": true}}, adapter)
stamp := time.Now().UTC()
result, err := registry.Execute(context.Background(), ClaimedCommand{
ID: "reward-operations", ProfileKey: ProfileKey, CommandType: "reward.deliver", FencingToken: 1,
LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute),
Payload: map[string]any{"grantId": "grant-operations", "playerId": "76561198000000001", "items": []any{}, "operations": []any{"#SetFamePoints 250"}},
})
if err != nil || result.Status != "succeeded" || result.Payload["accepted"] != true || len(port.grants) != 1 || len(port.grants[0].Items) != 0 || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SetFamePoints 250" {
t.Fatalf("operation-only reward was not preserved: result=%+v grants=%+v err=%v", result, port.grants, err)
}
if _, err := adapter.DeliverReward(context.Background(), map[string]any{
"grantId": "grant-empty", "playerId": "76561198000000001", "items": []any{}, "operations": []any{},
}); err == nil || len(port.grants) != 1 {
t.Fatalf("empty reward reached the typed reward port: grants=%+v err=%v", port.grants, err)
}
}
func TestEventStartRequiresMatchingClassAndType(t *testing.T) {
payload := map[string]any{"eventId": "event-fixed", "eventType": "fixed", "class": float64(2), "title": "Fixed Event", "placard": "Hold the point", "percent": float64(100), "produces": []any{}, "durationSeconds": float64(600)}
request, err := eventStartRequest(payload)
if err != nil || request.EventType != "fixed" || request.Class != 2 {
t.Fatalf("fixed class event was not accepted: request=%+v err=%v", request, err)
}
payload["class"] = float64(1)
if _, err := eventStartRequest(payload); err == nil {
t.Fatal("divergent event class and type was accepted")
}
}
func TestEventStartHandlerInvokesTypedPortAndReturnsCachedCommandResult(t *testing.T) {
stamp := time.Now().UTC()
port := &eventPortFixture{receipt: EventStartReceipt{Accepted: true, Status: "started", EventID: "event-1", Message: "range event started"}}
adapter := RuntimeAdapter{BoundServerID: "server-1", Events: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"event.start": true}}, adapter)
command := ClaimedCommand{
ID: "event-command-1", ProfileKey: ProfileKey, CommandType: "event.start", FencingToken: 1,
LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute),
Payload: map[string]any{"eventId": "event-1", "eventType": "range", "class": float64(1), "title": "Friday Range", "placard": "Event starting", "percent": float64(75), "npc": float64(2), "item": float64(3), "zombie": float64(4), "animal": float64(1), "produces": []any{map[string]any{"tradeGoodsId": "cargo-drop", "percent": float64(80), "value": float64(2), "r": float64(500), "x": float64(1000), "y": float64(2000), "z": float64(300)}}, "durationSeconds": float64(1800), "maxParticipants": float64(40), "announce": true},
}
for range 2 {
result, err := registry.Execute(context.Background(), command)
if err != nil || result.Status != "succeeded" || result.Payload["accepted"] != true || result.Payload["status"] != "started" || result.Payload["eventId"] != "event-1" {
t.Fatalf("event.start did not return its executable result: result=%+v err=%v", result, err)
}
}
if len(port.requests) != 1 {
t.Fatalf("event.start handler did not execute exactly once: %+v", port.requests)
}
request := port.requests[0]
if request.EventID != "event-1" || request.EventType != "range" || request.Class != 1 || request.Title != "Friday Range" || request.Placard != "Event starting" || request.Percent != 75 || request.NPC != 2 || request.Item != 3 || request.Zombie != 4 || request.Animal != 1 || len(request.Produces) != 1 || request.Produces[0].TradeGoodsID != "cargo-drop" || request.DurationSeconds != 1800 || request.MaxParticipants != 40 || !request.Announce {
t.Fatalf("event.start payload did not reach the typed event port: %+v", request)
}
}
func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) {
port := &notificationPortFixture{accepted: true}
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
@@ -18,6 +18,7 @@ type SafeAdapter interface {
Diagnostics(context.Context) (map[string]any, error)
PatchGameState(context.Context, map[string]any) (map[string]any, error)
DeliverReward(context.Context, map[string]any) (map[string]any, error)
StartEvent(context.Context, map[string]any) (map[string]any, error)
NotifyPlayer(context.Context, map[string]any) (map[string]any, error)
SpawnVehicle(context.Context, map[string]any) (map[string]any, error)
}
@@ -62,6 +63,9 @@ func NewHandlerRegistry(availability HandlerAvailability, adapter SafeAdapter) *
registry.handlers["reward.deliver"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.DeliverReward(ctx, payload)
}
registry.handlers["event.start"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.StartEvent(ctx, payload)
}
registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.NotifyPlayer(ctx, payload)
}
@@ -183,19 +187,23 @@ func validateCommandPayload(commandType string, payload map[string]any) error {
}
return nil
case "reward.deliver":
if err := require("grantId", "playerId", "items"); err != nil {
if err := require("grantId", "playerId", "items", "operations"); err != nil {
return err
}
if err := noUnknown("grantId", "playerId", "items"); err != nil {
if err := noUnknown("grantId", "playerId", "items", "operations"); err != nil {
return err
}
_, grantOK := payload["grantId"].(string)
_, playerOK := payload["playerId"].(string)
items, itemsOK := payload["items"].([]any)
if !grantOK || !playerOK || !itemsOK || len(items) == 0 || len(items) > 8 {
return fmt.Errorf("reward payload is invalid")
_, err := rewardGrant(payload)
return err
case "event.start":
if err := require("eventId", "eventType", "class", "title", "placard", "percent", "produces", "durationSeconds"); err != nil {
return err
}
return nil
if err := noUnknown("eventId", "eventType", "class", "title", "placard", "percent", "npc", "item", "zombie", "animal", "produces", "durationSeconds", "maxParticipants", "announce"); err != nil {
return err
}
_, err := eventStartRequest(payload)
return err
case "player.notify":
if err := require("playerId", "message"); err != nil {
return err
@@ -42,6 +42,9 @@ func (*adapterFixture) PatchGameState(context.Context, map[string]any) (map[stri
func (*adapterFixture) DeliverReward(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
func (*adapterFixture) StartEvent(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
func (*adapterFixture) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
@@ -92,3 +95,21 @@ func TestRegistryRejectsUndeclaredAndMalformedPayloads(t *testing.T) {
}
}
}
func TestRewardPayloadValidationRequiresItemsOrOperations(t *testing.T) {
validBase := map[string]any{"grantId": "grant-1", "playerId": "76561198000000001"}
for name, payload := range map[string]map[string]any{
"items": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(1)}}, "operations": []any{}},
"operations": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{}, "operations": []any{"#SetFamePoints 250"}},
"both": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(1)}}, "operations": []any{"#SetFamePoints 250"}},
} {
t.Run(name, func(t *testing.T) {
if err := validateCommandPayload("reward.deliver", payload); err != nil {
t.Fatalf("valid reward payload was rejected: %v", err)
}
})
}
if err := validateCommandPayload("reward.deliver", map[string]any{"grantId": "grant-empty", "playerId": "76561198000000001", "items": []any{}, "operations": []any{}}); err == nil {
t.Fatal("reward payload with no items or operations was accepted")
}
}