Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/config-key-specs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"kit": minor
---

Declared config key specs: `GameMeta.Config []ConfigKeySpec` (Go) /
`Meta.config: &[ConfigKeySpec]` (Rust) lets a game declare the admin-settable
config keys it reads — key, title, description, value type
(`text`/`number`/`bool`/`json`), the default used when unset, and (json keys)
an optional JSON Schema — so the arcade's admin tools can render typed get/edit
forms instead of a blind key/value prompt. Carried as a trailing
presence-guarded section of the packed Meta (ABI.md §4.2): old payloads decode
with no specs, old hosts ignore the trailing bytes — ABI major stays 2.
`wire.ValidateConfigSpecs` is the shared authoring rule set (unique non-empty
keys, no reserved `host.` prefix, schema only on json keys and well-formed),
enforced at `meta()` encode time by both SDKs; the Rust encoding is pinned
byte-identical to Go by a golden vector.
21 changes: 21 additions & 0 deletions ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,32 @@ str quickModeLabel · str soloModeLabel · str privateInviteLine ("" = default
u8 hasLeaderboard
if 1: str metricLabel · u8 direction (0 higher · 1 lower)
· u8 aggregation (0 best · 1 sum) · u8 format (0 int · 1 decimal · 2 duration)
u16 configSpecCount (trailing; see below)
per spec: str key · str title · str description
· u8 type (0 text · 1 number · 2 bool · 3 json)
· str default ("" = not declared) · str schema ("" = none; json only)
```

`slug` must be non-empty; the host refuses artifacts whose slug or version it
cannot accept.

**Config-spec section (minor addition).** The trailing config-spec section
declares the game's admin-settable config keys (the ones it reads at runtime
via `config_get`) so the platform's admin tools can render typed get/edit
forms. It is **presence-guarded**: a payload that ends immediately after the
leaderboard block is a valid pre-section meta with zero specs, and a host that
predates the section ignores the trailing bytes (the trailing-bytes tolerance
both sides already obey). Encoders that know the section always write it,
count `0` when nothing is declared.

Declared specs must satisfy: keys non-empty and unique; keys must NOT use the
reserved `host.` prefix (those knobs are declared by the platform, never the
game); `type` is one of the four assigned codes; `schema`, when non-empty, is
allowed only on `json`-typed keys and must itself be well-formed JSON (it is
intended to be a JSON Schema document — compilation and enforcement are a host
concern). The Go SDK enforces these rules at `meta()` encode time, and
`wire.ValidateConfigSpecs` is the shared rule set for decoders.

### 4.3 Frame (the delta container and its cell)

A frame is delivered as a **frame-delta container** (§4.5), a variable-length
Expand Down
19 changes: 19 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,25 @@ Per-game configuration (tunable by arcade admins without your involvement)
arrives through `r.Services().Config.Get(ctx, "key")` — read it on a slow
cadence in `OnWake` and fall back to compiled defaults when absent.

Declare the keys you read in `GameMeta.Config` so the arcade's admin tools can
render a real editor for them instead of a blind key/value prompt:

```go
Config: []kit.ConfigKeySpec{{
Key: "odds-variant",
Title: "Odds variant",
Description: "PAR sheet: per-symbol reel weights plus the paytable.",
Type: kit.ConfigJSON, // or ConfigText / ConfigNumber / ConfigBool
Default: defaultVariantJSON, // what you use when the key is unset
Schema: variantSchemaJSON, // optional JSON Schema → rich form editing
}},
```

Declarations are optional and validated at `meta()` time: keys must be unique,
non-empty, and never under the reserved `host.` prefix, and `Schema` (json keys
only) must parse as JSON. Admins can still set undeclared keys — specs improve
the editor, they don't gate the store.

## Multiplayer

Rooms hold 1–N players (your `GameMeta` declares the range). Render
Expand Down
17 changes: 17 additions & 0 deletions internal/game/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ func encodeMeta(m GameMeta) []byte {
wm.Aggregation = uint8(m.Leaderboard.Aggregation)
wm.Format = uint8(m.Leaderboard.Format)
}
for _, cs := range m.Config {
wm.ConfigSpecs = append(wm.ConfigSpecs, wire.ConfigSpec{
Key: cs.Key,
Title: cs.Title,
Description: cs.Description,
Type: uint8(cs.Type),
Default: cs.Default,
Schema: cs.Schema,
})
}
// Declared specs are validated here so an authoring mistake fails loudly
// at meta() time — surfaced by `shellcade-kit check`, the dev runner, and
// the host's load-time throwaway instance — same fail-fast posture as a
// compiled-in default that doesn't compile.
if err := wire.ValidateConfigSpecs(wm.ConfigSpecs); err != nil {
panic("kit: invalid GameMeta.Config: " + err.Error())
}
return wire.EncodeMeta(wm)
}

Expand Down
47 changes: 47 additions & 0 deletions internal/game/codec_meta_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package game

import (
"reflect"
"testing"

"github.com/shellcade/kit/v2/wire"
)

// TestEncodeMetaMapsConfigSpecs pins the authoring → wire mapping for declared
// config key specs, field by field and in declaration order.
func TestEncodeMetaMapsConfigSpecs(t *testing.T) {
m := GameMeta{
Slug: "pokies", Name: "Pokies", MinPlayers: 1, MaxPlayers: 5,
Config: []ConfigKeySpec{
{Key: "odds-variant", Title: "Odds variant", Description: "PAR sheet.",
Type: ConfigJSON, Default: `{"name":"Default"}`, Schema: `{"type":"object"}`},
{Key: "motd", Title: "Banner", Description: "Floor banner.", Type: ConfigText},
},
}
out, err := wire.DecodeMeta(encodeMeta(m))
if err != nil {
t.Fatal(err)
}
want := []wire.ConfigSpec{
{Key: "odds-variant", Title: "Odds variant", Description: "PAR sheet.",
Type: wire.ConfigJSON, Default: `{"name":"Default"}`, Schema: `{"type":"object"}`},
{Key: "motd", Title: "Banner", Description: "Floor banner.", Type: wire.ConfigText},
}
if !reflect.DeepEqual(out.ConfigSpecs, want) {
t.Fatalf("config specs mismatch:\n got=%+v\nwant=%+v", out.ConfigSpecs, want)
}
}

// TestEncodeMetaPanicsOnInvalidConfig pins the fail-fast posture: an invalid
// declaration is an authoring bug that surfaces at meta() time.
func TestEncodeMetaPanicsOnInvalidConfig(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("want panic for a host.-prefixed config spec key")
}
}()
encodeMeta(GameMeta{
Slug: "x", Name: "X", MinPlayers: 1, MaxPlayers: 1,
Config: []ConfigKeySpec{{Key: "host.heartbeat_ms", Type: ConfigNumber}},
})
}
29 changes: 29 additions & 0 deletions internal/game/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,29 @@ type LeaderboardSpec struct {
Format MetricFormat
}

// ConfigType tells the platform's admin surface how to render and validate a
// declared config value (values match the wire type codes).
type ConfigType uint8

const (
ConfigText ConfigType = iota // single-line string
ConfigNumber // decimal number
ConfigBool // true/false
ConfigJSON // JSON document (multiline / rich form)
)

// ConfigKeySpec declares one admin-settable config key the game reads via
// Services.Config. Declaring specs is optional; they exist so the platform's
// admin tools can render a real get/edit surface for the game's keys.
type ConfigKeySpec struct {
Key string // the ConfigStore key the game reads
Title string // short admin-facing label
Description string // one or two sentences for the admin screen
Type ConfigType // how the value is edited/validated
Default string // value the game uses when unset ("" = not declared)
Schema string // JSON Schema document (ConfigJSON only; "" = none)
}

// GameMeta is the static game metadata (mirrors native sdk.GameMeta).
type GameMeta struct {
Slug string
Expand All @@ -154,6 +177,12 @@ type GameMeta struct {
PrivateInviteLine string

Leaderboard *LeaderboardSpec

// Config optionally declares the game's admin-settable config keys.
// Nil/empty means the game declares no config surface (the platform's
// generic editor still works). Declarations are validated at meta encode
// time — an invalid spec list is an authoring bug and panics there.
Config []ConfigKeySpec
}

// Status is a player's terminal outcome.
Expand Down
7 changes: 7 additions & 0 deletions kit.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ type (
MergeRule = game.MergeRule
GameMeta = game.GameMeta
LeaderboardSpec = game.LeaderboardSpec
ConfigKeySpec = game.ConfigKeySpec
ConfigType = game.ConfigType
Direction = game.Direction
Aggregation = game.Aggregation
MetricFormat = game.MetricFormat
Expand All @@ -90,6 +92,11 @@ const (

HigherBetter = game.HigherBetter
LowerBetter = game.LowerBetter

ConfigText = game.ConfigText
ConfigNumber = game.ConfigNumber
ConfigBool = game.ConfigBool
ConfigJSON = game.ConfigJSON
BestResult = game.BestResult
SumResults = game.SumResults
Integer = game.Integer
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading