@wibly/internal-manifest 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 +25 -0
- package/src/content-rating.test.ts +26 -0
- package/src/content-rating.ts +51 -0
- package/src/fixtures.ts +229 -0
- package/src/index.ts +126 -0
- package/src/manifest.test.ts +351 -0
- package/src/manifest.ts +607 -0
- package/src/phase.test.ts +227 -0
- package/src/phase.ts +304 -0
- package/src/portal-display.test.ts +54 -0
- package/src/portal-display.ts +40 -0
- package/src/validate.test.ts +310 -0
- package/src/validate.ts +385 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ActorKindSchema,
|
|
5
|
+
CollectionRuleSchema,
|
|
6
|
+
InputSetSchema,
|
|
7
|
+
PhaseSchema,
|
|
8
|
+
TransitionSchema,
|
|
9
|
+
WorkflowSideEffectSchema,
|
|
10
|
+
} from './phase.js';
|
|
11
|
+
|
|
12
|
+
describe('ActorKindSchema', () => {
|
|
13
|
+
it.each(['host', 'player', 'team'])('accepts %s', (kind) => {
|
|
14
|
+
expect(ActorKindSchema.safeParse(kind).success).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects an unknown actor', () => {
|
|
18
|
+
expect(ActorKindSchema.safeParse('observer').success).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('InputSetSchema', () => {
|
|
23
|
+
it('accepts a well-formed input set', () => {
|
|
24
|
+
expect(
|
|
25
|
+
InputSetSchema.safeParse({
|
|
26
|
+
actors: ['player'],
|
|
27
|
+
inputType: 'guess',
|
|
28
|
+
}).success,
|
|
29
|
+
).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('rejects an empty actors array (a phase nobody answers is undriveable)', () => {
|
|
33
|
+
const result = InputSetSchema.safeParse({
|
|
34
|
+
actors: [],
|
|
35
|
+
inputType: 'guess',
|
|
36
|
+
});
|
|
37
|
+
expect(result.success).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects an empty inputType', () => {
|
|
41
|
+
expect(
|
|
42
|
+
InputSetSchema.safeParse({ actors: ['player'], inputType: '' }).success,
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('CollectionRuleSchema', () => {
|
|
48
|
+
it('accepts every variant', () => {
|
|
49
|
+
expect(CollectionRuleSchema.safeParse({ kind: 'all_respond' }).success).toBe(
|
|
50
|
+
true,
|
|
51
|
+
);
|
|
52
|
+
expect(
|
|
53
|
+
CollectionRuleSchema.safeParse({ kind: 'first_respond', count: 3 })
|
|
54
|
+
.success,
|
|
55
|
+
).toBe(true);
|
|
56
|
+
expect(
|
|
57
|
+
CollectionRuleSchema.safeParse({ kind: 'timeout', ms: 30_000 }).success,
|
|
58
|
+
).toBe(true);
|
|
59
|
+
expect(CollectionRuleSchema.safeParse({ kind: 'manual' }).success).toBe(
|
|
60
|
+
true,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects a zero-ms timeout (would deadlock the Runtime)', () => {
|
|
65
|
+
expect(
|
|
66
|
+
CollectionRuleSchema.safeParse({ kind: 'timeout', ms: 0 }).success,
|
|
67
|
+
).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects a zero-count first_respond (would resolve before any response)', () => {
|
|
71
|
+
expect(
|
|
72
|
+
CollectionRuleSchema.safeParse({ kind: 'first_respond', count: 0 })
|
|
73
|
+
.success,
|
|
74
|
+
).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects an unknown kind', () => {
|
|
78
|
+
expect(
|
|
79
|
+
CollectionRuleSchema.safeParse({ kind: 'eventually' }).success,
|
|
80
|
+
).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('TransitionSchema', () => {
|
|
85
|
+
it('accepts a transition without a `when` guard', () => {
|
|
86
|
+
expect(TransitionSchema.safeParse({ to: 'guess' }).success).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('accepts a transition with a `when` guard', () => {
|
|
90
|
+
expect(
|
|
91
|
+
TransitionSchema.safeParse({ to: 'guess', when: 'has_quorum' }).success,
|
|
92
|
+
).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('rejects an empty `to`', () => {
|
|
96
|
+
expect(TransitionSchema.safeParse({ to: '' }).success).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('PhaseSchema', () => {
|
|
101
|
+
const baseline = {
|
|
102
|
+
id: 'lobby',
|
|
103
|
+
inputSet: { actors: ['host'], inputType: 'start' },
|
|
104
|
+
collectionRule: { kind: 'manual' },
|
|
105
|
+
transitions: [{ to: 'guess' }],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
it('accepts a well-formed phase', () => {
|
|
109
|
+
expect(PhaseSchema.safeParse(baseline).success).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects a phase with no transitions (terminal phases require an explicit exit edge in MVP)', () => {
|
|
113
|
+
const result = PhaseSchema.safeParse({ ...baseline, transitions: [] });
|
|
114
|
+
expect(result.success).toBe(false);
|
|
115
|
+
if (!result.success) {
|
|
116
|
+
expect(
|
|
117
|
+
result.error.issues.some((i) => i.path.includes('transitions')),
|
|
118
|
+
).toBe(true);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects an empty phase id', () => {
|
|
123
|
+
const result = PhaseSchema.safeParse({ ...baseline, id: '' });
|
|
124
|
+
expect(result.success).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('rejects a phase missing the input set', () => {
|
|
128
|
+
const incomplete: Record<string, unknown> = { ...baseline };
|
|
129
|
+
delete incomplete.inputSet;
|
|
130
|
+
expect(PhaseSchema.safeParse(incomplete).success).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('defaults sideEffects to an empty array (chunk B8c)', () => {
|
|
134
|
+
const result = PhaseSchema.safeParse(baseline);
|
|
135
|
+
expect(result.success).toBe(true);
|
|
136
|
+
if (result.success) {
|
|
137
|
+
expect(result.data.sideEffects).toEqual([]);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('accepts a phase that declares sideEffects (chunk B8c)', () => {
|
|
142
|
+
const result = PhaseSchema.safeParse({
|
|
143
|
+
...baseline,
|
|
144
|
+
sideEffects: [
|
|
145
|
+
{ kind: 'state_write', patches: [{ op: 'replace', path: '/x', value: 1 }] },
|
|
146
|
+
{ kind: 'inference', callKind: 'host_judge' },
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
expect(result.success).toBe(true);
|
|
150
|
+
if (result.success) {
|
|
151
|
+
expect(result.data.sideEffects).toHaveLength(2);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects an unknown side-effect kind', () => {
|
|
156
|
+
const result = PhaseSchema.safeParse({
|
|
157
|
+
...baseline,
|
|
158
|
+
sideEffects: [{ kind: 'unknown', value: 1 }],
|
|
159
|
+
});
|
|
160
|
+
expect(result.success).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('WorkflowSideEffectSchema (chunk B8c)', () => {
|
|
165
|
+
it('accepts the state_write variant', () => {
|
|
166
|
+
const result = WorkflowSideEffectSchema.safeParse({
|
|
167
|
+
kind: 'state_write',
|
|
168
|
+
patches: [{ op: 'replace', path: '/session/round', value: 1 }],
|
|
169
|
+
});
|
|
170
|
+
expect(result.success).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects a state_write with zero patches (no-op should be omitted)', () => {
|
|
174
|
+
const result = WorkflowSideEffectSchema.safeParse({
|
|
175
|
+
kind: 'state_write',
|
|
176
|
+
patches: [],
|
|
177
|
+
});
|
|
178
|
+
expect(result.success).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('accepts an inference effect with a targetPath JSON-Pointer', () => {
|
|
182
|
+
const result = WorkflowSideEffectSchema.safeParse({
|
|
183
|
+
kind: 'inference',
|
|
184
|
+
callKind: 'host_judge',
|
|
185
|
+
qualityTier: 'standard',
|
|
186
|
+
targetPath: '/session/inference/host_judge',
|
|
187
|
+
});
|
|
188
|
+
expect(result.success).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('rejects an inference effect whose callKind is outside the enum', () => {
|
|
192
|
+
const result = WorkflowSideEffectSchema.safeParse({
|
|
193
|
+
kind: 'inference',
|
|
194
|
+
callKind: 'totally_made_up',
|
|
195
|
+
});
|
|
196
|
+
expect(result.success).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('rejects an inference effect with a malformed targetPath', () => {
|
|
200
|
+
const result = WorkflowSideEffectSchema.safeParse({
|
|
201
|
+
kind: 'inference',
|
|
202
|
+
callKind: 'host_judge',
|
|
203
|
+
targetPath: 'not-a-pointer',
|
|
204
|
+
});
|
|
205
|
+
expect(result.success).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('accepts a persona_memory effect', () => {
|
|
209
|
+
const result = WorkflowSideEffectSchema.safeParse({
|
|
210
|
+
kind: 'persona_memory',
|
|
211
|
+
personaId: 'per_abc123',
|
|
212
|
+
op: 'write',
|
|
213
|
+
payload: { note: 'hello' },
|
|
214
|
+
});
|
|
215
|
+
expect(result.success).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('accepts a scoring effect with a negative value (corrections)', () => {
|
|
219
|
+
const result = WorkflowSideEffectSchema.safeParse({
|
|
220
|
+
kind: 'scoring',
|
|
221
|
+
dimension: 'accuracy',
|
|
222
|
+
value: -1,
|
|
223
|
+
source: 'submit',
|
|
224
|
+
});
|
|
225
|
+
expect(result.success).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
package/src/phase.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest-time Zod schemas for the workflow primitives.
|
|
3
|
+
*
|
|
4
|
+
* Per Platform Spec §3.9.1: every Experience is a state machine of `Phase`s.
|
|
5
|
+
* Each phase declares an `InputSet` (whose inputs are accepted) and a
|
|
6
|
+
* `CollectionRule` (when the phase ends), plus zero or more `Transition`s
|
|
7
|
+
* out.
|
|
8
|
+
*
|
|
9
|
+
* The narrow on-the-wire types live in `@platform/protocol`; this package
|
|
10
|
+
* wraps them in Zod so a manifest blob (parsed from JSONB or hand-authored
|
|
11
|
+
* by a Creator) can be validated with one call. The protocol types stay
|
|
12
|
+
* minimal and TS-only because every WebSocket frame pays for those types
|
|
13
|
+
* at runtime — the Zod machinery has no business there.
|
|
14
|
+
*
|
|
15
|
+
* Per Vibecode Dev Plan §4.6 the protocol package is the *single* source
|
|
16
|
+
* of truth for the on-the-wire shape; the manifest layer is a strict
|
|
17
|
+
* superset (the chunk-B8c `sideEffects` declaration lives on the
|
|
18
|
+
* manifest's `Phase` but does NOT travel the protocol — phases on the
|
|
19
|
+
* wire are still the bare protocol shape).
|
|
20
|
+
*
|
|
21
|
+
* Per chunk-A1 §4.1: `z.infer<typeof X>` returns the schema's *output*
|
|
22
|
+
* type. The manifest-layer `Phase` re-export here is the schema-inferred
|
|
23
|
+
* shape (with `sideEffects: WorkflowSideEffect[]`); the wire-format
|
|
24
|
+
* `Phase` stays in `@platform/protocol` and is re-imported there.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
ActorKind,
|
|
29
|
+
CollectionRule,
|
|
30
|
+
InputSet,
|
|
31
|
+
Phase,
|
|
32
|
+
Transition,
|
|
33
|
+
} from '@wibly/internal-protocol';
|
|
34
|
+
import { z } from 'zod';
|
|
35
|
+
|
|
36
|
+
// -----------------------------------------------------------------------------
|
|
37
|
+
// Actor / input set
|
|
38
|
+
// -----------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export const ActorKindSchema = z.enum(['host', 'player', 'team']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The set of actors whose input the Runtime accepts during this phase,
|
|
44
|
+
* plus the manifest-defined `inputType` tag (e.g. `'guess'`, `'vote'`,
|
|
45
|
+
* `'free_text'`). The Runtime resolves the per-input Zod schema via the
|
|
46
|
+
* manifest's `stateSchema` / handler hook; the protocol carries
|
|
47
|
+
* `inputType` as an opaque string.
|
|
48
|
+
*
|
|
49
|
+
* `actors` is non-empty because a phase that accepts input from no one
|
|
50
|
+
* has no path forward; the validator catches that case at manifest time
|
|
51
|
+
* rather than at runtime.
|
|
52
|
+
*/
|
|
53
|
+
export const InputSetSchema = z.object({
|
|
54
|
+
actors: z.array(ActorKindSchema).min(1),
|
|
55
|
+
inputType: z.string().min(1),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// -----------------------------------------------------------------------------
|
|
59
|
+
// Collection rule
|
|
60
|
+
// -----------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* When the phase ends. Discriminated on `kind` so the Runtime can branch
|
|
64
|
+
* exhaustively. New variants land with the chunk that needs them; per
|
|
65
|
+
* the chunk-B0 trap, keep the protocol shape narrow.
|
|
66
|
+
*
|
|
67
|
+
* `manual` collection rules require a host trigger (e.g. a `host_judge`
|
|
68
|
+
* call resolves the phase). `timeout.ms` is positive — a zero-timeout
|
|
69
|
+
* phase is a Runtime infinite-loop hazard the validator should catch.
|
|
70
|
+
*/
|
|
71
|
+
export const CollectionRuleSchema = z.discriminatedUnion('kind', [
|
|
72
|
+
z.object({ kind: z.literal('all_respond') }),
|
|
73
|
+
z.object({
|
|
74
|
+
kind: z.literal('first_respond'),
|
|
75
|
+
count: z.number().int().positive(),
|
|
76
|
+
}),
|
|
77
|
+
z.object({ kind: z.literal('timeout'), ms: z.number().int().positive() }),
|
|
78
|
+
z.object({ kind: z.literal('manual') }),
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
// -----------------------------------------------------------------------------
|
|
82
|
+
// Transition + phase
|
|
83
|
+
// -----------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One outgoing edge from a phase. The Runtime evaluates transitions in
|
|
87
|
+
* declaration order on collection-rule fire; the first matching
|
|
88
|
+
* transition is taken. Conditions live behind manifest-defined `when`
|
|
89
|
+
* tags; the protocol carries the tag as an opaque string.
|
|
90
|
+
*/
|
|
91
|
+
export const TransitionSchema = z.object({
|
|
92
|
+
to: z.string().min(1),
|
|
93
|
+
when: z.string().min(1).optional(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// -----------------------------------------------------------------------------
|
|
97
|
+
// Phase side-effect declarations (chunk B8c)
|
|
98
|
+
// -----------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Quality-tier enum lifted here (chunk B8c) so the side-effect schema
|
|
102
|
+
* can carry an optional `qualityTier` without requiring a circular
|
|
103
|
+
* import. Mirrors `QualityTierSchema` re-exported from `manifest.ts`.
|
|
104
|
+
*/
|
|
105
|
+
const PhaseSideEffectQualityTierSchema = z.enum([
|
|
106
|
+
'fast',
|
|
107
|
+
'standard',
|
|
108
|
+
'premium',
|
|
109
|
+
'creative',
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Known `callKind` set lifted here for the same reason as
|
|
114
|
+
* {@link PhaseSideEffectQualityTierSchema}. Kept exactly in lock-step
|
|
115
|
+
* with `CallKindSchema` in `manifest.ts`; `manifest.ts` imports this
|
|
116
|
+
* symbol so they cannot drift.
|
|
117
|
+
*/
|
|
118
|
+
export const CallKindSchema = z.enum([
|
|
119
|
+
'host_open_phase',
|
|
120
|
+
'host_judge',
|
|
121
|
+
'host_resolve',
|
|
122
|
+
'host_recap',
|
|
123
|
+
'judge_funniness',
|
|
124
|
+
'narrate_event',
|
|
125
|
+
'classify',
|
|
126
|
+
'compose_clue',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* RFC 6901 JSON-Pointer validator. Accepts the empty pointer `""`
|
|
131
|
+
* (root) and any pointer starting with `/`. We do NOT enforce
|
|
132
|
+
* per-segment escaping at the schema layer — the JSON-Patch library
|
|
133
|
+
* the Runtime uses already rejects malformed segments at apply time;
|
|
134
|
+
* doing it twice would surface the same error in two places.
|
|
135
|
+
*/
|
|
136
|
+
const JsonPointerSchema = z
|
|
137
|
+
.string()
|
|
138
|
+
.refine((s) => s === '' || s.startsWith('/'), {
|
|
139
|
+
message: 'must be an RFC 6901 JSON-Pointer (empty or starting with "/")',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `state_write` side effect — apply a list of JSON-Patch ops to the
|
|
144
|
+
* session state. The Runtime resolves the patches against the
|
|
145
|
+
* registry-broadcast diff path; the manifest does not constrain the
|
|
146
|
+
* shape of `value` because the per-Experience state schema is opaque
|
|
147
|
+
* to the manifest layer.
|
|
148
|
+
*/
|
|
149
|
+
export const StateWriteSideEffectSchema = z.object({
|
|
150
|
+
kind: z.literal('state_write'),
|
|
151
|
+
patches: z
|
|
152
|
+
.array(
|
|
153
|
+
z.object({
|
|
154
|
+
op: z.enum(['add', 'remove', 'replace', 'move', 'copy']),
|
|
155
|
+
path: JsonPointerSchema,
|
|
156
|
+
from: JsonPointerSchema.optional(),
|
|
157
|
+
value: z.unknown().optional(),
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
.min(1),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `inference` side effect — dispatch a Gateway call. `callKind` keys
|
|
165
|
+
* the prompt-composition layer 4 entry; `slots` carries per-call
|
|
166
|
+
* variables for template interpolation. `targetPath` is an
|
|
167
|
+
* RFC 6901 JSON-Pointer the Runtime writes the inference output to
|
|
168
|
+
* once the Gateway returns (default at handler time:
|
|
169
|
+
* `/session/inference/<callKind>`).
|
|
170
|
+
*/
|
|
171
|
+
export const InferenceSideEffectSchema = z.object({
|
|
172
|
+
kind: z.literal('inference'),
|
|
173
|
+
callKind: CallKindSchema,
|
|
174
|
+
qualityTier: PhaseSideEffectQualityTierSchema.optional(),
|
|
175
|
+
slots: z.record(z.unknown()).optional(),
|
|
176
|
+
targetPath: JsonPointerSchema.optional(),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `persona_memory` side effect — read or write into the bound
|
|
181
|
+
* persona's memory. Per `services/persona/src/contract.ts` the
|
|
182
|
+
* effective write mode is decided by the Persona Service against the
|
|
183
|
+
* tenant + player consent posture; the manifest only declares intent.
|
|
184
|
+
*/
|
|
185
|
+
export const PersonaMemorySideEffectSchema = z.object({
|
|
186
|
+
kind: z.literal('persona_memory'),
|
|
187
|
+
personaId: z.string().min(1),
|
|
188
|
+
op: z.enum(['read', 'write']),
|
|
189
|
+
payload: z.record(z.unknown()).optional(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `scoring` side effect — append one row to the scoring ledger
|
|
194
|
+
* (chunk B8c). `dimension` references a `scoring.dimensions[].id`;
|
|
195
|
+
* cross-reference checking runs in `validateManifest`. `value` may be
|
|
196
|
+
* negative when the Runtime is recording a correction; the
|
|
197
|
+
* append-only ledger never updates the original row.
|
|
198
|
+
*/
|
|
199
|
+
export const ScoringSideEffectSchema = z.object({
|
|
200
|
+
kind: z.literal('scoring'),
|
|
201
|
+
dimension: z.string().min(1),
|
|
202
|
+
actorPlayerId: z.string().min(1).optional(),
|
|
203
|
+
value: z.number(),
|
|
204
|
+
source: z.enum(['submit', 'judge', 'compute', 'award']),
|
|
205
|
+
metadata: z.record(z.unknown()).optional(),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Discriminated union of every side effect a phase can declare. Wired
|
|
210
|
+
* to the runtime's `WorkflowSideEffect` set in
|
|
211
|
+
* `services/runtime/src/workflow/side-effects.ts`. The manifest type
|
|
212
|
+
* is the input contract; the runtime type is the dispatch contract;
|
|
213
|
+
* they are kept structurally compatible by the workflow's
|
|
214
|
+
* `resolveSideEffects` handler.
|
|
215
|
+
*/
|
|
216
|
+
export const WorkflowSideEffectSchema = z.discriminatedUnion('kind', [
|
|
217
|
+
StateWriteSideEffectSchema,
|
|
218
|
+
InferenceSideEffectSchema,
|
|
219
|
+
PersonaMemorySideEffectSchema,
|
|
220
|
+
ScoringSideEffectSchema,
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
export type WorkflowSideEffect = z.infer<typeof WorkflowSideEffectSchema>;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One workflow phase. The structural rule "every phase has at least one
|
|
227
|
+
* outgoing transition" is enforced here at the schema level rather than
|
|
228
|
+
* deferred to `validateManifest` so a hand-authored phase blob fails at
|
|
229
|
+
* its own boundary, with a located error, before the cross-phase checks
|
|
230
|
+
* run.
|
|
231
|
+
*
|
|
232
|
+
* `sideEffects` defaults to `[]` so every existing fixture parses
|
|
233
|
+
* unchanged (B8c widening; see chunk-B8b deferral notes). The Runtime
|
|
234
|
+
* resolver reads this array via the injected `PhaseSideEffectResolver`
|
|
235
|
+
* default.
|
|
236
|
+
*
|
|
237
|
+
* Chunk B12 extensions:
|
|
238
|
+
*
|
|
239
|
+
* - `subPhases?: Record<string, Phase>` — named sub-phase
|
|
240
|
+
* declarations the workflow interpreter looks up when an
|
|
241
|
+
* Experience's server-side bundle calls `ctx.runSubPhase(key)`
|
|
242
|
+
* (or the host emits `host.runSubPhase` with `{ subPhaseKey }`).
|
|
243
|
+
* Keys are manifest-local strings; the value is a recursive
|
|
244
|
+
* `Phase` shape (sub-phases can themselves declare sub-phases,
|
|
245
|
+
* though MVP doesn't exercise that depth). The chunk-B8b
|
|
246
|
+
* interpreter's existing `host.runSubPhase` path took an inline
|
|
247
|
+
* Phase object via `payload.data.subPhase`; chunk B12 keeps
|
|
248
|
+
* that overload for back-compat and adds the keyed lookup.
|
|
249
|
+
* - `computeScoreOnEnter?: boolean` — when `true`, the workflow
|
|
250
|
+
* transition fires the sandbox `computeScore` hook after the
|
|
251
|
+
* phase swap and before declarative side effects. Default
|
|
252
|
+
* `false` keeps every existing fixture parsing unchanged.
|
|
253
|
+
* - `endsRound?: boolean` — when `true`, the workflow transition
|
|
254
|
+
* fires the sandbox `onRoundEnd` hook on phase EXIT (before
|
|
255
|
+
* the swap). Used by multi-round Experiences (Rashomon) to
|
|
256
|
+
* trigger end-of-round bookkeeping in the bundle.
|
|
257
|
+
*/
|
|
258
|
+
export const PhaseSchema = z.lazy(() =>
|
|
259
|
+
z.object({
|
|
260
|
+
id: z.string().min(1),
|
|
261
|
+
inputSet: InputSetSchema,
|
|
262
|
+
collectionRule: CollectionRuleSchema,
|
|
263
|
+
transitions: z.array(TransitionSchema).min(1),
|
|
264
|
+
sideEffects: z.array(WorkflowSideEffectSchema).default([]),
|
|
265
|
+
subPhases: z.record(z.string().min(1), PhaseSchema).optional(),
|
|
266
|
+
computeScoreOnEnter: z.boolean().optional(),
|
|
267
|
+
endsRound: z.boolean().optional(),
|
|
268
|
+
}),
|
|
269
|
+
) as z.ZodType<ManifestPhase>;
|
|
270
|
+
|
|
271
|
+
// -----------------------------------------------------------------------------
|
|
272
|
+
// Re-exports
|
|
273
|
+
// -----------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
export type { ActorKind, CollectionRule, InputSet, Phase, Transition };
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Manifest-layer `Phase` shape with `sideEffects` attached (chunk B8c)
|
|
279
|
+
* + the B12 fields (`subPhases`, `computeScoreOnEnter`, `endsRound`).
|
|
280
|
+
* Use this in places that read the extended declaration — the
|
|
281
|
+
* Runtime's workflow side-effect resolver, the sub-phase lookup
|
|
282
|
+
* helper, and the manifest validator. Most consumers can keep
|
|
283
|
+
* importing `Phase` (the on-wire shape) and stay agnostic of the
|
|
284
|
+
* extension.
|
|
285
|
+
*
|
|
286
|
+
* Structurally a superset of the protocol's `Phase`; the
|
|
287
|
+
* `sideEffects` field defaults to `[]` at parse time so existing
|
|
288
|
+
* fixtures parse unchanged, and the B12 extensions are optional.
|
|
289
|
+
*
|
|
290
|
+
* Declared as a TS type (not `z.infer<typeof PhaseSchema>`) because
|
|
291
|
+
* the schema itself is recursive (`subPhases` references
|
|
292
|
+
* `PhaseSchema`) — Zod can't infer the recursive type, so we
|
|
293
|
+
* declare the shape here and reference it from the lazy schema.
|
|
294
|
+
*/
|
|
295
|
+
export type ManifestPhase = {
|
|
296
|
+
readonly id: string;
|
|
297
|
+
readonly inputSet: InputSet;
|
|
298
|
+
readonly collectionRule: CollectionRule;
|
|
299
|
+
readonly transitions: readonly Transition[];
|
|
300
|
+
readonly sideEffects: readonly WorkflowSideEffect[];
|
|
301
|
+
readonly subPhases?: Readonly<Record<string, ManifestPhase>>;
|
|
302
|
+
readonly computeScoreOnEnter?: boolean;
|
|
303
|
+
readonly endsRound?: boolean;
|
|
304
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { PortalMetadataSchema } from './manifest.js';
|
|
4
|
+
import {
|
|
5
|
+
portalContentCategoryLabel,
|
|
6
|
+
portalDurationLabel,
|
|
7
|
+
portalPlayersLabel,
|
|
8
|
+
} from './portal-display.js';
|
|
9
|
+
|
|
10
|
+
describe('portal display helpers', () => {
|
|
11
|
+
it('formats player counts as a range', () => {
|
|
12
|
+
expect(portalPlayersLabel({ minPlayers: 3, maxPlayers: 8 })).toBe(
|
|
13
|
+
'3 - 8 players',
|
|
14
|
+
);
|
|
15
|
+
expect(portalPlayersLabel({ minPlayers: 4, maxPlayers: 4 })).toBe(
|
|
16
|
+
'4 players',
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('formats duration labels', () => {
|
|
21
|
+
expect(portalDurationLabel(45)).toBe('~45 min');
|
|
22
|
+
expect(portalDurationLabel(60)).toBe('~1 hr');
|
|
23
|
+
expect(portalDurationLabel(90)).toBe('~1 hr 30 min');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('hides the all-ages content category pill', () => {
|
|
27
|
+
expect(portalContentCategoryLabel('none')).toBeNull();
|
|
28
|
+
expect(portalContentCategoryLabel('pg13')).toBe('PG-13');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('PortalMetadataSchema player defaults', () => {
|
|
33
|
+
it('applies defaults for legacy manifests', () => {
|
|
34
|
+
const parsed = PortalMetadataSchema.parse({
|
|
35
|
+
heroImageUrl: 'https://assets.wibly.example/hero.png',
|
|
36
|
+
sampleRoundDescription: 'Sample round copy.',
|
|
37
|
+
occasionTags: ['party'],
|
|
38
|
+
});
|
|
39
|
+
expect(parsed.minPlayers).toBe(3);
|
|
40
|
+
expect(parsed.maxPlayers).toBe(8);
|
|
41
|
+
expect(parsed.estimatedDurationMinutes).toBe(30);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects maxPlayers below minPlayers', () => {
|
|
45
|
+
const result = PortalMetadataSchema.safeParse({
|
|
46
|
+
heroImageUrl: 'https://assets.wibly.example/hero.png',
|
|
47
|
+
sampleRoundDescription: 'Sample round copy.',
|
|
48
|
+
occasionTags: ['party'],
|
|
49
|
+
minPlayers: 6,
|
|
50
|
+
maxPlayers: 4,
|
|
51
|
+
});
|
|
52
|
+
expect(result.success).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { portalContentRatingLabel } from './content-rating.js';
|
|
2
|
+
import type {
|
|
3
|
+
ExperienceContentRatingTier,
|
|
4
|
+
PortalMetadata,
|
|
5
|
+
} from './manifest.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_MIN_PLAYERS = 3;
|
|
8
|
+
export const DEFAULT_MAX_PLAYERS = 8;
|
|
9
|
+
export const DEFAULT_ESTIMATED_DURATION_MINUTES = 30;
|
|
10
|
+
|
|
11
|
+
export const portalPlayersLabel = (
|
|
12
|
+
metadata: Pick<PortalMetadata, 'minPlayers' | 'maxPlayers'>,
|
|
13
|
+
): string => {
|
|
14
|
+
if (metadata.minPlayers === metadata.maxPlayers) {
|
|
15
|
+
return `${metadata.minPlayers} player${metadata.minPlayers === 1 ? '' : 's'}`;
|
|
16
|
+
}
|
|
17
|
+
return `${metadata.minPlayers} - ${metadata.maxPlayers} players`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const portalDurationLabel = (minutes: number): string => {
|
|
21
|
+
if (minutes < 60) {
|
|
22
|
+
return `~${minutes} min`;
|
|
23
|
+
}
|
|
24
|
+
const hours = Math.floor(minutes / 60);
|
|
25
|
+
const remainder = minutes % 60;
|
|
26
|
+
if (remainder === 0) {
|
|
27
|
+
return `~${hours} hr`;
|
|
28
|
+
}
|
|
29
|
+
return `~${hours} hr ${remainder} min`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Returns a catalogue category pill label, or null when none should show. */
|
|
33
|
+
export const portalContentCategoryLabel = (
|
|
34
|
+
tier: ExperienceContentRatingTier,
|
|
35
|
+
): string | null => {
|
|
36
|
+
if (tier === 'none') {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return portalContentRatingLabel(tier);
|
|
40
|
+
};
|