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

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 },
@@ -2703,6 +2705,39 @@ const qr = ({ strapi }) => ({
2703
2705
  return count ?? 0;
2704
2706
  }
2705
2707
  });
2708
+ const rarityPoints = (season2, rarityKey) => {
2709
+ const table = season2?.rarities?.length ? season2.rarities : DEFAULT_RARITIES;
2710
+ const idx = table.findIndex((r) => r.key === rarityKey);
2711
+ const meta = table[idx] ?? DEFAULT_RARITIES.find((r) => r.key === rarityKey) ?? null;
2712
+ const min = Number(meta?.scoreMin);
2713
+ const max = Number(meta?.scoreMax);
2714
+ if (Number.isFinite(min) && Number.isFinite(max) && max >= min)
2715
+ return Math.round((min + max) / 2);
2716
+ return 10 + Math.max(0, idx) * 20;
2717
+ };
2718
+ const cardPoints = (card2, season2) => {
2719
+ const own = Number(card2?.score);
2720
+ if (Number.isFinite(own) && own > 0) return Math.round(own);
2721
+ return rarityPoints(season2, card2?.rarity);
2722
+ };
2723
+ const standingOf = (distinctCards, totalCards, sets, season2, limits = {}) => {
2724
+ const ownedIds = new Set(distinctCards.map((c) => String(c.id)));
2725
+ const cardScore = distinctCards.reduce((sum, c) => sum + cardPoints(c, season2), 0);
2726
+ const setsCompleted = (sets ?? []).filter(
2727
+ (s) => (s.cards ?? []).length > 0 && s.cards.every((c) => ownedIds.has(String(c.id)))
2728
+ ).length;
2729
+ const bonus = Math.max(0, Number(limits.setCompletionBonus) || 0);
2730
+ return {
2731
+ uniqueCards: distinctCards.length,
2732
+ totalCards,
2733
+ cardScore,
2734
+ setsCompleted,
2735
+ setBonus: setsCompleted * bonus,
2736
+ score: cardScore + setsCompleted * bonus
2737
+ };
2738
+ };
2739
+ const compareStandings = (a, b) => b.score - a.score || b.uniqueCards - a.uniqueCards || b.totalCards - a.totalCards;
2740
+ const rankAmong = (mine, all) => 1 + all.filter((s) => compareStandings(s, mine) < 0).length;
2706
2741
  const mediaUrl = (media) => media && typeof media === "object" ? media.url ?? null : null;
