chore: update docs

This commit is contained in:
ayo 2026-07-16 16:10:46 +02:00 committed by GitHub
parent a0e2970b55
commit 2aaba5d904
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1614 additions and 16 deletions

View file

@ -14,7 +14,8 @@
"test": "vitest run", "test": "vitest run",
"dev": "pnpm -F mnswpr dev", "dev": "pnpm -F mnswpr dev",
"test:watch": "vitest", "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", "build:lib": "vite build packages/mnswpr",
"publish:lib": "node scripts/publish-lib.js", "publish:lib": "node scripts/publish-lib.js",
"release": "pnpm build:lib && pnpm -F @cozy-games/mnswpr run release && pnpm publish:lib", "release": "pnpm build:lib && pnpm -F @cozy-games/mnswpr run release && pnpm publish:lib",
@ -36,6 +37,7 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"secretlint": "^13.0.2", "secretlint": "^13.0.2",
"simple-git": "^3.33.0", "simple-git": "^3.33.0",
"typescript": "^5.9.3",
"vite": "^8.0.3", "vite": "^8.0.3",
"vitest": "^4.1.9" "vitest": "^4.1.9"
}, },

View file

@ -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 } }) 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) ### Supabase (Postgres)
```js ```js

View file

@ -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<any>;
/**
* @param {Object} q - { category, since, order, limit }
* @returns {Promise<Object[]>} plain score records, best first
*/
listScores(q: any): Promise<any[]>;
addScore(category: any, entry: any): Promise<void>;
archive(entry: any): Promise<void>;
}

View file

@ -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<Object[]>} 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 })
}
}

View file

@ -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<import("firebase/firestore/lite").DocumentData>;
/**
* @param {Object} q - { category, since, order, limit }
* @returns {Promise<Object[]>} plain score records, best first
*/
listScores(q: any): Promise<any[]>;
addScore(category: any, entry: any): Promise<void>;
archive(entry: any): Promise<void>;
}

View file

@ -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<any>;
/**
* @param {Object} q - { category, since, order, limit }
* @returns {Promise<Object[]>} plain score records (must expose `name`, `score`)
*/
listScores(q: any): Promise<any[]>;
addScore(category: any, entry: any): Promise<void>;
archive(entry: any): Promise<void>;
}

67
packages/leaderboard/leader-board.d.ts vendored Normal file
View file

@ -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<Object|undefined>
* - listScores({ category, since, order, limit }): Promise<Object[]> // read
* - addScore(category, entry): Promise<void> // write
* - archive(entry): Promise<void> // 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<HTMLDivElement>;
/**
* Write surface submit a completed game (archive + ranked entry).
* @see LeaderBoardWriter#submit
*/
submit(entry: any): Promise<void>;
}
import { LeaderBoardReader } from './leaderboard-read.js';
import { LeaderBoardWriter } from './leaderboard-write.js';

View file

@ -0,0 +1,63 @@
/**
* Configure the backend + defaults for all <cozy-leaderboard> 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<void>;
/**
* 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<void>;
}
import { WebComponent } from 'web-component-base';
import { LeaderBoardService } from './leader-board.js';

View file

@ -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
* `<cozy-leaderboard>` 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
* `<cozy-leaderboard>` element) use this instead of {@link render}.
* @param {String} category
* @param {String} durationId - a DURATIONS id ('today' | 'week' | 'month' | 'all')
* @returns {Promise<Object[]>}
*/
list(category: string, durationId: string): Promise<any[]>;
/**
* 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<HTMLDivElement>}
*/
render(category: string, title: string, duration?: string): Promise<HTMLDivElement>;
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<void>;
_loadToken: any;
_renderList(listWrapper: any, rows: any): void;
}

View file

@ -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<void>;
}

View file

