@sonatel-os/strapi-plugin-tcg 0.2.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.
@@ -45,20 +45,46 @@ const RBAC = Object.freeze({
45
45
  SETTINGS_READ: "settings.read",
46
46
  SETTINGS_WRITE: "settings.write"
47
47
  });
48
- const SEASON_STATUS = Object.freeze({ DRAFT: "draft", ACTIVE: "active", ARCHIVED: "archived" });
49
- const PACK_KIND = Object.freeze({ LOCATION: "location", BOOSTER: "booster", EVENT: "event" });
48
+ const SEASON_STATUS = Object.freeze({
49
+ DRAFT: "draft",
50
+ ACTIVE: "active",
51
+ ARCHIVED: "archived"
52
+ });
53
+ const PACK_KIND = Object.freeze({
54
+ LOCATION: "location",
55
+ BOOSTER: "booster",
56
+ EVENT: "event"
57
+ });
50
58
  const ACQUISITION = Object.freeze({ PULL: "pull", TRADE: "trade", GRANT: "grant" });
51
59
  const TRADE_STATUS = Object.freeze({
52
60
  PENDING: "pending",
61
+ // the QR is shown, nobody has joined
62
+ JOINED: "joined",
63
+ // a receiver read the offer and is choosing what to give
64
+ PROPOSED: "proposed",
65
+ // the receiver proposed cards; the initiator confirms or declines
53
66
  ACCEPTED: "accepted",
54
67
  REJECTED: "rejected",
55
68
  EXPIRED: "expired",
56
69
  CANCELLED: "cancelled"
57
70
  });
58
- const REDEMPTION_STATUS = Object.freeze({ PENDING: "pending", FULFILLED: "fulfilled", CANCELLED: "cancelled" });
71
+ const TRADE_OPEN_STATUSES = Object.freeze([
72
+ TRADE_STATUS.PENDING,
73
+ TRADE_STATUS.JOINED,
74
+ TRADE_STATUS.PROPOSED
75
+ ]);
76
+ const REDEMPTION_STATUS = Object.freeze({
77
+ PENDING: "pending",
78
+ FULFILLED: "fulfilled",
79
+ CANCELLED: "cancelled"
80
+ });
59
81
  const NONCE_KIND = Object.freeze({ PACK: "pack", TRADE: "trade", DROP: "drop" });
60
82
  const DROP_MODE = Object.freeze({ STATIC: "static", ROTATING: "rotating" });
61
- const POOL_MODE = Object.freeze({ INHERIT: "inherit", EXTEND: "extend", OVERRIDE: "override" });
83
+ const POOL_MODE = Object.freeze({
84
+ INHERIT: "inherit",
85
+ EXTEND: "extend",
86
+ OVERRIDE: "override"
87
+ });
62
88
  const DEFAULT_DROP_NAME = "Entrée";
63
89
  const MAX_PULL_RETRIES = 3;
