@joystyk/content 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +36 -0
  4. package/README.md +113 -2
  5. package/dist/banks.d.ts +14 -0
  6. package/dist/banks.js +55 -0
  7. package/dist/banks.js.map +1 -0
  8. package/dist/extract-json.d.ts +12 -0
  9. package/dist/extract-json.js +59 -0
  10. package/dist/extract-json.js.map +1 -0
  11. package/dist/filter.d.ts +56 -0
  12. package/dist/filter.js +127 -0
  13. package/dist/filter.js.map +1 -0
  14. package/dist/games.d.ts +9 -0
  15. package/dist/games.js +40 -0
  16. package/dist/games.js.map +1 -0
  17. package/dist/hash.d.ts +4 -0
  18. package/dist/hash.js +17 -0
  19. package/dist/hash.js.map +1 -0
  20. package/dist/index.d.ts +17 -0
  21. package/dist/index.js +16 -0
  22. package/dist/log.d.ts +26 -0
  23. package/dist/log.js +58 -0
  24. package/dist/log.js.map +1 -0
  25. package/dist/model.d.ts +77 -0
  26. package/dist/model.js +268 -0
  27. package/dist/model.js.map +1 -0
  28. package/dist/packs.d.ts +144 -0
  29. package/dist/packs.js +466 -0
  30. package/dist/packs.js.map +1 -0
  31. package/dist/pipeline.d.ts +252 -0
  32. package/dist/pipeline.js +558 -0
  33. package/dist/pipeline.js.map +1 -0
  34. package/dist/publish.d.ts +21 -0
  35. package/dist/publish.js +56 -0
  36. package/dist/publish.js.map +1 -0
  37. package/dist/ranked.d.ts +12 -0
  38. package/dist/ranked.js +40 -0
  39. package/dist/ranked.js.map +1 -0
  40. package/dist/screen.d.ts +25 -0
  41. package/dist/screen.js +46 -0
  42. package/dist/screen.js.map +1 -0
  43. package/dist/store.d.ts +136 -0
  44. package/dist/store.js +300 -0
  45. package/dist/store.js.map +1 -0
  46. package/dist/text.d.ts +31 -0
  47. package/dist/text.js +111 -0
  48. package/dist/text.js.map +1 -0
  49. package/dist/vocabulary.d.ts +16 -0
  50. package/dist/vocabulary.js +44 -0
  51. package/dist/vocabulary.js.map +1 -0
  52. package/lists/SCOWL-COPYRIGHT +252 -0
  53. package/lists/blocklist.txt +214 -0
  54. package/lists/common-extra.txt +13 -0
  55. package/lists/scowl-common.txt +70763 -0
  56. package/lists/scowl-ranked.txt +72076 -0
  57. package/lists/scowl-vocabulary.txt +55636 -0
  58. package/lists/sensitive.txt +280 -0
  59. package/package.json +53 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # @joystyk/content
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/daily@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/daily@0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joystyk contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,36 @@
1
+ Third-party material in the Joystyk library release
2
+
3
+ This notice preserves third-party attribution. Original Joystyk code in the 17
4
+ platform libraries under packages/ uses the MIT license in packages/LICENSE.
5
+ That choice does not license the private applications, games or other repository
6
+ material, and does not replace the third-party terms below.
7
+
8
+ @joystyk/contracts
9
+ ------------------
10
+ Standard Schema v1 type definitions from @standard-schema/spec 1.1.0:
11
+ https://github.com/standard-schema/standard-schema
12
+ https://standardschema.dev
13
+ Copyright (c) 2024 Colin McDonnell. MIT License.
14
+ The complete license and source/change notes accompany the distributed
15
+ dist/standard-schema.d.ts definitions. Changes: individually exported declarations
16
+ and formatting; the declarations themselves are unchanged.
17
+
18
+ @joystyk/identity and @joystyk/content
19
+ -----------------------------------
20
+ Word blocklists derived from List of Dirty, Naughty, Obscene, and Otherwise Bad
21
+ Words, copyright Shutterstock, Inc. and contributors:
22
+ https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words
23
+ Licensed under Creative Commons Attribution 4.0 International:
24
+ https://creativecommons.org/licenses/by/4.0/
25
+ Identity uses the English list at commit
26
+ 5faf2ba42d7b1c0977169ec3611df25a3c08eb13. Changes include reducing or dropping
27
+ phrases, dropping words with everyday meanings, substituting stems for inflections
28
+ and adding entries. Identity's README and content's lists/blocklist.txt retain
29
+ their source-specific provenance and changes.
30
+
31
+ @joystyk/content
32
+ ---------------
33
+ SCOWL-derived common, vocabulary and ranked word lists use SCOWL 2020.12.07.
34
+ Their complete source licenses and attribution accompany the package in
35
+ lists/SCOWL-COPYRIGHT. Word-list transformations and build provenance are described
36
+ in the package README. Preserve that copyright file when redistributing the lists.
package/README.md CHANGED
@@ -1,3 +1,114 @@
1
- # Temporary Holding Version
1
+ # @joystyk/content
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The AI content pipeline, server only. It asks a model for the language parts of a daily
4
+ puzzle, checks every reply, and publishes the files the `daily` package's client downloads.
5
+ Nothing here runs in a browser. The design is
6
+ content-pipeline.md (Joystyk host reference); its *Implemented* section
7
+ lists where the build departs from it. Games plug in a generator from their `content/`
8
+ folder; apps/content (Joystyk host reference) runs the pipeline on a schedule.
9
+
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/content
16
+ ```
17
+
18
+ Requires Node 24.15 or later. This package runs on the server.
19
+
20
+ | Import | Format |
21
+ | --- | --- |
22
+ | `@joystyk/content` | ES module and TypeScript declarations |
23
+
24
+ Only the entries listed above are public API. Installed packages contain compiled ESM,
25
+ type declarations and runtime assets. Repository-relative links describe the Joystyk
26
+ workspace and are not needed to use the npm package.
27
+
28
+ ## The run
29
+
30
+ ```text
31
+ schedule ─▶ generate ─▶ schema ─▶ game validators ─▶ blocklist + sensitive list ─▶ model screen ─▶ validated ─▶ publish
32
+ ▲ │
33
+ └──────── up to 3 attempts per item per run; each retry hears what was wrong ───────────┘
34
+ past the deadline (48 h before release) and still failing: a bank item
35
+ ```
36
+
37
+ ```ts
38
+ import { createModelClient, createPublisher, defaultFilter, openStore, runPipeline } from '@joystyk/content';
39
+
40
+ const report = await runPipeline({
41
+ store: openStore('/data/content.db'),
42
+ generators: [wordOfTheDayGenerator()],
43
+ model: createModelClient({ baseUrl: process.env.CONTENT_MODEL_ORIGIN, key, model }),
44
+ filter: defaultFilter(),
45
+ publisher: createPublisher('/daily'), // null for a dry run
46
+ daysAhead: 1, // today and tomorrow
47
+ });
48
+ ```
49
+
50
+ - **Idempotent.** A slot already validated or published is left alone; a run cut short (a
51
+ `signal`) leaves its slot `scheduled`, and the next run resumes it.
52
+ - **Deterministic inputs.** A generator's `schedule` picks the slot's inputs (Word of the
53
+ Day's word, the answers' positions) from the date and the answers already used, so a rerun
54
+ asks for the same thing.
55
+ - **Answer history.** A generator can set `historyDays` when its avoidance rules require
56
+ more than the default 365 days. Scheduling, validation and fallback selection receive
57
+ that window on either side of the slot, including withdrawn answers. Honeycomb requests
58
+ five years for its full-hive words.
59
+ - **Circuit breaker.** Five endpoint errors in a row end generation for the run; banks cover
60
+ the slots past their deadline.
61
+ - **Takedowns.** `withdrawItem` marks an item withdrawn with a reason code and actor, drops it
62
+ from the index at once, and leaves the slot for the next run. The withdrawn answer counts as
63
+ used, so the replacement differs. The old file stays for games already started on it.
64
+ - **A game's own checks in the report.** `generate` receives `record(name, detail)`; what a
65
+ game records lands in that attempt's report as `checks[name]`, whether the attempt passes
66
+ or fails, and so in the item's provenance. Crossword records each variant's grids,
67
+ rewrites and fact-check verdicts this way.
68
+
69
+ ## Modules
70
+
71
+ | Module | Contents |
72
+ | --- | --- |
73
+ | `model.ts` | `createModelClient`: an OpenAI-compatible chat completions client on plain `fetch`, with a Bearer key. `complete({ name, schema, messages })` sends `z.toJSONSchema(schema)` as the `json_schema` response format, finds the JSON in the reply (reasoning blocks, prose and code fences are tolerated), and decodes it through the schema's Standard Schema interface. Timeouts, network errors, 408, 429 and 5xx retry with exponential backoff and `Retry-After`. A base URL works with or without `/v1`. If the endpoint refuses `response_format`, the schema goes into the prompt instead. Errors carry a kind and a safe message: never the key, the address or the reply text. |
74
+ | `pipeline.ts` | `runPipeline`, `rebuildIndex`, `withdrawItem`, and the `DailyGenerator` interface a game implements. |
75
+ | `packs.ts` | The non-daily item type: `runPackPipeline`, `rebuildPackIndex`, `withdrawPack`, `createPackPublisher` and the `PackGenerator` interface, for content keyed by a curated slot such as a theme (Letter Drop's word packs and Word Flux's catalogs). Same checks as a daily item; files go to `<dir>/<game>/packs/`. |
76
+ | `store.ts` | `openStore`: `content.db` on `node:sqlite` (WAL). Items with status, provenance (`source`, model, prompt version, seed, every attempt's report), runs, an event log, and the overlap lock (`tryLock`, `releaseLock`). Schema migrations by `user_version`; a database from a newer build is refused. |
77
+ | `publish.ts` | `createPublisher`: `<dir>/<game>/<date>.<hash>.json` and `<dir>/<game>/index.json` in the `daily` package's formats, written atomically. |
78
+ | `hash.ts` | `contentHash`: the first 12 hex digits of SHA-256 over the `daily` package's `canonicalJson`, the same digest the browser checks. |
79
+ | `filter.ts` | The all-ages word lists: whole-word matching in regular plural and verb forms, `*` stems, phrases, and every straight line of a letter grid. |
80
+ | `screen.ts` | The model screen, filter layer 3: `{ safe, reasons }`. It can only add a rejection. |
81
+ | `vocabulary.ts` | `scowlVocabulary()`: `isCommon` (SCOWL ≤ 50 plus `common-extra.txt`) and `isKnown` (SCOWL ≤ 70). |
82
+ | `ranked.ts` | `rankedWords()`: each word of `scowl-ranked.txt` with its SCOWL size; `scoreOfSize` turns a size into the crossword score (size 10 scores 100, size 60 scores 40). |
83
+ | `text.ts` | Tokens, a word's regular forms, stems, and the leak checks (`mentions`, `leaksLetters`). |
84
+ | `log.ts` | JSON-lines logger that redacts configured secrets (the key, the endpoint's origin and host) from every line. |
85
+
86
+ ## Word lists
87
+
88
+ `lists/` holds data with licenses (content-pipeline.md, *Word lists and licensing*):
89
+
90
+ | File | What | Source |
91
+ | --- | --- | --- |
92
+ | `scowl-common.txt`, `scowl-vocabulary.txt` | Words at SCOWL sizes 10–50, and 55–70 | SCOWL 2020.12.07, built by `node lists/build-lists.ts` from the pinned, checksummed release. Keep `SCOWL-COPYRIGHT` with them. Protected game names are left out. |
93
+ | `scowl-ranked.txt` | Lowercase words of 3 to 12 letters at SCOWL sizes 10–60, each with its smallest size level; no names, abbreviations or contractions | The same release, built by `node lists/build-ranked.ts` (`pnpm --filter @joystyk/content build-ranked`). Grid games build answers from it (`rankedWords`, `scoreOfSize`). |
94
+ | `common-extra.txt` | Everyday words SCOWL 2020 lacks ("app", "coder") | Original, hand-reviewed; keep it short |
95
+ | `blocklist.txt` | Profanity, slurs, sexual, drug and graphic-violence terms | Curated from LDNOOBW (CC BY 4.0), plus original additions; see its header |
96
+ | `sensitive.txt` | Topics wrong for a children's daily puzzle | Original |
97
+
98
+ Rebuild the SCOWL lists with `pnpm --filter @joystyk/content build-lists`; never edit them by hand.
99
+
100
+ Two games keep generated copies of `blocklist.txt`: Hangman's engine blocklist and Daily Word's
101
+ accepted guesses. After editing the blocklist, rebuild them with
102
+ `node games/hangman/src/content/build-blocklist.ts` and
103
+ `node games/daily-word/src/content/build-words.ts`; their unit tests fail until you do.
104
+
105
+ ## Tests
106
+
107
+ `src/*.test.ts` (Node), all against fakes; no test calls a real model. The model client against
108
+ a scripted `fetch` (URLs, headers, the schema, fences and reasoning, malformed JSON, schema
109
+ violations, truncation, retries and backoff, timeouts, aborts, the format fallback, no
110
+ secrets in errors); the pipeline with a toy game and a fake model (success, idempotent
111
+ reruns, each failure kind ending in a bank item, the screen, the circuit breaker, deadlines,
112
+ the publishing horizon, shutdown and resume, takedowns, dry runs); the store and its lock
113
+ across two connections; the hash against Web Crypto; the word lists. The live check against
114
+ the real endpoint is `pnpm validate:content-live` (apps/content (Joystyk host reference)).
@@ -0,0 +1,14 @@
1
+ import type { Json } from '@joystyk/contracts';
2
+ import type { DailyGenerator } from './pipeline.js';
3
+ import type { PackGenerator } from './packs.js';
4
+ import type { BankChange, ContentStore } from './store.js';
5
+ export interface BankEntry {
6
+ readonly key: string;
7
+ readonly config: Json;
8
+ readonly retired: boolean;
9
+ readonly custom: boolean;
10
+ }
11
+ /** Stable keys keep retirement and edits attached to the original item, never its array rank. */
12
+ export declare function dailyBankEntries(generator: DailyGenerator, changes: readonly BankChange[], puzzleId: string): BankEntry[];
13
+ export declare function withDailyBank(store: ContentStore, generator: DailyGenerator): DailyGenerator;
14
+ export declare function withPackBank(store: ContentStore, generator: PackGenerator): PackGenerator;
package/dist/banks.js ADDED
@@ -0,0 +1,55 @@
1
+ //#region src/banks.ts
2
+ var dated = (config, puzzleId) => typeof config === "object" && config !== null && !Array.isArray(config) && "puzzleId" in config ? {
3
+ ...config,
4
+ puzzleId
5
+ } : config;
6
+ /** Stable keys keep retirement and edits attached to the original item, never its array rank. */
7
+ function dailyBankEntries(generator, changes, puzzleId) {
8
+ const byKey = new Map(changes.map((change) => [change.key, change]));
9
+ return [...Array.from({ length: generator.bank.size }, (_, index) => {
10
+ const key = `base:${index}`;
11
+ const change = byKey.get(key);
12
+ return {
13
+ key,
14
+ config: dated(change?.config ?? generator.bank.config(index, puzzleId), puzzleId),
15
+ retired: change?.retired ?? false,
16
+ custom: false
17
+ };
18
+ }), ...changes.filter((change) => change.key.startsWith("staff:") && change.config !== null).map((change) => ({
19
+ key: change.key,
20
+ config: dated(change.config, puzzleId),
21
+ retired: change.retired,
22
+ custom: true
23
+ }))];
24
+ }
25
+ function withDailyBank(store, generator) {
26
+ const changes = store.bankChanges(generator.gameId);
27
+ if (changes.length === 0) return generator;
28
+ const byKey = new Map(changes.map((change) => [change.key, change]));
29
+ const keys = [...Array.from({ length: generator.bank.size }, (_, index) => `base:${index}`).filter((key) => byKey.get(key)?.retired !== true), ...changes.filter((change) => change.key.startsWith("staff:") && !change.retired && change.config !== null).map((change) => change.key)];
30
+ return {
31
+ ...generator,
32
+ bank: {
33
+ size: keys.length,
34
+ config: (index, puzzleId) => {
35
+ const key = keys[index];
36
+ const replacement = byKey.get(key)?.config;
37
+ return replacement === void 0 || replacement === null ? generator.bank.config(Number(key.slice(5)), puzzleId) : dated(replacement, puzzleId);
38
+ }
39
+ }
40
+ };
41
+ }
42
+ function withPackBank(store, generator) {
43
+ const changes = new Map(store.bankChanges(generator.gameId).map((change) => [change.key, change]));
44
+ return {
45
+ ...generator,
46
+ bank: (slot, itemId) => {
47
+ const change = changes.get(`pack:${slot.slug}`);
48
+ return change?.retired === true ? null : change?.config ?? generator.bank(slot, itemId);
49
+ }
50
+ };
51
+ }
52
+ //#endregion
53
+ export { dailyBankEntries, withDailyBank, withPackBank };
54
+
55
+ //# sourceMappingURL=banks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"banks.js","names":[],"sources":["../src/banks.ts"],"sourcesContent":["import type { Json } from '@joystyk/contracts';\nimport type { DailyGenerator } from './pipeline.ts';\nimport type { PackGenerator } from './packs.ts';\nimport type { BankChange, ContentStore } from './store.ts';\n\nexport interface BankEntry {\n readonly key: string;\n readonly config: Json;\n readonly retired: boolean;\n readonly custom: boolean;\n}\nconst dated = (config: Json, puzzleId: string): Json =>\n typeof config === 'object' && config !== null && !Array.isArray(config) && 'puzzleId' in config\n ? { ...config, puzzleId }\n : config;\n\n/** Stable keys keep retirement and edits attached to the original item, never its array rank. */\nexport function dailyBankEntries(\n generator: DailyGenerator,\n changes: readonly BankChange[],\n puzzleId: string,\n): BankEntry[] {\n const byKey = new Map(changes.map((change) => [change.key, change]));\n return [\n ...Array.from({ length: generator.bank.size }, (_, index) => {\n const key = `base:${index}`;\n const change = byKey.get(key);\n return {\n key,\n config: dated(change?.config ?? generator.bank.config(index, puzzleId), puzzleId),\n retired: change?.retired ?? false,\n custom: false,\n };\n }),\n ...changes\n .filter((change) => change.key.startsWith('staff:') && change.config !== null)\n .map((change) => ({\n key: change.key,\n config: dated(change.config!, puzzleId),\n retired: change.retired,\n custom: true,\n })),\n ];\n}\n\nexport function withDailyBank(store: ContentStore, generator: DailyGenerator): DailyGenerator {\n const changes = store.bankChanges(generator.gameId);\n if (changes.length === 0) return generator;\n const byKey = new Map(changes.map((change) => [change.key, change]));\n const keys = [\n ...Array.from({ length: generator.bank.size }, (_, index) => `base:${index}`).filter(\n (key) => byKey.get(key)?.retired !== true,\n ),\n ...changes\n .filter((change) => change.key.startsWith('staff:') && !change.retired && change.config !== null)\n .map((change) => change.key),\n ];\n return {\n ...generator,\n bank: {\n size: keys.length,\n config: (index, puzzleId) => {\n const key = keys[index]!;\n const replacement = byKey.get(key)?.config;\n return replacement === undefined || replacement === null\n ? generator.bank.config(Number(key.slice(5)), puzzleId)\n : dated(replacement, puzzleId);\n },\n },\n };\n}\n\nexport function withPackBank(store: ContentStore, generator: PackGenerator): PackGenerator {\n const changes = new Map(store.bankChanges(generator.gameId).map((change) => [change.key, change]));\n return {\n ...generator,\n bank: (slot, itemId) => {\n const change = changes.get(`pack:${slot.slug}`);\n return change?.retired === true ? null : (change?.config ?? generator.bank(slot, itemId));\n },\n };\n}\n"],"mappings":";AAWA,IAAM,SAAS,QAAc,aAC3B,OAAO,WAAW,YAAY,WAAW,QAAQ,CAAC,MAAM,QAAQ,MAAM,KAAK,cAAc,SACrF;CAAE,GAAG;CAAQ;AAAS,IACtB;;AAGN,SAAgB,iBACd,WACA,SACA,UACa;CACb,MAAM,QAAQ,IAAI,IAAI,QAAQ,KAAK,WAAW,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC;CACnE,OAAO,CACL,GAAG,MAAM,KAAK,EAAE,QAAQ,UAAU,KAAK,KAAK,IAAI,GAAG,UAAU;EAC3D,MAAM,MAAM,QAAQ;EACpB,MAAM,SAAS,MAAM,IAAI,GAAG;EAC5B,OAAO;GACL;GACA,QAAQ,MAAM,QAAQ,UAAU,UAAU,KAAK,OAAO,OAAO,QAAQ,GAAG,QAAQ;GAChF,SAAS,QAAQ,WAAW;GAC5B,QAAQ;EACV;CACF,CAAC,GACD,GAAG,QACA,QAAQ,WAAW,OAAO,IAAI,WAAW,QAAQ,KAAK,OAAO,WAAW,IAAI,CAAC,CAC7E,KAAK,YAAY;EAChB,KAAK,OAAO;EACZ,QAAQ,MAAM,OAAO,QAAS,QAAQ;EACtC,SAAS,OAAO;EAChB,QAAQ;CACV,EAAE,CACN;AACF;AAEA,SAAgB,cAAc,OAAqB,WAA2C;CAC5F,MAAM,UAAU,MAAM,YAAY,UAAU,MAAM;CAClD,IAAI,QAAQ,WAAW,GAAG,OAAO;CACjC,MAAM,QAAQ,IAAI,IAAI,QAAQ,KAAK,WAAW,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC;CACnE,MAAM,OAAO,CACX,GAAG,MAAM,KAAK,EAAE,QAAQ,UAAU,KAAK,KAAK,IAAI,GAAG,UAAU,QAAQ,OAAO,CAAC,CAAC,QAC3E,QAAQ,MAAM,IAAI,GAAG,CAAC,EAAE,YAAY,IACvC,GACA,GAAG,QACA,QAAQ,WAAW,OAAO,IAAI,WAAW,QAAQ,KAAK,CAAC,OAAO,WAAW,OAAO,WAAW,IAAI,CAAC,CAChG,KAAK,WAAW,OAAO,GAAG,CAC/B;CACA,OAAO;EACL,GAAG;EACH,MAAM;GACJ,MAAM,KAAK;GACX,SAAS,OAAO,aAAa;IAC3B,MAAM,MAAM,KAAK;IACjB,MAAM,cAAc,MAAM,IAAI,GAAG,CAAC,EAAE;IACpC,OAAO,gBAAgB,KAAA,KAAa,gBAAgB,OAChD,UAAU,KAAK,OAAO,OAAO,IAAI,MAAM,CAAC,CAAC,GAAG,QAAQ,IACpD,MAAM,aAAa,QAAQ;GACjC;EACF;CACF;AACF;AAEA,SAAgB,aAAa,OAAqB,WAAyC;CACzF,MAAM,UAAU,IAAI,IAAI,MAAM,YAAY,UAAU,MAAM,CAAC,CAAC,KAAK,WAAW,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC;CACjG,OAAO;EACL,GAAG;EACH,OAAO,MAAM,WAAW;GACtB,MAAM,SAAS,QAAQ,IAAI,QAAQ,KAAK,MAAM;GAC9C,OAAO,QAAQ,YAAY,OAAO,OAAQ,QAAQ,UAAU,UAAU,KAAK,MAAM,MAAM;EACzF;CACF;AACF"}
@@ -0,0 +1,12 @@
1
+ /** Removes reasoning blocks: <think>…</think>, and an unclosed leading block up to its end tag. */
2
+ export declare const stripReasoning: (text: string) => string;
3
+ /**
4
+ * The first JSON object (or array) in a model reply: the whole reply, then any fenced code
5
+ * block, then the first balanced `{…}` or `[…]` that parses. Undefined when there is none.
6
+ */
7
+ export declare const extractJson: (reply: string) => {
8
+ ok: true;
9
+ value: unknown;
10
+ } | {
11
+ ok: false;
12
+ };
@@ -0,0 +1,59 @@
1
+ //#region src/extract-json.ts
2
+ /** Removes reasoning blocks: <think>…</think>, and an unclosed leading block up to its end tag. */
3
+ var stripReasoning = (text) => text.replace(/<(think|thinking|reasoning)>[\s\S]*?<\/\1>/gi, "").replace(/^[\s\S]*?<\/(think|thinking|reasoning)>/i, "").trim();
4
+ var tryParse = (text) => {
5
+ try {
6
+ return {
7
+ ok: true,
8
+ value: JSON.parse(text)
9
+ };
10
+ } catch {
11
+ return { ok: false };
12
+ }
13
+ };
14
+ /** The end index (exclusive) of the balanced object or array starting at `start`, or -1. */
15
+ var balancedEnd = (text, start) => {
16
+ const stack = [];
17
+ let inString = false;
18
+ for (let i = start; i < text.length; i++) {
19
+ const ch = text[i];
20
+ if (inString) {
21
+ if (ch === "\\") i++;
22
+ else if (ch === "\"") inString = false;
23
+ continue;
24
+ }
25
+ if (ch === "\"") inString = true;
26
+ else if (ch === "{" || ch === "[") stack.push(ch === "{" ? "}" : "]");
27
+ else if (ch === "}" || ch === "]") {
28
+ if (stack.pop() !== ch) return -1;
29
+ if (stack.length === 0) return i + 1;
30
+ }
31
+ }
32
+ return -1;
33
+ };
34
+ /**
35
+ * The first JSON object (or array) in a model reply: the whole reply, then any fenced code
36
+ * block, then the first balanced `{…}` or `[…]` that parses. Undefined when there is none.
37
+ */
38
+ var extractJson = (reply) => {
39
+ const text = stripReasoning(reply);
40
+ const whole = tryParse(text);
41
+ if (whole.ok && typeof whole.value === "object" && whole.value !== null) return whole;
42
+ for (const fence of text.matchAll(/```(?:json|JSON)?\s*\n?([\s\S]*?)```/g)) {
43
+ const parsed = tryParse((fence[1] ?? "").trim());
44
+ if (parsed.ok && typeof parsed.value === "object" && parsed.value !== null) return parsed;
45
+ }
46
+ for (let i = 0; i < text.length; i++) {
47
+ const ch = text[i];
48
+ if (ch !== "{" && ch !== "[") continue;
49
+ const end = balancedEnd(text, i);
50
+ if (end < 0) continue;
51
+ const parsed = tryParse(text.slice(i, end));
52
+ if (parsed.ok) return parsed;
53
+ }
54
+ return { ok: false };
55
+ };
56
+ //#endregion
57
+ export { extractJson, stripReasoning };
58
+
59
+ //# sourceMappingURL=extract-json.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extract-json.js","names":[],"sources":["../src/extract-json.ts"],"sourcesContent":["// Finds the JSON value in a model reply. A JSON-schema response format usually yields bare\n// JSON, but some servers ignore it, and reasoning models may wrap the answer in a\n// <think>…</think> block, prose or a ```json code fence. This only *finds* the JSON; the\n// caller still decodes it through the item's schema (core belief 9), so nothing is guessed.\n\n/** Removes reasoning blocks: <think>…</think>, and an unclosed leading block up to its end tag. */\nexport const stripReasoning = (text: string): string =>\n text\n .replace(/<(think|thinking|reasoning)>[\\s\\S]*?<\\/\\1>/gi, '')\n .replace(/^[\\s\\S]*?<\\/(think|thinking|reasoning)>/i, '')\n .trim();\n\nconst tryParse = (text: string): { ok: true; value: unknown } | { ok: false } => {\n try {\n return { ok: true, value: JSON.parse(text) as unknown };\n } catch {\n return { ok: false };\n }\n};\n\n/** The end index (exclusive) of the balanced object or array starting at `start`, or -1. */\nconst balancedEnd = (text: string, start: number): number => {\n const stack: string[] = [];\n let inString = false;\n for (let i = start; i < text.length; i++) {\n const ch = text[i];\n if (inString) {\n if (ch === '\\\\') i++;\n else if (ch === '\"') inString = false;\n continue;\n }\n if (ch === '\"') inString = true;\n else if (ch === '{' || ch === '[') stack.push(ch === '{' ? '}' : ']');\n else if (ch === '}' || ch === ']') {\n if (stack.pop() !== ch) return -1;\n if (stack.length === 0) return i + 1;\n }\n }\n return -1;\n};\n\n/**\n * The first JSON object (or array) in a model reply: the whole reply, then any fenced code\n * block, then the first balanced `{…}` or `[…]` that parses. Undefined when there is none.\n */\nexport const extractJson = (reply: string): { ok: true; value: unknown } | { ok: false } => {\n const text = stripReasoning(reply);\n const whole = tryParse(text);\n if (whole.ok && typeof whole.value === 'object' && whole.value !== null) return whole;\n for (const fence of text.matchAll(/```(?:json|JSON)?\\s*\\n?([\\s\\S]*?)```/g)) {\n const parsed = tryParse((fence[1] ?? '').trim());\n if (parsed.ok && typeof parsed.value === 'object' && parsed.value !== null) return parsed;\n }\n for (let i = 0; i < text.length; i++) {\n const ch = text[i];\n if (ch !== '{' && ch !== '[') continue;\n const end = balancedEnd(text, i);\n if (end < 0) continue;\n const parsed = tryParse(text.slice(i, end));\n if (parsed.ok) return parsed;\n }\n return { ok: false };\n};\n"],"mappings":";;AAMA,IAAa,kBAAkB,SAC7B,KACG,QAAQ,gDAAgD,EAAE,CAAC,CAC3D,QAAQ,4CAA4C,EAAE,CAAC,CACvD,KAAK;AAEV,IAAM,YAAY,SAA+D;CAC/E,IAAI;EACF,OAAO;GAAE,IAAI;GAAM,OAAO,KAAK,MAAM,IAAI;EAAa;CACxD,QAAQ;EACN,OAAO,EAAE,IAAI,MAAM;CACrB;AACF;;AAGA,IAAM,eAAe,MAAc,UAA0B;CAC3D,MAAM,QAAkB,CAAC;CACzB,IAAI,WAAW;CACf,KAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,KAAK;EACxC,MAAM,KAAK,KAAK;EAChB,IAAI,UAAU;GACZ,IAAI,OAAO,MAAM;QACZ,IAAI,OAAO,MAAK,WAAW;GAChC;EACF;EACA,IAAI,OAAO,MAAK,WAAW;OACtB,IAAI,OAAO,OAAO,OAAO,KAAK,MAAM,KAAK,OAAO,MAAM,MAAM,GAAG;OAC/D,IAAI,OAAO,OAAO,OAAO,KAAK;GACjC,IAAI,MAAM,IAAI,MAAM,IAAI,OAAO;GAC/B,IAAI,MAAM,WAAW,GAAG,OAAO,IAAI;EACrC;CACF;CACA,OAAO;AACT;;;;;AAMA,IAAa,eAAe,UAAgE;CAC1F,MAAM,OAAO,eAAe,KAAK;CACjC,MAAM,QAAQ,SAAS,IAAI;CAC3B,IAAI,MAAM,MAAM,OAAO,MAAM,UAAU,YAAY,MAAM,UAAU,MAAM,OAAO;CAChF,KAAK,MAAM,SAAS,KAAK,SAAS,uCAAuC,GAAG;EAC1E,MAAM,SAAS,UAAU,MAAM,MAAM,GAAA,CAAI,KAAK,CAAC;EAC/C,IAAI,OAAO,MAAM,OAAO,OAAO,UAAU,YAAY,OAAO,UAAU,MAAM,OAAO;CACrF;CACA,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;EACpC,MAAM,KAAK,KAAK;EAChB,IAAI,OAAO,OAAO,OAAO,KAAK;EAC9B,MAAM,MAAM,YAAY,MAAM,CAAC;EAC/B,IAAI,MAAM,GAAG;EACb,MAAM,SAAS,SAAS,KAAK,MAAM,GAAG,GAAG,CAAC;EAC1C,IAAI,OAAO,IAAI,OAAO;CACxB;CACA,OAAO,EAAE,IAAI,MAAM;AACrB"}
@@ -0,0 +1,56 @@
1
+ export type FilterList = 'blocklist' | 'sensitive';
2
+ /** A text the filter reads, with a path that names it in reports ("entry.definition"). */
3
+ export interface FieldText {
4
+ readonly path: string;
5
+ readonly text: string;
6
+ }
7
+ export interface FilterHit {
8
+ readonly path: string;
9
+ readonly list: FilterList;
10
+ /** The list entry that matched. Logged, never shown to players. */
11
+ readonly term: string;
12
+ }
13
+ export interface FilterReport {
14
+ readonly ok: boolean;
15
+ readonly hits: readonly FilterHit[];
16
+ }
17
+ interface Term {
18
+ readonly entry: string;
19
+ /** Tokens; the last may be a prefix. */
20
+ readonly tokens: readonly string[];
21
+ readonly prefix: boolean;
22
+ /** The forms each token matches (unused for a prefix token). */
23
+ readonly forms: readonly Set<string>[];
24
+ }
25
+ /**
26
+ * The forms a list entry matches: the word, its plural and its verb endings. Deliberately
27
+ * narrower than `formsOf` (no -er, -ly or -y), so "butt" never matches "butter".
28
+ */
29
+ export declare const filterForms: (word: string) => Set<string>;
30
+ export declare const parseList: (text: string) => Term[];
31
+ export interface WordFilter {
32
+ /** The list entries a text contains. */
33
+ matches(text: string, list?: FilterList): {
34
+ list: FilterList;
35
+ term: string;
36
+ }[];
37
+ /** Layers 1 and 2 over every field. */
38
+ check(fields: readonly FieldText[]): FilterReport;
39
+ /** The blocklisted words of at least `minLength` letters along any straight line of a letter grid. */
40
+ checkGrid(rows: readonly string[], minLength?: number): FilterReport;
41
+ /** True when a single word (an answer, an option) is on either list. */
42
+ isListed(word: string): boolean;
43
+ }
44
+ /** A filter over the given list texts. `defaultFilter()` reads the repository's lists. */
45
+ export declare const createWordFilter: (lists: {
46
+ readonly blocklist: string;
47
+ readonly sensitive: string;
48
+ }) => WordFilter;
49
+ /**
50
+ * Every straight line of a letter grid, read both ways: rows, columns and both diagonals.
51
+ * Non-letter cells (blocks, spaces) break a line.
52
+ */
53
+ export declare const gridLines: (rows: readonly string[]) => string[];
54
+ /** The filter over lists/blocklist.txt and lists/sensitive.txt. */
55
+ export declare const defaultFilter: () => WordFilter;
56
+ export {};
package/dist/filter.js ADDED
@@ -0,0 +1,127 @@
1
+ import { fold, wordsOf } from "./text.js";
2
+ import { readFileSync } from "node:fs";
3
+ //#region src/filter.ts
4
+ var VOWEL = /[aeiou]/;
5
+ var consonant = (ch) => ch !== void 0 && /[a-z]/.test(ch) && !VOWEL.test(ch);
6
+ /**
7
+ * The forms a list entry matches: the word, its plural and its verb endings. Deliberately
8
+ * narrower than `formsOf` (no -er, -ly or -y), so "butt" never matches "butter".
9
+ */
10
+ var filterForms = (word) => {
11
+ const forms = /* @__PURE__ */ new Set([word, `${word}s`]);
12
+ const last = word.at(-1);
13
+ if (/(s|x|z|ch|sh)$/.test(word)) forms.add(`${word}es`);
14
+ if (word.length <= 4 && consonant(last) && VOWEL.test(word.at(-2) ?? "") && consonant(word.at(-3) ?? "b") && !/[wxy]/.test(last ?? "")) forms.add(`${word}${last}ed`).add(`${word}${last}ing`);
15
+ else if (last === "e") {
16
+ forms.add(`${word}d`);
17
+ if (word.length > 3) forms.add(`${word.slice(0, -1)}ing`);
18
+ } else if (last === "y" && consonant(word.at(-2))) forms.add(`${word.slice(0, -1)}ies`).add(`${word.slice(0, -1)}ied`).add(`${word}ing`);
19
+ else forms.add(`${word}ed`).add(`${word}ing`);
20
+ return forms;
21
+ };
22
+ var parseList = (text) => text.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")).map((entry) => {
23
+ const prefix = entry.endsWith("*");
24
+ const tokens = wordsOf(prefix ? entry.slice(0, -1) : entry);
25
+ return {
26
+ entry,
27
+ prefix,
28
+ tokens,
29
+ forms: tokens.map(filterForms)
30
+ };
31
+ }).filter((term) => term.tokens.length > 0);
32
+ var termAt = (tokens, i, term) => {
33
+ if (i + term.tokens.length > tokens.length) return false;
34
+ return term.tokens.every((word, k) => {
35
+ const token = tokens[i + k] ?? "";
36
+ return term.prefix && k === term.tokens.length - 1 ? token.startsWith(word) : term.forms[k]?.has(token) ?? false;
37
+ });
38
+ };
39
+ /** A filter over the given list texts. `defaultFilter()` reads the repository's lists. */
40
+ var createWordFilter = (lists) => {
41
+ const terms = {
42
+ blocklist: parseList(lists.blocklist),
43
+ sensitive: parseList(lists.sensitive)
44
+ };
45
+ const matches = (text, only) => {
46
+ const tokens = wordsOf(text);
47
+ const found = [];
48
+ for (const list of ["blocklist", "sensitive"]) {
49
+ if (only !== void 0 && list !== only) continue;
50
+ for (const term of terms[list]) for (let i = 0; i < tokens.length; i++) if (termAt(tokens, i, term)) {
51
+ found.push({
52
+ list,
53
+ term: term.entry
54
+ });
55
+ break;
56
+ }
57
+ }
58
+ return found;
59
+ };
60
+ const singleBlocked = terms.blocklist.filter((term) => term.tokens.length === 1);
61
+ return {
62
+ matches,
63
+ check: (fields) => {
64
+ const hits = fields.flatMap((field) => matches(field.text).map((hit) => ({
65
+ path: field.path,
66
+ ...hit
67
+ })));
68
+ return {
69
+ ok: hits.length === 0,
70
+ hits
71
+ };
72
+ },
73
+ checkGrid: (rows, minLength = 4) => {
74
+ const hits = [];
75
+ for (const [index, line] of gridLines(rows).entries()) for (const term of singleBlocked) {
76
+ const word = term.tokens[0] ?? "";
77
+ if (word.length >= minLength && line.includes(word)) hits.push({
78
+ path: `grid.line${String(index)}`,
79
+ list: "blocklist",
80
+ term: term.entry
81
+ });
82
+ }
83
+ return {
84
+ ok: hits.length === 0,
85
+ hits
86
+ };
87
+ },
88
+ isListed: (word) => matches(word).length > 0
89
+ };
90
+ };
91
+ /**
92
+ * Every straight line of a letter grid, read both ways: rows, columns and both diagonals.
93
+ * Non-letter cells (blocks, spaces) break a line.
94
+ */
95
+ var gridLines = (rows) => {
96
+ const grid = rows.map((row) => [...fold(row)]);
97
+ const height = grid.length;
98
+ const width = Math.max(0, ...grid.map((row) => row.length));
99
+ const at = (r, c) => grid[r]?.[c] ?? " ";
100
+ const lines = [];
101
+ const walk = (r, c, dr, dc) => {
102
+ let line = "";
103
+ for (; r >= 0 && r < height && c >= 0 && c < width; r += dr, c += dc) line += at(r, c);
104
+ for (const run of line.split(/[^a-z]+/)) if (run.length > 1) lines.push(run, [...run].reverse().join(""));
105
+ };
106
+ for (let r = 0; r < height; r++) walk(r, 0, 0, 1);
107
+ for (let c = 0; c < width; c++) walk(0, c, 1, 0);
108
+ for (let c = 0; c < width; c++) walk(0, c, 1, 1);
109
+ for (let r = 1; r < height; r++) walk(r, 0, 1, 1);
110
+ for (let c = 0; c < width; c++) walk(0, c, 1, -1);
111
+ for (let r = 1; r < height; r++) walk(r, width - 1, 1, -1);
112
+ return lines;
113
+ };
114
+ var readList = (name) => readFileSync(new URL(`../lists/${name}`, import.meta.url), "utf8");
115
+ var cached = null;
116
+ /** The filter over lists/blocklist.txt and lists/sensitive.txt. */
117
+ var defaultFilter = () => {
118
+ cached ??= createWordFilter({
119
+ blocklist: readList("blocklist.txt"),
120
+ sensitive: readList("sensitive.txt")
121
+ });
122
+ return cached;
123
+ };
124
+ //#endregion
125
+ export { createWordFilter, defaultFilter, filterForms, gridLines, parseList };
126
+
127
+ //# sourceMappingURL=filter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filter.js","names":[],"sources":["../src/filter.ts"],"sourcesContent":["// The all-ages filter (content-pipeline.md, \"The all-ages filter\"). Layers 1 and 2 are word\n// lists in lists/: the blocklist and the sensitive list. Matching is whole-word, in the\n// regular plural and verb forms of each entry, so innocent words that merely contain a bad\n// substring pass (\"class\", \"scamper\"). An entry ending in \"*\" matches any word starting with\n// it; an entry with spaces matches as a phrase. Letter grids are also read along every\n// straight line, where a blocklisted word of four or more letters rejects the grid.\n// Layer 3, the model screen, is in screen.ts.\nimport { readFileSync } from 'node:fs';\nimport { fold, wordsOf } from './text.ts';\n\nexport type FilterList = 'blocklist' | 'sensitive';\n\n/** A text the filter reads, with a path that names it in reports (\"entry.definition\"). */\nexport interface FieldText {\n readonly path: string;\n readonly text: string;\n}\n\nexport interface FilterHit {\n readonly path: string;\n readonly list: FilterList;\n /** The list entry that matched. Logged, never shown to players. */\n readonly term: string;\n}\n\nexport interface FilterReport {\n readonly ok: boolean;\n readonly hits: readonly FilterHit[];\n}\n\ninterface Term {\n readonly entry: string;\n /** Tokens; the last may be a prefix. */\n readonly tokens: readonly string[];\n readonly prefix: boolean;\n /** The forms each token matches (unused for a prefix token). */\n readonly forms: readonly Set<string>[];\n}\n\nconst VOWEL = /[aeiou]/;\nconst consonant = (ch: string | undefined) => ch !== undefined && /[a-z]/.test(ch) && !VOWEL.test(ch);\n\n/**\n * The forms a list entry matches: the word, its plural and its verb endings. Deliberately\n * narrower than `formsOf` (no -er, -ly or -y), so \"butt\" never matches \"butter\".\n */\nexport const filterForms = (word: string): Set<string> => {\n const forms = new Set([word, `${word}s`]);\n const last = word.at(-1);\n if (/(s|x|z|ch|sh)$/.test(word)) forms.add(`${word}es`);\n if (\n word.length <= 4 &&\n consonant(last) &&\n VOWEL.test(word.at(-2) ?? '') &&\n consonant(word.at(-3) ?? 'b') &&\n !/[wxy]/.test(last ?? '')\n ) {\n forms.add(`${word}${last}ed`).add(`${word}${last}ing`);\n } else if (last === 'e') {\n forms.add(`${word}d`);\n if (word.length > 3) forms.add(`${word.slice(0, -1)}ing`);\n } else if (last === 'y' && consonant(word.at(-2))) {\n forms\n .add(`${word.slice(0, -1)}ies`)\n .add(`${word.slice(0, -1)}ied`)\n .add(`${word}ing`);\n } else {\n forms.add(`${word}ed`).add(`${word}ing`);\n }\n return forms;\n};\n\nexport const parseList = (text: string): Term[] =>\n text\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line !== '' && !line.startsWith('#'))\n .map((entry) => {\n const prefix = entry.endsWith('*');\n const tokens = wordsOf(prefix ? entry.slice(0, -1) : entry);\n return { entry, prefix, tokens, forms: tokens.map(filterForms) };\n })\n .filter((term) => term.tokens.length > 0);\n\nconst termAt = (tokens: readonly string[], i: number, term: Term): boolean => {\n if (i + term.tokens.length > tokens.length) return false;\n return term.tokens.every((word, k) => {\n const token = tokens[i + k] ?? '';\n return term.prefix && k === term.tokens.length - 1 ? token.startsWith(word) : (term.forms[k]?.has(token) ?? false);\n });\n};\n\nexport interface WordFilter {\n /** The list entries a text contains. */\n matches(text: string, list?: FilterList): { list: FilterList; term: string }[];\n /** Layers 1 and 2 over every field. */\n check(fields: readonly FieldText[]): FilterReport;\n /** The blocklisted words of at least `minLength` letters along any straight line of a letter grid. */\n checkGrid(rows: readonly string[], minLength?: number): FilterReport;\n /** True when a single word (an answer, an option) is on either list. */\n isListed(word: string): boolean;\n}\n\n/** A filter over the given list texts. `defaultFilter()` reads the repository's lists. */\nexport const createWordFilter = (lists: { readonly blocklist: string; readonly sensitive: string }): WordFilter => {\n const terms: Record<FilterList, Term[]> = {\n blocklist: parseList(lists.blocklist),\n sensitive: parseList(lists.sensitive),\n };\n const matches = (text: string, only?: FilterList) => {\n const tokens = wordsOf(text);\n const found: { list: FilterList; term: string }[] = [];\n for (const list of ['blocklist', 'sensitive'] as const) {\n if (only !== undefined && list !== only) continue;\n for (const term of terms[list]) {\n for (let i = 0; i < tokens.length; i++) {\n if (termAt(tokens, i, term)) {\n found.push({ list, term: term.entry });\n break;\n }\n }\n }\n }\n return found;\n };\n const singleBlocked = terms.blocklist.filter((term) => term.tokens.length === 1);\n return {\n matches,\n check: (fields) => {\n const hits = fields.flatMap((field) => matches(field.text).map((hit) => ({ path: field.path, ...hit })));\n return { ok: hits.length === 0, hits };\n },\n checkGrid: (rows, minLength = 4) => {\n const hits: FilterHit[] = [];\n for (const [index, line] of gridLines(rows).entries()) {\n for (const term of singleBlocked) {\n const word = term.tokens[0] ?? '';\n if (word.length >= minLength && line.includes(word))\n hits.push({ path: `grid.line${String(index)}`, list: 'blocklist', term: term.entry });\n }\n }\n return { ok: hits.length === 0, hits };\n },\n isListed: (word) => matches(word).length > 0,\n };\n};\n\n/**\n * Every straight line of a letter grid, read both ways: rows, columns and both diagonals.\n * Non-letter cells (blocks, spaces) break a line.\n */\nexport const gridLines = (rows: readonly string[]): string[] => {\n const grid = rows.map((row) => [...fold(row)]);\n const height = grid.length;\n const width = Math.max(0, ...grid.map((row) => row.length));\n const at = (r: number, c: number) => grid[r]?.[c] ?? ' ';\n const lines: string[] = [];\n const walk = (r: number, c: number, dr: number, dc: number) => {\n let line = '';\n for (; r >= 0 && r < height && c >= 0 && c < width; r += dr, c += dc) line += at(r, c);\n for (const run of line.split(/[^a-z]+/)) if (run.length > 1) lines.push(run, [...run].reverse().join(''));\n };\n for (let r = 0; r < height; r++) walk(r, 0, 0, 1);\n for (let c = 0; c < width; c++) walk(0, c, 1, 0);\n for (let c = 0; c < width; c++) walk(0, c, 1, 1);\n for (let r = 1; r < height; r++) walk(r, 0, 1, 1);\n for (let c = 0; c < width; c++) walk(0, c, 1, -1);\n for (let r = 1; r < height; r++) walk(r, width - 1, 1, -1);\n return lines;\n};\n\nconst readList = (name: 'blocklist.txt' | 'sensitive.txt'): string =>\n readFileSync(new URL(`../lists/${name}`, import.meta.url), 'utf8');\n\nlet cached: WordFilter | null = null;\n\n/** The filter over lists/blocklist.txt and lists/sensitive.txt. */\nexport const defaultFilter = (): WordFilter => {\n cached ??= createWordFilter({ blocklist: readList('blocklist.txt'), sensitive: readList('sensitive.txt') });\n return cached;\n};\n"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import type { DailyGenerator } from './pipeline.js';
2
+ /** One registry for scheduled content and staff moderation. No game code is imported here. */
3
+ export declare const CONTENT_GAMES: {
4
+ readonly daily: readonly ["pipe-link", "mini-golf", "garden-crossing", "tower-stacker", "rock-field", "gem-swap", "map-guess", "equation", "word-hunt", "four-groups", "honeycomb", "word-of-the-day", "daily-word", "crossword", "word-flux", "freecell", "pyramid", "tripeaks", "golf", "code-breaker", "word-ladder", "queens", "tile-match"];
5
+ readonly packs: readonly ["letter-drop", "word-flux"];
6
+ };
7
+ export type DailyContentGame = (typeof CONTENT_GAMES.daily)[number];
8
+ /** Apps supply the implementations; the shared registry determines the complete lineup. */
9
+ export declare const dailyGenerators: (factories: Readonly<Record<DailyContentGame, () => DailyGenerator>>) => DailyGenerator[];
package/dist/games.js ADDED
@@ -0,0 +1,40 @@
1
+ //#region src/games.ts
2
+ /** One registry for scheduled content and staff moderation. No game code is imported here. */
3
+ var CONTENT_GAMES = {
4
+ daily: [
5
+ "pipe-link",
6
+ "mini-golf",
7
+ "garden-crossing",
8
+ "tower-stacker",
9
+ "rock-field",
10
+ "gem-swap",
11
+ "map-guess",
12
+ "equation",
13
+ "word-hunt",
14
+ "four-groups",
15
+ "honeycomb",
16
+ "word-of-the-day",
17
+ "daily-word",
18
+ "crossword",
19
+ "word-flux",
20
+ "freecell",
21
+ "pyramid",
22
+ "tripeaks",
23
+ "golf",
24
+ "code-breaker",
25
+ "word-ladder",
26
+ "queens",
27
+ "tile-match"
28
+ ],
29
+ packs: ["letter-drop", "word-flux"]
30
+ };
31
+ /** Apps supply the implementations; the shared registry determines the complete lineup. */
32
+ var dailyGenerators = (factories) => CONTENT_GAMES.daily.map((id) => {
33
+ const generator = factories[id]();
34
+ if (generator.gameId !== id) throw new Error(`The ${id} content factory returned ${generator.gameId}.`);
35
+ return generator;
36
+ });
37
+ //#endregion
38
+ export { CONTENT_GAMES, dailyGenerators };
39
+
40
+ //# sourceMappingURL=games.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"games.js","names":[],"sources":["../src/games.ts"],"sourcesContent":["import type { DailyGenerator } from './pipeline.ts';\n\n/** One registry for scheduled content and staff moderation. No game code is imported here. */\nexport const CONTENT_GAMES = {\n daily: [\n 'pipe-link',\n 'mini-golf',\n 'garden-crossing',\n 'tower-stacker',\n 'rock-field',\n 'gem-swap',\n 'map-guess',\n 'equation',\n 'word-hunt',\n 'four-groups',\n 'honeycomb',\n 'word-of-the-day',\n 'daily-word',\n 'crossword',\n 'word-flux',\n 'freecell',\n 'pyramid',\n 'tripeaks',\n 'golf',\n 'code-breaker',\n 'word-ladder',\n 'queens',\n 'tile-match',\n ],\n packs: ['letter-drop', 'word-flux'],\n} as const;\n\nexport type DailyContentGame = (typeof CONTENT_GAMES.daily)[number];\n\n/** Apps supply the implementations; the shared registry determines the complete lineup. */\nexport const dailyGenerators = (\n factories: Readonly<Record<DailyContentGame, () => DailyGenerator>>,\n): DailyGenerator[] =>\n CONTENT_GAMES.daily.map((id) => {\n const generator = factories[id]();\n if (generator.gameId !== id) throw new Error(`The ${id} content factory returned ${generator.gameId}.`);\n return generator;\n });\n"],"mappings":";;AAGA,IAAa,gBAAgB;CAC3B,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF;CACA,OAAO,CAAC,eAAe,WAAW;AACpC;;AAKA,IAAa,mBACX,cAEA,cAAc,MAAM,KAAK,OAAO;CAC9B,MAAM,YAAY,UAAU,GAAG,CAAC;CAChC,IAAI,UAAU,WAAW,IAAI,MAAM,IAAI,MAAM,OAAO,GAAG,4BAA4B,UAAU,OAAO,EAAE;CACtG,OAAO;AACT,CAAC"}
package/dist/hash.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { Json } from '@joystyk/contracts';
2
+ export declare const contentHash: (config: Json) => string;
3
+ /** A stable unsigned 32-bit number from a string (FNV-1a), for deterministic picks such as the bank's. */
4
+ export declare const stableIndex: (key: string, modulo: number) => number;