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
12 changes: 12 additions & 0 deletions .changeset/character-ctx-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"kit": minor
---

Per-member player character in the CallContext behind a new declared
`CtxFeatCharacter` (1<<1) ctx feature: each roster member carries
`str glyph · u8 ink RGB · u8 bg RGB · u8 asciiFallback` after its kind byte,
in both member-bearing forms (wire revision 5). Go and Rust SDKs expose
`Character` on `Player` and a `CharacterCell` / `character_cell` helper
returning the one styled cell — every catalogue glyph is width 1, so games
place a player's character with zero width logic. Non-declaring guests
decode byte-identically.
36 changes: 33 additions & 3 deletions ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,23 @@ do not declare the feature receive ONLY the legacy form, byte-identical to
prior revisions. The roster epoch and the frame-delta epoch (§4.6) are
independent counters.

**Per-member character section (minor addition).** For a guest whose meta
declares `CtxFeatCharacter` (§4.2), every member entry — in BOTH
member-bearing forms, the legacy full roster and the `0xFFFE` full-at-epoch
form — carries immediately after its `u8 kind`:

```
str glyph · u8 inkR · u8 inkG · u8 inkB · u8 bgR · u8 bgG · u8 bgB · u8 asciiFallback
```

The `0xFFFF` unchanged sentinel carries no member data and therefore no
character sections. Unlike the roster-epoch forms, which self-describe via
the count u16's spare sentinel space, per-member trailing bytes have no
in-band discriminator — the meta declaration is the entire negotiation, and
the host MUST encode the section iff the guest declared the feature. Guests
that do not declare `CtxFeatCharacter` receive encodings byte-identical to
revision 4.

### 4.2 Meta