2707
2742
  const format = () => ({
2708
2743
  rarityMeta(season2, key) {
@@ -2721,6 +2756,7 @@ const format = () => ({
2721
2756
  rarityColor: rarity.color,
2722
2757
  edition: card2.edition ?? "standard",
2723
2758
  score: card2.score ?? 0,
2759
+ points: cardPoints(card2, season2),
2724
2760
  imageUrl: mediaUrl(card2.image),
2725
2761
  attributes: card2.attributes ?? {},
2726
2762
  lore: card2.lore ?? null,
@@ -2782,7 +2818,13 @@ const format = () => ({
2782
2818
  },
2783
2819
  /** Admin-only view: includes msisdn. */
2784
2820
  playerAdmin(player2) {
2785
- return player2 ? { ...this.player(player2), externalId: player2.externalId ?? null, msisdn: player2.msisdn ?? null, isActive: player2.isActive, meta: player2.meta ?? {} } : null;
2821
+ return player2 ? {
2822
+ ...this.player(player2),
2823
+ externalId: player2.externalId ?? null,
2824
+ msisdn: player2.msisdn ?? null,
2825
+ isActive: player2.isActive,
2826
+ meta: player2.meta ?? {}
2827
+ } : null;
2786
2828
  },
2787
2829
  playerCard(pc, season2) {
2788
2830
  if (!pc) return null;
@@ -2810,7 +2852,16 @@ const format = () => ({
2810
2852
  note: r.note ?? null,
2811
2853
  player: r.player ? this.player(r.player) : void 0,
2812
2854
  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
2855
+ reward: r.reward ? {
2856
+ id: r.reward.documentId,
2857
+ name: r.reward.name,
2858
+ slug: r.reward.slug,
2859
+ tier: r.reward.tier ?? null,
2860
+ booth: r.reward.booth ?? null,
2861
+ imageUrl: mediaUrl(r.reward.image),
2862
+ stock: r.reward.stock ?? null,
2863
+ claimed: r.reward.claimed ?? 0
2864
+ } : r.rewardName ? { name: r.rewardName } : null
2814
2865
  };
2815
2866
  }
2816
2867
  });
@@ -3522,22 +3573,11 @@ const collection = ({ strapi }) => ({
3522
3573
  return [...new Set(rows.map((r) => r.card?.id).filter(Boolean))];
3523
3574
  },
3524
3575
  /**
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.
3576
+ * Global rank = 1 + the number of collectors with a better standing: rarity-weighted score, then distinct
3577
+ * cards, then total cards (utils/score.js). Null before the first card.
3527
3578
  */
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;
3579
+ async rank(player2, season2) {
3580
+ return (await strapi.plugin("tcg").service("stats").standingFor(player2, season2)).rank;
3541
3581
  },
3542
3582
  async stats(player2, season2) {
3543
3583
  const plugin = strapi.plugin("tcg");
@@ -3573,16 +3613,26 @@ const collection = ({ strapi }) => ({
3573
3613
  populate: { pack: { select: ["id"] }, card: { select: ["id"] } }
3574
3614
  });
3575
3615
  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))];
3616
+ const cardIds = [
3617
+ ...new Set(
3618
+ packRows.filter((r) => r.pack?.id === p.id).map((r) => r.card?.id).filter(Boolean)
3619
+ )
3620
+ ];
3577
3621
  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 };
3622
+ return {
3623
+ pack: format2.pack(p),
3624
+ collected,
3625
+ total: cardIds.length,
3626
+ isComplete: cardIds.length > 0 && collected === cardIds.length
3627
+ };
3579
3628
  });
3580
3629
  const tier = plugin.service("season").tierFor(season2, completion);
3581
- const [sets, badges, metrics, live] = await Promise.all([
3630
+ const [sets, badges, metrics, live, standing] = await Promise.all([
3582
3631
  plugin.service("set").list(season2, { includeInactive: false }),
3583
3632
  plugin.service("badge").forPlayer(player2, season2),
3584
3633
  plugin.service("badge").metrics(player2, season2),
3585
- plugin.service("boost").liveNow(season2)
3634
+ plugin.service("boost").liveNow(season2),
3635
+ plugin.service("stats").standingFor(player2, season2)
3586
3636
  ]);
3587
3637
  return {
3588
3638
  sets: plugin.service("set").progress(sets, ownedIds, season2),
@@ -3593,7 +3643,13 @@ const collection = ({ strapi }) => ({
3593
3643
  uniqueCards: ownedIds.size,
3594
3644
  catalogTotal,
3595
3645
  completionPercentage: completion,
3596
- globalRank: await this.rank(player2, season2, ownedIds.size),
3646
+ globalRank: standing.rank,
3647
+ score: standing.score,
3648
+ scoreBreakdown: {
3649
+ cards: standing.cardScore,
3650
+ setBonus: standing.setBonus,
3651
+ setsCompleted: standing.setsCompleted
3652
+ },
3597
3653
  rankTier: { key: tier.key, label: tier.label },
3598
3654
  season: format2.season(season2),
3599
3655
  byRarity,
@@ -3607,7 +3663,14 @@ const collection = ({ strapi }) => ({
3607
3663
  const mintNumber = await strapi.plugin("tcg").service("pull").claimSlot(UID.CARD, card2.id, "minted", null);
3608
3664
  const pc = await strapi.db.transaction(async () => {
3609
3665
  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 }
3666
+ data: {
3667
+ player: player2.id,
3668
+ card: card2.id,
3669
+ pack: pack2?.id ?? null,
3670
+ acquiredFrom: "grant",
3671
+ acquiredAt: now,
3672
+ mintNumber
3673
+ }
3611
3674
  });
3612
3675
  await strapi.db.query(UID.PLAYER).update({ where: { id: player2.id }, data: { totalCards: (player2.totalCards ?? 0) + 1 } });
3613
3676
  return row;
@@ -4122,6 +4185,8 @@ const redemption = ({ strapi }) => ({
4122
4185
  return { items: items.map((r) => format2.redemption(r)), total };
4123
4186
  }
4124
4187
  });
4188
+ const STANDINGS_TTL_MS = 10 * 1e3;
4189
+ const STANDINGS_CACHE = /* @__PURE__ */ new Map();
4125
4190
  const startOfToday = () => {
4126
4191
  const d = /* @__PURE__ */ new Date();
4127
4192
  d.setHours(0, 0, 0, 0);
@@ -4156,15 +4221,31 @@ const stats = ({ strapi }) => ({
4156
4221
  strapi.db.query(UID.PULL).count({ where: { ...seasonWhere, pulledAt: { $gte: since } } }),
4157
4222
  strapi.db.query(UID.PULL).count({ where: seasonWhere }),
4158
4223
  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() } } }),
4224
+ strapi.db.query(UID.TRADE_OFFER).count({
4225
+ where: { ...seasonWhere, status: TRADE_STATUS.PENDING, expiresAt: { $gt: /* @__PURE__ */ new Date() } }
4226
+ }),
4160
4227
  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 } } }),
4228
+ strapi.db.query(UID.REDEMPTION).count({
4229
+ where: {
4230
+ ...seasonWhere,
4231
+ status: REDEMPTION_STATUS.FULFILLED,
4232
+ fulfilledAt: { $gte: since }
4233
+ }
4234
+ }),
4162
4235
  season2 ? strapi.db.query(UID.CARD).count({ where: { season: season2.id, isActive: true } }) : Promise.resolve(0),
4163
4236
  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"] }),
4237
+ strapi.db.query(UID.PULL).findMany({
4238
+ where: { ...seasonWhere, pulledAt: { $gte: week } },
4239
+ select: ["pulledAt", "rarity"]
4240
+ }),
4165
4241
  strapi.db.query(UID.PULL).findMany({
4166
4242
  where: seasonWhere,
4167
- populate: { player: { select: ["code", "displayName"] }, card: { select: ["name", "rarity"] }, pack: { select: ["name"] }, drop: { select: ["name"] } },
4243
+ populate: {
4244
+ player: { select: ["code", "displayName"] },
4245
+ card: { select: ["name", "rarity"] },
4246
+ pack: { select: ["name"] },
4247
+ drop: { select: ["name"] }
4248
+ },
4168
4249
  orderBy: { pulledAt: "desc" },
4169
4250
  limit: 8
4170
4251
  })
@@ -4173,24 +4254,47 @@ const stats = ({ strapi }) => ({
4173
4254
  const day = daysAgo(6 - i);
4174
4255
  const next = new Date(day);
4175
4256
  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 };
4257
+ return {
4258
+ day: day.toISOString().slice(0, 10),
4259
+ pulls: weekPulls.filter((p) => new Date(p.pulledAt) >= day && new Date(p.pulledAt) < next).length
4260
+ };
4177
4261
  });
