@orakl/protocol 0.1.0

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.
Files changed (50) hide show
  1. package/LICENSE +662 -0
  2. package/dist/badges.d.ts +19 -0
  3. package/dist/badges.js +1 -0
  4. package/dist/emotes.d.ts +5 -0
  5. package/dist/emotes.js +62 -0
  6. package/dist/error-codes.d.ts +1 -0
  7. package/dist/error-codes.js +1 -0
  8. package/dist/game-answer-record.d.ts +31 -0
  9. package/dist/game-answer-record.js +1 -0
  10. package/dist/game.d.ts +83 -0
  11. package/dist/game.js +1 -0
  12. package/dist/game.settings.d.ts +91 -0
  13. package/dist/game.settings.js +61 -0
  14. package/dist/index.d.ts +23 -0
  15. package/dist/index.js +13 -0
  16. package/dist/pages/app.schema.d.ts +544 -0
  17. package/dist/pages/app.schema.js +336 -0
  18. package/dist/pages/auth.schema.d.ts +25 -0
  19. package/dist/pages/auth.schema.js +23 -0
  20. package/dist/pages/common.schema.d.ts +27 -0
  21. package/dist/pages/common.schema.js +28 -0
  22. package/dist/pages/history.schema.d.ts +273 -0
  23. package/dist/pages/history.schema.js +210 -0
  24. package/dist/pages/quiz.schema.d.ts +150 -0
  25. package/dist/pages/quiz.schema.js +132 -0
  26. package/dist/player-safe-question.d.ts +13 -0
  27. package/dist/player-safe-question.js +1 -0
  28. package/dist/question.d.ts +6 -0
  29. package/dist/question.js +12 -0
  30. package/dist/question.schema.d.ts +494 -0
  31. package/dist/question.schema.js +132 -0
  32. package/dist/scoring.schema.d.ts +7 -0
  33. package/dist/scoring.schema.js +8 -0
  34. package/dist/solo-ws.d.ts +202 -0
  35. package/dist/solo-ws.js +4 -0
  36. package/dist/streak-flourish.d.ts +6 -0
  37. package/dist/streak-flourish.js +1 -0
  38. package/dist/wire/leaderboard.d.ts +6 -0
  39. package/dist/wire/leaderboard.js +1 -0
  40. package/dist/wire/question-bank.d.ts +23 -0
  41. package/dist/wire/question-bank.js +1 -0
  42. package/dist/wire/question-flags.d.ts +40 -0
  43. package/dist/wire/question-flags.js +1 -0
  44. package/dist/ws-game.schema.d.ts +75 -0
  45. package/dist/ws-game.schema.js +131 -0
  46. package/dist/ws-solo.schema.d.ts +31 -0
  47. package/dist/ws-solo.schema.js +57 -0
  48. package/dist/ws.d.ts +281 -0
  49. package/dist/ws.js +1 -0
  50. package/package.json +39 -0
