@joystyk/daily 0.0.0 → 0.2.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.
- package/CHANGELOG.md +24 -0
- package/README.md +154 -2
- package/dist/browser/clock.d.ts +42 -0
- package/dist/browser/clock.js +56 -0
- package/dist/browser/clock.js.map +1 -0
- package/dist/browser/content.d.ts +73 -0
- package/dist/browser/content.js +170 -0
- package/dist/browser/content.js.map +1 -0
- package/dist/browser/errors.d.ts +23 -0
- package/dist/browser/errors.js +20 -0
- package/dist/browser/errors.js.map +1 -0
- package/dist/browser/hash.d.ts +6 -0
- package/dist/browser/hash.js +16 -0
- package/dist/browser/hash.js.map +1 -0
- package/dist/browser/index.d.ts +5 -0
- package/dist/browser/index.js +6 -0
- package/dist/browser/results.d.ts +37 -0
- package/dist/browser/results.js +102 -0
- package/dist/browser/results.js.map +1 -0
- package/dist/dates.d.ts +20 -0
- package/dist/dates.js +30 -0
- package/dist/dates.js.map +1 -0
- package/dist/ids.d.ts +15 -0
- package/dist/ids.js +25 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/local-time.d.ts +17 -0
- package/dist/local-time.js +29 -0
- package/dist/local-time.js.map +1 -0
- package/dist/release.d.ts +23 -0
- package/dist/release.js +29 -0
- package/dist/release.js.map +1 -0
- package/dist/schemas.d.ts +99 -0
- package/dist/schemas.js +121 -0
- package/dist/schemas.js.map +1 -0
- package/dist/share.d.ts +55 -0
- package/dist/share.js +73 -0
- package/dist/share.js.map +1 -0
- package/dist/streaks.d.ts +31 -0
- package/dist/streaks.js +69 -0
- package/dist/streaks.js.map +1 -0
- package/dist/word-stats.d.ts +12 -0
- package/dist/word-stats.js +46 -0
- package/dist/word-stats.js.map +1 -0
- package/package.json +47 -11
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @joystyk/daily
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [bb095c7]
|
|
8
|
+
- Updated dependencies [bb095c7]
|
|
9
|
+
- @joystyk/contracts@0.2.0
|
|
10
|
+
- @joystyk/storage@0.2.0
|
|
11
|
+
- @joystyk/kit@0.2.0
|
|
12
|
+
|
|
13
|
+
## 0.1.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- Prepare the first compiled library release: ESM and API declarations for every public entry point, runtime assets, and checks against installed npm tarballs. Games and applications remain private.
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Updated dependencies
|
|
22
|
+
- @joystyk/contracts@0.1.0
|
|
23
|
+
- @joystyk/kit@0.1.0
|
|
24
|
+
- @joystyk/storage@0.1.0
|
package/README.md
CHANGED
|
@@ -1,5 +1,157 @@
|
|
|
1
1
|
# @joystyk/daily
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Everything about daily puzzles that every daily game shares: which day it is, when the next
|
|
4
|
+
puzzle comes out in the player's own time, puzzle ids, results and streaks, spoiler-free
|
|
5
|
+
share text, and a client that downloads each day's puzzle and keeps it for offline play. No
|
|
6
|
+
game writes these again. The design is content-pipeline.md (Joystyk host reference)
|
|
7
|
+
("Which day is today", "Delivery", "Results and streaks", "Share text") and the daily rows of
|
|
8
|
+
offline-and-updates.md (Joystyk host reference).
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
## npm package
|
|
11
|
+
|
|
12
|
+
The first `0.1.0` release is being prepared; it has not been published. Once available:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @joystyk/daily
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Node consumers require Node 24.15 or later. Browser entry points require a modern
|
|
19
|
+
browser and should be loaded in the browser.
|
|
20
|
+
|
|
21
|
+
| Import | Format |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `@joystyk/daily` | ES module and TypeScript declarations |
|
|
24
|
+
| `@joystyk/daily/browser` | ES module and TypeScript declarations |
|
|
25
|
+
|
|
26
|
+
Only the entries listed above are public API. Installed packages contain compiled ESM,
|
|
27
|
+
type declarations and runtime assets. Repository-relative links describe the Joystyk
|
|
28
|
+
workspace and are not needed to use the npm package.
|
|
29
|
+
|
|
30
|
+
## Entry points
|
|
31
|
+
|
|
32
|
+
| Import | Runs in | Contents |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `@joystyk/daily` | Anywhere (no clock, storage, network or DOM) | Dates and release rules, local-time text, puzzle ids, `streaks`, share text, schemas |
|
|
35
|
+
| `@joystyk/daily/browser` | Browser | `today`, `nextReleaseLocal`, `watchDay`, the content client with its cache and prefetch, the results store |
|
|
36
|
+
|
|
37
|
+
The core imports only `@joystyk/contracts`, `@joystyk/kit` (daily ids and calendar
|
|
38
|
+
arithmetic) and Zod. Only the browser entry imports `@joystyk/storage`.
|
|
39
|
+
`tsconfig.core.json` type-checks the core without DOM types.
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { canStart, gridRows, shareText } from '@joystyk/daily';
|
|
45
|
+
import { createDailyClient, createDailyResults, nextReleaseLocal, today } from '@joystyk/daily/browser';
|
|
46
|
+
|
|
47
|
+
const date = today(); // the UTC date of the device clock
|
|
48
|
+
const client = createDailyClient({ baseUrl: '/daily', gameId: 'daily-word', config: engine.schemas.config, storage });
|
|
49
|
+
const puzzle = await client.puzzle(date); // Result: decoded config, from network or cache
|
|
50
|
+
if (!puzzle.ok) frame.notice(puzzle.error.message); // "This puzzle needs a connection…"
|
|
51
|
+
|
|
52
|
+
const results = createDailyResults(storage, profile.id);
|
|
53
|
+
const { streaks } = (await results.record(dailyResult, today())).value; // { current, best, winCurrent, winBest }
|
|
54
|
+
|
|
55
|
+
shareText({ title: 'Daily Word', date, headline: '4/6', rows: gridRows(board, legend, (i) => `Guess ${i + 1}`), url });
|
|
56
|
+
shareText(sameInput, 'words'); // "Copy as text": rows in plain words
|
|
57
|
+
|
|
58
|
+
const next = nextReleaseLocal(); // { time: '8:00 PM', countdown: 'in 6 hours', … }
|
|
59
|
+
frame.notice(`New puzzle at ${next.time} (${next.countdown})`);
|
|
60
|
+
|
|
61
|
+
await client.prefetch(date); // the PWA, at a safe point, if installed or pinned
|
|
62
|
+
await client.prune(date); // drop cached days before yesterday
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Days
|
|
66
|
+
|
|
67
|
+
- **One puzzle per UTC day** (host policy). **`utcDate(instant)`** is the puzzle date at an
|
|
68
|
+
instant, the same everywhere at once. In the browser, `today()` reads `Date.now()`. Travel
|
|
69
|
+
and daylight saving never change the date.
|
|
70
|
+
- **Release:** puzzle D comes out at 00:00 UTC on D (`releaseInstant`, `isReleased`);
|
|
71
|
+
`nextRelease(instant)` and `untilNextDay(instant)` give the next one. The server's index
|
|
72
|
+
lists dates up to tomorrow (`latestListedDate`), so installed clients can prefetch it.
|
|
73
|
+
- **Local time for display only.** `localTimeText(instant, { locale, timeZone })` gives the
|
|
74
|
+
release time as the player reads it ("8:00 PM"), and `countdownText(ms, locale)` a plain
|
|
75
|
+
countdown ("in 6 hours"). Both default to the runtime's locale and zone. In the browser,
|
|
76
|
+
`nextReleaseLocal()` puts them together with the device clock.
|
|
77
|
+
- **Starting and finishing:** a player starts only today's puzzle (`canStart`); a prefetched
|
|
78
|
+
puzzle for tomorrow stays locked until its day begins. A puzzle started is kept, and its
|
|
79
|
+
result counts for the puzzle's date even when it is finished after the next release
|
|
80
|
+
(`creditDate`).
|
|
81
|
+
- `addDays`, `daysBetween`, `dayNumber`, `startOfDay` and `puzzleNumber` work on
|
|
82
|
+
`yyyy-mm-dd` strings. The first three come from the kit.
|
|
83
|
+
|
|
84
|
+
## Streaks
|
|
85
|
+
|
|
86
|
+
`streaks(results, today)` is pure and is the only place streaks are decided. Streaks count
|
|
87
|
+
UTC days:
|
|
88
|
+
|
|
89
|
+
- **Streak:** consecutive puzzle dates finished (won, lost or completed, with or without
|
|
90
|
+
help; any variant). **Win streak:** consecutive dates won; only games that can be lost show it.
|
|
91
|
+
- Today not yet played leaves a streak standing. Any missed date before it sets the current
|
|
92
|
+
streak to 0; the best is kept. There are no freezes or grace days.
|
|
93
|
+
- A skipped date counts as missed; a repeated date counts once. Results dated after `today`
|
|
94
|
+
(a clock set ahead) count toward the best only.
|
|
95
|
+
|
|
96
|
+
The results store keeps the results and writes a summary to the storage package's streak
|
|
97
|
+
records after every `record` and `streaks` call: the days-finished streak in the player's
|
|
98
|
+
`StreakStore`, the win streak at `<player>/<game>/win` in the same shape. A stored best is
|
|
99
|
+
never lowered, ready for v2 sync's "max wins".
|
|
100
|
+
|
|
101
|
+
## Share text
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
Daily Word 2026-10-08 4/6 ← title, date (or "#412"), variant in brackets, headline
|
|
105
|
+
Solved in 1:42, no help ← summary line, when the game has one
|
|
106
|
+
⬜🟨⬜⬜⬜ ← symbol rows; in the words form, "Guess 1: 1 wrong place."
|
|
107
|
+
https://…/games/daily-word ← the game page, with no query string or fragment
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The game supplies the headline, summary and rows; `gridRows` builds rows in both forms from a
|
|
111
|
+
grid of cell kinds and a legend. `shareText` throws on a symbol row containing any letter or
|
|
112
|
+
digit, a line break in a one-line field, or a URL with a query or fragment, since each could
|
|
113
|
+
leak an answer or carry tracking. Never put letters, clues or answers in a summary.
|
|
114
|
+
|
|
115
|
+
## Delivery formats
|
|
116
|
+
|
|
117
|
+
The site serves `/daily/<game>/index.json` (`no-cache`) and
|
|
118
|
+
`/daily/<game>/<date>.<hash>.json` (immutable). Both decode through this package's schemas:
|
|
119
|
+
|
|
120
|
+
| Schema | Shape |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `dailyIndex` | `{ schemaVersion: 1, gameId, dates: [{ date, hash, size }] }` |
|
|
123
|
+
| `dailyFile` / `dailyEnvelope(config)` | `{ schemaVersion: 1, puzzleId, date, contentHash, config }`; `dailyEnvelope` also decodes `config` with the game's `schemas.config` and can require a game, date and hash |
|
|
124
|
+
| `dailyResult` | The `DailyResult` record from content-pipeline.md, plus an optional `shareWords` (the rows in words, for *Copy as text*) |
|
|
125
|
+
|
|
126
|
+
The **content hash** is the first 12 hex digits of SHA-256 over `canonicalJson(config)`
|
|
127
|
+
(keys sorted by UTF-16 code unit, no whitespace). The client checks it with Web Crypto before
|
|
128
|
+
caching a file. The `content` package must hash the same way.
|
|
129
|
+
|
|
130
|
+
## The content client
|
|
131
|
+
|
|
132
|
+
`puzzle(date)` revalidates the index, refuses a date the index doesn't list, uses the cached
|
|
133
|
+
file when its hash is still current, and otherwise downloads, checks and caches it. If the
|
|
134
|
+
network or server fails it falls back to any cached copy for that date; with none, it returns
|
|
135
|
+
`Offline` with a plain message. Every read, from the network or the cache, decodes the file
|
|
136
|
+
again, so a new game build with a stricter schema never trusts an old download. Requests send
|
|
137
|
+
no cookies, referrer or query string.
|
|
138
|
+
|
|
139
|
+
`prefetch(today)` downloads today and the next day (`prefetchDates`), where the index lists
|
|
140
|
+
them and the device lacks them; never further ahead (host policy). The package doesn't decide who
|
|
141
|
+
prefetches: the PWA calls it for installed clients and pinned games, at a safe point.
|
|
142
|
+
|
|
143
|
+
Downloads live in the storage package's `game-data` store under `~daily/<game>/<date>`;
|
|
144
|
+
results under `<player>/~daily/<game>/<date>/<variant or ->`. A `~` can't start a player id or
|
|
145
|
+
a game id, so neither meets a game's own keys.
|
|
146
|
+
|
|
147
|
+
## Tests
|
|
148
|
+
|
|
149
|
+
- `src/*.test.ts` (Node): the UTC date across midnight, daylight saving, leap days and 1970,
|
|
150
|
+
with a year-long hourly sweep against `Date#toISOString`; release and listing rules at
|
|
151
|
+
every quarter hour of three days; local release times in several zones and locales, and
|
|
152
|
+
the countdown text; streak rules (with a randomized check against a day-by-day count);
|
|
153
|
+
share text for Daily Word and Crossword; and the schemas.
|
|
154
|
+
- `src/browser/*.browser.test.ts` (Chromium): the content client against a fake `fetch` over
|
|
155
|
+
real IndexedDB and Web Crypto (cache hits, offline, withdraw and replace, bad hashes and
|
|
156
|
+
configs, prefetch of the next day only, prune), the results store, `today`,
|
|
157
|
+
`nextReleaseLocal` and `watchDay`.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type LocalTimeOptions } from '../local-time.js';
|
|
2
|
+
export type ClockOptions = {
|
|
3
|
+
/** Epoch milliseconds. Defaults to `Date.now`. */
|
|
4
|
+
readonly now?: () => number;
|
|
5
|
+
};
|
|
6
|
+
/** Today's puzzle date, `yyyy-mm-dd`: the UTC date of the device clock. */
|
|
7
|
+
export declare const today: (options?: ClockOptions) => string;
|
|
8
|
+
/** Milliseconds until the next puzzle comes out, at the start of the next UTC day. */
|
|
9
|
+
export declare const timeUntilNextDay: (options?: ClockOptions) => number;
|
|
10
|
+
/** The next release, ready to show: "New puzzle at 8:00 PM (in 6 hours)". */
|
|
11
|
+
export type NextRelease = {
|
|
12
|
+
/** The date of the next puzzle, `yyyy-mm-dd`. */
|
|
13
|
+
readonly date: string;
|
|
14
|
+
/** When it comes out, in epoch milliseconds. */
|
|
15
|
+
readonly instant: number;
|
|
16
|
+
/** Milliseconds from now until then. */
|
|
17
|
+
readonly inMs: number;
|
|
18
|
+
/** The release time in the player's zone, such as "8:00 PM". */
|
|
19
|
+
readonly time: string;
|
|
20
|
+
/** A plain countdown, such as "in 6 hours". */
|
|
21
|
+
readonly countdown: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* When the next puzzle comes out, in the device's time zone and locale unless `timeZone` or
|
|
25
|
+
* `locale` says otherwise. Call it again at safe points to refresh the countdown; it sets no
|
|
26
|
+
* timers of its own.
|
|
27
|
+
*/
|
|
28
|
+
export declare const nextReleaseLocal: (options?: ClockOptions & LocalTimeOptions) => NextRelease;
|
|
29
|
+
export type DayWatcher = {
|
|
30
|
+
/** Compares the date with the last one seen, and reports a change. Call it at safe points. */
|
|
31
|
+
check(): void;
|
|
32
|
+
/** Stops listening. Idempotent. */
|
|
33
|
+
stop(): void;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Tells the frame when the puzzle date changes: on `visibilitychange` (a tab brought back
|
|
37
|
+
* after the release) and whenever the frame calls `check` at a safe point. It never
|
|
38
|
+
* interrupts a game; the frame decides when to offer the new day.
|
|
39
|
+
*/
|
|
40
|
+
export declare const watchDay: (onChange: (date: string, previous: string) => void, options?: ClockOptions & {
|
|
41
|
+
readonly target?: Pick<Document, "addEventListener" | "removeEventListener">;
|
|
42
|
+
}) => DayWatcher;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { untilNextDay, utcDate } from "../dates.js";
|
|
2
|
+
import { countdownText, localTimeText } from "../local-time.js";
|
|
3
|
+
import { nextRelease } from "../release.js";
|
|
4
|
+
//#region src/browser/clock.ts
|
|
5
|
+
/** Today's puzzle date, `yyyy-mm-dd`: the UTC date of the device clock. */
|
|
6
|
+
var today = (options = {}) => utcDate((options.now ?? Date.now)());
|
|
7
|
+
/** Milliseconds until the next puzzle comes out, at the start of the next UTC day. */
|
|
8
|
+
var timeUntilNextDay = (options = {}) => untilNextDay((options.now ?? Date.now)());
|
|
9
|
+
/**
|
|
10
|
+
* When the next puzzle comes out, in the device's time zone and locale unless `timeZone` or
|
|
11
|
+
* `locale` says otherwise. Call it again at safe points to refresh the countdown; it sets no
|
|
12
|
+
* timers of its own.
|
|
13
|
+
*/
|
|
14
|
+
var nextReleaseLocal = (options = {}) => {
|
|
15
|
+
const now = (options.now ?? Date.now)();
|
|
16
|
+
const instant = nextRelease(now);
|
|
17
|
+
const inMs = instant - now;
|
|
18
|
+
return {
|
|
19
|
+
date: utcDate(instant),
|
|
20
|
+
instant,
|
|
21
|
+
inMs,
|
|
22
|
+
time: localTimeText(instant, options),
|
|
23
|
+
countdown: countdownText(inMs, options.locale)
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Tells the frame when the puzzle date changes: on `visibilitychange` (a tab brought back
|
|
28
|
+
* after the release) and whenever the frame calls `check` at a safe point. It never
|
|
29
|
+
* interrupts a game; the frame decides when to offer the new day.
|
|
30
|
+
*/
|
|
31
|
+
var watchDay = (onChange, options = {}) => {
|
|
32
|
+
const target = options.target ?? document;
|
|
33
|
+
let seen = today(options);
|
|
34
|
+
let stopped = false;
|
|
35
|
+
const check = () => {
|
|
36
|
+
if (stopped) return;
|
|
37
|
+
const date = today(options);
|
|
38
|
+
if (date === seen) return;
|
|
39
|
+
const previous = seen;
|
|
40
|
+
seen = date;
|
|
41
|
+
onChange(date, previous);
|
|
42
|
+
};
|
|
43
|
+
target.addEventListener("visibilitychange", check);
|
|
44
|
+
return {
|
|
45
|
+
check,
|
|
46
|
+
stop: () => {
|
|
47
|
+
if (stopped) return;
|
|
48
|
+
stopped = true;
|
|
49
|
+
target.removeEventListener("visibilitychange", check);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { nextReleaseLocal, timeUntilNextDay, today, watchDay };
|
|
55
|
+
|
|
56
|
+
//# sourceMappingURL=clock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clock.js","names":[],"sources":["../../src/browser/clock.ts"],"sourcesContent":["// Today from the device clock, the next release in the player's own time, and a watcher that\n// notices when the puzzle date changes. The date is the UTC date (D-46); only the times shown\n// to the player are local.\n\nimport { untilNextDay, utcDate } from '../dates.ts';\nimport { countdownText, localTimeText, type LocalTimeOptions } from '../local-time.ts';\nimport { nextRelease } from '../release.ts';\n\nexport type ClockOptions = {\n /** Epoch milliseconds. Defaults to `Date.now`. */\n readonly now?: () => number;\n};\n\n/** Today's puzzle date, `yyyy-mm-dd`: the UTC date of the device clock. */\nexport const today = (options: ClockOptions = {}): string => utcDate((options.now ?? Date.now)());\n\n/** Milliseconds until the next puzzle comes out, at the start of the next UTC day. */\nexport const timeUntilNextDay = (options: ClockOptions = {}): number => untilNextDay((options.now ?? Date.now)());\n\n/** The next release, ready to show: \"New puzzle at 8:00 PM (in 6 hours)\". */\nexport type NextRelease = {\n /** The date of the next puzzle, `yyyy-mm-dd`. */\n readonly date: string;\n /** When it comes out, in epoch milliseconds. */\n readonly instant: number;\n /** Milliseconds from now until then. */\n readonly inMs: number;\n /** The release time in the player's zone, such as \"8:00 PM\". */\n readonly time: string;\n /** A plain countdown, such as \"in 6 hours\". */\n readonly countdown: string;\n};\n\n/**\n * When the next puzzle comes out, in the device's time zone and locale unless `timeZone` or\n * `locale` says otherwise. Call it again at safe points to refresh the countdown; it sets no\n * timers of its own.\n */\nexport const nextReleaseLocal = (options: ClockOptions & LocalTimeOptions = {}): NextRelease => {\n const now = (options.now ?? Date.now)();\n const instant = nextRelease(now);\n const inMs = instant - now;\n return {\n date: utcDate(instant),\n instant,\n inMs,\n time: localTimeText(instant, options),\n countdown: countdownText(inMs, options.locale),\n };\n};\n\nexport type DayWatcher = {\n /** Compares the date with the last one seen, and reports a change. Call it at safe points. */\n check(): void;\n /** Stops listening. Idempotent. */\n stop(): void;\n};\n\n/**\n * Tells the frame when the puzzle date changes: on `visibilitychange` (a tab brought back\n * after the release) and whenever the frame calls `check` at a safe point. It never\n * interrupts a game; the frame decides when to offer the new day.\n */\nexport const watchDay = (\n onChange: (date: string, previous: string) => void,\n options: ClockOptions & { readonly target?: Pick<Document, 'addEventListener' | 'removeEventListener'> } = {},\n): DayWatcher => {\n const target = options.target ?? document;\n let seen = today(options);\n let stopped = false;\n const check = (): void => {\n if (stopped) return;\n const date = today(options);\n if (date === seen) return;\n const previous = seen;\n seen = date;\n onChange(date, previous);\n };\n target.addEventListener('visibilitychange', check);\n return {\n check,\n stop: () => {\n if (stopped) return;\n stopped = true;\n target.removeEventListener('visibilitychange', check);\n },\n };\n};\n"],"mappings":";;;;;AAcA,IAAa,SAAS,UAAwB,CAAC,MAAc,SAAS,QAAQ,OAAO,KAAK,IAAA,CAAK,CAAC;;AAGhG,IAAa,oBAAoB,UAAwB,CAAC,MAAc,cAAc,QAAQ,OAAO,KAAK,IAAA,CAAK,CAAC;;;;;;AAqBhH,IAAa,oBAAoB,UAA2C,CAAC,MAAmB;CAC9F,MAAM,OAAO,QAAQ,OAAO,KAAK,IAAA,CAAK;CACtC,MAAM,UAAU,YAAY,GAAG;CAC/B,MAAM,OAAO,UAAU;CACvB,OAAO;EACL,MAAM,QAAQ,OAAO;EACrB;EACA;EACA,MAAM,cAAc,SAAS,OAAO;EACpC,WAAW,cAAc,MAAM,QAAQ,MAAM;CAC/C;AACF;;;;;;AAcA,IAAa,YACX,UACA,UAA2G,CAAC,MAC7F;CACf,MAAM,SAAS,QAAQ,UAAU;CACjC,IAAI,OAAO,MAAM,OAAO;CACxB,IAAI,UAAU;CACd,MAAM,cAAoB;EACxB,IAAI,SAAS;EACb,MAAM,OAAO,MAAM,OAAO;EAC1B,IAAI,SAAS,MAAM;EACnB,MAAM,WAAW;EACjB,OAAO;EACP,SAAS,MAAM,QAAQ;CACzB;CACA,OAAO,iBAAiB,oBAAoB,KAAK;CACjD,OAAO;EACL;EACA,YAAY;GACV,IAAI,SAAS;GACb,UAAU;GACV,OAAO,oBAAoB,oBAAoB,KAAK;EACtD;CACF;AACF"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type Json, type Result, type Validator } from '@joystyk/contracts';
|
|
2
|
+
import { type Storage } from '@joystyk/storage';
|
|
3
|
+
import { type DailyEnvelope, type DailyIndex } from '../schemas.js';
|
|
4
|
+
import { type DailyError } from './errors.js';
|
|
5
|
+
/** A downloaded puzzle file, as the cache keeps it. */
|
|
6
|
+
export type CachedPuzzle = {
|
|
7
|
+
readonly hash: string;
|
|
8
|
+
/** The file exactly as served; decoded again on every read. */
|
|
9
|
+
readonly file: Json;
|
|
10
|
+
};
|
|
11
|
+
/** Version 1 of a cached puzzle file. */
|
|
12
|
+
export declare const CACHED_PUZZLE_RECORD: import("@joystyk/storage").RecordKind<CachedPuzzle>;
|
|
13
|
+
/** The cache key prefix for one game's downloaded puzzles. */
|
|
14
|
+
export declare const cachePrefix: (gameId: string) => string;
|
|
15
|
+
/** Just enough of `fetch` for the client; tests pass a fake. */
|
|
16
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
17
|
+
export type DailyClientOptions<Config> = {
|
|
18
|
+
/** Where the site serves daily content: "/daily" or "https://example.com/daily". */
|
|
19
|
+
readonly baseUrl: string;
|
|
20
|
+
readonly gameId: string;
|
|
21
|
+
/** The game's `schemas.config`, which every puzzle file's `config` must pass. */
|
|
22
|
+
readonly config: Validator<Config>;
|
|
23
|
+
readonly storage: Storage;
|
|
24
|
+
/** Defaults to the global `fetch`. */
|
|
25
|
+
readonly fetch?: FetchLike;
|
|
26
|
+
/**
|
|
27
|
+
* Check each downloaded file's content hash with Web Crypto before keeping it. Default true;
|
|
28
|
+
* skipped where `crypto.subtle` is missing (pages served over plain http).
|
|
29
|
+
*/
|
|
30
|
+
readonly verifyHash?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** A decoded puzzle and where it came from. */
|
|
33
|
+
export type DailyPuzzle<Config> = DailyEnvelope<Config> & {
|
|
34
|
+
readonly source: 'network' | 'cache';
|
|
35
|
+
};
|
|
36
|
+
/** What a prefetch did with each date. */
|
|
37
|
+
export type PrefetchReport = {
|
|
38
|
+
/** Downloaded and kept now. */
|
|
39
|
+
readonly fetched: readonly string[];
|
|
40
|
+
/** Already on the device with the current hash. */
|
|
41
|
+
readonly cached: readonly string[];
|
|
42
|
+
/** Not listed in the index yet. */
|
|
43
|
+
readonly unavailable: readonly string[];
|
|
44
|
+
/** Listed, but the download or decode failed. Tried again next time. */
|
|
45
|
+
readonly failed: readonly string[];
|
|
46
|
+
/** Set when the index itself couldn't be loaded; then every date is in `failed`. */
|
|
47
|
+
readonly error: DailyError | null;
|
|
48
|
+
};
|
|
49
|
+
export interface DailyClient<Config> {
|
|
50
|
+
/** Fetches and decodes the game's index, revalidating every time. */
|
|
51
|
+
index(): Promise<Result<DailyIndex, DailyError>>;
|
|
52
|
+
/**
|
|
53
|
+
* The puzzle for `date`. Checks the index; uses the cached file when its hash is current,
|
|
54
|
+
* otherwise downloads, verifies, decodes and caches it. Offline, or when the server fails,
|
|
55
|
+
* it falls back to a cached file only when the index itself is unreachable. A known
|
|
56
|
+
* superseded or unlisted copy is purged, including for subsequent offline reads.
|
|
57
|
+
* Pick `date` with `canStart`; this client doesn't judge which dates a player may start.
|
|
58
|
+
*/
|
|
59
|
+
puzzle(date: string): Promise<Result<DailyPuzzle<Config>, DailyError>>;
|
|
60
|
+
/** The cached puzzle for `date`, decoded, or null. Never touches the network. */
|
|
61
|
+
cached(date: string): Promise<DailyPuzzle<Config> | null>;
|
|
62
|
+
/**
|
|
63
|
+
* Downloads today's puzzle and the next day's (D-46), where the index lists them and the
|
|
64
|
+
* device doesn't hold them yet. The next day's puzzle stays locked until its UTC day begins
|
|
65
|
+
* (`canStart`). The PWA calls this at a safe point for installed clients and pinned games;
|
|
66
|
+
* this package doesn't decide who prefetches.
|
|
67
|
+
*/
|
|
68
|
+
prefetch(today: string): Promise<PrefetchReport>;
|
|
69
|
+
/** Deletes cached puzzles older than `keepPast` days before `today` (default 1: keep yesterday). */
|
|
70
|
+
prune(today: string, keepPast?: number): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
/** Creates the content client for one daily game. */
|
|
73
|
+
export declare const createDailyClient: <Config>(options: DailyClientOptions<Config>) => DailyClient<Config>;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { addDays, isIsoDate } from "../dates.js";
|
|
2
|
+
import { prefetchDates } from "../release.js";
|
|
3
|
+
import { CONTENT_HASH, dailyEnvelope, dailyIndex, decode, json } from "../schemas.js";
|
|
4
|
+
import { dailyError } from "./errors.js";
|
|
5
|
+
import { contentHashOf } from "./hash.js";
|
|
6
|
+
import { err, isGameId, isJson, ok } from "@joystyk/contracts";
|
|
7
|
+
import { defineRecordKind } from "@joystyk/storage";
|
|
8
|
+
import * as z from "zod/mini";
|
|
9
|
+
//#region src/browser/content.ts
|
|
10
|
+
/** Version 1 of a cached puzzle file. */
|
|
11
|
+
var CACHED_PUZZLE_RECORD = defineRecordKind({
|
|
12
|
+
store: "game-data",
|
|
13
|
+
version: 1,
|
|
14
|
+
schema: z.object({
|
|
15
|
+
hash: z.string().check(z.regex(CONTENT_HASH)),
|
|
16
|
+
file: json
|
|
17
|
+
}),
|
|
18
|
+
label: "downloaded daily puzzle"
|
|
19
|
+
});
|
|
20
|
+
/** The cache key prefix for one game's downloaded puzzles. */
|
|
21
|
+
var cachePrefix = (gameId) => `~daily/${gameId}/`;
|
|
22
|
+
var trimSlash = (url) => url.replace(/\/+$/, "");
|
|
23
|
+
var checkDate = (date) => {
|
|
24
|
+
if (!isIsoDate(date)) throw new RangeError(`Expected a "yyyy-mm-dd" date, got ${JSON.stringify(date)}.`);
|
|
25
|
+
};
|
|
26
|
+
/** Creates the content client for one daily game. */
|
|
27
|
+
var createDailyClient = (options) => {
|
|
28
|
+
const { gameId, storage } = options;
|
|
29
|
+
if (!isGameId(gameId)) throw new RangeError(`Expected a kebab-case game id, got ${JSON.stringify(gameId)}.`);
|
|
30
|
+
const base = `${trimSlash(options.baseUrl)}/${gameId}`;
|
|
31
|
+
const fetchJson = options.fetch ?? ((url, init) => fetch(url, init));
|
|
32
|
+
const verify = options.verifyHash ?? true;
|
|
33
|
+
const cache = storage.records(CACHED_PUZZLE_RECORD);
|
|
34
|
+
const keyOf = (date) => `${cachePrefix(gameId)}${date}`;
|
|
35
|
+
const getJson = async (url, init) => {
|
|
36
|
+
let response;
|
|
37
|
+
try {
|
|
38
|
+
response = await fetchJson(url, {
|
|
39
|
+
...init,
|
|
40
|
+
credentials: "omit",
|
|
41
|
+
referrerPolicy: "no-referrer"
|
|
42
|
+
});
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
return err(dailyError("Offline", String(cause)));
|
|
45
|
+
}
|
|
46
|
+
if (response.status === 404) return err(dailyError("NotPublished", `${url}: 404`));
|
|
47
|
+
if (!response.ok) return err(dailyError("Failed", `${url}: HTTP ${response.status}`));
|
|
48
|
+
try {
|
|
49
|
+
return ok(await response.json());
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
return err(dailyError("Invalid", `${url}: ${String(cause)}`));
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const index = async () => {
|
|
55
|
+
const url = `${base}/index.json`;
|
|
56
|
+
const raw = await getJson(url, { cache: "no-cache" });
|
|
57
|
+
if (!raw.ok) return raw;
|
|
58
|
+
const decoded = await decode(dailyIndex, raw.value);
|
|
59
|
+
if (!decoded.ok) return err(dailyError("Invalid", [url, ...decoded.error]));
|
|
60
|
+
if (decoded.value.gameId !== gameId) return err(dailyError("Invalid", [url, `index is for ${decoded.value.gameId}`]));
|
|
61
|
+
return decoded;
|
|
62
|
+
};
|
|
63
|
+
const decodeFile = async (raw, date, hash) => decode(dailyEnvelope(options.config, {
|
|
64
|
+
gameId,
|
|
65
|
+
date,
|
|
66
|
+
contentHash: hash
|
|
67
|
+
}), raw);
|
|
68
|
+
const cached = async (date) => {
|
|
69
|
+
checkDate(date);
|
|
70
|
+
const record = await cache.get(keyOf(date));
|
|
71
|
+
if (!record.ok || record.value === null) return null;
|
|
72
|
+
const decoded = await decodeFile(record.value.file, date, record.value.hash);
|
|
73
|
+
if (!decoded.ok) {
|
|
74
|
+
await cache.delete(keyOf(date));
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
...decoded.value,
|
|
79
|
+
source: "cache"
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const download = async (date, hash) => {
|
|
83
|
+
const url = `${base}/${date}.${hash}.json`;
|
|
84
|
+
const raw = await getJson(url, {});
|
|
85
|
+
if (!raw.ok) return raw.error.code === "NotPublished" ? err(dailyError("Failed", raw.error.detail)) : raw;
|
|
86
|
+
const decoded = await decodeFile(raw.value, date, hash);
|
|
87
|
+
if (!decoded.ok || !isJson(raw.value)) return err(dailyError("Invalid", [url, ...decoded.ok ? [] : decoded.error]));
|
|
88
|
+
if (verify) {
|
|
89
|
+
const actual = await contentHashOf(raw.value.config);
|
|
90
|
+
if (actual !== null && actual !== hash) return err(dailyError("Invalid", [url, `content hash is ${actual}`]));
|
|
91
|
+
}
|
|
92
|
+
await cache.set(keyOf(date), {
|
|
93
|
+
hash,
|
|
94
|
+
file: raw.value
|
|
95
|
+
});
|
|
96
|
+
return ok({
|
|
97
|
+
...decoded.value,
|
|
98
|
+
source: "network"
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const load = async (date, listing, held) => {
|
|
102
|
+
const entry = listing.dates.find((candidate) => candidate.date === date);
|
|
103
|
+
if (entry === void 0) {
|
|
104
|
+
await cache.delete(keyOf(date));
|
|
105
|
+
return err(dailyError("NotPublished", `${gameId} ${date}`));
|
|
106
|
+
}
|
|
107
|
+
if (held !== null && held.contentHash === entry.hash) return ok(held);
|
|
108
|
+
if (held !== null) await cache.delete(keyOf(date));
|
|
109
|
+
return download(date, entry.hash);
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
index,
|
|
113
|
+
cached,
|
|
114
|
+
puzzle: async (date) => {
|
|
115
|
+
checkDate(date);
|
|
116
|
+
const held = await cached(date);
|
|
117
|
+
const listing = await index();
|
|
118
|
+
if (!listing.ok) return held === null ? listing : ok(held);
|
|
119
|
+
return load(date, listing.value, held);
|
|
120
|
+
},
|
|
121
|
+
prefetch: async (today) => {
|
|
122
|
+
checkDate(today);
|
|
123
|
+
const dates = prefetchDates(today);
|
|
124
|
+
const report = {
|
|
125
|
+
fetched: [],
|
|
126
|
+
cached: [],
|
|
127
|
+
unavailable: [],
|
|
128
|
+
failed: []
|
|
129
|
+
};
|
|
130
|
+
const listing = await index();
|
|
131
|
+
if (!listing.ok) return {
|
|
132
|
+
...report,
|
|
133
|
+
failed: [...dates],
|
|
134
|
+
error: listing.error
|
|
135
|
+
};
|
|
136
|
+
for (const date of dates) {
|
|
137
|
+
const entry = listing.value.dates.find((candidate) => candidate.date === date);
|
|
138
|
+
if (entry === void 0) {
|
|
139
|
+
await cache.delete(keyOf(date));
|
|
140
|
+
report.unavailable.push(date);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if ((await cached(date))?.contentHash === entry.hash) {
|
|
144
|
+
report.cached.push(date);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
await cache.delete(keyOf(date));
|
|
148
|
+
((await download(date, entry.hash)).ok ? report.fetched : report.failed).push(date);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...report,
|
|
152
|
+
error: null
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
prune: async (today, keepPast = 1) => {
|
|
156
|
+
checkDate(today);
|
|
157
|
+
const oldest = addDays(today, -keepPast);
|
|
158
|
+
const keys = await cache.keys(cachePrefix(gameId));
|
|
159
|
+
if (!keys.ok) return;
|
|
160
|
+
for (const key of keys.value) {
|
|
161
|
+
const date = key.slice(cachePrefix(gameId).length);
|
|
162
|
+
if (!isIsoDate(date) || date < oldest) await cache.delete(key);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
//#endregion
|
|
168
|
+
export { CACHED_PUZZLE_RECORD, cachePrefix, createDailyClient };
|
|
169
|
+
|
|
170
|
+
//# sourceMappingURL=content.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content.js","names":[],"sources":["../../src/browser/content.ts"],"sourcesContent":["// The content client: fetch a game's index and puzzle files from the site, decode them through\n// the game's schema, and keep them on the device so a downloaded day plays offline\n// (content-pipeline.md, \"Delivery\" and \"Prefetching for offline play\").\n//\n// Downloaded puzzles are kept as raw JSON in the storage package's `game-data` store, under\n// `~daily/<game>/<date>`. The `~` can't start a player id, so these keys never meet a player's\n// own records. Every read, from the network or from the cache, decodes the file again.\n\nimport { err, isGameId, isJson, ok, type Json, type Result, type Validator } from '@joystyk/contracts';\nimport { defineRecordKind, type RecordStore, type Storage } from '@joystyk/storage';\nimport * as z from 'zod/mini';\nimport { addDays, isIsoDate } from '../dates.ts';\nimport { prefetchDates } from '../release.ts';\nimport {\n CONTENT_HASH,\n dailyEnvelope,\n dailyIndex,\n decode,\n json,\n type DailyEnvelope,\n type DailyIndex,\n} from '../schemas.ts';\nimport { dailyError, type DailyError } from './errors.ts';\nimport { contentHashOf } from './hash.ts';\n\n/** A downloaded puzzle file, as the cache keeps it. */\nexport type CachedPuzzle = {\n readonly hash: string;\n /** The file exactly as served; decoded again on every read. */\n readonly file: Json;\n};\n\n/** Version 1 of a cached puzzle file. */\nexport const CACHED_PUZZLE_RECORD = defineRecordKind<CachedPuzzle>({\n store: 'game-data',\n version: 1,\n schema: z.object({ hash: z.string().check(z.regex(CONTENT_HASH)), file: json }),\n label: 'downloaded daily puzzle',\n});\n\n/** The cache key prefix for one game's downloaded puzzles. */\nexport const cachePrefix = (gameId: string): string => `~daily/${gameId}/`;\n\n/** Just enough of `fetch` for the client; tests pass a fake. */\nexport type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;\n\nexport type DailyClientOptions<Config> = {\n /** Where the site serves daily content: \"/daily\" or \"https://example.com/daily\". */\n readonly baseUrl: string;\n readonly gameId: string;\n /** The game's `schemas.config`, which every puzzle file's `config` must pass. */\n readonly config: Validator<Config>;\n readonly storage: Storage;\n /** Defaults to the global `fetch`. */\n readonly fetch?: FetchLike;\n /**\n * Check each downloaded file's content hash with Web Crypto before keeping it. Default true;\n * skipped where `crypto.subtle` is missing (pages served over plain http).\n */\n readonly verifyHash?: boolean;\n};\n\n/** A decoded puzzle and where it came from. */\nexport type DailyPuzzle<Config> = DailyEnvelope<Config> & { readonly source: 'network' | 'cache' };\n\n/** What a prefetch did with each date. */\nexport type PrefetchReport = {\n /** Downloaded and kept now. */\n readonly fetched: readonly string[];\n /** Already on the device with the current hash. */\n readonly cached: readonly string[];\n /** Not listed in the index yet. */\n readonly unavailable: readonly string[];\n /** Listed, but the download or decode failed. Tried again next time. */\n readonly failed: readonly string[];\n /** Set when the index itself couldn't be loaded; then every date is in `failed`. */\n readonly error: DailyError | null;\n};\n\nexport interface DailyClient<Config> {\n /** Fetches and decodes the game's index, revalidating every time. */\n index(): Promise<Result<DailyIndex, DailyError>>;\n /**\n * The puzzle for `date`. Checks the index; uses the cached file when its hash is current,\n * otherwise downloads, verifies, decodes and caches it. Offline, or when the server fails,\n * it falls back to a cached file only when the index itself is unreachable. A known\n * superseded or unlisted copy is purged, including for subsequent offline reads.\n * Pick `date` with `canStart`; this client doesn't judge which dates a player may start.\n */\n puzzle(date: string): Promise<Result<DailyPuzzle<Config>, DailyError>>;\n /** The cached puzzle for `date`, decoded, or null. Never touches the network. */\n cached(date: string): Promise<DailyPuzzle<Config> | null>;\n /**\n * Downloads today's puzzle and the next day's (D-46), where the index lists them and the\n * device doesn't hold them yet. The next day's puzzle stays locked until its UTC day begins\n * (`canStart`). The PWA calls this at a safe point for installed clients and pinned games;\n * this package doesn't decide who prefetches.\n */\n prefetch(today: string): Promise<PrefetchReport>;\n /** Deletes cached puzzles older than `keepPast` days before `today` (default 1: keep yesterday). */\n prune(today: string, keepPast?: number): Promise<void>;\n}\n\nconst trimSlash = (url: string): string => url.replace(/\\/+$/, '');\n\nconst checkDate = (date: string): void => {\n if (!isIsoDate(date)) throw new RangeError(`Expected a \"yyyy-mm-dd\" date, got ${JSON.stringify(date)}.`);\n};\n\n/** Creates the content client for one daily game. */\nexport const createDailyClient = <Config>(options: DailyClientOptions<Config>): DailyClient<Config> => {\n const { gameId, storage } = options;\n if (!isGameId(gameId)) throw new RangeError(`Expected a kebab-case game id, got ${JSON.stringify(gameId)}.`);\n const base = `${trimSlash(options.baseUrl)}/${gameId}`;\n const fetchJson = options.fetch ?? ((url, init) => fetch(url, init));\n const verify = options.verifyHash ?? true;\n const cache: RecordStore<CachedPuzzle> = storage.records(CACHED_PUZZLE_RECORD);\n const keyOf = (date: string): string => `${cachePrefix(gameId)}${date}`;\n\n // No cookies, identifiers or query strings: only the path (content-pipeline.md, \"Delivery\").\n const getJson = async (url: string, init: RequestInit): Promise<Result<unknown, DailyError>> => {\n let response: Response;\n try {\n response = await fetchJson(url, { ...init, credentials: 'omit', referrerPolicy: 'no-referrer' });\n } catch (cause) {\n return err(dailyError('Offline', String(cause)));\n }\n if (response.status === 404) return err(dailyError('NotPublished', `${url}: 404`));\n if (!response.ok) return err(dailyError('Failed', `${url}: HTTP ${response.status}`));\n try {\n return ok(await response.json());\n } catch (cause) {\n return err(dailyError('Invalid', `${url}: ${String(cause)}`));\n }\n };\n\n const index = async (): Promise<Result<DailyIndex, DailyError>> => {\n const url = `${base}/index.json`;\n const raw = await getJson(url, { cache: 'no-cache' });\n if (!raw.ok) return raw;\n const decoded = await decode(dailyIndex, raw.value);\n if (!decoded.ok) return err(dailyError('Invalid', [url, ...decoded.error]));\n if (decoded.value.gameId !== gameId)\n return err(dailyError('Invalid', [url, `index is for ${decoded.value.gameId}`]));\n return decoded;\n };\n\n const decodeFile = async (raw: unknown, date: string, hash: string) =>\n decode(dailyEnvelope(options.config, { gameId, date, contentHash: hash }), raw);\n\n const cached = async (date: string): Promise<DailyPuzzle<Config> | null> => {\n checkDate(date);\n const record = await cache.get(keyOf(date));\n if (!record.ok || record.value === null) return null;\n const decoded = await decodeFile(record.value.file, date, record.value.hash);\n if (!decoded.ok) {\n // Usually a newer game build whose config schema changed; the next load downloads it again.\n await cache.delete(keyOf(date));\n return null;\n }\n return { ...decoded.value, source: 'cache' };\n };\n\n const download = async (date: string, hash: string): Promise<Result<DailyPuzzle<Config>, DailyError>> => {\n const url = `${base}/${date}.${hash}.json`;\n const raw = await getJson(url, {});\n if (!raw.ok) return raw.error.code === 'NotPublished' ? err(dailyError('Failed', raw.error.detail)) : raw;\n const decoded = await decodeFile(raw.value, date, hash);\n if (!decoded.ok || !isJson(raw.value))\n return err(dailyError('Invalid', [url, ...(decoded.ok ? [] : decoded.error)]));\n if (verify) {\n const actual = await contentHashOf((raw.value as { config: Json }).config);\n if (actual !== null && actual !== hash) return err(dailyError('Invalid', [url, `content hash is ${actual}`]));\n }\n // A failed write is reported by storage itself; the puzzle still plays from memory.\n await cache.set(keyOf(date), { hash, file: raw.value });\n return ok({ ...decoded.value, source: 'network' });\n };\n\n const load = async (\n date: string,\n listing: DailyIndex,\n held: DailyPuzzle<Config> | null,\n ): Promise<Result<DailyPuzzle<Config>, DailyError>> => {\n const entry = listing.dates.find((candidate) => candidate.date === date);\n if (entry === undefined) {\n await cache.delete(keyOf(date));\n return err(dailyError('NotPublished', `${gameId} ${date}`));\n }\n if (held !== null && held.contentHash === entry.hash) return ok(held);\n // The index revoked this version. Do not resurrect it if the new download fails.\n if (held !== null) await cache.delete(keyOf(date));\n return download(date, entry.hash);\n };\n\n return {\n index,\n cached,\n puzzle: async (date) => {\n checkDate(date);\n const held = await cached(date);\n const listing = await index();\n if (!listing.ok) return held === null ? listing : ok(held);\n return load(date, listing.value, held);\n },\n prefetch: async (today) => {\n checkDate(today);\n const dates = prefetchDates(today);\n const report = {\n fetched: [] as string[],\n cached: [] as string[],\n unavailable: [] as string[],\n failed: [] as string[],\n };\n const listing = await index();\n if (!listing.ok) return { ...report, failed: [...dates], error: listing.error };\n for (const date of dates) {\n const entry = listing.value.dates.find((candidate) => candidate.date === date);\n if (entry === undefined) {\n await cache.delete(keyOf(date));\n report.unavailable.push(date);\n continue;\n }\n if ((await cached(date))?.contentHash === entry.hash) {\n report.cached.push(date);\n continue;\n }\n await cache.delete(keyOf(date));\n const fetched = await download(date, entry.hash);\n (fetched.ok ? report.fetched : report.failed).push(date);\n }\n return { ...report, error: null };\n },\n prune: async (today, keepPast = 1) => {\n checkDate(today);\n const oldest = addDays(today, -keepPast);\n const keys = await cache.keys(cachePrefix(gameId));\n if (!keys.ok) return;\n for (const key of keys.value) {\n const date = key.slice(cachePrefix(gameId).length);\n if (!isIsoDate(date) || date < oldest) await cache.delete(key);\n }\n },\n };\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { GameError, Json } from '@joystyk/contracts';
|
|
2
|
+
/** Why a daily puzzle couldn't be loaded. */
|
|
3
|
+
export type DailyErrorCode =
|
|
4
|
+
/** The network is unreachable and nothing is cached for that date. */
|
|
5
|
+
'Offline'
|
|
6
|
+
/** The game's index doesn't list that date (not out yet, or no daily content at all). */
|
|
7
|
+
| 'NotPublished'
|
|
8
|
+
/** The server sent something that isn't a valid puzzle for that date. */
|
|
9
|
+
| 'Invalid'
|
|
10
|
+
/** Anything else, such as a server error. */
|
|
11
|
+
| 'Failed';
|
|
12
|
+
/** An expected daily failure, worded for players. Shaped like `GameError`. */
|
|
13
|
+
export type DailyError = GameError & {
|
|
14
|
+
readonly code: DailyErrorCode;
|
|
15
|
+
};
|
|
16
|
+
/** Player-facing wording for each failure (content-pipeline.md, "Prefetching for offline play"). */
|
|
17
|
+
export declare const DAILY_MESSAGES: {
|
|
18
|
+
readonly Offline: "This puzzle needs a connection. It will download next time you're online.";
|
|
19
|
+
readonly NotPublished: "This puzzle isn't out yet.";
|
|
20
|
+
readonly Invalid: "This puzzle couldn't be opened. Please try again later.";
|
|
21
|
+
readonly Failed: "Something went wrong while loading the puzzle. Please try again later.";
|
|
22
|
+
};
|
|
23
|
+
export declare const dailyError: (code: DailyErrorCode, detail?: Json) => DailyError;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/browser/errors.ts
|
|
2
|
+
/** Player-facing wording for each failure (content-pipeline.md, "Prefetching for offline play"). */
|
|
3
|
+
var DAILY_MESSAGES = {
|
|
4
|
+
Offline: "This puzzle needs a connection. It will download next time you're online.",
|
|
5
|
+
NotPublished: "This puzzle isn't out yet.",
|
|
6
|
+
Invalid: "This puzzle couldn't be opened. Please try again later.",
|
|
7
|
+
Failed: "Something went wrong while loading the puzzle. Please try again later."
|
|
8
|
+
};
|
|
9
|
+
var dailyError = (code, detail) => detail === void 0 ? {
|
|
10
|
+
code,
|
|
11
|
+
message: DAILY_MESSAGES[code]
|
|
12
|
+
} : {
|
|
13
|
+
code,
|
|
14
|
+
message: DAILY_MESSAGES[code],
|
|
15
|
+
detail
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { DAILY_MESSAGES, dailyError };
|
|
19
|
+
|
|
20
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","names":[],"sources":["../../src/browser/errors.ts"],"sourcesContent":["import type { GameError, Json } from '@joystyk/contracts';\n\n/** Why a daily puzzle couldn't be loaded. */\nexport type DailyErrorCode =\n /** The network is unreachable and nothing is cached for that date. */\n | 'Offline'\n /** The game's index doesn't list that date (not out yet, or no daily content at all). */\n | 'NotPublished'\n /** The server sent something that isn't a valid puzzle for that date. */\n | 'Invalid'\n /** Anything else, such as a server error. */\n | 'Failed';\n\n/** An expected daily failure, worded for players. Shaped like `GameError`. */\nexport type DailyError = GameError & { readonly code: DailyErrorCode };\n\n/** Player-facing wording for each failure (content-pipeline.md, \"Prefetching for offline play\"). */\nexport const DAILY_MESSAGES = {\n Offline: \"This puzzle needs a connection. It will download next time you're online.\",\n NotPublished: \"This puzzle isn't out yet.\",\n Invalid: \"This puzzle couldn't be opened. Please try again later.\",\n Failed: 'Something went wrong while loading the puzzle. Please try again later.',\n} as const satisfies Record<DailyErrorCode, string>;\n\nexport const dailyError = (code: DailyErrorCode, detail?: Json): DailyError =>\n detail === undefined ? { code, message: DAILY_MESSAGES[code] } : { code, message: DAILY_MESSAGES[code], detail };\n"],"mappings":";;AAiBA,IAAa,iBAAiB;CAC5B,SAAS;CACT,cAAc;CACd,SAAS;CACT,QAAQ;AACV;AAEA,IAAa,cAAc,MAAsB,WAC/C,WAAW,KAAA,IAAY;CAAE;CAAM,SAAS,eAAe;AAAM,IAAI;CAAE;CAAM,SAAS,eAAe;CAAO;AAAO"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Json } from '@joystyk/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* The content hash of a puzzle's `config`: the first 12 hex digits of SHA-256 over its
|
|
4
|
+
* canonical JSON. Null where Web Crypto's `subtle` is missing (an insecure context).
|
|
5
|
+
*/
|
|
6
|
+
export declare const contentHashOf: (config: Json) => Promise<string | null>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { canonicalJson } from "../schemas.js";
|
|
2
|
+
//#region src/browser/hash.ts
|
|
3
|
+
/**
|
|
4
|
+
* The content hash of a puzzle's `config`: the first 12 hex digits of SHA-256 over its
|
|
5
|
+
* canonical JSON. Null where Web Crypto's `subtle` is missing (an insecure context).
|
|
6
|
+
*/
|
|
7
|
+
var contentHashOf = async (config) => {
|
|
8
|
+
const subtle = globalThis.crypto?.subtle;
|
|
9
|
+
if (subtle === void 0) return null;
|
|
10
|
+
const digest = await subtle.digest("SHA-256", new TextEncoder().encode(canonicalJson(config)));
|
|
11
|
+
return Array.from(new Uint8Array(digest).slice(0, 6), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
export { contentHashOf };
|
|
15
|
+
|
|
16
|
+
//# sourceMappingURL=hash.js.map
|