feat(scum): rebuild plugin-owned management data
This commit is contained in:
@@ -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 := ¬ificationPortFixture{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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user