4178
4262
  const rarities = season2?.rarities ?? [];
4179
4263
  const plugin = strapi.plugin("tcg");
4180
4264
  const [liveBoosts, rewards, drops] = season2 ? await Promise.all([
4181
4265
  plugin.service("boost").liveNow(season2),
4182
4266
  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"] } } })
4267
+ strapi.db.query(UID.DROP).findMany({
4268
+ where: { pack: { season: season2.id }, isActive: true },
4269
+ populate: { pack: { select: ["name"] } }
4270
+ })
4184
4271
  ]) : [[], [], []];
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) }));
4272
+ const lowStock = rewards.filter((r) => r.stock != null).map((r) => ({
4273
+ id: r.documentId,
4274
+ name: r.name,
4275
+ remaining: Math.max(0, r.stock - (r.claimed ?? 0)),
4276
+ stock: r.stock
4277
+ })).filter((r) => r.remaining <= Math.max(3, Math.ceil(r.stock * 0.1))).sort((a, b) => a.remaining - b.remaining);
4278
+ const dropStates = drops.map((d) => ({
4279
+ id: d.documentId,
4280
+ name: `${d.pack?.name ?? ""} · ${d.name}`,
4281
+ mode: d.mode,
4282
+ scans: d.scanCount ?? 0,
4283
+ cap: d.maxScansTotal ?? null,
4284
+ isOpen: plugin.service("drop").isOpen(d)
4285
+ }));
4187
4286
  return {
4188
4287
  liveBoosts,
4189
4288
  lowStock,
4190
4289
  drops: dropStates,
4191
4290
  season: season2 ? { id: season2.documentId, name: season2.name, slug: season2.slug, status: season2.status } : null,
4192
4291
  players: { total: players, today: playersToday },
4193
- pulls: { today: pullsToday, total: pullsTotal, sparkline, distribution: distribution(weekPulls, rarities) },
4292
+ pulls: {
4293
+ today: pullsToday,
4294
+ total: pullsTotal,
4295
+ sparkline,
4296
+ distribution: distribution(weekPulls, rarities)
4297
+ },
4194
4298
  trades: { today: tradesToday, pendingOffers },
4195
4299
  redemptions: { pending: pendingRedemptions, fulfilledToday },
4196
4300
  catalog: { cards, packs },
@@ -4207,21 +4311,75 @@ const stats = ({ strapi }) => ({
4207
4311
  }))
4208
4312
  };
