From 10c16a1b565210b11533d8fb78a8505a0f3572ee Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Sun, 7 Jun 2026 19:36:06 +1000 Subject: [PATCH] =?UTF-8?q?guest=20sdk:=20room=20lifecycle=20declarations?= =?UTF-8?q?=20=E2=80=94=20resumable=20/=20ephemeral=20/=20resident?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameMeta.Lifecycle, a trailing presence-guarded byte after the large-room meta section (ABI major stays 2; older payloads decode as resumable, older hosts ignore the byte). resumable (0, default) is today's hibernate-on-abandon. ephemeral ends and disposes after the abandonment grace — no snapshot, no Resume entry — right for casual social rooms. resident declares one long-lived granted room per slug; the host treats unknown values as resumable. SDKs reject undefined values and resident+MinPlayers>1 (a resident room runs with zero members) at meta encode time. Rust crate mirrors field, validation, and goldens (lifecycle suffix cross-checked against the Go encoder). GUIDE.md gains "Choosing a lifecycle"; ABI.md documents the byte and the zero-member posture. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/room-lifecycle-modes.md | 22 +++++++++++++++ ABI.md | 16 +++++++++++ GUIDE.md | 24 +++++++++++++++++ internal/game/codec.go | 4 +++ internal/game/largeroom_test.go | 10 +++++++ internal/game/types.go | 20 ++++++++++++++ kit.go | 5 ++++ rust/Cargo.lock | 2 +- rust/src/lib.rs | 2 +- rust/src/types.rs | 17 ++++++++++++ rust/src/wire.rs | 11 ++++++-- wire/largeroom_test.go | 43 +++++++++++++++++++++++++++--- wire/wire.go | 36 +++++++++++++++++++++++++ 13 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 .changeset/room-lifecycle-modes.md diff --git a/.changeset/room-lifecycle-modes.md b/.changeset/room-lifecycle-modes.md new file mode 100644 index 0000000..a2070d5 --- /dev/null +++ b/.changeset/room-lifecycle-modes.md @@ -0,0 +1,22 @@ +--- +"kit": minor +--- + +Room lifecycle declarations: `GameMeta.Lifecycle` chooses what happens when +everyone leaves the room. + +- `LifecycleResumable` (default, byte-compat): hibernate on abandonment, + player-driven resume — today's behavior. +- `LifecycleEphemeral`: after the abandonment grace the room ends and + disposes — no snapshot, no Resume-menu entry. Right for casual social + rooms; the grace still protects against connection blips. +- `LifecycleResident`: one long-lived room per slug (persistent worlds): + ticks with zero players, periodic checkpoints, boot auto-restore. + Granted per slug by the platform — an ungranted declaration behaves as + resumable. Cannot combine with `MinPlayers > 1` (validated at meta + encode, like all trailer fields). + +Carried as a trailing presence-guarded byte after the large-room meta +section (ABI major stays 2; older payloads decode as resumable, older hosts +ignore the byte). Rust crate mirrors the field, validation, and goldens. +`GUIDE.md` gains "Choosing a lifecycle". diff --git a/ABI.md b/ABI.md index 6106970..eea618e 100644 --- a/ABI.md +++ b/ABI.md @@ -161,6 +161,7 @@ u16 configSpecCount (trailing; see · str default ("" = not declared) · str schema ("" = none; json only) u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRosterEpoch u16 heartbeatMS 0 = no declaration +u8 lifecycle trailing (see below); 0 resumable · 1 ephemeral · 2 resident ``` `slug` must be non-empty; the host refuses artifacts whose slug or version it @@ -196,6 +197,21 @@ resolves the wake heartbeat at room creation as: admin `host.heartbeat_ms` config > declared `heartbeatMS` > platform default (50ms), clamped to [20ms, 1000ms] — a declaration is authoring intent, never authority. +**Lifecycle byte (minor addition).** A trailing `u8` after the large-room +section, presence-guarded under the same rules (absent = `0`): the room's +end-of-life declaration. `0` resumable — hibernate on abandonment, +player-driven resume (the historical behavior and the default). `1` +ephemeral — after the abandonment grace the room ends and disposes: no +snapshot, no resume entry (right for casual social rooms whose match has no +meaning without its players). `2` resident — one long-lived room per slug; +the declaration takes effect only when the platform grants it, and an +ungranted declaration behaves as resumable. Hosts MUST treat lifecycle +values they do not implement as resumable. SDKs reject undefined values and +the resident + `minPlayers > 1` combination at `meta()` encode time (a +resident room runs with zero members — see the zero-member wake rule in +§4.1's roster-epoch notes; `start` precedes the first `join` universally, +so an empty roster is already legal in every callback). + ### 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 31ba489..23e87c4 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -383,6 +383,30 @@ The native runner also rides terminal resizes: shrink the window below 80×24 and it shows a "terminal too small" notice, then repaints your game the moment you grow it back — the same letterboxing the arcade does over SSH. +## Choosing a lifecycle + +`GameMeta.Lifecycle` declares what happens to your room when everyone +leaves: + +- **`LifecycleResumable`** (the default): the room hibernates and players + can resume it later from the lobby. Right for games where an interrupted + match is worth returning to — chess, anything with long-arc state. +- **`LifecycleEphemeral`**: after the abandonment grace the room ends and + disposes — no snapshot, no resume entry. Right for casual social rooms + (slots, card tables, quick board games) where a match without its players + is meaningless. The grace still protects against connection blips: a + rejoin within it finds the room intact. +- **`LifecycleResident`**: one long-lived room per slug — the persistent- + world shape. It keeps ticking with zero players, checkpoints + periodically, and survives deploys without anyone resuming it. Declaring + it is a REQUEST: the platform grants residency per slug (always-on + compute is an operator decision), and an ungranted declaration simply + behaves as resumable. Resident games must tolerate `r.Count() == 0` in + every callback (all games should — `OnStart` fires before the first + join), should idle-throttle expensive work when nobody is online, and + cannot declare `MinPlayers > 1`. Ending a resident room (`r.End`) is the + world-reset primitive: the next join creates a fresh world. + ## Large rooms: 100+ players in one room The SDK supports rooms of up to 1024 players, but a large room only stays diff --git a/internal/game/codec.go b/internal/game/codec.go index 200d46c..16c77ca 100644 --- a/internal/game/codec.go +++ b/internal/game/codec.go @@ -178,6 +178,10 @@ func encodeMeta(m GameMeta) []byte { if err := wire.ValidateMetaTrailer(wm.CtxFeatures, wm.HeartbeatMS); err != nil { panic("kit: invalid GameMeta: " + err.Error()) } + wm.Lifecycle = uint8(m.Lifecycle) + if err := wire.ValidateLifecycle(wm.Lifecycle, wm.MinPlayers); err != nil { + panic("kit: invalid GameMeta: " + err.Error()) + } return wire.EncodeMeta(wm) } diff --git a/internal/game/largeroom_test.go b/internal/game/largeroom_test.go index e96755e..21fb47d 100644 --- a/internal/game/largeroom_test.go +++ b/internal/game/largeroom_test.go @@ -107,4 +107,14 @@ func TestMetaTrailerEncode(t *testing.T) { } assertPanics("bad heartbeat", GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 2, HeartbeatMS: 5}, "HeartbeatMS") assertPanics("unknown feature bit", GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 2, CtxFeatures: 1 << 9}, "undefined bit") + assertPanics("undefined lifecycle", GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 2, Lifecycle: 7}, "Lifecycle") + assertPanics("resident multi-floor", GameMeta{Slug: "g", Name: "G", MinPlayers: 2, MaxPlayers: 8, Lifecycle: LifecycleResident}, "zero members") +} + +func TestLifecycleEncode(t *testing.T) { + b := encodeMeta(GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 8, Lifecycle: LifecycleEphemeral}) + wm, err := wire.DecodeMeta(b) + if err != nil || wm.Lifecycle != wire.LifecycleEphemeral { + t.Fatalf("lifecycle encode: %v %d", err, wm.Lifecycle) + } } diff --git a/internal/game/types.go b/internal/game/types.go index f045da5..b3c0663 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -196,8 +196,28 @@ type GameMeta struct { // to its envelope and an admin config override always wins; out-of-range // declarations are an authoring bug and panic at meta encode time. HeartbeatMS int + + // Lifecycle optionally declares the room's end-of-life shape. The zero + // value (LifecycleResumable) is today's behavior: hibernate on abandon, + // player-driven resume. LifecycleEphemeral ends and disposes the room + // after the abandon grace (no snapshot, no Resume entry) — right for + // casual social rooms. LifecycleResident declares one long-lived room + // per slug; it takes effect only when the platform grants it (an + // ungranted declaration behaves as resumable). Undefined values and + // resident-with-MinPlayers>1 are authoring bugs and panic at meta + // encode time. + Lifecycle Lifecycle } +// Lifecycle is the room end-of-life declaration. +type Lifecycle uint8 + +const ( + LifecycleResumable Lifecycle = Lifecycle(wire.LifecycleResumable) + LifecycleEphemeral Lifecycle = Lifecycle(wire.LifecycleEphemeral) + LifecycleResident Lifecycle = Lifecycle(wire.LifecycleResident) +) + // CtxFeatRosterEpoch opts the game into the ctx roster-epoch encoding: the // host sends the full member list only when the roster changes (with an // epoch), and a 6-byte unchanged marker otherwise — the large-room callback diff --git a/kit.go b/kit.go index 7a85525..d6a2038 100644 --- a/kit.go +++ b/kit.go @@ -71,6 +71,7 @@ type ( GameMeta = game.GameMeta LeaderboardSpec = game.LeaderboardSpec ConfigKeySpec = game.ConfigKeySpec + Lifecycle = game.Lifecycle ConfigType = game.ConfigType Direction = game.Direction Aggregation = game.Aggregation @@ -95,6 +96,10 @@ const ( CtxFeatRosterEpoch = game.CtxFeatRosterEpoch + LifecycleResumable = game.LifecycleResumable + LifecycleEphemeral = game.LifecycleEphemeral + LifecycleResident = game.LifecycleResident + ConfigText = game.ConfigText ConfigNumber = game.ConfigNumber ConfigBool = game.ConfigBool diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1316212..7902963 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -304,7 +304,7 @@ dependencies = [ [[package]] name = "shellcade-kit" -version = "2.5.0" +version = "2.6.0" dependencies = [ "extism-pdk", ] diff --git a/rust/src/lib.rs b/rust/src/lib.rs index d18d614..aecf245 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -89,7 +89,7 @@ pub use input::{Action, Input, InputContext, Key}; pub use room::Room; pub use types::{ Aggregation, ConfigKeySpec, ConfigType, Direction, Kind, Leaderboard, MergeRule, Meta, - MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, CTX_FEAT_ROSTER_EPOCH, + MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, CTX_FEAT_ROSTER_EPOCH, Lifecycle, }; // 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 4c76486..fe49fa6 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -226,6 +226,22 @@ pub struct Meta { /// host clamps to its envelope and an admin override always wins; /// out-of-range declarations panic at `meta()` encode time. pub heartbeat_ms: u16, + + /// Room end-of-life declaration. `Resumable` (default) hibernates on + /// abandon; `Ephemeral` ends and disposes after the abandon grace (no + /// snapshot, no Resume entry); `Resident` declares one long-lived room + /// per slug (takes effect only when the platform grants it). + /// Resident with `min_players > 1` panics at `meta()` encode time. + pub lifecycle: Lifecycle, +} + +/// Room end-of-life declaration (wire values 0/1/2). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Lifecycle { + #[default] + Resumable = 0, + Ephemeral = 1, + Resident = 2, } /// Opts the game into the ctx roster-epoch encoding: the host sends the full @@ -258,6 +274,7 @@ impl Meta { config: &[], ctx_features: 0, heartbeat_ms: 0, + lifecycle: Lifecycle::Resumable, }; } diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 6dcbc96..ce5c71e 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -219,6 +219,13 @@ pub(crate) fn encode_meta(m: &Meta) -> Vec { } w.u32(m.ctx_features); w.u16(m.heartbeat_ms); + // Trailing lifecycle byte (ABI.md §4.2, spec minor). Always written; + // resident with min_players > 1 is an authoring bug (a resident room + // runs with zero members), mirroring Go's wire.ValidateLifecycle. + if m.lifecycle == crate::types::Lifecycle::Resident && m.min_players > 1 { + panic!("shellcade-kit: invalid Meta: lifecycle Resident cannot require min_players {}", m.min_players); + } + w.u8(m.lifecycle as u8); w.b } @@ -463,7 +470,7 @@ mod tests { ], ..Meta::DEFAULT }; - let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e0000000000000000000000"; + let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000000000000000000"; 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"); } @@ -596,7 +603,7 @@ mod tests { }; let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect(); // trailer = u32 1 LE + u16 100 LE = "01000000" + "6400" - assert!(got.ends_with("0000010000006400"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-16..]); + assert!(got.ends_with("000001000000640000"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-18..]); } } diff --git a/wire/largeroom_test.go b/wire/largeroom_test.go index 83fc781..7712e19 100644 --- a/wire/largeroom_test.go +++ b/wire/largeroom_test.go @@ -115,14 +115,49 @@ func TestMetaTrailerRoundTrip(t *testing.T) { t.Fatalf("trailer round-trip = features %#x heartbeat %d", got.CtxFeatures, got.HeartbeatMS) } - // Pre-trailer payload: chop the trailing 6 bytes (u32 + u16). - old := b[:len(b)-6] + // Pre-large-room payload: chop the trailing 7 bytes (u32 + u16 + u8). + old := b[:len(b)-7] got, err = DecodeMeta(old) if err != nil { t.Fatalf("pre-trailer decode: %v", err) } - if got.CtxFeatures != 0 || got.HeartbeatMS != 0 { - t.Fatalf("pre-trailer payload decoded nonzero trailer: %#x %d", got.CtxFeatures, got.HeartbeatMS) + if got.CtxFeatures != 0 || got.HeartbeatMS != 0 || got.Lifecycle != LifecycleResumable { + t.Fatalf("pre-trailer payload decoded nonzero trailer: %#x %d %d", got.CtxFeatures, got.HeartbeatMS, got.Lifecycle) + } + + // Pre-lifecycle payload (kit v2.6.0 era): chop only the lifecycle byte — + // the large-room section decodes, lifecycle defaults to resumable. + v26 := b[:len(b)-1] + got, err = DecodeMeta(v26) + if err != nil { + t.Fatalf("pre-lifecycle decode: %v", err) + } + if got.CtxFeatures != CtxFeatRosterEpoch || got.HeartbeatMS != 100 || got.Lifecycle != LifecycleResumable { + t.Fatalf("pre-lifecycle payload: %#x %d %d", got.CtxFeatures, got.HeartbeatMS, got.Lifecycle) + } +} + +func TestLifecycleRoundTripAndValidation(t *testing.T) { + m := Meta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 8, Lifecycle: LifecycleEphemeral} + got, err := DecodeMeta(EncodeMeta(m)) + if err != nil || got.Lifecycle != LifecycleEphemeral { + t.Fatalf("lifecycle round-trip: %v %d", err, got.Lifecycle) + } + cases := []struct { + lc uint8 + minP uint16 + ok bool + }{ + {LifecycleResumable, 2, true}, + {LifecycleEphemeral, 1, true}, + {LifecycleResident, 1, true}, + {LifecycleResident, 2, false}, // resident runs with zero members + {7, 1, false}, // undefined value + } + for _, c := range cases { + if err := ValidateLifecycle(c.lc, c.minP); (err == nil) != c.ok { + t.Errorf("ValidateLifecycle(%d, %d) err=%v want ok=%v", c.lc, c.minP, err, c.ok) + } } } diff --git a/wire/wire.go b/wire/wire.go index 14efb22..e1daa07 100644 --- a/wire/wire.go +++ b/wire/wire.go @@ -168,8 +168,23 @@ type Meta struct { // values. CtxFeatures uint32 HeartbeatMS uint16 + + // Lifecycle is the room end-of-life declaration (spec minor addition; + // trailing byte after HeartbeatMS): 0 resumable (hibernate on abandon — + // today's behavior and the zero-value default), 1 ephemeral (end + + // dispose on abandon; no snapshot, no Resume entry), 2 resident (one + // long-lived granted room per slug). Hosts treat values they do not + // implement as resumable. + Lifecycle uint8 } +// Lifecycle values for the meta trailer. +const ( + LifecycleResumable uint8 = 0 + LifecycleEphemeral uint8 = 1 + LifecycleResident uint8 = 2 +) + // Config value type codes (how the admin surface renders/validates a value). const ( ConfigText uint8 = 0 @@ -449,6 +464,9 @@ func EncodeMeta(m Meta) []byte { // + declared heartbeat. Always written; older decoders ignore the bytes. w.U32(m.CtxFeatures) w.U16(m.HeartbeatMS) + // Trailing lifecycle byte (spec minor addition). Always written; older + // decoders ignore it. + w.U8(m.Lifecycle) return w.B } @@ -496,6 +514,10 @@ func DecodeMeta(b []byte) (Meta, error) { m.CtxFeatures = r.U32() m.HeartbeatMS = r.U16() } + // Trailing lifecycle byte, presence-guarded: absent = resumable. + if !r.Bad && r.Off < len(r.B) { + m.Lifecycle = r.U8() + } if err := r.Err(); err != nil { return Meta{}, err } @@ -550,6 +572,20 @@ const ( HeartbeatMaxMS uint16 = 1000 ) +// ValidateLifecycle is the shared authoring rule set for the lifecycle +// declaration, enforced at meta() encode time by both SDKs: the value must +// be a defined lifecycle, and resident cannot be combined with +// minPlayers > 1 (a resident room runs with zero members). +func ValidateLifecycle(lifecycle uint8, minPlayers uint16) error { + if lifecycle > LifecycleResident { + return fmt.Errorf("wire: Lifecycle %d undefined (0 resumable, 1 ephemeral, 2 resident)", lifecycle) + } + if lifecycle == LifecycleResident && minPlayers > 1 { + return fmt.Errorf("wire: Lifecycle resident cannot require MinPlayers %d — a resident room runs with zero members", minPlayers) + } + return nil +} + // ValidateMetaTrailer is the shared authoring rule set for the large-room // meta section, enforced at meta() encode time by both SDKs (the same // fail-fast posture as ValidateConfigSpecs): no undefined ctx-feature bits;