@@ -0,0 +1,132 @@
1
+ import { Schema } from "effect";
2
+ import { GAME } from "./game.settings.js";
3
+ export const DifficultySchema = Schema.Literal(...GAME.roundConfig.difficulties);
4
+ export const MediaTypeSchema = Schema.Literal("image", "audio", "video");
5
+ export const QuestionTypeSchema = Schema.Literal("text_choice", "image_choice", "true_false", "finish_line", "audio_identify", "sequence_order", "estimate_range", "fill_blank", "matching_pairs", "progressive_reveal", "image_matching");
6
+ export const SourceSchema = Schema.Literal(...GAME.question.sources);
7
+ export const MediaSchema = Schema.Struct({
8
+ type: MediaTypeSchema,
9
+ url: Schema.String,
10
+ thumbnail: Schema.optional(Schema.String),
11
+ });
12
+ /** True for an https URL WHATWG-parses; false for anything else (including unparsable strings). */
13
+ export function isHttpsUrl(url) {
14
+ try {
15
+ return new URL(url).protocol === "https:";
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ // Security: source.url is stored in the DB and read back on results/history
22
+ // pages; cap to match imageUrl's bound (curator.schema.ts) since both are
23
+ // URLs of the same practical size.
24
+ const HttpsUrlSchema = Schema.String.pipe(Schema.maxLength(2048, {
25
+ message: () => "Source URL must be 2048 characters or fewer",
26
+ }), Schema.filter(isHttpsUrl, { message: () => "Source URL must be https" }));
27
+ export const QuestionSourceSchema = Schema.Struct({
28
+ url: HttpsUrlSchema,
29
+ label: Schema.optional(Schema.String.pipe(Schema.maxLength(200, {
30
+ message: () => "Source label must be 200 characters or fewer",
31
+ }))),
32
+ });
33
+ export const PostAnswerNoteSchema = Schema.String.pipe(Schema.maxLength(160, {
34
+ message: () => "Post-answer note must be 160 characters or fewer",
35
+ }));
36
+ const MetadataSchema = Schema.Struct({
37
+ source: SourceSchema,
38
+ tags: Schema.optional(Schema.Array(Schema.String)),
39
+ region: Schema.optional(Schema.Array(Schema.String)),
40
+ isNiche: Schema.optional(Schema.Boolean),
41
+ timeLimit: Schema.optional(Schema.Number),
42
+ points: Schema.optional(Schema.Number),
43
+ });
44
+ const QuestionBaseSchema = Schema.Struct({
45
+ id: Schema.String,
46
+ category: Schema.String,
47
+ difficulty: DifficultySchema,
48
+ question: Schema.Struct({
49
+ text: Schema.String,
50
+ media: Schema.optional(MediaSchema),
51
+ }),
52
+ correctAnswer: Schema.String,
53
+ incorrectAnswers: Schema.Array(Schema.String),
54
+ question_fingerprint: Schema.optional(Schema.String),
55
+ metadata: MetadataSchema,
56
+ createdAt: Schema.String,
57
+ // Evidence for the fact asked about. Hidden through play (map #912,
58
+ // decision 2) — enters the client only via results/history page loads.
59
+ source: Schema.optional(QuestionSourceSchema),
60
+ // The curator's explanation of the answer, delivered to players only at
61
+ // reveal (map #912, decision 2) — see game-store.ts's round-result payload.
62
+ postAnswerNote: Schema.optional(PostAnswerNoteSchema),
63
+ });
64
+ const TextChoiceSchema = Schema.Struct({
65
+ ...QuestionBaseSchema.fields,
66
+ type: Schema.Literal("text_choice"),
67
+ });
68
+ const ImageChoiceSchema = Schema.Struct({
69
+ ...QuestionBaseSchema.fields,
70
+ type: Schema.Literal("image_choice"),
71
+ question: Schema.Struct({ text: Schema.String, media: MediaSchema }),
72
+ });
73
+ const TrueFalseSchema = Schema.Struct({
74
+ ...QuestionBaseSchema.fields,
75
+ type: Schema.Literal("true_false"),
76
+ correctAnswer: Schema.Literal("true", "false"),
77
+ incorrectAnswers: Schema.Tuple(Schema.Literal("true", "false")),
78
+ });
79
+ const FinishLineSchema = Schema.Struct({
80
+ ...QuestionBaseSchema.fields,
81
+ type: Schema.Literal("finish_line"),
82
+ question: Schema.Struct({ text: Schema.String, media: MediaSchema }),
83
+ });
84
+ const AudioIdentifySchema = Schema.Struct({
85
+ ...QuestionBaseSchema.fields,
86
+ type: Schema.Literal("audio_identify"),
87
+ question: Schema.Struct({ text: Schema.String, media: MediaSchema }),
88
+ });
89
+ const SequenceOrderSchema = Schema.Struct({
90
+ ...QuestionBaseSchema.fields,
91
+ type: Schema.Literal("sequence_order"),
92
+ });
93
+ const EstimateRangeSchema = Schema.Struct({
94
+ ...QuestionBaseSchema.fields,
95
+ type: Schema.Literal("estimate_range"),
96
+ metadata: Schema.Struct({
97
+ ...MetadataSchema.fields,
98
+ acceptableRange: Schema.Number,
99
+ }),
100
+ });
101
+ const FillBlankSchema = Schema.Struct({
102
+ ...QuestionBaseSchema.fields,
103
+ type: Schema.Literal("fill_blank"),
104
+ });
105
+ const MatchingPairsSchema = Schema.Struct({
106
+ ...QuestionBaseSchema.fields,
107
+ type: Schema.Literal("matching_pairs"),
108
+ });
109
+ const ProgressiveRevealSchema = Schema.Struct({
110
+ ...QuestionBaseSchema.fields,
111
+ type: Schema.Literal("progressive_reveal"),
112
+ question: Schema.Struct({ text: Schema.String, media: MediaSchema }),
113
+ metadata: Schema.Struct({
114
+ ...MetadataSchema.fields,
115
+ revealSteps: Schema.Number,
116
+ }),
117
+ });
118
+ const ImageMatchingSchema = Schema.Struct({
119
+ ...QuestionBaseSchema.fields,
120
+ type: Schema.Literal("image_matching"),
121
+ question: Schema.Struct({ text: Schema.String, media: MediaSchema }),
122
+ correctAnswer: Schema.String,
123
+ incorrectAnswers: Schema.Array(Schema.String),
124
+ matchItems: Schema.Struct({
125
+ left: Schema.Tuple(Schema.String, Schema.String, Schema.String, Schema.String),
126
+ right: Schema.Tuple(Schema.String, Schema.String, Schema.String, Schema.String),
127
+ }),
128
+ });
129
+ // Discriminant: Effect Schema detects the shared `type: Schema.Literal(...)` field and
130
+ // short-circuits matching without sequential probing — but this is implicit, not an API
131
+ // contract. All members must keep a unique `type` literal or the optimisation silently degrades.
132
+ export const QuestionSchema = Schema.Union(TextChoiceSchema, ImageChoiceSchema, TrueFalseSchema, FinishLineSchema, AudioIdentifySchema, SequenceOrderSchema, EstimateRangeSchema, FillBlankSchema, MatchingPairsSchema, ProgressiveRevealSchema, ImageMatchingSchema);
@@ -0,0 +1,7 @@
1
+ import { Schema } from "effect";
2
+ /** Shared scoring-mode literal — single source for every schema that accepts it
3
+ * (game start, lobby create, preset save). Mirrors GAME.roundConfig.scoringOptions. */
4
+ export declare const ScoringModeSchema: Schema.Literal<["classic", "time-based"]>;
5
+ /** Optional variant for request bodies where scoringMode may be omitted
6
+ * (defaults to GAME.roundConfig.defaultScoring at the call site). */
7
+ export declare const OptionalScoringModeSchema: Schema.optional<Schema.Literal<["classic", "time-based"]>>;
@@ -0,0 +1,8 @@
1
+ import { Schema } from "effect";
2
+ import { GAME } from "./game.settings.js";
3
+ /** Shared scoring-mode literal — single source for every schema that accepts it
4
+ * (game start, lobby create, preset save). Mirrors GAME.roundConfig.scoringOptions. */
5
+ export const ScoringModeSchema = Schema.Literal(...GAME.roundConfig.scoringOptions);
6
+ /** Optional variant for request bodies where scoringMode may be omitted
7
+ * (defaults to GAME.roundConfig.defaultScoring at the call site). */
8
+ export const OptionalScoringModeSchema = Schema.optional(ScoringModeSchema);
@@ -0,0 +1,202 @@
1
+ import type { BadgeAward } from "./badges.ts";
2
+ import type { PlayerSafeQuestion } from "./player-safe-question.ts";
3
+ import type { MediaType, QuestionSource } from "./question.ts";
4
+ export type SoloAuthMessage = {
5
+ type: "auth";
6
+ token: string;
7
+ };
8
+ export type SoloStartMessage = {
9
+ type: "solo:start";
10
+ categoryIds: string[];
11
+ timerDuration: number;
12
+ questionCount: number;
13
+ mode: "normal" | "endless";
14
+ difficulty: "all" | "easy" | "medium" | "hard";
15
+ /** Client device class (mobile/tablet/desktop) for cross-device stats. */
16
+ device?: "mobile" | "tablet" | "desktop";
17
+ };
18
+ export type SoloAnswerMessage = {
19
+ type: "solo:answer";
20
+ answerId: string;
21
+ /**
22
+ * The nonce from the current solo:question. The server rejects answers whose
23
+ * nonce doesn't match the in-flight question, defeating replayed answer frames
24
+ * (anti-cheat). Optional for backward compatibility with older clients.
25
+ */
26
+ nonce?: string;
27
+ };
28
+ /** The player switched their selection before committing (hesitation signal). */
29
+ export type SoloAnswerChangeMessage = {
30
+ type: "solo:answer-change";
31
+ };
32
+ export type SoloNextMessage = {
33
+ type: "solo:next";
34
+ };
35
+ export type SoloTabBlurMessage = {
36
+ type: "solo:tab-blur";
37
+ };
38
+ export type SoloTabFocusMessage = {
39
+ type: "solo:tab-focus";
40
+ };
41
+ export type SoloPongMessage = {
42
+ type: "solo:pong";
43
+ };
44
+ export type SoloResumeMessage = {
45
+ type: "solo:resume";
46
+ sessionId: string;
47
+ /** Secret issued in solo:ready; required to resume a guest session (anti-hijack). */
48
+ resumeToken?: string;
49
+ };
50
+ export type SoloClientMessage = SoloAuthMessage | SoloStartMessage | SoloAnswerMessage | SoloAnswerChangeMessage | SoloNextMessage | SoloTabBlurMessage | SoloTabFocusMessage | SoloPongMessage | SoloResumeMessage;
51
+ export type SoloReadyMessage = {
52
+ type: "solo:ready";
53
+ isGuest: boolean;
54
+ sessionId: string;
55
+ /**
56
+ * Secret the client must echo on solo:resume to reclaim this session. Binds
57
+ * resume to the original connection so a leaked sessionId alone can't hijack a
58
+ * run (anti-cheat, CHEAT-1).
59
+ */
60
+ resumeToken?: string;
61
+ };
62
+ export type SoloQuestionMessage = {
63
+ type: "solo:question";
64
+ question: PlayerSafeQuestion;
65
+ questionIndex: number;
66
+ totalQuestions: number | null;
67
+ timeRemaining: number;
68
+ serverTs: number;
69
+ /** Per-question nonce the client must echo on solo:answer (anti-replay). */
70
+ nonce?: string;
71
+ };
72
+ export type SoloTickMessage = {
73
+ type: "solo:tick";
74
+ timeRemaining: number;
75
+ serverTs: number;
76
+ };
77
+ export type SoloRoundResultMessage = {
78
+ type: "solo:round-result";
79
+ correctAnswerId: string;
80
+ /** The answer the player gave (null on timeout) — lets reconnect restore the reveal. */
81
+ selectedAnswerId: string | null;
82
+ isCorrect: boolean;
83
+ score: number;
84
+ totalScore: number;
85
+ timeToAnswerMs: number;
86
+ streak: number;
87
+ /** Strikes used in endless mode (0 in normal mode). */
88
+ strikes: number;
89
+ /**
90
+ * For a correct answer with enough prior data: the % of players this answer
91
+ * beat on time for this question. Null otherwise (wrong/timeout, or too few
92
+ * samples). Drives the "faster than most" cue (US #11).
93
+ */
94
+ fasterThanPercent: number | null;
95
+ /** The curator's explanation of the answer, withheld until now (map #912,
96
+ * decision 2). Omitted when the question has none. */
97
+ postAnswerNote?: string;
98
+ };
99
+ export type SoloStreakMessage = {
100
+ type: "solo:streak";
101
+ milestone: number;
102
+ streak: number;
103
+ };
104
+ export type SoloStreakLostMessage = {
105
+ type: "solo:streak-lost";
106
+ /** Size of the streak that just broke (see GAME.streak.losses). */
107
+ lostStreak: number;
108
+ streak: number;
109
+ };
110
+ export type SoloStreakNearMissMessage = {
111
+ type: "solo:streak-near-miss";
112
+ /** Size of the streak that broke one short of its next milestone. */
113
+ lostStreak: number;
114
+ streak: number;
115
+ };
116
+ export type SoloStreakBestMessage = {
117
+ type: "solo:streak-best";
118
+ /** New run-best streak — crossed the prior peak after a comeback. */
119
+ streak: number;
120
+ };
121
+ export type SoloLifeSpentMessage = {
122
+ type: "solo:life-spent";
123
+ /** Endless-mode lives left after this strike; the run continues. */
124
+ livesRemaining: number;
125
+ streak: number;
126
+ };
127
+ export type SoloFinalMessage = {
128
+ type: "solo:final";
129
+ totalScore: number;
130
+ totalAnswered: number;
131
+ correctCount: number;
132
+ timeToAnswerAvgMs: number;
133
+ /** Peak consecutive-correct streak this session. */
134
+ maxStreak: number;
135
+ /** Eligible for score leaderboards (non-guest, fixed-count mode). */
136
+ isLeaderboardEligible: boolean;
137
+ /** Eligible for the streak leaderboard (any non-guest session). */
138
+ isStreakEligible: boolean;
139
+ /**
140
+ * Total tab-away events across the whole session (US #23). 0 means the player
141
+ * never left the trial — drives the "you stayed focused" cue.
142
+ */
143
+ tabAwayCount: number;
144
+ /**
145
+ * The per-question timer (ms) this session ran with. Lets the results screen
146
+ * read each answer's `timeToAnswerMs` as a fraction of the clock for the
147
+ * implicit-confidence / decisiveness cue (US #24).
148
+ */
149
+ timerDurationMs: number;
150
+ /**
151
+ * For guest sessions, the persisted session id the player can claim by
152
+ * signing up/in (links the run to the new account). Null when already signed
153
+ * in (nothing to claim).
154
+ */
155
+ claimId: string | null;
156
+ mode: "normal" | "endless";
157
+ /** Difficulty the run was played at — feeds per-badge rarity on results. */
158
+ difficulty: "all" | "easy" | "medium" | "hard";
159
+ /**
160
+ * Achievement badges earned this run (US #14, #23, #24) — server-computed so
161
+ * they're authoritative and match what's tallied on the stats page. Optional
162
+ * for backward compatibility with pre-badge persisted/final payloads.
163
+ */
164
+ badges?: BadgeAward[];
165
+ results: Array<{
166
+ questionId: string;
167
+ questionText: string;
168
+ categoryId: string;
169
+ /** Difficulty band of the question (for per-difficulty results breakdowns). */
170
+ difficulty?: "easy" | "medium" | "hard";
171
+ isCorrect: boolean;
172
+ selectedAnswerId: string | null;
173
+ correctAnswerId: string;
174
+ timeToAnswerMs: number;
175
+ /** Results-only extras (never sent earlier in the run, map #912): the
176
+ * question's image, source and note. Absent when the question has none. */
177
+ mediaUrl?: string;
178
+ mediaType?: MediaType;
179
+ source?: QuestionSource;
180
+ postAnswerNote?: string;
181
+ /** From the same batched precheck as `solo:question`'s `alreadyFlagged`
182
+ * (issue #922) — hides the results screen's flag control for a question
183
+ * already flagged. Always false for guests. */
184
+ alreadyFlagged: boolean;
185
+ }>;
186
+ };
187
+ export type SoloPingMessage = {
188
+ type: "solo:ping";
189
+ };
190
+ export type SoloErrorMessage = {
191
+ type: "solo:error";
192
+ code: "auth_failed" | "entitlement_exceeded" | "invalid_message" | "session_expired" | "internal";
193
+ message: string;
194
+ };
195
+ /** Build a `solo:error` frame — the one shape every failure site sends. */
196
+ export declare function soloError(code: SoloErrorMessage["code"], message: string): SoloErrorMessage;
197
+ export type SoloGuestLimitMessage = {
198
+ type: "solo:guest-limit";
199
+ reason: string;
200
+ upgradeHint: string;
201
+ };
202
+ export type SoloServerMessage = SoloReadyMessage | SoloQuestionMessage | SoloTickMessage | SoloRoundResultMessage | SoloStreakMessage | SoloStreakLostMessage | SoloStreakNearMissMessage | SoloStreakBestMessage | SoloLifeSpentMessage | SoloFinalMessage | SoloPingMessage | SoloErrorMessage | SoloGuestLimitMessage;
@@ -0,0 +1,4 @@
1
+ /** Build a `solo:error` frame — the one shape every failure site sends. */
2
+ export function soloError(code, message) {
3
+ return { type: "solo:error", code, message };
4
+ }
@@ -0,0 +1,6 @@
1
+ /** A streak milestone/loss flourish carried on a wire frame — the app derives
2
+ * it from the answer log (see `src/lib/streak-flourish.ts`'s `streakFromLog`). */
3
+ export type StreakFlourish = {
4
+ kind: "gain" | "loss" | "near-miss" | "best";
5
+ value: number;
6
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ /** Which ranking a leaderboard sorts by. */
2
+ export type LeaderboardBoard = "score" | "streak" | "accuracy" | "speed" | "survival";
3
+ /** Rolling window a leaderboard is scoped to. */
4
+ export type LeaderboardScope = "all-time" | "daily" | "weekly" | "monthly";
5
+ /** Difficulty filter a leaderboard is narrowed to. */
6
+ export type LeaderboardDifficulty = "easy" | "medium" | "hard";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import type { Question } from "../question.schema.ts";
2
+ /** One prior version of an edited question, decoded. */
3
+ export type QuestionRevision = {
4
+ id: string;
5
+ createdAt: string;
6
+ actorId: string | null;
7
+ question: Question;
8
+ };
9
+ /** A revision plus the actor's identity, for the operator dashboard. */
10
+ export type OperatorQuestionRevision = QuestionRevision & {
11
+ actor: {
12
+ nickname: string | null;
13
+ email: string | null;
14
+ } | null;
15
+ };
16
+ /** One outcome of a bulk publish — an id that promoted, was already shared,
17
+ * is flagged, or wasn't found. */
18
+ export type PublishManyResult = {
19
+ id: string;
20
+ outcome: "promoted" | "already" | "flagged" | "not_found";
21
+ published_at?: string;
22
+ published_by?: string;
23
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import type { QuestionSource } from "../question.schema.ts";
2
+ /** Whether a flagged question still has an unreviewed report. */
3
+ export type FlagStatus = "open" | "resolved";
4
+ /** One answer as it stood when a flag was filed. */
5
+ export type QuestionFlagAnswerSnapshot = {
6
+ id: string;
7
+ text: string;
8
+ isCorrect: boolean;
9
+ };
10
+ /** Frozen-at-write-time question shape a flag was filed against — later
11
+ * edits/deletes to the source question never change what this reads. */
12
+ export type QuestionSnapshot = {
13
+ text: string;
14
+ type: string;
15
+ categoryId: string;
16
+ difficulty: string;
17
+ mediaUrl?: string;
18
+ mediaType?: string;
19
+ source?: QuestionSource;
20
+ postAnswerNote?: string;
21
+ answers: QuestionFlagAnswerSnapshot[];
22
+ };
23
+ /** One flag reporter's identity and submission. */
24
+ export type FlagReporter = {
25
+ userId: string;
26
+ nickname: string | null;
27
+ email: string;
28
+ selectedAnswerId: string | null;
29
+ reason: string | null;
30
+ url: string | null;
31
+ createdAt: string;
32
+ };
33
+ /** Full detail for one flagged question: its frozen snapshot plus every
34
+ * reporter. */
35
+ export type FlagDetail = {
36
+ questionId: string;
37
+ status: FlagStatus;
38
+ snapshot: QuestionSnapshot;
39
+ reporters: FlagReporter[];
40
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,75 @@
1
+ import { Schema } from "effect";
2
+ export declare const ClientMessageSchema: Schema.Union<[Schema.Struct<{
3
+ type: Schema.Literal<["auth"]>;
4
+ token: Schema.filter<typeof Schema.NonEmptyString>;
5
+ }>, Schema.Struct<{
6
+ type: Schema.Literal<["player:answer"]>;
7
+ answerId: Schema.filter<typeof Schema.NonEmptyString>;
8
+ playerId: Schema.optional<Schema.filter<typeof Schema.NonEmptyString>>;
9
+ }>, Schema.Struct<{
10
+ type: Schema.Literal<["player:emote"]>;
11
+ emoteId: Schema.Literal<["alert", "anger", "bars", "cash", "circle", "cloud", "cross", "dots1", "dots2", "dots3", "drop", "drops", "exclamation", "exclamations", "faceAngry", "faceHappy", "faceSad", "heart", "heartBroken", "hearts", "idea", "laugh", "music", "question", "sleep", "sleeps", "star", "stars", "swirl"]>;
12
+ offsetX: Schema.filter<Schema.filter<typeof Schema.Number>>;
13
+ offsetY: Schema.filter<Schema.filter<typeof Schema.Number>>;
14
+ }>, Schema.Struct<{
15
+ type: Schema.Literal<["player:role"]>;
16
+ role: Schema.Literal<["player", "observer"]>;
17
+ }>, Schema.Struct<{
18
+ type: Schema.Literal<["player:leave"]>;
19
+ }>, Schema.Struct<{
20
+ type: Schema.Literal<["curator:start"]>;
21
+ categoryIds: Schema.optional<Schema.filter<Schema.Array$<typeof Schema.NonEmptyString>>>;
22
+ timerDuration: Schema.optional<Schema.Literal<[15, 30, 45, 60]>>;
23
+ questionsPerRound: Schema.optional<Schema.filter<Schema.filter<Schema.filter<typeof Schema.Number>>>>;
24
+ difficulty: Schema.optional<Schema.Literal<["all", "easy", "medium", "hard"]>>;
25
+ advanceMode: Schema.optional<Schema.Literal<["auto_5", "auto_10", "manual"]>>;
26
+ scoringMode: Schema.optional<Schema.Literal<["classic", "time-based"]>>;
27
+ preloadedQuestions: Schema.optional<Schema.filter<Schema.Array$<Schema.Union<[Schema.Struct<{
28
+ type: Schema.Literal<["text_choice", "image_choice", "true_false", "finish_line", "audio_identify", "sequence_order", "estimate_range", "fill_blank", "matching_pairs", "progressive_reveal"]>;
29
+ id: Schema.filter<typeof Schema.NonEmptyString>;
30
+ text: Schema.filter<typeof Schema.String>;
31
+ categoryId: Schema.filter<typeof Schema.NonEmptyString>;
32
+ difficulty: Schema.Literal<["easy", "medium", "hard"]>;
33
+ answers: Schema.filter<Schema.filter<Schema.Array$<Schema.Struct<{
34
+ id: Schema.filter<typeof Schema.NonEmptyString>;
35
+ text: Schema.filter<typeof Schema.String>;
36
+ isCorrect: typeof Schema.Boolean;
37
+ }>>>>;
38
+ mediaUrl: Schema.optional<Schema.filter<typeof Schema.String>>;
39
+ mediaType: Schema.optional<Schema.Literal<["image", "audio", "video"]>>;
40
+ }>, Schema.Struct<{
41
+ type: Schema.Literal<["image_matching"]>;
42
+ matchItems: Schema.Struct<{
43
+ left: Schema.filter<Schema.filter<Schema.Array$<Schema.filter<typeof Schema.String>>>>;
44
+ right: Schema.filter<Schema.filter<Schema.Array$<Schema.filter<typeof Schema.String>>>>;
45
+ }>;
46
+ id: Schema.filter<typeof Schema.NonEmptyString>;
47
+ text: Schema.filter<typeof Schema.String>;
48
+ categoryId: Schema.filter<typeof Schema.NonEmptyString>;
49
+ difficulty: Schema.Literal<["easy", "medium", "hard"]>;
50
+ answers: Schema.filter<Schema.filter<Schema.Array$<Schema.Struct<{
51
+ id: Schema.filter<typeof Schema.NonEmptyString>;
52
+ text: Schema.filter<typeof Schema.String>;
53
+ isCorrect: typeof Schema.Boolean;
54
+ }>>>>;
55
+ mediaUrl: Schema.optional<Schema.filter<typeof Schema.String>>;
56
+ mediaType: Schema.optional<Schema.Literal<["image", "audio", "video"]>>;
57
+ }>]>>>>;
58
+ }>, Schema.Struct<{
59
+ type: Schema.Literal<["curator:advance"]>;
60
+ }>, Schema.Struct<{
61
+ type: Schema.Literal<["curator:start-role-selection"]>;
62
+ }>, Schema.Struct<{
63
+ type: Schema.Literal<["curator:intermission"]>;
64
+ action: Schema.Literal<["start", "end"]>;
65
+ }>, Schema.Struct<{
66
+ type: Schema.Literal<["curator:player"]>;
67
+ playerId: Schema.filter<typeof Schema.NonEmptyString>;
68
+ action: Schema.Literal<["approve", "reject", "remove", "block"]>;
69
+ }>, Schema.Struct<{
70
+ type: Schema.Literal<["curator:stop"]>;
71
+ }>, Schema.Struct<{
72
+ type: Schema.Literal<["curator:reset"]>;
73
+ }>, Schema.Struct<{
74
+ type: Schema.Literal<["pong"]>;
75
+ }>]>;
@@ -0,0 +1,131 @@
1
+ import { Schema } from "effect";
2
+ import { SELECTABLE_EMOTE_IDS } from "./emotes.js";
3
+ import { GAME } from "./game.settings.js";
4
+ import { DifficultySchema, MediaTypeSchema } from "./question.schema.js";
5
+ import { OptionalScoringModeSchema } from "./scoring.schema.js";
6
+ /**
7
+ * Frame-level validation for the multiplayer game WebSocket (issue #734 hardening).
8
+ * One schema per `ClientMessage` union member in `./ws.ts` — mirrors the
9
+ * hand-written types (no `Schema.Type` derivation feeds back into `ClientMessage`,
10
+ * see `game-connection-handler.ts`). Keep this file in sync with `ws.ts` whenever
11
+ * a frame is added/changed.
12
+ */
13
+ // Tokens ride the WS handshake/first-frame; generous cap is a DoS guard only.
14
+ const TokenSchema = Schema.NonEmptyString.pipe(Schema.maxLength(4096));
15
+ // Security: mirrors the retired REST AnswerSchema — answerId is broadcast in
16
+ // game:round-result to every player; cap to prevent stored DoS.
17
+ const AnswerIdSchema = Schema.NonEmptyString.pipe(Schema.maxLength(500));
18
+ // DoS guard: ids that round-trip through store state / broadcasts.
19
+ const BoundedIdSchema = Schema.NonEmptyString.pipe(Schema.maxLength(200));
20
+ // ─── auth ───────────────────────────────────────────────────────────────────
21
+ const WsAuthMessageSchema = Schema.Struct({
22
+ type: Schema.Literal("auth"),
23
+ token: TokenSchema,
24
+ });
25
+ // ─── player:* ───────────────────────────────────────────────────────────────
26
+ const PlayerAnswerMessageSchema = Schema.Struct({
27
+ type: Schema.Literal("player:answer"),
28
+ answerId: AnswerIdSchema,
29
+ // @deprecated identity is bound at the socket upgrade; ignored if sent.
30
+ playerId: Schema.optional(BoundedIdSchema),
31
+ });
32
+ const PlayerEmoteMessageSchema = Schema.Struct({
33
+ type: Schema.Literal("player:emote"),
34
+ emoteId: Schema.Literal(...SELECTABLE_EMOTE_IDS),
35
+ // Broadcast to every player and used directly as CSS left%/top% (0-1) —
36
+ // bounded, not just finite, or an attacker-controlled value corrupts the
37
+ // shared emote overlay for the whole lobby.
38
+ offsetX: Schema.Number.pipe(Schema.finite(), Schema.between(0, 1)),
39
+ offsetY: Schema.Number.pipe(Schema.finite(), Schema.between(0, 1)),
40
+ });
41
+ const PlayerRoleMessageSchema = Schema.Struct({
42
+ type: Schema.Literal("player:role"),
43
+ role: Schema.Literal("player", "observer"),
44
+ });
45
+ const PlayerLeaveMessageSchema = Schema.Struct({
46
+ type: Schema.Literal("player:leave"),
47
+ });
48
+ // ─── curator:* ──────────────────────────────────────────────────────────────
49
+ // QuestionType minus "image_matching" (mirrors StandardQuizQuestion).
50
+ const StandardQuizQuestionTypeSchema = Schema.Literal("text_choice", "image_choice", "true_false", "finish_line", "audio_identify", "sequence_order", "estimate_range", "fill_blank", "matching_pairs", "progressive_reveal");
51
+ // Security: text/answer content is broadcast in game:question to every player
52
+ // on every start using this import; cap to prevent stored-DoS amplification.
53
+ // Bounds mirror curator.schema.ts's CustomQuestionSchema (same broadcast path).
54
+ const QuizAnswerSchema = Schema.Struct({
55
+ id: BoundedIdSchema,
56
+ text: Schema.String.pipe(Schema.maxLength(300)),
57
+ isCorrect: Schema.Boolean,
58
+ });
59
+ const BoundedMatchString = Schema.String.pipe(Schema.maxLength(200));
60
+ const MatchItemsSchema = Schema.Struct({
61
+ left: Schema.Array(BoundedMatchString).pipe(Schema.minItems(1), Schema.maxItems(10)),
62
+ right: Schema.Array(BoundedMatchString).pipe(Schema.minItems(1), Schema.maxItems(10)),
63
+ });
64
+ const BaseQuizQuestionFields = {
65
+ id: BoundedIdSchema,
66
+ text: Schema.String.pipe(Schema.maxLength(500)),
67
+ categoryId: BoundedIdSchema,
68
+ difficulty: DifficultySchema,
69
+ // image_matching stores a single correct-answer entry (see question-mapper.ts);
70
+ // everything else needs at least 2 options — 1 is the real floor across both.
71
+ answers: Schema.Array(QuizAnswerSchema).pipe(Schema.minItems(1), Schema.maxItems(8)),
72
+ mediaUrl: Schema.optional(Schema.String.pipe(Schema.maxLength(2048))),
73
+ mediaType: Schema.optional(MediaTypeSchema),
74
+ };
75
+ const StandardPreloadedQuestionSchema = Schema.Struct({
76
+ ...BaseQuizQuestionFields,
77
+ type: StandardQuizQuestionTypeSchema,
78
+ });
79
+ const ImageMatchingPreloadedQuestionSchema = Schema.Struct({
80
+ ...BaseQuizQuestionFields,
81
+ type: Schema.Literal("image_matching"),
82
+ matchItems: MatchItemsSchema,
83
+ });
84
+ const PreloadedQuestionSchema = Schema.Union(StandardPreloadedQuestionSchema, ImageMatchingPreloadedQuestionSchema);
85
+ const DifficultyFilterSchema = Schema.Literal(...GAME.roundConfig.difficultyOptions);
86
+ const CuratorStartMessageSchema = Schema.Struct({
87
+ type: Schema.Literal("curator:start"),
88
+ // No minItems: an empty/omitted categoryIds is a valid business state when
89
+ // preloadedQuestions carries the round — game-ws-server.ts's makeStartGame
90
+ // surfaces the friendly "select a category" error itself.
91
+ categoryIds: Schema.optional(Schema.Array(Schema.NonEmptyString).pipe(Schema.maxItems(50))),
92
+ timerDuration: Schema.optional(Schema.Literal(...GAME.roundConfig.timerOptions)),
93
+ // Not the QuestionsPerRound literal on purpose (ws.ts types this as a plain
94
+ // number) — bounded generously instead, mirroring DrawQuestionsSchema's cap.
95
+ questionsPerRound: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.greaterThan(0), Schema.lessThanOrEqualTo(500))),
96
+ difficulty: Schema.optional(DifficultyFilterSchema),
97
+ advanceMode: Schema.optional(Schema.Literal(...GAME.roundConfig.advanceModeOptions)),
98
+ scoringMode: OptionalScoringModeSchema,
99
+ // Generous headroom above any real round size (max 100) so a legitimate
100
+ // curator import is never rejected; still bounded against a DoS payload.
101
+ preloadedQuestions: Schema.optional(Schema.Array(PreloadedQuestionSchema).pipe(Schema.maxItems(500))),
102
+ });
103
+ const CuratorAdvanceMessageSchema = Schema.Struct({
104
+ type: Schema.Literal("curator:advance"),
105
+ });
106
+ const CuratorStopMessageSchema = Schema.Struct({
107
+ type: Schema.Literal("curator:stop"),
108
+ });
109
+ const CuratorResetMessageSchema = Schema.Struct({
110
+ type: Schema.Literal("curator:reset"),
111
+ });
112
+ const CuratorStartRoleSelectionMessageSchema = Schema.Struct({
113
+ type: Schema.Literal("curator:start-role-selection"),
114
+ });
115
+ const CuratorIntermissionMessageSchema = Schema.Struct({
116
+ type: Schema.Literal("curator:intermission"),
117
+ action: Schema.Literal("start", "end"),
118
+ });
119
+ const CuratorPlayerActionMessageSchema = Schema.Struct({
120
+ type: Schema.Literal("curator:player"),
121
+ playerId: BoundedIdSchema,
122
+ action: Schema.Literal("approve", "reject", "remove", "block"),
123
+ });
124
+ // ─── pong ───────────────────────────────────────────────────────────────────
125
+ const WsPongMessageSchema = Schema.Struct({
126
+ type: Schema.Literal("pong"),
127
+ });
128
+ // Discriminant: Effect Schema detects the shared `type: Schema.Literal(...)`
129
+ // field and short-circuits matching — every member needs a unique `type`.
130
+ export const ClientMessageSchema = Schema.Union(WsAuthMessageSchema, PlayerAnswerMessageSchema, PlayerEmoteMessageSchema, PlayerRoleMessageSchema, PlayerLeaveMessageSchema, CuratorStartMessageSchema, CuratorAdvanceMessageSchema, CuratorStartRoleSelectionMessageSchema, CuratorIntermissionMessageSchema, CuratorPlayerActionMessageSchema, CuratorStopMessageSchema, CuratorResetMessageSchema, WsPongMessageSchema);
131
+ const _assertNoClientMessageDrift = null;