@ -19,6 +19,10 @@
"./dist/*": { "./dist/*": {
"default": "./dist/*" "default": "./dist/*"
}, },
"./*.js": {
"types": "./*.d.ts",
"default": "./*.js"
},
"./*": { "./*": {
"default": "./*" "default": "./*"
} }
@ -28,11 +32,9 @@
"./dist" "./dist"
], ],
"dependencies": { "dependencies": {
"@cozy-games/utils": "workspace:^",
"web-component-base": "^5.0.0" "web-component-base": "^5.0.0"
}, },
"devDependencies": {
"@cozy-games/utils": "workspace:*"
},
"peerDependencies": { "peerDependencies": {
"firebase": "^12.11.0" "firebase": "^12.11.0"
}, },

View file

@ -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())

View file

@ -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;

View file

@ -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<MnswprMoveEvent>` 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;

View file

@ -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<MnswprMoveEvent, BoardState>` 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[][];
};

31
packages/mnswpr/core/grid/grid.d.ts vendored Normal file
View file

@ -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<Cell> {
/**
* @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<Out>}
*/
map<Out>(fn: (cell: Cell, r: number, c: number) => Out): Grid<Out>;
}

View file

@ -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;
};

View file

@ -15,6 +15,7 @@
* @returns {Array<[number, number]>} * @returns {Array<[number, number]>}
*/ */
export function eightWay(grid, r, c) { export function eightWay(grid, r, c) {
/** @type {Array<[number, number]>} */
const out = [] const out = []
for (let dr = -1; dr <= 1; dr++) { for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) { for (let dc = -1; dc <= 1; dc++) {
@ -33,6 +34,7 @@ export function eightWay(grid, r, c) {
* @returns {Array<[number, number]>} * @returns {Array<[number, number]>}
*/ */
export function orthogonal(grid, r, c) { export function orthogonal(grid, r, c) {
/** @type {Array<[number, number]>} */
const out = [] const out = []
const deltas = [[-1, 0], [1, 0], [0, -1], [0, 1]] const deltas = [[-1, 0], [1, 0], [0, -1], [0, 1]]
for (const [dr, dc] of deltas) { for (const [dr, dc] of deltas) {

View file

@ -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<Cell>} grid
* @returns {{ rows: number, cols: number, cells: Cell[] }}
*/
export function toJSON<Cell>(grid: Grid<Cell>): {
rows: number;
cols: number;
cells: Cell[];
};
/**
* @template Cell
* @param {{ rows: number, cols: number, cells: Cell[] }} data
* @param {(cell: Cell) => Cell} [reviveCell]
* @returns {Grid<Cell>}
*/
export function fromJSON<Cell>(data: {
rows: number;
cols: number;
cells: Cell[];
}, reviveCell?: (cell: Cell) => Cell): Grid<Cell>;
import { Grid } from './grid.js';

9
packages/mnswpr/core/index.d.ts vendored Normal file
View file

@ -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";

View file

@ -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<number>} coordinate keys (r * cols + c)
*/
export function excludeAround(config: Config, r: any, c: any): Set<number>;
/**
* 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<number>} exclude - coordinate keys never to mine (first-click safety)
* @param {import('../grid/grid.js').Grid<Cell>} grid
* @returns {Set<number>} the mined coordinate keys
*/
export function placeMines(seed: number, config: Config, exclude: Set<number>, grid: import("../grid/grid.js").Grid<Cell>): Set<number>;
/**
* 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<number>} exclude - coordinate keys never to mine (first-click safety)
* @param {import('../grid/grid.js').Grid<Cell>} grid
* @returns {Set<number>} the mined coordinate keys
*/
export function fillMines(rng: () => number, config: Config, exclude: Set<number>, grid: import("../grid/grid.js").Grid<Cell>): Set<number>;
/**
* 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<number>, 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<number>;
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][];
};

View file

@ -191,7 +191,7 @@ export function generateBoard(rows, cols, mines, { rng, seed = 0, exclude = NO_E
} }
const config = { rows, cols, mines } 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) fillMines(rng ?? mulberry32(seed), config, excludeSet, grid)
/** @type {LayoutCell[][]} */ /** @type {LayoutCell[][]} */

View file

@ -0,0 +1,36 @@
/**
* @typedef {import('./board.js').Cell} Cell
* @typedef {import('../grid/grid.js').Grid<Cell>} 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<Cell>;
export type RevealedCell = {
r: number;
c: number;
adjacent: number;
};

View file

@ -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<Cell>, 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<Cell>;
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';

View file

@ -33,7 +33,7 @@ import { floodReveal, countFlagsAround, allMines } from './reveal.js'
/** The move-event vocabulary as runtime data (the `MoveEvent` `type` domain). */ /** The move-event vocabulary as runtime data (the `MoveEvent` `type` domain). */
export const MOVE_EVENT_TYPES = /** @type {const} */ (['reveal', 'flag', 'unflag', 'chord']) 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. */ /** Valid game phases and per-cell statuses — the closed sets a snapshot may name. */
const PHASES = new Set(['fresh', 'active', 'won', 'lost']) const PHASES = new Set(['fresh', 'active', 'won', 'lost'])

View file

@ -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;
};

16
packages/mnswpr/core/session/rng.d.ts vendored Normal file
View file

@ -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;

View file

@ -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;
};

View file

@ -135,7 +135,7 @@ export class GameSession {
* Requires the rules to implement `deserialize`. * Requires the rules to implement `deserialize`.
* *
* @param {Rules} rules * @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] * @param {{ clock?: () => number }} [opts]
* @returns {GameSession} * @returns {GameSession}
*/ */

45
packages/mnswpr/levels.d.ts vendored Normal file
View file

@ -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 };
}
}

View file

@ -22,11 +22,16 @@
"default": "./dist/mnswpr.js" "default": "./dist/mnswpr.js"
}, },
"./core": { "./core": {
"types": "./core/index.d.ts",
"default": "./core/index.js" "default": "./core/index.js"
}, },
"./dist/*": { "./dist/*": {
"default": "./dist/*" "default": "./dist/*"
}, },
"./*.js": {
"types": "./*.d.ts",
"default": "./*.js"
},
"./*": { "./*": {
"default": "./*" "default": "./*"
} }