```
Expand All @@ -167,7 +184,7 @@ u16 configSpecCount (trailing; see
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)
u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRosterEpoch
u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRosterEpoch · bit 1 = CtxFeatCharacter
u16 heartbeatMS 0 = no declaration
u8 lifecycle trailing (see below); 0 resumable · 1 ephemeral · 2 resident
u16 wireRevision trailing (see below); 0 = unknown (the meta predates the field)
Expand Down Expand Up @@ -195,7 +212,8 @@ concern). The Go SDK enforces these rules at `meta()` encode time, and

**Large-room section (minor addition).** A second trailing section after the
config-spec section: `u32 ctxFeatures` (a bitset of negotiated callback
encodings; bit 0 = `CtxFeatRosterEpoch`, see §4.1) then `u16 heartbeatMS`
encodings; bit 0 = `CtxFeatRosterEpoch`, bit 1 = `CtxFeatCharacter`, see
§4.1) then `u16 heartbeatMS`
(the game's preferred wake cadence; 0 = no declaration). Presence-guarded
exactly like the config-spec section: a payload ending after the config-spec
section is a valid older meta with zero values; older hosts ignore the
Expand All @@ -206,6 +224,17 @@ 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.

`CtxFeatCharacter` (`1<<1`) opts the guest into the per-member character
section (§4.1): each roster entry carries the member's resolved arcade
character. `glyph` is a single Unicode code point that renders at exactly one
terminal cell — a game places it with zero width logic. The ink and
background colours are already-resolved RGB triplets: the host applies any
palette, theme, or unlock resolution before encoding, so the guest never
interprets colour indirectly. `asciiFallback` is the single-byte stand-in the
HOST substitutes for the glyph when a viewer's terminal cannot take UTF-8 —
it rides along so the guest holds the complete character, but the degradation
itself is a host-side rendering concern, never guest logic.

**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,
Expand All @@ -229,7 +258,8 @@ monotonic counter of the wire-visible minor additions within an ABI major
never set by the author, and declares the newest wire feature the artifact
may assume the host understands. The ledger so far: `1` config-spec section
· `2` large-room section + roster-epoch sentinels · `3` lifecycle byte ·
`4` this field itself. `0` means unknown: the meta predates the field
`4` this field itself · `5` per-member ctx character section behind
`CtxFeatCharacter`. `0` means unknown: the meta predates the field
(revisions 1–3 existed before it, so artifacts of those eras cannot declare
them — only `0` or values ≥ `4` are ever observed). A hand-rolled guest
(§4.7) SHOULD stamp the revision whose features it actually uses; omitting
Expand Down
148 changes: 148 additions & 0 deletions internal/game/character_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package game

import (
"testing"

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

func featCtxPayload(features uint32, members ...wire.Player) []byte {
var w wire.Buf
wire.EncodeCtxFeat(&w, wire.Ctx{
NowUnixNanos: 1, Seed: 7, SeedSet: true, Capacity: 1000, MinPlayers: 1,
Members: members,
}, features)
return w.B
}

func epochFeatCtxPayload(features, epoch uint32, full bool, members ...wire.Player) []byte {
var w wire.Buf
wire.EncodeCtxEpochFeat(&w, wire.Ctx{
NowUnixNanos: 1, Seed: 7, SeedSet: true, Capacity: 1000, MinPlayers: 1,
Members: members,
}, epoch, full, features)
return w.B
}

// Legacy-form decode with CtxFeatCharacter declared: characters decode per
// member, the byte-skim cache stays aligned across the character section
// (identical bytes ⇒ changed=false), and a character-only change re-decodes.
func TestDecodeCtxCharacterSections(t *testing.T) {
resetRosterState()
declaredCtxFeatures = wire.CtxFeatCharacter
defer func() { declaredCtxFeatures = 0 }()

ada := wire.Player{Handle: "ada", AccountID: "a", Conn: "c1", Kind: 1,
Character: wire.Character{Glyph: "λ", InkR: 10, InkG: 20, InkB: 30, BgR: 1, BgG: 2, BgB: 3, Fallback: 'L'}}
bob := wire.Player{Handle: "bob", AccountID: "b", Conn: "c2", Kind: 1,
Character: wire.Character{Glyph: "@", InkR: 200, InkG: 100, InkB: 50, Fallback: '@'}}

c1, _, changed := decodeCtx(featCtxPayload(wire.CtxFeatCharacter, ada, bob))
if !changed || len(c1.members) != 2 {
t.Fatalf("first decode: changed=%v members=%d", changed, len(c1.members))
}
want0 := Character{Glyph: "λ", InkR: 10, InkG: 20, InkB: 30, BgR: 1, BgG: 2, BgB: 3, Fallback: 'L'}
want1 := Character{Glyph: "@", InkR: 200, InkG: 100, InkB: 50, Fallback: '@'}
if c1.members[0].Character != want0 || c1.members[1].Character != want1 {
t.Fatalf("characters = %+v / %+v", c1.members[0].Character, c1.members[1].Character)
}

// Same bytes again: the skim must skip the character section exactly so
// the memcmp region matches and the cached roster is reused.
c2, _, changed := decodeCtx(featCtxPayload(wire.CtxFeatCharacter, ada, bob))
if changed {
t.Fatal("identical payload re-decoded (skim misaligned over character section)")
}
if c2.members[0].Character != want0 || c2.members[1].Character != want1 {
t.Fatalf("cached characters lost: %+v / %+v", c2.members[0].Character, c2.members[1].Character)
}

// A character-only change must register as a roster change and decode the
// new value (the characters live inside the compared region).
bob.Character.InkR = 201
c3, _, changed := decodeCtx(featCtxPayload(wire.CtxFeatCharacter, ada, bob))
if !changed {
t.Fatal("InkR flip not detected as a change")
}
if c3.members[1].Character.InkR != 201 {
t.Fatalf("new InkR not decoded: %+v", c3.members[1].Character)
}
}

// Roster-epoch full form with both feature bits: characters decode, the epoch
// is honoured, and the unchanged sentinel reuses the cached roster WITH the
// characters intact.
func TestDecodeCtxCharacterEpochForm(t *testing.T) {
resetRosterState()
declaredCtxFeatures = wire.CtxFeatCharacter | wire.CtxFeatRosterEpoch
defer func() { declaredCtxFeatures = 0 }()

ada := wire.Player{Handle: "ada", AccountID: "a", Conn: "c1", Kind: 1,
Character: wire.Character{Glyph: "λ", InkR: 9, Fallback: 'L'}}
feats := wire.CtxFeatCharacter | wire.CtxFeatRosterEpoch

c1, _, changed := decodeCtx(epochFeatCtxPayload(feats, 7, true, ada))
if !changed || len(c1.members) != 1 {
t.Fatalf("full form: changed=%v members=%d", changed, len(c1.members))
}
want := Character{Glyph: "λ", InkR: 9, Fallback: 'L'}
if c1.members[0].Character != want {
t.Fatalf("character = %+v", c1.members[0].Character)
}
if !rosterCacheEpochSet || rosterCacheEpoch != 7 {
t.Fatalf("cache epoch = %d set=%v", rosterCacheEpoch, rosterCacheEpochSet)
}

c2, _, changed := decodeCtx(epochFeatCtxPayload(feats, 7, false))
if changed || len(c2.members) != 1 {
t.Fatalf("unchanged form: changed=%v members=%d", changed, len(c2.members))
}
if c2.members[0].Character != want {
t.Fatalf("cached character lost on unchanged sentinel: %+v", c2.members[0].Character)
}
}

// Feature undeclared: legacy bytes decode exactly as before and every
// member's Character is the zero value.
func TestDecodeCtxNoCharacterFeature(t *testing.T) {
resetRosterState()

ada := wire.Player{Handle: "ada", AccountID: "a", Conn: "c1", Kind: 1}
bob := wire.Player{Handle: "bob", AccountID: "b", Conn: "c2", Kind: 0}
var w wire.Buf
wire.EncodeCtx(&w, wire.Ctx{
NowUnixNanos: 1, Seed: 7, SeedSet: true, Capacity: 8, MinPlayers: 1,
Members: []wire.Player{ada, bob},
})

c, _, changed := decodeCtx(w.B)
if !changed || len(c.members) != 2 {
t.Fatalf("decode: changed=%v members=%d", changed, len(c.members))
}
for i, m := range c.members {
if m.Character != (Character{}) {
t.Fatalf("member %d Character = %+v, want zero value", i, m.Character)
}
}
}

// CharacterCell turns a character into one styled, ready-to-place cell; the
// zero Character (feature undeclared) yields a blank cell.
func TestCharacterCell(t *testing.T) {
c := Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, BgR: 0x2D, BgG: 0x1B, BgB: 0x4E, Fallback: 'L'}
cell := CharacterCell(c)
if cell.Rune != 'λ' || cell.Cp2 != 0 || cell.Cont {
t.Fatalf("cell shape wrong: %+v", cell)
}
r, g, b := cell.FG.RGBVals()
if !cell.FG.IsSet() || r != 0x39 || g != 0xFF || b != 0x14 {
t.Fatalf("ink wrong: %+v", cell.FG)
}
br, bg2, bb := cell.BG.RGBVals()
if !cell.BG.IsSet() || br != 0x2D || bg2 != 0x1B || bb != 0x4E {
t.Fatalf("bg wrong: %+v", cell.BG)
}
if blank := CharacterCell(Character{}); blank.Rune != ' ' || blank.FG.IsSet() || blank.BG.IsSet() {
t.Fatalf("zero character should be a blank cell: %+v", blank)
}
}
28 changes: 28 additions & 0 deletions internal/game/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ var (
epochMismatchLogged bool
)

// declaredCtxFeatures is the registered game's GameMeta.CtxFeatures, set by
// Run. The host encodes per-member character sections iff the guest's meta
// declares CtxFeatCharacter — there is no in-band discriminator — so the
// decoder must know the declaration to read (and skim) the member section
// with the right shape.
var declaredCtxFeatures uint32

// decodeCtx decodes a CallContext and returns the reader positioned at the
// event-specific extra payload, plus whether the roster changed since the
// previous callback (true on the first callback).
Expand Down Expand Up @@ -93,6 +100,16 @@ func decodeCtx(b []byte) (callContext, *wire.Rd, bool) {
r.SkipStr() // account id
r.SkipStr() // conn
r.U8() // kind
if declaredCtxFeatures&wire.CtxFeatCharacter != 0 {
// Character section (host sends it iff our meta declares the
// feature): glyph str + 7 fixed bytes (ink RGB, bg RGB,
// fallback). The skim must skip EXACTLY what
// decodeMembersInto reads or the memcmp region misaligns.
r.SkipStr() // glyph
for j := 0; j < 7; j++ {
r.U8()
}
}
}
region := r.B[start:r.Off]
changed = rosterCacheBytes == nil || !bytes.Equal(region, rosterCacheBytes)
Expand Down Expand Up @@ -127,6 +144,17 @@ func decodeMembersInto(r *wire.Rd, n int) {
p.AccountID = r.Str()
p.Conn = r.Str()
p.Kind = Kind(r.U8())
// keep the legacy skim in decodeCtx in lockstep with this section
if declaredCtxFeatures&wire.CtxFeatCharacter != 0 {
p.Character.Glyph = r.Str()
p.Character.InkR = r.U8()
p.Character.InkG = r.U8()
p.Character.InkB = r.U8()
p.Character.BgR = r.U8()
p.Character.BgG = r.U8()
p.Character.BgB = r.U8()
p.Character.Fallback = r.U8()
}
rosterCache = append(rosterCache, p)
}
}
Expand Down
1 change: 1 addition & 0 deletions internal/game/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func resetDiffState() {
}
rosterCache = nil
rosterCacheBytes = nil
declaredCtxFeatures = 0
}

func frameWith(text string) *Frame {
Expand Down
19 changes: 19 additions & 0 deletions internal/game/grid.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package game
// outside the grid are clamped (never errored), and the packed frame encoding
// preserves the canvas contract host-side.

import "unicode/utf8"

const (
Rows = 24
Cols = 80
Expand Down Expand Up @@ -215,3 +217,20 @@ func (f *Frame) Fill(r0, c0, r1, c1 int, cell Cell) {
}
}
}

// CharacterCell returns the one ready-made cell of a member's character tile:
// the glyph styled with the resolved ink and background (see ABI.md §4.1 —
// every admitted glyph is width 1, so games place a character with zero
// width logic). The zero Character (the game's meta does
// not declare CtxFeatCharacter) yields a blank cell.
func CharacterCell(c Character) Cell {
if c.Glyph == "" {
return Cell{Rune: ' '}
}
r, _ := utf8.DecodeRuneInString(c.Glyph)
return Cell{
Rune: r,
FG: RGB(c.InkR, c.InkG, c.InkB),
BG: RGB(c.BgR, c.BgG, c.BgB),
}
}
1 change: 1 addition & 0 deletions internal/game/largeroom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func resetRosterState() {
rosterCacheEpochSet = false
epochMismatch = false
epochMismatchLogged = false
declaredCtxFeatures = 0
}

func epochCtxPayload(epoch uint32, full bool, members ...wire.Player) []byte {
Expand Down
10 changes: 9 additions & 1 deletion internal/game/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ var (
)

// Run installs the game. The instance's Handler is created lazily on start.
func Run(g Game) { theGame = g }
// The declared ctx features are captured here so the callback decoder reads
// member sections with the shape the host encodes for this guest (the
// character section carries no in-band discriminator). Meta() is read once at
// registration, so it must return a complete value (the kit.Main scaffold
// pattern).
func Run(g Game) {
theGame = g
declaredCtxFeatures = g.Meta().CtxFeatures
}

// ExportABI backs the shellcade_abi export.
func ExportABI() int32 {
Expand Down
23 changes: 23 additions & 0 deletions internal/game/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,28 @@ const (
KindMember
)

// Character is a player's resolved arcade character (mirrors wire.Character):
// a single width-1 glyph with ink/background colors and an ASCII fallback
// codepoint. The zero value means "no character" — what every member carries
// unless the game declares CtxFeatCharacter in GameMeta.CtxFeatures.
type Character struct {
Glyph string
InkR uint8
InkG uint8
InkB uint8
BgR uint8
BgG uint8
BgB uint8
Fallback uint8
}

// Player is a value-comparable membership token (mirrors native sdk.Player).
type Player struct {
AccountID string
Handle string
Kind Kind
Conn string
Character Character
}

// Guest reports whether the player is a keyless guest.
Expand Down Expand Up @@ -224,6 +240,13 @@ const (
// path. Declare it in GameMeta.CtxFeatures.
const CtxFeatRosterEpoch = wire.CtxFeatRosterEpoch

// CtxFeatCharacter opts the game into per-member character sections: the host
// appends each member's resolved Character to every member-bearing roster
// encoding, and the SDK populates Player.Character. Without the declaration
// Player.Character is always the zero value. Declare it in
// GameMeta.CtxFeatures.
const CtxFeatCharacter = wire.CtxFeatCharacter

// Status is a player's terminal outcome.
type Status uint8

Expand Down
Loading
Loading