@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.
@@ -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
+ };