minimojs 1.0.0-alpha.34 → 1.0.0-alpha.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ import { Game } from "./minimo.js";
2
+ /**
3
+ * When a leaderboard starts again. Every period is a UTC calendar unit, and a
4
+ * week is an ISO week, starting on Monday, so every player sees the table
5
+ * reset at the same moment. Past periods stay readable.
6
+ */
7
+ export type LeaderboardCycle = "never" | "daily" | "weekly" | "monthly" | "yearly";
8
+ /** Which score is better: `higher`, for points, or `lower`, for times. */
9
+ export type LeaderboardOrder = "higher" | "lower";
10
+ /**
11
+ * What a leaderboard's table calls each player: `player`, the name the player
12
+ * wrote, or `noname`; `hidden`, no names; or `assigned`, a name such as
13
+ * `Blue Fox 42`, given to each player.
14
+ */
15
+ export type LeaderboardNames = "player" | "hidden" | "assigned";
16
+ /**
17
+ * Who can be in a leaderboard. `account`: players signed in to Minimo Games,
18
+ * in one table every player sees. `local`: light users, whose scores stay on
19
+ * their own device, so each player sees only their own.
20
+ */
21
+ export type LeaderboardPlayer = "account" | "local";
22
+ /**
23
+ * How a leaderboard works. On Minimo Games the creator sets these on the
24
+ * game's Edit leaderboards page, or an AI agent sets them with the
25
+ * `saveGameLeaderboard` tool, and the game reads them; the settings passed to
26
+ * {@link Leaderboard} are used only when the game runs anywhere else.
27
+ */
28
+ export interface LeaderboardSettings {
29
+ /** The title its table shows. */
30
+ title: string;
31
+ cycle: LeaderboardCycle;
32
+ order: LeaderboardOrder;
33
+ names: LeaderboardNames;
34
+ player: LeaderboardPlayer;
35
+ }
36
+ /**
37
+ * Which period of a leaderboard to read: `"current"` (the default),
38
+ * `"previous"`, such as yesterday's table of a daily leaderboard, or a period
39
+ * written out: `"2026-10-17"`, `"2026-W42"`, `"2026-10"`, or `"2026"`.
40
+ */
41
+ export type LeaderboardPeriod = "current" | "previous" | string;
42
+ /** One row of a leaderboard's table. */
43
+ export interface LeaderboardEntry {
44
+ /** 1 for the best score. Equal scores share a rank. */
45
+ rank: number;
46
+ /** The player's name, or `null` when the leaderboard shows no names. */
47
+ name: string | null;
48
+ score: number;
49
+ /** Whether this is the player playing now. */
50
+ mine: boolean;
51
+ }
52
+ /** One period of a leaderboard, best score first. */
53
+ export interface LeaderboardTable {
54
+ key: string;
55
+ settings: LeaderboardSettings;
56
+ /** The period read, or `null` for the period before a leaderboard that never resets. */
57
+ period: string | null;
58
+ entries: LeaderboardEntry[];
59
+ /** The player's own entry in the period, wherever it ranks, or `null`. */
60
+ mine: LeaderboardEntry | null;
61
+ /**
62
+ * `false` when the leaderboard does not exist, such as a key no leaderboard
63
+ * of the game has. The table is then empty.
64
+ */
65
+ available: boolean;
66
+ /** Whether the scores are kept on this device rather than by Minimo Games. */
67
+ local: boolean;
68
+ }
69
+ /**
70
+ * What became of a score:
71
+ *
72
+ * - `saved`: it is the player's best in the period, and the table shows it.
73
+ * - `not_better`: the player already has a better score in the period.
74
+ * - `pending_sign_in`: the leaderboard takes Minimo Games accounts and the
75
+ * player is not signed in. Minimo Games asks them to sign in, keeps the
76
+ * score, and saves it when they come back.
77
+ * - `unavailable`: the leaderboard does not exist, or the game is not taking
78
+ * new records now.
79
+ */
80
+ export type LeaderboardSubmitStatus = "saved" | "not_better" | "pending_sign_in" | "unavailable";
81
+ export interface LeaderboardSubmitResult {
82
+ status: LeaderboardSubmitStatus;
83
+ /** The player's best score in the period, or `null` when nothing was saved. */
84
+ best: number | null;
85
+ /** The rank of that best score, or `null` when nothing was saved. */
86
+ rank: number | null;
87
+ period: string | null;
88
+ }
89
+ /** Options for {@link Leaderboard.top}. */
90
+ export interface LeaderboardTopOptions {
91
+ /** Which period to read. Default `"current"`. */
92
+ period?: LeaderboardPeriod;
93
+ }
94
+ /** Options for {@link Leaderboard.show}. Every field is optional. */
95
+ export interface LeaderboardShowOptions {
96
+ /** How many of the best scores the table shows. Default `10`, at most `100`. */
97
+ top?: number;
98
+ /** Which period the table opens on. Default `"current"`; the player can switch to the previous one. */
99
+ period?: LeaderboardPeriod;
100
+ /** A title instead of the leaderboard's own. */
101
+ title?: string;
102
+ /** How a score is written, such as `(s) => s.toFixed(2) + " s"`. Default: the number, with digit grouping. */
103
+ format?: (score: number) => string;
104
+ /** CSS font family, such as one of the game's fonts. Default: the system font. */
105
+ font?: string;
106
+ /** Background color of the table. Default `"#15151d"`. */
107
+ background?: string;
108
+ /** Text color. Default `"#ffffff"`. */
109
+ color?: string;
110
+ /** Color of the title and of the player's own row. Default `"#ffcc33"`. */
111
+ accent?: string;
112
+ }
113
+ /**
114
+ * A leaderboard of the game: a table of each player's best score, per period.
115
+ *
116
+ * On Minimo Games, the creator sets up a game's leaderboards, up to 5, on the
117
+ * game's Edit leaderboards page, or an AI agent with the `saveGameLeaderboard`
118
+ * tool; the game names one by its key. Their settings come from there, and a
119
+ * key that no leaderboard of the game has is unavailable. Leaderboards are part
120
+ * of Minimo Games Pro: while the creator is not on it, the game shows its
121
+ * tables but saves no new records.
122
+ *
123
+ * Scores of a leaderboard that takes Minimo Games accounts go to one table
124
+ * that every player sees. Minimo Games posts them for the signed-in player, and
125
+ * asks a player who is not signed in to sign in, keeping the score until they
126
+ * do. Scores of a leaderboard for light users stay on the device, and a game
127
+ * preview keeps every score on the device, so a working version never changes
128
+ * the published game's tables.
129
+ *
130
+ * Anywhere else, every leaderboard keeps its scores on the device, with the
131
+ * settings passed here.
132
+ *
133
+ * Nothing here pauses the game. Await {@link Leaderboard.show} to continue
134
+ * after the player closes the table.
135
+ *
136
+ * ```js
137
+ * import { Leaderboard } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo-leaderboard.js";
138
+ *
139
+ * const best = new Leaderboard(game, "best-score");
140
+ * // When a run ends:
141
+ * await best.submit(score);
142
+ * await best.show({ top: 10 });
143
+ * ```
144
+ */
145
+ export declare class Leaderboard {
146
+ /** The key the game names this leaderboard by. */
147
+ readonly key: string;
148
+ /**
149
+ * @param game - The game.
150
+ * @param key - The leaderboard's key, as set up on Minimo Games.
151
+ * @param settings - Used only when the game runs outside Minimo Games.
152
+ * Default: a leaderboard on the device that never resets, where higher is
153
+ * better and players are named by their own name.
154
+ */
155
+ constructor(game: Game, key: string, settings?: Partial<LeaderboardSettings>);
156
+ /** Whether the table is showing. */
157
+ get isShowing(): boolean;
158
+ /**
159
+ * This leaderboard's settings, and whether it exists and keeps its scores on
160
+ * the device.
161
+ */
162
+ info(): Promise<{
163
+ settings: LeaderboardSettings;
164
+ available: boolean;
165
+ local: boolean;
166
+ }>;
167
+ /**
168
+ * Posts a score. Only the player's best score in the current period is
169
+ * kept, so posting every finished run is right.
170
+ *
171
+ * @param score - A finite number. Which is better, higher or lower, is the
172
+ * leaderboard's setting.
173
+ */
174
+ submit(score: number): Promise<LeaderboardSubmitResult>;
175
+ /**
176
+ * Reads one period of the table, best score first.
177
+ *
178
+ * @param limit - How many of the best scores. Default `10`, at most `100`.
179
+ */
180
+ top(limit?: number, options?: LeaderboardTopOptions): Promise<LeaderboardTable>;
181
+ /**
182
+ * The light user's name on this device, shared by every Minimo Games game,
183
+ * or `null` before the player writes one. The table asks for it on its own
184
+ * when a leaderboard for light users shows names.
185
+ */
186
+ playerName(): Promise<string | null>;
187
+ /** Sets the light user's name on this device. Returns it cleaned, or `null` when it is empty or too long. */
188
+ setPlayerName(name: string): Promise<string | null>;
189
+ /**
190
+ * Shows the table over the game, in HTML: the best scores of a period, the
191
+ * player's own entry, and, for a leaderboard that resets, a switch to the
192
+ * previous period. Resolves when the player closes it.
193
+ */
194
+ show(options?: LeaderboardShowOptions): Promise<void>;
195
+ /** Closes the table, if it is showing. */
196
+ close(): void;
197
+ /** Closes the table. The leaderboard's scores are kept. */
198
+ destroy(): void;
199
+ }
@@ -0,0 +1,566 @@
1
+ const DEFAULT_SETTINGS = {
2
+ title: "Leaderboard",
3
+ cycle: "never",
4
+ order: "higher",
5
+ names: "player",
6
+ player: "local",
7
+ };
8
+ const NO_NAME = "noname";
9
+ const MAX_NAME_CHARS = 24;
10
+ const MAX_LOCAL_ENTRIES = 100;
11
+ const MAX_LOCAL_PERIODS = 60;
12
+ const MAX_READ = 100;
13
+ // ---------------------------------------------------------------------------
14
+ // Periods: the same as Minimo Games computes for the leaderboards it keeps.
15
+ function pad(value) {
16
+ return String(value).padStart(2, "0");
17
+ }
18
+ function isoWeek(date) {
19
+ const day = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
20
+ const weekday = day.getUTCDay() || 7;
21
+ day.setUTCDate(day.getUTCDate() + 4 - weekday);
22
+ const yearStart = Date.UTC(day.getUTCFullYear(), 0, 1);
23
+ return { year: day.getUTCFullYear(), week: Math.ceil(((day.getTime() - yearStart) / 86400000 + 1) / 7) };
24
+ }
25
+ /** @internal */
26
+ export function leaderboardPeriod(cycle, at) {
27
+ switch (cycle) {
28
+ case "daily":
29
+ return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())}`;
30
+ case "weekly": {
31
+ const { year, week } = isoWeek(at);
32
+ return `${year}-W${pad(week)}`;
33
+ }
34
+ case "monthly":
35
+ return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}`;
36
+ case "yearly":
37
+ return String(at.getUTCFullYear());
38
+ default:
39
+ return "all";
40
+ }
41
+ }
42
+ /** @internal */
43
+ export function resolveLeaderboardPeriod(cycle, requested, now) {
44
+ const value = (requested ?? "current").trim();
45
+ if (value === "" || value === "current")
46
+ return leaderboardPeriod(cycle, now);
47
+ if (value !== "previous")
48
+ return value;
49
+ const day = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
50
+ switch (cycle) {
51
+ case "daily":
52
+ return leaderboardPeriod(cycle, new Date(day - 86400000));
53
+ case "weekly":
54
+ return leaderboardPeriod(cycle, new Date(day - 7 * 86400000));
55
+ case "monthly":
56
+ return leaderboardPeriod(cycle, new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)));
57
+ case "yearly":
58
+ return String(now.getUTCFullYear() - 1);
59
+ default:
60
+ return null;
61
+ }
62
+ }
63
+ const HOST_TIMEOUT_MS = 1500;
64
+ const REQUEST_TIMEOUT_MS = 15000;
65
+ const pendingRequests = new Map();
66
+ let nextRequestId = 1;
67
+ let listening = false;
68
+ let hostPromise = null;
69
+ function parentWindow() {
70
+ try {
71
+ return typeof window !== "undefined" && window.parent && window.parent !== window ? window.parent : null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function listen() {
78
+ if (listening || typeof window === "undefined")
79
+ return;
80
+ listening = true;
81
+ window.addEventListener("message", (event) => {
82
+ if (event.source !== parentWindow())
83
+ return;
84
+ const message = event.data;
85
+ if (!message || message.type !== "minimo:leaderboard-response" || typeof message.id !== "string")
86
+ return;
87
+ const pending = pendingRequests.get(message.id);
88
+ if (!pending)
89
+ return;
90
+ pendingRequests.delete(message.id);
91
+ if (message.ok)
92
+ pending.resolve(message.result);
93
+ else
94
+ pending.reject(new Error(message.error || "Leaderboard request failed"));
95
+ });
96
+ }
97
+ function request(action, payload, timeoutMs = REQUEST_TIMEOUT_MS) {
98
+ const parent = parentWindow();
99
+ if (!parent)
100
+ return Promise.reject(new Error("No leaderboard host"));
101
+ listen();
102
+ const id = `leaderboard-${nextRequestId++}`;
103
+ return new Promise((resolve, reject) => {
104
+ const timer = setTimeout(() => {
105
+ pendingRequests.delete(id);
106
+ reject(new Error("The leaderboard did not answer"));
107
+ }, timeoutMs);
108
+ pendingRequests.set(id, {
109
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
110
+ reject: (error) => { clearTimeout(timer); reject(error); },
111
+ });
112
+ parent.postMessage({ type: "minimo:leaderboard-request", id, action, ...payload }, "*");
113
+ });
114
+ }
115
+ function connectHost() {
116
+ hostPromise ?? (hostPromise = request("hello", {}, HOST_TIMEOUT_MS)
117
+ .then((info) => (info && info.host === "minimo-games" ? info : null))
118
+ .catch(() => null));
119
+ return hostPromise;
120
+ }
121
+ const memoryStorage = new Map();
122
+ function readStored(key) {
123
+ try {
124
+ const value = window.localStorage.getItem(key);
125
+ if (value !== null)
126
+ return value;
127
+ }
128
+ catch {
129
+ // Fall back to memory.
130
+ }
131
+ return memoryStorage.get(key) ?? null;
132
+ }
133
+ function writeStored(key, value) {
134
+ memoryStorage.set(key, value);
135
+ try {
136
+ window.localStorage.setItem(key, value);
137
+ }
138
+ catch {
139
+ // Kept in memory for this visit.
140
+ }
141
+ }
142
+ function cleanName(value) {
143
+ if (typeof value !== "string")
144
+ return null;
145
+ const name = value.replace(/\s+/g, " ").trim();
146
+ return name && Array.from(name).length <= MAX_NAME_CHARS ? name : null;
147
+ }
148
+ const ADJECTIVES = ["Blue", "Brave", "Bright", "Calm", "Clever", "Cosmic", "Golden", "Happy", "Jolly", "Lucky", "Mighty", "Quick",
149
+ "Quiet", "Red", "Silver", "Sleepy", "Sly", "Sunny", "Swift", "Tiny", "Wild", "Wise", "Witty", "Zesty"];
150
+ const ANIMALS = ["Badger", "Bear", "Cat", "Crab", "Deer", "Duck", "Eagle", "Fox", "Frog", "Gecko", "Goat", "Hare",
151
+ "Koala", "Lion", "Llama", "Mole", "Otter", "Owl", "Panda", "Seal", "Shark", "Tiger", "Turtle", "Wolf"];
152
+ function assignedLocalName() {
153
+ const key = "minimo-leaderboard-assigned-name";
154
+ const stored = cleanName(readStored(key));
155
+ if (stored)
156
+ return stored;
157
+ const pick = (list) => list[Math.floor(Math.random() * list.length)];
158
+ const name = `${pick(ADJECTIVES)} ${pick(ANIMALS)} ${Math.floor(Math.random() * 100)}`;
159
+ writeStored(key, name);
160
+ return name;
161
+ }
162
+ function isBetter(order, candidate, current) {
163
+ return order === "higher" ? candidate > current : candidate < current;
164
+ }
165
+ function rankEntries(scores) {
166
+ const ranks = [];
167
+ scores.forEach((score, index) => {
168
+ ranks.push(index > 0 && scores[index - 1] === score ? ranks[index - 1] : index + 1);
169
+ });
170
+ return ranks;
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // The table drawn over the game, in HTML.
174
+ const STYLE_ID = "minimo-leaderboard-style";
175
+ const STYLE = `
176
+ .minimo-leaderboard{position:fixed;z-index:2147483000;display:flex;align-items:center;justify-content:center;
177
+ box-sizing:border-box;background:rgba(0,0,0,.55);-webkit-user-select:none;user-select:none;touch-action:manipulation}
178
+ .minimo-leaderboard *{box-sizing:border-box}
179
+ .minimo-leaderboard-panel{display:flex;flex-direction:column;width:min(92%,30em);max-height:92%;padding:1em;border-radius:.9em;
180
+ background:var(--mlb-bg);color:var(--mlb-fg);font-family:var(--mlb-font);box-shadow:0 .6em 2em rgba(0,0,0,.5)}
181
+ .minimo-leaderboard-title{margin:0 0 .15em;color:var(--mlb-accent);font-size:1.35em;font-weight:700;text-align:center}
182
+ .minimo-leaderboard-period{margin:0 0 .7em;opacity:.7;font-size:.8em;text-align:center}
183
+ .minimo-leaderboard-tabs{display:flex;gap:.4em;justify-content:center;margin:0 0 .7em}
184
+ .minimo-leaderboard button{padding:.45em .9em;border:.08em solid currentColor;border-radius:999px;background:none;color:inherit;
185
+ font:inherit;font-size:.85em;cursor:pointer}
186
+ .minimo-leaderboard button[aria-pressed="true"],.minimo-leaderboard .minimo-leaderboard-primary{border-color:var(--mlb-accent);
187
+ background:var(--mlb-accent);color:var(--mlb-bg);font-weight:700}
188
+ .minimo-leaderboard-rows{flex:1 1 auto;min-height:0;margin:0;padding:0;overflow-y:auto;list-style:none}
189
+ .minimo-leaderboard-row{display:flex;align-items:baseline;gap:.6em;padding:.35em .5em;border-radius:.4em}
190
+ .minimo-leaderboard-row:nth-child(odd){background:rgba(255,255,255,.06)}
191
+ .minimo-leaderboard-row.is-mine{color:var(--mlb-accent);font-weight:700}
192
+ .minimo-leaderboard-rank{flex:0 0 2.2em;text-align:right;opacity:.8}
193
+ .minimo-leaderboard-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
194
+ .minimo-leaderboard-score{flex:0 0 auto;font-variant-numeric:tabular-nums}
195
+ .minimo-leaderboard-gap{padding:.1em .5em;opacity:.5;text-align:center;list-style:none}
196
+ .minimo-leaderboard-empty{margin:.8em 0;opacity:.75;text-align:center}
197
+ .minimo-leaderboard-name-form{display:flex;gap:.4em;margin:0 0 .7em}
198
+ .minimo-leaderboard-name-form input{flex:1 1 auto;min-width:0;padding:.45em .6em;border:.08em solid currentColor;border-radius:.4em;
199
+ background:rgba(255,255,255,.08);color:inherit;font:inherit;font-size:max(16px,.9em)}
200
+ .minimo-leaderboard-note{margin:.6em 0 0;opacity:.6;font-size:.75em;text-align:center}
201
+ .minimo-leaderboard-actions{display:flex;justify-content:center;gap:.5em;margin-top:.8em}
202
+ `;
203
+ function escapeHtml(value) {
204
+ return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
205
+ }
206
+ function defaultFormat(score) {
207
+ return Number.isInteger(score) ? score.toLocaleString() : score.toLocaleString(undefined, { maximumFractionDigits: 3 });
208
+ }
209
+ const CURRENT_LABELS = {
210
+ never: "All time", daily: "Today", weekly: "This week", monthly: "This month", yearly: "This year",
211
+ };
212
+ const PREVIOUS_LABELS = {
213
+ never: "", daily: "Yesterday", weekly: "Last week", monthly: "Last month", yearly: "Last year",
214
+ };
215
+ /** @internal The table's HTML, apart from the page, so it can be checked without a browser. */
216
+ export function renderLeaderboardHtml(table, view) {
217
+ const cycle = table.settings.cycle;
218
+ const parts = [];
219
+ parts.push(`<div class="minimo-leaderboard-panel" role="dialog" aria-modal="true" aria-label="${escapeHtml(view.title)}">`);
220
+ parts.push(`<h2 class="minimo-leaderboard-title">${escapeHtml(view.title)}</h2>`);
221
+ if (cycle !== "never") {
222
+ parts.push('<div class="minimo-leaderboard-tabs" role="group">');
223
+ parts.push(`<button type="button" data-period="current" aria-pressed="${view.previous ? "false" : "true"}">${CURRENT_LABELS[cycle]}</button>`);
224
+ parts.push(`<button type="button" data-period="previous" aria-pressed="${view.previous ? "true" : "false"}">${PREVIOUS_LABELS[cycle]}</button>`);
225
+ parts.push("</div>");
226
+ }
227
+ else {
228
+ parts.push(`<p class="minimo-leaderboard-period">${CURRENT_LABELS.never}</p>`);
229
+ }
230
+ if (view.askName) {
231
+ // Not a <form>: a game's frame on Minimo Games is sandboxed without forms.
232
+ parts.push('<div class="minimo-leaderboard-name-form" data-name-form>');
233
+ parts.push(`<input name="name" maxlength="${MAX_NAME_CHARS}" placeholder="Your name" aria-label="Your name" autocomplete="nickname" value="${escapeHtml(view.playerName ?? "")}">`);
234
+ parts.push('<button type="button" class="minimo-leaderboard-primary" data-save-name>Save</button></div>');
235
+ }
236
+ if (!table.available) {
237
+ parts.push('<p class="minimo-leaderboard-empty">This leaderboard is not available.</p>');
238
+ }
239
+ else if (table.entries.length === 0) {
240
+ parts.push('<p class="minimo-leaderboard-empty">No scores yet.</p>');
241
+ }
242
+ else {
243
+ parts.push('<ol class="minimo-leaderboard-rows">');
244
+ const row = (entry) => parts.push(`<li class="minimo-leaderboard-row${entry.mine ? " is-mine" : ""}">`
245
+ + `<span class="minimo-leaderboard-rank">${entry.rank}</span>`
246
+ + `<span class="minimo-leaderboard-name">${entry.name === null ? "" : escapeHtml(entry.name)}</span>`
247
+ + `<span class="minimo-leaderboard-score">${escapeHtml(view.format(entry.score))}</span></li>`);
248
+ table.entries.forEach(row);
249
+ if (table.mine && !table.entries.some((entry) => entry.mine)) {
250
+ parts.push('<li class="minimo-leaderboard-gap" aria-hidden="true">…</li>');
251
+ row(table.mine);
252
+ }
253
+ parts.push("</ol>");
254
+ }
255
+ if (table.local && table.available) {
256
+ parts.push('<p class="minimo-leaderboard-note">Scores on this device</p>');
257
+ }
258
+ parts.push('<div class="minimo-leaderboard-actions"><button type="button" class="minimo-leaderboard-primary" data-close>Close</button></div>');
259
+ parts.push("</div>");
260
+ return parts.join("");
261
+ }
262
+ /**
263
+ * A leaderboard of the game: a table of each player's best score, per period.
264
+ *
265
+ * On Minimo Games, the creator sets up a game's leaderboards, up to 5, on the
266
+ * game's Edit leaderboards page, or an AI agent with the `saveGameLeaderboard`
267
+ * tool; the game names one by its key. Their settings come from there, and a
268
+ * key that no leaderboard of the game has is unavailable. Leaderboards are part
269
+ * of Minimo Games Pro: while the creator is not on it, the game shows its
270
+ * tables but saves no new records.
271
+ *
272
+ * Scores of a leaderboard that takes Minimo Games accounts go to one table
273
+ * that every player sees. Minimo Games posts them for the signed-in player, and
274
+ * asks a player who is not signed in to sign in, keeping the score until they
275
+ * do. Scores of a leaderboard for light users stay on the device, and a game
276
+ * preview keeps every score on the device, so a working version never changes
277
+ * the published game's tables.
278
+ *
279
+ * Anywhere else, every leaderboard keeps its scores on the device, with the
280
+ * settings passed here.
281
+ *
282
+ * Nothing here pauses the game. Await {@link Leaderboard.show} to continue
283
+ * after the player closes the table.
284
+ *
285
+ * ```js
286
+ * import { Leaderboard } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo-leaderboard.js";
287
+ *
288
+ * const best = new Leaderboard(game, "best-score");
289
+ * // When a run ends:
290
+ * await best.submit(score);
291
+ * await best.show({ top: 10 });
292
+ * ```
293
+ */
294
+ export class Leaderboard {
295
+ /**
296
+ * @param game - The game.
297
+ * @param key - The leaderboard's key, as set up on Minimo Games.
298
+ * @param settings - Used only when the game runs outside Minimo Games.
299
+ * Default: a leaderboard on the device that never resets, where higher is
300
+ * better and players are named by their own name.
301
+ */
302
+ constructor(game, key, settings = {}) {
303
+ /** @internal */ this._overlay = null;
304
+ /** @internal */ this._closeOverlay = null;
305
+ this._game = game;
306
+ this.key = String(key);
307
+ this._fallback = { ...DEFAULT_SETTINGS, title: this.key, ...settings };
308
+ }
309
+ /** Whether the table is showing. */
310
+ get isShowing() {
311
+ return this._overlay !== null;
312
+ }
313
+ /** @internal */
314
+ async _resolve() {
315
+ const host = await connectHost();
316
+ if (!host) {
317
+ return { settings: this._fallback, available: true, local: true, accepting: true, preview: false, host: false };
318
+ }
319
+ const settings = host.leaderboards[this.key];
320
+ if (!settings) {
321
+ return { settings: { ...this._fallback, title: this.key }, available: false, local: true, accepting: false, preview: host.preview, host: true };
322
+ }
323
+ return {
324
+ settings,
325
+ available: true,
326
+ local: settings.player === "local" || host.preview,
327
+ accepting: host.accepting,
328
+ preview: host.preview,
329
+ host: true,
330
+ };
331
+ }
332
+ /**
333
+ * This leaderboard's settings, and whether it exists and keeps its scores on
334
+ * the device.
335
+ */
336
+ async info() {
337
+ const { settings, available, local } = await this._resolve();
338
+ return { settings, available, local };
339
+ }
340
+ /** @internal */
341
+ _storageKey(preview) {
342
+ return `${preview ? "minimo-leaderboard-preview:" : "minimo-leaderboard:"}${this.key}`;
343
+ }
344
+ /** @internal */
345
+ _readLocal(preview) {
346
+ try {
347
+ const parsed = JSON.parse(readStored(this._storageKey(preview)) ?? "null");
348
+ if (parsed && parsed.v === 1 && parsed.periods && typeof parsed.periods === "object")
349
+ return parsed;
350
+ }
351
+ catch {
352
+ // A damaged board starts again.
353
+ }
354
+ return { v: 1, periods: {} };
355
+ }
356
+ /** @internal */
357
+ _writeLocal(preview, board) {
358
+ const periods = Object.keys(board.periods);
359
+ // The oldest periods go first when there are too many to keep on a device.
360
+ for (const period of periods.sort().slice(0, Math.max(0, periods.length - MAX_LOCAL_PERIODS))) {
361
+ delete board.periods[period];
362
+ }
363
+ writeStored(this._storageKey(preview), JSON.stringify(board));
364
+ }
365
+ /** @internal */
366
+ async _localName(settings) {
367
+ if (settings.names === "hidden")
368
+ return null;
369
+ if (settings.names === "assigned")
370
+ return assignedLocalName();
371
+ return (await this.playerName()) ?? NO_NAME;
372
+ }
373
+ /**
374
+ * Posts a score. Only the player's best score in the current period is
375
+ * kept, so posting every finished run is right.
376
+ *
377
+ * @param score - A finite number. Which is better, higher or lower, is the
378
+ * leaderboard's setting.
379
+ */
380
+ async submit(score) {
381
+ const value = Number(score);
382
+ if (!Number.isFinite(value))
383
+ throw new Error("Leaderboard.submit needs a finite number");
384
+ const resolved = await this._resolve();
385
+ if (!resolved.available || !resolved.accepting) {
386
+ return { status: "unavailable", best: null, rank: null, period: null };
387
+ }
388
+ if (!resolved.local) {
389
+ const result = await request("submit", { key: this.key, score: value });
390
+ return { status: result.status, best: result.best ?? null, rank: result.rank ?? null, period: result.period ?? null };
391
+ }
392
+ const period = leaderboardPeriod(resolved.settings.cycle, new Date());
393
+ const board = this._readLocal(resolved.preview);
394
+ const entries = board.periods[period] ?? [];
395
+ const order = resolved.settings.order;
396
+ const previousBest = entries.length > 0 ? entries[0].score : null;
397
+ entries.push({ score: value, at: new Date().toISOString() });
398
+ entries.sort((a, b) => (order === "higher" ? b.score - a.score : a.score - b.score));
399
+ board.periods[period] = entries.slice(0, MAX_LOCAL_ENTRIES);
400
+ this._writeLocal(resolved.preview, board);
401
+ const best = board.periods[period][0].score;
402
+ const improved = previousBest === null || isBetter(order, value, previousBest);
403
+ const rank = rankEntries(board.periods[period].map((entry) => entry.score))[board.periods[period].findIndex((entry) => entry.score === value)] ?? null;
404
+ return { status: improved ? "saved" : "not_better", best, rank: improved ? 1 : rank, period };
405
+ }
406
+ /**
407
+ * Reads one period of the table, best score first.
408
+ *
409
+ * @param limit - How many of the best scores. Default `10`, at most `100`.
410
+ */
411
+ async top(limit = 10, options = {}) {
412
+ const count = Math.min(Math.max(Math.trunc(Number(limit) || 10), 1), MAX_READ);
413
+ const resolved = await this._resolve();
414
+ const base = { key: this.key, settings: resolved.settings, available: resolved.available, local: resolved.local };
415
+ if (!resolved.available) {
416
+ return { ...base, period: null, entries: [], mine: null };
417
+ }
418
+ if (!resolved.local) {
419
+ const table = await request("top", { key: this.key, limit: count, period: options.period ?? "current" });
420
+ return { ...base, period: table.period, entries: table.entries, mine: table.mine };
421
+ }
422
+ const period = resolveLeaderboardPeriod(resolved.settings.cycle, options.period, new Date());
423
+ if (period === null)
424
+ return { ...base, period: null, entries: [], mine: null };
425
+ const scores = (this._readLocal(resolved.preview).periods[period] ?? []).map((entry) => entry.score);
426
+ const ranks = rankEntries(scores);
427
+ const name = await this._localName(resolved.settings);
428
+ const entries = scores.slice(0, count).map((score, index) => ({ rank: ranks[index], name, score, mine: true }));
429
+ return { ...base, period, entries, mine: entries[0] ?? null };
430
+ }
431
+ /**
432
+ * The light user's name on this device, shared by every Minimo Games game,
433
+ * or `null` before the player writes one. The table asks for it on its own
434
+ * when a leaderboard for light users shows names.
435
+ */
436
+ async playerName() {
437
+ if (await connectHost()) {
438
+ try {
439
+ return cleanName((await request("getPlayerName", {})).name);
440
+ }
441
+ catch {
442
+ return null;
443
+ }
444
+ }
445
+ return cleanName(readStored("minimo-leaderboard-player-name"));
446
+ }
447
+ /** Sets the light user's name on this device. Returns it cleaned, or `null` when it is empty or too long. */
448
+ async setPlayerName(name) {
449
+ const clean = cleanName(name);
450
+ if (!clean)
451
+ return null;
452
+ if (await connectHost()) {
453
+ return cleanName((await request("setPlayerName", { name: clean })).name);
454
+ }
455
+ writeStored("minimo-leaderboard-player-name", clean);
456
+ return clean;
457
+ }
458
+ /**
459
+ * Shows the table over the game, in HTML: the best scores of a period, the
460
+ * player's own entry, and, for a leaderboard that resets, a switch to the
461
+ * previous period. Resolves when the player closes it.
462
+ */
463
+ async show(options = {}) {
464
+ if (typeof document === "undefined" || !document.body)
465
+ return;
466
+ this.close();
467
+ const resolved = await this._resolve();
468
+ const format = options.format ?? defaultFormat;
469
+ const title = options.title ?? resolved.settings.title;
470
+ let previous = options.period === "previous";
471
+ const askName = resolved.available && resolved.local && resolved.settings.names === "player";
472
+ let playerName = askName ? await this.playerName() : null;
473
+ if (!document.getElementById(STYLE_ID)) {
474
+ const style = document.createElement("style");
475
+ style.id = STYLE_ID;
476
+ style.textContent = STYLE;
477
+ document.head.appendChild(style);
478
+ }
479
+ const root = document.createElement("div");
480
+ root.className = "minimo-leaderboard";
481
+ root.style.setProperty("--mlb-bg", options.background ?? "#15151d");
482
+ root.style.setProperty("--mlb-fg", options.color ?? "#ffffff");
483
+ root.style.setProperty("--mlb-accent", options.accent ?? "#ffcc33");
484
+ root.style.setProperty("--mlb-font", options.font ?? 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif');
485
+ const canvas = this._game.getCanvasElement();
486
+ const place = () => {
487
+ const rect = canvas.getBoundingClientRect();
488
+ root.style.left = `${rect.left}px`;
489
+ root.style.top = `${rect.top}px`;
490
+ root.style.width = `${rect.width}px`;
491
+ root.style.height = `${rect.height}px`;
492
+ root.style.fontSize = `${Math.max(12, Math.min(22, rect.width / 22))}px`;
493
+ };
494
+ place();
495
+ let token = 0;
496
+ const render = async () => {
497
+ const mine = ++token;
498
+ const table = await this.top(options.top ?? 10, { period: previous ? "previous" : options.period && options.period !== "previous" ? options.period : "current" })
499
+ .catch(() => ({ key: this.key, settings: resolved.settings, period: null, entries: [], mine: null, available: false, local: resolved.local }));
500
+ if (mine !== token || this._overlay !== root)
501
+ return;
502
+ root.innerHTML = renderLeaderboardHtml(table, { title, previous, format, askName: askName && !previous, playerName });
503
+ };
504
+ // Keys typed into the table, such as a name, must not reach the game.
505
+ const stopKeys = (event) => event.stopPropagation();
506
+ return new Promise((resolve) => {
507
+ const finish = () => {
508
+ window.removeEventListener("resize", place);
509
+ root.removeEventListener("keydown", stopKeys);
510
+ root.removeEventListener("keyup", stopKeys);
511
+ root.remove();
512
+ if (this._overlay === root) {
513
+ this._overlay = null;
514
+ this._closeOverlay = null;
515
+ }
516
+ resolve();
517
+ };
518
+ const saveName = () => {
519
+ const input = root.querySelector("[data-name-form] input");
520
+ void this.setPlayerName(input?.value ?? "").then((name) => {
521
+ if (name) {
522
+ playerName = name;
523
+ void render();
524
+ }
525
+ });
526
+ };
527
+ root.addEventListener("keydown", (event) => {
528
+ if (event.key === "Escape")
529
+ finish();
530
+ else if (event.key === "Enter" && event.target?.closest?.("[data-name-form]"))
531
+ saveName();
532
+ });
533
+ root.addEventListener("keydown", stopKeys);
534
+ root.addEventListener("keyup", stopKeys);
535
+ root.addEventListener("click", (event) => {
536
+ const target = event.target;
537
+ if (target === root || target?.closest("[data-close]")) {
538
+ finish();
539
+ return;
540
+ }
541
+ if (target?.closest("[data-save-name]")) {
542
+ saveName();
543
+ return;
544
+ }
545
+ const tab = target?.closest("[data-period]");
546
+ if (tab) {
547
+ previous = tab.getAttribute("data-period") === "previous";
548
+ void render();
549
+ }
550
+ });
551
+ window.addEventListener("resize", place);
552
+ this._overlay = root;
553
+ this._closeOverlay = finish;
554
+ document.body.appendChild(root);
555
+ void render();
556
+ });
557
+ }
558
+ /** Closes the table, if it is showing. */
559
+ close() {
560
+ this._closeOverlay?.();
561
+ }
562
+ /** Closes the table. The leaderboard's scores are kept. */
563
+ destroy() {
564
+ this.close();
565
+ }
566
+ }
package/dist/minimo.js CHANGED
@@ -2216,6 +2216,15 @@ export class Game {
2216
2216
  isPointerCancelled() {
2217
2217
  return this._inputSystem.isPointerCancelled();
2218
2218
  }
2219
+ /**
2220
+ * The canvas the game draws on, for a module in this package that lays HTML
2221
+ * over it, as `leaderboard` does with its table. Games never need it.
2222
+ *
2223
+ * @internal
2224
+ */
2225
+ getCanvasElement() {
2226
+ return this._canvas;
2227
+ }
2219
2228
  /**
2220
2229
  * Returns `true` while any active pointer is held down over the target sprite.
2221
2230
  * Works with both mouse input and multiple simultaneous touches.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.34",
3
+ "version": "1.0.0-alpha.35",
4
4
  "description": "MinimoJS v1 — ultra-minimal, flat, deterministic 2D web game engine. Image-first sprites with emoji as a zero-asset alternative, rAF loop, TypeScript-first, LLM-friendly.",
5
5
  "type": "module",
6
6
  "main": "dist/minimo.js",