4209
4313
  },
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
- });
4314
+ /**
4315
+ * Every collector's standing in a season (utils/score.js), sorted. Computed from every player card of
4316
+ * the season and cached a few seconds: the rank is asked on every stats screen.
4317
+ * @returns {Promise<Array<{player: Object, uniqueCards: number, totalCards: number, cardScore: number, setsCompleted: number, setBonus: number, score: number}>>}
4318
+ */
4319
+ async standings(season2) {
4320
+ const cached = STANDINGS_CACHE.get(season2.id);
4321
+ if (cached && Date.now() - cached.at < STANDINGS_TTL_MS) return cached.value;
4322
+ const plugin = strapi.plugin("tcg");
4323
+ const [rows, sets] = await Promise.all([
4324
+ strapi.db.query(UID.PLAYER_CARD).findMany({
4325
+ where: { card: { season: season2.id } },
4326
+ populate: {
4327
+ player: { select: ["id", "documentId", "code", "displayName"] },
4328
+ card: { select: ["id", "rarity", "score"] }
4329
+ }
4330
+ }),
4331
+ plugin.service("set").list(season2, { includeInactive: false })
4332
+ ]);
4333
+ const limits = plugin.service("season").limitsOf(season2);
4216
4334
  const byPlayer = /* @__PURE__ */ new Map();
4217
4335
  for (const r of rows) {
4218
4336
  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);
4337
+ const entry = byPlayer.get(r.player.id) ?? { player: r.player, cards: /* @__PURE__ */ new Map(), total: 0 };
4338
+ entry.cards.set(r.card.id, r.card);
4221
4339
  entry.total += 1;
4222
4340
  byPlayer.set(r.player.id, entry);
4223
4341
  }
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 }));
4342
+ const value = [...byPlayer.values()].map((e) => ({
4343
+ player: e.player,
4344
+ ...standingOf([...e.cards.values()], e.total, sets, season2, limits)
4345
+ })).sort(compareStandings);
4346
+ STANDINGS_CACHE.set(season2.id, { at: Date.now(), value });
4347
+ return value;
4348
+ },
4349
+ /** Forget the cached standings (after a grant, a trade…); they rebuild on the next read. */
4350
+ invalidateStandings(season2) {
4351
+ if (season2) STANDINGS_CACHE.delete(season2.id);
4352
+ else STANDINGS_CACHE.clear();
4353
+ },
4354
+ /** Where one player stands: rank among every collector, and the score's parts. Null rank with no card. */
4355
+ async standingFor(player2, season2) {
4356
+ const all = await this.standings(season2);
4357
+ const mine = all.find((s) => s.player.id === player2.id);
4358
+ if (!mine)
4359
+ return {
4360
+ rank: null,
4361
+ uniqueCards: 0,
4362
+ totalCards: 0,
4363
+ cardScore: 0,
4364
+ setsCompleted: 0,
4365
+ setBonus: 0,
4366
+ score: 0
4367
+ };
4368
+ return { rank: rankAmong(mine, all), ...mine, player: void 0 };
4369
+ },
4370
+ /** Top collectors by score (admin leaderboard). */
4371
+ async leaderboard(season2, limit = 10) {
4372
+ const all = await this.standings(season2);
4373
+ return all.slice(0, limit).map((e, i) => ({
4374
+ rank: i + 1,
4375
+ player: { id: e.player.documentId, code: e.player.code, displayName: e.player.displayName },
4376
+ score: e.score,
4377
+ cardScore: e.cardScore,
4378
+ setBonus: e.setBonus,
4379
+ setsCompleted: e.setsCompleted,
4380
+ uniqueCards: e.uniqueCards,
4381
+ totalCards: e.totalCards
4382
+ }));
4225
4383
  },
4226
4384
  /** Recent pulls for the audit screen. */
