@diffenderfer-games/hub-contract 2.0.0-next.0 → 2.0.0-next.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -21
  3. package/README.md +26 -26
  4. package/dist/index.d.ts +95 -95
  5. package/package.json +53 -53
  6. package/src/api/analytics.ts +81 -81
  7. package/src/api/auth.ts +77 -77
  8. package/src/api/catalog.ts +171 -171
  9. package/src/api/daily.ts +122 -122
  10. package/src/api/economy/bundle.ts +69 -69
  11. package/src/api/index.ts +85 -85
  12. package/src/api/leaderboards.ts +151 -151
  13. package/src/api/messages.ts +142 -142
  14. package/src/api/mp/chat.ts +36 -36
  15. package/src/api/mp/races.ts +211 -211
  16. package/src/api/mp/rooms.ts +90 -90
  17. package/src/api/mp/snapshot.ts +17 -17
  18. package/src/api/mp.ts +110 -110
  19. package/src/api/notify.ts +111 -111
  20. package/src/api/progress/game-data.ts +189 -189
  21. package/src/api/progress.ts +59 -59
  22. package/src/api/race.ts +47 -47
  23. package/src/api/reports.ts +208 -208
  24. package/src/api/social/config.ts +47 -47
  25. package/src/api/social/endpoints.ts +179 -179
  26. package/src/api/social/people.ts +118 -118
  27. package/src/api/social/presence.ts +95 -95
  28. package/src/api/social.ts +9 -9
  29. package/src/api/version.ts +40 -40
  30. package/src/coerce.ts +50 -50
  31. package/src/common.ts +42 -42
  32. package/src/entities.ts +62 -62
  33. package/src/errors/codes/core.ts +10 -10
  34. package/src/errors/codes/multiplayer.ts +43 -43
  35. package/src/errors/codes/play.ts +5 -5
  36. package/src/errors/codes/social.ts +31 -31
  37. package/src/errors/index.ts +88 -88
  38. package/src/index.ts +16 -16
  39. package/src/limits.ts +100 -100
  40. package/src/logic/daily.ts +54 -54
  41. package/src/logic/index.ts +8 -8
  42. package/src/logic/presence.ts +62 -62
  43. package/src/logic/time.ts +81 -81
  44. package/src/manifest/changes.ts +55 -55
  45. package/src/manifest/controls.ts +42 -42
  46. package/src/manifest/fields.ts +89 -89
  47. package/src/manifest/game.ts +132 -132
  48. package/src/manifest/index.ts +12 -12
  49. package/src/manifest/inject.ts +31 -31
  50. package/src/manifest/leaderboards.ts +29 -29
  51. package/src/manifest/multiplayer.ts +71 -71
  52. package/src/manifest/race.ts +93 -93
  53. package/src/middleware.ts +47 -47
  54. package/src/realtime/define.ts +52 -52
  55. package/src/realtime/envelope.ts +67 -67
  56. package/src/realtime/events/multiplayer.ts +8 -8
  57. package/src/realtime/events/parties.ts +31 -31
  58. package/src/realtime/events/races.ts +27 -27
  59. package/src/realtime/events/rooms.ts +48 -48
  60. package/src/realtime/events/social.ts +81 -81
  61. package/src/realtime/index.ts +24 -24
  62. package/src/realtime/ops/empty.ts +8 -8
  63. package/src/realtime/ops/hello.ts +40 -40
  64. package/src/realtime/ops/multiplayer.ts +8 -8
  65. package/src/realtime/ops/parties.ts +13 -13
  66. package/src/realtime/ops/races.ts +99 -99
  67. package/src/realtime/ops/rooms.ts +1 -1
  68. package/src/realtime/ops/social.ts +58 -58
