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