4227
4385
  async pulls(season2, { playerCode, rarity }, page) {
@@ -4231,7 +4389,12 @@ const stats = ({ strapi }) => ({
4231
4389
  const [items, total] = await Promise.all([
4232
4390
  strapi.db.query(UID.PULL).findMany({
4233
4391
  where,
4234
- populate: { player: { select: ["code", "displayName"] }, card: { select: ["name", "rarity", "number"] }, pack: { select: ["name", "slug"] }, drop: { select: ["name", "slug"] } },
4392
+ populate: {
4393
+ player: { select: ["code", "displayName"] },
4394
+ card: { select: ["name", "rarity", "number"] },
4395
+ pack: { select: ["name", "slug"] },
4396
+ drop: { select: ["name", "slug"] }
4397
+ },
4235
4398
  orderBy: { pulledAt: "desc" },
4236
4399
  offset: page.offset,
4237
4400
  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 },
@@ -2702,6 +2704,39 @@ const qr = ({ strapi }) => ({
2702
2704
  return count ?? 0;
2703
2705
  }
2704
2706
  });
2707
+ const rarityPoints = (season2, rarityKey) => {
2708
+ const table = season2?.rarities?.length ? season2.rarities : DEFAULT_RARITIES;
2709
+ const idx = table.findIndex((r) => r.key === rarityKey);
2710
+ const meta = table[idx] ?? DEFAULT_RARITIES.find((r) => r.key === rarityKey) ?? null;
2711
+ const min = Number(meta?.scoreMin);
2712
+ const max = Number(meta?.scoreMax);
2713
+ if (Number.isFinite(min) && Number.isFinite(max) && max >= min)
2714
+ return Math.round((min + max) / 2);
2715
+ return 10 + Math.max(0, idx) * 20;
2716
+ };
2717
+ const cardPoints = (card2, season2) => {
2718
+ const own = Number(card2?.score);
2719
+ if (Number.isFinite(own) && own > 0) return Math.round(own);
2720
+ return rarityPoints(season2, card2?.rarity);
2721
+ };
2722
+ const standingOf = (distinctCards, totalCards, sets, season2, limits = {}) => {
2723
+ const ownedIds = new Set(distinctCards.map((c) => String(c.id)));
2724
+ const cardScore = distinctCards.reduce((sum, c) => sum + cardPoints(c, season2), 0);
2725
+ const setsCompleted = (sets ?? []).filter(
2726
+ (s) => (s.cards ?? []).length > 0 && s.cards.every((c) => ownedIds.has(String(c.id)))
2727
+ ).length;
2728
+ const bonus = Math.max(0, Number(limits.setCompletionBonus) || 0);
2729
+ return {
2730
+ uniqueCards: distinctCards.length,
2731
+ totalCards,
2732
+ cardScore,
2733
+ setsCompleted,
2734
+ setBonus: setsCompleted * bonus,
2735
+ score: cardScore + setsCompleted * bonus
2736
+ };
2737
+ };
2738
+ const compareStandings = (a, b) => b.score - a.score || b.uniqueCards - a.uniqueCards || b.totalCards - a.totalCards;
2739
+ const rankAmong = (mine, all) => 1 + all.filter((s) => compareStandings(s, mine) < 0).length;
2705
2740
  const mediaUrl = (media) => media && typeof media === "object" ? media.url ?? null : null;
2706
2741
  const format = () => ({
2707
2742
  rarityMeta(season2, key) {
@@ -2720,6 +2755,7 @@ const format = () => ({
2720
2755
  rarityColor: rarity.color,
2721
2756
  edition: card2.edition ?? "standard",
2722
2757
  score: card2.score ?? 0,
2758
+ points: cardPoints(card2, season2),
2723
2759
  imageUrl: mediaUrl(card2.image),
2724
2760
  attributes: card2.attributes ?? {},
2725
2761
  lore: card2.lore ?? null,
@@ -2781,7 +2817,13 @@ const format = () => ({
2781
2817
  },
2782
2818
  /** Admin-only view: includes msisdn. */
2783
2819
  playerAdmin(player2) {
2784
- return player2 ? { ...this.player(player2), externalId: player2.externalId ?? null, msisdn: player2.msisdn ?? null, isActive: player2.isActive, meta: player2.meta ?? {} } : null;
2820
+ return player2 ? {
2821
+ ...this.player(player2),
2822
+ externalId: player2.externalId ?? null,
2823
+ msisdn: player2.msisdn ?? null,
2824
+ isActive: player2.isActive,
2825
+ meta: player2.meta ?? {}
2826
+ } : null;
2785
2827
  },
2786
2828
  playerCard(pc, season2) {
2787
2829
  if (!pc) return null;
@@ -2809,7 +2851,16 @@ const format = () => ({
2809
2851
  note: r.note ?? null,
2810
2852
  player: r.player ? this.player(r.player) : void 0,
2811
2853
  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
2854
+ reward: r.reward ? {
2855
+ id: r.reward.documentId,
2856
+ name: r.reward.name,
2857
+ slug: r.reward.slug,
2858
+ tier: r.reward.tier ?? null,
2859
+ booth: r.reward.booth ?? null,
2860
+ imageUrl: mediaUrl(r.reward.image),
2861
+ stock: r.reward.stock ?? null,
2862
+ claimed: r.reward.claimed ?? 0
2863
+ } : r.rewardName ? { name: r.rewardName } : null
2813
2864
  };
2814
2865
  }
2815
2866
  });
@@ -3521,22 +3572,11 @@ const collection = ({ strapi }) => ({
3521
3572
  return [...new Set(rows.map((r) => r.card?.id).filter(Boolean))];
3522
3573
  },
3523
3574
  /**
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.
3575
+ * Global rank = 1 + the number of collectors with a better standing: rarity-weighted score, then distinct
3576
+ * cards, then total cards (utils/score.js). Null before the first card.
3526
3577
  */
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;
3578
+ async rank(player2, season2) {
3579
+ return (await strapi.plugin("tcg").service("stats").standingFor(player2, season2)).rank;
3540
3580
  },
3541
3581
  async stats(player2, season2) {
3542
3582
  const plugin = strapi.plugin("tcg");
@@ -3572,16 +3612,26 @@ const collection = ({ strapi }) => ({
3572
3612
  populate: { pack: { select: ["id"] }, card: { select: ["id"] } }
3573
3613
  });
3574
3614
  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))];
3615
+ const cardIds = [
3616
+ ...new Set(
3617
+ packRows.filter((r) => r.pack?.id === p.id).map((r) => r.card?.id).filter(Boolean)
3618
+ )
3619
+ ];
3576
3620
  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 };
3621
+ return {
3622
+ pack: format2.pack(p),
3623
+ collected,
3624
+ total: cardIds.length,
3625
+ isComplete: cardIds.length > 0 && collected === cardIds.length
3626
+ };
3578
3627
  });
3579
3628
  const tier = plugin.service("season").tierFor(season2, completion);
3580
- const [sets, badges, metrics, live] = await Promise.all([
3629
+ const [sets, badges, metrics, live, standing] = await Promise.all([
3581
3630
  plugin.service("set").list(season2, { includeInactive: false }),
3582
3631
  plugin.service("badge").forPlayer(player2, season2),
3583
3632
  plugin.service("badge").metrics(player2, season2),
3584
- plugin.service("boost").liveNow(season2)
3633
+ plugin.service("boost").liveNow(season2),
3634
+ plugin.service("stats").standingFor(player2, season2)
3585
3635
  ]);
3586
3636
  return {
3587
3637
  sets: plugin.service("set").progress(sets, ownedIds, season2),
@@ -3592,7 +3642,13 @@ const collection = ({ strapi }) => ({
3592
3642
  uniqueCards: ownedIds.size,
3593
3643
  catalogTotal,
3594
3644
  completionPercentage: completion,
3595
- globalRank: await this.rank(player2, season2, ownedIds.size),
3645
+ globalRank: standing.rank,
3646
+ score: standing.score,
3647
+ scoreBreakdown: {
3648
+ cards: standing.cardScore,
3649
+ setBonus: standing.setBonus,
3650
+ setsCompleted: standing.setsCompleted
3651
+ },
3596
3652
  rankTier: { key: tier.key, label: tier.label },
3597
3653
  season: format2.season(season2),
3598
3654
  byRarity,
@@ -3606,7 +3662,14 @@ const collection = ({ strapi }) => ({
3606
3662
  const mintNumber = await strapi.plugin("tcg").service("pull").claimSlot(UID.CARD, card2.id, "minted", null);
3607
3663
  const pc = await strapi.db.transaction(async () => {
3608
3664
  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 }
3665
+ data: {
3666
+ player: player2.id,
3667
+ card: card2.id,
3668
+ pack: pack2?.id ?? null,
3669
+ acquiredFrom: "grant",
3670
+ acquiredAt: now,
3671
+ mintNumber
3672
+ }
3610
3673
  });
3611
3674
  await strapi.db.query(UID.PLAYER).update({ where: { id: player2.id }, data: { totalCards: (player2.totalCards ?? 0) + 1 } });
3612
3675
  return row;
@@ -4121,6 +4184,8 @@ const redemption = ({ strapi }) => ({
4121
4184
  return { items: items.map((r) => format2.redemption(r)), total };
4122
4185
  }
4123
4186
  });
4187
+ const STANDINGS_TTL_MS = 10 * 1e3;
4188
+ const STANDINGS_CACHE = /* @__PURE__ */ new Map();
4124
4189
  const startOfToday = () => {
4125
4190
  const d = /* @__PURE__ */ new Date();
4126
4191
  d.setHours(0, 0, 0, 0);
@@ -4155,15 +4220,31 @@ const stats = ({ strapi }) => ({
4155
4220
  strapi.db.query(UID.PULL).count({ where: { ...seasonWhere, pulledAt: { $gte: since } } }),
4156
4221
  strapi.db.query(UID.PULL).count({ where: seasonWhere }),
4157
4222
  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() } } }),
4223
+ strapi.db.query(UID.TRADE_OFFER).count({
4224
+ where: { ...seasonWhere, status: TRADE_STATUS.PENDING, expiresAt: { $gt: /* @__PURE__ */ new Date() } }
4225
+ }),
4159
4226
  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 } } }),
4227
+ strapi.db.query(UID.REDEMPTION).count({
4228
+ where: {
4229
+ ...seasonWhere,
4230
+ status: REDEMPTION_STATUS.FULFILLED,
4231
+ fulfilledAt: { $gte: since }
4232
+ }
4233
+ }),
4161
4234
  season2 ? strapi.db.query(UID.CARD).count({ where: { season: season2.id, isActive: true } }) : Promise.resolve(0),
4162
4235
  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"] }),
4236
+ strapi.db.query(UID.PULL).findMany({
4237
+ where: { ...seasonWhere, pulledAt: { $gte: week } },
4238
+ select: ["pulledAt", "rarity"]
4239
+ }),
4164
4240
  strapi.db.query(UID.PULL).findMany({
4165
4241
  where: seasonWhere,
4166
- populate: { player: { select: ["code", "displayName"] }, card: { select: ["name", "rarity"] }, pack: { select: ["name"] }, drop: { select: ["name"] } },
4242
+ populate: {
4243
+ player: { select: ["code", "displayName"] },
4244
+ card: { select: ["name", "rarity"] },
4245
+ pack: { select: ["name"] },
4246
+ drop: { select: ["name"] }
4247
+ },
4167
4248
  orderBy: { pulledAt: "desc" },
4168
4249
  limit: 8
4169
4250
  })
@@ -4172,24 +4253,47 @@ const stats = ({ strapi }) => ({
4172
4253
  const day = daysAgo(6 - i);
4173
4254
  const next = new Date(day);
4174
4255
  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 };
4256
+ return {
4257
+ day: day.toISOString().slice(0, 10),
4258
+ pulls: weekPulls.filter((p) => new Date(p.pulledAt) >= day && new Date(p.pulledAt) < next).length
4259
+ };
4176
4260
  });
4177
4261
  const rarities = season2?.rarities ?? [];
4178
4262
  const plugin = strapi.plugin("tcg");
4179
4263
  const [liveBoosts, rewards, drops] = season2 ? await Promise.all([
4180
4264
  plugin.service("boost").liveNow(season2),
4181
4265
  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"] } } })
4266
+ strapi.db.query(UID.DROP).findMany({
4267
+ where: { pack: { season: season2.id }, isActive: true },
4268
+ populate: { pack: { select: ["name"] } }
4269
+ })
4183
4270
  ]) : [[], [], []];
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) }));
4271
+ const lowStock = rewards.filter((r) => r.stock != null).map((r) => ({
4272
+ id: r.documentId,
4273
+ name: r.name,
4274
+ remaining: Math.max(0, r.stock - (r.claimed ?? 0)),
4275
+ stock: r.stock
4276
+ })).filter((r) => r.remaining <= Math.max(3, Math.ceil(r.stock * 0.1))).sort((a, b) => a.remaining - b.remaining);
4277
+ const dropStates = drops.map((d) => ({
4278
+ id: d.documentId,
4279
+ name: `${d.pack?.name ?? ""} · ${d.name}`,
4280
+ mode: d.mode,
4281
+ scans: d.scanCount ?? 0,
4282
+ cap: d.maxScansTotal ?? null,
4283
+ isOpen: plugin.service("drop").isOpen(d)
4284
+ }));
4186
4285
  return {
4187
4286
  liveBoosts,
4188
4287
  lowStock,
4189
4288
  drops: dropStates,
4190
4289
  season: season2 ? { id: season2.documentId, name: season2.name, slug: season2.slug, status: season2.status } : null,
4191
4290
  players: { total: players, today: playersToday },
4192
- pulls: { today: pullsToday, total: pullsTotal, sparkline, distribution: distribution(weekPulls, rarities) },
4291
+ pulls: {
4292
+ today: pullsToday,
4293
+ total: pullsTotal,
4294
+ sparkline,
4295
+ distribution: distribution(weekPulls, rarities)
4296
+ },
4193
4297
  trades: { today: tradesToday, pendingOffers },
4194
4298
  redemptions: { pending: pendingRedemptions, fulfilledToday },
4195
4299
  catalog: { cards, packs },
@@ -4206,21 +4310,75 @@ const stats = ({ strapi }) => ({
4206
4310
  }))
4207
4311
  };