@@ -1,171 +1,171 @@
1
- /**
2
- * The game catalog, favorites and What's new notes.
3
- */
4
- import { z } from 'zod';
5
- import { Millis, Slug } from '../common';
6
- import { ErrorBody } from '../errors';
7
- import { requireUser, session } from '../middleware';
8
- import { RaceStatFormat } from '../manifest/race';
9
- import { RaceParamValue } from './mp/races';
10
- import { LooseNumber, LooseText, Truthy } from '../coerce';
11
-
12
- /** A `[min, max]` player count, each 1 to 8. */
13
- export const PlayerRange = z.tuple([z.number().int().min(1).max(8), z.number().int().min(1).max(8)]);
14
-
15
- /**
16
- * What the menu needs to know about an online game (invite picker, Join and
17
- * Watch buttons, the catalog's Online tag), not the full manifest.
18
- */
19
- export const MultiplayerSummary = z.object({
20
- /** `own-server` only for process apps that run their own game server. */
21
- transport: z.enum(['hub-rooms', 'own-server']),
22
- players: PlayerRange,
23
- invites: z.boolean(),
24
- join: z.boolean(),
25
- spectate: z.boolean(),
26
- modes: z.array(z.object({ key: z.string(), title: z.string() })),
27
- /** Present (and `true`) when the block was made for a race-only game. */
28
- raceOnly: z.literal(true).optional(),
29
- });
30
-
31
- /** An online game's capabilities. */
32
- export type MultiplayerSummary = z.infer<typeof MultiplayerSummary>;
33
-
34
- /** A race game's manifest summary (players, the creator's param choices, the stats the HUD shows). */
35
- export const RaceSummary = z.object({
36
- /** Each 2 to 8. */
37
- players: PlayerRange,
38
- /** Up to 6 choices the race creator picks. */
39
- params: z.array(z.object({
40
- key: z.string(),
41
- title: z.string(),
42
- /** Up to 12. */
43
- options: z.array(z.object({ value: RaceParamValue, title: z.string() })),
44
- /** One of the options' values. */
45
- default: RaceParamValue,
46
- })),
47
- /** Up to 8 stats the race HUD and history show. */
48
- stats: z.array(z.object({
49
- key: z.string(),
50
- title: z.string(),
51
- format: RaceStatFormat,
52
- })),
53
- });
54
-
55
- /** A race game's manifest summary. */
56
- export type RaceSummary = z.infer<typeof RaceSummary>;
57
-
58
- /** A listed game, as the menu's game list and the catalog show it. Broken apps are left out. */
59
- export const GameInfo = z.object({
60
- slug: Slug,
61
- title: z.string(),
62
- /** Its URL path, e.g. `/duel`. */
63
- path: z.string(),
64
- /** `process` apps run their own server behind the host. */
65
- kind: z.enum(['static', 'process']),
66
- hasImage: z.boolean(),
67
- /** `false` when the game opted out of the injected hub menu. */
68
- hubEnabled: z.boolean(),
69
- /** `null` for single-player games. */
70
- multiplayer: MultiplayerSummary.nullable(),
71
- /** `null` unless the game supports races. */
72
- race: RaceSummary.nullable(),
73
- });
74
-
75
- /** A listed game. */
76
- export type GameInfo = z.infer<typeof GameInfo>;
77
-
78
- /** `{ games }`: the caller's starred slugs, oldest star first. */
79
- const Favorites = z.object({ games: z.array(Slug) });
80
-
81
- /** One What's new note. */
82
- export const ChangeNote = z.object({
83
- id: z.number().int().positive(),
84
- /** The game's slug, or `_hub` for the site's own notes. */
85
- game: Slug,
86
- /** The game's title, or `diffenderfer.games` for the site's own notes. */
87
- title: z.string(),
88
- text: z.string(),
89
- /** When the note went live (the deploy that first saw it), epoch ms. */
90
- at: Millis,
91
- /** Not yet seen by this player (or since `?since=` when signed out). */
92
- new: z.boolean(),
93
- });
94
-
95
- /** A What's new note. */
96
- export type ChangeNote = z.infer<typeof ChangeNote>;
97
-
98
- /** The catalog endpoints. */
99
- export const catalogEndpoints = {
100
- ...session.group({
101
- listGames: {
102
- method: 'GET', path: '/games',
103
- response: z.object({ games: z.array(GameInfo) }),
104
- doc: { summary: 'The game catalog, sorted by title', tags: ['catalog'] },
105
- },
106
- /**
107
- * Notes from the last 30 days, newest first (at most 6 per game and 30
108
- * in all). In a game, `?game=` limits them to that game's and the site's.
109
- */
110
- listWhatsNewNotes: {
111
- method: 'GET', path: '/changes',
112
- query: z.object({
113
- /** A game's slug. Empty means everything; an unknown one lists only the site's notes. */
114
- game: z.string().optional(),
115
- /**
116
- * Signed out only: the device's last-cleared time (epoch ms); without
117
- * it (or when it isn't a number) nothing is new.
118
- */
119
- since: z.preprocess((value: number | string | undefined) => (value === '' ? undefined : value), LooseNumber.optional()).catch(undefined),
120
- }),
121
- response: z.object({
122
- items: z.array(ChangeNote),
123
- /** How many of `items` are new. */
124
- unseen: z.number().int().nonnegative(),
125
- signedIn: z.boolean(),
126
- }),
127
- doc: { summary: "What's new: recent change notes", tags: ['catalog'] },
128
- },
129
- }),
130
- ...requireUser.group({
131
- listFavoriteGames: {
132
- method: 'GET', path: '/favorites', response: Favorites,
133
- errors: { 401: ErrorBody },
134
- doc: { summary: "The caller's starred games", tags: ['catalog'] },
135
- },
136
- /**
137
- * Star or unstar a game (idempotent). Starring needs a known game (404);
138
- * unstarring any slug succeeds.
139
- */
140
- setGameFavorite: {
141
- method: 'PUT', path: '/favorites/:slug',
142
- params: z.object({ slug: Slug }),
143
- body: z.object({
144
- /** `true` stars, `false` (or missing) unstars. */
145
- on: Truthy.optional(),
146
- }),
147
- response: Favorites,
148
- errors: { 401: ErrorBody, 404: ErrorBody },
149
- doc: { summary: 'Star or unstar a game', tags: ['catalog'] },
150
- },
151
- /**
152
- * Mark notes seen now. With `game`: that game's and the site's. With
153
- * `all` (or neither): everything.
154
- */
155
- markWhatsNewSeen: {
156
- method: 'POST', path: '/changes/seen',
157
- body: z.object({
158
- /** A game's slug, read as text; an unknown one is a 404. Ignored with `all`. */
159
- game: LooseText.nullish(),
160
- all: Truthy.optional(),
161
- }),
162
- response: z.object({
163
- ok: z.literal(true),
164
- /** The seen mark stored, epoch ms. */
165
- seenAt: Millis,
166
- }),
167
- errors: { 401: ErrorBody, 404: ErrorBody },
168
- doc: { summary: "Mark What's new notes seen", tags: ['catalog'] },
169
- },
170
- }),
171
- };
1
+ /**
2
+ * The game catalog, favorites and What's new notes.
3
+ */
4
+ import { z } from 'zod';
5
+ import { Millis, Slug } from '../common';
6
+ import { ErrorBody } from '../errors';
7
+ import { requireUser, session } from '../middleware';
8
+ import { RaceStatFormat } from '../manifest/race';
9
+ import { RaceParamValue } from './mp/races';
10
+ import { LooseNumber, LooseText, Truthy } from '../coerce';
11
+
12
+ /** A `[min, max]` player count, each 1 to 8. */
13
+ export const PlayerRange = z.tuple([z.number().int().min(1).max(8), z.number().int().min(1).max(8)]);
14
+
15
+ /**
16
+ * What the menu needs to know about an online game (invite picker, Join and
17
+ * Watch buttons, the catalog's Online tag), not the full manifest.
18
+ */
19
+ export const MultiplayerSummary = z.object({
20
+ /** `own-server` only for process apps that run their own game server. */
21
+ transport: z.enum(['hub-rooms', 'own-server']),
22
+ players: PlayerRange,
23
+ invites: z.boolean(),
24
+ join: z.boolean(),
25
+ spectate: z.boolean(),
26
+ modes: z.array(z.object({ key: z.string(), title: z.string() })),
27
+ /** Present (and `true`) when the block was made for a race-only game. */
28
+ raceOnly: z.literal(true).optional(),
29
+ });
30
+
31
+ /** An online game's capabilities. */
32
+ export type MultiplayerSummary = z.infer<typeof MultiplayerSummary>;
33
+
34
+ /** A race game's manifest summary (players, the creator's param choices, the stats the HUD shows). */
35
+ export const RaceSummary = z.object({
36
+ /** Each 2 to 8. */
37
+ players: PlayerRange,
38
+ /** Up to 6 choices the race creator picks. */
39
+ params: z.array(z.object({
40
+ key: z.string(),
41
+ title: z.string(),
42
+ /** Up to 12. */
43
+ options: z.array(z.object({ value: RaceParamValue, title: z.string() })),
44
+ /** One of the options' values. */
45
+ default: RaceParamValue,
46
+ })),
47
+ /** Up to 8 stats the race HUD and history show. */
48
+ stats: z.array(z.object({
49
+ key: z.string(),
50
+ title: z.string(),
51
+ format: RaceStatFormat,
52
+ })),
53
+ });
54
+
55
+ /** A race game's manifest summary. */
56
+ export type RaceSummary = z.infer<typeof RaceSummary>;
57
+
58
+ /** A listed game, as the menu's game list and the catalog show it. Broken apps are left out. */
59
+ export const GameInfo = z.object({
60
+ slug: Slug,
61
+ title: z.string(),
62
+ /** Its URL path, e.g. `/duel`. */
63
+ path: z.string(),
64
+ /** `process` apps run their own server behind the host. */
65
+ kind: z.enum(['static', 'process']),
66
+ hasImage: z.boolean(),
67
+ /** `false` when the game opted out of the injected hub menu. */
68
+ hubEnabled: z.boolean(),
69
+ /** `null` for single-player games. */
70
+ multiplayer: MultiplayerSummary.nullable(),
71
+ /** `null` unless the game supports races. */
72
+ race: RaceSummary.nullable(),
73
+ });
74
+
75
+ /** A listed game. */
76
+ export type GameInfo = z.infer<typeof GameInfo>;
77
+
78
+ /** `{ games }`: the caller's starred slugs, oldest star first. */
79
+ const Favorites = z.object({ games: z.array(Slug) });
80
+
81
+ /** One What's new note. */
82
+ export const ChangeNote = z.object({
83
+ id: z.number().int().positive(),
84
+ /** The game's slug, or `_hub` for the site's own notes. */
85
+ game: Slug,
86
+ /** The game's title, or `diffenderfer.games` for the site's own notes. */
87
+ title: z.string(),
88
+ text: z.string(),
89
+ /** When the note went live (the deploy that first saw it), epoch ms. */
90
+ at: Millis,
91
+ /** Not yet seen by this player (or since `?since=` when signed out). */
92
+ new: z.boolean(),
93
+ });
94
+
95
+ /** A What's new note. */
96
+ export type ChangeNote = z.infer<typeof ChangeNote>;
97
+
98
+ /** The catalog endpoints. */
99
+ export const catalogEndpoints = {
100
+ ...session.group({
101
+ listGames: {
102
+ method: 'GET', path: '/games',
103
+ response: z.object({ games: z.array(GameInfo) }),
104
+ doc: { summary: 'The game catalog, sorted by title', tags: ['catalog'] },
105
+ },
106
+ /**
107
+ * Notes from the last 30 days, newest first (at most 6 per game and 30
108
+ * in all). In a game, `?game=` limits them to that game's and the site's.
109
+ */
110
+ listWhatsNewNotes: {
111
+ method: 'GET', path: '/changes',
112
+ query: z.object({
113
+ /** A game's slug. Empty means everything; an unknown one lists only the site's notes. */
114
+ game: z.string().optional(),
115
+ /**
116
+ * Signed out only: the device's last-cleared time (epoch ms); without
117
+ * it (or when it isn't a number) nothing is new.
118
+ */
119
+ since: z.preprocess((value: number | string | undefined) => (value === '' ? undefined : value), LooseNumber.optional()).catch(undefined),
120
+ }),
121
+ response: z.object({
122
+ items: z.array(ChangeNote),
123
+ /** How many of `items` are new. */
124
+ unseen: z.number().int().nonnegative(),
125
+ signedIn: z.boolean(),
126
+ }),
127
+ doc: { summary: "What's new: recent change notes", tags: ['catalog'] },
128
+ },
129
+ }),
130
+ ...requireUser.group({
131
+ listFavoriteGames: {
132
+ method: 'GET', path: '/favorites', response: Favorites,
133
+ errors: { 401: ErrorBody },
134
+ doc: { summary: "The caller's starred games", tags: ['catalog'] },
135
+ },
136
+ /**
137
+ * Star or unstar a game (idempotent). Starring needs a known game (404);
138
+ * unstarring any slug succeeds.
139
+ */
140
+ setGameFavorite: {
141
+ method: 'PUT', path: '/favorites/:slug',
142
+ params: z.object({ slug: Slug }),
143
+ body: z.object({
144
+ /** `true` stars, `false` (or missing) unstars. */
145
+ on: Truthy.optional(),
146
+ }),
147
+ response: Favorites,
148
+ errors: { 401: ErrorBody, 404: ErrorBody },
149
+ doc: { summary: 'Star or unstar a game', tags: ['catalog'] },
150
+ },
151
+ /**
152
+ * Mark notes seen now. With `game`: that game's and the site's. With
153
+ * `all` (or neither): everything.
154
+ */
155
+ markWhatsNewSeen: {
156
+ method: 'POST', path: '/changes/seen',
157
+ body: z.object({
158
+ /** A game's slug, read as text; an unknown one is a 404. Ignored with `all`. */
159
+ game: LooseText.nullish(),
160
+ all: Truthy.optional(),
161
+ }),
162
+ response: z.object({
163
+ ok: z.literal(true),
164
+ /** The seen mark stored, epoch ms. */
165
+ seenAt: Millis,
166
+ }),
167
+ errors: { 401: ErrorBody, 404: ErrorBody },
168
+ doc: { summary: "Mark What's new notes seen", tags: ['catalog'] },
169
+ },
170
+ }),
171
+ };
package/src/api/daily.ts CHANGED
@@ -1,122 +1,122 @@
1
- /**
2
- * Daily challenges: one deterministic challenge per UTC day for games with
3
- * `game.dailyChallenges: true`. Starting a day mints a signed token; the game
4
- * presents it on completion. Completions are counted on the game's `daily`
5
- * leaderboard. The client owns the calendar (its local date); the hub only
6
- * reports which days are done.
7
- */
8
- import { use } from '@ayepi/core';
9
- import { z } from 'zod';
10
- import { DayKey, Json, Millis, Slug, UserId } from '../common';
11
- import { ErrorBody } from '../errors';
12
- import { game, requireUser, session } from '../middleware';
13
- import { Rank } from './leaderboards';
14
-
15
- /** One completed day. */
16
- export const DailyCompletion = z.object({
17
- /** Epoch ms (server clock). */
18
- completedAt: Millis,
19
- /** The JSON the game sent on completion, or `null`. */
20
- meta: Json,
21
- });
22
-
23
- /** One completed day. */
24
- export type DailyCompletion = z.infer<typeof DailyCompletion>;
25
-
26
- /** A player's completions in one game, keyed by `YYYY-MM-DD`. */
27
- export const DailyCompletions = z.record(DayKey, DailyCompletion);
28
-
29
- /** A player's completions in one game. */
30
- export type DailyCompletions = z.infer<typeof DailyCompletions>;
31
-
32
- /** `:day` of a challenge: within 30 days back and 1 day ahead of UTC today. */
33
- const DayParams = z.object({ day: z.string().refine((day) => DayKey.safeParse(day).success, { error: 'Invalid day (expected YYYY-MM-DD).' }) });
34
-
35
- /** The daily endpoints. */
36
- export const dailyEndpoints = {
37
- ...session.group({
38
- /**
39
- * Every daily game with the caller's completions (empty maps when signed
40
- * out), plus the top 10 players by completions across all games.
41
- */
42
- listDailyChallengesForAllGames: {
43
- method: 'GET', path: '/daily',
44
- response: z.object({
45
- /** UTC today, a hint: clients use their local date. */
46
- today: DayKey,
47
- games: z.array(z.object({ slug: Slug, title: z.string(), completions: DailyCompletions })),
48
- global: z.array(z.object({
49
- rank: Rank,
50
- userId: UserId,
51
- username: z.string(),
52
- /** Daily challenges completed, across all games. */
53
- score: z.number().int().nonnegative(),
54
- })),
55
- }),
56
- doc: { summary: 'Daily challenges across every game, plus global standings', tags: ['daily'] },
57
- },
58
- }),
59
- ...use(session, game).path('/games/:slug').group({
60
- getGameDailyChallenges: {
61
- method: 'GET', path: '/daily',
62
- response: z.object({
63
- enabled: z.literal(true),
64
- /** UTC today, a hint: clients use their local date. */
65
- today: DayKey,
66
- /** Empty when signed out. */
67
- completions: DailyCompletions,
68
- }),
69
- errors: { 404: ErrorBody },
70
- doc: { summary: "The caller's completions in this game (404 if it has no dailies)", tags: ['daily'] },
71
- },
72
- }),
73
- ...use(requireUser, game).path('/games/:slug').group({
74
- /**
75
- * Start (or replay) a day: returns its seed and a signed token to present
76
- * on completion. 400 when the day is outside the window.
77
- */
78
- startDailyChallenge: {
79
- method: 'POST', path: '/daily/:day/start', params: DayParams,
80
- response: z.object({
81
- /** Present it to `dailyComplete` for the same day. */
82
- token: z.string(),
83
- day: DayKey,
84
- /** Deterministic per (game, day), an unsigned 32-bit integer. */
85
- seed: z.number().int().min(0).max(0xffff_ffff),
86
- /** When the token stops working (epoch ms), about 26 hours out. */
87
- expiresAt: Millis,
88
- alreadyCompleted: z.boolean(),
89
- /** Epoch ms, or `null` when not completed yet. */
90
- completedAt: Millis.nullable(),
91
- /** The earlier completion's JSON, or `null`. */
92
- meta: Json,
93
- }),
94
- errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
95
- doc: { summary: "Start a day's challenge and get its token", tags: ['daily'] },
96
- },
97
- /**
98
- * Record a completion with the token from `dailyStart`. Idempotent: a
99
- * replay of a done day changes nothing. 401 on a bad, expired or
100
- * mismatched token.
101
- */
102
- completeDailyChallenge: {
103
- method: 'POST', path: '/daily/:day/complete', params: DayParams,
104
- body: z.object({
105
- /** The token from `dailyStart`; a missing or non-text token is refused like a bad one (401). */
106
- token: z.string().optional().catch(undefined),
107
- /** Any JSON; a numeric `meta.score` is kept with the completion. */
108
- meta: Json.optional(),
109
- }),
110
- response: z.object({
111
- ok: z.literal(true),
112
- day: DayKey,
113
- /** The caller's completions in this game. */
114
- count: z.number().int().positive(),
115
- /** The caller's rank on the game's `daily` board. */
116
- rank: Rank.nullable(),
117
- }),
118
- errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
119
- doc: { summary: 'Complete a day with its token', tags: ['daily'] },
120
- },
121
- }),
122
- };
1
+ /**
2
+ * Daily challenges: one deterministic challenge per UTC day for games with
3
+ * `game.dailyChallenges: true`. Starting a day mints a signed token; the game
4
+ * presents it on completion. Completions are counted on the game's `daily`
5
+ * leaderboard. The client owns the calendar (its local date); the hub only
6
+ * reports which days are done.
7
+ */
8
+ import { use } from '@ayepi/core';
9
+ import { z } from 'zod';
10
+ import { DayKey, Json, Millis, Slug, UserId } from '../common';
11
+ import { ErrorBody } from '../errors';
12
+ import { game, requireUser, session } from '../middleware';
13
+ import { Rank } from './leaderboards';
14
+
15
+ /** One completed day. */
16
+ export const DailyCompletion = z.object({
17
+ /** Epoch ms (server clock). */
18
+ completedAt: Millis,
19
+ /** The JSON the game sent on completion, or `null`. */
20
+ meta: Json,
21
+ });
22
+
23
+ /** One completed day. */
24
+ export type DailyCompletion = z.infer<typeof DailyCompletion>;
25
+
26
+ /** A player's completions in one game, keyed by `YYYY-MM-DD`. */
27
+ export const DailyCompletions = z.record(DayKey, DailyCompletion);
28
+
29
+ /** A player's completions in one game. */
30
+ export type DailyCompletions = z.infer<typeof DailyCompletions>;
31
+
32
+ /** `:day` of a challenge: within 30 days back and 1 day ahead of UTC today. */
33
+ const DayParams = z.object({ day: z.string().refine((day) => DayKey.safeParse(day).success, { error: 'Invalid day (expected YYYY-MM-DD).' }) });
34
+
35
+ /** The daily endpoints. */
36
+ export const dailyEndpoints = {
37
+ ...session.group({
38
+ /**
39
+ * Every daily game with the caller's completions (empty maps when signed
40
+ * out), plus the top 10 players by completions across all games.
41
+ */
42
+ listDailyChallengesForAllGames: {
43
+ method: 'GET', path: '/daily',
44
+ response: z.object({
45
+ /** UTC today, a hint: clients use their local date. */
46
+ today: DayKey,
47
+ games: z.array(z.object({ slug: Slug, title: z.string(), completions: DailyCompletions })),
48
+ global: z.array(z.object({
49
+ rank: Rank,
50
+ userId: UserId,
51
+ username: z.string(),
52
+ /** Daily challenges completed, across all games. */
53
+ score: z.number().int().nonnegative(),
54
+ })),
55
+ }),
56
+ doc: { summary: 'Daily challenges across every game, plus global standings', tags: ['daily'] },
57
+ },
58
+ }),
59
+ ...use(session, game).path('/games/:slug').group({
60
+ getGameDailyChallenges: {
61
+ method: 'GET', path: '/daily',
62
+ response: z.object({
63
+ enabled: z.literal(true),
64
+ /** UTC today, a hint: clients use their local date. */
65
+ today: DayKey,
66
+ /** Empty when signed out. */
67
+ completions: DailyCompletions,
68
+ }),
69
+ errors: { 404: ErrorBody },
70
+ doc: { summary: "The caller's completions in this game (404 if it has no dailies)", tags: ['daily'] },
71
+ },
72
+ }),
73
+ ...use(requireUser, game).path('/games/:slug').group({
74
+ /**
75
+ * Start (or replay) a day: returns its seed and a signed token to present
76
+ * on completion. 400 when the day is outside the window.
77
+ */
78
+ startDailyChallenge: {
79
+ method: 'POST', path: '/daily/:day/start', params: DayParams,
80
+ response: z.object({
81
+ /** Present it to `dailyComplete` for the same day. */
82
+ token: z.string(),
83
+ day: DayKey,
84
+ /** Deterministic per (game, day), an unsigned 32-bit integer. */
85
+ seed: z.number().int().min(0).max(0xffff_ffff),
86
+ /** When the token stops working (epoch ms), about 26 hours out. */
87
+ expiresAt: Millis,
88
+ alreadyCompleted: z.boolean(),
89
+ /** Epoch ms, or `null` when not completed yet. */
90
+ completedAt: Millis.nullable(),
91
+ /** The earlier completion's JSON, or `null`. */
92
+ meta: Json,
93
+ }),
94
+ errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
95
+ doc: { summary: "Start a day's challenge and get its token", tags: ['daily'] },
96
+ },
97
+ /**
98
+ * Record a completion with the token from `dailyStart`. Idempotent: a
99
+ * replay of a done day changes nothing. 401 on a bad, expired or
100
+ * mismatched token.
101
+ */
102
+ completeDailyChallenge: {
103
+ method: 'POST', path: '/daily/:day/complete', params: DayParams,
104
+ body: z.object({
105
+ /** The token from `dailyStart`; a missing or non-text token is refused like a bad one (401). */
106
+ token: z.string().optional().catch(undefined),
107
+ /** Any JSON; a numeric `meta.score` is kept with the completion. */
108
+ meta: Json.optional(),
109
+ }),
110
+ response: z.object({
111
+ ok: z.literal(true),
112
+ day: DayKey,
113
+ /** The caller's completions in this game. */
114
+ count: z.number().int().positive(),
115
+ /** The caller's rank on the game's `daily` board. */
116
+ rank: Rank.nullable(),
117
+ }),
118
+ errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
119
+ doc: { summary: 'Complete a day with its token', tags: ['daily'] },
120
+ },
121
+ }),
122
+ };