diff --git a/.changeset/config-key-specs.md b/.changeset/config-key-specs.md new file mode 100644 index 0000000..1c50dd2 --- /dev/null +++ b/.changeset/config-key-specs.md @@ -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. diff --git a/ABI.md b/ABI.md index 9a2bfe7..c983eb2 100644 --- a/ABI.md +++ b/ABI.md @@ -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 diff --git a/GUIDE.md b/GUIDE.md index 7f41c0d..c777455 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -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 diff --git a/internal/game/codec.go b/internal/game/codec.go index 218c0ac..c42031d 100644 --- a/internal/game/codec.go +++ b/internal/game/codec.go @@ -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) } diff --git a/internal/game/codec_meta_test.go b/internal/game/codec_meta_test.go new file mode 100644 index 0000000..35684d1 --- /dev/null +++ b/internal/game/codec_meta_test.go @@ -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}}, + }) +} diff --git a/internal/game/types.go b/internal/game/types.go index a5d2682..adf2fd8 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -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 @@ -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. diff --git a/kit.go b/kit.go index ecf032d..f6601ac 100644 --- a/kit.go +++ b/kit.go @@ -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 @@ -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 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2cfc179..f2ff8b0 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -304,7 +304,7 @@ dependencies = [ [[package]] name = "shellcade-kit" -version = "2.1.1" +version = "2.2.0" dependencies = [ "extism-pdk", ] diff --git a/rust/src/json.rs b/rust/src/json.rs new file mode 100644 index 0000000..33eb880 --- /dev/null +++ b/rust/src/json.rs @@ -0,0 +1,235 @@ +//! A minimal RFC 8259 JSON well-formedness scanner — the dependency-free +//! counterpart of Go's `json.Valid`, used to validate declared config-spec +//! schemas at meta() encode time (ABI.md §4.2). It checks syntax only; it +//! allocates nothing and bounds nesting so a pathological document cannot +//! blow the guest stack. + +/// Nesting bound for arrays/objects. Schemas are shallow; 128 is generous. +const MAX_DEPTH: usize = 128; + +/// Reports whether `s` is a single well-formed JSON value. +pub(crate) fn valid(s: &str) -> bool { + let b = s.as_bytes(); + let mut p = Parser { b, i: 0 }; + p.ws(); + if !p.value(0) { + return false; + } + p.ws(); + p.i == b.len() +} + +struct Parser<'a> { + b: &'a [u8], + i: usize, +} + +impl Parser<'_> { + fn peek(&self) -> Option { + self.b.get(self.i).copied() + } + + fn ws(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.i += 1; + } + } + + fn eat(&mut self, c: u8) -> bool { + if self.peek() == Some(c) { + self.i += 1; + true + } else { + false + } + } + + fn lit(&mut self, lit: &[u8]) -> bool { + if self.b[self.i..].starts_with(lit) { + self.i += lit.len(); + true + } else { + false + } + } + + fn value(&mut self, depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + match self.peek() { + Some(b'{') => self.object(depth), + Some(b'[') => self.array(depth), + Some(b'"') => self.string(), + Some(b't') => self.lit(b"true"), + Some(b'f') => self.lit(b"false"), + Some(b'n') => self.lit(b"null"), + Some(b'-' | b'0'..=b'9') => self.number(), + _ => false, + } + } + + fn object(&mut self, depth: usize) -> bool { + self.i += 1; // '{' + self.ws(); + if self.eat(b'}') { + return true; + } + loop { + self.ws(); + if !self.string() { + return false; + } + self.ws(); + if !self.eat(b':') { + return false; + } + self.ws(); + if !self.value(depth + 1) { + return false; + } + self.ws(); + if self.eat(b'}') { + return true; + } + if !self.eat(b',') { + return false; + } + } + } + + fn array(&mut self, depth: usize) -> bool { + self.i += 1; // '[' + self.ws(); + if self.eat(b']') { + return true; + } + loop { + self.ws(); + if !self.value(depth + 1) { + return false; + } + self.ws(); + if self.eat(b']') { + return true; + } + if !self.eat(b',') { + return false; + } + } + } + + fn string(&mut self) -> bool { + if !self.eat(b'"') { + return false; + } + while let Some(c) = self.peek() { + self.i += 1; + match c { + b'"' => return true, + b'\\' => match self.peek() { + Some(b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't') => self.i += 1, + Some(b'u') => { + self.i += 1; + for _ in 0..4 { + match self.peek() { + Some(h) if h.is_ascii_hexdigit() => self.i += 1, + _ => return false, + } + } + } + _ => return false, + }, + // Raw control characters are forbidden inside strings. Any + // other byte (incl. multi-byte UTF-8, valid by &str input) + // passes through. + 0x00..=0x1f => return false, + _ => {} + } + } + false // unterminated + } + + fn number(&mut self) -> bool { + self.eat(b'-'); + // int part: 0, or 1-9 digits + match self.peek() { + Some(b'0') => self.i += 1, + Some(b'1'..=b'9') => { + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.i += 1; + } + } + _ => return false, + } + if self.eat(b'.') { + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return false; + } + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.i += 1; + } + } + if matches!(self.peek(), Some(b'e' | b'E')) { + self.i += 1; + if matches!(self.peek(), Some(b'+' | b'-')) { + self.i += 1; + } + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return false; + } + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.i += 1; + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::valid; + + #[test] + fn accepts_well_formed() { + for s in [ + "{}", + "[]", + "null", + "true", + "-1.5e+10", + r#""hi \"there\" é""#, + r#"{"name":"Default","weights":{"7":1},"paytable":[{"faces":"7","multiplier":500}]}"#, + " [1, 2, {\"a\": [false]}]\n", + ] { + assert!(valid(s), "want valid: {s}"); + } + } + + #[test] + fn rejects_malformed() { + for s in [ + "", + "{", + "{nope", + "[1,]", + "{\"a\":}", + "{\"a\" 1}", + "01", + "1.", + "1e", + "\"unterminated", + "\"bad \\x escape\"", + "true false", + "{\"a\":1} extra", + ] { + assert!(!valid(s), "want invalid: {s}"); + } + } + + #[test] + fn bounds_nesting() { + let deep = "[".repeat(1000) + &"]".repeat(1000); + assert!(!valid(&deep)); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 515d079..bf4b05c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -64,6 +64,7 @@ mod broadcast; mod frame; mod host; mod input; +mod json; mod rng; mod room; mod types; @@ -87,8 +88,8 @@ pub use frame::{ pub use input::{Action, Input, InputContext, Key}; pub use room::Room; pub use types::{ - Aggregation, Direction, Kind, Leaderboard, MergeRule, Meta, MetricFormat, Mode, Outcome, - Player, PlayerResult, RoomConfig, Status, + Aggregation, ConfigKeySpec, ConfigType, Direction, Kind, Leaderboard, MergeRule, Meta, + MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, }; // Native-only scriptable host double for `cargo test` of games and the SDK diff --git a/rust/src/types.rs b/rust/src/types.rs index b4ffe28..7423afa 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -136,6 +136,52 @@ pub struct Leaderboard { pub format: MetricFormat, } +/// Config value type for a declared config key (wire codes; ABI.md §4.2). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum ConfigType { + /// Single-line string. + #[default] + Text, + /// Decimal number. + Number, + /// true/false. + Bool, + /// JSON document (multiline / rich-form editing). + Json, +} + +/// One declared admin-settable config key (mirrors Go `ConfigKeySpec`): the +/// keys the game reads via `Room::config` declared in [`Meta::config`] so the +/// arcade's admin tools can render typed get/edit forms. Const-constructible +/// via `..ConfigKeySpec::DEFAULT`. +#[derive(Clone, Copy, Debug)] +pub struct ConfigKeySpec { + /// The config key the game reads. Non-empty, unique, never `host.*`. + pub key: &'static str, + /// Short admin-facing label. + pub title: &'static str, + /// One or two sentences for the admin screen. + pub description: &'static str, + /// How the value is edited/validated (`type` on the wire). + pub config_type: ConfigType, + /// Value the game uses when unset (`""` = not declared). + pub default: &'static str, + /// JSON Schema document (`Json` keys only; `""` = none). + pub schema: &'static str, +} + +impl ConfigKeySpec { + /// The all-defaults spec for `..ConfigKeySpec::DEFAULT` struct updates. + pub const DEFAULT: ConfigKeySpec = ConfigKeySpec { + key: "", + title: "", + description: "", + config_type: ConfigType::Text, + default: "", + schema: "", + }; +} + /// Static game metadata (Go's `GameMeta`; the SDK owns the §4.2 serializer so /// authors never write positional codec calls). Const-constructible: /// @@ -166,6 +212,10 @@ pub struct Meta { pub solo_mode_label: &'static str, pub private_invite_line: &'static str, pub leaderboard: Option, + /// Declared admin-settable config keys (`&[]` = none declared). Validated + /// at `meta()` encode time — an invalid declaration is an authoring bug + /// and panics there. + pub config: &'static [ConfigKeySpec], } impl Meta { @@ -182,6 +232,7 @@ impl Meta { solo_mode_label: "", private_invite_line: "", leaderboard: None, + config: &[], }; } diff --git a/rust/src/wire.rs b/rust/src/wire.rs index fd4ba55..b22eb96 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -162,9 +162,53 @@ pub(crate) fn encode_meta(m: &Meta) -> Vec { w.u8(lb.format as u8); } } + // Trailing config-spec section (ABI.md §4.2, spec minor): always written, + // count 0 when nothing is declared. Declarations are validated here so an + // authoring mistake fails loudly at meta() time — the same fail-fast + // posture as the Go SDK. + if let Err(e) = validate_config_specs(m.config) { + panic!("shellcade-kit: invalid Meta.config: {e}"); + } + w.u16(m.config.len().min(0xffff) as u16); + for cs in m.config { + w.str(cs.key); + w.str(cs.title); + w.str(cs.description); + w.u8(cs.config_type as u8); + w.str(cs.default); + w.str(cs.schema); + } w.b } +/// The authoring rules for declared config specs (ABI.md §4.2), mirroring Go's +/// `wire.ValidateConfigSpecs`: keys non-empty and unique, no reserved `host.` +/// prefix, and `schema` only on `Json`-typed keys where it must itself be +/// well-formed JSON. (The type code is total by construction in Rust.) +pub(crate) fn validate_config_specs(specs: &[crate::types::ConfigKeySpec]) -> Result<(), String> { + use crate::types::ConfigType; + for (i, cs) in specs.iter().enumerate() { + if cs.key.is_empty() { + return Err("config spec has an empty key".into()); + } + if specs[..i].iter().any(|p| p.key == cs.key) { + return Err(format!("duplicate config spec key {:?}", cs.key)); + } + if cs.key.starts_with("host.") { + return Err(format!("config spec key {:?} uses the reserved \"host.\" prefix", cs.key)); + } + if !cs.schema.is_empty() { + if cs.config_type != ConfigType::Json { + return Err(format!("config spec {:?} declares a schema on a non-json type", cs.key)); + } + if !crate::json::valid(cs.schema) { + return Err(format!("config spec {:?} schema is not valid JSON", cs.key)); + } + } + } + Ok(()) +} + // ---- Result (§4.4) ------------------------------------------------------------- /// Pack an [`Outcome`] against the current roster (player → index; an absent @@ -277,6 +321,126 @@ mod tests { assert!(!r.bad()); } + #[test] + fn meta_config_spec_wire_layout() { + use crate::types::{ConfigKeySpec, ConfigType}; + let m = Meta { + slug: "g", + name: "G", + short_description: "d", + min_players: 1, + max_players: 4, + config: &[ + ConfigKeySpec { + key: "odds-variant", + title: "Odds variant", + description: "PAR sheet.", + config_type: ConfigType::Json, + default: r#"{"name":"Default"}"#, + schema: r#"{"type":"object"}"#, + }, + ConfigKeySpec { key: "motd", title: "Banner", config_type: ConfigType::Text, ..ConfigKeySpec::DEFAULT }, + ], + ..Meta::DEFAULT + }; + let b = encode_meta(&m); + let mut r = Rd::new(&b); + // Skip the pre-section fields. + for _ in 0..3 { + r.string(); + } + r.u16(); + r.u16(); + assert_eq!(r.u16(), 0); // tags + for _ in 0..3 { + r.string(); + } + assert_eq!(r.u8(), 0); // no leaderboard + // The trailing config-spec section. + assert_eq!(r.u16(), 2); + assert_eq!(r.string(), "odds-variant"); + assert_eq!(r.string(), "Odds variant"); + assert_eq!(r.string(), "PAR sheet."); + assert_eq!(r.u8(), 3); // Json + assert_eq!(r.string(), r#"{"name":"Default"}"#); + assert_eq!(r.string(), r#"{"type":"object"}"#); + assert_eq!(r.string(), "motd"); + assert_eq!(r.string(), "Banner"); + assert_eq!(r.string(), ""); + assert_eq!(r.u8(), 0); // Text + assert_eq!(r.string(), ""); + assert_eq!(r.string(), ""); + assert!(!r.bad()); + } + + /// Byte-identity with the Go reference: the hex is the Go + /// `wire.EncodeMeta` output for this exact declaration (regenerate with a + /// throwaway Go test against kit/wire if the fixture changes). + #[test] + fn meta_config_spec_matches_go_golden() { + use crate::types::{ConfigKeySpec, ConfigType, Direction, MetricFormat}; + let m = Meta { + slug: "golden", + name: "Golden", + short_description: "golden fixture", + min_players: 1, + max_players: 4, + tags: &["a", "b"], + leaderboard: Some(Leaderboard { + metric_label: "score", + direction: Direction::LowerBetter, + aggregation: crate::types::Aggregation::BestResult, + format: MetricFormat::Duration, + }), + config: &[ + ConfigKeySpec { + key: "odds-variant", + title: "Odds variant", + description: "PAR sheet.", + config_type: ConfigType::Json, + default: r#"{"name":"Default"}"#, + schema: r#"{"type":"object"}"#, + }, + ConfigKeySpec { key: "motd", title: "Banner", description: "Floor banner.", config_type: ConfigType::Text, ..ConfigKeySpec::DEFAULT }, + ], + ..Meta::DEFAULT + }; + let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e0000000000"; + let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(got, golden, "Rust meta encoding diverges from the Go golden"); + } + + #[test] + fn config_spec_validation_rejects_authoring_mistakes() { + use crate::types::{ConfigKeySpec, ConfigType}; + let cases: &[(&str, &[ConfigKeySpec])] = &[ + ("empty key", &[ConfigKeySpec::DEFAULT]), + ("duplicate key", &[ + ConfigKeySpec { key: "k", ..ConfigKeySpec::DEFAULT }, + ConfigKeySpec { key: "k", ..ConfigKeySpec::DEFAULT }, + ]), + ("reserved prefix", &[ConfigKeySpec { key: "host.heartbeat_ms", ..ConfigKeySpec::DEFAULT }]), + ("schema on non-json", &[ConfigKeySpec { key: "k", schema: "{}", ..ConfigKeySpec::DEFAULT }]), + ("schema not JSON", &[ConfigKeySpec { + key: "k", + config_type: ConfigType::Json, + schema: "{nope", + ..ConfigKeySpec::DEFAULT + }]), + ]; + for (name, specs) in cases { + assert!(validate_config_specs(specs).is_err(), "want error: {name}"); + } + assert!(validate_config_specs(&[]).is_ok()); + assert!(validate_config_specs(&[ConfigKeySpec { + key: "odds-variant", + config_type: ConfigType::Json, + schema: r#"{"type":"object"}"#, + ..ConfigKeySpec::DEFAULT + }]) + .is_ok()); + } + #[test] fn outcome_maps_players_to_roster_indices() { let roster = vec![ diff --git a/wire/wire.go b/wire/wire.go index b809c44..f39759b 100644 --- a/wire/wire.go +++ b/wire/wire.go @@ -10,6 +10,7 @@ package wire import ( "encoding/binary" + "encoding/json" "errors" "fmt" ) @@ -119,6 +120,30 @@ type Meta struct { Direction uint8 Aggregation uint8 Format uint8 + + // ConfigSpecs is the trailing config-spec section (spec minor addition): + // the game's declared admin-settable config keys. Encoders always write + // the section (count 0 when empty); decoders treat a payload ending after + // the leaderboard block as a valid pre-config meta with no specs. + ConfigSpecs []ConfigSpec +} + +// Config value type codes (how the admin surface renders/validates a value). +const ( + ConfigText uint8 = 0 + ConfigNumber uint8 = 1 + ConfigBool uint8 = 2 + ConfigJSON uint8 = 3 +) + +// ConfigSpec is one declared admin-settable config key in the meta payload. +type ConfigSpec struct { + Key string // the config_get key the game reads + Title string // short admin-facing label + Description string // one-or-two-sentence admin help + Type uint8 // ConfigText..ConfigJSON + Default string // value the game uses when unset ("" = not declared) + Schema string // JSON Schema document (json type only; "" = none) } // Cell is one drawable cell of a frame. In v2 it carries up to three code @@ -306,6 +331,18 @@ func EncodeMeta(m Meta) []byte { w.U8(m.Aggregation) w.U8(m.Format) } + // Trailing config-spec section (spec minor addition). Always written, so + // a freshly encoded meta round-trips field-exact; decoders that predate + // the section ignore trailing bytes. + w.U16(uint16(len(m.ConfigSpecs))) + for _, cs := range m.ConfigSpecs { + w.Str(cs.Key) + w.Str(cs.Title) + w.Str(cs.Description) + w.U8(cs.Type) + w.Str(cs.Default) + w.Str(cs.Schema) + } return w.B } @@ -332,6 +369,21 @@ func DecodeMeta(b []byte) (Meta, error) { m.Aggregation = r.U8() m.Format = r.U8() } + // Trailing config-spec section, presence-guarded: a payload that ends + // here is a valid pre-config meta with no declared specs. + if !r.Bad && r.Off < len(r.B) { + n := int(r.U16()) + for i := 0; i < n && !r.Bad; i++ { + var cs ConfigSpec + cs.Key = r.Str() + cs.Title = r.Str() + cs.Description = r.Str() + cs.Type = r.U8() + cs.Default = r.Str() + cs.Schema = r.Str() + m.ConfigSpecs = append(m.ConfigSpecs, cs) + } + } if err := r.Err(); err != nil { return Meta{}, err } @@ -341,6 +393,45 @@ func DecodeMeta(b []byte) (Meta, error) { return m, nil } +// HostKeyPrefix is the reserved config-key namespace interpreted by the host +// (e.g. host.heartbeat_ms). Games MUST NOT declare specs under it — the +// platform declares those knobs itself. +const HostKeyPrefix = "host." + +// ValidateConfigSpecs enforces the authoring rules for declared config specs, +// shared by guest SDK encoders and host/CLI decoders: keys non-empty and +// unique, no reserved host. prefix, a known type code, and Schema only on +// JSON-typed keys where it must itself parse as JSON. The JSON check is a +// well-formedness scan (json.Valid) — schema COMPILATION is a host concern, +// keeping this package dependency-free. +func ValidateConfigSpecs(specs []ConfigSpec) error { + seen := make(map[string]bool, len(specs)) + for _, cs := range specs { + if cs.Key == "" { + return errors.New("wire: config spec has an empty key") + } + if seen[cs.Key] { + return fmt.Errorf("wire: duplicate config spec key %q", cs.Key) + } + seen[cs.Key] = true + if len(cs.Key) >= len(HostKeyPrefix) && cs.Key[:len(HostKeyPrefix)] == HostKeyPrefix { + return fmt.Errorf("wire: config spec key %q uses the reserved %q prefix", cs.Key, HostKeyPrefix) + } + if cs.Type > ConfigJSON { + return fmt.Errorf("wire: config spec %q has unknown type %d", cs.Key, cs.Type) + } + if cs.Schema != "" { + if cs.Type != ConfigJSON { + return fmt.Errorf("wire: config spec %q declares a schema on a non-json type", cs.Key) + } + if !json.Valid([]byte(cs.Schema)) { + return fmt.Errorf("wire: config spec %q schema is not valid JSON", cs.Key) + } + } + } + return nil +} + // ---- Frames ---------------------------------------------------------------------- // PutCell writes one cell at index i (0..FrameCells-1) into a FrameBytes buffer diff --git a/wire/wire_test.go b/wire/wire_test.go index 0e06c18..c7c0d6b 100644 --- a/wire/wire_test.go +++ b/wire/wire_test.go @@ -49,6 +49,102 @@ func TestMetaRoundTrip(t *testing.T) { } } +func TestMetaConfigSpecsRoundTrip(t *testing.T) { + in := Meta{ + Slug: "pokies", Name: "Pokies", ShortDescription: "spin to win", + MinPlayers: 1, MaxPlayers: 5, + ConfigSpecs: []ConfigSpec{ + {Key: "odds-variant", Title: "Odds variant", Description: "PAR sheet: weights + paytable.", + Type: ConfigJSON, Default: `{"name":"Default"}`, Schema: `{"type":"object"}`}, + {Key: "motd", Title: "Banner", Description: "Floor banner text.", Type: ConfigText}, + {Key: "speed", Title: "Speed", Description: "Reel speed.", Type: ConfigNumber, Default: "1"}, + {Key: "wild", Title: "Wilds", Description: "Enable wild faces.", Type: ConfigBool, Default: "false"}, + }, + } + out, err := DecodeMeta(EncodeMeta(in)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(in, out) { + t.Fatalf("round trip mismatch:\n in=%+v\nout=%+v", in, out) + } +} + +// TestMetaPreConfigBytesDecode pins the presence guard: a payload encoded +// WITHOUT the trailing config-spec section (a pre-v2.3 meta) decodes cleanly +// with no specs. The bytes are hand-built to the old layout — EncodeMeta can +// no longer produce them. +func TestMetaPreConfigBytesDecode(t *testing.T) { + var w Buf + w.Str("pokies") + w.Str("Pokies") + w.Str("spin to win") + w.U16(1) + w.U16(5) + w.U16(1) + w.Str("slots") + w.Str("") + w.Str("") + w.Str("") + w.Bool(true) // leaderboard block ends the old payload + w.Str("Credits") + w.U8(0) + w.U8(0) + w.U8(0) + out, err := DecodeMeta(w.B) + if err != nil { + t.Fatal(err) + } + if out.ConfigSpecs != nil { + t.Fatalf("pre-config bytes decoded specs: %+v", out.ConfigSpecs) + } + if out.Slug != "pokies" || !out.HasLeaderboard || out.MetricLabel != "Credits" { + t.Fatalf("pre-config fields corrupted: %+v", out) + } +} + +func TestMetaZeroCountConfigSection(t *testing.T) { + b := EncodeMeta(Meta{Slug: "x", Name: "y"}) + // The new encoder always writes the section: the payload ends with a + // zero u16 count, and it decodes to nil specs. + if b[len(b)-2] != 0 || b[len(b)-1] != 0 { + t.Fatalf("want trailing zero count, got % x", b[len(b)-2:]) + } + out, err := DecodeMeta(b) + if err != nil { + t.Fatal(err) + } + if out.ConfigSpecs != nil { + t.Fatalf("zero-count section decoded specs: %+v", out.ConfigSpecs) + } +} + +func TestValidateConfigSpecs(t *testing.T) { + ok := []ConfigSpec{ + {Key: "odds-variant", Type: ConfigJSON, Schema: `{"type":"object"}`}, + {Key: "motd", Type: ConfigText}, + } + if err := ValidateConfigSpecs(ok); err != nil { + t.Fatalf("valid specs rejected: %v", err) + } + if err := ValidateConfigSpecs(nil); err != nil { + t.Fatalf("nil specs rejected: %v", err) + } + cases := map[string][]ConfigSpec{ + "empty key": {{Key: "", Type: ConfigText}}, + "duplicate key": {{Key: "k", Type: ConfigText}, {Key: "k", Type: ConfigBool}}, + "reserved prefix": {{Key: "host.heartbeat_ms", Type: ConfigNumber}}, + "unknown type": {{Key: "k", Type: 9}}, + "schema on non-json": {{Key: "k", Type: ConfigNumber, Schema: `{}`}}, + "schema not JSON": {{Key: "k", Type: ConfigJSON, Schema: `{nope`}}, + } + for name, specs := range cases { + if err := ValidateConfigSpecs(specs); err == nil { + t.Errorf("%s: want error, got nil", name) + } + } +} + func TestMetaRejectsEmptySlug(t *testing.T) { if _, err := DecodeMeta(EncodeMeta(Meta{Name: "x"})); err == nil { t.Fatal("want error for empty slug") @@ -95,6 +191,9 @@ func TestResultRoundTrip(t *testing.T) { func FuzzDecodeMeta(f *testing.F) { f.Add(EncodeMeta(Meta{Slug: "x", Name: "y"})) + f.Add(EncodeMeta(Meta{Slug: "x", Name: "y", ConfigSpecs: []ConfigSpec{ + {Key: "k", Title: "K", Type: ConfigJSON, Default: "{}", Schema: `{"type":"object"}`}, + }})) f.Add([]byte{}) f.Fuzz(func(t *testing.T, b []byte) { _, _ = DecodeMeta(b) // must never panic