@sonatel-os/strapi-plugin-tcg 0.3.0 → 0.3.2

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.
package/README.md CHANGED
@@ -16,6 +16,7 @@ It was built to move **Jambaar** (the Dakar 2026 Youth Olympics card game) off i
16
16
  | **Live** | **Boosts**: multiply the odds of any rarity for a time window, season-wide or on one pack; presets like "Golden hour". Open/sold-out drops, rewards running low. | `GET /live` |
17
17
  | **Pulls** | Dashboard rarity split vs target, audit log with seed, roll, **odds snapshot**, **pity** flag and **mint number**. | `POST /pulls/scan`, `GET /pulls/history`, `GET /pulls/cooldowns` |
18
18
  | **Collection** | Per-player view with completion, rank, sets, badges, mint numbers. | `GET /collection`, `GET /collection/stats` |
19
+ | **Score & rank** | Rarity-weighted, not a card count: every distinct card is worth its own `score` (admin, inside its rarity's band) or the midpoint of the band (defaults: common 20 · rare 45 · epic 70 · legendary 92); every completed set adds `limits.setCompletionBonus` (50). Standings sort by score, then distinct cards, then total cards. `GET /collection/stats` returns `globalRank`, `score`, `scoreBreakdown`; cards carry `points`. | `GET /collection/stats`, admin `GET /seasons/:season/leaderboard` |
19
20
  | **Trades** | A handshake both players confirm: the initiator's offer (QR) → a receiver joins → proposes cards → the initiator confirms (atomic swap) or declines. Both screens poll `GET /trades/offer/:id` (`status`, `role`, both sides' cards). Counts on the dashboard. | `POST /trades/offer`, `POST /trades/scan`, `GET …/offer/:id`, `…/join`, `…/propose`, `…/confirm`, `…/decline`, `…/reject`, `…/cancel`, `GET /trades/active`, `GET /trades/history` |
20
21
  | **Rewards** | **Catalog** with tiers, stock, per-player limit, booth, availability window and a **cost builder**: N cards of a rarity (or at least a rarity), a specific card, a complete set (owned or handed in). | `GET /rewards`, `GET /rewards/eligibility` |
21
22
  | **Booth** | Type or scan a redemption code, see the cards and the reward, hand it over or cancel and give the cards back (stock released). | `POST /redemptions`, `GET /redemptions/:id`, `GET /redemptions/history` |
@@ -7,9 +7,9 @@ const admin = require("@strapi/strapi/admin");
7
7
  const reactRouterDom = require("react-router-dom");
8
8
  const reactIntl = require("react-intl");
9
9
  const styledComponents = require("styled-components");
10
- const api = require("./api-hTExOv_J.js");
10
+ const api = require("./api-BqmNHByN.js");
11
11
  const react = require("react");
12
- const index = require("./index-CRbzJppw.js");
12
+ const index = require("./index-DQmN1LMy.js");
13
13
  const QR = require("qrcode");
14
14
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
15
15
  const QR__default = /* @__PURE__ */ _interopDefault(QR);
@@ -422,10 +422,17 @@ const Dashboard = () => {
422
422
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: e.player.code })
423
423
  ] }),
424
424
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { variant: "pi", children: [
425
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { fontWeight: "bold", children: [
426
+ e.score,
427
+ " pts"
428
+ ] }),
429
+ " · ",
425
430
  e.uniqueCards,
426
- " unique · ",
431
+ " ",
432
+ "unique · ",
427
433
  e.totalCards,
428
- " total"
434
+ " total",
435
+ e.setsCompleted ? ` · ${e.setsCompleted} set${e.setsCompleted > 1 ? "s" : ""}` : ""
429
436
  ] })
430
437
  ] }, e.player.id)) })
431
438
  }
@@ -2533,7 +2540,7 @@ const PlayerDetailPage = () => {
2533
2540
  {
2534
2541
  label: "Cards",
2535
2542
  value: stats?.totalCards ?? player.totalCards,
2536
- hint: stats ? `${stats.uniqueCards} unique of ${stats.catalogTotal}` : void 0
2543
+ hint: stats ? `${stats.uniqueCards} unique of ${stats.catalogTotal} · ${stats.score ?? 0} pts` : void 0
2537
2544
  }
2538
2545
  ) }),
2539
2546
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Grid.Item, { col: 3, s: 6, alignItems: "stretch", children: /* @__PURE__ */ jsxRuntime.jsx(
@@ -5,9 +5,9 @@ import { Page, useRBAC, useNotification } from "@strapi/strapi/admin";
5
5
  import { useNavigate, useParams, Routes, Route, NavLink } from "react-router-dom";
6
6
  import { useIntl } from "react-intl";
7
7
  import { styled } from "styled-components";
8
- import { u as useGetSeasonsQuery, E as Eyebrow, I as Intro, S as SPRING$1, a as useGetDashboardQuery, b as useGetLeaderboardQuery, c as useGetRewardsQuery, d as useGetSetsQuery, e as useGetBadgesQuery, f as Empty, g as Section, h as SetupSteps, i as StatTile, j as Sparkline, k as fmtNumber, R as RarityBadge, B as Bar, t as timeAgo, C as Card, P as Pill, l as StatusBadge, m as fmtDate, n as useGetSeasonDefaultsQuery, o as useGetSeasonQuery, p as useCreateSeasonMutation, q as useUpdateSeasonMutation, r as useDeleteSeasonMutation, s as errorMessage, v as useGetCardsQuery, w as useCreateCardMutation, x as useUpdateCardMutation, y as useDeleteCardMutation, z as rarityOf, A as useGetPacksQuery, D as useCreatePackMutation, F as useGetPackQuery, G as useUpdatePackMutation, H as useSetPackCardsMutation, J as useDeletePackMutation, K as useCreateDropMutation, L as useLazySimulatePackQuery, M as useGetPlayersQuery, N as fmtDateTime, O as useGetPlayerQuery, Q as useSetPlayerActiveMutation, T as useGrantCardMutation, U as useLazyGetRedemptionQuery, V as useFulfillRedemptionMutation, W as useCancelRedemptionMutation, X as useGetRedemptionsQuery, Y as Mono, Z as useGetPullsQuery, _ as useGetDropQuery, $ as useUpdateDropMutation, a0 as useSetDropCardsMutation, a1 as useDeleteDropMutation, a2 as useRotateDropSecretMutation, a3 as useDropQRMutation, a4 as useGetBoostsQuery, a5 as useCreateBoostMutation, a6 as useUpdateBoostMutation, a7 as useDeleteBoostMutation, a8 as useCreateRewardMutation, a9 as useUpdateRewardMutation, aa as useDeleteRewardMutation, ab as useCreateSetMutation, ac as useUpdateSetMutation, ad as useDeleteSetMutation, ae as useCreateBadgeMutation, af as useUpdateBadgeMutation, ag as useDeleteBadgeMutation, ah as useSeedBadgesMutation } from "./api-BgR-fpDp.mjs";
8
+ import { u as useGetSeasonsQuery, E as Eyebrow, I as Intro, S as SPRING$1, a as useGetDashboardQuery, b as useGetLeaderboardQuery, c as useGetRewardsQuery, d as useGetSetsQuery, e as useGetBadgesQuery, f as Empty, g as Section, h as SetupSteps, i as StatTile, j as Sparkline, k as fmtNumber, R as RarityBadge, B as Bar, t as timeAgo, C as Card, P as Pill, l as StatusBadge, m as fmtDate, n as useGetSeasonDefaultsQuery, o as useGetSeasonQuery, p as useCreateSeasonMutation, q as useUpdateSeasonMutation, r as useDeleteSeasonMutation, s as errorMessage, v as useGetCardsQuery, w as useCreateCardMutation, x as useUpdateCardMutation, y as useDeleteCardMutation, z as rarityOf, A as useGetPacksQuery, D as useCreatePackMutation, F as useGetPackQuery, G as useUpdatePackMutation, H as useSetPackCardsMutation, J as useDeletePackMutation, K as useCreateDropMutation, L as useLazySimulatePackQuery, M as useGetPlayersQuery, N as fmtDateTime, O as useGetPlayerQuery, Q as useSetPlayerActiveMutation, T as useGrantCardMutation, U as useLazyGetRedemptionQuery, V as useFulfillRedemptionMutation, W as useCancelRedemptionMutation, X as useGetRedemptionsQuery, Y as Mono, Z as useGetPullsQuery, _ as useGetDropQuery, $ as useUpdateDropMutation, a0 as useSetDropCardsMutation, a1 as useDeleteDropMutation, a2 as useRotateDropSecretMutation, a3 as useDropQRMutation, a4 as useGetBoostsQuery, a5 as useCreateBoostMutation, a6 as useUpdateBoostMutation, a7 as useDeleteBoostMutation, a8 as useCreateRewardMutation, a9 as useUpdateRewardMutation, aa as useDeleteRewardMutation, ab as useCreateSetMutation, ac as useUpdateSetMutation, ad as useDeleteSetMutation, ae as useCreateBadgeMutation, af as useUpdateBadgeMutation, ag as useDeleteBadgeMutation, ah as useSeedBadgesMutation } from "./api-Clm2RMWV.mjs";
9
9
  import { useState, useEffect, useMemo, createContext, useContext, useRef } from "react";
10
- import { P as PLUGIN_ID, a as PERMISSIONS } from "./index-ClDgxnnH.mjs";
10
+ import { P as PLUGIN_ID, a as PERMISSIONS } from "./index-CwyVbzVg.mjs";
11
11
  import QR from "qrcode";
12
12
  const KEY = "tcg.season";
13
13
  const SeasonContext = createContext({
@@ -418,10 +418,17 @@ const Dashboard = () => {
418
418
  /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: e.player.code })
419
419
  ] }),