123
packages/move-log/index.d.ts vendored Normal file
View file

@ -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<any>}
*/
export function assertMoveLog(value: unknown): MoveLog<any>;
/**
* 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<T>[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }`
* @returns {MoveLog<T>}
*/
export function createMoveLog<T>(events?: MoveEvent<T>[]): MoveLog<T>;
/**
* 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<T>} log
* @param {(event: MoveEvent<T>, index: number) => number | undefined} stamp
* @returns {MoveLog<T>}
*/
export function withReceivedTs<T>(log: MoveLog<T>, stamp: (event: MoveEvent<T>, index: number) => number | undefined): MoveLog<T>;
/**
* 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<T>} log
* @returns {string}
*/
export function serializeMoveLog<T>(log: MoveLog<T>): 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<any>}
*/
export function deserializeMoveLog(json: string): MoveLog<any>;
/**
* `@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<T> = {
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<T> = {
schema_version: SchemaVersion;
events: MoveEvent<T>[];
};
/**
* 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;

View file

@ -10,10 +10,16 @@
"url": "https://github.com/ayo-run/mnswpr" "url": "https://github.com/ayo-run/mnswpr"
}, },
"main": "index.js", "main": "index.js",
"types": "./index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./index.d.ts",
"default": "./index.js" "default": "./index.js"
}, },
"./*.js": {
"types": "./*.d.ts",
"default": "./*.js"
},
"./*": { "./*": {
"default": "./*" "default": "./*"
} }

View file

@ -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;
};

5
packages/utils/index.d.ts vendored Normal file
View file

@ -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";

4
packages/utils/loading/loading.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
export class LoadingService {
addLoading(element: any): void;
removeLoading(element: any): void;
}

3
packages/utils/logger/logger.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
export class LoggerService {
debug(message: any, data: any): void;
}

View file

@ -10,10 +10,16 @@
"url": "https://github.com/ayo-run/mnswpr" "url": "https://github.com/ayo-run/mnswpr"
}, },
"main": "index.js", "main": "index.js",
"types": "./index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./index.d.ts",
"default": "./index.js" "default": "./index.js"
}, },
"./*.js": {
"types": "./*.d.ts",
"default": "./*.js"
},
"./*": { "./*": {
"default": "./*" "default": "./*"
} }

6
packages/utils/storage/storage.d.ts vendored Normal file
View file

@ -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;
}

28
packages/utils/timer/timer.d.ts vendored Normal file
View file

@ -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';

View file

@ -41,6 +41,9 @@ importers:
simple-git: simple-git:
specifier: ^3.33.0 specifier: ^3.33.0
version: 3.33.0(supports-color@10.2.2) version: 3.33.0(supports-color@10.2.2)
typescript:
specifier: ^5.9.3
version: 5.9.3
vite: vite:
specifier: ^8.0.3 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) 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: packages/leaderboard:
dependencies: dependencies:
'@cozy-games/utils':
specifier: workspace:^
version: link:../utils
firebase: firebase:
specifier: ^12.11.0 specifier: ^12.11.0
version: 12.11.0 version: 12.11.0
web-component-base: web-component-base:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0 version: 5.0.0
devDependencies:
'@cozy-games/utils':
specifier: workspace:*
version: link:../utils
packages/mnswpr: packages/mnswpr:
devDependencies: devDependencies:
@ -10452,7 +10454,7 @@ snapshots:
form-data: 4.0.6 form-data: 4.0.6
fs-extra: 10.1.0 fs-extra: 10.1.0
fuzzy: 0.1.3 fuzzy: 0.1.3
gaxios: 6.7.1 gaxios: 6.7.1(supports-color@10.2.2)
glob: 10.5.0 glob: 10.5.0
google-auth-library: 9.15.1 google-auth-library: 9.15.1
ignore: 7.0.5 ignore: 7.0.5
@ -10613,7 +10615,7 @@ snapshots:
fuzzy@0.1.3: {} fuzzy@0.1.3: {}
gaxios@6.7.1: gaxios@6.7.1(supports-color@10.2.2):
dependencies: dependencies:
extend: 3.0.2 extend: 3.0.2
https-proxy-agent: 7.0.6(supports-color@10.2.2) https-proxy-agent: 7.0.6(supports-color@10.2.2)
@ -10643,7 +10645,7 @@ snapshots:
gcp-metadata@6.1.1: gcp-metadata@6.1.1:
dependencies: dependencies:
gaxios: 6.7.1 gaxios: 6.7.1(supports-color@10.2.2)
google-logging-utils: 0.0.2 google-logging-utils: 0.0.2
json-bigint: 1.0.0 json-bigint: 1.0.0
transitivePeerDependencies: transitivePeerDependencies:
@ -10820,7 +10822,7 @@ snapshots:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1
ecdsa-sig-formatter: 1.0.11 ecdsa-sig-formatter: 1.0.11
gaxios: 6.7.1 gaxios: 6.7.1(supports-color@10.2.2)
gcp-metadata: 6.1.1 gcp-metadata: 6.1.1
gtoken: 7.1.0 gtoken: 7.1.0
jws: 4.0.1 jws: 4.0.1
@ -10869,7 +10871,7 @@ snapshots:
gtoken@7.1.0: gtoken@7.1.0:
dependencies: dependencies:
gaxios: 6.7.1 gaxios: 6.7.1(supports-color@10.2.2)
jws: 4.0.1 jws: 4.0.1
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding

31
scripts/build-types.mjs Normal file
View file

@ -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' })

43
tsconfig.types.json Normal file
View file

@ -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"
]
}