@wibly/internal-protocol 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +24 -0
- package/src/__snapshots__/wire-format.test.ts.snap +23 -0
- package/src/codec.test.ts +177 -0
- package/src/codec.ts +140 -0
- package/src/fixtures.ts +194 -0
- package/src/index.ts +108 -0
- package/src/messages.test.ts +192 -0
- package/src/messages.ts +347 -0
- package/src/phase.ts +55 -0
- package/src/state-shape.ts +54 -0
- package/src/version.ts +11 -0
- package/src/wire-format.test.ts +30 -0
package/CHANGELOG.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wibly/internal-protocol",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Wibly @wibly/internal-protocol",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"license": "UNLICENSED",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/wibly/wibly"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@wibly/internal-shared": "0.1.1",
|
|
21
|
+
"zod": "^3.25.76"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {}
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`wire-format byte snapshots > encodes the ack fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"ack","seq":3,"id":"msg_ackFixture_00000000000000","ts":1736000000400,"payload":{"sessionId":"ses_V1StGXR8_Z5jdHi6B_myT","ackSeq":5}}"`;
|
|
4
|
+
|
|
5
|
+
exports[`wire-format byte snapshots > encodes the emit fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"emit","seq":2,"id":"msg_emitFixture_0000000000000","ts":1736000000300,"payload":{"sessionId":"ses_V1StGXR8_Z5jdHi6B_myT","eventType":"host.riff","data":{"text":"while you all type..."}}}"`;
|
|
6
|
+
|
|
7
|
+
exports[`wire-format byte snapshots > encodes the error fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"error","seq":4,"id":"msg_errorFixture_00000000000","ts":1736000004000,"payload":{"code":"invalid_request","message":"submit not allowed in current phase","causeMessageId":"msg_submitFixture_00000000000"}}"`;
|
|
8
|
+
|
|
9
|
+
exports[`wire-format byte snapshots > encodes the event fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"event","seq":2,"id":"msg_eventFixture_00000000000","ts":1736000002000,"payload":{"sessionId":"ses_V1StGXR8_Z5jdHi6B_myT","eventType":"phase.opened","data":{"phaseId":"guess"}}}"`;
|
|
10
|
+
|
|
11
|
+
exports[`wire-format byte snapshots > encodes the lifecycle fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"lifecycle","seq":3,"id":"msg_lifecycleFixture_0000000","ts":1736000003000,"payload":{"sessionId":"ses_V1StGXR8_Z5jdHi6B_myT","transition":"session.opened","detail":{"hostPlayerId":"plr_host"}}}"`;
|
|
12
|
+
|
|
13
|
+
exports[`wire-format byte snapshots > encodes the ping fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"ping","seq":4,"id":"msg_pingFixture_0000000000000","ts":1736000000500,"payload":{"sessionId":"ses_V1StGXR8_Z5jdHi6B_myT"}}"`;
|
|
14
|
+
|
|
15
|
+
exports[`wire-format byte snapshots > encodes the pong fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"pong","seq":5,"id":"msg_pongFixture_000000000000","ts":1736000005000,"payload":{"echoTs":1736000004900}}"`;
|
|
16
|
+
|
|
17
|
+
exports[`wire-format byte snapshots > encodes the snapshot fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"snapshot","seq":0,"id":"msg_snapshotFixture_0000000","ts":1736000000000,"payload":{"sessionId":"ses_V1StGXR8_Z5jdHi6B_myT","phaseId":"lobby","state":{"round":0,"scores":{}},"baseSeq":0}}"`;
|
|
18
|
+
|
|
19
|
+
exports[`wire-format byte snapshots > encodes the state_diff fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"state_diff","seq":1,"id":"msg_stateDiffFixture_000000","ts":1736000001000,"payload":{"sessionId":"ses_V1StGXR8_Z5jdHi6B_myT","fromSeq":0,"toSeq":1,"patches":[{"op":"replace","path":"/round","value":1},{"op":"add","path":"/scores/p1","value":0}]}}"`;
|
|
20
|
+
|
|
21
|
+
exports[`wire-format byte snapshots > encodes the submit fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"submit","seq":1,"id":"msg_submitFixture_00000000000","ts":1736000000200,"payload":{"sessionId":"ses_V1StGXR8_Z5jdHi6B_myT","phaseId":"guess","inputType":"guess","data":{"guess":"lemon"}}}"`;
|
|
22
|
+
|
|
23
|
+
exports[`wire-format byte snapshots > encodes the subscribe fixture to its locked wire form 1`] = `"{"protocolVersion":1,"kind":"subscribe","seq":0,"id":"msg_subscribeFixture_000000","ts":1736000000100,"payload":{"sessionId":"ses_V1StGXR8_Z5jdHi6B_myT","resumeFromSeq":12}}"`;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { decode, encode } from './codec.js';
|
|
4
|
+
import {
|
|
5
|
+
fixtures,
|
|
6
|
+
snapshotFixture,
|
|
7
|
+
subscribeFixture,
|
|
8
|
+
} from './fixtures.js';
|
|
9
|
+
import { MESSAGE_KINDS } from './messages.js';
|
|
10
|
+
import { PROTOCOL_VERSION } from './version.js';
|
|
11
|
+
|
|
12
|
+
describe('encode / decode — round-trip every kind losslessly', () => {
|
|
13
|
+
for (const kind of MESSAGE_KINDS) {
|
|
14
|
+
it(`round-trips the ${kind} fixture`, () => {
|
|
15
|
+
const fixture = fixtures[kind];
|
|
16
|
+
const wire = encode(fixture);
|
|
17
|
+
const decoded = decode(wire);
|
|
18
|
+
expect(decoded.ok).toBe(true);
|
|
19
|
+
if (decoded.ok) {
|
|
20
|
+
expect(decoded.value).toEqual(fixture);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('decode — categorised failures', () => {
|
|
27
|
+
it('returns invalid_json for non-JSON input', () => {
|
|
28
|
+
const result = decode('this is not json');
|
|
29
|
+
expect(result.ok).toBe(false);
|
|
30
|
+
if (!result.ok) {
|
|
31
|
+
expect(result.error.kind).toBe('invalid_json');
|
|
32
|
+
if (result.error.kind === 'invalid_json') {
|
|
33
|
+
expect(typeof result.error.cause).toBe('string');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns invalid_envelope when protocolVersion is missing', () => {
|
|
39
|
+
const wire = JSON.stringify({
|
|
40
|
+
kind: 'ping',
|
|
41
|
+
seq: 0,
|
|
42
|
+
id: 'msg_abcdefghijklmnopqrstu',
|
|
43
|
+
ts: 0,
|
|
44
|
+
payload: { sessionId: 'ses_abc' },
|
|
45
|
+
});
|
|
46
|
+
const result = decode(wire);
|
|
47
|
+
expect(result.ok).toBe(false);
|
|
48
|
+
if (!result.ok) {
|
|
49
|
+
expect(result.error.kind).toBe('invalid_envelope');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns invalid_envelope when seq is the wrong type', () => {
|
|
54
|
+
const fixture = fixtures.ping;
|
|
55
|
+
const wire = JSON.stringify({ ...fixture, seq: 'not a number' });
|
|
56
|
+
const result = decode(wire);
|
|
57
|
+
expect(result.ok).toBe(false);
|
|
58
|
+
if (!result.ok) {
|
|
59
|
+
expect(result.error.kind).toBe('invalid_envelope');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns invalid_envelope when id is missing the msg_ prefix', () => {
|
|
64
|
+
const fixture = fixtures.ping;
|
|
65
|
+
const wire = JSON.stringify({ ...fixture, id: 'not-prefixed' });
|
|
66
|
+
const result = decode(wire);
|
|
67
|
+
expect(result.ok).toBe(false);
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
expect(result.error.kind).toBe('invalid_envelope');
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns version_mismatch when protocolVersion is the wrong number', () => {
|
|
74
|
+
const fixture = fixtures.ping;
|
|
75
|
+
const wire = JSON.stringify({ ...fixture, protocolVersion: 999 });
|
|
76
|
+
const result = decode(wire);
|
|
77
|
+
expect(result.ok).toBe(false);
|
|
78
|
+
if (!result.ok) {
|
|
79
|
+
expect(result.error.kind).toBe('version_mismatch');
|
|
80
|
+
if (result.error.kind === 'version_mismatch') {
|
|
81
|
+
expect(result.error.supported).toBe(PROTOCOL_VERSION);
|
|
82
|
+
expect(result.error.received).toBe(999);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns invalid_envelope when protocolVersion is the wrong type (e.g. string)', () => {
|
|
88
|
+
// protocolVersion must be a number per the envelope shape; "1" is a
|
|
89
|
+
// string and fails envelope validation BEFORE the version-literal
|
|
90
|
+
// check. Documenting the order so callers know what to expect.
|
|
91
|
+
const fixture = fixtures.ping;
|
|
92
|
+
const wire = JSON.stringify({ ...fixture, protocolVersion: '1' });
|
|
93
|
+
const result = decode(wire);
|
|
94
|
+
expect(result.ok).toBe(false);
|
|
95
|
+
if (!result.ok) {
|
|
96
|
+
expect(result.error.kind).toBe('invalid_envelope');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns unknown_kind when kind is a string but not in the catalogue', () => {
|
|
101
|
+
const fixture = fixtures.ping;
|
|
102
|
+
const wire = JSON.stringify({ ...fixture, kind: 'snapshott' });
|
|
103
|
+
const result = decode(wire);
|
|
104
|
+
expect(result.ok).toBe(false);
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
expect(result.error.kind).toBe('unknown_kind');
|
|
107
|
+
if (result.error.kind === 'unknown_kind') {
|
|
108
|
+
expect(result.error.received).toBe('snapshott');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns invalid_payload when the kind is known but the payload is wrong', () => {
|
|
114
|
+
const wire = JSON.stringify({
|
|
115
|
+
...snapshotFixture,
|
|
116
|
+
payload: {
|
|
117
|
+
sessionId: snapshotFixture.payload.sessionId,
|
|
118
|
+
phaseId: snapshotFixture.payload.phaseId,
|
|
119
|
+
state: snapshotFixture.payload.state,
|
|
120
|
+
// baseSeq omitted on purpose
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const result = decode(wire);
|
|
124
|
+
expect(result.ok).toBe(false);
|
|
125
|
+
if (!result.ok) {
|
|
126
|
+
expect(result.error.kind).toBe('invalid_payload');
|
|
127
|
+
if (result.error.kind === 'invalid_payload') {
|
|
128
|
+
expect(result.error.messageKind).toBe('snapshot');
|
|
129
|
+
expect(result.error.issues.length).toBeGreaterThan(0);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns invalid_payload when the payload sessionId has the wrong prefix', () => {
|
|
135
|
+
const wire = JSON.stringify({
|
|
136
|
+
...subscribeFixture,
|
|
137
|
+
payload: { ...subscribeFixture.payload, sessionId: 'tnt_wrong' },
|
|
138
|
+
});
|
|
139
|
+
const result = decode(wire);
|
|
140
|
+
expect(result.ok).toBe(false);
|
|
141
|
+
if (!result.ok) {
|
|
142
|
+
expect(result.error.kind).toBe('invalid_payload');
|
|
143
|
+
if (result.error.kind === 'invalid_payload') {
|
|
144
|
+
expect(result.error.messageKind).toBe('subscribe');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('encode — produces deterministic JSON', () => {
|
|
151
|
+
it('encodes the snapshot fixture identically across calls', () => {
|
|
152
|
+
expect(encode(snapshotFixture)).toEqual(encode(snapshotFixture));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('produces parseable JSON for every kind', () => {
|
|
156
|
+
for (const kind of MESSAGE_KINDS) {
|
|
157
|
+
const wire = encode(fixtures[kind]);
|
|
158
|
+
expect(() => JSON.parse(wire)).not.toThrow();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('version-mismatch error shape', () => {
|
|
164
|
+
it('lets the client surface a "please refresh" prompt with both versions', () => {
|
|
165
|
+
const wire = JSON.stringify({
|
|
166
|
+
...snapshotFixture,
|
|
167
|
+
protocolVersion: PROTOCOL_VERSION + 7,
|
|
168
|
+
});
|
|
169
|
+
const result = decode(wire);
|
|
170
|
+
if (result.ok || result.error.kind !== 'version_mismatch') {
|
|
171
|
+
throw new Error('expected version_mismatch error');
|
|
172
|
+
}
|
|
173
|
+
// The acceptance criterion: both supported and received are present.
|
|
174
|
+
expect(result.error.supported).toBe(PROTOCOL_VERSION);
|
|
175
|
+
expect(result.error.received).toBe(PROTOCOL_VERSION + 7);
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/codec.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire codec for protocol messages.
|
|
3
|
+
*
|
|
4
|
+
* - `encode(msg)` — turns a typed `Message` into a JSON string. Pure;
|
|
5
|
+
* never throws (all inputs are already typed).
|
|
6
|
+
* - `decode(raw)` — turns a JSON string into a `Result<Message,
|
|
7
|
+
* ProtocolError>`. Categorises failures so the caller can render the
|
|
8
|
+
* right surface (a version mismatch is a "please refresh" banner, an
|
|
9
|
+
* invalid payload is a logged drop, etc.). Per Vibecode Dev Plan §4.3,
|
|
10
|
+
* never throws across the boundary.
|
|
11
|
+
*
|
|
12
|
+
* MVP uses JSON for legibility and AI tool compatibility. A binary codec
|
|
13
|
+
* (CBOR / MessagePack) is a Phase 2 optimisation — the encode/decode
|
|
14
|
+
* surface stays the same, only the body changes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { err, ok, type Result } from '@wibly/internal-shared';
|
|
18
|
+
import type { ZodIssue } from 'zod';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
MessageSchema,
|
|
23
|
+
isMessageKind,
|
|
24
|
+
type Message,
|
|
25
|
+
type MessageKind,
|
|
26
|
+
} from './messages.js';
|
|
27
|
+
import { PROTOCOL_VERSION, type ProtocolVersion } from './version.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Categorised decode failure. Each variant carries enough information for
|
|
31
|
+
* the caller to render the right surface and log structured context.
|
|
32
|
+
*
|
|
33
|
+
* - `invalid_json` — `JSON.parse` threw. The frame is unrecoverable.
|
|
34
|
+
* - `version_mismatch` — `protocolVersion` was present but didn't equal
|
|
35
|
+
* `PROTOCOL_VERSION`. Carries `supported` and
|
|
36
|
+
* `received` so the client can render a
|
|
37
|
+
* "please refresh" surface (Acceptance §1077).
|
|
38
|
+
* - `invalid_envelope` — Envelope shape is wrong (missing required
|
|
39
|
+
* field, wrong type on a top-level field). Caller
|
|
40
|
+
* should drop the frame and log.
|
|
41
|
+
* - `unknown_kind` — `kind` was a string but not in the catalogue.
|
|
42
|
+
* Likely a forward-compat artifact (newer peer).
|
|
43
|
+
* - `invalid_payload` — Envelope was fine and `kind` was known, but the
|
|
44
|
+
* payload didn't match the per-kind schema.
|
|
45
|
+
*/
|
|
46
|
+
export type ProtocolError =
|
|
47
|
+
| { readonly kind: 'invalid_json'; readonly cause: string }
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: 'version_mismatch';
|
|
50
|
+
readonly supported: ProtocolVersion;
|
|
51
|
+
readonly received: unknown;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
readonly kind: 'invalid_envelope';
|
|
55
|
+
readonly issues: readonly ZodIssue[];
|
|
56
|
+
}
|
|
57
|
+
| { readonly kind: 'unknown_kind'; readonly received: string }
|
|
58
|
+
| {
|
|
59
|
+
readonly kind: 'invalid_payload';
|
|
60
|
+
readonly messageKind: MessageKind;
|
|
61
|
+
readonly issues: readonly ZodIssue[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Envelope-shape pre-check. We use this to peel off the version /
|
|
66
|
+
* envelope / unknown-kind error categories *before* running the full
|
|
67
|
+
* discriminated-union parse — the union's failure mode is "no member
|
|
68
|
+
* matched" which collapses every category into one opaque issue list.
|
|
69
|
+
*
|
|
70
|
+
* `payload` is `unknown` here on purpose; the payload Zod check happens
|
|
71
|
+
* downstream once the kind is known.
|
|
72
|
+
*/
|
|
73
|
+
const EnvelopeShapeSchema = z.object({
|
|
74
|
+
protocolVersion: z.number().int(),
|
|
75
|
+
kind: z.string().min(1),
|
|
76
|
+
seq: z.number().int().nonnegative(),
|
|
77
|
+
id: z.string().regex(/^msg_[A-Za-z0-9_-]+$/),
|
|
78
|
+
ts: z.number().int(),
|
|
79
|
+
payload: z.unknown(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Encode a typed message to its wire form. Pure JSON for MVP. */
|
|
83
|
+
export const encode = (msg: Message): string => JSON.stringify(msg);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Decode a wire-form string. Returns a `Result` so the caller branches
|
|
87
|
+
* explicitly on failure category. Never throws.
|
|
88
|
+
*
|
|
89
|
+
* Decoding order (matters for error categorisation):
|
|
90
|
+
*
|
|
91
|
+
* 1. JSON parse → `invalid_json`
|
|
92
|
+
* 2. Envelope shape → `invalid_envelope`
|
|
93
|
+
* 3. Version literal → `version_mismatch`
|
|
94
|
+
* 4. Kind in catalogue → `unknown_kind`
|
|
95
|
+
* 5. Full payload parse → `invalid_payload`
|
|
96
|
+
*
|
|
97
|
+
* Order is deliberate: a frame missing `protocolVersion` is "envelope
|
|
98
|
+
* malformed" (we can't even tell what version it claims); a frame with a
|
|
99
|
+
* present-but-wrong `protocolVersion` is "we're talking different
|
|
100
|
+
* languages" (the right surface is a refresh prompt, not a bug log).
|
|
101
|
+
*/
|
|
102
|
+
export const decode = (raw: string): Result<Message, ProtocolError> => {
|
|
103
|
+
let parsed: unknown;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw) as unknown;
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return err({
|
|
108
|
+
kind: 'invalid_json',
|
|
109
|
+
cause: e instanceof Error ? e.message : String(e),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const envelope = EnvelopeShapeSchema.safeParse(parsed);
|
|
114
|
+
if (!envelope.success) {
|
|
115
|
+
return err({ kind: 'invalid_envelope', issues: envelope.error.issues });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (envelope.data.protocolVersion !== PROTOCOL_VERSION) {
|
|
119
|
+
return err({
|
|
120
|
+
kind: 'version_mismatch',
|
|
121
|
+
supported: PROTOCOL_VERSION,
|
|
122
|
+
received: envelope.data.protocolVersion,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!isMessageKind(envelope.data.kind)) {
|
|
127
|
+
return err({ kind: 'unknown_kind', received: envelope.data.kind });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const message = MessageSchema.safeParse(parsed);
|
|
131
|
+
if (!message.success) {
|
|
132
|
+
return err({
|
|
133
|
+
kind: 'invalid_payload',
|
|
134
|
+
messageKind: envelope.data.kind,
|
|
135
|
+
issues: message.error.issues,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return ok(message.data);
|
|
140
|
+
};
|
package/src/fixtures.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical message fixtures used by the codec, round-trip, and
|
|
3
|
+
* wire-format snapshot tests. One fixture per message kind, with
|
|
4
|
+
* deterministic ids / seqs / timestamps so the snapshot files lock the
|
|
5
|
+
* exact bytes a downstream peer will receive.
|
|
6
|
+
*
|
|
7
|
+
* Keep these honest examples — anything weird ("does an empty array
|
|
8
|
+
* work?", "what about a 64-bit int?") goes into a dedicated test, not
|
|
9
|
+
* the canonical fixture set.
|
|
10
|
+
*
|
|
11
|
+
* Test-only — never imported by runtime code.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { PROTOCOL_VERSION } from './version.js';
|
|
15
|
+
import type {
|
|
16
|
+
AckMessage,
|
|
17
|
+
EmitMessage,
|
|
18
|
+
ErrorMessage,
|
|
19
|
+
EventMessage,
|
|
20
|
+
LifecycleMessage,
|
|
21
|
+
Message,
|
|
22
|
+
MessageKind,
|
|
23
|
+
PingMessage,
|
|
24
|
+
PongMessage,
|
|
25
|
+
SnapshotMessage,
|
|
26
|
+
StateDiffMessage,
|
|
27
|
+
SubmitMessage,
|
|
28
|
+
SubscribeMessage,
|
|
29
|
+
} from './messages.js';
|
|
30
|
+
|
|
31
|
+
const SESSION_ID = 'ses_V1StGXR8_Z5jdHi6B_myT';
|
|
32
|
+
const FIXED_TS = 1_736_000_000_000;
|
|
33
|
+
|
|
34
|
+
export const snapshotFixture: SnapshotMessage = {
|
|
35
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
36
|
+
kind: 'snapshot',
|
|
37
|
+
seq: 0,
|
|
38
|
+
id: 'msg_snapshotFixture_0000000',
|
|
39
|
+
ts: FIXED_TS,
|
|
40
|
+
payload: {
|
|
41
|
+
sessionId: SESSION_ID,
|
|
42
|
+
phaseId: 'lobby',
|
|
43
|
+
state: { round: 0, scores: {} },
|
|
44
|
+
baseSeq: 0,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const stateDiffFixture: StateDiffMessage = {
|
|
49
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
50
|
+
kind: 'state_diff',
|
|
51
|
+
seq: 1,
|
|
52
|
+
id: 'msg_stateDiffFixture_000000',
|
|
53
|
+
ts: FIXED_TS + 1_000,
|
|
54
|
+
payload: {
|
|
55
|
+
sessionId: SESSION_ID,
|
|
56
|
+
fromSeq: 0,
|
|
57
|
+
toSeq: 1,
|
|
58
|
+
patches: [
|
|
59
|
+
{ op: 'replace', path: '/round', value: 1 },
|
|
60
|
+
{ op: 'add', path: '/scores/p1', value: 0 },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const eventFixture: EventMessage = {
|
|
66
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
67
|
+
kind: 'event',
|
|
68
|
+
seq: 2,
|
|
69
|
+
id: 'msg_eventFixture_00000000000',
|
|
70
|
+
ts: FIXED_TS + 2_000,
|
|
71
|
+
payload: {
|
|
72
|
+
sessionId: SESSION_ID,
|
|
73
|
+
eventType: 'phase.opened',
|
|
74
|
+
data: { phaseId: 'guess' },
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const lifecycleFixture: LifecycleMessage = {
|
|
79
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
80
|
+
kind: 'lifecycle',
|
|
81
|
+
seq: 3,
|
|
82
|
+
id: 'msg_lifecycleFixture_0000000',
|
|
83
|
+
ts: FIXED_TS + 3_000,
|
|
84
|
+
payload: {
|
|
85
|
+
sessionId: SESSION_ID,
|
|
86
|
+
transition: 'session.opened',
|
|
87
|
+
detail: { hostPlayerId: 'plr_host' },
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const errorFixture: ErrorMessage = {
|
|
92
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
93
|
+
kind: 'error',
|
|
94
|
+
seq: 4,
|
|
95
|
+
id: 'msg_errorFixture_00000000000',
|
|
96
|
+
ts: FIXED_TS + 4_000,
|
|
97
|
+
payload: {
|
|
98
|
+
code: 'invalid_request',
|
|
99
|
+
message: 'submit not allowed in current phase',
|
|
100
|
+
causeMessageId: 'msg_submitFixture_00000000000',
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const pongFixture: PongMessage = {
|
|
105
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
106
|
+
kind: 'pong',
|
|
107
|
+
seq: 5,
|
|
108
|
+
id: 'msg_pongFixture_000000000000',
|
|
109
|
+
ts: FIXED_TS + 5_000,
|
|
110
|
+
payload: {
|
|
111
|
+
echoTs: FIXED_TS + 4_900,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const subscribeFixture: SubscribeMessage = {
|
|
116
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
117
|
+
kind: 'subscribe',
|
|
118
|
+
seq: 0,
|
|
119
|
+
id: 'msg_subscribeFixture_000000',
|
|
120
|
+
ts: FIXED_TS + 100,
|
|
121
|
+
payload: {
|
|
122
|
+
sessionId: SESSION_ID,
|
|
123
|
+
resumeFromSeq: 12,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const submitFixture: SubmitMessage = {
|
|
128
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
129
|
+
kind: 'submit',
|
|
130
|
+
seq: 1,
|
|
131
|
+
id: 'msg_submitFixture_00000000000',
|
|
132
|
+
ts: FIXED_TS + 200,
|
|
133
|
+
payload: {
|
|
134
|
+
sessionId: SESSION_ID,
|
|
135
|
+
phaseId: 'guess',
|
|
136
|
+
inputType: 'guess',
|
|
137
|
+
data: { guess: 'lemon' },
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const emitFixture: EmitMessage = {
|
|
142
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
143
|
+
kind: 'emit',
|
|
144
|
+
seq: 2,
|
|
145
|
+
id: 'msg_emitFixture_0000000000000',
|
|
146
|
+
ts: FIXED_TS + 300,
|
|
147
|
+
payload: {
|
|
148
|
+
sessionId: SESSION_ID,
|
|
149
|
+
eventType: 'host.riff',
|
|
150
|
+
data: { text: 'while you all type...' },
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const ackFixture: AckMessage = {
|
|
155
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
156
|
+
kind: 'ack',
|
|
157
|
+
seq: 3,
|
|
158
|
+
id: 'msg_ackFixture_00000000000000',
|
|
159
|
+
ts: FIXED_TS + 400,
|
|
160
|
+
payload: {
|
|
161
|
+
sessionId: SESSION_ID,
|
|
162
|
+
ackSeq: 5,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const pingFixture: PingMessage = {
|
|
167
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
168
|
+
kind: 'ping',
|
|
169
|
+
seq: 4,
|
|
170
|
+
id: 'msg_pingFixture_0000000000000',
|
|
171
|
+
ts: FIXED_TS + 500,
|
|
172
|
+
payload: {
|
|
173
|
+
sessionId: SESSION_ID,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* One fixture per `MessageKind`, keyed by kind so tests can iterate
|
|
179
|
+
* exhaustively. The compile-time check below would fail if a new kind
|
|
180
|
+
* is added to the union without a matching fixture entry.
|
|
181
|
+
*/
|
|
182
|
+
export const fixtures: Readonly<Record<MessageKind, Message>> = {
|
|
183
|
+
snapshot: snapshotFixture,
|
|
184
|
+
state_diff: stateDiffFixture,
|
|
185
|
+
event: eventFixture,
|
|
186
|
+
lifecycle: lifecycleFixture,
|
|
187
|
+
error: errorFixture,
|
|
188
|
+
pong: pongFixture,
|
|
189
|
+
subscribe: subscribeFixture,
|
|
190
|
+
submit: submitFixture,
|
|
191
|
+
emit: emitFixture,
|
|
192
|
+
ack: ackFixture,
|
|
193
|
+
ping: pingFixture,
|
|
194
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@platform/protocol` — wire-protocol types, schemas, and codec shared
|
|
3
|
+
* between the Runtime (`services/runtime`) and the clients (the SDK in
|
|
4
|
+
* `packages/sdk` plus the Host / Player web shells).
|
|
5
|
+
*
|
|
6
|
+
* Per Vibecode Dev Plan §4.6 — every WebSocket message is encoded /
|
|
7
|
+
* decoded with the codecs in this package; no inline schemas anywhere
|
|
8
|
+
* else. The protocol package is the single source of truth.
|
|
9
|
+
*
|
|
10
|
+
* Built in chunk B0; see `docs/chunks/B0.md` for the close note.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { PROTOCOL_VERSION, type ProtocolVersion } from './version.js';
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
// Primitives
|
|
17
|
+
SessionIdSchema,
|
|
18
|
+
type SessionId,
|
|
19
|
+
MessageIdSchema,
|
|
20
|
+
type MessageId,
|
|
21
|
+
JsonPatchOpSchema,
|
|
22
|
+
type JsonPatchOp,
|
|
23
|
+
// Per-kind payload schemas
|
|
24
|
+
SnapshotPayloadSchema,
|
|
25
|
+
type SnapshotPayload,
|
|
26
|
+
StateDiffPayloadSchema,
|
|
27
|
+
type StateDiffPayload,
|
|
28
|
+
EventPayloadSchema,
|
|
29
|
+
type EventPayload,
|
|
30
|
+
LifecyclePayloadSchema,
|
|
31
|
+
type LifecyclePayload,
|
|
32
|
+
ErrorPayloadSchema,
|
|
33
|
+
type ErrorPayload,
|
|
34
|
+
PongPayloadSchema,
|
|
35
|
+
type PongPayload,
|
|
36
|
+
SubscribePayloadSchema,
|
|
37
|
+
type SubscribePayload,
|
|
38
|
+
SubmitPayloadSchema,
|
|
39
|
+
type SubmitPayload,
|
|
40
|
+
EmitPayloadSchema,
|
|
41
|
+
type EmitPayload,
|
|
42
|
+
AckPayloadSchema,
|
|
43
|
+
type AckPayload,
|
|
44
|
+
PingPayloadSchema,
|
|
45
|
+
type PingPayload,
|
|
46
|
+
// Per-kind message schemas
|
|
47
|
+
SnapshotMessageSchema,
|
|
48
|
+
type SnapshotMessage,
|
|
49
|
+
StateDiffMessageSchema,
|
|
50
|
+
type StateDiffMessage,
|
|
51
|
+
EventMessageSchema,
|
|
52
|
+
type EventMessage,
|
|
53
|
+
LifecycleMessageSchema,
|
|
54
|
+
type LifecycleMessage,
|
|
55
|
+
ErrorMessageSchema,
|
|
56
|
+
type ErrorMessage,
|
|
57
|
+
PongMessageSchema,
|
|
58
|
+
type PongMessage,
|
|
59
|
+
SubscribeMessageSchema,
|
|
60
|
+
type SubscribeMessage,
|
|
61
|
+
SubmitMessageSchema,
|
|
62
|
+
type SubmitMessage,
|
|
63
|
+
EmitMessageSchema,
|
|
64
|
+
type EmitMessage,
|
|
65
|
+
AckMessageSchema,
|
|
66
|
+
type AckMessage,
|
|
67
|
+
PingMessageSchema,
|
|
68
|
+
type PingMessage,
|
|
69
|
+
// Direction-grouped + universal schemas
|
|
70
|
+
ServerMessageSchema,
|
|
71
|
+
type ServerMessage,
|
|
72
|
+
ClientMessageSchema,
|
|
73
|
+
type ClientMessage,
|
|
74
|
+
MessageSchema,
|
|
75
|
+
type Message,
|
|
76
|
+
// Kind catalogues + guards
|
|
77
|
+
SERVER_MESSAGE_KINDS,
|
|
78
|
+
CLIENT_MESSAGE_KINDS,
|
|
79
|
+
MESSAGE_KINDS,
|
|
80
|
+
type ServerMessageKind,
|
|
81
|
+
type ClientMessageKind,
|
|
82
|
+
type MessageKind,
|
|
83
|
+
isMessageKind,
|
|
84
|
+
isServerMessageKind,
|
|
85
|
+
isClientMessageKind,
|
|
86
|
+
} from './messages.js';
|
|
87
|
+
|
|
88
|
+
export {
|
|
89
|
+
encode,
|
|
90
|
+
decode,
|
|
91
|
+
type ProtocolError,
|
|
92
|
+
} from './codec.js';
|
|
93
|
+
|
|
94
|
+
export {
|
|
95
|
+
type SessionState,
|
|
96
|
+
type HostState,
|
|
97
|
+
type PlayerState,
|
|
98
|
+
type TeamState,
|
|
99
|
+
type StateSlices,
|
|
100
|
+
} from './state-shape.js';
|
|
101
|
+
|
|
102
|
+
export {
|
|
103
|
+
type ActorKind,
|
|
104
|
+
type InputSet,
|
|
105
|
+
type CollectionRule,
|
|
106
|
+
type Transition,
|
|
107
|
+
type Phase,
|
|
108
|
+
} from './phase.js';
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
errorFixture,
|
|
5
|
+
fixtures,
|
|
6
|
+
snapshotFixture,
|
|
7
|
+
stateDiffFixture,
|
|
8
|
+
subscribeFixture,
|
|
9
|
+
} from './fixtures.js';
|
|
10
|
+
import {
|
|
11
|
+
CLIENT_MESSAGE_KINDS,
|
|
12
|
+
ClientMessageSchema,
|
|
13
|
+
isClientMessageKind,
|
|
14
|
+
isMessageKind,
|
|
15
|
+
isServerMessageKind,
|
|
16
|
+
MESSAGE_KINDS,
|
|
17
|
+
MessageSchema,
|
|
18
|
+
SERVER_MESSAGE_KINDS,
|
|
19
|
+
ServerMessageSchema,
|
|
20
|
+
type MessageKind,
|
|
21
|
+
} from './messages.js';
|
|
22
|
+
|
|
23
|
+
describe('MessageSchema — every kind has a fixture', () => {
|
|
24
|
+
it('the catalogue and the fixture map are exactly aligned', () => {
|
|
25
|
+
expect(Object.keys(fixtures).sort()).toEqual([...MESSAGE_KINDS].sort());
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('MessageSchema — every fixture parses', () => {
|
|
30
|
+
for (const kind of MESSAGE_KINDS) {
|
|
31
|
+
it(`accepts the ${kind} fixture`, () => {
|
|
32
|
+
const fixture = fixtures[kind];
|
|
33
|
+
const parsed = MessageSchema.safeParse(fixture);
|
|
34
|
+
expect(parsed.success).toBe(true);
|
|
35
|
+
if (parsed.success) {
|
|
36
|
+
expect(parsed.data).toEqual(fixture);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('ServerMessageSchema / ClientMessageSchema partition the union', () => {
|
|
43
|
+
for (const kind of SERVER_MESSAGE_KINDS) {
|
|
44
|
+
it(`server schema accepts the ${kind} fixture`, () => {
|
|
45
|
+
expect(ServerMessageSchema.safeParse(fixtures[kind]).success).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
it(`client schema rejects the ${kind} fixture`, () => {
|
|
48
|
+
expect(ClientMessageSchema.safeParse(fixtures[kind]).success).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
for (const kind of CLIENT_MESSAGE_KINDS) {
|
|
52
|
+
it(`client schema accepts the ${kind} fixture`, () => {
|
|
53
|
+
expect(ClientMessageSchema.safeParse(fixtures[kind]).success).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
it(`server schema rejects the ${kind} fixture`, () => {
|
|
56
|
+
expect(ServerMessageSchema.safeParse(fixtures[kind]).success).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('kind guards', () => {
|
|
62
|
+
it('isMessageKind accepts every catalogue entry', () => {
|
|
63
|
+
for (const kind of MESSAGE_KINDS) {
|
|
64
|
+
expect(isMessageKind(kind)).toBe(true);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('isMessageKind rejects strings outside the catalogue', () => {
|
|
69
|
+
expect(isMessageKind('snapshott')).toBe(false);
|
|
70
|
+
expect(isMessageKind('')).toBe(false);
|
|
71
|
+
expect(isMessageKind('SNAPSHOT')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('isServerMessageKind / isClientMessageKind partition the catalogue', () => {
|
|
75
|
+
for (const kind of MESSAGE_KINDS) {
|
|
76
|
+
const server = isServerMessageKind(kind);
|
|
77
|
+
const client = isClientMessageKind(kind);
|
|
78
|
+
expect(server || client).toBe(true);
|
|
79
|
+
expect(server && client).toBe(false);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('envelope shape — required fields', () => {
|
|
85
|
+
it.each([['protocolVersion'], ['kind'], ['seq'], ['id'], ['ts'], ['payload']])(
|
|
86
|
+
'rejects messages missing %s',
|
|
87
|
+
(missingField) => {
|
|
88
|
+
const broken: Record<string, unknown> = { ...snapshotFixture };
|
|
89
|
+
delete broken[missingField];
|
|
90
|
+
const result = MessageSchema.safeParse(broken);
|
|
91
|
+
expect(result.success).toBe(false);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
it('rejects negative seq', () => {
|
|
96
|
+
const broken = { ...snapshotFixture, seq: -1 };
|
|
97
|
+
expect(MessageSchema.safeParse(broken).success).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects non-integer seq', () => {
|
|
101
|
+
const broken = { ...snapshotFixture, seq: 1.5 };
|
|
102
|
+
expect(MessageSchema.safeParse(broken).success).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects an id without the msg_ prefix', () => {
|
|
106
|
+
const broken = { ...snapshotFixture, id: 'not-prefixed' };
|
|
107
|
+
expect(MessageSchema.safeParse(broken).success).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects a sessionId without the ses_ prefix', () => {
|
|
111
|
+
const broken = {
|
|
112
|
+
...snapshotFixture,
|
|
113
|
+
payload: {
|
|
114
|
+
...snapshotFixture.payload,
|
|
115
|
+
sessionId: 'tnt_wrong_prefix_id_value',
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
expect(MessageSchema.safeParse(broken).success).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('per-kind payload shape — happy path is in fixtures; sad paths here', () => {
|
|
123
|
+
it('snapshot rejects a missing baseSeq', () => {
|
|
124
|
+
const broken = {
|
|
125
|
+
...snapshotFixture,
|
|
126
|
+
payload: {
|
|
127
|
+
sessionId: snapshotFixture.payload.sessionId,
|
|
128
|
+
phaseId: snapshotFixture.payload.phaseId,
|
|
129
|
+
state: snapshotFixture.payload.state,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
expect(MessageSchema.safeParse(broken).success).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('state_diff rejects an out-of-order patch op (unknown kind)', () => {
|
|
136
|
+
const broken = {
|
|
137
|
+
...stateDiffFixture,
|
|
138
|
+
payload: {
|
|
139
|
+
...stateDiffFixture.payload,
|
|
140
|
+
patches: [{ op: 'test', path: '/round', value: 1 }],
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
expect(MessageSchema.safeParse(broken).success).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('error rejects an empty message string', () => {
|
|
147
|
+
const broken = {
|
|
148
|
+
...errorFixture,
|
|
149
|
+
payload: { ...errorFixture.payload, message: '' },
|
|
150
|
+
};
|
|
151
|
+
expect(MessageSchema.safeParse(broken).success).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('subscribe accepts an absent resumeFromSeq', () => {
|
|
155
|
+
const slim = {
|
|
156
|
+
...subscribeFixture,
|
|
157
|
+
payload: { sessionId: subscribeFixture.payload.sessionId },
|
|
158
|
+
};
|
|
159
|
+
expect(MessageSchema.safeParse(slim).success).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('catalogues are exhaustive at compile time', () => {
|
|
164
|
+
// Smoke: switch over MessageKind narrows; if a kind is added to the
|
|
165
|
+
// union without an arm here, this test stops compiling. Doubles as a
|
|
166
|
+
// runtime sanity check.
|
|
167
|
+
it('fixtures cover every MessageKind via exhaustive switch', () => {
|
|
168
|
+
const seen = new Set<MessageKind>();
|
|
169
|
+
for (const kind of MESSAGE_KINDS) {
|
|
170
|
+
switch (kind) {
|
|
171
|
+
case 'snapshot':
|
|
172
|
+
case 'state_diff':
|
|
173
|
+
case 'event':
|
|
174
|
+
case 'lifecycle':
|
|
175
|
+
case 'error':
|
|
176
|
+
case 'pong':
|
|
177
|
+
case 'subscribe':
|
|
178
|
+
case 'submit':
|
|
179
|
+
case 'emit':
|
|
180
|
+
case 'ack':
|
|
181
|
+
case 'ping':
|
|
182
|
+
seen.add(kind);
|
|
183
|
+
break;
|
|
184
|
+
default: {
|
|
185
|
+
const exhaustive: never = kind;
|
|
186
|
+
throw new Error(`unhandled kind: ${exhaustive as string}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
expect(seen.size).toBe(MESSAGE_KINDS.length);
|
|
191
|
+
});
|
|
192
|
+
});
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-protocol message schemas.
|
|
3
|
+
*
|
|
4
|
+
* Every WebSocket frame between the Runtime and a connected client is a
|
|
5
|
+
* single envelope of this shape:
|
|
6
|
+
*
|
|
7
|
+
* { protocolVersion, kind, seq, id, ts, payload }
|
|
8
|
+
*
|
|
9
|
+
* - `protocolVersion` — literal `PROTOCOL_VERSION` (see ./version.ts).
|
|
10
|
+
* - `kind` — discriminant; one of `MESSAGE_KINDS`.
|
|
11
|
+
* - `seq` — monotonic per session. Server-assigned on
|
|
12
|
+
* server→client frames; client-assigned (per-tab,
|
|
13
|
+
* per-session) on client→server frames so the
|
|
14
|
+
* server can detect dropped frames + dedupe.
|
|
15
|
+
* - `id` — stable `msg_…` id (per-message, per-direction).
|
|
16
|
+
* Used as the idempotency key on `submit`/`emit`.
|
|
17
|
+
* - `ts` — unix milliseconds. Server time on server→client,
|
|
18
|
+
* client time on client→server.
|
|
19
|
+
* - `payload` — typed by `kind`. See per-kind schemas below.
|
|
20
|
+
*
|
|
21
|
+
* Add new kinds in the chunk that needs them. Per the chunk-B0 trap,
|
|
22
|
+
* keep payloads minimal — extra metadata fields land with the chunk that
|
|
23
|
+
* actually consumes them.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
|
|
28
|
+
import { PROTOCOL_VERSION } from './version.js';
|
|
29
|
+
|
|
30
|
+
// -----------------------------------------------------------------------------
|
|
31
|
+
// Shared primitives
|
|
32
|
+
// -----------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Session id. Format is `ses_<nanoid>` per the prefixed-id convention from
|
|
36
|
+
* `@platform/shared/id`. The protocol validates the prefix only; the full
|
|
37
|
+
* id format is enforced at the Runtime boundary.
|
|
38
|
+
*/
|
|
39
|
+
export const SessionIdSchema = z
|
|
40
|
+
.string()
|
|
41
|
+
.regex(/^ses_[A-Za-z0-9_-]+$/, 'must be a ses_ prefixed nanoid');
|
|
42
|
+
export type SessionId = z.infer<typeof SessionIdSchema>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Message id. Format is `msg_<nanoid>` per `newId('msg')` from
|
|
46
|
+
* `@platform/shared/id`.
|
|
47
|
+
*/
|
|
48
|
+
export const MessageIdSchema = z
|
|
49
|
+
.string()
|
|
50
|
+
.regex(/^msg_[A-Za-z0-9_-]+$/, 'must be a msg_ prefixed nanoid');
|
|
51
|
+
export type MessageId = z.infer<typeof MessageIdSchema>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* JSON-Patch operation subset (RFC 6902 minus `test` — the protocol
|
|
55
|
+
* doesn't need conditional patches). Used inside `state_diff` payloads.
|
|
56
|
+
*/
|
|
57
|
+
export const JsonPatchOpSchema = z.discriminatedUnion('op', [
|
|
58
|
+
z.object({ op: z.literal('add'), path: z.string(), value: z.unknown() }),
|
|
59
|
+
z.object({ op: z.literal('remove'), path: z.string() }),
|
|
60
|
+
z.object({ op: z.literal('replace'), path: z.string(), value: z.unknown() }),
|
|
61
|
+
z.object({ op: z.literal('move'), path: z.string(), from: z.string() }),
|
|
62
|
+
z.object({ op: z.literal('copy'), path: z.string(), from: z.string() }),
|
|
63
|
+
]);
|
|
64
|
+
export type JsonPatchOp = z.infer<typeof JsonPatchOpSchema>;
|
|
65
|
+
|
|
66
|
+
// -----------------------------------------------------------------------------
|
|
67
|
+
// Server → client payloads
|
|
68
|
+
// -----------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Full state load. Sent on initial subscribe and on resume when the
|
|
72
|
+
* client's `resumeFromSeq` is older than the server can satisfy with
|
|
73
|
+
* incremental diffs.
|
|
74
|
+
*
|
|
75
|
+
* `state` is the per-recipient projection of the session's state slices —
|
|
76
|
+
* see `./state-shape.ts`. It's `unknown` at the protocol layer because
|
|
77
|
+
* the shape is described by the Experience manifest (chunk B1).
|
|
78
|
+
*
|
|
79
|
+
* `baseSeq` is the seq the next `state_diff` will start `fromSeq`-equal
|
|
80
|
+
* to. It lets the client tie subsequent diffs to this snapshot without
|
|
81
|
+
* a separate handshake.
|
|
82
|
+
*/
|
|
83
|
+
export const SnapshotPayloadSchema = z.object({
|
|
84
|
+
sessionId: SessionIdSchema,
|
|
85
|
+
phaseId: z.string().min(1),
|
|
86
|
+
state: z.unknown(),
|
|
87
|
+
baseSeq: z.number().int().nonnegative(),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Incremental state update. `fromSeq` must equal the receiver's current
|
|
92
|
+
* applied seq; `toSeq` is the new applied seq after the patches land.
|
|
93
|
+
* Out-of-order or skipped diffs trigger a resync (server resends
|
|
94
|
+
* `snapshot`).
|
|
95
|
+
*/
|
|
96
|
+
export const StateDiffPayloadSchema = z.object({
|
|
97
|
+
sessionId: SessionIdSchema,
|
|
98
|
+
fromSeq: z.number().int().nonnegative(),
|
|
99
|
+
toSeq: z.number().int().nonnegative(),
|
|
100
|
+
patches: z.array(JsonPatchOpSchema),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Server-emitted event. Distinct from `state_diff`: events are
|
|
105
|
+
* fire-and-forget signals (animation cues, system messages, opportunity
|
|
106
|
+
* announcements) that don't mutate persistent state.
|
|
107
|
+
*/
|
|
108
|
+
export const EventPayloadSchema = z.object({
|
|
109
|
+
sessionId: SessionIdSchema,
|
|
110
|
+
eventType: z.string().min(1),
|
|
111
|
+
data: z.unknown(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Lifecycle transition (session opened / closed, phase entered / exited,
|
|
116
|
+
* host reclaimed, etc.). `transition` is a namespaced string so future
|
|
117
|
+
* lifecycle verbs land without a schema bump.
|
|
118
|
+
*/
|
|
119
|
+
export const LifecyclePayloadSchema = z.object({
|
|
120
|
+
sessionId: SessionIdSchema,
|
|
121
|
+
transition: z.string().min(1),
|
|
122
|
+
detail: z.unknown(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Server-emitted error. `code` is the structured category; `message` is
|
|
127
|
+
* operator-readable. `causeMessageId` ties the error to the client frame
|
|
128
|
+
* that triggered it (when applicable).
|
|
129
|
+
*/
|
|
130
|
+
export const ErrorPayloadSchema = z.object({
|
|
131
|
+
code: z.string().min(1),
|
|
132
|
+
message: z.string().min(1),
|
|
133
|
+
causeMessageId: MessageIdSchema.optional(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Heartbeat response. `echoTs` is the client's `ping` envelope `ts` so
|
|
138
|
+
* the client can compute round-trip latency.
|
|
139
|
+
*/
|
|
140
|
+
export const PongPayloadSchema = z.object({
|
|
141
|
+
echoTs: z.number().int(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// -----------------------------------------------------------------------------
|
|
145
|
+
// Client → server payloads
|
|
146
|
+
// -----------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Subscribe to a session. `resumeFromSeq` is the last seq the client has
|
|
150
|
+
* fully applied; the server replies with `state_diff`s when it can
|
|
151
|
+
* satisfy from buffer, otherwise a fresh `snapshot`.
|
|
152
|
+
*/
|
|
153
|
+
export const SubscribePayloadSchema = z.object({
|
|
154
|
+
sessionId: SessionIdSchema,
|
|
155
|
+
resumeFromSeq: z.number().int().nonnegative().optional(),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Active input — the workhorse for turn-based collection. The Runtime
|
|
160
|
+
* gates on the current phase's `InputSet`; submissions for the wrong
|
|
161
|
+
* phase / input type get rejected with a structured `error` frame.
|
|
162
|
+
*
|
|
163
|
+
* `id` (envelope-level) is the idempotency key — duplicates are deduped
|
|
164
|
+
* server-side per Vibecode Dev Plan §4.6.
|
|
165
|
+
*/
|
|
166
|
+
export const SubmitPayloadSchema = z.object({
|
|
167
|
+
sessionId: SessionIdSchema,
|
|
168
|
+
phaseId: z.string().min(1),
|
|
169
|
+
inputType: z.string().min(1),
|
|
170
|
+
data: z.unknown(),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Non-Active emission (host riff while players type, the "Yellow"
|
|
175
|
+
* pattern in Platform Spec §1.2). Idempotency rules match `submit`.
|
|
176
|
+
*/
|
|
177
|
+
export const EmitPayloadSchema = z.object({
|
|
178
|
+
sessionId: SessionIdSchema,
|
|
179
|
+
eventType: z.string().min(1),
|
|
180
|
+
data: z.unknown(),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Acknowledge a server message by its `seq`. Lets the server prune its
|
|
185
|
+
* outgoing buffer once every recipient has acked.
|
|
186
|
+
*/
|
|
187
|
+
export const AckPayloadSchema = z.object({
|
|
188
|
+
sessionId: SessionIdSchema,
|
|
189
|
+
ackSeq: z.number().int().nonnegative(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Heartbeat. Carries `sessionId` so the server can route in a
|
|
194
|
+
* multi-session connection (Phase 2 — MVP is one session per socket).
|
|
195
|
+
*/
|
|
196
|
+
export const PingPayloadSchema = z.object({
|
|
197
|
+
sessionId: SessionIdSchema,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// -----------------------------------------------------------------------------
|
|
201
|
+
// Kind catalogue
|
|
202
|
+
// -----------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Reserved for Phase 2 — P-bundles. The Runtime emits `bundle_snapshot` on
|
|
206
|
+
* bundle subscribe, `bundle_diff` on bundle state mutation, and
|
|
207
|
+
* `bundle_event` on cross-session bundle events. Adding them to the union
|
|
208
|
+
* is a Phase-2 chunk; the codec round-trip tests expand at the same time.
|
|
209
|
+
*
|
|
210
|
+
* @deferred Phase 2 — P-bundles
|
|
211
|
+
*
|
|
212
|
+
* 'bundle_snapshot',
|
|
213
|
+
* 'bundle_diff',
|
|
214
|
+
* 'bundle_event',
|
|
215
|
+
*/
|
|
216
|
+
|
|
217
|
+
export const SERVER_MESSAGE_KINDS = [
|
|
218
|
+
'snapshot',
|
|
219
|
+
'state_diff',
|
|
220
|
+
'event',
|
|
221
|
+
'lifecycle',
|
|
222
|
+
'error',
|
|
223
|
+
'pong',
|
|
224
|
+
] as const;
|
|
225
|
+
|
|
226
|
+
export const CLIENT_MESSAGE_KINDS = [
|
|
227
|
+
'subscribe',
|
|
228
|
+
'submit',
|
|
229
|
+
'emit',
|
|
230
|
+
'ack',
|
|
231
|
+
'ping',
|
|
232
|
+
] as const;
|
|
233
|
+
|
|
234
|
+
export const MESSAGE_KINDS = [
|
|
235
|
+
...SERVER_MESSAGE_KINDS,
|
|
236
|
+
...CLIENT_MESSAGE_KINDS,
|
|
237
|
+
] as const;
|
|
238
|
+
|
|
239
|
+
export type ServerMessageKind = (typeof SERVER_MESSAGE_KINDS)[number];
|
|
240
|
+
export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
|
|
241
|
+
export type MessageKind = ServerMessageKind | ClientMessageKind;
|
|
242
|
+
|
|
243
|
+
const SERVER_KIND_SET: ReadonlySet<string> = new Set(SERVER_MESSAGE_KINDS);
|
|
244
|
+
const CLIENT_KIND_SET: ReadonlySet<string> = new Set(CLIENT_MESSAGE_KINDS);
|
|
245
|
+
const KIND_SET: ReadonlySet<string> = new Set(MESSAGE_KINDS);
|
|
246
|
+
|
|
247
|
+
export const isMessageKind = (k: string): k is MessageKind => KIND_SET.has(k);
|
|
248
|
+
export const isServerMessageKind = (k: string): k is ServerMessageKind =>
|
|
249
|
+
SERVER_KIND_SET.has(k);
|
|
250
|
+
export const isClientMessageKind = (k: string): k is ClientMessageKind =>
|
|
251
|
+
CLIENT_KIND_SET.has(k);
|
|
252
|
+
|
|
253
|
+
// -----------------------------------------------------------------------------
|
|
254
|
+
// Envelope + discriminated union
|
|
255
|
+
// -----------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
const envelopeFields = {
|
|
258
|
+
protocolVersion: z.literal(PROTOCOL_VERSION),
|
|
259
|
+
seq: z.number().int().nonnegative(),
|
|
260
|
+
id: MessageIdSchema,
|
|
261
|
+
ts: z.number().int(),
|
|
262
|
+
} as const;
|
|
263
|
+
|
|
264
|
+
const message = <K extends MessageKind, P extends z.ZodTypeAny>(
|
|
265
|
+
kind: K,
|
|
266
|
+
payload: P,
|
|
267
|
+
) =>
|
|
268
|
+
z.object({
|
|
269
|
+
...envelopeFields,
|
|
270
|
+
kind: z.literal(kind),
|
|
271
|
+
payload,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
export const SnapshotMessageSchema = message('snapshot', SnapshotPayloadSchema);
|
|
275
|
+
export const StateDiffMessageSchema = message('state_diff', StateDiffPayloadSchema);
|
|
276
|
+
export const EventMessageSchema = message('event', EventPayloadSchema);
|
|
277
|
+
export const LifecycleMessageSchema = message('lifecycle', LifecyclePayloadSchema);
|
|
278
|
+
export const ErrorMessageSchema = message('error', ErrorPayloadSchema);
|
|
279
|
+
export const PongMessageSchema = message('pong', PongPayloadSchema);
|
|
280
|
+
export const SubscribeMessageSchema = message('subscribe', SubscribePayloadSchema);
|
|
281
|
+
export const SubmitMessageSchema = message('submit', SubmitPayloadSchema);
|
|
282
|
+
export const EmitMessageSchema = message('emit', EmitPayloadSchema);
|
|
283
|
+
export const AckMessageSchema = message('ack', AckPayloadSchema);
|
|
284
|
+
export const PingMessageSchema = message('ping', PingPayloadSchema);
|
|
285
|
+
|
|
286
|
+
export const ServerMessageSchema = z.discriminatedUnion('kind', [
|
|
287
|
+
SnapshotMessageSchema,
|
|
288
|
+
StateDiffMessageSchema,
|
|
289
|
+
EventMessageSchema,
|
|
290
|
+
LifecycleMessageSchema,
|
|
291
|
+
ErrorMessageSchema,
|
|
292
|
+
PongMessageSchema,
|
|
293
|
+
]);
|
|
294
|
+
|
|
295
|
+
export const ClientMessageSchema = z.discriminatedUnion('kind', [
|
|
296
|
+
SubscribeMessageSchema,
|
|
297
|
+
SubmitMessageSchema,
|
|
298
|
+
EmitMessageSchema,
|
|
299
|
+
AckMessageSchema,
|
|
300
|
+
PingMessageSchema,
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
export const MessageSchema = z.discriminatedUnion('kind', [
|
|
304
|
+
SnapshotMessageSchema,
|
|
305
|
+
StateDiffMessageSchema,
|
|
306
|
+
EventMessageSchema,
|
|
307
|
+
LifecycleMessageSchema,
|
|
308
|
+
ErrorMessageSchema,
|
|
309
|
+
PongMessageSchema,
|
|
310
|
+
SubscribeMessageSchema,
|
|
311
|
+
SubmitMessageSchema,
|
|
312
|
+
EmitMessageSchema,
|
|
313
|
+
AckMessageSchema,
|
|
314
|
+
PingMessageSchema,
|
|
315
|
+
]);
|
|
316
|
+
|
|
317
|
+
// -----------------------------------------------------------------------------
|
|
318
|
+
// Inferred types (use the OUTPUT type via z.infer per §4.1 / chunk A1)
|
|
319
|
+
// -----------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
export type SnapshotMessage = z.infer<typeof SnapshotMessageSchema>;
|
|
322
|
+
export type StateDiffMessage = z.infer<typeof StateDiffMessageSchema>;
|
|
323
|
+
export type EventMessage = z.infer<typeof EventMessageSchema>;
|
|
324
|
+
export type LifecycleMessage = z.infer<typeof LifecycleMessageSchema>;
|
|
325
|
+
export type ErrorMessage = z.infer<typeof ErrorMessageSchema>;
|
|
326
|
+
export type PongMessage = z.infer<typeof PongMessageSchema>;
|
|
327
|
+
export type SubscribeMessage = z.infer<typeof SubscribeMessageSchema>;
|
|
328
|
+
export type SubmitMessage = z.infer<typeof SubmitMessageSchema>;
|
|
329
|
+
export type EmitMessage = z.infer<typeof EmitMessageSchema>;
|
|
330
|
+
export type AckMessage = z.infer<typeof AckMessageSchema>;
|
|
331
|
+
export type PingMessage = z.infer<typeof PingMessageSchema>;
|
|
332
|
+
|
|
333
|
+
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
|
334
|
+
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
|
335
|
+
export type Message = z.infer<typeof MessageSchema>;
|
|
336
|
+
|
|
337
|
+
export type SnapshotPayload = z.infer<typeof SnapshotPayloadSchema>;
|
|
338
|
+
export type StateDiffPayload = z.infer<typeof StateDiffPayloadSchema>;
|
|
339
|
+
export type EventPayload = z.infer<typeof EventPayloadSchema>;
|
|
340
|
+
export type LifecyclePayload = z.infer<typeof LifecyclePayloadSchema>;
|
|
341
|
+
export type ErrorPayload = z.infer<typeof ErrorPayloadSchema>;
|
|
342
|
+
export type PongPayload = z.infer<typeof PongPayloadSchema>;
|
|
343
|
+
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
|
|
344
|
+
export type SubmitPayload = z.infer<typeof SubmitPayloadSchema>;
|
|
345
|
+
export type EmitPayload = z.infer<typeof EmitPayloadSchema>;
|
|
346
|
+
export type AckPayload = z.infer<typeof AckPayloadSchema>;
|
|
347
|
+
export type PingPayload = z.infer<typeof PingPayloadSchema>;
|
package/src/phase.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow primitives per Platform Spec §3.9.1.
|
|
3
|
+
*
|
|
4
|
+
* Every Experience is a state machine of `Phase`s. Each phase declares an
|
|
5
|
+
* `InputSet` (whose inputs are accepted) and a `CollectionRule` (when the
|
|
6
|
+
* phase ends), plus zero or more `Transition`s out. The Runtime owns the
|
|
7
|
+
* authoritative phase pointer; clients cannot drive transitions
|
|
8
|
+
* (Invariant §1.2 — turn-based by default).
|
|
9
|
+
*
|
|
10
|
+
* Per the chunk-B0 trap ("keep messages minimal — add fields when a chunk
|
|
11
|
+
* needs them"), these types are deliberately narrow. The manifest schema
|
|
12
|
+
* in chunk B1 layers Zod validation, structural checks, and any
|
|
13
|
+
* additional manifest-time fields on top.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type ActorKind = 'host' | 'player' | 'team';
|
|
17
|
+
|
|
18
|
+
export type InputSet = {
|
|
19
|
+
/** Which actor classes may produce input during this phase. */
|
|
20
|
+
readonly actors: ReadonlyArray<ActorKind>;
|
|
21
|
+
/**
|
|
22
|
+
* Manifest-defined input identifier (e.g. 'guess', 'vote', 'free_text').
|
|
23
|
+
* The Runtime validates against the manifest's per-input Zod schema; the
|
|
24
|
+
* protocol carries it as an opaque string.
|
|
25
|
+
*/
|
|
26
|
+
readonly inputType: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* When the phase ends. Discriminated on `kind` so the Runtime can branch
|
|
31
|
+
* exhaustively. Add new variants in the chunk that needs them.
|
|
32
|
+
*/
|
|
33
|
+
export type CollectionRule =
|
|
34
|
+
| { readonly kind: 'all_respond' }
|
|
35
|
+
| { readonly kind: 'first_respond'; readonly count: number }
|
|
36
|
+
| { readonly kind: 'timeout'; readonly ms: number }
|
|
37
|
+
| { readonly kind: 'manual' };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One outgoing edge from a phase. The Runtime evaluates transitions in
|
|
41
|
+
* declaration order on collection-rule fire; the first matching transition
|
|
42
|
+
* is taken. Conditions are manifest-level; the protocol just carries the
|
|
43
|
+
* tag.
|
|
44
|
+
*/
|
|
45
|
+
export type Transition = {
|
|
46
|
+
readonly to: string;
|
|
47
|
+
readonly when?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type Phase = {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly inputSet: InputSet;
|
|
53
|
+
readonly collectionRule: CollectionRule;
|
|
54
|
+
readonly transitions: ReadonlyArray<Transition>;
|
|
55
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic state-slice types per Platform Spec §2.4.4.
|
|
3
|
+
*
|
|
4
|
+
* The Runtime maintains a session's authoritative state on the server and
|
|
5
|
+
* pushes it to clients as a `snapshot` on subscribe / reconnect, then as
|
|
6
|
+
* incremental `state_diff` patches between snapshots. The slices below are
|
|
7
|
+
* the *shapes* the protocol carries; the *content* is described by the
|
|
8
|
+
* Experience manifest's `stateSchema` (chunk B1) and is opaque at the
|
|
9
|
+
* protocol layer — hence the type parameters default to `unknown`.
|
|
10
|
+
*
|
|
11
|
+
* Visibility model:
|
|
12
|
+
*
|
|
13
|
+
* - `SessionState` — global, visible to host + every player.
|
|
14
|
+
* - `HostState` — host-only.
|
|
15
|
+
* - `PlayerState` — split into `public` (visible to all) and `private`
|
|
16
|
+
* (visible only to that player). Per Platform Spec
|
|
17
|
+
* §2.4.4 — the public/private split is the workhorse
|
|
18
|
+
* for hidden information in turn-based games.
|
|
19
|
+
* - `TeamState` — visible to members of one team.
|
|
20
|
+
*
|
|
21
|
+
* Actor-specific slices are optional: a snapshot delivered to a player
|
|
22
|
+
* carries `session` + `player` + (their) `team`; a snapshot delivered to
|
|
23
|
+
* the host carries `session` + `host` + every team. The Runtime composes
|
|
24
|
+
* the per-recipient projection; the protocol just transports it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type SessionState<T = unknown> = T;
|
|
28
|
+
|
|
29
|
+
export type HostState<T = unknown> = T;
|
|
30
|
+
|
|
31
|
+
export type PlayerState<TPublic = unknown, TPrivate = unknown> = {
|
|
32
|
+
readonly public: TPublic;
|
|
33
|
+
readonly private: TPrivate;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type TeamState<T = unknown> = T;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Composite shape carried inside the `snapshot` payload's `state` field.
|
|
40
|
+
* Each slice is optional so the Runtime can omit slices the recipient is
|
|
41
|
+
* not entitled to see.
|
|
42
|
+
*/
|
|
43
|
+
export type StateSlices<
|
|
44
|
+
TSession = unknown,
|
|
45
|
+
THost = unknown,
|
|
46
|
+
TPlayerPublic = unknown,
|
|
47
|
+
TPlayerPrivate = unknown,
|
|
48
|
+
TTeam = unknown,
|
|
49
|
+
> = {
|
|
50
|
+
readonly session: SessionState<TSession>;
|
|
51
|
+
readonly host?: HostState<THost>;
|
|
52
|
+
readonly player?: PlayerState<TPlayerPublic, TPlayerPrivate>;
|
|
53
|
+
readonly team?: TeamState<TTeam>;
|
|
54
|
+
};
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire-protocol version. Bumped when an incompatible envelope or
|
|
3
|
+
* payload change ships. Servers and clients refuse to talk to a peer
|
|
4
|
+
* advertising a different version (see `decode` in `./codec.ts` →
|
|
5
|
+
* `version_mismatch`).
|
|
6
|
+
*
|
|
7
|
+
* MVP starts at version 1; subsequent bumps land in their own chunk.
|
|
8
|
+
*/
|
|
9
|
+
export const PROTOCOL_VERSION = 1 as const;
|
|
10
|
+
|
|
11
|
+
export type ProtocolVersion = typeof PROTOCOL_VERSION;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-format snapshot tests.
|
|
3
|
+
*
|
|
4
|
+
* Per Vibecode Dev Plan §4.7.5 — snapshot tests are appropriate for
|
|
5
|
+
* stable wire formats (anything where a non-meaningful change really
|
|
6
|
+
* IS a contract break). This file holds one snapshot per `MessageKind`
|
|
7
|
+
* locking the exact JSON-string bytes a downstream peer would receive.
|
|
8
|
+
*
|
|
9
|
+
* If a fixture change is expected (e.g. a new optional field), update
|
|
10
|
+
* the fixture AND regenerate this file's snapshot in the same commit:
|
|
11
|
+
* `pnpm --filter @platform/protocol test -- -u`
|
|
12
|
+
*
|
|
13
|
+
* If a snapshot diff is *un*expected, the contract is breaking and the
|
|
14
|
+
* change needs a `PROTOCOL_VERSION` bump. (See `./version.ts`.)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from 'vitest';
|
|
18
|
+
|
|
19
|
+
import { encode } from './codec.js';
|
|
20
|
+
import { fixtures } from './fixtures.js';
|
|
21
|
+
import { MESSAGE_KINDS } from './messages.js';
|
|
22
|
+
|
|
23
|
+
describe('wire-format byte snapshots', () => {
|
|
24
|
+
for (const kind of MESSAGE_KINDS) {
|
|
25
|
+
it(`encodes the ${kind} fixture to its locked wire form`, () => {
|
|
26
|
+
const wire = encode(fixtures[kind]);
|
|
27
|
+
expect(wire).toMatchSnapshot();
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
});
|