@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 +1 -0
- package/dist/_chunks/{App-B-WZGNkc.js → App-C3L5FTNZ.js} +12 -5
- package/dist/_chunks/{App-CS_cVYEI.mjs → App-DvkOruFZ.mjs} +12 -5
- package/dist/_chunks/{SettingsPage-DNWXuY8j.js → SettingsPage-BxmcGJ5s.js} +2 -2
- package/dist/_chunks/{SettingsPage-fGiEcX6l.mjs → SettingsPage-SjgoOH4S.mjs} +2 -2
- package/dist/_chunks/{api-hTExOv_J.js → api-BqmNHByN.js} +1 -1
- package/dist/_chunks/{api-BgR-fpDp.mjs → api-Clm2RMWV.mjs} +1 -1
- package/dist/_chunks/{index-ClDgxnnH.mjs → index-CwyVbzVg.mjs} +2 -2
- package/dist/_chunks/{index-CRbzJppw.js → index-DQmN1LMy.js} +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +262 -52
- package/dist/server/index.mjs +262 -52
- package/package.json +1 -1
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-
|
|
10
|
+
const api = require("./api-BqmNHByN.js");
|
|
11
11
|
const react = require("react");
|
|
12
|
-
const index = require("./index-
|
|
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
|
-
"
|
|
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-
|
|
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-
|
|
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
|
-
"
|
|
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-
|
|
7
|
-
const index = require("./index-
|
|
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-
|
|
5
|
-
import { a as PERMISSIONS } from "./index-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
79
|
+
const { SettingsPage } = await Promise.resolve().then(() => require("./SettingsPage-BxmcGJ5s.js"));
|
|
80
80
|
return SettingsPage;
|
|
81
81
|
}
|
|
82
82
|
}
|
package/dist/admin/index.js
CHANGED
package/dist/admin/index.mjs
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
{
|
|
243
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
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(
|
|
277
|
-
|
|
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(
|
|
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 ? {
|
|
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 ? {
|
|
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
|
|
3526
|
-
*
|
|
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
|
|
3529
|
-
|
|
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 = [
|
|
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 {
|
|
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:
|
|
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: {
|
|
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({
|
|
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({
|
|
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({
|
|
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: {
|
|
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 {
|
|
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({
|
|
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) => ({
|
|
4186
|
-
|
|
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: {
|
|
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
|
-
/**
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
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,
|
|
4220
|
-
entry.
|
|
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
|
-
|
|
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: {
|
|
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
|
package/dist/server/index.mjs
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
{
|
|
242
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
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(
|
|
276
|
-
|
|
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(
|
|
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 ? {
|
|
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 ? {
|
|
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
|
|
3525
|
-
*
|
|
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
|
|
3528
|
-
|
|
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 = [
|
|
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 {
|
|
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:
|
|
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: {
|
|
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({
|
|
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({
|
|
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({
|
|
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: {
|
|
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 {
|
|
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({
|
|
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) => ({
|
|
4185
|
-
|
|
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: {
|
|
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
|
-
/**
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
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,
|
|
4219
|
-
entry.
|
|
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
|
-
|
|
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: {
|
|
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.
|
|
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>",
|