4208
4312
  },
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
- });
4313
+ /**
4314
+ * Every collector's standing in a season (utils/score.js), sorted. Computed from every player card of
4315
+ * the season and cached a few seconds: the rank is asked on every stats screen.
4316
+ * @returns {Promise<Array<{player: Object, uniqueCards: number, totalCards: number, cardScore: number, setsCompleted: number, setBonus: number, score: number}>>}
4317
+ */
4318
+ async standings(season2) {
4319
+ const cached = STANDINGS_CACHE.get(season2.id);
4320
+ if (cached && Date.now() - cached.at < STANDINGS_TTL_MS) return cached.value;
4321
+ const plugin = strapi.plugin("tcg");
4322
+ const [rows, sets] = await Promise.all([
4323
+ strapi.db.query(UID.PLAYER_CARD).findMany({
4324
+ where: { card: { season: season2.id } },
4325
+ populate: {
4326
+ player: { select: ["id", "documentId", "code", "displayName"] },
4327
+ card: { select: ["id", "rarity", "score"] }
4328
+ }
4329
+ }),
4330
+ plugin.service("set").list(season2, { includeInactive: false })
4331
+ ]);
4332
+ const limits = plugin.service("season").limitsOf(season2);
4215
4333
  const byPlayer = /* @__PURE__ */ new Map();
4216
4334
  for (const r of rows) {
4217
4335
  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);
4336
+ const entry = byPlayer.get(r.player.id) ?? { player: r.player, cards: /* @__PURE__ */ new Map(), total: 0 };
4337
+ entry.cards.set(r.card.id, r.card);
4220
4338
  entry.total += 1;
4221
4339
  byPlayer.set(r.player.id, entry);
4222
4340
  }
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 }));
4341
+ const value = [...byPlayer.values()].map((e) => ({
4342
+ player: e.player,
4343
+ ...standingOf([...e.cards.values()], e.total, sets, season2, limits)
4344
+ })).sort(compareStandings);
4345
+ STANDINGS_CACHE.set(season2.id, { at: Date.now(), value });
4346
+ return value;
4347
+ },
4348
+ /** Forget the cached standings (after a grant, a trade…); they rebuild on the next read. */
4349
+ invalidateStandings(season2) {
4350
+ if (season2) STANDINGS_CACHE.delete(season2.id);
4351
+ else STANDINGS_CACHE.clear();
4352
+ },
4353
+ /** Where one player stands: rank among every collector, and the score's parts. Null rank with no card. */
4354
+ async standingFor(player2, season2) {
4355
+ const all = await this.standings(season2);
4356
+ const mine = all.find((s) => s.player.id === player2.id);
4357
+ if (!mine)
4358
+ return {
4359
+ rank: null,
4360
+ uniqueCards: 0,
4361
+ totalCards: 0,
4362
+ cardScore: 0,
4363
+ setsCompleted: 0,
4364
+ setBonus: 0,
4365
+ score: 0
4366
+ };
4367
+ return { rank: rankAmong(mine, all), ...mine, player: void 0 };
4368
+ },
4369
+ /** Top collectors by score (admin leaderboard). */
4370
+ async leaderboard(season2, limit = 10) {
4371
+ const all = await this.standings(season2);
4372
+ return all.slice(0, limit).map((e, i) => ({
4373
+ rank: i + 1,
4374
+ player: { id: e.player.documentId, code: e.player.code, displayName: e.player.displayName },
4375
+ score: e.score,
4376
+ cardScore: e.cardScore,
4377
+ setBonus: e.setBonus,
4378
+ setsCompleted: e.setsCompleted,
4379
+ uniqueCards: e.uniqueCards,
4380
+ totalCards: e.totalCards
4381
+ }));
4224
4382
  },
4225
4383
  /** Recent pulls for the audit screen. */
4226
4384
  async pulls(season2, { playerCode, rarity }, page) {
@@ -4230,7 +4388,12 @@ const stats = ({ strapi }) => ({
4230
4388
  const [items, total] = await Promise.all([
4231
4389
  strapi.db.query(UID.PULL).findMany({
4232
4390
  where,
4233
- populate: { player: { select: ["code", "displayName"] }, card: { select: ["name", "rarity", "number"] }, pack: { select: ["name", "slug"] }, drop: { select: ["name", "slug"] } },
4391
+ populate: {
4392
+ player: { select: ["code", "displayName"] },
4393
+ card: { select: ["name", "rarity", "number"] },
4394
+ pack: { select: ["name", "slug"] },
4395
+ drop: { select: ["name", "slug"] }
4396
+ },
4234
4397
  orderBy: { pulledAt: "desc" },
4235
4398
  offset: page.offset,
4236
4399
  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.1",
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>",