From 2aaba5d904409b6cf7f7f01c0d54f131e201900e Mon Sep 17 00:00:00 2001 From: ayo Date: Thu, 16 Jul 2026 16:10:46 +0200 Subject: [PATCH] chore: update docs --- package.json | 4 +- packages/leaderboard/README.md | 36 +++++ .../leaderboard/adapters/firebase-admin.d.ts | 58 ++++++++ .../leaderboard/adapters/firebase-admin.js | 100 +++++++++++++ packages/leaderboard/adapters/firebase.d.ts | 40 +++++ packages/leaderboard/adapters/supabase.d.ts | 35 +++++ packages/leaderboard/leader-board.d.ts | 67 +++++++++ packages/leaderboard/leaderboard-element.d.ts | 63 ++++++++ packages/leaderboard/leaderboard-read.d.ts | 115 +++++++++++++++ packages/leaderboard/leaderboard-write.d.ts | 39 +++++ packages/leaderboard/package.json | 8 +- .../test/firebase-admin-adapter.test.js | 123 ++++++++++++++++ packages/mnswpr/adapters/replay-common.d.ts | 18 +++ packages/mnswpr/adapters/replay-progress.d.ts | 27 ++++ packages/mnswpr/adapters/replay-state.d.ts | 38 +++++ packages/mnswpr/core/grid/grid.d.ts | 31 ++++ packages/mnswpr/core/grid/neighbors.d.ts | 29 ++++ packages/mnswpr/core/grid/neighbors.js | 2 + packages/mnswpr/core/grid/serialize.d.ts | 25 ++++ packages/mnswpr/core/index.d.ts | 9 ++ packages/mnswpr/core/minesweeper/board.d.ts | 99 +++++++++++++ packages/mnswpr/core/minesweeper/board.js | 2 +- packages/mnswpr/core/minesweeper/reveal.d.ts | 36 +++++ packages/mnswpr/core/minesweeper/rules.d.ts | 107 ++++++++++++++ packages/mnswpr/core/minesweeper/rules.js | 2 +- packages/mnswpr/core/session/replay.d.ts | 28 ++++ packages/mnswpr/core/session/rng.d.ts | 16 ++ packages/mnswpr/core/session/session.d.ts | 138 ++++++++++++++++++ packages/mnswpr/core/session/session.js | 2 +- packages/mnswpr/levels.d.ts | 45 ++++++ packages/mnswpr/package.json | 5 + packages/move-log/index.d.ts | 123 ++++++++++++++++ packages/move-log/package.json | 6 + packages/utils/date-bucket/date-bucket.d.ts | 8 + packages/utils/index.d.ts | 5 + packages/utils/loading/loading.d.ts | 4 + packages/utils/logger/logger.d.ts | 3 + packages/utils/package.json | 6 + packages/utils/storage/storage.d.ts | 6 + packages/utils/timer/timer.d.ts | 28 ++++ pnpm-lock.yaml | 20 +-- scripts/build-types.mjs | 31 ++++ tsconfig.types.json | 43 ++++++ 43 files changed, 1614 insertions(+), 16 deletions(-) create mode 100644 packages/leaderboard/adapters/firebase-admin.d.ts create mode 100644 packages/leaderboard/adapters/firebase-admin.js create mode 100644 packages/leaderboard/adapters/firebase.d.ts create mode 100644 packages/leaderboard/adapters/supabase.d.ts create mode 100644 packages/leaderboard/leader-board.d.ts create mode 100644 packages/leaderboard/leaderboard-element.d.ts create mode 100644 packages/leaderboard/leaderboard-read.d.ts create mode 100644 packages/leaderboard/leaderboard-write.d.ts create mode 100644 packages/leaderboard/test/firebase-admin-adapter.test.js create mode 100644 packages/mnswpr/adapters/replay-common.d.ts create mode 100644 packages/mnswpr/adapters/replay-progress.d.ts create mode 100644 packages/mnswpr/adapters/replay-state.d.ts create mode 100644 packages/mnswpr/core/grid/grid.d.ts create mode 100644 packages/mnswpr/core/grid/neighbors.d.ts create mode 100644 packages/mnswpr/core/grid/serialize.d.ts create mode 100644 packages/mnswpr/core/index.d.ts create mode 100644 packages/mnswpr/core/minesweeper/board.d.ts create mode 100644 packages/mnswpr/core/minesweeper/reveal.d.ts create mode 100644 packages/mnswpr/core/minesweeper/rules.d.ts create mode 100644 packages/mnswpr/core/session/replay.d.ts create mode 100644 packages/mnswpr/core/session/rng.d.ts create mode 100644 packages/mnswpr/core/session/session.d.ts create mode 100644 packages/mnswpr/levels.d.ts create mode 100644 packages/move-log/index.d.ts create mode 100644 packages/utils/date-bucket/date-bucket.d.ts create mode 100644 packages/utils/index.d.ts create mode 100644 packages/utils/loading/loading.d.ts create mode 100644 packages/utils/logger/logger.d.ts create mode 100644 packages/utils/storage/storage.d.ts create mode 100644 packages/utils/timer/timer.d.ts create mode 100644 scripts/build-types.mjs create mode 100644 tsconfig.types.json diff --git a/package.json b/package.json index 22828c8..2a987a6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "test": "vitest run", "dev": "pnpm -F mnswpr dev", "test:watch": "vitest", - "build": "pnpm -r --filter \"./packages/*\" run build", + "build": "pnpm build:types && pnpm -r --filter \"./packages/*\" run build", + "build:types": "node scripts/build-types.mjs", "build:lib": "vite build packages/mnswpr", "publish:lib": "node scripts/publish-lib.js", "release": "pnpm build:lib && pnpm -F @cozy-games/mnswpr run release && pnpm publish:lib", @@ -36,6 +37,7 @@ "jsdom": "^29.1.1", "secretlint": "^13.0.2", "simple-git": "^3.33.0", + "typescript": "^5.9.3", "vite": "^8.0.3", "vitest": "^4.1.9" }, diff --git a/packages/leaderboard/README.md b/packages/leaderboard/README.md index 7198d9d..46bee82 100644 --- a/packages/leaderboard/README.md +++ b/packages/leaderboard/README.md @@ -221,6 +221,42 @@ no deploy (wire the emulator into your own store if you inject one): new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } }) ``` +#### Client SDK vs Admin SDK (server-side writes) + +`FirebaseAdapter` is built on the **client** SDK (`firebase/firestore/lite`) — +the right choice for browser reads and for deployments where clients write +directly. When writes must run in a **privileged server context** — a Cloud +Function writing the ranked board that security rules deny to browsers — use the +Admin SDK instead. The two Firestore SDKs are call-incompatible (the client SDK +is free functions, `doc(store, …)`/`getDoc(ref)`; the Admin SDK is instance +methods, `store.doc(path).get()`), so the package ships a **second adapter** +rather than trying to make one `store` serve both: + +```js +import { getFirestore } from 'firebase-admin/firestore' +import { FirebaseAdminAdapter } from '@cozy-games/leaderboard/adapters/firebase-admin.js' +import { LeaderBoardWriter } from '@cozy-games/leaderboard/leaderboard-write.js' + +// inside a Cloud Function / trusted server context: +const adapter = new FirebaseAdminAdapter({ store: getFirestore(adminApp), namespace: 'mw' }) +await new LeaderBoardWriter({ adapter }).submit(entry) +``` + +Both adapters use the **same collections** (`{ns}-scores/{category}/games`, +`{ns}-all/{playerId}/games`, `{ns}-config/configuration`) and implement the same +`getConfig`/`listScores`/`addScore`/`archive` contract, so a client instance can +read exactly what an Admin instance wrote. `FirebaseAdminAdapter` **always** +takes an injected `store` (it never initializes an app and takes no +`firebase-admin` dependency of its own). + +**Injection contract — the exact Firestore methods each adapter calls** on the +injected `store`, so you can confirm an instance satisfies it: + +| Adapter | SDK | Methods called on `store` | +| --- | --- | --- | +| `FirebaseAdapter` | `firebase/firestore/lite` (client) | free functions `doc`, `getDoc`, `collection`, `query`, `where`, `orderBy`, `limit`, `getDocs`, `setDoc` — each passed the `store`/refs | +| `FirebaseAdminAdapter` | `firebase-admin/firestore` (Admin) | `store.doc(path)` → `.get()` / `.set(data, { merge })`; `store.collection(path)` → `.doc()` (auto-id), `.where()/.orderBy()/.limit()` (chainable) → `.get()` | + ### Supabase (Postgres) ```js diff --git a/packages/leaderboard/adapters/firebase-admin.d.ts b/packages/leaderboard/adapters/firebase-admin.d.ts new file mode 100644 index 0000000..5f14765 --- /dev/null +++ b/packages/leaderboard/adapters/firebase-admin.d.ts @@ -0,0 +1,58 @@ +/** + * Firestore storage adapter for the leaderboard built on the **Admin** SDK + * (`firebase-admin/firestore`), the server-side counterpart to + * {@link ./firebase.js}'s `FirebaseAdapter` (which uses the **client** + * `firebase/firestore/lite` SDK). + * + * Why a second adapter instead of one that takes either SDK by injection: the + * two Firestore SDKs expose the same operations through INCOMPATIBLE shapes. The + * client-lite SDK is a tree of free functions that take the store as an argument + * — `doc(store, …)`, `getDoc(ref)`, `setDoc(ref, data)`, `query(coll, …)`. The + * Admin SDK is method-based on the instance — `store.doc(path).get()`, + * `store.collection(path).doc().set(data)`, `coll.where(…).orderBy(…).get()`. + * There is no call-compatible intersection, so a single adapter cannot serve + * both from the same code path. This adapter implements the SAME leaderboard + * contract (`getConfig` / `listScores` / `addScore` / `archive`) against the + * Admin API, so a privileged server context (e.g. a Cloud Function running the + * ranked WRITE path that security rules deny to browsers) can reuse this exact + * package — no fork of `@cozy-games/leaderboard`. + * + * Collections are IDENTICAL to `FirebaseAdapter`, so the two SDKs read and write + * the same data: `{ns}-scores/{category}/games`, `{ns}-all/{playerId}/games`, + * `{ns}-config/configuration`. + * + * ## Injection contract (methods called on the injected `store`) + * An Admin Firestore instance (`getFirestore(adminApp)`) satisfies all of these: + * - `store.doc(path)` → DocumentReference + * - `.get()` → DocumentSnapshot (`.data()`) + * - `.set(data, { merge })` → Promise + * - `store.collection(path)` → CollectionReference (also a Query) + * - `.doc()` → DocumentReference with an auto-generated id + * - `.where(field, op, value)` / `.orderBy(field, direction)` / `.limit(n)` → Query (chainable) + * - `.get()` → QuerySnapshot (`.docs`, each a snapshot with `.data()`) + * + * The consumer ALWAYS supplies `store`; this adapter initializes no app and takes + * no `firebase-admin` dependency of its own (keeping the package's open-core, + * backend-agnostic boundary intact — see ADR 0001). + */ +export class FirebaseAdminAdapter { + /** + * @param {Object} options + * @param {Object} [options.store] - an Admin Firestore instance (`getFirestore(adminApp)`); required (the constructor throws without it), consumer-supplied + * @param {String} [options.namespace] - collection prefix (default `'lb'`) + */ + constructor(options?: { + store?: any; + namespace?: string; + }); + store: any; + namespace: string; + getConfig(): Promise; + /** + * @param {Object} q - { category, since, order, limit } + * @returns {Promise} plain score records, best first + */ + listScores(q: any): Promise; + addScore(category: any, entry: any): Promise; + archive(entry: any): Promise; +} diff --git a/packages/leaderboard/adapters/firebase-admin.js b/packages/leaderboard/adapters/firebase-admin.js new file mode 100644 index 0000000..9673a05 --- /dev/null +++ b/packages/leaderboard/adapters/firebase-admin.js @@ -0,0 +1,100 @@ +// @ts-check + +/** + * Firestore storage adapter for the leaderboard built on the **Admin** SDK + * (`firebase-admin/firestore`), the server-side counterpart to + * {@link ./firebase.js}'s `FirebaseAdapter` (which uses the **client** + * `firebase/firestore/lite` SDK). + * + * Why a second adapter instead of one that takes either SDK by injection: the + * two Firestore SDKs expose the same operations through INCOMPATIBLE shapes. The + * client-lite SDK is a tree of free functions that take the store as an argument + * — `doc(store, …)`, `getDoc(ref)`, `setDoc(ref, data)`, `query(coll, …)`. The + * Admin SDK is method-based on the instance — `store.doc(path).get()`, + * `store.collection(path).doc().set(data)`, `coll.where(…).orderBy(…).get()`. + * There is no call-compatible intersection, so a single adapter cannot serve + * both from the same code path. This adapter implements the SAME leaderboard + * contract (`getConfig` / `listScores` / `addScore` / `archive`) against the + * Admin API, so a privileged server context (e.g. a Cloud Function running the + * ranked WRITE path that security rules deny to browsers) can reuse this exact + * package — no fork of `@cozy-games/leaderboard`. + * + * Collections are IDENTICAL to `FirebaseAdapter`, so the two SDKs read and write + * the same data: `{ns}-scores/{category}/games`, `{ns}-all/{playerId}/games`, + * `{ns}-config/configuration`. + * + * ## Injection contract (methods called on the injected `store`) + * An Admin Firestore instance (`getFirestore(adminApp)`) satisfies all of these: + * - `store.doc(path)` → DocumentReference + * - `.get()` → DocumentSnapshot (`.data()`) + * - `.set(data, { merge })` → Promise + * - `store.collection(path)` → CollectionReference (also a Query) + * - `.doc()` → DocumentReference with an auto-generated id + * - `.where(field, op, value)` / `.orderBy(field, direction)` / `.limit(n)` → Query (chainable) + * - `.get()` → QuerySnapshot (`.docs`, each a snapshot with `.data()`) + * + * The consumer ALWAYS supplies `store`; this adapter initializes no app and takes + * no `firebase-admin` dependency of its own (keeping the package's open-core, + * backend-agnostic boundary intact — see ADR 0001). + */ +export class FirebaseAdminAdapter { + + /** + * @param {Object} options + * @param {Object} [options.store] - an Admin Firestore instance (`getFirestore(adminApp)`); required (the constructor throws without it), consumer-supplied + * @param {String} [options.namespace] - collection prefix (default `'lb'`) + */ + constructor(options = {}) { + if (!options.store) { + throw new TypeError('FirebaseAdminAdapter: provide `store` (an Admin Firestore instance)') + } + this.store = options.store + this.namespace = options.namespace || 'lb' + } + + async getConfig() { + const snapshot = await this.store.doc(`${this.namespace}-config/configuration`).get() + return snapshot.data() + } + + /** + * @param {Object} q - { category, since, order, limit } + * @returns {Promise} plain score records, best first + */ + async listScores(q) { + const games = this.store.collection(`${this.namespace}-scores/${q.category}/games`) + if (q.since) { + // Rolling window: Firestore requires the inequality field to sort first, + // so fetch the in-window rows (newest first, capped) and rank by score + // in-process. The cap is a safety bound for busy windows. Mirrors the + // client adapter exactly. + const snapshot = await games + .where('time_stamp', '>=', q.since) + .orderBy('time_stamp', 'desc') + .limit(500) + .get() + const rows = snapshot.docs.map(d => d.data()) + rows.sort((a, b) => q.order === 'desc' ? b.score - a.score : a.score - b.score) + return rows.slice(0, q.limit) + } + const snapshot = await games.orderBy('score', q.order).limit(q.limit).get() + return snapshot.docs.map(d => d.data()) + } + + async addScore(category, entry) { + await this.store.collection(`${this.namespace}-scores/${category}/games`).doc().set(entry) + } + + async archive(entry) { + const sessionId = new Date().toDateString().replace(/\s/g, '_') + const gameId = new Date().toTimeString().replace(/\s/g, '_') + const data = {} + data[gameId] = { + score: entry.score, + category: entry.category, + time_stamp: entry.time_stamp, + ...(entry.meta || {}) + } + await this.store.doc(`${this.namespace}-all/${entry.playerId}/games/${sessionId}`).set(data, { merge: true }) + } +} diff --git a/packages/leaderboard/adapters/firebase.d.ts b/packages/leaderboard/adapters/firebase.d.ts new file mode 100644 index 0000000..8e9a0a1 --- /dev/null +++ b/packages/leaderboard/adapters/firebase.d.ts @@ -0,0 +1,40 @@ +/** + * Firestore storage adapter for LeaderBoardService. Requires the `firebase` + * peer dependency. Collections are namespaced: `{ns}-scores/{category}/games`, + * `{ns}-all/{playerId}/games`, and `{ns}-config/configuration`. + */ +export class FirebaseAdapter { + /** + * Supply EITHER a ready Firestore instance via `store` (the injection point — + * e.g. a privileged/server-side setup, or an app you already initialized), OR a + * `firebaseConfig` for the package to initialize its own app. `store` wins when + * both are given; with an injected store the package initializes nothing and + * owns no app lifecycle (so `emulator` — a convenience of internal init — is + * ignored; wire the emulator into your own store). + * + * @param {Object} options + * @param {Object} [options.store] - a Firestore instance to use as-is (injection point) + * @param {Object} [options.firebaseConfig] - Firebase app config for internal init (public; access governed by security rules) + * @param {String} [options.namespace] - collection prefix + * @param {{ host?: string, port?: number }} [options.emulator] - point the internally-created store at a local Firestore emulator (dev/test only) + */ + constructor(options?: { + store?: any; + firebaseConfig?: any; + namespace?: string; + emulator?: { + host?: string; + port?: number; + }; + }); + namespace: string; + store: any; + getConfig(): Promise; + /** + * @param {Object} q - { category, since, order, limit } + * @returns {Promise} plain score records, best first + */ + listScores(q: any): Promise; + addScore(category: any, entry: any): Promise; + archive(entry: any): Promise; +} diff --git a/packages/leaderboard/adapters/supabase.d.ts b/packages/leaderboard/adapters/supabase.d.ts new file mode 100644 index 0000000..2a4fe2b --- /dev/null +++ b/packages/leaderboard/adapters/supabase.d.ts @@ -0,0 +1,35 @@ +/** + * Supabase (Postgres) storage adapter for LeaderBoardService. + * + * You pass in a supabase-js client you constructed yourself, so this package + * takes NO supabase dependency: + * + * import { createClient } from '@supabase/supabase-js' + * const client = createClient(url, anonKey) + * const adapter = new SupabaseAdapter({ client, namespace: 'mw' }) + * + * Expects three tables (see leaderboard/README.md for the SQL + row-level + * security): `{ns}_scores`, `{ns}_archive`, `{ns}_config`. Column names are + * snake_case; this adapter maps the generic entry's `playerId` <-> `player_id`. + */ +export class SupabaseAdapter { + /** + * @param {Object} options + * @param {Object} options.client - a supabase-js client + * @param {String} [options.namespace] - table prefix + */ + constructor(options?: { + client: any; + namespace?: string; + }); + client: any; + namespace: string; + getConfig(): Promise; + /** + * @param {Object} q - { category, since, order, limit } + * @returns {Promise} plain score records (must expose `name`, `score`) + */ + listScores(q: any): Promise; + addScore(category: any, entry: any): Promise; + archive(entry: any): Promise; +} diff --git a/packages/leaderboard/leader-board.d.ts b/packages/leaderboard/leader-board.d.ts new file mode 100644 index 0000000..fd8d4dd --- /dev/null +++ b/packages/leaderboard/leader-board.d.ts @@ -0,0 +1,67 @@ +/** + * Generic, game- AND backend-agnostic leaderboard. Nothing here knows about + * minesweeper or Firebase: the ranked value is a plain `score`, sorted in the + * configured direction and displayed through an injected formatter, while all + * storage I/O is delegated to an injected adapter (see adapters/). + * + * This is the COMBINED facade: it simply composes the two separable surfaces — + * {@link LeaderBoardReader} (read/subscribe/render) and {@link LeaderBoardWriter} + * (submit) — for consumers that want both in one object. Consumers who need only + * one half import it directly and pull in nothing from the other: + * + * import { LeaderBoardReader } from '@cozy-games/leaderboard/leaderboard-read.js' + * import { LeaderBoardWriter } from '@cozy-games/leaderboard/leaderboard-write.js' + * + * Public API is unchanged: `render()` (read) and `submit()` (write). The reader + * and writer are also exposed as `.reader` / `.writer` for direct access. Reads + * and writes may be wired to differently-privileged backend instances by passing + * the surfaces different adapters (construct a Reader and Writer directly). + * + * An adapter implements: + * - getConfig(): Promise + * - listScores({ category, since, order, limit }): Promise // read + * - addScore(category, entry): Promise // write + * - archive(entry): Promise // optional personal history // write + */ +export class LeaderBoardService { + /** + * @param {Object} options - see {@link LeaderBoardReader} and {@link LeaderBoardWriter} for the full set + * @param {Object} options.adapter - storage backend (e.g. FirebaseAdapter, SupabaseAdapter) + * @param {'asc'|'desc'} [options.scoreOrder] + * @param {(value: number) => string} [options.formatScore] + * @param {(entry: Object) => boolean} [options.qualifies] + * @param {Object} [options.labels] + * @param {Object} [options.tooltips] + * @param {string[]} [options.emptyMessages] + * @param {string} [options.loadingText] + * @param {string} [options.errorText] + * @param {string} [options.anonymousName] + */ + constructor(options?: { + adapter: any; + scoreOrder?: "asc" | "desc"; + formatScore?: (value: number) => string; + qualifies?: (entry: any) => boolean; + labels?: any; + tooltips?: any; + emptyMessages?: string[]; + loadingText?: string; + errorText?: string; + anonymousName?: string; + }); + adapter: any; + reader: LeaderBoardReader; + writer: LeaderBoardWriter; + /** + * Read surface — render the ranked list with a duration tab bar. + * @see LeaderBoardReader#render + */ + render(category: any, title: any, duration: any): Promise; + /** + * Write surface — submit a completed game (archive + ranked entry). + * @see LeaderBoardWriter#submit + */ + submit(entry: any): Promise; +} +import { LeaderBoardReader } from './leaderboard-read.js'; +import { LeaderBoardWriter } from './leaderboard-write.js'; diff --git a/packages/leaderboard/leaderboard-element.d.ts b/packages/leaderboard/leaderboard-element.d.ts new file mode 100644 index 0000000..bc74d86 --- /dev/null +++ b/packages/leaderboard/leaderboard-element.d.ts @@ -0,0 +1,63 @@ +/** + * Configure the backend + defaults for all elements. Call + * once at startup, after building your adapter (Firebase/Supabase/…). User-facing + * strings (labels, emptyMessages, loadingText, errorText, anonymousName) can be + * passed here to localize without changing the package. + * @param {Object} options - { adapter, scoreOrder?, format?, formatScore?, qualifies?, labels?, emptyMessages?, loadingText?, errorText?, anonymousName? } + */ +export function configureLeaderboard(options?: any): void; +export class CozyLeaderboard extends WebComponent { + static props: { + category: string; + title: string; + duration: string; + scoreOrder: string; + format: string; + }; + _view: { + board: boolean; + }; + _activeDuration: any; + _connected: boolean; + _token: number; + /** + * WCB change hook — `property` is the camelCase prop name (WCB ≥5). A title + * change needs no re-query: the heading reads `this.props.title`, so the base + * class's own render already updated it. score-order/format changes rebuild + * the service so the new config actually takes effect. + */ + onChanges({ property }: { + property: any; + }): void; + _svc: LeaderBoardService; + _service(): LeaderBoardService; + /** + * (Re)mount the board. The first mount honors the author's `duration` + * attribute; later mounts keep the selected duration (so switching category + * keeps the selected tab) unless a duration is passed explicitly. + */ + _mount(durationArg: any): void; + _selectTab(id: any): void; + /** + * Query one duration window and project the result into view state: a + * loading message immediately, then rows / a random empty message / the + * error text. The token guards against a stale response (quick tab or + * category switches) overwriting a newer one. + */ + _load(service: any, durationId: any): Promise; + /** + * Render the current view state through WCB. WCB's render() replaces the + * whole subtree (no diffing yet), which would drop focus from a clicked + * duration tab — the one behavior the base class can't preserve for us — so + * focus is handed to the replacement tab explicitly. + */ + _paint(): void; + /** + * Submit a finished game through this element's service — keeps score + * submission a one-liner from the host app. + * @param {Object} entry + */ + submit(entry: any): Promise; +} +import { WebComponent } from 'web-component-base'; +import { LeaderBoardService } from './leader-board.js'; diff --git a/packages/leaderboard/leaderboard-read.d.ts b/packages/leaderboard/leaderboard-read.d.ts new file mode 100644 index 0000000..33ef309 --- /dev/null +++ b/packages/leaderboard/leaderboard-read.d.ts @@ -0,0 +1,115 @@ +/** + * The four time windows are ROLLING: each shows entries from the last `ms` + * milliseconds (strictly nested — 24h ⊆ 7d ⊆ 30d ⊆ all), sorted by score. + * `ms: null` is the all-time view (no time filter). `title` is the hover tooltip + * that spells out the window. Exported so view layers (e.g. the + * `` element) can render the tab bar themselves. + */ +export const DURATIONS: { + id: string; + label: string; + ms: number; + title: string; +}[]; +/** + * Read-only leaderboard view: windows, sorting (via the adapter), and rendering. + * Nothing here writes; the ranked value is a plain `score` displayed through an + * injected formatter, and all query I/O is delegated to an injected adapter's + * `listScores`. Safe to wire to a read-only / less-privileged backend instance. + */ +export class LeaderBoardReader { + /** + * @param {Object} options + * @param {Object} options.adapter - storage backend; the READ side uses `listScores` + * @param {'asc'|'desc'} [options.scoreOrder] - 'asc' = lower is better (e.g. time), 'desc' = higher is better + * @param {(value: number) => string} [options.formatScore] - display formatter for a score + * @param {Object} [options.labels] - optional tab-label overrides keyed by duration id + * @param {Object} [options.tooltips] - optional tab hover-text overrides keyed by duration id + * @param {string[]} [options.emptyMessages] - empty-state messages (one picked at random); localize here + * @param {string} [options.loadingText] - shown while a window loads + * @param {string} [options.errorText] - shown when a window fails to load + * @param {string} [options.anonymousName] - fallback display name for entries without one + */ + constructor(options?: { + adapter: any; + scoreOrder?: "asc" | "desc"; + formatScore?: (value: number) => string; + labels?: any; + tooltips?: any; + emptyMessages?: string[]; + loadingText?: string; + errorText?: string; + anonymousName?: string; + }); + adapter: any; + scoreOrder: string; + formatScore: (value: number) => string; + labels: any; + tooltips: any; + emptyMessages: string[]; + loadingText: string; + errorText: string; + anonymousName: string; + /** + * Display label for a duration window (override-aware). + * @param {{ id: String, label: String }} duration - a DURATIONS entry + */ + label(duration: { + id: string; + label: string; + }): any; + /** + * Hover tooltip for a duration window (override-aware). + * @param {{ id: String, title: String }} duration - a DURATIONS entry + */ + tooltip(duration: { + id: string; + title: string; + }): any; + /** One empty-state message, picked at random. */ + emptyMessage(): string; + /** + * Data-level query: the ranked entries for a category and duration window, + * without any DOM. View layers that render themselves (e.g. the + * `` element) use this instead of {@link render}. + * @param {String} category + * @param {String} durationId - a DURATIONS id ('today' | 'week' | 'month' | 'all') + * @returns {Promise} + */ + list(category: string, durationId: string): Promise; + /** + * Backend-neutral query descriptor for a category and time window. `since` is + * the rolling cutoff (entries with `time_stamp >= since`); `null` means + * all-time (no time filter). The adapter turns this into a real query. + */ + _descriptor(category: any, duration: any): { + category: any; + since: Date; + order: string; + limit: number; + }; + /** + * Render the leaderboard for a category with a duration tab bar. When + * `duration` is omitted the last-selected tab is reused (so switching game + * category keeps the player on the same window), defaulting to "today". + * Returns the wrapper element; tab clicks re-query in place. + * @param {String} category + * @param {String} title + * @param {String} [duration] + * @returns {Promise} + */ + render(category: string, title: string, duration?: string): Promise; + category: string; + title: string; + duration: any; + _styleTab(tab: any, active: any): void; + /** + * Load a window's entries into the list area, showing a loading placeholder + * and turning any failure (e.g. the backend being unreachable) into a + * message instead of an unhandled rejection. Guards against races when the + * player switches tabs quickly by tagging the in-flight request. + */ + _loadList(listWrapper: any, category: any, duration: any): Promise; + _loadToken: any; + _renderList(listWrapper: any, rows: any): void; +} diff --git a/packages/leaderboard/leaderboard-write.d.ts b/packages/leaderboard/leaderboard-write.d.ts new file mode 100644 index 0000000..d87943f --- /dev/null +++ b/packages/leaderboard/leaderboard-write.d.ts @@ -0,0 +1,39 @@ +/** + * The WRITE surface of the leaderboard: submitting a completed game — the + * personal archive plus, if it qualifies, a ranked entry with denormalized + * day/week/month bucket keys. Importable WITHOUT any read/render code — no DOM, + * no list rendering, no `listScores` — so a consumer can wire writes to a + * separate, more-privileged backend instance (leaderboard-01) and pull in none + * of the read surface. + * + * The WRITE side calls `adapter.archive` (optional) and `adapter.addScore`; it + * also reads the ranking `config` once (an adapter call, not read/render code) + * to power the default qualifier. + */ +export class LeaderBoardWriter { + /** + * @param {Object} options + * @param {Object} options.adapter - storage backend; the WRITE side uses `addScore`, optional `archive`, and `getConfig` + * @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status + */ + constructor(options?: { + adapter: any; + qualifies?: (entry: any) => boolean; + }); + adapter: any; + qualifies: (entry: any) => boolean; + configuration: any; + /** + * Default ranking gate: if the server config names a `passingStatus`, only + * entries whose `status` matches qualify; otherwise every entry qualifies. + */ + _defaultQualifies(entry: any): boolean; + /** + * Submit a completed game. Always archives it (personal history); if it + * qualifies, also writes a ranked entry with denormalized bucket keys. Both + * writes go through the adapter, so the storage backend is pluggable. The + * caller owns display-name/nickname UX. + * @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? } + */ + submit(entry: any): Promise; +} diff --git a/packages/leaderboard/package.json b/packages/leaderboard/package.json index d360ce1..8063e89 100644 --- a/packages/leaderboard/package.json +++ b/packages/leaderboard/package.json @@ -19,6 +19,10 @@ "./dist/*": { "default": "./dist/*" }, + "./*.js": { + "types": "./*.d.ts", + "default": "./*.js" + }, "./*": { "default": "./*" } @@ -28,11 +32,9 @@ "./dist" ], "dependencies": { + "@cozy-games/utils": "workspace:^", "web-component-base": "^5.0.0" }, - "devDependencies": { - "@cozy-games/utils": "workspace:*" - }, "peerDependencies": { "firebase": "^12.11.0" }, diff --git a/packages/leaderboard/test/firebase-admin-adapter.test.js b/packages/leaderboard/test/firebase-admin-adapter.test.js new file mode 100644 index 0000000..de3c3e9 --- /dev/null +++ b/packages/leaderboard/test/firebase-admin-adapter.test.js @@ -0,0 +1,123 @@ +// @ts-check +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { FirebaseAdminAdapter } from '../adapters/firebase-admin.js' + +/** + * A faithful stand-in for a `firebase-admin/firestore` instance: METHOD-based + * (`store.doc(path).get()`, `store.collection(path).doc().set()`, chained + * `where`/`orderBy`/`limit().get()`) — unlike the client-lite SDK's free + * functions. Records every call and path so we can assert the adapter drives the + * Admin API correctly. `queryDocs` is what a collection `.get()` returns. + */ +function makeAdminStore(queryDocs = []) { + const calls = { collectionPaths: [], docPaths: [], where: [], orderBy: [], limit: [], sets: [], autoDocs: 0, gets: 0 } + + const autoDoc = { + set: vi.fn(async (data) => { calls.sets.push({ via: 'collection.doc', data }) }) + } + + function makeCollection() { + const col = { + where: vi.fn((...a) => { calls.where.push(a); return col }), + orderBy: vi.fn((...a) => { calls.orderBy.push(a); return col }), + limit: vi.fn((...a) => { calls.limit.push(a); return col }), + get: vi.fn(async () => { calls.gets++; return { docs: queryDocs.map(d => ({ data: () => d })) } }), + doc: vi.fn(() => { calls.autoDocs++; return autoDoc }) + } + return col + } + + function makeDoc(path) { + return { + get: vi.fn(async () => ({ data: () => ({ passingStatus: 'ok' }), exists: true })), + set: vi.fn(async (data, opts) => { calls.sets.push({ via: 'doc', path, data, opts }) }) + } + } + + const store = { + collection: vi.fn((path) => { calls.collectionPaths.push(path); return makeCollection() }), + doc: vi.fn((path) => { calls.docPaths.push(path); return makeDoc(path) }) + } + + return { store, calls, autoDoc } +} + +describe('FirebaseAdminAdapter — Admin (method-based) injected store', () => { + it('requires an injected store', () => { + expect(() => new FirebaseAdminAdapter({})).toThrow(TypeError) + }) + + it('defaults the namespace to "lb"', () => { + const { store } = makeAdminStore() + expect(new FirebaseAdminAdapter({ store }).namespace).toBe('lb') + }) + + it('reads config from {ns}-config/configuration via store.doc().get()', async () => { + const { store, calls } = makeAdminStore() + const adapter = new FirebaseAdminAdapter({ store, namespace: 'mw' }) + const config = await adapter.getConfig() + expect(calls.docPaths).toEqual(['mw-config/configuration']) + expect(config).toEqual({ passingStatus: 'ok' }) + }) + + it('lists all-time scores ordered by score, capped at limit', async () => { + const docs = [{ name: 'A', score: 5 }, { name: 'B', score: 9 }] + const { store, calls } = makeAdminStore(docs) + const adapter = new FirebaseAdminAdapter({ store, namespace: 'mw' }) + + const rows = await adapter.listScores({ category: 'beginner', since: null, order: 'asc', limit: 10 }) + + expect(calls.collectionPaths).toEqual(['mw-scores/beginner/games']) + expect(calls.orderBy).toEqual([['score', 'asc']]) + expect(calls.limit).toEqual([[10]]) + expect(calls.where).toEqual([]) // no time filter on the all-time window + expect(rows).toEqual(docs) + }) + + it('lists a rolling window: filters by time_stamp, then ranks by score in-process and slices to limit', async () => { + // Returned newest-first by the query; the adapter must re-rank by score and + // slice to `limit` (here 2), exactly like the client adapter. + const windowDocs = [{ score: 3 }, { score: 8 }, { score: 1 }, { score: 5 }] + const { store, calls } = makeAdminStore(windowDocs) + const adapter = new FirebaseAdminAdapter({ store, namespace: 'mw' }) + const since = new Date('2026-07-01T00:00:00Z') + + const rows = await adapter.listScores({ category: 'expert', since, order: 'desc', limit: 2 }) + + expect(calls.collectionPaths).toEqual(['mw-scores/expert/games']) + expect(calls.where).toEqual([['time_stamp', '>=', since]]) + expect(calls.orderBy).toEqual([['time_stamp', 'desc']]) + expect(calls.limit).toEqual([[500]]) + // desc → highest score first, top 2 + expect(rows).toEqual([{ score: 8 }, { score: 5 }]) + }) + + it('writes a ranked score to an auto-id doc under {ns}-scores/{category}/games', async () => { + const { store, calls, autoDoc } = makeAdminStore() + const adapter = new FirebaseAdminAdapter({ store, namespace: 'mw' }) + const entry = { name: 'A', score: 42, category: 'beginner', time_stamp: 123 } + + await adapter.addScore('beginner', entry) + + expect(calls.collectionPaths).toEqual(['mw-scores/beginner/games']) + expect(calls.autoDocs).toBe(1) + expect(autoDoc.set).toHaveBeenCalledWith(entry) + }) + + it('archives to {ns}-all/{playerId}/games/{session} with merge', async () => { + const { store, calls } = makeAdminStore() + const adapter = new FirebaseAdminAdapter({ store, namespace: 'mw' }) + + await adapter.archive({ playerId: 'p1', score: 9, category: 'beginner', time_stamp: 5, meta: { isMobile: true } }) + + const set = calls.sets.find(s => s.via === 'doc') + expect(set).toBeTruthy() + expect(set.path).toMatch(/^mw-all\/p1\/games\//) + expect(set.opts).toEqual({ merge: true }) + // one gameId key, carrying the score + merged meta + const gameEntry = Object.values(set.data)[0] + expect(gameEntry).toMatchObject({ score: 9, category: 'beginner', time_stamp: 5, isMobile: true }) + }) +}) + +beforeEach(() => vi.clearAllMocks()) diff --git a/packages/mnswpr/adapters/replay-common.d.ts b/packages/mnswpr/adapters/replay-common.d.ts new file mode 100644 index 0000000..4c9cf28 --- /dev/null +++ b/packages/mnswpr/adapters/replay-common.d.ts @@ -0,0 +1,18 @@ +/** + * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + */ +/** + * Map a recorded move-event back to the rules move that produced it: `flag` and + * `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through. + * Unknown kinds are ignored. Shared by the mnswpr replay adapters (progress and + * full-board state) so both replay a stream through the core rules identically. + * + * @param {MnswprMoveEvent} e + * @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null} + */ +export function toMove(e: MnswprMoveEvent): { + type: "reveal" | "flag" | "chord"; + r: number; + c: number; +} | null; +export type MnswprMoveEvent = import("../core/minesweeper/rules.js").MoveEvent; diff --git a/packages/mnswpr/adapters/replay-progress.d.ts b/packages/mnswpr/adapters/replay-progress.d.ts new file mode 100644 index 0000000..65c29a3 --- /dev/null +++ b/packages/mnswpr/adapters/replay-progress.d.ts @@ -0,0 +1,27 @@ +/** + * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + * @typedef {import('../core/minesweeper/board.js').Layout} Layout + */ +/** + * The percent-cleared progress reducer for Minesweeper — mnswpr's first concrete + * implementation of the replay engine's `ProgressReducer` seam + * (see `@cozy-games/replay` / replay-02). Given the ordered slice of move-events + * played so far, it returns completion as + * `revealed safe cells / total safe cells * 100`. + * + * Why it needs the board: a single `reveal` or `chord` event floods MANY cells, + * but the recorded move-event only carries `{ type, r, c }` — not how many cells + * opened. So the reducer takes the board as closure input (consistent with the + * interface design) and replays the moves through the pure core rules. That makes + * reveals flood, chords reveal via their (non-flagged) neighbors, and + * flags/unflags only gate chords — never advancing progress themselves — with no + * cell double-counted. The engine stays game-blind; all interpretation is here. + * + * @param {Layout} layout - the recorded board (as produced by `generateBoard`) + * @returns {(events: { event: MnswprMoveEvent }[]) => number} a reducer to `[0, 100]` + */ +export function createProgressReducer(layout: Layout): (events: { + event: MnswprMoveEvent; +}[]) => number; +export type MnswprMoveEvent = import("../core/minesweeper/rules.js").MoveEvent; +export type Layout = import("../core/minesweeper/board.js").Layout; diff --git a/packages/mnswpr/adapters/replay-state.d.ts b/packages/mnswpr/adapters/replay-state.d.ts new file mode 100644 index 0000000..866d5be --- /dev/null +++ b/packages/mnswpr/adapters/replay-state.d.ts @@ -0,0 +1,38 @@ +/** + * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + * @typedef {import('../core/minesweeper/board.js').Layout} Layout + * @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell + * @typedef {{ rows: number, cols: number, phase: string, revealedSafe: number, cells: BoardCell[][] }} BoardState + */ +/** + * The full-board state reducer for Minesweeper — mnswpr's implementation of the + * replay engine's `StateReducer` seam (replay-05). + * Given the ordered slice of move-events played so far, it reconstructs the + * COMPLETE board at that point: every cell's mine/adjacent/status plus the phase. + * + * Like the progress reducer, it takes the board as closure input and replays the + * moves through the pure core rules — so reveals flood, chords open their + * neighbors, and flags toggle — giving an exact reconstruction at any event + * index. Statelessly a function of the slice, so the engine can jump (seek) to + * any position and rebuild the board there. + * + * @param {Layout} layout - the recorded board (as produced by `generateBoard`) + * @returns {(events: { event: MnswprMoveEvent }[]) => BoardState} + */ +export function createStateReducer(layout: Layout): (events: { + event: MnswprMoveEvent; +}[]) => BoardState; +export type MnswprMoveEvent = import("../core/minesweeper/rules.js").MoveEvent; +export type Layout = import("../core/minesweeper/board.js").Layout; +export type BoardCell = { + mine: boolean; + adjacent: number; + status: "hidden" | "flagged" | "revealed"; +}; +export type BoardState = { + rows: number; + cols: number; + phase: string; + revealedSafe: number; + cells: BoardCell[][]; +}; diff --git a/packages/mnswpr/core/grid/grid.d.ts b/packages/mnswpr/core/grid/grid.d.ts new file mode 100644 index 0000000..383e2f8 --- /dev/null +++ b/packages/mnswpr/core/grid/grid.d.ts @@ -0,0 +1,31 @@ +/** + * Layer 0 — a dense 2D container of opaque cells. Knows nothing about game + * meaning (no "mine", no "reveal"). Future home: @cozy-games/grid. + * + * @template Cell + */ +export class Grid { + /** + * @param {number} rows + * @param {number} cols + * @param {(r: number, c: number) => Cell} [fill] - factory for each cell + */ + constructor(rows: number, cols: number, fill?: (r: number, c: number) => Cell); + rows: number; + cols: number; + /** @type {Cell[]} */ + _cells: Cell[]; + /** @returns {boolean} */ + inBounds(r: any, c: any): boolean; + /** @returns {Cell} */ + at(r: any, c: any): Cell; + set(r: any, c: any, cell: any): void; + /** @param {(cell: Cell, r: number, c: number) => void} fn */ + forEach(fn: (cell: Cell, r: number, c: number) => void): void; + /** + * @template Out + * @param {(cell: Cell, r: number, c: number) => Out} fn + * @returns {Grid} + */ + map(fn: (cell: Cell, r: number, c: number) => Out): Grid; +} diff --git a/packages/mnswpr/core/grid/neighbors.d.ts b/packages/mnswpr/core/grid/neighbors.d.ts new file mode 100644 index 0000000..dd3e1ec --- /dev/null +++ b/packages/mnswpr/core/grid/neighbors.d.ts @@ -0,0 +1,29 @@ +/** + * Neighbor STRATEGIES — the extraction seam. A game injects the topology it + * wants; the grid layer never assumes one. Minesweeper uses `eightWay`; a future + * Sudoku would inject its row/col/box `peers`. Each returns in-bounds [r, c] + * coordinate pairs. + * + * @typedef {{ inBounds: (r: number, c: number) => boolean }} Boundable + */ +/** + * All 8 surrounding cells (orthogonal + diagonal). + * @param {Boundable} grid + * @returns {Array<[number, number]>} + */ +export function eightWay(grid: Boundable, r: any, c: any): Array<[number, number]>; +/** + * The 4 orthogonal cells (N/E/S/W). + * @param {Boundable} grid + * @returns {Array<[number, number]>} + */ +export function orthogonal(grid: Boundable, r: any, c: any): Array<[number, number]>; +/** + * Neighbor STRATEGIES — the extraction seam. A game injects the topology it + * wants; the grid layer never assumes one. Minesweeper uses `eightWay`; a future + * Sudoku would inject its row/col/box `peers`. Each returns in-bounds [r, c] + * coordinate pairs. + */ +export type Boundable = { + inBounds: (r: number, c: number) => boolean; +}; diff --git a/packages/mnswpr/core/grid/neighbors.js b/packages/mnswpr/core/grid/neighbors.js index e54fea0..f816a3e 100644 --- a/packages/mnswpr/core/grid/neighbors.js +++ b/packages/mnswpr/core/grid/neighbors.js @@ -15,6 +15,7 @@ * @returns {Array<[number, number]>} */ export function eightWay(grid, r, c) { + /** @type {Array<[number, number]>} */ const out = [] for (let dr = -1; dr <= 1; dr++) { for (let dc = -1; dc <= 1; dc++) { @@ -33,6 +34,7 @@ export function eightWay(grid, r, c) { * @returns {Array<[number, number]>} */ export function orthogonal(grid, r, c) { + /** @type {Array<[number, number]>} */ const out = [] const deltas = [[-1, 0], [1, 0], [0, -1], [0, 1]] for (const [dr, dc] of deltas) { diff --git a/packages/mnswpr/core/grid/serialize.d.ts b/packages/mnswpr/core/grid/serialize.d.ts new file mode 100644 index 0000000..0b92e67 --- /dev/null +++ b/packages/mnswpr/core/grid/serialize.d.ts @@ -0,0 +1,25 @@ +/** + * Plain-JSON serialization of a Grid. Used by the move log / server persistence + * (Layer 1) and by replay. Cells must themselves be JSON-serializable. + * + * @template Cell + * @param {Grid} grid + * @returns {{ rows: number, cols: number, cells: Cell[] }} + */ +export function toJSON(grid: Grid): { + rows: number; + cols: number; + cells: Cell[]; +}; +/** + * @template Cell + * @param {{ rows: number, cols: number, cells: Cell[] }} data + * @param {(cell: Cell) => Cell} [reviveCell] + * @returns {Grid} + */ +export function fromJSON(data: { + rows: number; + cols: number; + cells: Cell[]; +}, reviveCell?: (cell: Cell) => Cell): Grid; +import { Grid } from './grid.js'; diff --git a/packages/mnswpr/core/index.d.ts b/packages/mnswpr/core/index.d.ts new file mode 100644 index 0000000..ec3523f --- /dev/null +++ b/packages/mnswpr/core/index.d.ts @@ -0,0 +1,9 @@ +export { Grid } from "./grid/grid.js"; +export { GameSession } from "./session/session.js"; +export { replay } from "./session/replay.js"; +export { levels } from "../levels.js"; +export { eightWay, orthogonal } from "./grid/neighbors.js"; +export { toJSON, fromJSON } from "./grid/serialize.js"; +export { mulberry32, randInt } from "./session/rng.js"; +export { MinesweeperRules, MOVE_EVENT_TYPES } from "./minesweeper/rules.js"; +export { generateBoard, validateLayout } from "./minesweeper/board.js"; diff --git a/packages/mnswpr/core/minesweeper/board.d.ts b/packages/mnswpr/core/minesweeper/board.d.ts new file mode 100644 index 0000000..c9175a1 --- /dev/null +++ b/packages/mnswpr/core/minesweeper/board.d.ts @@ -0,0 +1,99 @@ +/** + * The set of cells kept mine-free for first-click safety: the clicked cell, plus + * its 8 neighbors when the board has room for all mines outside that 3x3. Falls + * back to just the clicked cell on boards too dense to spare the neighborhood. + * + * @param {Config} config + * @returns {Set} coordinate keys (r * cols + c) + */ +export function excludeAround(config: Config, r: any, c: any): Set; +/** + * Deterministically place mines and compute adjacency counts, mutating the grid + * in place. Pure function of (seed, config, exclude) — same inputs, same board. + * Thin convenience wrapper over {@link fillMines} that builds the RNG from a seed. + * + * @param {number} seed + * @param {Config} config + * @param {Set} exclude - coordinate keys never to mine (first-click safety) + * @param {import('../grid/grid.js').Grid} grid + * @returns {Set} the mined coordinate keys + */ +export function placeMines(seed: number, config: Config, exclude: Set, grid: import("../grid/grid.js").Grid): Set; +/** + * The injected-RNG seam under {@link placeMines}: place mines and compute + * adjacency counts, mutating the grid in place. Takes an rng function (any + * `() => [0, 1)`), so callers own determinism — same rng sequence, same board. + * + * @param {() => number} rng + * @param {Config} config + * @param {Set} exclude - coordinate keys never to mine (first-click safety) + * @param {import('../grid/grid.js').Grid} grid + * @returns {Set} the mined coordinate keys + */ +export function fillMines(rng: () => number, config: Config, exclude: Set, grid: import("../grid/grid.js").Grid): Set; +/** + * Assert a plain layout object (as produced by {@link generateBoard}) is + * well-formed before it's injected into a game: correct dimensions, cell shape, + * a mine count that matches its own cells, and in-bounds mine positions. Throws a + * clear error on the first problem so a malformed board can't silently corrupt + * win detection or adjacency. Returns the layout for convenient chaining. + * + * @param {unknown} layout + * @returns {Layout} + */ +export function validateLayout(layout: unknown): Layout; +/** + * Pure, Node-runnable board generation: given a size, a mine count, and an + * injected RNG, produce a plain layout object — no DOM, no I/O, no `Grid` class + * leaking out. This is the headless entry point behind `@cozy-games/mnswpr/core`; + * the DOM client reaches the same generator lazily through `MinesweeperRules`. + * + * The injected `rng` is the determinism seam: the same rng sequence always + * yields the same layout. `seed` is a convenience — when no `rng` is given it is + * wrapped with {@link mulberry32}, keeping generation reproducible and free of + * `Math.random` (invariant #4). + * + * First-move safety: pass `safeCell: { r, c }` to guarantee that cell is never a + * mine — the coordinate-friendly front door to the low-level `exclude` set, so + * callers don't have to know the `r * cols + c` key encoding. It merges with any + * `exclude` given, and the capacity check below rejects layouts where the mines + * can't fit once it's carved out. For 3x3 first-click *flood* safety (the clicked + * cell plus its 8 neighbors), see {@link excludeAround}. + * + * @param {number} rows - number of rows (board height) + * @param {number} cols - number of columns (board width) + * @param {number} mines - number of mines to place + * @param {{ rng?: () => number, seed?: number, exclude?: Set, safeCell?: { r: number, c: number } }} [options] + * @returns {Layout} a plain, serializable layout object + */ +export function generateBoard(rows: number, cols: number, mines: number, { rng, seed, exclude, safeCell }?: { + rng?: () => number; + seed?: number; + exclude?: Set; + safeCell?: { + r: number; + c: number; + }; +}): Layout; +export type Config = { + rows: number; + cols: number; + mines: number; + id?: string; +}; +export type Cell = { + mine: boolean; + adjacent: number; + status: "hidden" | "flagged" | "revealed"; +}; +export type LayoutCell = { + mine: boolean; + adjacent: number; +}; +export type Layout = { + rows: number; + cols: number; + mines: number; + cells: LayoutCell[][]; + mineLocations: [number, number][]; +}; diff --git a/packages/mnswpr/core/minesweeper/board.js b/packages/mnswpr/core/minesweeper/board.js index 47e2bf5..f7d19d4 100644 --- a/packages/mnswpr/core/minesweeper/board.js +++ b/packages/mnswpr/core/minesweeper/board.js @@ -191,7 +191,7 @@ export function generateBoard(rows, cols, mines, { rng, seed = 0, exclude = NO_E } const config = { rows, cols, mines } - const grid = new Grid(rows, cols, () => ({ mine: false, adjacent: 0, status: 'hidden' })) + const grid = new Grid(rows, cols, () => /** @type {Cell} */ ({ mine: false, adjacent: 0, status: 'hidden' })) fillMines(rng ?? mulberry32(seed), config, excludeSet, grid) /** @type {LayoutCell[][]} */ diff --git a/packages/mnswpr/core/minesweeper/reveal.d.ts b/packages/mnswpr/core/minesweeper/reveal.d.ts new file mode 100644 index 0000000..26475a9 --- /dev/null +++ b/packages/mnswpr/core/minesweeper/reveal.d.ts @@ -0,0 +1,36 @@ +/** + * @typedef {import('./board.js').Cell} Cell + * @typedef {import('../grid/grid.js').Grid} MineGrid + * @typedef {{ r: number, c: number, adjacent: number }} RevealedCell + */ +/** + * Reveal starting at (r, c) and flood-fill outward while cells are blank (zero + * adjacent mines), stopping at numbers and flags. Mutates cell statuses; returns + * the newly-revealed cells (never includes already-revealed/flagged cells, so + * callers can count these as fresh safe reveals). The start cell must be a known + * non-mine, hidden cell. + * + * @param {MineGrid} grid + * @returns {RevealedCell[]} + */ +export function floodReveal(grid: MineGrid, startR: any, startC: any): RevealedCell[]; +/** + * @param {MineGrid} grid + * @returns {number} count of flagged cells around (r, c) + */ +export function countFlagsAround(grid: MineGrid, r: any, c: any): number; +/** + * @param {MineGrid} grid + * @returns {Array<{ r: number, c: number }>} every mined coordinate + */ +export function allMines(grid: MineGrid): Array<{ + r: number; + c: number; +}>; +export type Cell = import("./board.js").Cell; +export type MineGrid = import("../grid/grid.js").Grid; +export type RevealedCell = { + r: number; + c: number; + adjacent: number; +}; diff --git a/packages/mnswpr/core/minesweeper/rules.d.ts b/packages/mnswpr/core/minesweeper/rules.d.ts new file mode 100644 index 0000000..b01b49e --- /dev/null +++ b/packages/mnswpr/core/minesweeper/rules.d.ts @@ -0,0 +1,107 @@ +/** + * Minesweeper as a pure, deterministic state machine — no DOM, no wall clock. + * `GameSession` (Layer 1) drives it; the client renders the events it emits. + * + * @typedef {import('./board.js').Config} Config + * @typedef {import('./board.js').Cell} Cell + * @typedef {'fresh' | 'active' | 'won' | 'lost'} Phase + * @typedef {{ seed: number, config: Config, grid: Grid, phase: Phase, minesPlaced: boolean, revealedSafe: number }} State + * @typedef {{ type: 'reveal', r: number, c: number } | { type: 'flag', r: number, c: number } | { type: 'chord', r: number, c: number }} Move + * @typedef {object} Event + */ +/** + * The typed move-event vocabulary emitted by the session (one per effective + * move). This is the game's public event language — consumed later by the shared + * envelope. `type` + `r`/`c` are game meaning (classified here); `t` (injected + * clock) and `seq` (monotonic) are stamped by `GameSession` when it emits. + * + * Note `flag` vs `unflag`: both come from a `flag` move — the distinction is the + * outcome (did the toggle set or clear the flag), which only the rules can tell. + * + * @typedef {'reveal' | 'flag' | 'unflag' | 'chord'} MoveEventType + * @typedef {{ type: MoveEventType, r: number, c: number, t: number, seq: number }} MoveEvent + */ +/** The move-event vocabulary as runtime data (the `MoveEvent` `type` domain). */ +export const MOVE_EVENT_TYPES: readonly ["reveal", "flag", "unflag", "chord"]; +/** + * The GameRules contract consumed by GameSession/replay: init / apply / status / + * project, plus serialize / deserialize for snapshotting. Deterministic and + * DOM-free. + */ +export const MinesweeperRules: any; +/** + * Minesweeper as a pure, deterministic state machine — no DOM, no wall clock. + * `GameSession` (Layer 1) drives it; the client renders the events it emits. + */ +export type Config = import("./board.js").Config; +/** + * Minesweeper as a pure, deterministic state machine — no DOM, no wall clock. + * `GameSession` (Layer 1) drives it; the client renders the events it emits. + */ +export type Cell = import("./board.js").Cell; +/** + * Minesweeper as a pure, deterministic state machine — no DOM, no wall clock. + * `GameSession` (Layer 1) drives it; the client renders the events it emits. + */ +export type Phase = "fresh" | "active" | "won" | "lost"; +/** + * Minesweeper as a pure, deterministic state machine — no DOM, no wall clock. + * `GameSession` (Layer 1) drives it; the client renders the events it emits. + */ +export type State = { + seed: number; + config: Config; + grid: Grid; + phase: Phase; + minesPlaced: boolean; + revealedSafe: number; +}; +/** + * Minesweeper as a pure, deterministic state machine — no DOM, no wall clock. + * `GameSession` (Layer 1) drives it; the client renders the events it emits. + */ +export type Move = { + type: "reveal"; + r: number; + c: number; +} | { + type: "flag"; + r: number; + c: number; +} | { + type: "chord"; + r: number; + c: number; +}; +/** + * Minesweeper as a pure, deterministic state machine — no DOM, no wall clock. + * `GameSession` (Layer 1) drives it; the client renders the events it emits. + */ +export type Event = object; +/** + * The typed move-event vocabulary emitted by the session (one per effective + * move). This is the game's public event language — consumed later by the shared + * envelope. `type` + `r`/`c` are game meaning (classified here); `t` (injected + * clock) and `seq` (monotonic) are stamped by `GameSession` when it emits. + * + * Note `flag` vs `unflag`: both come from a `flag` move — the distinction is the + * outcome (did the toggle set or clear the flag), which only the rules can tell. + */ +export type MoveEventType = "reveal" | "flag" | "unflag" | "chord"; +/** + * The typed move-event vocabulary emitted by the session (one per effective + * move). This is the game's public event language — consumed later by the shared + * envelope. `type` + `r`/`c` are game meaning (classified here); `t` (injected + * clock) and `seq` (monotonic) are stamped by `GameSession` when it emits. + * + * Note `flag` vs `unflag`: both come from a `flag` move — the distinction is the + * outcome (did the toggle set or clear the flag), which only the rules can tell. + */ +export type MoveEvent = { + type: MoveEventType; + r: number; + c: number; + t: number; + seq: number; +}; +import { Grid } from '../grid/grid.js'; diff --git a/packages/mnswpr/core/minesweeper/rules.js b/packages/mnswpr/core/minesweeper/rules.js index d473d6c..56e6238 100644 --- a/packages/mnswpr/core/minesweeper/rules.js +++ b/packages/mnswpr/core/minesweeper/rules.js @@ -33,7 +33,7 @@ import { floodReveal, countFlagsAround, allMines } from './reveal.js' /** The move-event vocabulary as runtime data (the `MoveEvent` `type` domain). */ export const MOVE_EVENT_TYPES = /** @type {const} */ (['reveal', 'flag', 'unflag', 'chord']) -const freshCell = () => ({ mine: false, adjacent: 0, status: 'hidden' }) +const freshCell = () => /** @type {Cell} */ ({ mine: false, adjacent: 0, status: 'hidden' }) /** Valid game phases and per-cell statuses — the closed sets a snapshot may name. */ const PHASES = new Set(['fresh', 'active', 'won', 'lost']) diff --git a/packages/mnswpr/core/session/replay.d.ts b/packages/mnswpr/core/session/replay.d.ts new file mode 100644 index 0000000..55e2089 --- /dev/null +++ b/packages/mnswpr/core/session/replay.d.ts @@ -0,0 +1,28 @@ +/** + * Deterministically re-run a submitted game from `{ seed, config, log }` and + * report the authoritative outcome. A server calls this at submit time to verify + * a score without trusting the client: it recomputes the result and time from the + * moves, and rejects logs that don't terminate or whose timestamps aren't + * monotonic. (The "verifiable-replay" anti-cheat path — no live server needed.) + * + * @param {{ init: Function, apply: Function, status: Function }} rules + * @param {{ seed: number, config: object, log: Array<{ move: object, t: number }> }} submission + * @returns {{ status: string, time: number, valid: boolean, reason: string | null }} + */ +export function replay(rules: { + init: Function; + apply: Function; + status: Function; +}, { seed, config, log }: { + seed: number; + config: object; + log: Array<{ + move: object; + t: number; + }>; +}): { + status: string; + time: number; + valid: boolean; + reason: string | null; +}; diff --git a/packages/mnswpr/core/session/rng.d.ts b/packages/mnswpr/core/session/rng.d.ts new file mode 100644 index 0000000..6a3f66a --- /dev/null +++ b/packages/mnswpr/core/session/rng.d.ts @@ -0,0 +1,16 @@ +/** + * Deterministic, seedable PRNG (mulberry32). A given seed always yields the same + * sequence — this is what makes board generation reproducible and `replay()` + * possible. Uses `Math.imul` (integer math), NOT `Math.random`, so it stays + * inside the determinism guard. + * + * @param {number} seed - any 32-bit integer + * @returns {() => number} a function returning floats in [0, 1) + */ +export function mulberry32(seed: number): () => number; +/** + * Integer in [0, n) from an rng function. + * @param {() => number} rng + * @param {number} n + */ +export function randInt(rng: () => number, n: number): number; diff --git a/packages/mnswpr/core/session/session.d.ts b/packages/mnswpr/core/session/session.d.ts new file mode 100644 index 0000000..8dff90f --- /dev/null +++ b/packages/mnswpr/core/session/session.d.ts @@ -0,0 +1,138 @@ +/** + * Layer 1 — owns lifecycle, the (injected) clock, and the move log; delegates all + * game meaning to an injected `rules` object. This is where timing authority + * lives: on the client `clock` is `Date.now` (cosmetic); on a server it is the + * server's clock (authoritative). The session never calls a wall clock itself. + * Future home: @cozy-games/game-session. + * + * @typedef {{ init: Function, apply: Function, status: Function, project: Function, serialize?: Function, deserialize?: Function, toMoveEvent?: Function }} Rules + */ +export class GameSession { + /** + * Rebuild a session from a {@link serialize} snapshot (or its JSON round-trip). + * The clock is re-injected — it's a live function, not serializable — while the + * log and timing anchors are restored so `elapsed()` resumes correctly. + * Requires the rules to implement `deserialize`. + * + * @param {Rules} rules + * @param {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null, seq?: number }} snapshot + * @param {{ clock?: () => number }} [opts] + * @returns {GameSession} + */ + static deserialize(rules: Rules, snapshot: { + state: object; + log: Array<{ + move: object; + t: number; + }>; + t0: number | null; + tEnd: number | null; + seq?: number; + }, { clock }?: { + clock?: () => number; + }): GameSession; + /** + * Start from either `{ seed, config }` (the rules generate the board) or a + * pre-built `{ state }` (e.g. a rules factory that injected an explicit board); + * `state` wins when both are given. The session stays generic — it just holds + * whatever state the rules produced. + * + * @param {Rules} rules + * @param {{ seed?: number, config?: object, state?: object, clock?: () => number }} opts + */ + constructor(rules: Rules, { seed, config, state, clock }: { + seed?: number; + config?: object; + state?: object; + clock?: () => number; + }); + rules: Rules; + clock: () => number; + state: any; + /** @type {Array<{ move: object, t: number }>} */ + _log: Array<{ + move: object; + t: number; + }>; + _t0: number; + _tEnd: number; + /** @type {Set<(event: object) => void>} */ + _moveHandlers: Set<(event: object) => void>; + _seq: number; + /** + * Subscribe to typed move-events — one per effective move (reveal / flag / + * unflag / chord), each carrying `{ type, r, c, t, seq }`. Returns an + * unsubscribe function. Pure in-process pub/sub: no DOM, no rendering. Requires + * the rules to implement `toMoveEvent`. + * + * @param {(event: object) => void} handler + * @returns {() => void} unsubscribe + */ + onMove(handler: (event: object) => void): () => void; + /** + * Apply a move: stamp it, fold it through the rules, and return the projected + * view + events + authoritative elapsed time. + * @param {object} move + */ + applyMove(move: object): { + events: any; + view: any; + time: number; + }; + /** @param {object} event */ + _emitMove(event: object): void; + status(): any; + view(): any; + log(): { + move: object; + t: number; + }[]; + /** Authoritative elapsed ms: first move → terminal move (or → now if ongoing). */ + elapsed(): number; + /** The signed-off result, or null while the game is still in progress. */ + result(): { + status: any; + time: number; + seed: any; + config: any; + log: { + move: object; + t: number; + }[]; + }; + /** + * Full, JSON-safe snapshot of the whole session — game state (board + per-cell + * status, via the rules' own serializer) plus the move log and timing anchors + * (`t0`/`tEnd`) that `elapsed()` derives from. Everything needed to later resume + * (core-05); the live `clock` is deliberately excluded (it's re-injected on + * {@link GameSession.deserialize}). Requires the rules to implement `serialize`. + * + * @returns {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null, seq: number }} + */ + serialize(): { + state: object; + log: Array<{ + move: object; + t: number; + }>; + t0: number | null; + tEnd: number | null; + seq: number; + }; +} +/** + * Layer 1 — owns lifecycle, the (injected) clock, and the move log; delegates all + * game meaning to an injected `rules` object. This is where timing authority + * lives: on the client `clock` is `Date.now` (cosmetic); on a server it is the + * server's clock (authoritative). The session never calls a wall clock itself. + * Future home: + */ +export type Rules = { + init: Function; + apply: Function; + status: Function; + project: Function; + serialize?: Function; + deserialize?: Function; + toMoveEvent?: Function; +}; diff --git a/packages/mnswpr/core/session/session.js b/packages/mnswpr/core/session/session.js index 265f42e..c4ee5f0 100644 --- a/packages/mnswpr/core/session/session.js +++ b/packages/mnswpr/core/session/session.js @@ -135,7 +135,7 @@ export class GameSession { * Requires the rules to implement `deserialize`. * * @param {Rules} rules - * @param {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null }} snapshot + * @param {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null, seq?: number }} snapshot * @param {{ clock?: () => number }} [opts] * @returns {GameSession} */ diff --git a/packages/mnswpr/levels.d.ts b/packages/mnswpr/levels.d.ts new file mode 100644 index 0000000..15f853e --- /dev/null +++ b/packages/mnswpr/levels.d.ts @@ -0,0 +1,45 @@ +export namespace levels { + namespace beginner { + let rows: number; + let cols: number; + let mines: number; + let id: string; + let name: string; + } + namespace intermediate { + let rows_1: number; + export { rows_1 as rows }; + let cols_1: number; + export { cols_1 as cols }; + let mines_1: number; + export { mines_1 as mines }; + let id_1: string; + export { id_1 as id }; + let name_1: string; + export { name_1 as name }; + } + namespace expert { + let rows_2: number; + export { rows_2 as rows }; + let cols_2: number; + export { cols_2 as cols }; + let mines_2: number; + export { mines_2 as mines }; + let id_2: string; + export { id_2 as id }; + let name_2: string; + export { name_2 as name }; + } + namespace nightmare { + let rows_3: number; + export { rows_3 as rows }; + let cols_3: number; + export { cols_3 as cols }; + let mines_3: number; + export { mines_3 as mines }; + let id_3: string; + export { id_3 as id }; + let name_3: string; + export { name_3 as name }; + } +} diff --git a/packages/mnswpr/package.json b/packages/mnswpr/package.json index d1613b8..658e551 100644 --- a/packages/mnswpr/package.json +++ b/packages/mnswpr/package.json @@ -22,11 +22,16 @@ "default": "./dist/mnswpr.js" }, "./core": { + "types": "./core/index.d.ts", "default": "./core/index.js" }, "./dist/*": { "default": "./dist/*" }, + "./*.js": { + "types": "./*.d.ts", + "default": "./*.js" + }, "./*": { "default": "./*" } diff --git a/packages/move-log/index.d.ts b/packages/move-log/index.d.ts new file mode 100644 index 0000000..7c0e8d9 --- /dev/null +++ b/packages/move-log/index.d.ts @@ -0,0 +1,123 @@ +/** + * Assert a value is a well-formed move log — correct `schema_version` and a valid + * events array — throwing a clear, specific error otherwise. Returns the value + * (typed) for chaining; never mutates. + * + * @param {unknown} value + * @returns {MoveLog} + */ +export function assertMoveLog(value: unknown): MoveLog; +/** + * Build a move log from an ordered list of `{ seq, t, event }` records. Pure and + * game-agnostic: it validates only the log's own invariants (metadata types and + * strictly increasing `seq`), never the shape of `T`. Order is preserved and + * entries are copied, so the log never aliases the caller's array. + * + * @template T + * @param {MoveEvent[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }` + * @returns {MoveLog} + */ +export function createMoveLog(events?: MoveEvent[]): MoveLog; +/** + * Return a new move log with a received-side timestamp attached to each event + * for which `stamp` returns a finite number; events where `stamp` returns + * `undefined` are left as-is (keeping any existing `receivedTs`). This is how a + * consumer records WHEN it received events — the log never cares where the value + * came from. Pure: the input log is not mutated. + * + * @template T + * @param {MoveLog} log + * @param {(event: MoveEvent, index: number) => number | undefined} stamp + * @returns {MoveLog} + */ +export function withReceivedTs(log: MoveLog, stamp: (event: MoveEvent, index: number) => number | undefined): MoveLog; +/** + * Non-throwing type guard: is `value` a well-formed move log of the current + * schema version? Checks the container invariants only — remains blind to `T`. + * + * @param {unknown} value + * @returns {boolean} + */ +export function isMoveLog(value: unknown): boolean; +/** + * Serialize a move log to a JSON string. Validates first, so a malformed log is + * rejected here rather than emitted. Inverse of {@link deserializeMoveLog}. + * + * @template T + * @param {MoveLog} log + * @returns {string} + */ +export function serializeMoveLog(log: MoveLog): string; +/** + * Parse and validate a JSON string into a move log, with full fidelity: event + * order, timestamps, and sequence numbers survive the round-trip exactly. + * Rejects malformed input (bad JSON, missing/typed-wrong fields, non-monotonic + * `seq`) with a clear error and NEVER returns a partially-parsed log. Inverse of + * {@link serializeMoveLog}. + * + * @param {string} json + * @returns {MoveLog} + */ +export function deserializeMoveLog(json: string): MoveLog; +/** + * `@cozy-games/move-log` — a game-agnostic container for a recorded run of move + * events. It wraps ANY game's event stream: `T` is the consuming game's own + * event vocabulary (mnswpr's `MoveEvent` union from core-06 is the first `T`), + * supplied by the caller. + * + * This module imports NO game types — that independence is the whole point and + * is enforced by a dependency-graph guard in the tests. The log owns the + * per-event recording metadata (`seq` + `t`, and an optional received-side + * `receivedTs`) so `T` can stay a pure game payload with no required shape; the + * module never inspects the inside of an `event`. + * + * Extraction to a standalone published package comes later; for now it lives as + * a shared workspace module alongside `packages/utils`. + */ +/** + * The move-log schema version. + * + * Versioning policy: OPTIONAL, purely additive fields (an event gaining an + * optional `receivedTs`, say) do NOT bump this — a v1 reader ignores fields it + * doesn't know, and a log written with them stays a valid v1 log. Bump ONLY on a + * breaking change to the container shape (a renamed/removed field, a newly + * *required* field), which would need dispatch on read. Never bump for changes + * to a game's `T` vocabulary. + * + * @typedef {1} SchemaVersion + */ +export const SCHEMA_VERSION: SchemaVersion; +/** + * A single recorded event: the log-owned recording metadata — a strictly + * increasing sequence number `seq`, a source-side timestamp `t` (milliseconds), + * and an OPTIONAL received-side timestamp `receivedTs` a consumer may attach when + * it received the event — plus the game's opaque payload `event`. Generic over + * the game's event type `T`. `receivedTs` is purpose-neutral: the log records + * only THAT it was received at some time, never why or from where. + */ +export type MoveEvent = { + seq: number; + t: number; + event: T; + receivedTs?: number; +}; +/** + * The container: a schema-versioned, ordered array of timestamped, sequenced + * events for one recorded run. Generic over the game's event vocabulary `T`. + * JSON-safe as long as `T` is. + */ +export type MoveLog = { + schema_version: SchemaVersion; + events: MoveEvent[]; +}; +/** + * The move-log schema version. + * + * Versioning policy: OPTIONAL, purely additive fields (an event gaining an + * optional `receivedTs`, say) do NOT bump this — a v1 reader ignores fields it + * doesn't know, and a log written with them stays a valid v1 log. Bump ONLY on a + * breaking change to the container shape (a renamed/removed field, a newly + * *required* field), which would need dispatch on read. Never bump for changes + * to a game's `T` vocabulary. + */ +export type SchemaVersion = 1; diff --git a/packages/move-log/package.json b/packages/move-log/package.json index 9aa0b81..fe3479f 100644 --- a/packages/move-log/package.json +++ b/packages/move-log/package.json @@ -10,10 +10,16 @@ "url": "https://github.com/ayo-run/mnswpr" }, "main": "index.js", + "types": "./index.d.ts", "exports": { ".": { + "types": "./index.d.ts", "default": "./index.js" }, + "./*.js": { + "types": "./*.d.ts", + "default": "./*.js" + }, "./*": { "default": "./*" } diff --git a/packages/utils/date-bucket/date-bucket.d.ts b/packages/utils/date-bucket/date-bucket.d.ts new file mode 100644 index 0000000..5786c7e --- /dev/null +++ b/packages/utils/date-bucket/date-bucket.d.ts @@ -0,0 +1,8 @@ +export function dayKey(date: Date): string; +export function monthKey(date: Date): string; +export function weekKey(date: Date): string; +export function buckets(date: Date): { + day: string; + week: string; + month: string; +}; diff --git a/packages/utils/index.d.ts b/packages/utils/index.d.ts new file mode 100644 index 0000000..14c58ce --- /dev/null +++ b/packages/utils/index.d.ts @@ -0,0 +1,5 @@ +export * from "./logger/logger.js"; +export * from "./storage/storage.js"; +export * from "./timer/timer.js"; +export * from "./loading/loading.js"; +export * from "./date-bucket/date-bucket.js"; diff --git a/packages/utils/loading/loading.d.ts b/packages/utils/loading/loading.d.ts new file mode 100644 index 0000000..239046c --- /dev/null +++ b/packages/utils/loading/loading.d.ts @@ -0,0 +1,4 @@ +export class LoadingService { + addLoading(element: any): void; + removeLoading(element: any): void; +} diff --git a/packages/utils/logger/logger.d.ts b/packages/utils/logger/logger.d.ts new file mode 100644 index 0000000..ddcfed1 --- /dev/null +++ b/packages/utils/logger/logger.d.ts @@ -0,0 +1,3 @@ +export class LoggerService { + debug(message: any, data: any): void; +} diff --git a/packages/utils/package.json b/packages/utils/package.json index 5a47817..24439c8 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -10,10 +10,16 @@ "url": "https://github.com/ayo-run/mnswpr" }, "main": "index.js", + "types": "./index.d.ts", "exports": { ".": { + "types": "./index.d.ts", "default": "./index.js" }, + "./*.js": { + "types": "./*.d.ts", + "default": "./*.js" + }, "./*": { "default": "./*" } diff --git a/packages/utils/storage/storage.d.ts b/packages/utils/storage/storage.d.ts new file mode 100644 index 0000000..c7eeed6 --- /dev/null +++ b/packages/utils/storage/storage.d.ts @@ -0,0 +1,6 @@ +export class StorageService { + saveToLocal(key: any, value: any): void; + saveToSession(key: any, value: any): void; + getFromLocal(key: any): any; + getFromSession(key: any): any; +} diff --git a/packages/utils/timer/timer.d.ts b/packages/utils/timer/timer.d.ts new file mode 100644 index 0000000..210af11 --- /dev/null +++ b/packages/utils/timer/timer.d.ts @@ -0,0 +1,28 @@ +export class TimerService { + loggerService: LoggerService; + time: number; + rendered: any; + initialize(el: any): void; + display: any; + startTime: number; + start(): void; + running: boolean; + stop(): number; + id: number; + /** + * Recompute the elapsed time and schedule the next frame. + * Driven by requestAnimationFrame so it aligns with the browser's paint + * cadence and pauses automatically when the tab is hidden — instead of the + * old fixed 1ms interval that fired ~1000 times a second. + */ + tick(): void; + /** + * Write to the DOM only when the visible value actually changes. The display + * has 100ms (tenths-of-a-second) resolution, so most frames are a no-op and + * cost no reflow. + */ + render(): void; + pretty(duration: any): string; + clean(str: any, separator: any): string; +} +import { LoggerService } from '../logger/logger'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df6ab88..0d9f8f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: simple-git: specifier: ^3.33.0 version: 3.33.0(supports-color@10.2.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 vite: specifier: ^8.0.3 version: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(jiti@2.6.1)(yaml@2.8.3) @@ -74,16 +77,15 @@ importers: packages/leaderboard: dependencies: + '@cozy-games/utils': + specifier: workspace:^ + version: link:../utils firebase: specifier: ^12.11.0 version: 12.11.0 web-component-base: specifier: ^5.0.0 version: 5.0.0 - devDependencies: - '@cozy-games/utils': - specifier: workspace:* - version: link:../utils packages/mnswpr: devDependencies: @@ -10452,7 +10454,7 @@ snapshots: form-data: 4.0.6 fs-extra: 10.1.0 fuzzy: 0.1.3 - gaxios: 6.7.1 + gaxios: 6.7.1(supports-color@10.2.2) glob: 10.5.0 google-auth-library: 9.15.1 ignore: 7.0.5 @@ -10613,7 +10615,7 @@ snapshots: fuzzy@0.1.3: {} - gaxios@6.7.1: + gaxios@6.7.1(supports-color@10.2.2): dependencies: extend: 3.0.2 https-proxy-agent: 7.0.6(supports-color@10.2.2) @@ -10643,7 +10645,7 @@ snapshots: gcp-metadata@6.1.1: dependencies: - gaxios: 6.7.1 + gaxios: 6.7.1(supports-color@10.2.2) google-logging-utils: 0.0.2 json-bigint: 1.0.0 transitivePeerDependencies: @@ -10820,7 +10822,7 @@ snapshots: dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 - gaxios: 6.7.1 + gaxios: 6.7.1(supports-color@10.2.2) gcp-metadata: 6.1.1 gtoken: 7.1.0 jws: 4.0.1 @@ -10869,7 +10871,7 @@ snapshots: gtoken@7.1.0: dependencies: - gaxios: 6.7.1 + gaxios: 6.7.1(supports-color@10.2.2) jws: 4.0.1 transitivePeerDependencies: - encoding diff --git a/scripts/build-types.mjs b/scripts/build-types.mjs new file mode 100644 index 0000000..d8e3e72 --- /dev/null +++ b/scripts/build-types.mjs @@ -0,0 +1,31 @@ +// Generate the packages' TypeScript declarations (.d.ts) from their JSDoc. +// +// The repo stays JS-authored (JSDoc + `// @ts-check`); this is a build-time step +// that ships types with the published `@cozy-games/*` packages so consumers get +// them without an ambient shim. Declarations are emitted co-located next to each +// source file and committed. +// +// Why the clean step: once a `.d.ts` sits next to its `.js`, TypeScript treats it +// as that file's authoritative types and refuses to re-emit over it (TS5055). So +// we delete the previously generated declarations before re-running tsc, making +// the build repeatable. Configured by tsconfig.types.json. +import { readdirSync, statSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { execFileSync } from 'node:child_process' + +const root = resolve(import.meta.dirname, '..') +const packagesDir = resolve(root, 'packages') + +function removeDeclarations(dir) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'dist') continue + const entry = join(dir, name) + if (statSync(entry).isDirectory()) removeDeclarations(entry) + else if (name.endsWith('.d.ts')) rmSync(entry) + } +} + +removeDeclarations(packagesDir) + +const tsc = resolve(root, 'node_modules/.bin/tsc') +execFileSync(tsc, ['-p', resolve(root, 'tsconfig.types.json')], { cwd: root, stdio: 'inherit' }) diff --git a/tsconfig.types.json b/tsconfig.types.json new file mode 100644 index 0000000..0973c83 --- /dev/null +++ b/tsconfig.types.json @@ -0,0 +1,43 @@ +{ + "//": "Declaration-only build: emit .d.ts from the packages' existing JSDoc so published @cozy-games/* packages ship types. The repo stays JS-authored (JSDoc + @ts-check); tsc is a build-time tool for declarations only. Regenerate with `pnpm build:types`.", + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "declaration": true, + "emitDeclarationOnly": true, + "noEmitOnError": false, + "skipLibCheck": true, + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2022", + "lib": ["es2022", "dom", "dom.iterable"], + "strict": false, + "types": [] + }, + "include": [ + "packages/utils/index.js", + "packages/utils/logger/logger.js", + "packages/utils/storage/storage.js", + "packages/utils/timer/timer.js", + "packages/utils/loading/loading.js", + "packages/utils/date-bucket/date-bucket.js", + "packages/move-log/index.js", + "packages/mnswpr/core/**/*.js", + "packages/mnswpr/adapters/**/*.js", + "packages/mnswpr/levels.js", + "packages/leaderboard/leader-board.js", + "packages/leaderboard/leaderboard-read.js", + "packages/leaderboard/leaderboard-write.js", + "packages/leaderboard/leaderboard-element.js", + "packages/leaderboard/adapters/firebase.js", + "packages/leaderboard/adapters/firebase-admin.js", + "packages/leaderboard/adapters/supabase.js" + ], + "exclude": [ + "**/node_modules", + "**/dist", + "**/test", + "**/*.test.js", + "**/*.config.js" + ] +}