chore: update docs
This commit is contained in:
parent
a0e2970b55
commit
2aaba5d904
43 changed files with 1614 additions and 16 deletions
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
58
packages/leaderboard/adapters/firebase-admin.d.ts
vendored
Normal file
58
packages/leaderboard/adapters/firebase-admin.d.ts
vendored
Normal 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>;
|
||||||
|
}
|
||||||
100
packages/leaderboard/adapters/firebase-admin.js
Normal file
100
packages/leaderboard/adapters/firebase-admin.js
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
40
packages/leaderboard/adapters/firebase.d.ts
vendored
Normal file
40
packages/leaderboard/adapters/firebase.d.ts
vendored
Normal 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>;
|
||||||
|
}
|
||||||
35
packages/leaderboard/adapters/supabase.d.ts
vendored
Normal file
35
packages/leaderboard/adapters/supabase.d.ts
vendored
Normal 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
67
packages/leaderboard/leader-board.d.ts
vendored
Normal 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';
|
||||||
63
packages/leaderboard/leaderboard-element.d.ts
vendored
Normal file
63
packages/leaderboard/leaderboard-element.d.ts
vendored
Normal 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';
|
||||||
115
packages/leaderboard/leaderboard-read.d.ts
vendored
Normal file
115
packages/leaderboard/leaderboard-read.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
39
packages/leaderboard/leaderboard-write.d.ts
vendored
Normal file
39
packages/leaderboard/leaderboard-write.d.ts
vendored
Normal 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>;
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
123
packages/leaderboard/test/firebase-admin-adapter.test.js
Normal file
123
packages/leaderboard/test/firebase-admin-adapter.test.js
Normal 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())
|
||||||
18
packages/mnswpr/adapters/replay-common.d.ts
vendored
Normal file
18
packages/mnswpr/adapters/replay-common.d.ts
vendored
Normal 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;
|
||||||
27
packages/mnswpr/adapters/replay-progress.d.ts
vendored
Normal file
27
packages/mnswpr/adapters/replay-progress.d.ts
vendored
Normal 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;
|
||||||
38
packages/mnswpr/adapters/replay-state.d.ts
vendored
Normal file
38
packages/mnswpr/adapters/replay-state.d.ts
vendored
Normal 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
31
packages/mnswpr/core/grid/grid.d.ts
vendored
Normal 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>;
|
||||||
|
}
|
||||||
29
packages/mnswpr/core/grid/neighbors.d.ts
vendored
Normal file
29
packages/mnswpr/core/grid/neighbors.d.ts
vendored
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
25
packages/mnswpr/core/grid/serialize.d.ts
vendored
Normal file
25
packages/mnswpr/core/grid/serialize.d.ts
vendored
Normal 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
9
packages/mnswpr/core/index.d.ts
vendored
Normal 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";
|
||||||
99
packages/mnswpr/core/minesweeper/board.d.ts
vendored
Normal file
99
packages/mnswpr/core/minesweeper/board.d.ts
vendored
Normal 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][];
|
||||||
|
};
|
||||||
|
|
@ -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[][]} */
|
||||||
|
|
|
||||||
36
packages/mnswpr/core/minesweeper/reveal.d.ts
vendored
Normal file
36
packages/mnswpr/core/minesweeper/reveal.d.ts
vendored
Normal 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;
|
||||||
|
};
|
||||||
107
packages/mnswpr/core/minesweeper/rules.d.ts
vendored
Normal file
107
packages/mnswpr/core/minesweeper/rules.d.ts
vendored
Normal 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';
|
||||||
|
|
@ -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'])
|
||||||
|
|
|
||||||
28
packages/mnswpr/core/session/replay.d.ts
vendored
Normal file
28
packages/mnswpr/core/session/replay.d.ts
vendored
Normal 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
16
packages/mnswpr/core/session/rng.d.ts
vendored
Normal 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;
|
||||||
138
packages/mnswpr/core/session/session.d.ts
vendored
Normal file
138
packages/mnswpr/core/session/session.d.ts
vendored
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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
45
packages/mnswpr/levels.d.ts
vendored
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
123
packages/move-log/index.d.ts
vendored
Normal 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;
|
||||||
|
|
@ -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": "./*"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
packages/utils/date-bucket/date-bucket.d.ts
vendored
Normal file
8
packages/utils/date-bucket/date-bucket.d.ts
vendored
Normal 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
5
packages/utils/index.d.ts
vendored
Normal 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
4
packages/utils/loading/loading.d.ts
vendored
Normal 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
3
packages/utils/logger/logger.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export class LoggerService {
|
||||||
|
debug(message: any, data: any): void;
|
||||||
|
}
|
||||||
|
|
@ -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
6
packages/utils/storage/storage.d.ts
vendored
Normal 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
28
packages/utils/timer/timer.d.ts
vendored
Normal 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';
|
||||||
|
|
@ -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
31
scripts/build-types.mjs
Normal 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
43
tsconfig.types.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue