@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,31 @@
1
+ import { Schema } from "effect";
2
+ export declare const SoloClientMessageSchema: Schema.Union<[Schema.Struct<{
3
+ type: Schema.Literal<["auth"]>;
4
+ token: Schema.filter<typeof Schema.NonEmptyString>;
5
+ }>, Schema.Struct<{
6
+ type: Schema.Literal<["solo:start"]>;
7
+ categoryIds: Schema.filter<Schema.Array$<typeof Schema.NonEmptyString>>;
8
+ timerDuration: Schema.filter<Schema.filter<Schema.filter<typeof Schema.Number>>>;
9
+ questionCount: Schema.filter<Schema.filter<Schema.filter<typeof Schema.Number>>>;
10
+ mode: Schema.Literal<["normal", "endless"]>;
11
+ difficulty: Schema.Literal<["all", "easy", "medium", "hard"]>;
12
+ device: Schema.optional<Schema.Literal<["mobile", "tablet", "desktop"]>>;
13
+ }>, Schema.Struct<{
14
+ type: Schema.Literal<["solo:answer"]>;
15
+ answerId: Schema.filter<typeof Schema.NonEmptyString>;
16
+ nonce: Schema.optional<Schema.filter<typeof Schema.NonEmptyString>>;
17
+ }>, Schema.Struct<{
18
+ type: Schema.Literal<["solo:answer-change"]>;
19
+ }>, Schema.Struct<{
20
+ type: Schema.Literal<["solo:next"]>;
21
+ }>, Schema.Struct<{
22
+ type: Schema.Literal<["solo:tab-blur"]>;
23
+ }>, Schema.Struct<{
24
+ type: Schema.Literal<["solo:tab-focus"]>;
25
+ }>, Schema.Struct<{
26
+ type: Schema.Literal<["solo:pong"]>;
27
+ }>, Schema.Struct<{
28
+ type: Schema.Literal<["solo:resume"]>;
29
+ sessionId: Schema.filter<typeof Schema.NonEmptyString>;
30
+ resumeToken: Schema.optional<Schema.filter<typeof Schema.NonEmptyString>>;
31
+ }>]>;
@@ -0,0 +1,57 @@
1
+ import { Schema } from "effect";
2
+ /**
3
+ * Frame-level validation for the solo-mode WebSocket (issue #734 hardening).
4
+ * One schema per `SoloClientMessage` union member in `./solo-ws.ts` —
5
+ * mirrors the hand-written types (no `Schema.Type` derivation feeds back into
6
+ * `SoloClientMessage`, see `solo-connection-handler.ts`). Keep in sync with
7
+ * `solo-ws.ts` whenever a frame is added/changed.
8
+ */
9
+ // Tokens ride the first frame; generous cap is a DoS guard only.
10
+ const TokenSchema = Schema.NonEmptyString.pipe(Schema.maxLength(4096));
11
+ // DoS guard: ids that round-trip through session state.
12
+ const BoundedIdSchema = Schema.NonEmptyString.pipe(Schema.maxLength(200));
13
+ const SoloAuthMessageSchema = Schema.Struct({
14
+ type: Schema.Literal("auth"),
15
+ token: TokenSchema,
16
+ });
17
+ const SoloStartMessageSchema = Schema.Struct({
18
+ type: Schema.Literal("solo:start"),
19
+ categoryIds: Schema.Array(Schema.NonEmptyString).pipe(Schema.maxItems(50)),
20
+ // Plain `number` on the hand-written type (clamped to [5,120] downstream) —
21
+ // bounded generously here against absurd/non-finite values.
22
+ timerDuration: Schema.Number.pipe(Schema.finite(), Schema.greaterThan(0), Schema.lessThanOrEqualTo(600)),
23
+ // 0 is a valid sentinel for endless mode (draw count is uncapped downstream).
24
+ questionCount: Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(0), Schema.lessThanOrEqualTo(500)),
25
+ mode: Schema.Literal("normal", "endless"),
26
+ difficulty: Schema.Literal("all", "easy", "medium", "hard"),
27
+ device: Schema.optional(Schema.Literal("mobile", "tablet", "desktop")),
28
+ });
29
+ const SoloAnswerMessageSchema = Schema.Struct({
30
+ type: Schema.Literal("solo:answer"),
31
+ answerId: BoundedIdSchema,
32
+ nonce: Schema.optional(BoundedIdSchema),
33
+ });
34
+ const SoloAnswerChangeMessageSchema = Schema.Struct({
35
+ type: Schema.Literal("solo:answer-change"),
36
+ });
37
+ const SoloNextMessageSchema = Schema.Struct({
38
+ type: Schema.Literal("solo:next"),
39
+ });
40
+ const SoloTabBlurMessageSchema = Schema.Struct({
41
+ type: Schema.Literal("solo:tab-blur"),
42
+ });
43
+ const SoloTabFocusMessageSchema = Schema.Struct({
44
+ type: Schema.Literal("solo:tab-focus"),
45
+ });
46
+ const SoloPongMessageSchema = Schema.Struct({
47
+ type: Schema.Literal("solo:pong"),
48
+ });
49
+ const SoloResumeMessageSchema = Schema.Struct({
50
+ type: Schema.Literal("solo:resume"),
51
+ sessionId: BoundedIdSchema,
52
+ resumeToken: Schema.optional(BoundedIdSchema),
53
+ });
54
+ // Discriminant: Effect Schema detects the shared `type: Schema.Literal(...)`
55
+ // field and short-circuits matching — every member needs a unique `type`.
56
+ export const SoloClientMessageSchema = Schema.Union(SoloAuthMessageSchema, SoloStartMessageSchema, SoloAnswerMessageSchema, SoloAnswerChangeMessageSchema, SoloNextMessageSchema, SoloTabBlurMessageSchema, SoloTabFocusMessageSchema, SoloPongMessageSchema, SoloResumeMessageSchema);
57
+ const _assertNoSoloClientMessageDrift = null;
package/dist/ws.d.ts ADDED
@@ -0,0 +1,281 @@
1
+ import type { BadgeAward, BadgeRunParams } from "./badges.ts";
2
+ import type { ErrorCode } from "./error-codes.ts";
3
+ import type { AdvanceMode, Difficulty, DifficultyFilter, ScoringMode, TimerDuration } from "./game.settings.ts";
4
+ import type { Player, PlayerRole, QuizQuestion } from "./game.ts";
5
+ import type { GameAnswerRecord } from "./game-answer-record.ts";
6
+ import type { MediaType, QuestionType } from "./question.schema.ts";
7
+ import type { StreakFlourish } from "./streak-flourish.ts";
8
+ export type ClientMessage = WsAuthMessage | PlayerAnswerMessage | PlayerEmoteMessage | PlayerRoleMessage | PlayerLeaveMessage | CuratorStartMessage | CuratorAdvanceMessage | CuratorStartRoleSelectionMessage | CuratorIntermissionMessage | CuratorPlayerActionMessage | CuratorStopMessage | CuratorResetMessage | WsPongMessage;
9
+ /** First frame for native/display (cross-origin, no cookie): authenticates the
10
+ * socket with the bearer game token (ADR 0014). Web rides the cookie instead. */
11
+ export interface WsAuthMessage {
12
+ type: "auth";
13
+ token: string;
14
+ }
15
+ export interface PlayerAnswerMessage {
16
+ type: "player:answer";
17
+ answerId: string;
18
+ /** @deprecated identity is bound at the socket upgrade; ignored if sent. */
19
+ playerId?: string;
20
+ }
21
+ /** Player reacts after answering; positioned at the long-press/right-click point. */
22
+ export interface PlayerEmoteMessage {
23
+ type: "player:emote";
24
+ emoteId: string;
25
+ offsetX: number;
26
+ offsetY: number;
27
+ }
28
+ /** Player picks a role during role selection. */
29
+ export interface PlayerRoleMessage {
30
+ type: "player:role";
31
+ role: PlayerRole;
32
+ }
33
+ /** Player leaves the lobby/game voluntarily. */
34
+ export interface PlayerLeaveMessage {
35
+ type: "player:leave";
36
+ }
37
+ /** Curator starts the game. Questions are drawn server-side from categories,
38
+ * or taken from a curator-side import (preloadedQuestions). */
39
+ export interface CuratorStartMessage {
40
+ type: "curator:start";
41
+ categoryIds?: string[];
42
+ timerDuration?: TimerDuration;
43
+ questionsPerRound?: number;
44
+ difficulty?: DifficultyFilter;
45
+ advanceMode?: AdvanceMode;
46
+ scoringMode?: ScoringMode;
47
+ preloadedQuestions?: QuizQuestion[];
48
+ }
49
+ export interface CuratorAdvanceMessage {
50
+ type: "curator:advance";
51
+ }
52
+ /** Curator stops the game early — remaining questions drop, the open
53
+ * question miss-fills, and the game finishes on the answers given. */
54
+ export interface CuratorStopMessage {
55
+ type: "curator:stop";
56
+ }
57
+ /** Curator sends everyone back to setup — membership drops, lobby and code
58
+ * stay alive, nothing is saved. */
59
+ export interface CuratorResetMessage {
60
+ type: "curator:reset";
61
+ }
62
+ /** Curator opens the end-of-game role-selection window. */
63
+ export interface CuratorStartRoleSelectionMessage {
64
+ type: "curator:start-role-selection";
65
+ }
66
+ export interface CuratorIntermissionMessage {
67
+ type: "curator:intermission";
68
+ action: "start" | "end";
69
+ }
70
+ export interface CuratorPlayerActionMessage {
71
+ type: "curator:player";
72
+ playerId: string;
73
+ action: "approve" | "reject" | "remove" | "block";
74
+ }
75
+ /** Keepalive reply to a server game:ping (survives Cloudflare's ~100s idle WS timeout). */
76
+ export interface WsPongMessage {
77
+ type: "pong";
78
+ }
79
+ export type ServerMessage = JoinAcceptedMessage | JoinRejectedMessage | LobbyUpdateMessage | LobbyEndedMessage | LobbyResetMessage | PlayerNicknameChangedMessage | GameStartMessage | GameQuestionMessage | GameTickMessage | PlayerAnsweredMessage | GameRoundResultMessage | GameFinalScoresMessage | GameResultsReadyMessage | RoleSelectionStartMessage | RoleSelectionUpdateMessage | RoleSelectionLockedMessage | PlayerRemovedMessage | GameIntermissionMessage | GameResumeMessage | PlayerEmoteBroadcastMessage | GameErrorMessage | GamePingMessage;
80
+ export interface JoinAcceptedMessage {
81
+ type: "join:accepted";
82
+ playerId: string;
83
+ quizName: string;
84
+ }
85
+ export interface JoinRejectedMessage {
86
+ type: "join:rejected";
87
+ reason: string;
88
+ }
89
+ export interface LobbyUpdateMessage {
90
+ type: "lobby:update";
91
+ players: readonly Player[];
92
+ gameStarted?: boolean;
93
+ accessMode?: "open" | "invite-only";
94
+ maxPlayers?: number | null;
95
+ /** Curator's live-edited quiz title. */
96
+ quizName?: string;
97
+ /** Curator's live-edited quiz description. */
98
+ description?: string;
99
+ /** Curator's live-edited category list. */
100
+ categories?: readonly string[];
101
+ /** Curator's live-edited difficulty filter. */
102
+ difficulty?: DifficultyFilter;
103
+ }
104
+ export interface LobbyEndedMessage {
105
+ type: "lobby:ended";
106
+ reason?: string;
107
+ }
108
+ /**
109
+ * The game was cleared so the curator can host the next quiz ("New quiz" or
110
+ * "Reset to setup"): every player keeps their seat with score 0 and returns to
111
+ * the lobby waiting room (phase → lobby); the curator's client goes to the
112
+ * create page to edit the quiz.
113
+ */
114
+ export interface LobbyResetMessage {
115
+ type: "lobby:reset";
116
+ }
117
+ export interface PlayerNicknameChangedMessage {
118
+ type: "player:nickname_changed";
119
+ playerId: string;
120
+ nickname: string;
121
+ }
122
+ export interface GameStartMessage {
123
+ type: "game:start";
124
+ totalQuestions: number;
125
+ timerDuration: TimerDuration;
126
+ advanceMode: AdvanceMode;
127
+ }
128
+ /** Question sent to players — isCorrect is omitted to prevent cheating */
129
+ export interface PlayerQuestionAnswer {
130
+ id: string;
131
+ text: string;
132
+ }
133
+ export interface GameQuestionMessage {
134
+ type: "game:question";
135
+ /** The live `questions` table id — stable across the shuffle instance that
136
+ * built this round's `answers[]` ids (question-flagging, ADR 0020). Never
137
+ * compare answer ids across questions/games; this is the one stable
138
+ * identifier for "which question is this". */
139
+ questionId: string;
140
+ questionIndex: number;
141
+ totalQuestions: number;
142
+ text: string;
143
+ categoryId: string;
144
+ difficulty?: Difficulty;
145
+ questionType?: QuestionType;
146
+ answers: PlayerQuestionAnswer[];
147
+ matchItems?: {
148
+ left: string[];
149
+ right: string[];
150
+ };
151
+ mediaUrl?: string;
152
+ mediaType?: MediaType;
153
+ timeRemaining: number;
154
+ serverTs: number;
155
+ /** This recipient's own already-submitted answer, if any — set only on a
156
+ * snapshot replay so a mid-question (re)connect restores the locked/answered
157
+ * UI instead of re-opening the question. Omitted on the live broadcast. */
158
+ yourAnswerId?: string;
159
+ /** Current answered tally, set only on a snapshot replay so the live
160
+ * "N of M answered" progress survives a mid-question reconnect (otherwise it
161
+ * resets to 0 until the next player:answered frame). Omitted on broadcast. */
162
+ answeredCount?: number;
163
+ answeredTotal?: number;
164
+ }
165
+ export interface GameTickMessage {
166
+ type: "game:tick";
167
+ timeRemaining: number;
168
+ serverTs: number;
169
+ /** Live answered tally piggybacked on the tick so players get "N of M
170
+ * answered" progress without a per-answer broadcast (that stays curator/
171
+ * display-only). Coarse to the 1s tick — fine for a progress cue. */
172
+ answeredCount?: number;
173
+ answeredTotal?: number;
174
+ }
175
+ export interface PlayerAnsweredMessage {
176
+ type: "player:answered";
177
+ playerId: string;
178
+ answeredCount: number;
179
+ totalCount: number;
180
+ }
181
+ export interface GameRoundResultMessage {
182
+ type: "game:round-result";
183
+ correctAnswerId: string;
184
+ players: Player[];
185
+ playerAnswers: Record<string, string>;
186
+ /** The curator's explanation of the answer, withheld until now (map #912,
187
+ * decision 2). Omitted when the question has none. */
188
+ postAnswerNote?: string;
189
+ /** The recipient's own consecutive-correct streak and flourish — set per
190
+ * connection by the transport, never broadcast, so each player sees only
191
+ * their own streak (mirrors solo). */
192
+ yourStreak?: {
193
+ streak: number;
194
+ flourish: StreakFlourish | null;
195
+ };
196
+ /** Seconds left on the between-question countdown, carried on the
197
+ * reconnect replay only, so the ring resumes where the server's advance
198
+ * timer stands rather than at the full delay. */
199
+ countdownRemaining?: number;
200
+ }
201
+ export interface GameFinalScoresMessage {
202
+ type: "game:final-scores";
203
+ /** The roster as it stood at game end, roles included. Later role changes
204
+ * arrive on `lobby:update` and never rewrite this list. */
205
+ players: Player[];
206
+ /** The recipient's own badges — set per-connection by the transport, never
207
+ * broadcast, so each player sees only their honours (ADR 0018). */
208
+ yourBadges?: BadgeAward[];
209
+ /** Run params the client derives badge rarity from (rarity is never stored). */
210
+ badgeRun?: BadgeRunParams;
211
+ /** The recipient's own per-question answer log — set per-connection by the
212
+ * transport, never broadcast, so the results page has its breakdown before
213
+ * `game_results` is persisted (the persist call is fire-and-forget). */
214
+ yourBreakdown?: GameAnswerRecord[];
215
+ /** Present only when the curator stopped the game early. `served` is the
216
+ * completed-question count (a round still open at the stop is dropped);
217
+ * `planned` is the quiz length, which feeds history and badge floors. */
218
+ stopped?: {
219
+ served: number;
220
+ planned: number;
221
+ };
222
+ }
223
+ /**
224
+ * Broadcast once the game's `game_results` row is persisted (the persist call
225
+ * from `game:final-scores` is fire-and-forget, so the row may not exist yet
226
+ * when a results page loads on arrival). Tells a results page that loaded
227
+ * early to reload its row-dependent state (canFlag, alreadyFlaggedIds).
228
+ */
229
+ export interface GameResultsReadyMessage {
230
+ type: "game:results-ready";
231
+ }
232
+ export interface RoleSelectionStartMessage {
233
+ type: "role:selection-start";
234
+ players: Player[];
235
+ timeRemaining: number;
236
+ }
237
+ export interface RoleSelectionUpdateMessage {
238
+ type: "role:selection-update";
239
+ players: Player[];
240
+ timeRemaining: number;
241
+ }
242
+ export interface RoleSelectionLockedMessage {
243
+ type: "role:selection-locked";
244
+ players: Player[];
245
+ }
246
+ export interface PlayerRemovedMessage {
247
+ type: "player:removed";
248
+ playerId: string;
249
+ }
250
+ export interface GameIntermissionMessage {
251
+ type: "game:intermission";
252
+ frozenTimeRemaining: number;
253
+ }
254
+ export interface GameResumeMessage {
255
+ type: "game:resume";
256
+ timeRemaining: number;
257
+ }
258
+ /** Server broadcast of a player's emote to every connected client. */
259
+ export interface PlayerEmoteBroadcastMessage {
260
+ type: "player:emote";
261
+ playerId: string;
262
+ emoteId: string;
263
+ offsetX: number;
264
+ offsetY: number;
265
+ }
266
+ /** Server keepalive; client replies with {type:"pong"}. */
267
+ export interface GamePingMessage {
268
+ type: "game:ping";
269
+ }
270
+ /** Failure feedback for the acting client — sent only to the socket whose
271
+ * action failed (a start that drew zero questions, an answer from an
272
+ * observer, a malformed frame), never broadcast. */
273
+ export interface GameErrorMessage {
274
+ type: "game:error";
275
+ /** Machine code for the UI to map to copy. Store `ErrorCode`s plus two
276
+ * transport-only codes: a frame that failed decoding, or an unexpected
277
+ * server-side throw. */
278
+ code: ErrorCode | "invalid_message" | "internal";
279
+ /** Human-readable fallback when the UI has no copy for `code`. */
280
+ message: string;
281
+ }
package/dist/ws.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@orakl/protocol",
3
+ "version": "0.1.0",
4
+ "description": "Orakl wire contract: WebSocket frames and API response schemas.",
5
+ "license": "AGPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/robcsaszar/orakl.git",
9
+ "directory": "packages/protocol"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "types": "./dist/index.d.ts",
19
+ "peerDependencies": {
20
+ "effect": "^3.22.1"
21
+ },
22
+ "devDependencies": {
23
+ "effect": "^3.22.1",
24
+ "typescript": "^6.0.3",
25
+ "vitest": "^5.0.0"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "LICENSE"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.build.json",
36
+ "test": "vitest run",
37
+ "typecheck": "tsc --noEmit"
38
+ }
39
+ }