@joystyk/daily 0.0.0-stage → 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/LICENSE +21 -0
- package/NOTICE +36 -0
- package/README.md +156 -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 +53 -3
|
@@ -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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hash.js","names":[],"sources":["../../src/browser/hash.ts"],"sourcesContent":["import type { Json } from '@joystyk/contracts';\nimport { canonicalJson } from '../schemas.ts';\n\n/**\n * The content hash of a puzzle's `config`: the first 12 hex digits of SHA-256 over its\n * canonical JSON. Null where Web Crypto's `subtle` is missing (an insecure context).\n */\nexport const contentHashOf = async (config: Json): Promise<string | null> => {\n const subtle = globalThis.crypto?.subtle as SubtleCrypto | undefined;\n if (subtle === undefined) return null;\n const digest = await subtle.digest('SHA-256', new TextEncoder().encode(canonicalJson(config)));\n return Array.from(new Uint8Array(digest).slice(0, 6), (byte) => byte.toString(16).padStart(2, '0')).join('');\n};\n"],"mappings":";;;;;;AAOA,IAAa,gBAAgB,OAAO,WAAyC;CAC3E,MAAM,SAAS,WAAW,QAAQ;CAClC,IAAI,WAAW,KAAA,GAAW,OAAO;CACjC,MAAM,SAAS,MAAM,OAAO,OAAO,WAAW,IAAI,YAAY,CAAC,CAAC,OAAO,cAAc,MAAM,CAAC,CAAC;CAC7F,OAAO,MAAM,KAAK,IAAI,WAAW,MAAM,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;AAC7G"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { nextReleaseLocal, timeUntilNextDay, today, watchDay, type ClockOptions, type DayWatcher, type NextRelease, } from './clock.js';
|
|
2
|
+
export { CACHED_PUZZLE_RECORD, cachePrefix, createDailyClient, type CachedPuzzle, type DailyClient, type DailyClientOptions, type DailyPuzzle, type FetchLike, type PrefetchReport, } from './content.js';
|
|
3
|
+
export { DAILY_MESSAGES, dailyError, type DailyError, type DailyErrorCode } from './errors.js';
|
|
4
|
+
export { contentHashOf } from './hash.js';
|
|
5
|
+
export { createDailyResults, DAILY_RESULT_RECORD, type DailyResults, type Recorded } from './results.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { nextReleaseLocal, timeUntilNextDay, today, watchDay } from "./clock.js";
|
|
2
|
+
import { DAILY_MESSAGES, dailyError } from "./errors.js";
|
|
3
|
+
import { contentHashOf } from "./hash.js";
|
|
4
|
+
import { CACHED_PUZZLE_RECORD, cachePrefix, createDailyClient } from "./content.js";
|
|
5
|
+
import { DAILY_RESULT_RECORD, createDailyResults } from "./results.js";
|
|
6
|
+
export { CACHED_PUZZLE_RECORD, DAILY_MESSAGES, DAILY_RESULT_RECORD, cachePrefix, contentHashOf, createDailyClient, createDailyResults, dailyError, nextReleaseLocal, timeUntilNextDay, today, watchDay };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Result } from '@joystyk/contracts';
|
|
2
|
+
import { type Storage, type StorageError } from '@joystyk/storage';
|
|
3
|
+
import { type DailyResult } from '../schemas.js';
|
|
4
|
+
import { type Streaks } from '../streaks.js';
|
|
5
|
+
/** Version 1 of a stored daily result. */
|
|
6
|
+
export declare const DAILY_RESULT_RECORD: import("@joystyk/storage").RecordKind<DailyResult>;
|
|
7
|
+
/** What `record` did. */
|
|
8
|
+
export type Recorded = {
|
|
9
|
+
/** The result now stored: the new one, or the earlier one when this puzzle was already finished. */
|
|
10
|
+
readonly result: DailyResult;
|
|
11
|
+
/** False when a result for this puzzle and variant already existed; it is kept as it was. */
|
|
12
|
+
readonly isNew: boolean;
|
|
13
|
+
readonly streaks: Streaks;
|
|
14
|
+
};
|
|
15
|
+
/** One player's daily results and streaks. */
|
|
16
|
+
export interface DailyResults {
|
|
17
|
+
/**
|
|
18
|
+
* Keeps a finished puzzle's result, unless one already exists for the same puzzle and
|
|
19
|
+
* variant (a finished result stays as it was), then returns the game's streaks for `today`.
|
|
20
|
+
*/
|
|
21
|
+
record(result: DailyResult, today: string): Promise<Result<Recorded, StorageError>>;
|
|
22
|
+
/** The result for a puzzle and variant, or null. */
|
|
23
|
+
get(puzzleId: string, variant: string | null): Promise<Result<DailyResult | null, StorageError>>;
|
|
24
|
+
/** Every result kept for a game, oldest puzzle first. */
|
|
25
|
+
list(gameId: string): Promise<Result<readonly DailyResult[], StorageError>>;
|
|
26
|
+
/**
|
|
27
|
+
* The game's streaks on `today`, today's UTC date, computed from the results.
|
|
28
|
+
* Also writes the streak records, so a missed date resets the stored current streak the
|
|
29
|
+
* next time the player opens the game. A stored best higher than the computed one is kept.
|
|
30
|
+
*/
|
|
31
|
+
streaks(gameId: string, today: string): Promise<Result<Streaks, StorageError>>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The results store for one player. Throws a `RangeError` for a player id that can't be part
|
|
35
|
+
* of a storage key, as `storage.player` does.
|
|
36
|
+
*/
|
|
37
|
+
export declare const createDailyResults: (storage: Storage, playerId: string) => DailyResults;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { parsePuzzleId } from "../ids.js";
|
|
2
|
+
import { dailyResult } from "../schemas.js";
|
|
3
|
+
import { streaks } from "../streaks.js";
|
|
4
|
+
import { isGameId, ok } from "@joystyk/contracts";
|
|
5
|
+
import { EMPTY_STREAK, STREAK_RECORD, defineRecordKind } from "@joystyk/storage";
|
|
6
|
+
//#region src/browser/results.ts
|
|
7
|
+
/** Version 1 of a stored daily result. */
|
|
8
|
+
var DAILY_RESULT_RECORD = defineRecordKind({
|
|
9
|
+
store: "game-data",
|
|
10
|
+
version: 1,
|
|
11
|
+
schema: dailyResult,
|
|
12
|
+
label: "daily result"
|
|
13
|
+
});
|
|
14
|
+
var checkGame = (gameId) => {
|
|
15
|
+
if (!isGameId(gameId)) throw new RangeError(`Expected a kebab-case game id, got ${JSON.stringify(gameId)}.`);
|
|
16
|
+
return gameId;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The results store for one player. Throws a `RangeError` for a player id that can't be part
|
|
20
|
+
* of a storage key, as `storage.player` does.
|
|
21
|
+
*/
|
|
22
|
+
var createDailyResults = (storage, playerId) => {
|
|
23
|
+
const player = storage.player(playerId);
|
|
24
|
+
const results = storage.records(DAILY_RESULT_RECORD);
|
|
25
|
+
const streakRecords = storage.records(STREAK_RECORD);
|
|
26
|
+
const prefix = (gameId) => `${playerId}/~daily/${checkGame(gameId)}/`;
|
|
27
|
+
const refOf = (puzzleId) => {
|
|
28
|
+
const ref = parsePuzzleId(puzzleId);
|
|
29
|
+
if (ref === null) throw new RangeError(`Not a daily puzzle id: ${JSON.stringify(puzzleId)}.`);
|
|
30
|
+
return ref;
|
|
31
|
+
};
|
|
32
|
+
const gameOf = (puzzleId) => refOf(puzzleId).gameId;
|
|
33
|
+
const keyOf = (puzzleId, variant) => {
|
|
34
|
+
const ref = refOf(puzzleId);
|
|
35
|
+
return `${prefix(ref.gameId)}${ref.date}/${variant ?? "-"}`;
|
|
36
|
+
};
|
|
37
|
+
const winKey = (gameId) => `${playerId}/${checkGame(gameId)}/win`;
|
|
38
|
+
const list = async (gameId) => {
|
|
39
|
+
const keys = await results.keys(prefix(gameId));
|
|
40
|
+
if (!keys.ok) return keys;
|
|
41
|
+
const found = [];
|
|
42
|
+
for (const key of keys.value) {
|
|
43
|
+
const result = await results.get(key);
|
|
44
|
+
if (!result.ok) return result;
|
|
45
|
+
if (result.value !== null) found.push(result.value);
|
|
46
|
+
}
|
|
47
|
+
return ok(found);
|
|
48
|
+
};
|
|
49
|
+
const streaks$1 = async (gameId, today) => {
|
|
50
|
+
const all = await list(gameId);
|
|
51
|
+
if (!all.ok) return all;
|
|
52
|
+
const computed = streaks(all.value, today);
|
|
53
|
+
const [stored, storedWin] = await Promise.all([player.streaks.get(gameId), streakRecords.get(winKey(gameId))]);
|
|
54
|
+
const best = Math.max(computed.best, stored.ok ? stored.value.best : 0);
|
|
55
|
+
const winBest = Math.max(computed.winBest, storedWin.ok ? (storedWin.value ?? EMPTY_STREAK).best : 0);
|
|
56
|
+
const merged = {
|
|
57
|
+
...computed,
|
|
58
|
+
best,
|
|
59
|
+
winBest
|
|
60
|
+
};
|
|
61
|
+
const days = {
|
|
62
|
+
current: merged.current,
|
|
63
|
+
best,
|
|
64
|
+
lastDay: merged.lastDay
|
|
65
|
+
};
|
|
66
|
+
const wins = {
|
|
67
|
+
current: merged.winCurrent,
|
|
68
|
+
best: winBest,
|
|
69
|
+
lastDay: merged.lastDay
|
|
70
|
+
};
|
|
71
|
+
const wrote = await player.streaks.set(gameId, days);
|
|
72
|
+
if (!wrote.ok) return wrote;
|
|
73
|
+
const wroteWin = await streakRecords.set(winKey(gameId), wins);
|
|
74
|
+
return wroteWin.ok ? ok(merged) : wroteWin;
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
get: async (puzzleId, variant) => results.get(keyOf(puzzleId, variant)),
|
|
78
|
+
list,
|
|
79
|
+
streaks: streaks$1,
|
|
80
|
+
record: async (result, today) => {
|
|
81
|
+
const key = keyOf(result.puzzleId, result.variant);
|
|
82
|
+
const existing = await results.get(key);
|
|
83
|
+
if (!existing.ok) return existing;
|
|
84
|
+
const isNew = existing.value === null;
|
|
85
|
+
if (isNew) {
|
|
86
|
+
const written = await results.set(key, result);
|
|
87
|
+
if (!written.ok) return written;
|
|
88
|
+
}
|
|
89
|
+
const current = await streaks$1(gameOf(result.puzzleId), today);
|
|
90
|
+
if (!current.ok) return current;
|
|
91
|
+
return ok({
|
|
92
|
+
result: existing.value ?? result,
|
|
93
|
+
isNew,
|
|
94
|
+
streaks: current.value
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
//#endregion
|
|
100
|
+
export { DAILY_RESULT_RECORD, createDailyResults };
|
|
101
|
+
|
|
102
|
+
//# sourceMappingURL=results.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"results.js","names":["streaks","computeStreaks"],"sources":["../../src/browser/results.ts"],"sourcesContent":["// The results store: one `DailyResult` per puzzle id and variant, and the streaks computed\n// from them, kept through the storage package (content-pipeline.md, \"Results and streaks\").\n//\n// Where things live:\n// - results: the `game-data` store, `<playerId>/~daily/<game>/<date>/<variant or ->`. A game's\n// own keys there are `<playerId>/<game>/…`, and a game id never starts with `~`, so the two\n// never meet;\n// - the days-finished streak: the player's `StreakStore`, under the game id;\n// - the win streak: the `streaks` store, `<playerId>/<game>/win`, in the same record shape.\n// The streak records are a summary for the frame and for v2 sync (\"max wins\"); the results\n// stay the source of truth, and a stored best only ever grows.\n\nimport { isGameId, ok, type Result } from '@joystyk/contracts';\nimport {\n defineRecordKind,\n EMPTY_STREAK,\n STREAK_RECORD,\n type RecordStore,\n type Storage,\n type StorageError,\n type Streak,\n} from '@joystyk/storage';\nimport { parsePuzzleId } from '../ids.ts';\nimport { dailyResult, type DailyResult } from '../schemas.ts';\nimport { streaks as computeStreaks, type Streaks } from '../streaks.ts';\n\n/** Version 1 of a stored daily result. */\nexport const DAILY_RESULT_RECORD = defineRecordKind<DailyResult>({\n store: 'game-data',\n version: 1,\n schema: dailyResult,\n label: 'daily result',\n});\n\n/** What `record` did. */\nexport type Recorded = {\n /** The result now stored: the new one, or the earlier one when this puzzle was already finished. */\n readonly result: DailyResult;\n /** False when a result for this puzzle and variant already existed; it is kept as it was. */\n readonly isNew: boolean;\n readonly streaks: Streaks;\n};\n\n/** One player's daily results and streaks. */\nexport interface DailyResults {\n /**\n * Keeps a finished puzzle's result, unless one already exists for the same puzzle and\n * variant (a finished result stays as it was), then returns the game's streaks for `today`.\n */\n record(result: DailyResult, today: string): Promise<Result<Recorded, StorageError>>;\n /** The result for a puzzle and variant, or null. */\n get(puzzleId: string, variant: string | null): Promise<Result<DailyResult | null, StorageError>>;\n /** Every result kept for a game, oldest puzzle first. */\n list(gameId: string): Promise<Result<readonly DailyResult[], StorageError>>;\n /**\n * The game's streaks on `today`, today's UTC date, computed from the results.\n * Also writes the streak records, so a missed date resets the stored current streak the\n * next time the player opens the game. A stored best higher than the computed one is kept.\n */\n streaks(gameId: string, today: string): Promise<Result<Streaks, StorageError>>;\n}\n\nconst checkGame = (gameId: string): string => {\n if (!isGameId(gameId)) {\n throw new RangeError(`Expected a kebab-case game id, got ${JSON.stringify(gameId)}.`);\n }\n return gameId;\n};\n\n/**\n * The results store for one player. Throws a `RangeError` for a player id that can't be part\n * of a storage key, as `storage.player` does.\n */\nexport const createDailyResults = (storage: Storage, playerId: string): DailyResults => {\n const player = storage.player(playerId);\n const results: RecordStore<DailyResult> = storage.records(DAILY_RESULT_RECORD);\n const streakRecords: RecordStore<Streak> = storage.records(STREAK_RECORD);\n const prefix = (gameId: string): string => `${playerId}/~daily/${checkGame(gameId)}/`;\n const refOf = (puzzleId: string) => {\n const ref = parsePuzzleId(puzzleId);\n if (ref === null) throw new RangeError(`Not a daily puzzle id: ${JSON.stringify(puzzleId)}.`);\n return ref;\n };\n const gameOf = (puzzleId: string): string => refOf(puzzleId).gameId;\n const keyOf = (puzzleId: string, variant: string | null): string => {\n const ref = refOf(puzzleId);\n return `${prefix(ref.gameId)}${ref.date}/${variant ?? '-'}`;\n };\n const winKey = (gameId: string): string => `${playerId}/${checkGame(gameId)}/win`;\n\n const list = async (gameId: string): Promise<Result<readonly DailyResult[], StorageError>> => {\n const keys = await results.keys(prefix(gameId));\n if (!keys.ok) return keys;\n const found: DailyResult[] = [];\n for (const key of keys.value) {\n const result = await results.get(key);\n if (!result.ok) return result;\n if (result.value !== null) found.push(result.value);\n }\n return ok(found);\n };\n\n const streaks = async (gameId: string, today: string): Promise<Result<Streaks, StorageError>> => {\n const all = await list(gameId);\n if (!all.ok) return all;\n const computed = computeStreaks(all.value, today);\n const [stored, storedWin] = await Promise.all([player.streaks.get(gameId), streakRecords.get(winKey(gameId))]);\n const best = Math.max(computed.best, stored.ok ? stored.value.best : 0);\n const winBest = Math.max(computed.winBest, storedWin.ok ? (storedWin.value ?? EMPTY_STREAK).best : 0);\n const merged: Streaks = { ...computed, best, winBest };\n\n const days: Streak = { current: merged.current, best, lastDay: merged.lastDay };\n const wins: Streak = { current: merged.winCurrent, best: winBest, lastDay: merged.lastDay };\n const wrote = await player.streaks.set(gameId, days);\n if (!wrote.ok) return wrote;\n const wroteWin = await streakRecords.set(winKey(gameId), wins);\n return wroteWin.ok ? ok(merged) : wroteWin;\n };\n\n return {\n get: async (puzzleId, variant) => results.get(keyOf(puzzleId, variant)),\n list,\n streaks,\n record: async (result, today) => {\n const key = keyOf(result.puzzleId, result.variant);\n const existing = await results.get(key);\n if (!existing.ok) return existing;\n const isNew = existing.value === null;\n if (isNew) {\n const written = await results.set(key, result);\n if (!written.ok) return written;\n }\n const current = await streaks(gameOf(result.puzzleId), today);\n if (!current.ok) return current;\n return ok({ result: existing.value ?? result, isNew, streaks: current.value });\n },\n };\n};\n"],"mappings":"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"}
|
package/dist/dates.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { addDays, dayNumber, isIsoDate } from '@joystyk/kit';
|
|
2
|
+
export { addDays, dayNumber, isIsoDate };
|
|
3
|
+
export declare const DAY_MS = 86400000;
|
|
4
|
+
/** The UTC calendar date at `instant`: the date of the daily puzzle that is out then. */
|
|
5
|
+
export declare const utcDate: (instant: number) => string;
|
|
6
|
+
/** Whole days from `from` to `to`: `daysBetween('2028-02-28', '2028-03-01')` is 2. */
|
|
7
|
+
export declare const daysBetween: (from: string, to: string) => number;
|
|
8
|
+
/** The instant (epoch milliseconds) at which `date` begins: 00:00 UTC. */
|
|
9
|
+
export declare const startOfDay: (date: string) => number;
|
|
10
|
+
/**
|
|
11
|
+
* Milliseconds from `instant` until the next UTC day begins, when the next puzzle comes out.
|
|
12
|
+
* Show it as plain information ("in 6 hours"), never as a countdown meant to pressure
|
|
13
|
+
* (audience-and-age.md).
|
|
14
|
+
*/
|
|
15
|
+
export declare const untilNextDay: (instant: number) => number;
|
|
16
|
+
/**
|
|
17
|
+
* The puzzle number of `date` in a series that began on `firstDate` (number 1), for share text
|
|
18
|
+
* such as "Daily Word #412". Zero or negative for dates before the series began.
|
|
19
|
+
*/
|
|
20
|
+
export declare const puzzleNumber: (date: string, firstDate: string) => number;
|
package/dist/dates.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { addDays, dateOfDay, dayNumber, isIsoDate } from "@joystyk/kit";
|
|
2
|
+
//#region src/dates.ts
|
|
3
|
+
var DAY_MS = 864e5;
|
|
4
|
+
var checkInstant = (instant) => {
|
|
5
|
+
if (!Number.isFinite(instant)) throw new RangeError(`Expected an instant in epoch milliseconds, got ${instant}.`);
|
|
6
|
+
};
|
|
7
|
+
/** The UTC calendar date at `instant`: the date of the daily puzzle that is out then. */
|
|
8
|
+
var utcDate = (instant) => {
|
|
9
|
+
checkInstant(instant);
|
|
10
|
+
return dateOfDay(Math.floor(instant / DAY_MS));
|
|
11
|
+
};
|
|
12
|
+
/** Whole days from `from` to `to`: `daysBetween('2028-02-28', '2028-03-01')` is 2. */
|
|
13
|
+
var daysBetween = (from, to) => dayNumber(to) - dayNumber(from);
|
|
14
|
+
/** The instant (epoch milliseconds) at which `date` begins: 00:00 UTC. */
|
|
15
|
+
var startOfDay = (date) => dayNumber(date) * DAY_MS;
|
|
16
|
+
/**
|
|
17
|
+
* Milliseconds from `instant` until the next UTC day begins, when the next puzzle comes out.
|
|
18
|
+
* Show it as plain information ("in 6 hours"), never as a countdown meant to pressure
|
|
19
|
+
* (audience-and-age.md).
|
|
20
|
+
*/
|
|
21
|
+
var untilNextDay = (instant) => startOfDay(addDays(utcDate(instant), 1)) - instant;
|
|
22
|
+
/**
|
|
23
|
+
* The puzzle number of `date` in a series that began on `firstDate` (number 1), for share text
|
|
24
|
+
* such as "Daily Word #412". Zero or negative for dates before the series began.
|
|
25
|
+
*/
|
|
26
|
+
var puzzleNumber = (date, firstDate) => daysBetween(firstDate, date) + 1;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { DAY_MS, addDays, dayNumber, daysBetween, isIsoDate, puzzleNumber, startOfDay, untilNextDay, utcDate };
|
|
29
|
+
|
|
30
|
+
//# sourceMappingURL=dates.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dates.js","names":[],"sources":["../src/dates.ts"],"sourcesContent":["// Which day it is, and calendar arithmetic on \"yyyy-mm-dd\" strings. A daily puzzle's day is\n// the UTC day (D-46): the same date everywhere at once. Nothing here reads a clock: the caller\n// passes the instant in epoch milliseconds (content-pipeline.md, \"Which day is today\").\n\nimport { addDays, dateOfDay, dayNumber, isIsoDate } from '@joystyk/kit';\n\nexport { addDays, dayNumber, isIsoDate };\n\nexport const DAY_MS = 86_400_000;\n\nconst checkInstant = (instant: number): void => {\n if (!Number.isFinite(instant)) throw new RangeError(`Expected an instant in epoch milliseconds, got ${instant}.`);\n};\n\n/** The UTC calendar date at `instant`: the date of the daily puzzle that is out then. */\nexport const utcDate = (instant: number): string => {\n checkInstant(instant);\n return dateOfDay(Math.floor(instant / DAY_MS));\n};\n\n/** Whole days from `from` to `to`: `daysBetween('2028-02-28', '2028-03-01')` is 2. */\nexport const daysBetween = (from: string, to: string): number => dayNumber(to) - dayNumber(from);\n\n/** The instant (epoch milliseconds) at which `date` begins: 00:00 UTC. */\nexport const startOfDay = (date: string): number => dayNumber(date) * DAY_MS;\n\n/**\n * Milliseconds from `instant` until the next UTC day begins, when the next puzzle comes out.\n * Show it as plain information (\"in 6 hours\"), never as a countdown meant to pressure\n * (audience-and-age.md).\n */\nexport const untilNextDay = (instant: number): number => startOfDay(addDays(utcDate(instant), 1)) - instant;\n\n/**\n * The puzzle number of `date` in a series that began on `firstDate` (number 1), for share text\n * such as \"Daily Word #412\". Zero or negative for dates before the series began.\n */\nexport const puzzleNumber = (date: string, firstDate: string): number => daysBetween(firstDate, date) + 1;\n"],"mappings":";;AAQA,IAAa,SAAS;AAEtB,IAAM,gBAAgB,YAA0B;CAC9C,IAAI,CAAC,OAAO,SAAS,OAAO,GAAG,MAAM,IAAI,WAAW,kDAAkD,QAAQ,EAAE;AAClH;;AAGA,IAAa,WAAW,YAA4B;CAClD,aAAa,OAAO;CACpB,OAAO,UAAU,KAAK,MAAM,UAAU,MAAM,CAAC;AAC/C;;AAGA,IAAa,eAAe,MAAc,OAAuB,UAAU,EAAE,IAAI,UAAU,IAAI;;AAG/F,IAAa,cAAc,SAAyB,UAAU,IAAI,IAAI;;;;;;AAOtE,IAAa,gBAAgB,YAA4B,WAAW,QAAQ,QAAQ,OAAO,GAAG,CAAC,CAAC,IAAI;;;;;AAMpG,IAAa,gBAAgB,MAAc,cAA8B,YAAY,WAAW,IAAI,IAAI"}
|
package/dist/ids.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A parsed daily puzzle id. */
|
|
2
|
+
export type PuzzleRef = {
|
|
3
|
+
readonly gameId: string;
|
|
4
|
+
/** The puzzle's own calendar date, `yyyy-mm-dd`. */
|
|
5
|
+
readonly date: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The daily puzzle id `<game>:<yyyy-mm-dd>`, e.g. "daily-word:2026-10-08". Throws a
|
|
9
|
+
* `RangeError` for a game id that isn't kebab-case or a date that isn't real.
|
|
10
|
+
*/
|
|
11
|
+
export declare const puzzleId: (gameId: string, date: string) => string;
|
|
12
|
+
/** Splits a daily puzzle id into its game and date, or returns null when it isn't one. */
|
|
13
|
+
export declare const parsePuzzleId: (id: string) => PuzzleRef | null;
|
|
14
|
+
/** True for a well-formed daily puzzle id. */
|
|
15
|
+
export declare const isPuzzleId: (value: unknown) => value is string;
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { dailyId, isIsoDate } from "@joystyk/kit";
|
|
2
|
+
import { isGameId } from "@joystyk/contracts";
|
|
3
|
+
//#region src/ids.ts
|
|
4
|
+
/**
|
|
5
|
+
* The daily puzzle id `<game>:<yyyy-mm-dd>`, e.g. "daily-word:2026-10-08". Throws a
|
|
6
|
+
* `RangeError` for a game id that isn't kebab-case or a date that isn't real.
|
|
7
|
+
*/
|
|
8
|
+
var puzzleId = (gameId, date) => dailyId(gameId, date);
|
|
9
|
+
/** Splits a daily puzzle id into its game and date, or returns null when it isn't one. */
|
|
10
|
+
var parsePuzzleId = (id) => {
|
|
11
|
+
const at = id.lastIndexOf(":");
|
|
12
|
+
if (at < 0) return null;
|
|
13
|
+
const gameId = id.slice(0, at);
|
|
14
|
+
const date = id.slice(at + 1);
|
|
15
|
+
return isGameId(gameId) && isIsoDate(date) ? {
|
|
16
|
+
gameId,
|
|
17
|
+
date
|
|
18
|
+
} : null;
|
|
19
|
+
};
|
|
20
|
+
/** True for a well-formed daily puzzle id. */
|
|
21
|
+
var isPuzzleId = (value) => typeof value === "string" && parsePuzzleId(value) !== null;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { isPuzzleId, parsePuzzleId, puzzleId };
|
|
24
|
+
|
|
25
|
+
//# sourceMappingURL=ids.js.map
|
package/dist/ids.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ids.js","names":[],"sources":["../src/ids.ts"],"sourcesContent":["// Daily puzzle ids: `<game>:<yyyy-mm-dd>`, built by the kit's `dailyId` (offline-and-updates.md).\n\nimport { isGameId } from '@joystyk/contracts';\nimport { dailyId, isIsoDate } from '@joystyk/kit';\n\n/** A parsed daily puzzle id. */\nexport type PuzzleRef = {\n readonly gameId: string;\n /** The puzzle's own calendar date, `yyyy-mm-dd`. */\n readonly date: string;\n};\n\n/**\n * The daily puzzle id `<game>:<yyyy-mm-dd>`, e.g. \"daily-word:2026-10-08\". Throws a\n * `RangeError` for a game id that isn't kebab-case or a date that isn't real.\n */\nexport const puzzleId = (gameId: string, date: string): string => dailyId(gameId, date);\n\n/** Splits a daily puzzle id into its game and date, or returns null when it isn't one. */\nexport const parsePuzzleId = (id: string): PuzzleRef | null => {\n const at = id.lastIndexOf(':');\n if (at < 0) return null;\n const gameId = id.slice(0, at);\n const date = id.slice(at + 1);\n return isGameId(gameId) && isIsoDate(date) ? { gameId, date } : null;\n};\n\n/** True for a well-formed daily puzzle id. */\nexport const isPuzzleId = (value: unknown): value is string =>\n typeof value === 'string' && parsePuzzleId(value) !== null;\n"],"mappings":";;;;;;;AAgBA,IAAa,YAAY,QAAgB,SAAyB,QAAQ,QAAQ,IAAI;;AAGtF,IAAa,iBAAiB,OAAiC;CAC7D,MAAM,KAAK,GAAG,YAAY,GAAG;CAC7B,IAAI,KAAK,GAAG,OAAO;CACnB,MAAM,SAAS,GAAG,MAAM,GAAG,EAAE;CAC7B,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC;CAC5B,OAAO,SAAS,MAAM,KAAK,UAAU,IAAI,IAAI;EAAE;EAAQ;CAAK,IAAI;AAClE;;AAGA,IAAa,cAAc,UACzB,OAAO,UAAU,YAAY,cAAc,KAAK,MAAM"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { addDays, DAY_MS, dayNumber, daysBetween, isIsoDate, puzzleNumber, startOfDay, untilNextDay, utcDate, } from './dates.js';
|
|
2
|
+
export { canStart, isReleased, latestListedDate, nextRelease, prefetchDates, releaseInstant } from './release.js';
|
|
3
|
+
export { countdownText, localTimeText, type LocalTimeOptions } from './local-time.js';
|
|
4
|
+
export { isPuzzleId, parsePuzzleId, puzzleId, type PuzzleRef } from './ids.js';
|
|
5
|
+
export { NO_STREAKS, streaks, type Streaks } from './streaks.js';
|
|
6
|
+
export { formatClock, gridRows, shareText, type ShareForm, type ShareInput, type ShareLegendEntry, type ShareRow, } from './share.js';
|
|
7
|
+
export { canonicalJson, CONTENT_HASH, creditDate, DAILY_SCHEMA_VERSION, dailyEnvelope, dailyFile, dailyIndex, dailyResult, decode, json, VARIANT, type DailyEnvelope, type DailyFile, type DailyIndex, type DailyIndexEntry, type DailyOutcome, type DailyResult, type EnvelopeExpectations, type Issue, } from './schemas.js';
|
|
8
|
+
export { dailyWordStats, dailyWordStatsSchema, type DailyWordStats } from './word-stats.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { DAY_MS, addDays, dayNumber, daysBetween, isIsoDate, puzzleNumber, startOfDay, untilNextDay, utcDate } from "./dates.js";
|
|
2
|
+
import { countdownText, localTimeText } from "./local-time.js";
|
|
3
|
+
import { canStart, isReleased, latestListedDate, nextRelease, prefetchDates, releaseInstant } from "./release.js";
|
|
4
|
+
import { isPuzzleId, parsePuzzleId, puzzleId } from "./ids.js";
|
|
5
|
+
import { CONTENT_HASH, DAILY_SCHEMA_VERSION, VARIANT, canonicalJson, creditDate, dailyEnvelope, dailyFile, dailyIndex, dailyResult, decode, json } from "./schemas.js";
|
|
6
|
+
import { NO_STREAKS, streaks } from "./streaks.js";
|
|
7
|
+
import { formatClock, gridRows, shareText } from "./share.js";
|
|
8
|
+
import { dailyWordStats, dailyWordStatsSchema } from "./word-stats.js";
|
|
9
|
+
export { CONTENT_HASH, DAILY_SCHEMA_VERSION, DAY_MS, NO_STREAKS, VARIANT, addDays, canStart, canonicalJson, countdownText, creditDate, dailyEnvelope, dailyFile, dailyIndex, dailyResult, dailyWordStats, dailyWordStatsSchema, dayNumber, daysBetween, decode, formatClock, gridRows, isIsoDate, isPuzzleId, isReleased, json, latestListedDate, localTimeText, nextRelease, parsePuzzleId, prefetchDates, puzzleId, puzzleNumber, releaseInstant, shareText, startOfDay, streaks, untilNextDay, utcDate };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type LocalTimeOptions = {
|
|
2
|
+
/** A BCP 47 locale such as "en-US". Defaults to the runtime's locale. */
|
|
3
|
+
readonly locale?: string;
|
|
4
|
+
/** An IANA time zone such as "America/New_York". Defaults to the runtime's zone. */
|
|
5
|
+
readonly timeZone?: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The wall-clock time of `instant` in the player's zone, such as "8:00 PM" or "20:00",
|
|
9
|
+
* following the locale's 12- or 24-hour habit.
|
|
10
|
+
*/
|
|
11
|
+
export declare const localTimeText: (instant: number, options?: LocalTimeOptions) => string;
|
|
12
|
+
/**
|
|
13
|
+
* A plain countdown such as "in 6 hours" or "in 45 minutes". From an hour up it counts whole
|
|
14
|
+
* hours, rounded down; below an hour it counts minutes, rounded up, so it never says "in 0
|
|
15
|
+
* minutes" before the release. Information, never pressure (audience-and-age.md).
|
|
16
|
+
*/
|
|
17
|
+
export declare const countdownText: (ms: number, locale?: string) => string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/local-time.ts
|
|
2
|
+
var MINUTE = 6e4;
|
|
3
|
+
/**
|
|
4
|
+
* The wall-clock time of `instant` in the player's zone, such as "8:00 PM" or "20:00",
|
|
5
|
+
* following the locale's 12- or 24-hour habit.
|
|
6
|
+
*/
|
|
7
|
+
var localTimeText = (instant, options = {}) => {
|
|
8
|
+
if (!Number.isFinite(instant)) throw new RangeError(`Expected an instant in epoch milliseconds, got ${instant}.`);
|
|
9
|
+
return new Intl.DateTimeFormat(options.locale, {
|
|
10
|
+
hour: "numeric",
|
|
11
|
+
minute: "2-digit",
|
|
12
|
+
...options.timeZone === void 0 ? {} : { timeZone: options.timeZone }
|
|
13
|
+
}).format(instant);
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A plain countdown such as "in 6 hours" or "in 45 minutes". From an hour up it counts whole
|
|
17
|
+
* hours, rounded down; below an hour it counts minutes, rounded up, so it never says "in 0
|
|
18
|
+
* minutes" before the release. Information, never pressure (audience-and-age.md).
|
|
19
|
+
*/
|
|
20
|
+
var countdownText = (ms, locale) => {
|
|
21
|
+
if (!Number.isFinite(ms)) throw new RangeError(`Expected a duration in milliseconds, got ${ms}.`);
|
|
22
|
+
const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
23
|
+
const minutes = Math.max(0, Math.ceil(ms / MINUTE));
|
|
24
|
+
return minutes < 60 ? format.format(minutes, "minute") : format.format(Math.floor(minutes / 60), "hour");
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { countdownText, localTimeText };
|
|
28
|
+
|
|
29
|
+
//# sourceMappingURL=local-time.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-time.js","names":[],"sources":["../src/local-time.ts"],"sourcesContent":["// Release times in the player's own time (D-46). The puzzle day is the UTC day, but players\n// read times in their own zone: \"New puzzle at 8:00 PM\", \"in 6 hours\". These format a\n// supplied instant or duration with Intl and read no clock. Leaving out `locale` or\n// `timeZone` uses the runtime's own, which in a browser is the device's.\n\nconst MINUTE = 60_000;\n\nexport type LocalTimeOptions = {\n /** A BCP 47 locale such as \"en-US\". Defaults to the runtime's locale. */\n readonly locale?: string;\n /** An IANA time zone such as \"America/New_York\". Defaults to the runtime's zone. */\n readonly timeZone?: string;\n};\n\n/**\n * The wall-clock time of `instant` in the player's zone, such as \"8:00 PM\" or \"20:00\",\n * following the locale's 12- or 24-hour habit.\n */\nexport const localTimeText = (instant: number, options: LocalTimeOptions = {}): string => {\n if (!Number.isFinite(instant)) throw new RangeError(`Expected an instant in epoch milliseconds, got ${instant}.`);\n return new Intl.DateTimeFormat(options.locale, {\n hour: 'numeric',\n minute: '2-digit',\n ...(options.timeZone === undefined ? {} : { timeZone: options.timeZone }),\n }).format(instant);\n};\n\n/**\n * A plain countdown such as \"in 6 hours\" or \"in 45 minutes\". From an hour up it counts whole\n * hours, rounded down; below an hour it counts minutes, rounded up, so it never says \"in 0\n * minutes\" before the release. Information, never pressure (audience-and-age.md).\n */\nexport const countdownText = (ms: number, locale?: string): string => {\n if (!Number.isFinite(ms)) throw new RangeError(`Expected a duration in milliseconds, got ${ms}.`);\n const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });\n const minutes = Math.max(0, Math.ceil(ms / MINUTE));\n return minutes < 60 ? format.format(minutes, 'minute') : format.format(Math.floor(minutes / 60), 'hour');\n};\n"],"mappings":";AAKA,IAAM,SAAS;;;;;AAaf,IAAa,iBAAiB,SAAiB,UAA4B,CAAC,MAAc;CACxF,IAAI,CAAC,OAAO,SAAS,OAAO,GAAG,MAAM,IAAI,WAAW,kDAAkD,QAAQ,EAAE;CAChH,OAAO,IAAI,KAAK,eAAe,QAAQ,QAAQ;EAC7C,MAAM;EACN,QAAQ;EACR,GAAI,QAAQ,aAAa,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,QAAQ,SAAS;CACzE,CAAC,CAAC,CAAC,OAAO,OAAO;AACnB;;;;;;AAOA,IAAa,iBAAiB,IAAY,WAA4B;CACpE,IAAI,CAAC,OAAO,SAAS,EAAE,GAAG,MAAM,IAAI,WAAW,4CAA4C,GAAG,EAAE;CAChG,MAAM,SAAS,IAAI,KAAK,mBAAmB,QAAQ,EAAE,SAAS,OAAO,CAAC;CACtE,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,KAAK,KAAK,MAAM,CAAC;CAClD,OAAO,UAAU,KAAK,OAAO,OAAO,SAAS,QAAQ,IAAI,OAAO,OAAO,KAAK,MAAM,UAAU,EAAE,GAAG,MAAM;AACzG"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The instant puzzle `date` comes out: 00:00 UTC on `date`. */
|
|
2
|
+
export declare const releaseInstant: (date: string) => number;
|
|
3
|
+
/** True once puzzle `date` is out, so it may be started. */
|
|
4
|
+
export declare const isReleased: (date: string, instant: number) => boolean;
|
|
5
|
+
/** The instant the next puzzle after `instant` comes out: the start of the next UTC day. */
|
|
6
|
+
export declare const nextRelease: (instant: number) => number;
|
|
7
|
+
/**
|
|
8
|
+
* The newest date a game's index lists at `instant`: tomorrow's UTC date, so that installed
|
|
9
|
+
* and pinned clients can prefetch it. It stays locked until `isReleased`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const latestListedDate: (instant: number) => string;
|
|
12
|
+
/**
|
|
13
|
+
* True when a player may start the puzzle for `date`, given today's UTC date. Only today's
|
|
14
|
+
* puzzle can be started: an unstarted past puzzle is gone in v1, and a prefetched future one
|
|
15
|
+
* is locked until its day begins. A puzzle already started is always kept, whatever the date
|
|
16
|
+
* becomes, and its result counts for its own date (see `creditDate`).
|
|
17
|
+
*/
|
|
18
|
+
export declare const canStart: (date: string, today: string) => boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The dates an installed or pinned client holds when today's UTC date is `today`: today and
|
|
21
|
+
* the next day, never more (D-46).
|
|
22
|
+
*/
|
|
23
|
+
export declare const prefetchDates: (today: string) => readonly string[];
|
package/dist/release.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { addDays, daysBetween, startOfDay, utcDate } from "./dates.js";
|
|
2
|
+
//#region src/release.ts
|
|
3
|
+
/** The instant puzzle `date` comes out: 00:00 UTC on `date`. */
|
|
4
|
+
var releaseInstant = (date) => startOfDay(date);
|
|
5
|
+
/** True once puzzle `date` is out, so it may be started. */
|
|
6
|
+
var isReleased = (date, instant) => instant >= releaseInstant(date);
|
|
7
|
+
/** The instant the next puzzle after `instant` comes out: the start of the next UTC day. */
|
|
8
|
+
var nextRelease = (instant) => releaseInstant(addDays(utcDate(instant), 1));
|
|
9
|
+
/**
|
|
10
|
+
* The newest date a game's index lists at `instant`: tomorrow's UTC date, so that installed
|
|
11
|
+
* and pinned clients can prefetch it. It stays locked until `isReleased`.
|
|
12
|
+
*/
|
|
13
|
+
var latestListedDate = (instant) => addDays(utcDate(instant), 1);
|
|
14
|
+
/**
|
|
15
|
+
* True when a player may start the puzzle for `date`, given today's UTC date. Only today's
|
|
16
|
+
* puzzle can be started: an unstarted past puzzle is gone in v1, and a prefetched future one
|
|
17
|
+
* is locked until its day begins. A puzzle already started is always kept, whatever the date
|
|
18
|
+
* becomes, and its result counts for its own date (see `creditDate`).
|
|
19
|
+
*/
|
|
20
|
+
var canStart = (date, today) => daysBetween(today, date) === 0;
|
|
21
|
+
/**
|
|
22
|
+
* The dates an installed or pinned client holds when today's UTC date is `today`: today and
|
|
23
|
+
* the next day, never more (D-46).
|
|
24
|
+
*/
|
|
25
|
+
var prefetchDates = (today) => [today, addDays(today, 1)];
|
|
26
|
+
//#endregion
|
|
27
|
+
export { canStart, isReleased, latestListedDate, nextRelease, prefetchDates, releaseInstant };
|
|
28
|
+
|
|
29
|
+
//# sourceMappingURL=release.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"release.js","names":[],"sources":["../src/release.ts"],"sourcesContent":["// When a daily puzzle is out. There is one puzzle per UTC day (D-46): puzzle D is out from\n// 00:00 UTC on D until 00:00 UTC on D + 1, for everyone at once. The server lists D + 1 in a\n// game's index a day early, so installed and pinned clients can prefetch it, and the client\n// keeps it locked until its day begins (content-pipeline.md, \"Which day is today\").\n\nimport { addDays, daysBetween, startOfDay, utcDate } from './dates.ts';\n\n/** The instant puzzle `date` comes out: 00:00 UTC on `date`. */\nexport const releaseInstant = (date: string): number => startOfDay(date);\n\n/** True once puzzle `date` is out, so it may be started. */\nexport const isReleased = (date: string, instant: number): boolean => instant >= releaseInstant(date);\n\n/** The instant the next puzzle after `instant` comes out: the start of the next UTC day. */\nexport const nextRelease = (instant: number): number => releaseInstant(addDays(utcDate(instant), 1));\n\n/**\n * The newest date a game's index lists at `instant`: tomorrow's UTC date, so that installed\n * and pinned clients can prefetch it. It stays locked until `isReleased`.\n */\nexport const latestListedDate = (instant: number): string => addDays(utcDate(instant), 1);\n\n/**\n * True when a player may start the puzzle for `date`, given today's UTC date. Only today's\n * puzzle can be started: an unstarted past puzzle is gone in v1, and a prefetched future one\n * is locked until its day begins. A puzzle already started is always kept, whatever the date\n * becomes, and its result counts for its own date (see `creditDate`).\n */\nexport const canStart = (date: string, today: string): boolean => daysBetween(today, date) === 0;\n\n/**\n * The dates an installed or pinned client holds when today's UTC date is `today`: today and\n * the next day, never more (D-46).\n */\nexport const prefetchDates = (today: string): readonly string[] => [today, addDays(today, 1)];\n"],"mappings":";;;AAQA,IAAa,kBAAkB,SAAyB,WAAW,IAAI;;AAGvE,IAAa,cAAc,MAAc,YAA6B,WAAW,eAAe,IAAI;;AAGpG,IAAa,eAAe,YAA4B,eAAe,QAAQ,QAAQ,OAAO,GAAG,CAAC,CAAC;;;;;AAMnG,IAAa,oBAAoB,YAA4B,QAAQ,QAAQ,OAAO,GAAG,CAAC;;;;;;;AAQxF,IAAa,YAAY,MAAc,UAA2B,YAAY,OAAO,IAAI,MAAM;;;;;AAM/F,IAAa,iBAAiB,UAAqC,CAAC,OAAO,QAAQ,OAAO,CAAC,CAAC"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type Json, type Result, type StandardSchemaV1, type Validator } from '@joystyk/contracts';
|
|
2
|
+
import * as z from 'zod/mini';
|
|
3
|
+
/** One validation problem, flattened for logs: `path: message`. */
|
|
4
|
+
export type Issue = string;
|
|
5
|
+
/** Runs any Standard Schema, sync or async, and returns its output or its issues. */
|
|
6
|
+
export declare const decode: <Output>(schema: StandardSchemaV1<unknown, Output>, value: unknown) => Promise<Result<Output, readonly Issue[]>>;
|
|
7
|
+
/** Any JSON value, checked with the contracts' `isJson`. */
|
|
8
|
+
export declare const json: z.ZodMiniCustom<Json, Json>;
|
|
9
|
+
/** A content hash: the first 12 hex digits of SHA-256 over the canonical JSON of the puzzle. */
|
|
10
|
+
export declare const CONTENT_HASH: RegExp;
|
|
11
|
+
/** A variant name such as "quick" or "standard": lowercase kebab-case, at most 32 characters. */
|
|
12
|
+
export declare const VARIANT: RegExp;
|
|
13
|
+
/** The current version of the puzzle file and index formats. */
|
|
14
|
+
export declare const DAILY_SCHEMA_VERSION = 1;
|
|
15
|
+
/**
|
|
16
|
+
* One day's puzzle file as the site serves it: the game's `Config` for the day (every variant)
|
|
17
|
+
* wrapped with its id, date, hash and format version. `config` is still untrusted JSON here;
|
|
18
|
+
* `dailyEnvelope` decodes it with the game's own schema.
|
|
19
|
+
*/
|
|
20
|
+
export type DailyFile = {
|
|
21
|
+
readonly schemaVersion: typeof DAILY_SCHEMA_VERSION;
|
|
22
|
+
readonly puzzleId: string;
|
|
23
|
+
readonly date: string;
|
|
24
|
+
readonly contentHash: string;
|
|
25
|
+
readonly config: Json;
|
|
26
|
+
};
|
|
27
|
+
/** A puzzle file whose `config` has passed the game's `schemas.config`. */
|
|
28
|
+
export type DailyEnvelope<Config> = Omit<DailyFile, 'config'> & {
|
|
29
|
+
readonly config: Config;
|
|
30
|
+
};
|
|
31
|
+
/** Decodes the puzzle file's wrapper. The id's date must match `date`. */
|
|
32
|
+
export declare const dailyFile: z.ZodMiniType<DailyFile>;
|
|
33
|
+
/** What the caller knows before decoding a puzzle file, from the URL it asked for. */
|
|
34
|
+
export type EnvelopeExpectations = {
|
|
35
|
+
readonly gameId?: string;
|
|
36
|
+
readonly date?: string;
|
|
37
|
+
readonly contentHash?: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* A Standard Schema for one game's puzzle file: the wrapper, then `config` through the game's
|
|
41
|
+
* own `schemas.config` (any Standard Schema, sync or async). With `expected`, it also refuses
|
|
42
|
+
* a file for another game, date or hash than the one requested. Issue paths inside the config
|
|
43
|
+
* start with `config`.
|
|
44
|
+
*/
|
|
45
|
+
export declare const dailyEnvelope: <Config>(config: Validator<Config>, expected?: EnvelopeExpectations) => StandardSchemaV1<unknown, DailyEnvelope<Config>>;
|
|
46
|
+
/** One published date in a game's index. */
|
|
47
|
+
export type DailyIndexEntry = {
|
|
48
|
+
readonly date: string;
|
|
49
|
+
readonly hash: string;
|
|
50
|
+
/** The file's size in bytes. */
|
|
51
|
+
readonly size: number;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The dates a game has published, served `no-cache`. It lists dates up to tomorrow's UTC date
|
|
55
|
+
* (`latestListedDate`), each with its content hash, which names the file.
|
|
56
|
+
*/
|
|
57
|
+
export type DailyIndex = {
|
|
58
|
+
readonly schemaVersion: typeof DAILY_SCHEMA_VERSION;
|
|
59
|
+
readonly gameId: string;
|
|
60
|
+
readonly dates: readonly DailyIndexEntry[];
|
|
61
|
+
};
|
|
62
|
+
/** Decodes a game index. Each date appears once. */
|
|
63
|
+
export declare const dailyIndex: z.ZodMiniType<DailyIndex>;
|
|
64
|
+
/** How a daily puzzle ended. Finishing means the engine reached an outcome. */
|
|
65
|
+
export type DailyOutcome = 'won' | 'lost' | 'completed';
|
|
66
|
+
/** One finished daily puzzle, kept per puzzle id and variant (content-pipeline.md). */
|
|
67
|
+
export type DailyResult = {
|
|
68
|
+
/** "daily-word:2026-10-08". Its date is the date the result counts for. */
|
|
69
|
+
readonly puzzleId: string;
|
|
70
|
+
/** "quick", "standard", … or null for a game without variants. */
|
|
71
|
+
readonly variant: string | null;
|
|
72
|
+
readonly contentHash: string;
|
|
73
|
+
readonly outcome: DailyOutcome;
|
|
74
|
+
/** Hints, checks or reveals were used, as each game defines them. */
|
|
75
|
+
readonly assisted: boolean;
|
|
76
|
+
/** The outcome's summary, e.g. "4/6". */
|
|
77
|
+
readonly summary: string;
|
|
78
|
+
readonly stats: Readonly<Record<string, number>>;
|
|
79
|
+
/** Spoiler-free symbol rows from the game, e.g. "⬜🟨⬜⬜⬜". */
|
|
80
|
+
readonly shareRows: readonly string[];
|
|
81
|
+
/** The same rows in words, for *Copy as text*: "Guess 1: 1 wrong place". Absent in older results. */
|
|
82
|
+
readonly shareWords?: readonly string[];
|
|
83
|
+
/** Separate validated annotations; absent in legacy records. */
|
|
84
|
+
readonly shareDetails?: readonly (number | 'found' | null)[];
|
|
85
|
+
/** The UTC date when it finished, `yyyy-mm-dd`. May be after the puzzle's date. */
|
|
86
|
+
readonly finishedOn: string;
|
|
87
|
+
};
|
|
88
|
+
/** Decodes a `DailyResult`. */
|
|
89
|
+
export declare const dailyResult: z.ZodMiniType<DailyResult>;
|
|
90
|
+
/**
|
|
91
|
+
* The date a result counts for: its puzzle's own date, even when it was finished after the
|
|
92
|
+
* next puzzle came out (a puzzle started is a puzzle kept).
|
|
93
|
+
*/
|
|
94
|
+
export declare const creditDate: (result: Pick<DailyResult, "puzzleId">) => string;
|
|
95
|
+
/**
|
|
96
|
+
* JSON with object keys sorted by UTF-16 code unit and no whitespace. The content hash is the
|
|
97
|
+
* first 12 hex digits of SHA-256 over the canonical JSON of a puzzle file's `config`.
|
|
98
|
+
*/
|
|
99
|
+
export declare const canonicalJson: (value: Json) => string;
|