420
420
  /* @__PURE__ */ jsxs(Typography, { variant: "pi", children: [
421
+ /* @__PURE__ */ jsxs(Typography, { fontWeight: "bold", children: [
422
+ e.score,
423
+ " pts"
424
+ ] }),
425
+ " · ",
421
426
  e.uniqueCards,
422
- " unique · ",
427
+ " ",
428
+ "unique · ",
423
429
  e.totalCards,
424
- " total"
430
+ " total",
431
+ e.setsCompleted ? ` · ${e.setsCompleted} set${e.setsCompleted > 1 ? "s" : ""}` : ""
425
432
  ] })
426
433
  ] }, e.player.id)) })
427
434
  }
@@ -2529,7 +2536,7 @@ const PlayerDetailPage = () => {
2529
2536
  {
2530
2537
  label: "Cards",
2531
2538
  value: stats?.totalCards ?? player.totalCards,
2532
- hint: stats ? `${stats.uniqueCards} unique of ${stats.catalogTotal}` : void 0
2539
+ hint: stats ? `${stats.uniqueCards} unique of ${stats.catalogTotal} · ${stats.score ?? 0} pts` : void 0
2533
2540
  }
2534
2541
  ) }),
2535
2542
  /* @__PURE__ */ jsx(Grid.Item, { col: 3, s: 6, alignItems: "stretch", children: /* @__PURE__ */ jsx(
@@ -3,8 +3,8 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const jsxRuntime = require("react/jsx-runtime");
4
4
  const designSystem = require("@strapi/design-system");
5
5
  const admin = require("@strapi/strapi/admin");
6
- const api = require("./api-hTExOv_J.js");
7
- const index = require("./index-CRbzJppw.js");
6
+ const api = require("./api-BqmNHByN.js");
7
+ const index = require("./index-DQmN1LMy.js");
8
8
  const Row = ({ k, v, hint, tone }) => /* @__PURE__ */ jsxRuntime.jsxs(
9
9
  designSystem.Flex,
10
10
  {
@@ -1,8 +1,8 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { Grid, Box, Flex, Typography, Badge } from "@strapi/design-system";
3
3
  import { Page, Layouts } from "@strapi/strapi/admin";
4
- import { ai as useGetSettingsQuery, I as Intro, g as Section, Y as Mono } from "./api-BgR-fpDp.mjs";
5
- import { a as PERMISSIONS } from "./index-ClDgxnnH.mjs";
4
+ import { ai as useGetSettingsQuery, I as Intro, g as Section, Y as Mono } from "./api-Clm2RMWV.mjs";
5
+ import { a as PERMISSIONS } from "./index-CwyVbzVg.mjs";
6
6
  const Row = ({ k, v, hint, tone }) => /* @__PURE__ */ jsxs(
7
7
  Flex,
8
8
  {
@@ -7,7 +7,7 @@ const symbols = require("@strapi/icons/symbols");
7
7
  const reactRouterDom = require("react-router-dom");
8
8
  const styledComponents = require("styled-components");
9
9
  const admin = require("@strapi/strapi/admin");
10
- const index = require("./index-CRbzJppw.js");
10
+ const index = require("./index-DQmN1LMy.js");
11
11
  const fmtDateTime = (value, locale) => {
12
12
  if (!value) return "—";
13
13
  try {
@@ -6,7 +6,7 @@ import { EmptyDocuments } from "@strapi/icons/symbols";
6
6
  import { useNavigate } from "react-router-dom";
7
7
  import { styled, css, keyframes } from "styled-components";
8
8
  import { adminApi } from "@strapi/strapi/admin";
9
- import { P as PLUGIN_ID } from "./index-ClDgxnnH.mjs";
9
+ import { P as PLUGIN_ID } from "./index-CwyVbzVg.mjs";
10
10
  const fmtDateTime = (value, locale) => {
11
11
  if (!value) return "—";
12
12
  try {
@@ -62,7 +62,7 @@ const index = {
62
62
  intlLabel: { id: `${PLUGIN_ID}.menu.label`, defaultMessage: "TCG" },
63
63
  permissions: PERMISSIONS.access,
64
64
  Component: async () => {
65
- const { App } = await import("./App-CS_cVYEI.mjs");
65
+ const { App } = await import("./App-DvkOruFZ.mjs");
66
66
  return App;
67
67
  }
68
68
  });
@@ -75,7 +75,7 @@ const index = {
75
75
  intlLabel: { id: `${PLUGIN_ID}.settings.link`, defaultMessage: "Configuration" },
76
76
  permissions: PERMISSIONS.settingsRead,
77
77
  Component: async () => {
78
- const { SettingsPage } = await import("./SettingsPage-fGiEcX6l.mjs");
78
+ const { SettingsPage } = await import("./SettingsPage-SjgoOH4S.mjs");
79
79
  return SettingsPage;
80
80
  }
81
81
  }
@@ -63,7 +63,7 @@ const index = {
63
63
  intlLabel: { id: `${PLUGIN_ID}.menu.label`, defaultMessage: "TCG" },
64
64
  permissions: PERMISSIONS.access,
65
65
  Component: async () => {
66
- const { App } = await Promise.resolve().then(() => require("./App-B-WZGNkc.js"));
66
+ const { App } = await Promise.resolve().then(() => require("./App-C3L5FTNZ.js"));
67
67
  return App;
68
68
  }
69
69
  });
@@ -76,7 +76,7 @@ const index = {
76
76
  intlLabel: { id: `${PLUGIN_ID}.settings.link`, defaultMessage: "Configuration" },
77
77
  permissions: PERMISSIONS.settingsRead,
78
78
  Component: async () => {
79
- const { SettingsPage } = await Promise.resolve().then(() => require("./SettingsPage-DNWXuY8j.js"));
79
+ const { SettingsPage } = await Promise.resolve().then(() => require("./SettingsPage-BxmcGJ5s.js"));
80
80
  return SettingsPage;
81
81
  }
82
82
  }
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-CRbzJppw.js");
2
+ const index = require("../_chunks/index-DQmN1LMy.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-ClDgxnnH.mjs";
1
+ import { i } from "../_chunks/index-CwyVbzVg.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -158,7 +158,9 @@ const DEFAULT_LIMITS = Object.freeze({
158
158
  qrTokenTtlMs: 5 * 60 * 1e3,
159
159
  staticScanCooldownMs: 24 * 60 * 60 * 1e3,
160
160
  pityRareAfter: 0,
161
- pityLegendaryAfter: 0
161
+ pityLegendaryAfter: 0,
162
+ /** Points a completed set adds to the collector's score (utils/score.js). */
163
+ setCompletionBonus: 50
162
164
  });
163
165
  const DEFAULT_RANK_TIERS = Object.freeze([
164
166
  { key: "legend", label: "Legend", minPercent: 100 },
@@ -233,14 +235,28 @@ const ACTIONS = [
233
235
  { section: "plugins", uid: RBAC.PLAYERS_READ, displayName: "Read players" },
234
236
  { section: "plugins", uid: RBAC.PLAYERS_WRITE, displayName: "Deactivate players, grant cards" },
235
237
  { section: "plugins", uid: RBAC.REDEMPTIONS_READ, displayName: "Read redemptions" },
236
- { section: "plugins", uid: RBAC.REDEMPTIONS_FULFILL, displayName: "Fulfill or cancel redemptions (booth)" },
238
+ {
239
+ section: "plugins",
240
+ uid: RBAC.REDEMPTIONS_FULFILL,
241
+ displayName: "Fulfill or cancel redemptions (booth)"
242
+ },
237
243
  { section: "plugins", uid: RBAC.AUDIT_READ, displayName: "Read the pull audit log" },
238
244
  { section: "plugins", uid: RBAC.REWARDS_READ, displayName: "Read the reward catalog" },
239
245
  { section: "plugins", uid: RBAC.REWARDS_WRITE, displayName: "Edit the reward catalog" },
240
246
  { section: "plugins", uid: RBAC.LIVE_READ, displayName: "Read live boosts" },
241
247
  { section: "plugins", uid: RBAC.LIVE_WRITE, displayName: "Create and edit live boosts" },
242
- { section: "settings", category: CATEGORY, uid: RBAC.SETTINGS_READ, displayName: "Read settings" },
243
- { section: "settings", category: CATEGORY, uid: RBAC.SETTINGS_WRITE, displayName: "Edit settings" }
248
+ {
249
+ section: "settings",
250
+ category: CATEGORY,
251
+ uid: RBAC.SETTINGS_READ,
252
+ displayName: "Read settings"
253
+ },
254
+ {
255
+ section: "settings",
256
+ category: CATEGORY,
257
+ uid: RBAC.SETTINGS_WRITE,
258
+ displayName: "Edit settings"
259
+ }
244
260
  ].map((a) => ({ ...a, pluginName: PLUGIN_ID }));
245
261
  const timers = [];
246
262
  const UNIQUE_COLUMNS = [
@@ -248,21 +264,46 @@ const UNIQUE_COLUMNS = [
248
264
  ["plugin::tcg.player", "code"],
249
265
  ["plugin::tcg.redemption", "code"]
250
266
  ];
267
+ const hasIndex = async (knex, table, name) => {
268
+ const client = String(knex.client?.config?.client ?? "");
269
+ if (/mysql/.test(client)) {
270
+ const rows2 = await knex.raw("SHOW INDEX FROM ?? WHERE Key_name = ?", [table, name]);
271
+ return (rows2?.[0] ?? []).length > 0;
272
+ }
273
+ if (/pg|postgres/.test(client)) {
274
+ const rows2 = await knex.raw("SELECT 1 FROM pg_indexes WHERE tablename = ? AND indexname = ?", [
275
+ table,
276
+ name
277
+ ]);
278
+ return (rows2?.rows ?? []).length > 0;
279
+ }
280
+ const rows = await knex.raw("PRAGMA index_list(??)", [table]);
281
+ return (rows ?? []).some((r) => r.name === name);
282
+ };
251
283
  const ensureUniqueIndexes = async (strapi) => {
284
+ const knex = strapi.db.connection;
252
285
  for (const [uid, column] of UNIQUE_COLUMNS) {
253
286
  const table = strapi.db.metadata.get(uid)?.tableName;
254
287
  if (!table) continue;
255
288
  const name = `${table}_${column}_tcg_uq`;
256
289
  try {
257
- await strapi.db.connection.schema.alterTable(table, (t) => t.unique([column], { indexName: name }));
290
+ if (await hasIndex(knex, table, name)) continue;
291
+ await knex.schema.alterTable(table, (t) => t.unique([column], { indexName: name }));
292
+ strapi.log.info(`${LOG_PREFIX} unique index ${name} created`);
258
293
  } catch (err) {
259
- if (!/exist|duplicate/i.test(err.message)) strapi.log.warn(`${LOG_PREFIX} unique index ${name}: ${err.message}`);
294
+ if (!/exist|duplicate/i.test(err.message))
295
+ strapi.log.warn(`${LOG_PREFIX} unique index ${name}: ${err.message}`);
260
296
  }
261
297
  }
262
298
  };
263
299
  const bootstrap = async ({ strapi }) => {
264
300
  await strapi.service("admin::permission").actionProvider.registerMany(ACTIONS);
265
- await ensureUniqueIndexes(strapi);
301
+ setTimeout(
302
+ () => ensureUniqueIndexes(strapi).catch(
303
+ (err) => strapi.log.warn(`${LOG_PREFIX} unique indexes: ${err.message}`)
304
+ ),
305
+ 5e3
306
+ );
266
307
  const plugin = strapi.plugin(PLUGIN_ID);
267
308
  if (plugin.service("settings").sweepsEnabled()) {
268
309
  const safe = (label, fn) => async () => {
@@ -273,13 +314,21 @@ const bootstrap = async ({ strapi }) => {
273
314
  strapi.log.warn(`${LOG_PREFIX} sweep ${label} failed: ${err.message}`);
274
315
  }
275
316
  };
276
- const nonceTimer = setInterval(safe("nonces", () => plugin.service("qr").sweep()), NONCE_SWEEP_INTERVAL_MS);
277
- const tradeTimer = setInterval(safe("expired offers", () => plugin.service("trade").sweep()), TRADE_SWEEP_INTERVAL_MS);
317
+ const nonceTimer = setInterval(
318
+ safe("nonces", () => plugin.service("qr").sweep()),
319
+ NONCE_SWEEP_INTERVAL_MS
320
+ );
321
+ const tradeTimer = setInterval(
322
+ safe("expired offers", () => plugin.service("trade").sweep()),
323
+ TRADE_SWEEP_INTERVAL_MS
324
+ );
278
325
  nonceTimer.unref();
279
326
  tradeTimer.unref();
280
327
  timers.push(nonceTimer, tradeTimer);
281
328
  }
282
- strapi.log.info(`${LOG_PREFIX} ready — ${ACTIONS.length} RBAC actions, content API at /api/${PLUGIN_ID}`);
329
+ strapi.log.info(
330
+ `${LOG_PREFIX} ready — ${ACTIONS.length} RBAC actions, content API at /api/${PLUGIN_ID}`
331
+ );
283
332
  };
284
333
  const stopTimers = () => {
285
334
  while (timers.length) clearInterval(timers.pop());
@@ -2703,6 +2752,39 @@ const qr = ({ strapi }) => ({
2703
2752
  return count ?? 0;
2704
2753
  }
2705
2754
  });
2755
+ const rarityPoints = (season2, rarityKey) => {
2756
+ const table = season2?.rarities?.length ? season2.rarities : DEFAULT_RARITIES;
2757
+ const idx = table.findIndex((r) => r.key === rarityKey);
2758
+ const meta = table[idx] ?? DEFAULT_RARITIES.find((r) => r.key === rarityKey) ?? null;
2759
+ const min = Number(meta?.scoreMin);
2760
+ const max = Number(meta?.scoreMax);
2761
+ if (Number.isFinite(min) && Number.isFinite(max) && max >= min)
2762
+ return Math.round((min + max) / 2);
2763
+ return 10 + Math.max(0, idx) * 20;
2764
+ };
2765
+ const cardPoints = (card2, season2) => {
2766
+ const own = Number(card2?.score);
2767
+ if (Number.isFinite(own) && own > 0) return Math.round(own);
2768
+ return rarityPoints(season2, card2?.rarity);
2769
+ };
2770
+ const standingOf = (distinctCards, totalCards, sets, season2, limits = {}) => {
2771
+ const ownedIds = new Set(distinctCards.map((c) => String(c.id)));
2772
+ const cardScore = distinctCards.reduce((sum, c) => sum + cardPoints(c, season2), 0);
2773
+ const setsCompleted = (sets ?? []).filter(
2774
+ (s) => (s.cards ?? []).length > 0 && s.cards.every((c) => ownedIds.has(String(c.id)))
2775
+ ).length;
2776
+ const bonus = Math.max(0, Number(limits.setCompletionBonus) || 0);
2777
+ return {
2778
+ uniqueCards: distinctCards.length,
2779
+ totalCards,
2780
+ cardScore,
2781
+ setsCompleted,
2782
+ setBonus: setsCompleted * bonus,
2783
+ score: cardScore + setsCompleted * bonus
2784
+ };
2785
+ };
2786
+ const compareStandings = (a, b) => b.score - a.score || b.uniqueCards - a.uniqueCards || b.totalCards - a.totalCards;
2787
+ const rankAmong = (mine, all) => 1 + all.filter((s) => compareStandings(s, mine) < 0).length;
2706
2788
  const mediaUrl = (media) => media && typeof media === "object" ? media.url ?? null : null;
2707
2789
  const format = () => ({
2708
2790
  rarityMeta(season2, key) {
@@ -2721,6 +2803,7 @@ const format = () => ({
2721
2803
  rarityColor: rarity.color,
2722
2804
  edition: card2.edition ?? "standard",
2723
2805
  score: card2.score ?? 0,
2806
+ points: cardPoints(card2, season2),
2724
2807
  imageUrl: mediaUrl(card2.image),
2725
2808
  attributes: card2.attributes ?? {},
2726
2809
  lore: card2.lore ?? null,
@@ -2782,7 +2865,13 @@ const format = () => ({
2782
2865
  },
2783
2866
  /** Admin-only view: includes msisdn. */
2784
2867
  playerAdmin(player2) {
2785
- return player2 ? { ...this.player(player2), externalId: player2.externalId ?? null, msisdn: player2.msisdn ?? null, isActive: player2.isActive, meta: player2.meta ?? {} } : null;
2868
+ return player2 ? {
2869
+ ...this.player(player2),
2870
+ externalId: player2.externalId ?? null,
2871
+ msisdn: player2.msisdn ?? null,
2872
+ isActive: player2.isActive,
2873
+ meta: player2.meta ?? {}
2874
+ } : null;
2786
2875
  },
2787
2876
  playerCard(pc, season2) {
2788
2877
  if (!pc) return null;
@@ -2810,7 +2899,16 @@ const format = () => ({
2810
2899
  note: r.note ?? null,
2811
2900
  player: r.player ? this.player(r.player) : void 0,
2812
2901
  season: r.season ? { id: r.season.documentId, name: r.season.name, slug: r.season.slug } : void 0,
2813
- reward: r.reward ? { id: r.reward.documentId, name: r.reward.name, slug: r.reward.slug, tier: r.reward.tier ?? null, booth: r.reward.booth ?? null, imageUrl: mediaUrl(r.reward.image), stock: r.reward.stock ?? null, claimed: r.reward.claimed ?? 0 } : r.rewardName ? { name: r.rewardName } : null
2902
+ reward: r.reward ? {
2903
+ id: r.reward.documentId,
2904
+ name: r.reward.name,
2905
+ slug: r.reward.slug,
2906
+ tier: r.reward.tier ?? null,
2907
+ booth: r.reward.booth ?? null,
2908
+ imageUrl: mediaUrl(r.reward.image),
2909
+ stock: r.reward.stock ?? null,
2910
+ claimed: r.reward.claimed ?? 0
2911
+ } : r.rewardName ? { name: r.rewardName } : null
2814
2912
  };
2815
2913
  }
2816
2914
  });
@@ -3522,22 +3620,11 @@ const collection = ({ strapi }) => ({
3522
3620
  return [...new Set(rows.map((r) => r.card?.id).filter(Boolean))];
3523
3621
  },
3524
3622
  /**
3525
- * Global rank = 1 + number of players owning more distinct season cards. Uses the link tables
3526
- * Strapi 5 creates for relations, resolved from metadata so table names never get hardcoded.
3623
+ * Global rank = 1 + the number of collectors with a better standing: rarity-weighted score, then distinct
3624
+ * cards, then total cards (utils/score.js). Null before the first card.
3527
3625
  */
3528
- async rank(player2, season2, myUnique) {
3529
- if (myUnique === 0) return null;
3530
- const knex = strapi.db.connection;
3531
- const md = strapi.db.metadata.get(UID.PLAYER_CARD);
3532
- const pcTable = md.tableName;
3533
- const playerLnk = md.attributes.player.joinTable;
3534
- const cardLnk = md.attributes.card.joinTable;
3535
- const cardMd = strapi.db.metadata.get(UID.CARD);
3536
- const seasonLnk = cardMd.attributes.season.joinTable;
3537
- const seasonId = season2.id;
3538
- const sub = knex(`${pcTable} as pc`).join(`${playerLnk.name} as pl`, "pl." + playerLnk.joinColumn.name, "pc.id").join(`${cardLnk.name} as cl`, "cl." + cardLnk.joinColumn.name, "pc.id").join(`${seasonLnk.name} as sl`, "sl." + seasonLnk.joinColumn.name, "cl." + cardLnk.inverseJoinColumn.name).where("sl." + seasonLnk.inverseJoinColumn.name, seasonId).groupBy("pl." + playerLnk.inverseJoinColumn.name).select("pl." + playerLnk.inverseJoinColumn.name + " as player_id").countDistinct("cl." + cardLnk.inverseJoinColumn.name + " as unique_cards");
3539
- const row = await knex.from(sub.as("ranked")).where("ranked.unique_cards", ">", myUnique).count("* as better").first();
3540
- return Number(row?.better ?? 0) + 1;
3626
+ async rank(player2, season2) {
3627
+ return (await strapi.plugin("tcg").service("stats").standingFor(player2, season2)).rank;
3541
3628
  },
3542
3629
  async stats(player2, season2) {
3543
3630
  const plugin = strapi.plugin("tcg");
@@ -3573,16 +3660,26 @@ const collection = ({ strapi }) => ({
3573
3660
  populate: { pack: { select: ["id"] }, card: { select: ["id"] } }
3574
3661
  });
3575
3662
  const byPack = packs.map((p) => {
3576
- const cardIds = [...new Set(packRows.filter((r) => r.pack?.id === p.id).map((r) => r.card?.id).filter(Boolean))];
3663
+ const cardIds = [
3664
+ ...new Set(
3665
+ packRows.filter((r) => r.pack?.id === p.id).map((r) => r.card?.id).filter(Boolean)
3666
+ )
3667
+ ];
3577
3668
  const collected = cardIds.filter((id) => ownedIds.has(id)).length;
3578
- return { pack: format2.pack(p), collected, total: cardIds.length, isComplete: cardIds.length > 0 && collected === cardIds.length };
3669
+ return {
3670
+ pack: format2.pack(p),
3671
+ collected,
3672
+ total: cardIds.length,
3673
+ isComplete: cardIds.length > 0 && collected === cardIds.length
3674
+ };
3579
3675
  });
3580
3676
  const tier = plugin.service("season").tierFor(season2, completion);
3581
- const [sets, badges, metrics, live] = await Promise.all([
3677
+ const [sets, badges, metrics, live, standing] = await Promise.all([
3582
3678
  plugin.service("set").list(season2, { includeInactive: false }),
3583
3679
  plugin.service("badge").forPlayer(player2, season2),
3584
3680
  plugin.service("badge").metrics(player2, season2),
3585
- plugin.service("boost").liveNow(season2)
3681
+ plugin.service("boost").liveNow(season2),
3682
+ plugin.service("stats").standingFor(player2, season2)
3586
3683
  ]);
3587
3684
  return {
3588
3685
  sets: plugin.service("set").progress(sets, ownedIds, season2),
@@ -3593,7 +3690,13 @@ const collection = ({ strapi }) => ({
3593
3690
  uniqueCards: ownedIds.size,
3594
3691
  catalogTotal,
3595
3692
  completionPercentage: completion,
3596
- globalRank: await this.rank(player2, season2, ownedIds.size),
3693
+ globalRank: standing.rank,
3694
+ score: standing.score,
3695
+ scoreBreakdown: {
3696
+ cards: standing.cardScore,
3697
+ setBonus: standing.setBonus,
3698
+ setsCompleted: standing.setsCompleted
3699
+ },
3597
3700
  rankTier: { key: tier.key, label: tier.label },
3598
3701
  season: format2.season(season2),
3599
3702
  byRarity,
@@ -3607,7 +3710,14 @@ const collection = ({ strapi }) => ({
3607
3710
  const mintNumber = await strapi.plugin("tcg").service("pull").claimSlot(UID.CARD, card2.id, "minted", null);
3608
3711
  const pc = await strapi.db.transaction(async () => {
3609
3712
  const row = await strapi.db.query(UID.PLAYER_CARD).create({
3610
- data: { player: player2.id, card: card2.id, pack: pack2?.id ?? null, acquiredFrom: "grant", acquiredAt: now, mintNumber }
3713
+ data: {
3714
+ player: player2.id,
3715
+ card: card2.id,
3716
+ pack: pack2?.id ?? null,
3717
+ acquiredFrom: "grant",
3718
+ acquiredAt: now,
3719
+ mintNumber
3720
+ }
3611
3721
  });
3612
3722
  await strapi.db.query(UID.PLAYER).update({ where: { id: player2.id }, data: { totalCards: (player2.totalCards ?? 0) + 1 } });
3613
3723
  return row;
@@ -4122,6 +4232,8 @@ const redemption = ({ strapi }) => ({
4122
4232
  return { items: items.map((r) => format2.redemption(r)), total };
4123
4233
  }
4124
4234
  });
4235
+ const STANDINGS_TTL_MS = 10 * 1e3;
4236
+ const STANDINGS_CACHE = /* @__PURE__ */ new Map();
4125
4237
  const startOfToday = () => {
4126
4238
  const d = /* @__PURE__ */ new Date();
4127
4239
  d.setHours(0, 0, 0, 0);
@@ -4156,15 +4268,31 @@ const stats = ({ strapi }) => ({
4156
4268
  strapi.db.query(UID.PULL).count({ where: { ...seasonWhere, pulledAt: { $gte: since } } }),
4157
4269
  strapi.db.query(UID.PULL).count({ where: seasonWhere }),
4158
4270
  strapi.db.query(UID.TRADE).count({ where: { ...seasonWhere, completedAt: { $gte: since } } }),
4159
- strapi.db.query(UID.TRADE_OFFER).count({ where: { ...seasonWhere, status: TRADE_STATUS.PENDING, expiresAt: { $gt: /* @__PURE__ */ new Date() } } }),
4271
+ strapi.db.query(UID.TRADE_OFFER).count({
4272
+ where: { ...seasonWhere, status: TRADE_STATUS.PENDING, expiresAt: { $gt: /* @__PURE__ */ new Date() } }
4273
+ }),
4160
4274
  strapi.db.query(UID.REDEMPTION).count({ where: { ...seasonWhere, status: REDEMPTION_STATUS.PENDING } }),
4161
- strapi.db.query(UID.REDEMPTION).count({ where: { ...seasonWhere, status: REDEMPTION_STATUS.FULFILLED, fulfilledAt: { $gte: since } } }),
4275
+ strapi.db.query(UID.REDEMPTION).count({
4276
+ where: {
4277
+ ...seasonWhere,
4278
+ status: REDEMPTION_STATUS.FULFILLED,
4279
+ fulfilledAt: { $gte: since }
4280
+ }
4281
+ }),
4162
4282
  season2 ? strapi.db.query(UID.CARD).count({ where: { season: season2.id, isActive: true } }) : Promise.resolve(0),
4163
4283
  season2 ? strapi.db.query(UID.PACK).count({ where: { season: season2.id, isActive: true } }) : Promise.resolve(0),
4164
- strapi.db.query(UID.PULL).findMany({ where: { ...seasonWhere, pulledAt: { $gte: week } }, select: ["pulledAt", "rarity"] }),
4284
+ strapi.db.query(UID.PULL).findMany({
4285
+ where: { ...seasonWhere, pulledAt: { $gte: week } },
4286
+ select: ["pulledAt", "rarity"]
4287
+ }),
4165
4288
  strapi.db.query(UID.PULL).findMany({
4166
4289
  where: seasonWhere,
4167
- populate: { player: { select: ["code", "displayName"] }, card: { select: ["name", "rarity"] }, pack: { select: ["name"] }, drop: { select: ["name"] } },
4290
+ populate: {
4291
+ player: { select: ["code", "displayName"] },
4292
+ card: { select: ["name", "rarity"] },
4293
+ pack: { select: ["name"] },
4294
+ drop: { select: ["name"] }
4295
+ },
4168
4296
  orderBy: { pulledAt: "desc" },
4169
4297
  limit: 8
4170
4298
  })
@@ -4173,24 +4301,47 @@ const stats = ({ strapi }) => ({
4173
4301
  const day = daysAgo(6 - i);
4174
4302
  const next = new Date(day);
4175
4303
  next.setDate(day.getDate() + 1);
4176
- return { day: day.toISOString().slice(0, 10), pulls: weekPulls.filter((p) => new Date(p.pulledAt) >= day && new Date(p.pulledAt) < next).length };
4304
+ return {
4305
+ day: day.toISOString().slice(0, 10),
4306
+ pulls: weekPulls.filter((p) => new Date(p.pulledAt) >= day && new Date(p.pulledAt) < next).length
4307
+ };
4177
4308
  });
4178
4309
  const rarities = season2?.rarities ?? [];
4179
4310
  const plugin = strapi.plugin("tcg");
4180
4311
  const [liveBoosts, rewards, drops] = season2 ? await Promise.all([
4181
4312
  plugin.service("boost").liveNow(season2),
4182
4313
  plugin.service("reward").list(season2, { includeInactive: false }),
4183
- strapi.db.query(UID.DROP).findMany({ where: { pack: { season: season2.id }, isActive: true }, populate: { pack: { select: ["name"] } } })
4314
+ strapi.db.query(UID.DROP).findMany({
4315
+ where: { pack: { season: season2.id }, isActive: true },
4316
+ populate: { pack: { select: ["name"] } }
4317
+ })
4184
4318
  ]) : [[], [], []];
4185
- const lowStock = rewards.filter((r) => r.stock != null).map((r) => ({ id: r.documentId, name: r.name, remaining: Math.max(0, r.stock - (r.claimed ?? 0)), stock: r.stock })).filter((r) => r.remaining <= Math.max(3, Math.ceil(r.stock * 0.1))).sort((a, b) => a.remaining - b.remaining);
4186
- const dropStates = drops.map((d) => ({ id: d.documentId, name: `${d.pack?.name ?? ""} · ${d.name}`, mode: d.mode, scans: d.scanCount ?? 0, cap: d.maxScansTotal ?? null, isOpen: plugin.service("drop").isOpen(d) }));
4319
+ const lowStock = rewards.filter((r) => r.stock != null).map((r) => ({
4320
+ id: r.documentId,
4321
+ name: r.name,
4322
+ remaining: Math.max(0, r.stock - (r.claimed ?? 0)),
4323
+ stock: r.stock
4324
+ })).filter((r) => r.remaining <= Math.max(3, Math.ceil(r.stock * 0.1))).sort((a, b) => a.remaining - b.remaining);
4325
+ const dropStates = drops.map((d) => ({
4326
+ id: d.documentId,
4327
+ name: `${d.pack?.name ?? ""} · ${d.name}`,
4328
+ mode: d.mode,
4329
+ scans: d.scanCount ?? 0,
4330
+ cap: d.maxScansTotal ?? null,
4331
+ isOpen: plugin.service("drop").isOpen(d)
4332
+ }));
4187
4333
  return {
4188
4334
  liveBoosts,
4189
4335
  lowStock,
4190
4336
  drops: dropStates,
4191
4337
  season: season2 ? { id: season2.documentId, name: season2.name, slug: season2.slug, status: season2.status } : null,
4192
4338
  players: { total: players, today: playersToday },
4193
- pulls: { today: pullsToday, total: pullsTotal, sparkline, distribution: distribution(weekPulls, rarities) },
4339
+ pulls: {
4340
+ today: pullsToday,
4341
+ total: pullsTotal,
4342
+ sparkline,
4343
+ distribution: distribution(weekPulls, rarities)
4344
+ },
4194
4345
  trades: { today: tradesToday, pendingOffers },
4195
4346
  redemptions: { pending: pendingRedemptions, fulfilledToday },
4196
4347
  catalog: { cards, packs },
@@ -4207,21 +4358,75 @@ const stats = ({ strapi }) => ({
4207
4358
  }))
4208
4359
  };
4209
4360
  },
4210
- /** Top collectors by distinct cards owned in a season (admin leaderboard). */
4211
- async leaderboard(season2, limit = 10) {
4212
- const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
4213
- where: { card: { season: season2.id } },
4214
- populate: { player: { select: ["id", "documentId", "code", "displayName"] }, card: { select: ["id"] } }
4215
- });
4361
+ /**
4362
+ * Every collector's standing in a season (utils/score.js), sorted. Computed from every player card of
4363
+ * the season and cached a few seconds: the rank is asked on every stats screen.
4364
+ * @returns {Promise<Array<{player: Object, uniqueCards: number, totalCards: number, cardScore: number, setsCompleted: number, setBonus: number, score: number}>>}
4365
+ */
4366
+ async standings(season2) {
4367
+ const cached = STANDINGS_CACHE.get(season2.id);
4368
+ if (cached && Date.now() - cached.at < STANDINGS_TTL_MS) return cached.value;
4369
+ const plugin = strapi.plugin("tcg");
4370
+ const [rows, sets] = await Promise.all([
4371
+ strapi.db.query(UID.PLAYER_CARD).findMany({
4372
+ where: { card: { season: season2.id } },
4373
+ populate: {
4374
+ player: { select: ["id", "documentId", "code", "displayName"] },
4375
+ card: { select: ["id", "rarity", "score"] }
4376
+ }
4377
+ }),
4378
+ plugin.service("set").list(season2, { includeInactive: false })
4379
+ ]);
4380
+ const limits = plugin.service("season").limitsOf(season2);
4216
4381
  const byPlayer = /* @__PURE__ */ new Map();
4217
4382
  for (const r of rows) {
4218
4383
  if (!r.player || !r.card) continue;
4219
- const entry = byPlayer.get(r.player.id) ?? { player: r.player, unique: /* @__PURE__ */ new Set(), total: 0 };
4220
- entry.unique.add(r.card.id);
4384
+ const entry = byPlayer.get(r.player.id) ?? { player: r.player, cards: /* @__PURE__ */ new Map(), total: 0 };
4385
+ entry.cards.set(r.card.id, r.card);
4221
4386
  entry.total += 1;
4222
4387
  byPlayer.set(r.player.id, entry);
4223
4388
  }
4224
- return [...byPlayer.values()].map((e) => ({ player: { id: e.player.documentId, code: e.player.code, displayName: e.player.displayName }, uniqueCards: e.unique.size, totalCards: e.total })).sort((a, b) => b.uniqueCards - a.uniqueCards || b.totalCards - a.totalCards).slice(0, limit).map((e, i) => ({ rank: i + 1, ...e }));
4389
+ const value = [...byPlayer.values()].map((e) => ({
4390
+ player: e.player,
4391
+ ...standingOf([...e.cards.values()], e.total, sets, season2, limits)
4392
+ })).sort(compareStandings);
4393
+ STANDINGS_CACHE.set(season2.id, { at: Date.now(), value });
4394
+ return value;
4395
+ },
4396
+ /** Forget the cached standings (after a grant, a trade…); they rebuild on the next read. */
4397
+ invalidateStandings(season2) {
4398
+ if (season2) STANDINGS_CACHE.delete(season2.id);
4399
+ else STANDINGS_CACHE.clear();
4400
+ },
4401
+ /** Where one player stands: rank among every collector, and the score's parts. Null rank with no card. */
4402
+ async standingFor(player2, season2) {
4403
+ const all = await this.standings(season2);
4404
+ const mine = all.find((s) => s.player.id === player2.id);
4405
+ if (!mine)
4406
+ return {
4407
+ rank: null,
4408
+ uniqueCards: 0,
4409
+ totalCards: 0,
4410
+ cardScore: 0,
4411
+ setsCompleted: 0,
4412
+ setBonus: 0,
4413
+ score: 0
4414
+ };
4415
+ return { rank: rankAmong(mine, all), ...mine, player: void 0 };
4416
+ },
4417
+ /** Top collectors by score (admin leaderboard). */
4418
+ async leaderboard(season2, limit = 10) {
4419
+ const all = await this.standings(season2);
4420
+ return all.slice(0, limit).map((e, i) => ({
4421
+ rank: i + 1,
4422
+ player: { id: e.player.documentId, code: e.player.code, displayName: e.player.displayName },
4423
+ score: e.score,
4424
+ cardScore: e.cardScore,
4425
+ setBonus: e.setBonus,
4426
+ setsCompleted: e.setsCompleted,
4427
+ uniqueCards: e.uniqueCards,
4428
+ totalCards: e.totalCards
4429
+ }));
4225
4430
  },
4226
4431
  /** Recent pulls for the audit screen. */
4227
4432
  async pulls(season2, { playerCode, rarity }, page) {
@@ -4231,7 +4436,12 @@ const stats = ({ strapi }) => ({
4231
4436
  const [items, total] = await Promise.all([
4232
4437
  strapi.db.query(UID.PULL).findMany({
4233
4438
  where,
4234
- populate: { player: { select: ["code", "displayName"] }, card: { select: ["name", "rarity", "number"] }, pack: { select: ["name", "slug"] }, drop: { select: ["name", "slug"] } },
4439
+ populate: {
4440
+ player: { select: ["code", "displayName"] },
4441
+ card: { select: ["name", "rarity", "number"] },
4442
+ pack: { select: ["name", "slug"] },
4443
+ drop: { select: ["name", "slug"] }
4444
+ },
4235
4445
  orderBy: { pulledAt: "desc" },
4236
4446
  offset: page.offset,
4237
4447
  limit: page.limit
@@ -157,7 +157,9 @@ const DEFAULT_LIMITS = Object.freeze({
157
157
  qrTokenTtlMs: 5 * 60 * 1e3,
158
158
  staticScanCooldownMs: 24 * 60 * 60 * 1e3,
159
159
  pityRareAfter: 0,
160
- pityLegendaryAfter: 0
160
+ pityLegendaryAfter: 0,
161
+ /** Points a completed set adds to the collector's score (utils/score.js). */
162
+ setCompletionBonus: 50
161
163
  });
162
164
  const DEFAULT_RANK_TIERS = Object.freeze([
163
165
  { key: "legend", label: "Legend", minPercent: 100 },
@@ -232,14 +234,28 @@ const ACTIONS = [
232
234
  { section: "plugins", uid: RBAC.PLAYERS_READ, displayName: "Read players" },
233
235
  { section: "plugins", uid: RBAC.PLAYERS_WRITE, displayName: "Deactivate players, grant cards" },
234
236
  { section: "plugins", uid: RBAC.REDEMPTIONS_READ, displayName: "Read redemptions" },
235
- { section: "plugins", uid: RBAC.REDEMPTIONS_FULFILL, displayName: "Fulfill or cancel redemptions (booth)" },
237
+ {
238
+ section: "plugins",
239
+ uid: RBAC.REDEMPTIONS_FULFILL,
240
+ displayName: "Fulfill or cancel redemptions (booth)"
241
+ },
236
242
  { section: "plugins", uid: RBAC.AUDIT_READ, displayName: "Read the pull audit log" },
237
243
  { section: "plugins", uid: RBAC.REWARDS_READ, displayName: "Read the reward catalog" },
238
244
  { section: "plugins", uid: RBAC.REWARDS_WRITE, displayName: "Edit the reward catalog" },
239
245
  { section: "plugins", uid: RBAC.LIVE_READ, displayName: "Read live boosts" },
240
246
  { section: "plugins", uid: RBAC.LIVE_WRITE, displayName: "Create and edit live boosts" },
241
- { section: "settings", category: CATEGORY, uid: RBAC.SETTINGS_READ, displayName: "Read settings" },
242
- { section: "settings", category: CATEGORY, uid: RBAC.SETTINGS_WRITE, displayName: "Edit settings" }
247
+ {
248
+ section: "settings",
249
+ category: CATEGORY,
250
+ uid: RBAC.SETTINGS_READ,
251
+ displayName: "Read settings"
252
+ },
253
+ {
254
+ section: "settings",
255
+ category: CATEGORY,
256
+ uid: RBAC.SETTINGS_WRITE,
257
+ displayName: "Edit settings"
258
+ }
243
259
  ].map((a) => ({ ...a, pluginName: PLUGIN_ID }));
244
260
  const timers = [];
245
261
  const UNIQUE_COLUMNS = [
@@ -247,21 +263,46 @@ const UNIQUE_COLUMNS = [
247
263
  ["plugin::tcg.player", "code"],
248
264
  ["plugin::tcg.redemption", "code"]
249
265
  ];
266
+ const hasIndex = async (knex, table, name) => {
267
+ const client = String(knex.client?.config?.client ?? "");
268
+ if (/mysql/.test(client)) {
269
+ const rows2 = await knex.raw("SHOW INDEX FROM ?? WHERE Key_name = ?", [table, name]);
270
+ return (rows2?.[0] ?? []).length > 0;
271
+ }
272
+ if (/pg|postgres/.test(client)) {
273
+ const rows2 = await knex.raw("SELECT 1 FROM pg_indexes WHERE tablename = ? AND indexname = ?", [
274
+ table,
275
+ name
276
+ ]);
277
+ return (rows2?.rows ?? []).length > 0;
278
+ }
279
+ const rows = await knex.raw("PRAGMA index_list(??)", [table]);
280
+ return (rows ?? []).some((r) => r.name === name);
281
+ };
250
282
  const ensureUniqueIndexes = async (strapi) => {
283
+ const knex = strapi.db.connection;
251
284
  for (const [uid, column] of UNIQUE_COLUMNS) {
252
285
  const table = strapi.db.metadata.get(uid)?.tableName;
253
286
  if (!table) continue;
254
287
  const name = `${table}_${column}_tcg_uq`;
255
288
  try {
256
- await strapi.db.connection.schema.alterTable(table, (t) => t.unique([column], { indexName: name }));
289
+ if (await hasIndex(knex, table, name)) continue;
290
+ await knex.schema.alterTable(table, (t) => t.unique([column], { indexName: name }));
291
+ strapi.log.info(`${LOG_PREFIX} unique index ${name} created`);
257
292
  } catch (err) {
258
- if (!/exist|duplicate/i.test(err.message)) strapi.log.warn(`${LOG_PREFIX} unique index ${name}: ${err.message}`);
293
+ if (!/exist|duplicate/i.test(err.message))
294
+ strapi.log.warn(`${LOG_PREFIX} unique index ${name}: ${err.message}`);
259
295
  }
260
296
  }
261
297
  };
262
298
  const bootstrap = async ({ strapi }) => {
263
299
  await strapi.service("admin::permission").actionProvider.registerMany(ACTIONS);
264
- await ensureUniqueIndexes(strapi);
300
+ setTimeout(
301
+ () => ensureUniqueIndexes(strapi).catch(
302
+ (err) => strapi.log.warn(`${LOG_PREFIX} unique indexes: ${err.message}`)
303
+ ),
304
+ 5e3
305
+ );
265
306
  const plugin = strapi.plugin(PLUGIN_ID);
266
307
  if (plugin.service("settings").sweepsEnabled()) {
267
308
  const safe = (label, fn) => async () => {
@@ -272,13 +313,21 @@ const bootstrap = async ({ strapi }) => {
272
313
  strapi.log.warn(`${LOG_PREFIX} sweep ${label} failed: ${err.message}`);
273
314
  }
274
315
  };
275
- const nonceTimer = setInterval(safe("nonces", () => plugin.service("qr").sweep()), NONCE_SWEEP_INTERVAL_MS);
276
- const tradeTimer = setInterval(safe("expired offers", () => plugin.service("trade").sweep()), TRADE_SWEEP_INTERVAL_MS);
316
+ const nonceTimer = setInterval(
317
+ safe("nonces", () => plugin.service("qr").sweep()),
318
+ NONCE_SWEEP_INTERVAL_MS
319
+ );
320
+ const tradeTimer = setInterval(
321
+ safe("expired offers", () => plugin.service("trade").sweep()),
322
+ TRADE_SWEEP_INTERVAL_MS
323
+ );
277
324
  nonceTimer.unref();
278
325
  tradeTimer.unref();
279
326
  timers.push(nonceTimer, tradeTimer);
280
327
  }
281
- strapi.log.info(`${LOG_PREFIX} ready — ${ACTIONS.length} RBAC actions, content API at /api/${PLUGIN_ID}`);
328
+ strapi.log.info(
329
+ `${LOG_PREFIX} ready — ${ACTIONS.length} RBAC actions, content API at /api/${PLUGIN_ID}`
330
+ );
282
331
  };
283
332
  const stopTimers = () => {
284
333
  while (timers.length) clearInterval(timers.pop());
@@ -2702,6 +2751,39 @@ const qr = ({ strapi }) => ({
2702
2751
  return count ?? 0;
2703
2752
  }
2704
2753
  });
2754
+ const rarityPoints = (season2, rarityKey) => {
2755
+ const table = season2?.rarities?.length ? season2.rarities : DEFAULT_RARITIES;
2756
+ const idx = table.findIndex((r) => r.key === rarityKey);
2757
+ const meta = table[idx] ?? DEFAULT_RARITIES.find((r) => r.key === rarityKey) ?? null;
2758
+ const min = Number(meta?.scoreMin);
2759
+ const max = Number(meta?.scoreMax);
2760
+ if (Number.isFinite(min) && Number.isFinite(max) && max >= min)
2761
+ return Math.round((min + max) / 2);
2762
+ return 10 + Math.max(0, idx) * 20;
2763
+ };
2764
+ const cardPoints = (card2, season2) => {
2765
+ const own = Number(card2?.score);
2766
+ if (Number.isFinite(own) && own > 0) return Math.round(own);
2767
+ return rarityPoints(season2, card2?.rarity);
2768
+ };
2769
+ const standingOf = (distinctCards, totalCards, sets, season2, limits = {}) => {
2770
+ const ownedIds = new Set(distinctCards.map((c) => String(c.id)));
2771
+ const cardScore = distinctCards.reduce((sum, c) => sum + cardPoints(c, season2), 0);
2772
+ const setsCompleted = (sets ?? []).filter(
2773
+ (s) => (s.cards ?? []).length > 0 && s.cards.every((c) => ownedIds.has(String(c.id)))
2774
+ ).length;
2775
+ const bonus = Math.max(0, Number(limits.setCompletionBonus) || 0);
2776
+ return {
2777
+ uniqueCards: distinctCards.length,
2778
+ totalCards,
2779
+ cardScore,
2780
+ setsCompleted,
2781
+ setBonus: setsCompleted * bonus,
2782
+ score: cardScore + setsCompleted * bonus
2783
+ };
2784
+ };
2785
+ const compareStandings = (a, b) => b.score - a.score || b.uniqueCards - a.uniqueCards || b.totalCards - a.totalCards;
2786
+ const rankAmong = (mine, all) => 1 + all.filter((s) => compareStandings(s, mine) < 0).length;
2705
2787
  const mediaUrl = (media) => media && typeof media === "object" ? media.url ?? null : null;
2706
2788
  const format = () => ({
2707
2789
  rarityMeta(season2, key) {
@@ -2720,6 +2802,7 @@ const format = () => ({
2720
2802
  rarityColor: rarity.color,
2721
2803
  edition: card2.edition ?? "standard",
2722
2804
  score: card2.score ?? 0,
2805
+ points: cardPoints(card2, season2),
2723
2806
  imageUrl: mediaUrl(card2.image),
2724
2807
  attributes: card2.attributes ?? {},
2725
2808
  lore: card2.lore ?? null,
@@ -2781,7 +2864,13 @@ const format = () => ({
2781
2864
  },
2782
2865
  /** Admin-only view: includes msisdn. */
2783
2866
  playerAdmin(player2) {
2784
- return player2 ? { ...this.player(player2), externalId: player2.externalId ?? null, msisdn: player2.msisdn ?? null, isActive: player2.isActive, meta: player2.meta ?? {} } : null;
2867
+ return player2 ? {
2868
+ ...this.player(player2),
2869
+ externalId: player2.externalId ?? null,
2870
+ msisdn: player2.msisdn ?? null,
2871
+ isActive: player2.isActive,
2872
+ meta: player2.meta ?? {}
2873
+ } : null;
2785
2874
  },
2786
2875
  playerCard(pc, season2) {
2787
2876
  if (!pc) return null;
@@ -2809,7 +2898,16 @@ const format = () => ({
2809
2898
  note: r.note ?? null,
2810
2899
  player: r.player ? this.player(r.player) : void 0,
2811
2900
  season: r.season ? { id: r.season.documentId, name: r.season.name, slug: r.season.slug } : void 0,
2812
- reward: r.reward ? { id: r.reward.documentId, name: r.reward.name, slug: r.reward.slug, tier: r.reward.tier ?? null, booth: r.reward.booth ?? null, imageUrl: mediaUrl(r.reward.image), stock: r.reward.stock ?? null, claimed: r.reward.claimed ?? 0 } : r.rewardName ? { name: r.rewardName } : null
2901
+ reward: r.reward ? {
2902
+ id: r.reward.documentId,
2903
+ name: r.reward.name,
2904
+ slug: r.reward.slug,
2905
+ tier: r.reward.tier ?? null,
2906
+ booth: r.reward.booth ?? null,
2907
+ imageUrl: mediaUrl(r.reward.image),
2908
+ stock: r.reward.stock ?? null,
2909
+ claimed: r.reward.claimed ?? 0
2910
+ } : r.rewardName ? { name: r.rewardName } : null
2813
2911
  };
2814
2912
  }
2815
2913
  });
@@ -3521,22 +3619,11 @@ const collection = ({ strapi }) => ({
3521
3619
  return [...new Set(rows.map((r) => r.card?.id).filter(Boolean))];
3522
3620
  },
3523
3621
  /**
3524
- * Global rank = 1 + number of players owning more distinct season cards. Uses the link tables
3525
- * Strapi 5 creates for relations, resolved from metadata so table names never get hardcoded.
3622
+ * Global rank = 1 + the number of collectors with a better standing: rarity-weighted score, then distinct
3623
+ * cards, then total cards (utils/score.js). Null before the first card.
3526
3624
  */
3527
- async rank(player2, season2, myUnique) {
3528
- if (myUnique === 0) return null;
3529
- const knex = strapi.db.connection;
3530
- const md = strapi.db.metadata.get(UID.PLAYER_CARD);
3531
- const pcTable = md.tableName;
3532
- const playerLnk = md.attributes.player.joinTable;
3533
- const cardLnk = md.attributes.card.joinTable;
3534
- const cardMd = strapi.db.metadata.get(UID.CARD);
3535
- const seasonLnk = cardMd.attributes.season.joinTable;
3536
- const seasonId = season2.id;
3537
- const sub = knex(`${pcTable} as pc`).join(`${playerLnk.name} as pl`, "pl." + playerLnk.joinColumn.name, "pc.id").join(`${cardLnk.name} as cl`, "cl." + cardLnk.joinColumn.name, "pc.id").join(`${seasonLnk.name} as sl`, "sl." + seasonLnk.joinColumn.name, "cl." + cardLnk.inverseJoinColumn.name).where("sl." + seasonLnk.inverseJoinColumn.name, seasonId).groupBy("pl." + playerLnk.inverseJoinColumn.name).select("pl." + playerLnk.inverseJoinColumn.name + " as player_id").countDistinct("cl." + cardLnk.inverseJoinColumn.name + " as unique_cards");
3538
- const row = await knex.from(sub.as("ranked")).where("ranked.unique_cards", ">", myUnique).count("* as better").first();
3539
- return Number(row?.better ?? 0) + 1;
3625
+ async rank(player2, season2) {
3626
+ return (await strapi.plugin("tcg").service("stats").standingFor(player2, season2)).rank;
3540
3627
  },
3541
3628
  async stats(player2, season2) {
3542
3629
  const plugin = strapi.plugin("tcg");
@@ -3572,16 +3659,26 @@ const collection = ({ strapi }) => ({
3572
3659
  populate: { pack: { select: ["id"] }, card: { select: ["id"] } }
3573
3660
  });
3574
3661
  const byPack = packs.map((p) => {
3575
- const cardIds = [...new Set(packRows.filter((r) => r.pack?.id === p.id).map((r) => r.card?.id).filter(Boolean))];
3662
+ const cardIds = [
3663
+ ...new Set(
3664
+ packRows.filter((r) => r.pack?.id === p.id).map((r) => r.card?.id).filter(Boolean)
3665
+ )
3666
+ ];
3576
3667
  const collected = cardIds.filter((id) => ownedIds.has(id)).length;
3577
- return { pack: format2.pack(p), collected, total: cardIds.length, isComplete: cardIds.length > 0 && collected === cardIds.length };
3668
+ return {
3669
+ pack: format2.pack(p),
3670
+ collected,
3671
+ total: cardIds.length,
3672
+ isComplete: cardIds.length > 0 && collected === cardIds.length
3673
+ };
3578
3674
  });
3579
3675
  const tier = plugin.service("season").tierFor(season2, completion);
3580
- const [sets, badges, metrics, live] = await Promise.all([
3676
+ const [sets, badges, metrics, live, standing] = await Promise.all([
3581
3677
  plugin.service("set").list(season2, { includeInactive: false }),
3582
3678
  plugin.service("badge").forPlayer(player2, season2),
3583
3679
  plugin.service("badge").metrics(player2, season2),
3584
- plugin.service("boost").liveNow(season2)
3680
+ plugin.service("boost").liveNow(season2),
3681
+ plugin.service("stats").standingFor(player2, season2)
3585
3682
  ]);
3586
3683
  return {
3587
3684
  sets: plugin.service("set").progress(sets, ownedIds, season2),
@@ -3592,7 +3689,13 @@ const collection = ({ strapi }) => ({
3592
3689
  uniqueCards: ownedIds.size,
3593
3690
  catalogTotal,
3594
3691
  completionPercentage: completion,
3595
- globalRank: await this.rank(player2, season2, ownedIds.size),
3692
+ globalRank: standing.rank,
3693
+ score: standing.score,
3694
+ scoreBreakdown: {
3695
+ cards: standing.cardScore,
3696
+ setBonus: standing.setBonus,
3697
+ setsCompleted: standing.setsCompleted
3698
+ },
3596
3699
  rankTier: { key: tier.key, label: tier.label },
3597
3700
  season: format2.season(season2),
3598
3701
  byRarity,
@@ -3606,7 +3709,14 @@ const collection = ({ strapi }) => ({
3606
3709
  const mintNumber = await strapi.plugin("tcg").service("pull").claimSlot(UID.CARD, card2.id, "minted", null);
3607
3710
  const pc = await strapi.db.transaction(async () => {
3608
3711
  const row = await strapi.db.query(UID.PLAYER_CARD).create({
3609
- data: { player: player2.id, card: card2.id, pack: pack2?.id ?? null, acquiredFrom: "grant", acquiredAt: now, mintNumber }
3712
+ data: {
3713
+ player: player2.id,
3714
+ card: card2.id,
3715
+ pack: pack2?.id ?? null,
3716
+ acquiredFrom: "grant",
3717
+ acquiredAt: now,
3718
+ mintNumber
3719
+ }
3610
3720
  });
3611
3721
  await strapi.db.query(UID.PLAYER).update({ where: { id: player2.id }, data: { totalCards: (player2.totalCards ?? 0) + 1 } });
3612
3722
  return row;
@@ -4121,6 +4231,8 @@ const redemption = ({ strapi }) => ({
4121
4231
  return { items: items.map((r) => format2.redemption(r)), total };
4122
4232
  }
4123
4233
  });
4234
+ const STANDINGS_TTL_MS = 10 * 1e3;
4235
+ const STANDINGS_CACHE = /* @__PURE__ */ new Map();
4124
4236
  const startOfToday = () => {
4125
4237
  const d = /* @__PURE__ */ new Date();
4126
4238
  d.setHours(0, 0, 0, 0);
@@ -4155,15 +4267,31 @@ const stats = ({ strapi }) => ({
4155
4267
  strapi.db.query(UID.PULL).count({ where: { ...seasonWhere, pulledAt: { $gte: since } } }),
4156
4268
  strapi.db.query(UID.PULL).count({ where: seasonWhere }),
4157
4269
  strapi.db.query(UID.TRADE).count({ where: { ...seasonWhere, completedAt: { $gte: since } } }),
4158
- strapi.db.query(UID.TRADE_OFFER).count({ where: { ...seasonWhere, status: TRADE_STATUS.PENDING, expiresAt: { $gt: /* @__PURE__ */ new Date() } } }),
4270
+ strapi.db.query(UID.TRADE_OFFER).count({
4271
+ where: { ...seasonWhere, status: TRADE_STATUS.PENDING, expiresAt: { $gt: /* @__PURE__ */ new Date() } }
4272
+ }),
4159
4273
  strapi.db.query(UID.REDEMPTION).count({ where: { ...seasonWhere, status: REDEMPTION_STATUS.PENDING } }),
4160
- strapi.db.query(UID.REDEMPTION).count({ where: { ...seasonWhere, status: REDEMPTION_STATUS.FULFILLED, fulfilledAt: { $gte: since } } }),
4274
+ strapi.db.query(UID.REDEMPTION).count({
4275
+ where: {
4276
+ ...seasonWhere,
4277
+ status: REDEMPTION_STATUS.FULFILLED,
4278
+ fulfilledAt: { $gte: since }
4279
+ }
4280
+ }),
4161
4281
  season2 ? strapi.db.query(UID.CARD).count({ where: { season: season2.id, isActive: true } }) : Promise.resolve(0),
4162
4282
  season2 ? strapi.db.query(UID.PACK).count({ where: { season: season2.id, isActive: true } }) : Promise.resolve(0),
4163
- strapi.db.query(UID.PULL).findMany({ where: { ...seasonWhere, pulledAt: { $gte: week } }, select: ["pulledAt", "rarity"] }),
4283
+ strapi.db.query(UID.PULL).findMany({
4284
+ where: { ...seasonWhere, pulledAt: { $gte: week } },
4285
+ select: ["pulledAt", "rarity"]
4286
+ }),
4164
4287
  strapi.db.query(UID.PULL).findMany({
4165
4288
  where: seasonWhere,
4166
- populate: { player: { select: ["code", "displayName"] }, card: { select: ["name", "rarity"] }, pack: { select: ["name"] }, drop: { select: ["name"] } },
4289
+ populate: {
4290
+ player: { select: ["code", "displayName"] },
4291
+ card: { select: ["name", "rarity"] },
4292
+ pack: { select: ["name"] },
4293
+ drop: { select: ["name"] }
4294
+ },
4167
4295
  orderBy: { pulledAt: "desc" },
4168
4296
  limit: 8
4169
4297
  })
@@ -4172,24 +4300,47 @@ const stats = ({ strapi }) => ({
4172
4300
  const day = daysAgo(6 - i);
4173
4301
  const next = new Date(day);
4174
4302
  next.setDate(day.getDate() + 1);
4175
- return { day: day.toISOString().slice(0, 10), pulls: weekPulls.filter((p) => new Date(p.pulledAt) >= day && new Date(p.pulledAt) < next).length };
4303
+ return {
4304
+ day: day.toISOString().slice(0, 10),
4305
+ pulls: weekPulls.filter((p) => new Date(p.pulledAt) >= day && new Date(p.pulledAt) < next).length
4306
+ };
4176
4307
  });
4177
4308
  const rarities = season2?.rarities ?? [];
4178
4309
  const plugin = strapi.plugin("tcg");
4179
4310
  const [liveBoosts, rewards, drops] = season2 ? await Promise.all([
4180
4311
  plugin.service("boost").liveNow(season2),
4181
4312
  plugin.service("reward").list(season2, { includeInactive: false }),
4182
- strapi.db.query(UID.DROP).findMany({ where: { pack: { season: season2.id }, isActive: true }, populate: { pack: { select: ["name"] } } })
4313
+ strapi.db.query(UID.DROP).findMany({
4314
+ where: { pack: { season: season2.id }, isActive: true },
4315
+ populate: { pack: { select: ["name"] } }
4316
+ })
4183
4317
  ]) : [[], [], []];
4184
- const lowStock = rewards.filter((r) => r.stock != null).map((r) => ({ id: r.documentId, name: r.name, remaining: Math.max(0, r.stock - (r.claimed ?? 0)), stock: r.stock })).filter((r) => r.remaining <= Math.max(3, Math.ceil(r.stock * 0.1))).sort((a, b) => a.remaining - b.remaining);
4185
- const dropStates = drops.map((d) => ({ id: d.documentId, name: `${d.pack?.name ?? ""} · ${d.name}`, mode: d.mode, scans: d.scanCount ?? 0, cap: d.maxScansTotal ?? null, isOpen: plugin.service("drop").isOpen(d) }));
4318
+ const lowStock = rewards.filter((r) => r.stock != null).map((r) => ({
4319
+ id: r.documentId,
4320
+ name: r.name,
4321
+ remaining: Math.max(0, r.stock - (r.claimed ?? 0)),
4322
+ stock: r.stock
4323
+ })).filter((r) => r.remaining <= Math.max(3, Math.ceil(r.stock * 0.1))).sort((a, b) => a.remaining - b.remaining);
4324
+ const dropStates = drops.map((d) => ({
4325
+ id: d.documentId,
4326
+ name: `${d.pack?.name ?? ""} · ${d.name}`,
4327
+ mode: d.mode,
4328
+ scans: d.scanCount ?? 0,
4329
+ cap: d.maxScansTotal ?? null,
4330
+ isOpen: plugin.service("drop").isOpen(d)
4331
+ }));
4186
4332
  return {
4187
4333
  liveBoosts,
4188
4334
  lowStock,
4189
4335
  drops: dropStates,
4190
4336
  season: season2 ? { id: season2.documentId, name: season2.name, slug: season2.slug, status: season2.status } : null,
4191
4337
  players: { total: players, today: playersToday },
4192
- pulls: { today: pullsToday, total: pullsTotal, sparkline, distribution: distribution(weekPulls, rarities) },
4338
+ pulls: {
4339
+ today: pullsToday,
4340
+ total: pullsTotal,
4341
+ sparkline,
4342
+ distribution: distribution(weekPulls, rarities)
4343
+ },
4193
4344
  trades: { today: tradesToday, pendingOffers },
4194
4345
  redemptions: { pending: pendingRedemptions, fulfilledToday },
4195
4346
  catalog: { cards, packs },
@@ -4206,21 +4357,75 @@ const stats = ({ strapi }) => ({
4206
4357
  }))
4207
4358
  };
4208
4359
  },
4209
- /** Top collectors by distinct cards owned in a season (admin leaderboard). */
4210
- async leaderboard(season2, limit = 10) {
4211
- const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
4212
- where: { card: { season: season2.id } },
4213
- populate: { player: { select: ["id", "documentId", "code", "displayName"] }, card: { select: ["id"] } }
4214
- });
4360
+ /**
4361
+ * Every collector's standing in a season (utils/score.js), sorted. Computed from every player card of
4362
+ * the season and cached a few seconds: the rank is asked on every stats screen.
4363
+ * @returns {Promise<Array<{player: Object, uniqueCards: number, totalCards: number, cardScore: number, setsCompleted: number, setBonus: number, score: number}>>}
4364
+ */
4365
+ async standings(season2) {
4366
+ const cached = STANDINGS_CACHE.get(season2.id);
4367
+ if (cached && Date.now() - cached.at < STANDINGS_TTL_MS) return cached.value;
4368
+ const plugin = strapi.plugin("tcg");
4369
+ const [rows, sets] = await Promise.all([
4370
+ strapi.db.query(UID.PLAYER_CARD).findMany({
4371
+ where: { card: { season: season2.id } },
4372
+ populate: {
4373
+ player: { select: ["id", "documentId", "code", "displayName"] },
4374
+ card: { select: ["id", "rarity", "score"] }
4375
+ }
4376
+ }),
4377
+ plugin.service("set").list(season2, { includeInactive: false })
4378
+ ]);
4379
+ const limits = plugin.service("season").limitsOf(season2);
4215
4380
  const byPlayer = /* @__PURE__ */ new Map();
4216
4381
  for (const r of rows) {
4217
4382
  if (!r.player || !r.card) continue;
4218
- const entry = byPlayer.get(r.player.id) ?? { player: r.player, unique: /* @__PURE__ */ new Set(), total: 0 };
4219
- entry.unique.add(r.card.id);
4383
+ const entry = byPlayer.get(r.player.id) ?? { player: r.player, cards: /* @__PURE__ */ new Map(), total: 0 };
4384
+ entry.cards.set(r.card.id, r.card);
4220
4385
  entry.total += 1;
4221
4386
  byPlayer.set(r.player.id, entry);
4222
4387
  }
4223
- return [...byPlayer.values()].map((e) => ({ player: { id: e.player.documentId, code: e.player.code, displayName: e.player.displayName }, uniqueCards: e.unique.size, totalCards: e.total })).sort((a, b) => b.uniqueCards - a.uniqueCards || b.totalCards - a.totalCards).slice(0, limit).map((e, i) => ({ rank: i + 1, ...e }));
4388
+ const value = [...byPlayer.values()].map((e) => ({
4389
+ player: e.player,
4390
+ ...standingOf([...e.cards.values()], e.total, sets, season2, limits)
4391
+ })).sort(compareStandings);
4392
+ STANDINGS_CACHE.set(season2.id, { at: Date.now(), value });
4393
+ return value;
4394
+ },
4395
+ /** Forget the cached standings (after a grant, a trade…); they rebuild on the next read. */
4396
+ invalidateStandings(season2) {
4397
+ if (season2) STANDINGS_CACHE.delete(season2.id);
4398
+ else STANDINGS_CACHE.clear();
4399
+ },
4400
+ /** Where one player stands: rank among every collector, and the score's parts. Null rank with no card. */
4401
+ async standingFor(player2, season2) {
4402
+ const all = await this.standings(season2);
4403
+ const mine = all.find((s) => s.player.id === player2.id);
4404
+ if (!mine)
4405
+ return {
4406
+ rank: null,
4407
+ uniqueCards: 0,
4408
+ totalCards: 0,
4409
+ cardScore: 0,
4410
+ setsCompleted: 0,
4411
+ setBonus: 0,
4412
+ score: 0
4413
+ };
4414
+ return { rank: rankAmong(mine, all), ...mine, player: void 0 };
4415
+ },
4416
+ /** Top collectors by score (admin leaderboard). */
4417
+ async leaderboard(season2, limit = 10) {
4418
+ const all = await this.standings(season2);
4419
+ return all.slice(0, limit).map((e, i) => ({
4420
+ rank: i + 1,
4421
+ player: { id: e.player.documentId, code: e.player.code, displayName: e.player.displayName },
4422
+ score: e.score,
4423
+ cardScore: e.cardScore,
4424
+ setBonus: e.setBonus,
4425
+ setsCompleted: e.setsCompleted,
4426
+ uniqueCards: e.uniqueCards,
4427
+ totalCards: e.totalCards
4428
+ }));
4224
4429
  },
4225
4430
  /** Recent pulls for the audit screen. */
4226
4431
  async pulls(season2, { playerCode, rarity }, page) {
@@ -4230,7 +4435,12 @@ const stats = ({ strapi }) => ({
4230
4435
  const [items, total] = await Promise.all([
4231
4436
  strapi.db.query(UID.PULL).findMany({
4232
4437
  where,
4233
- populate: { player: { select: ["code", "displayName"] }, card: { select: ["name", "rarity", "number"] }, pack: { select: ["name", "slug"] }, drop: { select: ["name", "slug"] } },
4438
+ populate: {
4439
+ player: { select: ["code", "displayName"] },
4440
+ card: { select: ["name", "rarity", "number"] },
4441
+ pack: { select: ["name", "slug"] },
4442
+ drop: { select: ["name", "slug"] }
4443
+ },
4234
4444
  orderBy: { pulledAt: "desc" },
4235
4445
  offset: page.offset,
4236
4446
  limit: page.limit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sonatel-os/strapi-plugin-tcg",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "A full trading-card-game module for Strapi v5: seasons, packs, cards, pulls, trades, redemptions — with admin screens and RBAC.",
5
5
  "license": "MIT",
6
6
  "author": "LPIX-11 <mohamed.johnson@orange-sonatel.com>",