64
90
  const COST_RULE = Object.freeze({
@@ -96,10 +122,24 @@ const BADGE_RULE = Object.freeze({
96
122
  // { type:'scan_drop', drop:<slug> }
97
123
  });
98
124
  const DEFAULT_RARITIES = Object.freeze([
99
- { key: "common", label: "Common", probability: 0.5, color: "#7A7A86", scoreMin: 10, scoreMax: 30 },
125
+ {
126
+ key: "common",
127
+ label: "Common",
128
+ probability: 0.5,
129
+ color: "#7A7A86",
130
+ scoreMin: 10,
131
+ scoreMax: 30
132
+ },
100
133
  { key: "rare", label: "Rare", probability: 0.3, color: "#00BFFF", scoreMin: 35, scoreMax: 55 },
101
134
  { key: "epic", label: "Epic", probability: 0.15, color: "#A020F0", scoreMin: 60, scoreMax: 80 },
102
- { key: "legendary", label: "Legendary", probability: 0.05, color: "#FFD700", scoreMin: 85, scoreMax: 100 }
135
+ {
136
+ key: "legendary",
137
+ label: "Legendary",
138
+ probability: 0.05,
139
+ color: "#FFD700",
140
+ scoreMin: 85,
141
+ scoreMax: 100
142
+ }
103
143
  ]);
104
144
  const DEFAULT_EDITIONS = Object.freeze([
105
145
  { key: "standard", label: "Standard" },
@@ -118,7 +158,9 @@ const DEFAULT_LIMITS = Object.freeze({
118
158
  qrTokenTtlMs: 5 * 60 * 1e3,
119
159
  staticScanCooldownMs: 24 * 60 * 60 * 1e3,
120
160
  pityRareAfter: 0,
121
- pityLegendaryAfter: 0
161
+ pityLegendaryAfter: 0,
162
+ /** Points a completed set adds to the collector's score (utils/score.js). */
163
+ setCompletionBonus: 50
122
164
  });
123
165
  const DEFAULT_RANK_TIERS = Object.freeze([
124
166
  { key: "legend", label: "Legend", minPercent: 100 },
@@ -568,6 +610,12 @@ const attributes$f = {
568
610
  cooldownMs: {
569
611
  type: "integer"
570
612
  },
613
+ cardsPerPull: {
614
+ type: "integer",
615
+ min: 1,
616
+ max: 10,
617
+ "default": 1
618
+ },
571
619
  scanCount: {
572
620
  type: "integer",
573
621
  "default": 0,
@@ -1424,6 +1472,10 @@ const attributes$4 = {
1424
1472
  },
1425
1473
  mintNumber: {
1426
1474
  type: "integer"
1475
+ },
1476
+ isExtra: {
1477
+ type: "boolean",
1478
+ "default": false
1427
1479
  }
1428
1480
  };
1429
1481
  const schema$4 = {
@@ -1441,7 +1493,7 @@ const info$3 = {
1441
1493
  singularName: "trade-offer",
1442
1494
  pluralName: "trade-offers",
1443
1495
  displayName: "TCG Trade Offer",
1444
- description: "A pending P2P proposal carried by a QR code."
1496
+ description: "A P2P exchange: the initiator's QR, a receiver who joins and proposes cards, the initiator's confirmation."
1445
1497
  };
1446
1498
  const options$3 = {
1447
1499
  draftAndPublish: false
@@ -1469,6 +1521,8 @@ const attributes$3 = {
1469
1521
  type: "enumeration",
1470
1522
  "enum": [
1471
1523
  "pending",
1524
+ "joined",
1525
+ "proposed",
1472
1526
  "accepted",
1473
1527
  "rejected",
1474
1528
  "expired",
@@ -1488,6 +1542,23 @@ const attributes$3 = {
1488
1542
  expiresAt: {
1489
1543
  type: "datetime",
1490
1544
  required: true
1545
+ },
1546
+ receiver: {
1547
+ type: "relation",
1548
+ relation: "manyToOne",
1549
+ target: "plugin::tcg.player"
1550
+ },
1551
+ receiverCardIds: {
1552
+ type: "json"
1553
+ },
1554
+ joinedAt: {
1555
+ type: "datetime"
1556
+ },
1557
+ proposedAt: {
1558
+ type: "datetime"
1559
+ },
1560
+ declinedAt: {
1561
+ type: "datetime"
1491
1562
  }
1492
1563
  };
1493
1564
  const schema$3 = {
@@ -1776,7 +1847,10 @@ const game = ({ strapi }) => {
1776
1847
  return {
1777
1848
  // ── Player ───────────────────────────────────────────────────────────
1778
1849
  me: handle(strapi, async (ctx) => {
1779
- ok(ctx, { ...svc("format").player(ctx.state.tcgPlayer), isNew: Boolean(ctx.state.tcgPlayerIsNew) });
1850
+ ok(ctx, {
1851
+ ...svc("format").player(ctx.state.tcgPlayer),
1852
+ isNew: Boolean(ctx.state.tcgPlayerIsNew)
1853
+ });
1780
1854
  }),
1781
1855
  updateMe: handle(strapi, async (ctx) => {
1782
1856
  const { displayName, avatarUrl } = body(ctx);
@@ -1794,12 +1868,19 @@ const game = ({ strapi }) => {
1794
1868
  packs: handle(strapi, async (ctx) => {
1795
1869
  const season2 = await seasonOf(ctx);
1796
1870
  const packs = await svc("pack").list(season2);
1797
- ok(ctx, packs.map((p) => svc("format").pack(p)));
1871
+ ok(
1872
+ ctx,
1873
+ packs.map((p) => svc("format").pack(p))
1874
+ );
1798
1875
  }),
1799
1876
  pack: handle(strapi, async (ctx) => {
1800
1877
  const season2 = await seasonOf(ctx);
1801
1878
  const pack2 = await svc("pack").findOne(season2, ctx.params.slug);
1802
- const [pool, drops, exclusiveIds] = await Promise.all([svc("pack").pool(pack2), svc("drop").list(pack2, { includeInactive: false }), svc("drop").exclusiveCardIds(season2)]);
1879
+ const [pool, drops, exclusiveIds] = await Promise.all([
1880
+ svc("pack").pool(pack2),
1881
+ svc("drop").list(pack2, { includeInactive: false }),
1882
+ svc("drop").exclusiveCardIds(season2)
1883
+ ]);
1803
1884
  ok(ctx, {
1804
1885
  ...svc("format").pack(pack2),
1805
1886
  cards: pool.filter((c) => !exclusiveIds.has(c.id) || c.isExclusive).map((c) => ({ ...svc("format").card(c, season2), isExclusive: c.isExclusive })),
@@ -1819,7 +1900,11 @@ const game = ({ strapi }) => {
1819
1900
  windows.push({ ...v, pack: { id: pack2.documentId, name: pack2.name, slug: pack2.slug } });
1820
1901
  }
1821
1902
  }
1822
- ok(ctx, { boosts: await svc("boost").liveNow(season2), drops: windows, serverTime: /* @__PURE__ */ new Date() });
1903
+ ok(ctx, {
1904
+ boosts: await svc("boost").liveNow(season2),
1905
+ drops: windows,
1906
+ serverTime: /* @__PURE__ */ new Date()
1907
+ });
1823
1908
  }),
1824
1909
  sets: handle(strapi, async (ctx) => {
1825
1910
  const season2 = await seasonOf(ctx);
@@ -1834,8 +1919,13 @@ const game = ({ strapi }) => {
1834
1919
  rewards: handle(strapi, async (ctx) => {
1835
1920
  const season2 = await seasonOf(ctx);
1836
1921
  const rewards = await svc("reward").list(season2, { includeInactive: false });
1837
- const sets = Object.fromEntries((await svc("set").list(season2)).map((x) => [x.slug, { name: x.name }]));
1838
- ok(ctx, rewards.map((r) => svc("reward").format(r, season2, sets)));
1922
+ const sets = Object.fromEntries(
1923
+ (await svc("set").list(season2)).map((x) => [x.slug, { name: x.name }])
1924
+ );
1925
+ ok(
1926
+ ctx,
1927
+ rewards.map((r) => svc("reward").format(r, season2, sets))
1928
+ );
1839
1929
  }),
1840
1930
  rewardEligibility: handle(strapi, async (ctx) => {
1841
1931
  const season2 = await seasonOf(ctx);
@@ -1844,8 +1934,16 @@ const game = ({ strapi }) => {
1844
1934
  cards: handle(strapi, async (ctx) => {
1845
1935
  const season2 = await seasonOf(ctx);
1846
1936
  const page = parsePagination(ctx.query);
1847
- const { items, total } = await svc("card").list(season2, { rarity: ctx.query.rarity, edition: ctx.query.edition, isActive: true }, page);
1848
- ctx.body = paged(items.map((c) => svc("format").card(c, season2)), total, page);
1937
+ const { items, total } = await svc("card").list(
1938
+ season2,
1939
+ { rarity: ctx.query.rarity, edition: ctx.query.edition, isActive: true },
1940
+ page
1941
+ );
1942
+ ctx.body = paged(
1943
+ items.map((c) => svc("format").card(c, season2)),
1944
+ total,
1945
+ page
1946
+ );
1849
1947
  }),
1850
1948
  card: handle(strapi, async (ctx) => {
1851
1949
  const card2 = await svc("card").findOne(ctx.params.id);
@@ -1856,7 +1954,12 @@ const game = ({ strapi }) => {
1856
1954
  collection: handle(strapi, async (ctx) => {
1857
1955
  const season2 = await seasonOf(ctx);
1858
1956
  const page = parsePagination(ctx.query);
1859
- const { items, total } = await svc("collection").list(ctx.state.tcgPlayer, season2, { rarity: ctx.query.rarity }, page);
1957
+ const { items, total } = await svc("collection").list(
1958
+ ctx.state.tcgPlayer,
1959
+ season2,
1960
+ { rarity: ctx.query.rarity },
1961
+ page
1962
+ );
1860
1963
  ctx.body = paged(items, total, page);
1861
1964
  }),
1862
1965
  collectionStats: handle(strapi, async (ctx) => {
@@ -1881,17 +1984,34 @@ const game = ({ strapi }) => {
1881
1984
  // ── Trades ───────────────────────────────────────────────────────────
1882
1985
  createOffer: handle(strapi, async (ctx) => {
1883
1986
  const season2 = await seasonOf(ctx);
1884
- created(ctx, await svc("trade").createOffer(ctx.state.tcgPlayer, season2, { cardIds: body(ctx).cardIds }));
1987
+ created(
1988
+ ctx,
1989
+ await svc("trade").createOffer(ctx.state.tcgPlayer, season2, { cardIds: body(ctx).cardIds })
1990
+ );
1885
1991
  }),
1886
1992
  scanOffer: handle(strapi, async (ctx) => {
1887
1993
  ok(ctx, await svc("trade").scan(ctx.state.tcgPlayer, String(body(ctx).qrToken ?? "")));
1888
1994
  }),
1889
1995
  getOffer: handle(strapi, async (ctx) => {
1890
1996
  const offer = await svc("trade").findOffer(ctx.params.id);
1891
- ok(ctx, await svc("trade").describe(offer, offer.season));
1997
+ ok(ctx, await svc("trade").describe(offer, offer.season, ctx.state.tcgPlayer));
1892
1998
  }),
1893
- acceptOffer: handle(strapi, async (ctx) => {
1894
- ok(ctx, await svc("trade").accept(ctx.state.tcgPlayer, ctx.params.id, { cardIds: body(ctx).cardIds }));
1999
+ joinOffer: handle(strapi, async (ctx) => {
2000
+ ok(ctx, await svc("trade").join(ctx.state.tcgPlayer, ctx.params.id));
2001
+ }),
2002
+ proposeOffer: handle(strapi, async (ctx) => {
2003
+ ok(
2004
+ ctx,
2005
+ await svc("trade").propose(ctx.state.tcgPlayer, ctx.params.id, {
2006
+ cardIds: body(ctx).cardIds
2007
+ })
2008
+ );
2009
+ }),
2010
+ confirmOffer: handle(strapi, async (ctx) => {
2011
+ ok(ctx, await svc("trade").confirm(ctx.state.tcgPlayer, ctx.params.id));
2012
+ }),
2013
+ declineOffer: handle(strapi, async (ctx) => {
2014
+ ok(ctx, await svc("trade").decline(ctx.state.tcgPlayer, ctx.params.id));
1895
2015
  }),
1896
2016
  rejectOffer: handle(strapi, async (ctx) => {
1897
2017
  ok(ctx, await svc("trade").reject(ctx.state.tcgPlayer, ctx.params.id));
@@ -1913,10 +2033,15 @@ const game = ({ strapi }) => {
1913
2033
  createRedemption: handle(strapi, async (ctx) => {
1914
2034
  const season2 = await seasonOf(ctx);
1915
2035
  const { cardIds, rewardId } = body(ctx);
1916
- created(ctx, await svc("redemption").create(ctx.state.tcgPlayer, season2, { cardIds, rewardId }));
2036
+ created(
2037
+ ctx,
2038
+ await svc("redemption").create(ctx.state.tcgPlayer, season2, { cardIds, rewardId })
2039
+ );
1917
2040
  }),
1918
2041
  getRedemption: handle(strapi, async (ctx) => {
1919
- const rec = await svc("redemption").findOne(ctx.params.id, { forPlayer: ctx.state.tcgPlayer });
2042
+ const rec = await svc("redemption").findOne(ctx.params.id, {
2043
+ forPlayer: ctx.state.tcgPlayer
2044
+ });
1920
2045
  ok(ctx, svc("format").redemption(rec));
1921
2046
  }),
1922
2047
  redemptionHistory: handle(strapi, async (ctx) => {
@@ -2305,7 +2430,12 @@ const contentApi = [
2305
2430
  { method: "GET", path: "/cards/:id", handler: "game.card", config: CATALOG },
2306
2431
  { method: "GET", path: "/live", handler: "game.live", config: CATALOG },
2307
2432
  { method: "GET", path: "/rewards", handler: "game.rewards", config: CATALOG },
2308
- { method: "GET", path: "/rewards/eligibility", handler: "game.rewardEligibility", config: PLAYER },
2433
+ {
2434
+ method: "GET",
2435
+ path: "/rewards/eligibility",
2436
+ handler: "game.rewardEligibility",
2437
+ config: PLAYER
2438
+ },
2309
2439
  { method: "GET", path: "/sets", handler: "game.sets", config: PLAYER },
2310
2440
  { method: "GET", path: "/badges", handler: "game.badges", config: PLAYER },
2311
2441
  // collection
@@ -2321,12 +2451,35 @@ const contentApi = [
2321
2451
  { method: "GET", path: "/trades/active", handler: "game.activeOffers", config: PLAYER },
2322
2452
  { method: "GET", path: "/trades/history", handler: "game.tradeHistory", config: PLAYER },
2323
2453
  { method: "GET", path: "/trades/offer/:id", handler: "game.getOffer", config: PLAYER },
2324
- { method: "POST", path: "/trades/offer/:id/accept", handler: "game.acceptOffer", config: PLAYER },
2454
+ { method: "POST", path: "/trades/offer/:id/join", handler: "game.joinOffer", config: PLAYER },
2455
+ {
2456
+ method: "POST",
2457
+ path: "/trades/offer/:id/propose",
2458
+ handler: "game.proposeOffer",
2459
+ config: PLAYER
2460
+ },
2461
+ {
2462
+ method: "POST",
2463
+ path: "/trades/offer/:id/confirm",
2464
+ handler: "game.confirmOffer",
2465
+ config: PLAYER
2466
+ },
2467
+ {
2468
+ method: "POST",
2469
+ path: "/trades/offer/:id/decline",
2470
+ handler: "game.declineOffer",
2471
+ config: PLAYER
2472
+ },
2325
2473
  { method: "POST", path: "/trades/offer/:id/reject", handler: "game.rejectOffer", config: PLAYER },
2326
2474
  { method: "POST", path: "/trades/offer/:id/cancel", handler: "game.cancelOffer", config: PLAYER },
2327
2475
  // redemptions
2328
2476
  { method: "POST", path: "/redemptions", handler: "game.createRedemption", config: PLAYER },
2329
- { method: "GET", path: "/redemptions/history", handler: "game.redemptionHistory", config: PLAYER },
2477
+ {
2478
+ method: "GET",
2479
+ path: "/redemptions/history",
2480
+ handler: "game.redemptionHistory",
2481
+ config: PLAYER
2482
+ },
2330
2483
  { method: "GET", path: "/redemptions/:id", handler: "game.getRedemption", config: PLAYER }
2331
2484
  ];
2332
2485
  const can = (...actions) => ({
@@ -2495,34 +2648,55 @@ const qr = ({ strapi }) => ({
2495
2648
  },
2496
2649
  /** @param {Object} drop @param {number} ttlMs */
2497
2650
  forDrop(drop2, ttlMs) {
2498
- if (drop2.mode === DROP_MODE.STATIC) return { ...signStaticToken(drop2.documentId, this.secretOf(drop2)), mode: DROP_MODE.STATIC, expiresAt: null };
2499
- return { ...signDropToken(drop2.documentId, this.secretOf(drop2), ttlMs), mode: DROP_MODE.ROTATING };
2651
+ if (drop2.mode === DROP_MODE.STATIC)
2652
+ return {
2653
+ ...signStaticToken(drop2.documentId, this.secretOf(drop2)),
2654
+ mode: DROP_MODE.STATIC,
2655
+ expiresAt: null
2656
+ };
2657
+ return {
2658
+ ...signDropToken(drop2.documentId, this.secretOf(drop2), ttlMs),
2659
+ mode: DROP_MODE.ROTATING
2660
+ };
2500
2661
  },
2501
2662
  forTrade(offer, player2, ttlMs) {
2502
- return signTradeToken(offer.documentId, player2.documentId, strapi.plugin("tcg").service("settings").qrGlobalSecret(), ttlMs);
2663
+ return signTradeToken(
2664
+ offer.documentId,
2665
+ player2.documentId,
2666
+ strapi.plugin("tcg").service("settings").qrGlobalSecret(),
2667
+ ttlMs
2668
+ );
2503
2669
  },
2504
2670
  /**
2505
- * Verifies signature + expiry; burns the nonce for rotating/trade tokens. Throws TcgError.
2671
+ * Verifies signature + expiry; burns the nonce for rotating/trade tokens — per player for a drop code, so
2672
+ * the same displayed code serves everyone around the screen, once each. Throws TcgError.
2506
2673
  * @param {string} token
2507
2674
  * @param {string} secret
2508
2675
  * @param {'drop'|'trade'} kind
2676
+ * @param {{ playerId?: string }} [scope] - the scanning player's documentId (drops)
2509
2677
  */
2510
- async consume(token, secret, kind2) {
2678
+ async consume(token, secret, kind2, scope = {}) {
2511
2679
  const result = verifyToken(token, secret);
2512
2680
  if (!result.valid) {
2513
- if (result.reason === "expired") throw badRequest(ERROR_CODES.QR_EXPIRED, "Ce QR code a expiré.");
2681
+ if (result.reason === "expired")
2682
+ throw badRequest(ERROR_CODES.QR_EXPIRED, "Ce QR code a expiré.");
2514
2683
  throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
2515
2684
  }
2516
2685
  if (result.kind !== kind2) throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
2517
2686
  if (!result.nonce) return result;
2518
- const seen = await strapi.db.query(UID.NONCE).findOne({ where: { nonce: result.nonce }, select: ["id"] });
2519
- if (seen) throw badRequest(ERROR_CODES.QR_REUSED, "Ce QR code a déjà été utilisé.");
2687
+ const key = kind2 === "drop" && scope.playerId ? `${result.nonce}:${scope.playerId}` : result.nonce;
2688
+ const seen = await strapi.db.query(UID.NONCE).findOne({ where: { nonce: key }, select: ["id"] });
2689
+ if (seen) throw badRequest(ERROR_CODES.QR_REUSED, "Vous avez déjà scanné ce QR code.");
2520
2690
  try {
2521
2691
  await strapi.db.query(UID.NONCE).create({
2522
- data: { nonce: result.nonce, kind: kind2 === "drop" ? NONCE_KIND.DROP : NONCE_KIND.TRADE, expiresAt: new Date(result.expiresAt) }
2692
+ data: {
2693
+ nonce: key,
2694
+ kind: kind2 === "drop" ? NONCE_KIND.DROP : NONCE_KIND.TRADE,
2695
+ expiresAt: new Date(result.expiresAt)
2696
+ }
2523
2697
  });
2524
2698
  } catch (err) {
2525
- throw badRequest(ERROR_CODES.QR_REUSED, "Ce QR code a déjà été utilisé.");
2699
+ throw badRequest(ERROR_CODES.QR_REUSED, "Vous avez déjà scanné ce QR code.");
2526
2700
  }
2527
2701
  return result;
2528
2702
  },
@@ -2531,6 +2705,39 @@ const qr = ({ strapi }) => ({
2531
2705
  return count ?? 0;
2532
2706
  }
2533
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;
2534
2741
  const mediaUrl = (media) => media && typeof media === "object" ? media.url ?? null : null;
2535
2742
  const format = () => ({
2536
2743
  rarityMeta(season2, key) {
@@ -2549,6 +2756,7 @@ const format = () => ({
2549
2756
  rarityColor: rarity.color,
2550
2757
  edition: card2.edition ?? "standard",
2551
2758
  score: card2.score ?? 0,
2759
+ points: cardPoints(card2, season2),
2552
2760
  imageUrl: mediaUrl(card2.image),
2553
2761
  attributes: card2.attributes ?? {},
2554
2762
  lore: card2.lore ?? null,
@@ -2610,7 +2818,13 @@ const format = () => ({
2610
2818
  },
2611
2819
  /** Admin-only view: includes msisdn. */
2612
2820
  playerAdmin(player2) {
2613
- 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;
2614
2828
  },
2615
2829
  playerCard(pc, season2) {
2616
2830
  if (!pc) return null;
@@ -2638,7 +2852,16 @@ const format = () => ({
2638
2852
  note: r.note ?? null,
2639
2853
  player: r.player ? this.player(r.player) : void 0,
2640
2854
  season: r.season ? { id: r.season.documentId, name: r.season.name, slug: r.season.slug } : void 0,
2641
- 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
2642
2865
  };
2643
2866
  }
2644
2867
  });
@@ -3041,44 +3264,91 @@ const pull = ({ strapi }) => ({
3041
3264
  async scan(player2, qrToken) {
3042
3265
  const plugin = strapi.plugin("tcg");
3043
3266
  const peek = peekToken(qrToken);
3044
- if (!peek || peek.kind !== "drop") throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3267
+ if (!peek || peek.kind !== "drop")
3268
+ throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3045
3269
  const drop2 = await plugin.service("drop").byDocumentId(peek.dropId);
3046
3270
  const pack2 = drop2.pack;
3047
3271
  const season2 = pack2?.season;
3048
- if (!pack2 || !pack2.isActive) throw unprocessable(ERROR_CODES.PACK_INACTIVE, "Ce pack n'est plus disponible.");
3049
- if (!season2 || season2.status !== "active") throw unprocessable(ERROR_CODES.SEASON_INACTIVE, "Cette saison n'est pas en cours.");
3272
+ if (!pack2 || !pack2.isActive)
3273
+ throw unprocessable(ERROR_CODES.PACK_INACTIVE, "Ce pack n'est plus disponible.");
3274
+ if (!season2 || season2.status !== "active")
3275
+ throw unprocessable(ERROR_CODES.SEASON_INACTIVE, "Cette saison n'est pas en cours.");
3050
3276
  if (peek.mode !== drop2.mode) throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3051
- if (!plugin.service("drop").isOpen(drop2)) throw unprocessable(ERROR_CODES.DROP_CLOSED, "Ce point n'est pas ouvert en ce moment.", { startsAt: drop2.startsAt, endsAt: drop2.endsAt });
3052
- if (drop2.maxScansTotal != null && drop2.scanCount >= drop2.maxScansTotal) throw unprocessable(ERROR_CODES.DROP_SOLD_OUT, "Toutes les cartes de ce point ont été distribuées.");
3277
+ if (!plugin.service("drop").isOpen(drop2))
3278
+ throw unprocessable(ERROR_CODES.DROP_CLOSED, "Ce point n'est pas ouvert en ce moment.", {
3279
+ startsAt: drop2.startsAt,
3280
+ endsAt: drop2.endsAt
3281
+ });
3282
+ if (drop2.maxScansTotal != null && drop2.scanCount >= drop2.maxScansTotal)
3283
+ throw unprocessable(
3284
+ ERROR_CODES.DROP_SOLD_OUT,
3285
+ "Toutes les cartes de ce point ont été distribuées."
3286
+ );
3053
3287
  const limits = plugin.service("season").limitsOf(season2);
3054
- await plugin.service("qr").consume(qrToken, plugin.service("qr").secretOf(drop2), "drop");
3288
+ await plugin.service("qr").consume(qrToken, plugin.service("qr").secretOf(drop2), "drop", {
3289
+ playerId: player2.documentId
3290
+ });
3055
3291
  const now = /* @__PURE__ */ new Date();
3056
3292
  const owned = await strapi.db.query(UID.PLAYER_CARD).count({ where: { player: player2.id, card: { season: season2.id } } });
3057
- if (owned >= limits.maxCollectionSize) throw unprocessable(ERROR_CODES.COLLECTION_FULL, `Collection pleine (${limits.maxCollectionSize} cartes max).`);
3293
+ if (owned >= limits.maxCollectionSize)
3294
+ throw unprocessable(
3295
+ ERROR_CODES.COLLECTION_FULL,
3296
+ `Collection pleine (${limits.maxCollectionSize} cartes max).`
3297
+ );
3058
3298
  const cooldownMs = drop2.cooldownMs ?? (drop2.mode === DROP_MODE.STATIC ? Math.max(limits.pullCooldownMs, limits.staticScanCooldownMs) : limits.pullCooldownMs);
3059
- const lastHere = await strapi.db.query(UID.PULL).findOne({ where: { player: player2.id, drop: drop2.id }, orderBy: { pulledAt: "desc" }, select: ["pulledAt"] });
3299
+ const lastHere = await strapi.db.query(UID.PULL).findOne({
3300
+ where: { player: player2.id, drop: drop2.id, isExtra: false },
3301
+ orderBy: { pulledAt: "desc" },
3302
+ select: ["pulledAt"]
3303
+ });
3060
3304
  if (lastHere) {
3061
3305
  const elapsed = now.getTime() - new Date(lastHere.pulledAt).getTime();
3062
3306
  if (elapsed < cooldownMs) {
3063
3307
  const remainingSeconds = Math.ceil((cooldownMs - elapsed) / 1e3);
3064
- throw unprocessable(ERROR_CODES.COOLDOWN_ACTIVE, remainingSeconds > 3600 ? `Revenez dans ${Math.ceil(remainingSeconds / 3600)} h.` : `Patientez encore ${remainingSeconds} s.`, { remainingSeconds });
3308
+ throw unprocessable(
3309
+ ERROR_CODES.COOLDOWN_ACTIVE,
3310
+ remainingSeconds > 3600 ? `Revenez dans ${Math.ceil(remainingSeconds / 3600)} h.` : `Patientez encore ${remainingSeconds} s.`,
3311
+ { remainingSeconds }
3312
+ );
3065
3313
  }
3066
3314
  }
3067
3315
  if (drop2.maxScansPerPlayer != null) {
3068
- const mine = await strapi.db.query(UID.PULL).count({ where: { player: player2.id, drop: drop2.id } });
3069
- if (mine >= drop2.maxScansPerPlayer) throw unprocessable(ERROR_CODES.DROP_PLAYER_LIMIT, `Vous avez déjà obtenu vos ${drop2.maxScansPerPlayer} carte(s) ici.`);
3316
+ const mine = await strapi.db.query(UID.PULL).count({ where: { player: player2.id, drop: drop2.id, isExtra: false } });
3317
+ if (mine >= drop2.maxScansPerPlayer)
3318
+ throw unprocessable(
3319
+ ERROR_CODES.DROP_PLAYER_LIMIT,
3320
+ `Vous avez déjà obtenu vos ${drop2.maxScansPerPlayer} carte(s) ici.`
3321
+ );
3070
3322
  }
3071
3323
  const since = startOfToday$1();
3072
3324
  const [packToday, totalToday] = await Promise.all([
3073
- strapi.db.query(UID.PULL).count({ where: { player: player2.id, pack: pack2.id, pulledAt: { $gte: since } } }),
3074
- strapi.db.query(UID.PULL).count({ where: { player: player2.id, season: season2.id, pulledAt: { $gte: since } } })
3325
+ strapi.db.query(UID.PULL).count({
3326
+ where: { player: player2.id, pack: pack2.id, pulledAt: { $gte: since }, isExtra: false }
3327
+ }),
3328
+ strapi.db.query(UID.PULL).count({
3329
+ where: {
3330
+ player: player2.id,
3331
+ season: season2.id,
3332
+ pulledAt: { $gte: since },
3333
+ isExtra: false
3334
+ }
3335
+ })
3075
3336
  ]);
3076
- if (packToday >= limits.maxDailyPullsPerPack) throw unprocessable(ERROR_CODES.DAILY_LIMIT_PACK, `Limite atteinte pour ce pack aujourd'hui (${limits.maxDailyPullsPerPack}).`);
3077
- if (totalToday >= limits.maxDailyPullsTotal) throw unprocessable(ERROR_CODES.DAILY_LIMIT_TOTAL, `Limite quotidienne atteinte (${limits.maxDailyPullsTotal}).`);
3337
+ if (packToday >= limits.maxDailyPullsPerPack)
3338
+ throw unprocessable(
3339
+ ERROR_CODES.DAILY_LIMIT_PACK,
3340
+ `Limite atteinte pour ce pack aujourd'hui (${limits.maxDailyPullsPerPack}).`
3341
+ );
3342
+ if (totalToday >= limits.maxDailyPullsTotal)
3343
+ throw unprocessable(
3344
+ ERROR_CODES.DAILY_LIMIT_TOTAL,
3345
+ `Limite quotidienne atteinte (${limits.maxDailyPullsTotal}).`
3346
+ );
3078
3347
  const raw = await plugin.service("drop").rawPool(drop2, pack2);
3079
3348
  const exclusiveIds = await plugin.service("drop").exclusiveCardIds(season2);
3080
3349
  let pool = prunePool(raw, exclusiveIds);
3081
- if (pool.length === 0) throw unprocessable(ERROR_CODES.PACK_EMPTY, "Aucune carte disponible ici pour le moment.");
3350
+ if (pool.length === 0)
3351
+ throw unprocessable(ERROR_CODES.PACK_EMPTY, "Aucune carte disponible ici pour le moment.");
3082
3352
  const boosts = await plugin.service("boost").activeFor(season2, pack2, drop2);
3083
3353
  const multipliers = plugin.service("boost").combine(boosts);
3084
3354
  const recent = await strapi.db.query(UID.PULL).findMany({
@@ -3088,65 +3358,113 @@ const pull = ({ strapi }) => ({
3088
3358
  select: ["rarity"]
3089
3359
  });
3090
3360
  const minRarity = pityFloor(recent, season2.rarities ?? [], limits);
3091
- let result = null;
3092
- let mintNumber = null;
3093
- for (let attempt = 0; attempt < MAX_PULL_RETRIES && pool.length; attempt += 1) {
3094
- result = pull$1({ playerId: player2.documentId, dropId: drop2.documentId, pullNumber: totalToday + 1 + attempt, pool, rarities: season2.rarities ?? [], multipliers, minRarity });
3095
- if (!result.card) break;
3096
- mintNumber = await this.claimSlot(UID.CARD, result.card.id, "minted", "supplyCap");
3097
- if (mintNumber != null) break;
3098
- pool = pool.filter((c) => c.id !== result.card.id);
3099
- result = null;
3100
- }
3101
- if (!result?.card || mintNumber == null) throw unprocessable(ERROR_CODES.PACK_EMPTY, "Plus de cartes disponibles ici.");
3102
- if (drop2.maxScansTotal != null) {
3103
- const slot = await this.claimSlot(UID.DROP, drop2.id, "scanCount", "maxScansTotal");
3104
- if (slot == null) throw unprocessable(ERROR_CODES.DROP_SOLD_OUT, "Toutes les cartes de ce point ont été distribuées.");
3105
- } else {
3106
- await this.claimSlot(UID.DROP, drop2.id, "scanCount", null);
3107
- }
3108
- const alreadyOwned = await strapi.db.query(UID.PLAYER_CARD).count({ where: { player: player2.id, card: result.card.id } });
3109
- const playerCard2 = await strapi.db.transaction(async () => {
3110
- const pc = await strapi.db.query(UID.PLAYER_CARD).create({
3111
- data: { player: player2.id, card: result.card.id, pack: pack2.id, acquiredFrom: ACQUISITION.PULL, acquiredAt: now, mintNumber }
3112
- });
3113
- await strapi.db.query(UID.PULL).create({
3114
- data: {
3115
- player: player2.id,
3116
- pack: pack2.id,
3117
- drop: drop2.id,
3118
- card: result.card.id,
3119
- season: season2.id,
3120
- seed: String(result.seed),
3121
- roll: result.roll,
3122
- rarity: result.rarity,
3123
- verificationHash: result.verificationHash,
3124
- qrToken: peek.mode === DROP_MODE.STATIC ? null : qrToken,
3125
- pulledAt: now,
3126
- odds: result.odds,
3127
- pity: result.pity,
3128
- mintNumber
3361
+ const count = Math.max(1, Math.min(10, Number(drop2.cardsPerPull) || 1));
3362
+ const format2 = plugin.service("format");
3363
+ const cards = [];
3364
+ for (let n = 0; n < count; n += 1) {
3365
+ let result = null;
3366
+ let mintNumber = null;
3367
+ for (let attempt = 0; attempt < MAX_PULL_RETRIES && pool.length; attempt += 1) {
3368
+ result = pull$1({
3369
+ playerId: player2.documentId,
3370
+ dropId: drop2.documentId,
3371
+ pullNumber: totalToday + 1 + n * MAX_PULL_RETRIES + attempt,
3372
+ pool,
3373
+ rarities: season2.rarities ?? [],
3374
+ multipliers,
3375
+ minRarity: n === 0 ? minRarity : null
3376
+ });
3377
+ if (!result.card) break;
3378
+ mintNumber = await this.claimSlot(UID.CARD, result.card.id, "minted", "supplyCap");
3379
+ if (mintNumber != null) break;
3380
+ pool = pool.filter((c) => c.id !== result.card.id);
3381
+ result = null;
3382
+ }
3383
+ if (!result?.card || mintNumber == null) {
3384
+ if (n === 0) throw unprocessable(ERROR_CODES.PACK_EMPTY, "Plus de cartes disponibles ici.");
3385
+ break;
3386
+ }
3387
+ if (n === 0) {
3388
+ if (drop2.maxScansTotal != null) {
3389
+ const slot = await this.claimSlot(UID.DROP, drop2.id, "scanCount", "maxScansTotal");
3390
+ if (slot == null)
3391
+ throw unprocessable(
3392
+ ERROR_CODES.DROP_SOLD_OUT,
3393
+ "Toutes les cartes de ce point ont été distribuées."
3394
+ );
3395
+ } else {
3396
+ await this.claimSlot(UID.DROP, drop2.id, "scanCount", null);
3129
3397
  }
3398
+ }
3399
+ const alreadyOwned = await strapi.db.query(UID.PLAYER_CARD).count({ where: { player: player2.id, card: result.card.id } });
3400
+ const playerCard2 = await strapi.db.transaction(async () => {
3401
+ const pc = await strapi.db.query(UID.PLAYER_CARD).create({
3402
+ data: {
3403
+ player: player2.id,
3404
+ card: result.card.id,
3405
+ pack: pack2.id,
3406
+ acquiredFrom: ACQUISITION.PULL,
3407
+ acquiredAt: now,
3408
+ mintNumber
3409
+ }
3410
+ });
3411
+ await strapi.db.query(UID.PULL).create({
3412
+ data: {
3413
+ player: player2.id,
3414
+ pack: pack2.id,
3415
+ drop: drop2.id,
3416
+ card: result.card.id,
3417
+ season: season2.id,
3418
+ seed: String(result.seed),
3419
+ roll: result.roll,
3420
+ rarity: result.rarity,
3421
+ verificationHash: result.verificationHash,
3422
+ qrToken: peek.mode === DROP_MODE.STATIC ? null : qrToken,
3423
+ pulledAt: now,
3424
+ odds: result.odds,
3425
+ pity: result.pity,
3426
+ mintNumber,
3427
+ isExtra: n > 0
3428
+ }
3429
+ });
3430
+ await strapi.db.query(UID.PLAYER).update({
3431
+ where: { id: player2.id },
3432
+ data: { totalCards: (player2.totalCards ?? 0) + n + 1, lastActiveAt: now }
3433
+ });
3434
+ return pc;
3130
3435
  });
3131
- await strapi.db.query(UID.PLAYER).update({ where: { id: player2.id }, data: { totalCards: (player2.totalCards ?? 0) + 1, lastActiveAt: now } });
3132
- return pc;
3133
- });
3134
- strapi.log.info(`[tcg] pull ${player2.code} ← ${result.card.name} #${mintNumber} (${result.rarity}${result.pity ? ", pity" : ""}) @ ${pack2.slug}/${drop2.slug}${boosts.length ? ` boosts=${boosts.map((b) => b.name).join("+")}` : ""}`);
3135
- const format2 = plugin.service("format");
3136
- const unlocked = await plugin.service("badge").evaluate(player2, season2, { event: "pull", card: result.card.documentId });
3137
- return {
3138
- playerCardId: playerCard2.documentId,
3139
- card: { ...format2.card(result.card, season2), mintNumber },
3140
- pull: {
3436
+ cards.push({
3437
+ playerCardId: playerCard2.documentId,
3438
+ card: { ...format2.card(result.card, season2), mintNumber },
3141
3439
  rarity: result.rarity,
3142
- isNew: alreadyOwned === 0,
3440
+ isNew: alreadyOwned === 0 && !cards.some((c) => c.card.id === result.card.documentId),
3143
3441
  pity: result.pity,
3144
3442
  odds: result.odds,
3145
- boosts: boosts.map((b) => ({ name: b.name, multipliers: b.multipliers })),
3146
3443
  isExclusive: Boolean(result.card.isExclusive),
3444
+ verificationHash: result.verificationHash,
3445
+ mintNumber
3446
+ });
3447
+ strapi.log.info(
3448
+ `[tcg] pull ${player2.code} ← ${result.card.name} #${mintNumber} (${result.rarity}${result.pity ? ", pity" : ""}${n > 0 ? ", extra" : ""}) @ ${pack2.slug}/${drop2.slug}${boosts.length ? ` boosts=${boosts.map((b) => b.name).join("+")}` : ""}`
3449
+ );
3450
+ }
3451
+ const first = cards[0];
3452
+ const unlocked = await plugin.service("badge").evaluate(player2, season2, { event: "pull", card: first.card.id });
3453
+ return {
3454
+ playerCardId: first.playerCardId,
3455
+ card: first.card,
3456
+ cards,
3457
+ pull: {
3458
+ rarity: first.rarity,
3459
+ isNew: first.isNew,
3460
+ pity: first.pity,
3461
+ odds: first.odds,
3462
+ boosts: boosts.map((b) => ({ name: b.name, multipliers: b.multipliers })),
3463
+ isExclusive: first.isExclusive,
3464
+ cardCount: cards.length,
3147
3465
  pack: { id: pack2.documentId, name: pack2.name, slug: pack2.slug },
3148
3466
  drop: { id: drop2.documentId, name: drop2.name, slug: drop2.slug },
3149
- verificationHash: result.verificationHash,
3467
+ verificationHash: first.verificationHash,
3150
3468
  pulledAt: now
3151
3469
  },
3152
3470
  unlocked
@@ -3155,7 +3473,13 @@ const pull = ({ strapi }) => ({
3155
3473
  async history(player2, season2, page) {
3156
3474
  const where = { player: player2.id, season: season2.id };
3157
3475
  const [items, total] = await Promise.all([
3158
- strapi.db.query(UID.PULL).findMany({ where, populate: { card: { populate: { image: true } }, pack: true, drop: true }, orderBy: { pulledAt: "desc" }, offset: page.offset, limit: page.limit }),
3476
+ strapi.db.query(UID.PULL).findMany({
3477
+ where,
3478
+ populate: { card: { populate: { image: true } }, pack: true, drop: true },
3479
+ orderBy: { pulledAt: "desc" },
3480
+ offset: page.offset,
3481
+ limit: page.limit
3482
+ }),
3159
3483
  strapi.db.query(UID.PULL).count({ where })
3160
3484
  ]);
3161
3485
  const format2 = strapi.plugin("tcg").service("format");
@@ -3180,7 +3504,11 @@ const pull = ({ strapi }) => ({
3180
3504
  const limits = plugin.service("season").limitsOf(season2);
3181
3505
  const packs = await plugin.service("pack").list(season2);
3182
3506
  const since = startOfToday$1();
3183
- const todays = await strapi.db.query(UID.PULL).findMany({ where: { player: player2.id, season: season2.id, pulledAt: { $gte: since } }, populate: { pack: { select: ["id"] } }, select: ["pulledAt"] });
3507
+ const todays = await strapi.db.query(UID.PULL).findMany({
3508
+ where: { player: player2.id, season: season2.id, pulledAt: { $gte: since }, isExtra: false },
3509
+ populate: { pack: { select: ["id"] } },
3510
+ select: ["pulledAt"]
3511
+ });
3184
3512
  const countByPack = {};
3185
3513
  for (const p of todays) countByPack[p.pack?.id] = (countByPack[p.pack?.id] ?? 0) + 1;
3186
3514
  const totalToday = todays.length;
@@ -3190,10 +3518,21 @@ const pull = ({ strapi }) => ({
3190
3518
  const drops = await plugin.service("drop").list(pack2, { includeInactive: false });
3191
3519
  const dropStates = [];
3192
3520
  for (const drop2 of drops) {
3193
- const last = await strapi.db.query(UID.PULL).findOne({ where: { player: player2.id, drop: drop2.id }, orderBy: { pulledAt: "desc" }, select: ["pulledAt"] });
3521
+ const last = await strapi.db.query(UID.PULL).findOne({
3522
+ where: { player: player2.id, drop: drop2.id, isExtra: false },
3523
+ orderBy: { pulledAt: "desc" },
3524
+ select: ["pulledAt"]
3525
+ });
3194
3526
  const cooldownMs = drop2.cooldownMs ?? (drop2.mode === DROP_MODE.STATIC ? Math.max(limits.pullCooldownMs, limits.staticScanCooldownMs) : limits.pullCooldownMs);
3195
3527
  const remaining = last ? Math.max(0, cooldownMs - (now - new Date(last.pulledAt).getTime())) : 0;
3196
- dropStates.push({ drop: { id: drop2.documentId, name: drop2.name, slug: drop2.slug }, isOpen: plugin.service("drop").isOpen(drop2), cooldownRemainingMs: remaining, cooldownRemainingSeconds: Math.ceil(remaining / 1e3) });
3528
+ dropStates.push({
3529
+ drop: { id: drop2.documentId, name: drop2.name, slug: drop2.slug },
3530
+ isOpen: plugin.service("drop").isOpen(drop2),
3531
+ cooldownRemainingMs: remaining,
3532
+ cooldownRemainingSeconds: Math.ceil(remaining / 1e3),
3533
+ cooldownUntil: remaining > 0 ? new Date(now + remaining) : null,
3534
+ cardsPerPull: Math.max(1, Number(drop2.cardsPerPull) || 1)
3535
+ });
3197
3536
  }
3198
3537
  const pullsToday = countByPack[pack2.id] ?? 0;
3199
3538
  const anyReady = dropStates.some((d) => d.isOpen && d.cooldownRemainingMs === 0);
@@ -3234,22 +3573,11 @@ const collection = ({ strapi }) => ({
3234
3573
  return [...new Set(rows.map((r) => r.card?.id).filter(Boolean))];
3235
3574
  },
3236
3575
  /**
3237
- * Global rank = 1 + number of players owning more distinct season cards. Uses the link tables
3238
- * 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.
3239
3578
  */
3240
- async rank(player2, season2, myUnique) {
3241
- if (myUnique === 0) return null;
3242
- const knex = strapi.db.connection;
3243
- const md = strapi.db.metadata.get(UID.PLAYER_CARD);
3244
- const pcTable = md.tableName;
3245
- const playerLnk = md.attributes.player.joinTable;
3246
- const cardLnk = md.attributes.card.joinTable;
3247
- const cardMd = strapi.db.metadata.get(UID.CARD);
3248
- const seasonLnk = cardMd.attributes.season.joinTable;
3249
- const seasonId = season2.id;
3250
- 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");
3251
- const row = await knex.from(sub.as("ranked")).where("ranked.unique_cards", ">", myUnique).count("* as better").first();
3252
- return Number(row?.better ?? 0) + 1;
3579
+ async rank(player2, season2) {
3580
+ return (await strapi.plugin("tcg").service("stats").standingFor(player2, season2)).rank;
3253
3581
  },
3254
3582
  async stats(player2, season2) {
3255
3583
  const plugin = strapi.plugin("tcg");
@@ -3285,16 +3613,26 @@ const collection = ({ strapi }) => ({
3285
3613
  populate: { pack: { select: ["id"] }, card: { select: ["id"] } }
3286
3614
  });
3287
3615
  const byPack = packs.map((p) => {
3288
- 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
+ ];
3289
3621
  const collected = cardIds.filter((id) => ownedIds.has(id)).length;
3290
- 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
+ };
3291
3628
  });
3292
3629
  const tier = plugin.service("season").tierFor(season2, completion);
3293
- const [sets, badges, metrics, live] = await Promise.all([
3630
+ const [sets, badges, metrics, live, standing] = await Promise.all([
3294
3631
  plugin.service("set").list(season2, { includeInactive: false }),
3295
3632
  plugin.service("badge").forPlayer(player2, season2),
3296
3633
  plugin.service("badge").metrics(player2, season2),
3297
- plugin.service("boost").liveNow(season2)
3634
+ plugin.service("boost").liveNow(season2),
3635
+ plugin.service("stats").standingFor(player2, season2)
3298
3636
  ]);
3299
3637
  return {
3300
3638
  sets: plugin.service("set").progress(sets, ownedIds, season2),
@@ -3305,7 +3643,13 @@ const collection = ({ strapi }) => ({
3305
3643
  uniqueCards: ownedIds.size,
3306
3644
  catalogTotal,
3307
3645
  completionPercentage: completion,
3308
- 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
+ },
3309
3653
  rankTier: { key: tier.key, label: tier.label },
3310
3654
  season: format2.season(season2),
3311
3655
  byRarity,
@@ -3319,7 +3663,14 @@ const collection = ({ strapi }) => ({
3319
3663
  const mintNumber = await strapi.plugin("tcg").service("pull").claimSlot(UID.CARD, card2.id, "minted", null);
3320
3664
  const pc = await strapi.db.transaction(async () => {
3321
3665
  const row = await strapi.db.query(UID.PLAYER_CARD).create({
3322
- 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
+ }
3323
3674
  });
3324
3675
  await strapi.db.query(UID.PLAYER).update({ where: { id: player2.id }, data: { totalCards: (player2.totalCards ?? 0) + 1 } });
3325
3676
  return row;
@@ -3331,28 +3682,46 @@ const trade = ({ strapi }) => ({
3331
3682
  /** Player-card rows owned by `player` among `documentIds`; throws if any is missing. */
3332
3683
  async ownedRows(player2, documentIds, season2) {
3333
3684
  const ids = [...new Set((documentIds ?? []).map(String))];
3334
- if (ids.length === 0) throw badRequest(ERROR_CODES.VALIDATION, "Sélectionnez au moins une carte.");
3685
+ if (ids.length === 0)
3686
+ throw badRequest(ERROR_CODES.VALIDATION, "Sélectionnez au moins une carte.");
3335
3687
  const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3336
- where: { player: player2.id, documentId: { $in: ids }, ...season2 ? { card: { season: season2.id } } : {} },
3688
+ where: {
3689
+ player: player2.id,
3690
+ documentId: { $in: ids },
3691
+ ...season2 ? { card: { season: season2.id } } : {}
3692
+ },
3337
3693
  populate: { card: { populate: { image: true } } }
3338
3694
  });
3339
- if (rows.length !== ids.length) throw badRequest(ERROR_CODES.CARD_NOT_OWNED, "Vous ne possédez pas toutes ces cartes.");
3695
+ if (rows.length !== ids.length)
3696
+ throw badRequest(ERROR_CODES.CARD_NOT_OWNED, "Vous ne possédez pas toutes ces cartes.");
3340
3697
  return rows;
3341
3698
  },
3342
3699
  async createOffer(player2, season2, { cardIds }) {
3343
3700
  const plugin = strapi.plugin("tcg");
3344
3701
  const limits = plugin.service("season").limitsOf(season2);
3345
3702
  if ((cardIds ?? []).length > limits.maxCardsPerTrade) {
3346
- throw unprocessable(ERROR_CODES.TRADE_TOO_MANY_CARDS, `Maximum ${limits.maxCardsPerTrade} cartes par échange.`);
3703
+ throw unprocessable(
3704
+ ERROR_CODES.TRADE_TOO_MANY_CARDS,
3705
+ `Maximum ${limits.maxCardsPerTrade} cartes par échange.`
3706
+ );
3347
3707
  }
3348
- const active = await strapi.db.query(UID.TRADE_OFFER).count({ where: { initiator: player2.id, status: TRADE_STATUS.PENDING } });
3708
+ const active = await strapi.db.query(UID.TRADE_OFFER).count({ where: { initiator: player2.id, status: { $in: TRADE_OPEN_STATUSES } } });
3349
3709
  if (active >= limits.maxActiveTradeOffers) {
3350
- throw unprocessable(ERROR_CODES.TRADE_LIMIT, `Maximum ${limits.maxActiveTradeOffers} offres actives.`);
3710
+ throw unprocessable(
3711
+ ERROR_CODES.TRADE_LIMIT,
3712
+ `Maximum ${limits.maxActiveTradeOffers} offres actives.`
3713
+ );
3351
3714
  }
3352
3715
  const rows = await this.ownedRows(player2, cardIds, season2);
3353
3716
  const expiresAt = new Date(Date.now() + limits.tradeOfferExpiryMs);
3354
3717
  const offer = await strapi.db.query(UID.TRADE_OFFER).create({
3355
- data: { initiator: player2.id, season: season2.id, status: TRADE_STATUS.PENDING, offeredCardIds: rows.map((r) => r.documentId), expiresAt }
3718
+ data: {
3719
+ initiator: player2.id,
3720
+ season: season2.id,
3721
+ status: TRADE_STATUS.PENDING,
3722
+ offeredCardIds: rows.map((r) => r.documentId),
3723
+ expiresAt
3724
+ }
3356
3725
  });
3357
3726
  const { token } = plugin.service("qr").forTrade(offer, player2, limits.tradeOfferExpiryMs);
3358
3727
  await strapi.db.query(UID.TRADE_OFFER).update({ where: { id: offer.id }, data: { qrToken: token } });
@@ -3368,68 +3737,184 @@ const trade = ({ strapi }) => ({
3368
3737
  async findOffer(documentId) {
3369
3738
  const offer = await strapi.db.query(UID.TRADE_OFFER).findOne({
3370
3739
  where: { documentId },
3371
- populate: { initiator: true, season: true }
3740
+ populate: { initiator: true, receiver: true, season: true }
3372
3741
  });
3373
3742
  if (!offer) throw notFound(ERROR_CODES.TRADE_NOT_FOUND, "Offre introuvable.");
3374
3743
  return offer;
3375
3744
  },
3745
+ /** Alive and not past its expiry; otherwise the status names why. */
3376
3746
  assertOpen(offer) {
3377
- if (offer.status !== TRADE_STATUS.PENDING) throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, `Cette offre est ${offer.status}.`);
3378
- if (/* @__PURE__ */ new Date() > new Date(offer.expiresAt)) throw unprocessable(ERROR_CODES.TRADE_EXPIRED, "Cette offre a expiré.");
3747
+ if (!TRADE_OPEN_STATUSES.includes(offer.status))
3748
+ throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, `Cette offre est ${offer.status}.`);
3749
+ if (/* @__PURE__ */ new Date() > new Date(offer.expiresAt))
3750
+ throw unprocessable(ERROR_CODES.TRADE_EXPIRED, "Cette offre a expiré.");
3379
3751
  },
3380
- async describe(offer, season2) {
3752
+ async cardsOf(ids, season2) {
3381
3753
  const format2 = strapi.plugin("tcg").service("format");
3754
+ if (!ids?.length) return [];
3382
3755
  const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3383
- where: { documentId: { $in: offer.offeredCardIds ?? [] } },
3756
+ where: { documentId: { $in: ids } },
3384
3757
  populate: { card: { populate: { image: true } } }
3385
3758
  });
3759
+ return rows.map((r) => format2.playerCard(r, season2));
3760
+ },
3761
+ /**
3762
+ * The offer as both players see it: who is on each side, what each side puts in, where the
3763
+ * handshake stands. `role` is the caller's seat when a player is given.
3764
+ */
3765
+ async describe(offer, season2, viewer = null) {
3766
+ const [offeredCards, receiverCards] = await Promise.all([
3767
+ this.cardsOf(offer.offeredCardIds, season2),
3768
+ this.cardsOf(offer.receiverCardIds, season2)
3769
+ ]);
3770
+ const who = (p) => p ? { code: p.code, displayName: p.displayName } : null;
3771
+ const role = !viewer ? null : offer.initiator?.id === viewer.id ? "initiator" : offer.receiver?.id === viewer.id ? "receiver" : null;
3386
3772
  return {
3387
3773
  id: offer.documentId,
3388
3774
  status: offer.status,
3389
3775
  expiresAt: offer.expiresAt,
3390
- initiator: offer.initiator ? { code: offer.initiator.code, displayName: offer.initiator.displayName } : null,
3391
- offeredCards: rows.map((r) => format2.playerCard(r, season2))
3776
+ initiator: who(offer.initiator),
3777
+ receiver: who(offer.receiver),
3778
+ offeredCards,
3779
+ receiverCards,
3780
+ joinedAt: offer.joinedAt ?? null,
3781
+ proposedAt: offer.proposedAt ?? null,
3782
+ declinedAt: offer.declinedAt ?? null,
3783
+ role
3392
3784
  };
3393
3785
  },
3394
- /** The receiver scans the initiator's QR. The token is NOT burned here; acceptance burns it. */
3786
+ /** The receiver scans the initiator's signed QR: same as joining by id. */
3395
3787
  async scan(player2, qrToken) {
3396
3788
  const peek = peekToken(qrToken);
3397
- if (!peek || peek.kind !== "trade") throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3398
- if (peek.playerId === player2.documentId) throw badRequest(ERROR_CODES.TRADE_SELF, "Vous ne pouvez pas scanner votre propre offre.");
3789
+ if (!peek || peek.kind !== "trade")
3790
+ throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3399
3791
  const offer = await this.findOffer(peek.offerId);
3400
3792
  if (offer.qrToken !== qrToken) throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3401
- this.assertOpen(offer);
3402
- return this.describe(offer, offer.season);
3793
+ return this.join(player2, offer.documentId);
3403
3794
  },
3404
3795
  /**
3405
- * Atomic swap. `cardIds` are the receiver's player-card documentIds.
3796
+ * A receiver takes the seat opposite the initiator. Idempotent for the same receiver; refused
3797
+ * while another receiver holds the seat, so two phones cannot answer one QR at once.
3406
3798
  */
3407
- async accept(receiver, offerDocumentId, { cardIds }) {
3799
+ async join(player2, offerDocumentId) {
3800
+ const offer = await this.findOffer(offerDocumentId);
3801
+ if (offer.initiator?.id === player2.id)
3802
+ throw badRequest(ERROR_CODES.TRADE_SELF, "Vous ne pouvez pas rejoindre votre propre offre.");
3803
+ this.assertOpen(offer);
3804
+ if (offer.receiver && offer.receiver.id !== player2.id) {
3805
+ throw unprocessable(
3806
+ ERROR_CODES.TRADE_INVALID_STATUS,
3807
+ "Un autre joueur est déjà sur cette offre."
3808
+ );
3809
+ }
3810
+ if (!offer.receiver) {
3811
+ await strapi.db.query(UID.TRADE_OFFER).update({
3812
+ where: { id: offer.id },
3813
+ data: {
3814
+ receiver: player2.id,
3815
+ status: TRADE_STATUS.JOINED,
3816
+ joinedAt: /* @__PURE__ */ new Date(),
3817
+ receiverCardIds: null,
3818
+ declinedAt: null
3819
+ }
3820
+ });
3821
+ }
3822
+ return this.describe(await this.findOffer(offerDocumentId), offer.season, player2);
3823
+ },
3824
+ /** The receiver says what they give. Replaces a previous proposal; the initiator must confirm. */
3825
+ async propose(player2, offerDocumentId, { cardIds }) {
3408
3826
  const plugin = strapi.plugin("tcg");
3409
3827
  const offer = await this.findOffer(offerDocumentId);
3828
+ if (offer.initiator?.id === player2.id)
3829
+ throw badRequest(
3830
+ ERROR_CODES.TRADE_SELF,
3831
+ "Vous ne pouvez pas proposer sur votre propre offre."
3832
+ );
3410
3833
  this.assertOpen(offer);
3411
- if (offer.initiator?.id === receiver.id) throw badRequest(ERROR_CODES.TRADE_SELF, "Vous ne pouvez pas accepter votre propre offre.");
3834
+ if (offer.receiver && offer.receiver.id !== player2.id) {
3835
+ throw unprocessable(
3836
+ ERROR_CODES.TRADE_INVALID_STATUS,
3837
+ "Un autre joueur est déjà sur cette offre."
3838
+ );
3839
+ }
3412
3840
  const season2 = offer.season;
3413
3841
  const limits = plugin.service("season").limitsOf(season2);
3414
3842
  if ((cardIds ?? []).length > limits.maxCardsPerTrade) {
3415
- throw unprocessable(ERROR_CODES.TRADE_TOO_MANY_CARDS, `Maximum ${limits.maxCardsPerTrade} cartes par échange.`);
3843
+ throw unprocessable(
3844
+ ERROR_CODES.TRADE_TOO_MANY_CARDS,
3845
+ `Maximum ${limits.maxCardsPerTrade} cartes par échange.`
3846
+ );
3847
+ }
3848
+ const rows = await this.ownedRows(player2, cardIds, season2);
3849
+ await strapi.db.query(UID.TRADE_OFFER).update({
3850
+ where: { id: offer.id },
3851
+ data: {
3852
+ receiver: player2.id,
3853
+ status: TRADE_STATUS.PROPOSED,
3854
+ receiverCardIds: rows.map((r) => r.documentId),
3855
+ joinedAt: offer.joinedAt ?? /* @__PURE__ */ new Date(),
3856
+ proposedAt: /* @__PURE__ */ new Date(),
3857
+ declinedAt: null
3858
+ }
3859
+ });
3860
+ return this.describe(await this.findOffer(offerDocumentId), season2, player2);
3861
+ },
3862
+ /** The initiator sends the proposal back; the receiver keeps the seat and can propose again. */
3863
+ async decline(player2, offerDocumentId) {
3864
+ const offer = await this.findOffer(offerDocumentId);
3865
+ if (offer.initiator?.id !== player2.id)
3866
+ throw forbidden(
3867
+ ERROR_CODES.TRADE_FORBIDDEN,
3868
+ "Seul l’auteur de l’offre peut refuser une proposition."
3869
+ );
3870
+ this.assertOpen(offer);
3871
+ if (offer.status !== TRADE_STATUS.PROPOSED)
3872
+ throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, "Aucune proposition à refuser.");
3873
+ await strapi.db.query(UID.TRADE_OFFER).update({
3874
+ where: { id: offer.id },
3875
+ data: { status: TRADE_STATUS.JOINED, receiverCardIds: null, declinedAt: /* @__PURE__ */ new Date() }
3876
+ });
3877
+ return this.describe(await this.findOffer(offerDocumentId), offer.season, player2);
3878
+ },
3879
+ /**
3880
+ * The initiator confirms the proposal: the atomic swap. Both players have said yes by now —
3881
+ * the receiver by proposing, the initiator here.
3882
+ */
3883
+ async confirm(initiator, offerDocumentId) {
3884
+ const plugin = strapi.plugin("tcg");
3885
+ const offer = await this.findOffer(offerDocumentId);
3886
+ if (offer.initiator?.id !== initiator.id)
3887
+ throw forbidden(
3888
+ ERROR_CODES.TRADE_FORBIDDEN,
3889
+ "Seul l’auteur de l’offre peut confirmer l’échange."
3890
+ );
3891
+ this.assertOpen(offer);
3892
+ if (offer.status !== TRADE_STATUS.PROPOSED || !offer.receiver)
3893
+ throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, "Aucune proposition à confirmer.");
3894
+ const receiver = offer.receiver;
3895
+ const season2 = offer.season;
3896
+ const receiverRows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3897
+ where: { player: receiver.id, documentId: { $in: offer.receiverCardIds ?? [] } }
3898
+ });
3899
+ if (receiverRows.length !== (offer.receiverCardIds ?? []).length || receiverRows.length === 0) {
3900
+ throw unprocessable(ERROR_CODES.CARD_NOT_OWNED, "L'autre joueur ne possède plus ces cartes.");
3416
3901
  }
3417
- const receiverRows = await this.ownedRows(receiver, cardIds, season2);
3418
3902
  const initiatorRows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3419
- where: { player: offer.initiator.id, documentId: { $in: offer.offeredCardIds ?? [] } }
3903
+ where: { player: initiator.id, documentId: { $in: offer.offeredCardIds ?? [] } }
3420
3904
  });
3421
3905
  if (initiatorRows.length !== (offer.offeredCardIds ?? []).length) {
3422
- throw unprocessable(ERROR_CODES.CARD_NOT_OWNED, "L'autre joueur ne possède plus ces cartes.");
3906
+ throw unprocessable(ERROR_CODES.CARD_NOT_OWNED, "Vous ne possédez plus ces cartes.");
3423
3907
  }
3424
3908
  const now = /* @__PURE__ */ new Date();
3425
3909
  const trade2 = await strapi.db.transaction(async () => {
3426
3910
  const fresh = await strapi.db.query(UID.TRADE_OFFER).findOne({ where: { id: offer.id }, select: ["status"] });
3427
- if (fresh.status !== TRADE_STATUS.PENDING) throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, "Offre déjà traitée.");
3911
+ if (fresh.status !== TRADE_STATUS.PROPOSED)
3912
+ throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, "Offre déjà traitée.");
3428
3913
  await strapi.db.query(UID.TRADE_OFFER).update({ where: { id: offer.id }, data: { status: TRADE_STATUS.ACCEPTED } });
3429
3914
  const t = await strapi.db.query(UID.TRADE).create({
3430
3915
  data: {
3431
3916
  offer: offer.id,
3432
- initiator: offer.initiator.id,
3917
+ initiator: initiator.id,
3433
3918
  receiver: receiver.id,
3434
3919
  season: season2.id,
3435
3920
  initiatorCardIds: initiatorRows.map((r) => r.documentId),
@@ -3438,53 +3923,127 @@ const trade = ({ strapi }) => ({
3438
3923
  }
3439
3924
  });
3440
3925
  for (const row of initiatorRows) {
3441
- await strapi.db.query(UID.PLAYER_CARD).update({ where: { id: row.id }, data: { player: receiver.id, acquiredFrom: ACQUISITION.TRADE, acquiredAt: now, sourceTrade: t.id } });
3926
+ await strapi.db.query(UID.PLAYER_CARD).update({
3927
+ where: { id: row.id },
3928
+ data: {
3929
+ player: receiver.id,
3930
+ acquiredFrom: ACQUISITION.TRADE,
3931
+ acquiredAt: now,
3932
+ sourceTrade: t.id
3933
+ }
3934
+ });
3442
3935
  }
3443
3936
  for (const row of receiverRows) {
3444
- await strapi.db.query(UID.PLAYER_CARD).update({ where: { id: row.id }, data: { player: offer.initiator.id, acquiredFrom: ACQUISITION.TRADE, acquiredAt: now, sourceTrade: t.id } });
3937
+ await strapi.db.query(UID.PLAYER_CARD).update({
3938
+ where: { id: row.id },
3939
+ data: {
3940
+ player: initiator.id,
3941
+ acquiredFrom: ACQUISITION.TRADE,
3942
+ acquiredAt: now,
3943
+ sourceTrade: t.id
3944
+ }
3945
+ });
3445
3946
  }
3446
3947
  const delta = receiverRows.length - initiatorRows.length;
3447
3948
  await strapi.db.query(UID.PLAYER).update({
3448
- where: { id: offer.initiator.id },
3449
- data: { totalTrades: (offer.initiator.totalTrades ?? 0) + 1, totalCards: Math.max(0, (offer.initiator.totalCards ?? 0) + delta), lastActiveAt: now }
3949
+ where: { id: initiator.id },
3950
+ data: {
3951
+ totalTrades: (initiator.totalTrades ?? 0) + 1,
3952
+ totalCards: Math.max(0, (initiator.totalCards ?? 0) + delta),
3953
+ lastActiveAt: now
3954
+ }
3450
3955
  });
3451
3956
  await strapi.db.query(UID.PLAYER).update({
3452
3957
  where: { id: receiver.id },
3453
- data: { totalTrades: (receiver.totalTrades ?? 0) + 1, totalCards: Math.max(0, (receiver.totalCards ?? 0) - delta), lastActiveAt: now }
3958
+ data: {
3959
+ totalTrades: (receiver.totalTrades ?? 0) + 1,
3960
+ totalCards: Math.max(0, (receiver.totalCards ?? 0) - delta),
3961
+ lastActiveAt: now
3962
+ }
3454
3963
  });
3455
3964
  return t;
3456
3965
  });
3457
- strapi.log.info(`[tcg] trade ${offer.initiator.code} ⇄ ${receiver.code} (${initiatorRows.length}:${receiverRows.length})`);
3966
+ strapi.log.info(
3967
+ `[tcg] trade ${initiator.code} ⇄ ${receiver.code} (${initiatorRows.length}:${receiverRows.length})`
3968
+ );
3458
3969
  const badge2 = plugin.service("badge");
3459
- const [unlocked] = await Promise.all([badge2.evaluate(receiver, season2, { event: "trade" }), badge2.evaluate(offer.initiator, season2, { event: "trade" })]);
3460
- return { id: trade2.documentId, offerId: offer.documentId, completedAt: now, received: initiatorRows.length, given: receiverRows.length, unlocked };
3970
+ const [unlocked, receiverUnlocked] = await Promise.all([
3971
+ badge2.evaluate(initiator, season2, { event: "trade" }),
3972
+ badge2.evaluate(receiver, season2, { event: "trade" })
3973
+ ]);
3974
+ return {
3975
+ id: trade2.documentId,
3976
+ offerId: offer.documentId,
3977
+ completedAt: now,
3978
+ received: receiverRows.length,
3979
+ given: initiatorRows.length,
3980
+ unlocked,
3981
+ receiverUnlocked
3982
+ };
3461
3983
  },
3984
+ /** The receiver leaves: the seat frees up, the initiator's QR stays valid for someone else. */
3462
3985
  async reject(player2, offerDocumentId) {
3463
3986
  const offer = await this.findOffer(offerDocumentId);
3987
+ if (offer.initiator?.id === player2.id)
3988
+ throw badRequest(ERROR_CODES.TRADE_SELF, "Utilisez « annuler » pour votre propre offre.");
3989
+ if (!offer.receiver || offer.receiver.id !== player2.id)
3990
+ throw forbidden(ERROR_CODES.TRADE_FORBIDDEN, "Vous n’êtes pas sur cette offre.");
3464
3991
  this.assertOpen(offer);
3465
- if (offer.initiator?.id === player2.id) throw badRequest(ERROR_CODES.TRADE_SELF, "Utilisez « annuler » pour votre propre offre.");
3466
- await strapi.db.query(UID.TRADE_OFFER).update({ where: { id: offer.id }, data: { status: TRADE_STATUS.REJECTED } });
3992
+ await strapi.db.query(UID.TRADE_OFFER).update({
3993
+ where: { id: offer.id },
3994
+ data: {
3995
+ status: TRADE_STATUS.PENDING,
3996
+ receiver: null,
3997
+ receiverCardIds: null,
3998
+ joinedAt: null,
3999
+ proposedAt: null,
4000
+ declinedAt: null
4001
+ }
4002
+ });
3467
4003
  return { ok: true };
3468
4004
  },
3469
4005
  async cancel(player2, offerDocumentId) {
3470
4006
  const offer = await this.findOffer(offerDocumentId);
3471
- if (offer.initiator?.id !== player2.id) throw forbidden(ERROR_CODES.TRADE_FORBIDDEN, "Vous ne pouvez annuler que vos offres.");
3472
- if (offer.status !== TRADE_STATUS.PENDING) throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, `Cette offre est déjà ${offer.status}.`);
4007
+ if (offer.initiator?.id !== player2.id)
4008
+ throw forbidden(ERROR_CODES.TRADE_FORBIDDEN, "Vous ne pouvez annuler que vos offres.");
4009
+ if (!TRADE_OPEN_STATUSES.includes(offer.status))
4010
+ throw unprocessable(
4011
+ ERROR_CODES.TRADE_INVALID_STATUS,
4012
+ `Cette offre est déjà ${offer.status}.`
4013
+ );
3473
4014
  await strapi.db.query(UID.TRADE_OFFER).update({ where: { id: offer.id }, data: { status: TRADE_STATUS.CANCELLED } });
3474
4015
  return { ok: true };
3475
4016
  },
4017
+ /** The player's live offers, on either seat, newest first — what a reopened screen resumes. */
3476
4018
  async activeOffers(player2, season2) {
3477
4019
  const offers = await strapi.db.query(UID.TRADE_OFFER).findMany({
3478
- where: { initiator: player2.id, season: season2.id, status: TRADE_STATUS.PENDING, expiresAt: { $gt: /* @__PURE__ */ new Date() } },
3479
- populate: { initiator: true },
4020
+ where: {
4021
+ season: season2.id,
4022
+ status: { $in: TRADE_OPEN_STATUSES },
4023
+ expiresAt: { $gt: /* @__PURE__ */ new Date() },
4024
+ $or: [{ initiator: player2.id }, { receiver: player2.id }]
4025
+ },
4026
+ populate: { initiator: true, receiver: true },
3480
4027
  orderBy: { createdAt: "desc" }
3481
4028
  });
3482
- return Promise.all(offers.map((o) => this.describe(o, season2).then((d) => ({ ...d, qrToken: o.qrToken }))));
4029
+ return Promise.all(
4030
+ offers.map(
4031
+ (o) => this.describe(o, season2, player2).then(
4032
+ (d) => o.initiator?.id === player2.id ? { ...d, qrToken: o.qrToken } : d
4033
+ )
4034
+ )
4035
+ );
3483
4036
  },
3484
4037
  async history(player2, season2, page) {
3485
4038
  const where = { season: season2.id, $or: [{ initiator: player2.id }, { receiver: player2.id }] };
3486
4039
  const [items, total] = await Promise.all([
3487
- strapi.db.query(UID.TRADE).findMany({ where, populate: { initiator: true, receiver: true }, orderBy: { completedAt: "desc" }, offset: page.offset, limit: page.limit }),
4040
+ strapi.db.query(UID.TRADE).findMany({
4041
+ where,
4042
+ populate: { initiator: true, receiver: true },
4043
+ orderBy: { completedAt: "desc" },
4044
+ offset: page.offset,
4045
+ limit: page.limit
4046
+ }),
3488
4047
  strapi.db.query(UID.TRADE).count({ where })
3489
4048
  ]);
3490
4049
  return {
@@ -3492,17 +4051,20 @@ const trade = ({ strapi }) => ({
3492
4051
  id: t.documentId,
3493
4052
  completedAt: t.completedAt,
3494
4053
  role: t.initiator?.id === player2.id ? "initiator" : "receiver",
3495
- counterpart: (t.initiator?.id === player2.id ? t.receiver : t.initiator) ? { code: (t.initiator?.id === player2.id ? t.receiver : t.initiator).code, displayName: (t.initiator?.id === player2.id ? t.receiver : t.initiator).displayName } : null,
4054
+ counterpart: (t.initiator?.id === player2.id ? t.receiver : t.initiator) ? {
4055
+ code: (t.initiator?.id === player2.id ? t.receiver : t.initiator).code,
4056
+ displayName: (t.initiator?.id === player2.id ? t.receiver : t.initiator).displayName
4057
+ } : null,
3496
4058
  given: (t.initiator?.id === player2.id ? t.initiatorCardIds : t.receiverCardIds) ?? [],
3497
4059
  received: (t.initiator?.id === player2.id ? t.receiverCardIds : t.initiatorCardIds) ?? []
3498
4060
  })),
3499
4061
  total
3500
4062
  };
3501
4063
  },
3502
- /** Marks pending offers past their expiry. */
4064
+ /** Marks live offers past their expiry. */
3503
4065
  async sweep() {
3504
4066
  const { count } = await strapi.db.query(UID.TRADE_OFFER).updateMany({
3505
- where: { status: TRADE_STATUS.PENDING, expiresAt: { $lt: /* @__PURE__ */ new Date() } },
4067
+ where: { status: { $in: TRADE_OPEN_STATUSES }, expiresAt: { $lt: /* @__PURE__ */ new Date() } },
3506
4068
  data: { status: TRADE_STATUS.EXPIRED }
3507
4069
  });
3508
4070
  return count ?? 0;
@@ -3623,6 +4185,8 @@ const redemption = ({ strapi }) => ({
3623
4185
  return { items: items.map((r) => format2.redemption(r)), total };
3624
4186
  }
3625
4187
  });
4188
+ const STANDINGS_TTL_MS = 10 * 1e3;
4189
+ const STANDINGS_CACHE = /* @__PURE__ */ new Map();
3626
4190
  const startOfToday = () => {
3627
4191
  const d = /* @__PURE__ */ new Date();
3628
4192
  d.setHours(0, 0, 0, 0);
@@ -3657,15 +4221,31 @@ const stats = ({ strapi }) => ({
3657
4221
  strapi.db.query(UID.PULL).count({ where: { ...seasonWhere, pulledAt: { $gte: since } } }),
3658
4222
  strapi.db.query(UID.PULL).count({ where: seasonWhere }),
3659
4223
  strapi.db.query(UID.TRADE).count({ where: { ...seasonWhere, completedAt: { $gte: since } } }),
3660
- 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
+ }),
3661
4227
  strapi.db.query(UID.REDEMPTION).count({ where: { ...seasonWhere, status: REDEMPTION_STATUS.PENDING } }),
3662
- 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
+ }),
3663
4235
  season2 ? strapi.db.query(UID.CARD).count({ where: { season: season2.id, isActive: true } }) : Promise.resolve(0),
3664
4236
  season2 ? strapi.db.query(UID.PACK).count({ where: { season: season2.id, isActive: true } }) : Promise.resolve(0),
3665
- 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
+ }),
3666
4241
  strapi.db.query(UID.PULL).findMany({
3667
4242
  where: seasonWhere,
3668
- 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
+ },
3669
4249
  orderBy: { pulledAt: "desc" },
3670
4250
  limit: 8
3671
4251
  })
@@ -3674,24 +4254,47 @@ const stats = ({ strapi }) => ({
3674
4254
  const day = daysAgo(6 - i);
3675
4255
  const next = new Date(day);
3676
4256
  next.setDate(day.getDate() + 1);
3677
- 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
+ };
3678
4261
  });
3679
4262
  const rarities = season2?.rarities ?? [];
3680
4263
  const plugin = strapi.plugin("tcg");
3681
4264
  const [liveBoosts, rewards, drops] = season2 ? await Promise.all([
3682
4265
  plugin.service("boost").liveNow(season2),
3683
4266
  plugin.service("reward").list(season2, { includeInactive: false }),
3684
- 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
+ })
3685
4271
  ]) : [[], [], []];
3686
- 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);
3687
- 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
+ }));
3688
4286
  return {
3689
4287
  liveBoosts,
3690
4288
  lowStock,
3691
4289
  drops: dropStates,
3692
4290
  season: season2 ? { id: season2.documentId, name: season2.name, slug: season2.slug, status: season2.status } : null,
3693
4291
  players: { total: players, today: playersToday },
3694
- 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
+ },
3695
4298
  trades: { today: tradesToday, pendingOffers },
3696
4299
  redemptions: { pending: pendingRedemptions, fulfilledToday },
3697
4300
  catalog: { cards, packs },
@@ -3708,21 +4311,75 @@ const stats = ({ strapi }) => ({
3708
4311
  }))
3709
4312
  };
3710
4313
  },
3711
- /** Top collectors by distinct cards owned in a season (admin leaderboard). */
3712
- async leaderboard(season2, limit = 10) {
3713
- const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3714
- where: { card: { season: season2.id } },
3715
- populate: { player: { select: ["id", "documentId", "code", "displayName"] }, card: { select: ["id"] } }
3716
- });
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);
3717
4334
  const byPlayer = /* @__PURE__ */ new Map();
3718
4335
  for (const r of rows) {
3719
4336
  if (!r.player || !r.card) continue;
3720
- const entry = byPlayer.get(r.player.id) ?? { player: r.player, unique: /* @__PURE__ */ new Set(), total: 0 };
3721
- 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);
3722
4339
  entry.total += 1;
3723
4340
  byPlayer.set(r.player.id, entry);
3724
4341
  }
3725
- 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
+ }));
3726
4383
  },
3727
4384
  /** Recent pulls for the audit screen. */
3728
4385
  async pulls(season2, { playerCode, rarity }, page) {
@@ -3732,7 +4389,12 @@ const stats = ({ strapi }) => ({
3732
4389
  const [items, total] = await Promise.all([
3733
4390
  strapi.db.query(UID.PULL).findMany({
3734
4391
  where,
3735
- 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
+ },
3736
4398
  orderBy: { pulledAt: "desc" },
3737
4399
  offset: page.offset,
3738
4400
  limit: page.limit
@@ -3784,10 +4446,16 @@ const drop = ({ strapi }) => ({
3784
4446
  return drop2;
3785
4447
  },
3786
4448
  validate(data) {
3787
- if (data.mode && !Object.values(DROP_MODE).includes(data.mode)) throw badRequest(ERROR_CODES.VALIDATION, "Mode invalide.");
3788
- if (data.poolMode && !Object.values(POOL_MODE).includes(data.poolMode)) throw badRequest(ERROR_CODES.VALIDATION, "Mode de pool invalide.");
4449
+ if (data.mode && !Object.values(DROP_MODE).includes(data.mode))
4450
+ throw badRequest(ERROR_CODES.VALIDATION, "Mode invalide.");
4451
+ if (data.poolMode && !Object.values(POOL_MODE).includes(data.poolMode))
4452
+ throw badRequest(ERROR_CODES.VALIDATION, "Mode de pool invalide.");
3789
4453
  for (const k of ["maxScansTotal", "maxScansPerPlayer", "cooldownMs"]) {
3790
- if (data[k] != null && data[k] !== "" && !(Number(data[k]) >= 0)) throw badRequest(ERROR_CODES.VALIDATION, `${k} invalide.`);
4454
+ if (data[k] != null && data[k] !== "" && !(Number(data[k]) >= 0))
4455
+ throw badRequest(ERROR_CODES.VALIDATION, `${k} invalide.`);
4456
+ }
4457
+ if (data.cardsPerPull != null && data.cardsPerPull !== "" && !(Number.isInteger(Number(data.cardsPerPull)) && Number(data.cardsPerPull) >= 1 && Number(data.cardsPerPull) <= 10)) {
4458
+ throw badRequest(ERROR_CODES.VALIDATION, "cardsPerPull doit être entre 1 et 10.");
3791
4459
  }
3792
4460
  },
3793
4461
  async create(pack2, data) {
@@ -3810,13 +4478,19 @@ const drop = ({ strapi }) => ({
3810
4478
  async ensureDefault(pack2) {
3811
4479
  const existing = await strapi.db.query(UID.DROP).findOne({ where: { pack: pack2.id, isDefault: true } });
3812
4480
  if (existing) return existing;
3813
- return this.create(pack2, { name: DEFAULT_DROP_NAME, mode: DROP_MODE.STATIC, isDefault: true, poolMode: POOL_MODE.INHERIT });
4481
+ return this.create(pack2, {
4482
+ name: DEFAULT_DROP_NAME,
4483
+ mode: DROP_MODE.STATIC,
4484
+ isDefault: true,
4485
+ poolMode: POOL_MODE.INHERIT
4486
+ });
3814
4487
  },
3815
4488
  async update(pack2, ref, data) {
3816
4489
  const drop2 = await this.findOne(pack2, ref);
3817
4490
  this.validate(data);
3818
4491
  const { cards, pack: _p, secret: _s, scanCount: _c, isDefault: _d, ...rest } = data;
3819
- if (rest.slug && rest.slug !== drop2.slug) rest.slug = await uniqueSlug(strapi, UID.DROP, rest.slug, { pack: pack2.id });
4492
+ if (rest.slug && rest.slug !== drop2.slug)
4493
+ rest.slug = await uniqueSlug(strapi, UID.DROP, rest.slug, { pack: pack2.id });
3820
4494
  const updated = await strapi.documents(UID.DROP).update({ documentId: drop2.documentId, data: rest });
3821
4495
  if (Array.isArray(cards)) await this.setCards(pack2, drop2.documentId, cards);
3822
4496
  return updated;
@@ -3828,9 +4502,17 @@ const drop = ({ strapi }) => ({
3828
4502
  },
3829
4503
  async remove(pack2, ref) {
3830
4504
  const drop2 = await this.findOne(pack2, ref);
3831
- if (drop2.isDefault) throw badRequest(ERROR_CODES.VALIDATION, "Le point de scan par défaut ne peut pas être supprimé : désactivez-le.");
4505
+ if (drop2.isDefault)
4506
+ throw badRequest(
4507
+ ERROR_CODES.VALIDATION,
4508
+ "Le point de scan par défaut ne peut pas être supprimé : désactivez-le."
4509
+ );
3832
4510
  const pulls = await strapi.db.query(UID.PULL).count({ where: { drop: drop2.id } });
3833
- if (pulls > 0) throw badRequest(ERROR_CODES.VALIDATION, "Ce point a des tirages : désactivez-le au lieu de le supprimer.");
4511
+ if (pulls > 0)
4512
+ throw badRequest(
4513
+ ERROR_CODES.VALIDATION,
4514
+ "Ce point a des tirages : désactivez-le au lieu de le supprimer."
4515
+ );
3834
4516
  await strapi.db.query(UID.DROP_CARD).deleteMany({ where: { drop: drop2.id } });
3835
4517
  await strapi.documents(UID.DROP).delete({ documentId: drop2.documentId });
3836
4518
  return { ok: true };
@@ -3839,15 +4521,28 @@ const drop = ({ strapi }) => ({
3839
4521
  const drop2 = await this.findOne(pack2, dropRef);
3840
4522
  const season2 = pack2.season?.id ? pack2.season : await strapi.db.query(UID.PACK).findOne({ where: { id: pack2.id }, populate: { season: true } }).then((p) => p.season);
3841
4523
  const docIds = [...new Set(entries.map((e) => typeof e === "string" ? e : e.card))];
3842
- const cards = docIds.length ? await strapi.db.query(UID.CARD).findMany({ where: { documentId: { $in: docIds }, season: season2.id }, select: ["id", "documentId"] }) : [];
3843
- if (cards.length !== docIds.length) throw badRequest(ERROR_CODES.VALIDATION, "Certaines cartes ne font pas partie de cette saison.");
4524
+ const cards = docIds.length ? await strapi.db.query(UID.CARD).findMany({
4525
+ where: { documentId: { $in: docIds }, season: season2.id },
4526
+ select: ["id", "documentId"]
4527
+ }) : [];
4528
+ if (cards.length !== docIds.length)
4529
+ throw badRequest(
4530
+ ERROR_CODES.VALIDATION,
4531
+ "Certaines cartes ne font pas partie de cette saison."
4532
+ );
3844
4533
  const idByDoc = Object.fromEntries(cards.map((c) => [c.documentId, c.id]));
3845
4534
  await strapi.db.transaction(async () => {
3846
4535
  await strapi.db.query(UID.DROP_CARD).deleteMany({ where: { drop: drop2.id } });
3847
4536
  for (const e of entries) {
3848
4537
  const entry = typeof e === "string" ? { card: e } : e;
3849
4538
  await strapi.db.query(UID.DROP_CARD).create({
3850
- data: { drop: drop2.id, card: idByDoc[entry.card], weight: Number.isFinite(Number(entry.weight)) ? Number(entry.weight) : 100, isExclusive: Boolean(entry.isExclusive), isActive: entry.isActive !== false }
4539
+ data: {
4540
+ drop: drop2.id,
4541
+ card: idByDoc[entry.card],
4542
+ weight: Number.isFinite(Number(entry.weight)) ? Number(entry.weight) : 100,
4543
+ isExclusive: Boolean(entry.isExclusive),
4544
+ isActive: entry.isActive !== false
4545
+ }
3851
4546
  });
3852
4547
  }
3853
4548
  });
@@ -3855,8 +4550,18 @@ const drop = ({ strapi }) => ({
3855
4550
  },
3856
4551
  async cards(pack2, ref) {
3857
4552
  const drop2 = await this.findOne(pack2, ref);
3858
- const rows = await strapi.db.query(UID.DROP_CARD).findMany({ where: { drop: drop2.id }, populate: { card: { populate: { image: true } } }, orderBy: { id: "asc" } });
3859
- return rows.filter((r) => r.card).map((r) => ({ id: r.documentId, weight: r.weight, isExclusive: r.isExclusive, isActive: r.isActive, card: r.card }));
4553
+ const rows = await strapi.db.query(UID.DROP_CARD).findMany({
4554
+ where: { drop: drop2.id },
4555
+ populate: { card: { populate: { image: true } } },
4556
+ orderBy: { id: "asc" }
4557
+ });
4558
+ return rows.filter((r) => r.card).map((r) => ({
4559
+ id: r.documentId,
4560
+ weight: r.weight,
4561
+ isExclusive: r.isExclusive,
4562
+ isActive: r.isActive,
4563
+ card: r.card
4564
+ }));
3860
4565
  },
3861
4566
  /**
3862
4567
  * Effective raw pool for a drop (before exclusivity/supply pruning): pack pool, drop pool, or both.
@@ -3870,25 +4575,46 @@ const drop = ({ strapi }) => ({
3870
4575
  });
3871
4576
  const ownEntries = own.filter((r) => r.card).map((r) => ({ ...r.card, weight: r.weight, isExclusive: r.isExclusive, source: "drop" }));
3872
4577
  const seen = new Set(ownEntries.map((c) => c.id));
3873
- return [...ownEntries, ...packPool.filter((c) => !seen.has(c.id)).map((c) => ({ ...c, source: "pack" }))];
4578
+ return [
4579
+ ...ownEntries,
4580
+ ...packPool.filter((c) => !seen.has(c.id)).map((c) => ({ ...c, source: "pack" }))
4581
+ ];
3874
4582
  },
3875
4583
  /** Card ids flagged exclusive on any pack-card or drop-card of a season. */
3876
4584
  async exclusiveCardIds(season2) {
3877
4585
  const [pc, dc] = await Promise.all([
3878
- strapi.db.query(UID.PACK_CARD).findMany({ where: { isExclusive: true, isActive: true, pack: { season: season2.id } }, populate: { card: { select: ["id"] } } }),
3879
- strapi.db.query(UID.DROP_CARD).findMany({ where: { isExclusive: true, isActive: true, drop: { pack: { season: season2.id } } }, populate: { card: { select: ["id"] } } })
4586
+ strapi.db.query(UID.PACK_CARD).findMany({
4587
+ where: { isExclusive: true, isActive: true, pack: { season: season2.id } },
4588
+ populate: { card: { select: ["id"] } }
4589
+ }),
4590
+ strapi.db.query(UID.DROP_CARD).findMany({
4591
+ where: { isExclusive: true, isActive: true, drop: { pack: { season: season2.id } } },
4592
+ populate: { card: { select: ["id"] } }
4593
+ })
3880
4594
  ]);
3881
4595
  return new Set([...pc, ...dc].map((r) => r.card?.id).filter(Boolean));
3882
4596
  },
3883
4597
  /** Where a card is exclusive, for the admin: [{ kind:'pack'|'drop', name, slug }]. */
3884
4598
  async exclusivityOf(cardId) {
3885
4599
  const [pc, dc] = await Promise.all([
3886
- strapi.db.query(UID.PACK_CARD).findMany({ where: { card: cardId, isExclusive: true }, populate: { pack: { select: ["name", "slug"] } } }),
3887
- strapi.db.query(UID.DROP_CARD).findMany({ where: { card: cardId, isExclusive: true }, populate: { drop: { select: ["name", "slug"], populate: { pack: { select: ["name"] } } } } })
4600
+ strapi.db.query(UID.PACK_CARD).findMany({
4601
+ where: { card: cardId, isExclusive: true },
4602
+ populate: { pack: { select: ["name", "slug"] } }
4603
+ }),
4604
+ strapi.db.query(UID.DROP_CARD).findMany({
4605
+ where: { card: cardId, isExclusive: true },
4606
+ populate: {
4607
+ drop: { select: ["name", "slug"], populate: { pack: { select: ["name"] } } }
4608
+ }
4609
+ })
3888
4610
  ]);
3889
4611
  return [
3890
4612
  ...pc.filter((r) => r.pack).map((r) => ({ kind: "pack", name: r.pack.name, slug: r.pack.slug })),
3891
- ...dc.filter((r) => r.drop).map((r) => ({ kind: "drop", name: `${r.drop.pack?.name ?? ""} · ${r.drop.name}`, slug: r.drop.slug }))
4613
+ ...dc.filter((r) => r.drop).map((r) => ({
4614
+ kind: "drop",
4615
+ name: `${r.drop.pack?.name ?? ""} · ${r.drop.name}`,
4616
+ slug: r.drop.slug
4617
+ }))
3892
4618
  ];
3893
4619
  },
3894
4620
  /** Booth / poster token. */
@@ -3901,7 +4627,10 @@ const drop = ({ strapi }) => ({
3901
4627
  /** Public shape, exclusives revealed as count (+ names unless the drop is secret). */
3902
4628
  async publicView(drop2, pack2, season2) {
3903
4629
  const format2 = strapi.plugin("tcg").service("format");
3904
- const own = drop2.poolMode === POOL_MODE.INHERIT ? [] : await strapi.db.query(UID.DROP_CARD).findMany({ where: { drop: drop2.id, isActive: true, isExclusive: true }, populate: { card: true } });
4630
+ const own = drop2.poolMode === POOL_MODE.INHERIT ? [] : await strapi.db.query(UID.DROP_CARD).findMany({
4631
+ where: { drop: drop2.id, isActive: true, isExclusive: true },
4632
+ populate: { card: true }
4633
+ });
3905
4634
  return {
3906
4635
  id: drop2.documentId,
3907
4636
  name: drop2.name,
@@ -3910,12 +4639,18 @@ const drop = ({ strapi }) => ({
3910
4639
  mode: drop2.mode,
3911
4640
  isDefault: drop2.isDefault,
3912
4641
  isOpen: this.isOpen(drop2),
4642
+ cardsPerPull: Math.max(1, Number(drop2.cardsPerPull) || 1),
3913
4643
  startsAt: drop2.startsAt ?? null,
3914
4644
  endsAt: drop2.endsAt ?? null,
3915
4645
  soldOut: drop2.maxScansTotal != null && drop2.scanCount >= drop2.maxScansTotal,
3916
4646
  remaining: drop2.maxScansTotal != null ? Math.max(0, drop2.maxScansTotal - drop2.scanCount) : null,
3917
4647
  exclusiveCount: own.length,
3918
- exclusiveCards: drop2.isSecret ? [] : own.filter((r) => r.card).map((r) => ({ id: r.card.documentId, name: r.card.name, rarity: r.card.rarity, rarityColor: format2.rarityMeta(season2, r.card.rarity).color })),
4648
+ exclusiveCards: drop2.isSecret ? [] : own.filter((r) => r.card).map((r) => ({
4649
+ id: r.card.documentId,
4650
+ name: r.card.name,
4651
+ rarity: r.card.rarity,
4652
+ rarityColor: format2.rarityMeta(season2, r.card.rarity).color
4653
+ })),
3919
4654
  geo: drop2.geo ?? null
3920
4655
  };
3921
4656
  }