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

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" },
@@ -568,6 +608,12 @@ const attributes$f = {
568
608
  cooldownMs: {
569
609
  type: "integer"
570
610
  },
611
+ cardsPerPull: {
612
+ type: "integer",
613
+ min: 1,
614
+ max: 10,
615
+ "default": 1
616
+ },
571
617
  scanCount: {
572
618
  type: "integer",
573
619
  "default": 0,
@@ -1424,6 +1470,10 @@ const attributes$4 = {
1424
1470
  },
1425
1471
  mintNumber: {
1426
1472
  type: "integer"
1473
+ },
1474
+ isExtra: {
1475
+ type: "boolean",
1476
+ "default": false
1427
1477
  }
1428
1478
  };
1429
1479
  const schema$4 = {
@@ -1441,7 +1491,7 @@ const info$3 = {
1441
1491
  singularName: "trade-offer",
1442
1492
  pluralName: "trade-offers",
1443
1493
  displayName: "TCG Trade Offer",
1444
- description: "A pending P2P proposal carried by a QR code."
1494
+ description: "A P2P exchange: the initiator's QR, a receiver who joins and proposes cards, the initiator's confirmation."
1445
1495
  };
1446
1496
  const options$3 = {
1447
1497
  draftAndPublish: false
@@ -1469,6 +1519,8 @@ const attributes$3 = {
1469
1519
  type: "enumeration",
1470
1520
  "enum": [
1471
1521
  "pending",
1522
+ "joined",
1523
+ "proposed",
1472
1524
  "accepted",
1473
1525
  "rejected",
1474
1526
  "expired",
@@ -1488,6 +1540,23 @@ const attributes$3 = {
1488
1540
  expiresAt: {
1489
1541
  type: "datetime",
1490
1542
  required: true
1543
+ },
1544
+ receiver: {
1545
+ type: "relation",
1546
+ relation: "manyToOne",
1547
+ target: "plugin::tcg.player"
1548
+ },
1549
+ receiverCardIds: {
1550
+ type: "json"
1551
+ },
1552
+ joinedAt: {
1553
+ type: "datetime"
1554
+ },
1555
+ proposedAt: {
1556
+ type: "datetime"
1557
+ },
1558
+ declinedAt: {
1559
+ type: "datetime"
1491
1560
  }
1492
1561
  };
1493
1562
  const schema$3 = {
@@ -1776,7 +1845,10 @@ const game = ({ strapi }) => {
1776
1845
  return {
1777
1846
  // ── Player ───────────────────────────────────────────────────────────
1778
1847
  me: handle(strapi, async (ctx) => {
1779
- ok(ctx, { ...svc("format").player(ctx.state.tcgPlayer), isNew: Boolean(ctx.state.tcgPlayerIsNew) });
1848
+ ok(ctx, {
1849
+ ...svc("format").player(ctx.state.tcgPlayer),
1850
+ isNew: Boolean(ctx.state.tcgPlayerIsNew)
1851
+ });
1780
1852
  }),
1781
1853
  updateMe: handle(strapi, async (ctx) => {
1782
1854
  const { displayName, avatarUrl } = body(ctx);
@@ -1794,12 +1866,19 @@ const game = ({ strapi }) => {
1794
1866
  packs: handle(strapi, async (ctx) => {
1795
1867
  const season2 = await seasonOf(ctx);
1796
1868
  const packs = await svc("pack").list(season2);
1797
- ok(ctx, packs.map((p) => svc("format").pack(p)));
1869
+ ok(
1870
+ ctx,
1871
+ packs.map((p) => svc("format").pack(p))
1872
+ );
1798
1873
  }),
1799
1874
  pack: handle(strapi, async (ctx) => {
1800
1875
  const season2 = await seasonOf(ctx);
1801
1876
  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)]);
1877
+ const [pool, drops, exclusiveIds] = await Promise.all([
1878
+ svc("pack").pool(pack2),
1879
+ svc("drop").list(pack2, { includeInactive: false }),
1880
+ svc("drop").exclusiveCardIds(season2)
1881
+ ]);
1803
1882
  ok(ctx, {
1804
1883
  ...svc("format").pack(pack2),
1805
1884
  cards: pool.filter((c) => !exclusiveIds.has(c.id) || c.isExclusive).map((c) => ({ ...svc("format").card(c, season2), isExclusive: c.isExclusive })),
@@ -1819,7 +1898,11 @@ const game = ({ strapi }) => {
1819
1898
  windows.push({ ...v, pack: { id: pack2.documentId, name: pack2.name, slug: pack2.slug } });
1820
1899
  }
1821
1900
  }
1822
- ok(ctx, { boosts: await svc("boost").liveNow(season2), drops: windows, serverTime: /* @__PURE__ */ new Date() });
1901
+ ok(ctx, {
1902
+ boosts: await svc("boost").liveNow(season2),
1903
+ drops: windows,
1904
+ serverTime: /* @__PURE__ */ new Date()
1905
+ });
1823
1906
  }),
1824
1907
  sets: handle(strapi, async (ctx) => {
1825
1908
  const season2 = await seasonOf(ctx);
@@ -1834,8 +1917,13 @@ const game = ({ strapi }) => {
1834
1917
  rewards: handle(strapi, async (ctx) => {
1835
1918
  const season2 = await seasonOf(ctx);
1836
1919
  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)));
1920
+ const sets = Object.fromEntries(
1921
+ (await svc("set").list(season2)).map((x) => [x.slug, { name: x.name }])
1922
+ );
1923
+ ok(
1924
+ ctx,
1925
+ rewards.map((r) => svc("reward").format(r, season2, sets))
1926
+ );
1839
1927
  }),
1840
1928
  rewardEligibility: handle(strapi, async (ctx) => {
1841
1929
  const season2 = await seasonOf(ctx);
@@ -1844,8 +1932,16 @@ const game = ({ strapi }) => {
1844
1932
  cards: handle(strapi, async (ctx) => {
1845
1933
  const season2 = await seasonOf(ctx);
1846
1934
  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);
1935
+ const { items, total } = await svc("card").list(
1936
+ season2,
1937
+ { rarity: ctx.query.rarity, edition: ctx.query.edition, isActive: true },
1938
+ page
1939
+ );
1940
+ ctx.body = paged(
1941
+ items.map((c) => svc("format").card(c, season2)),
1942
+ total,
1943
+ page
1944
+ );
1849
1945
  }),
1850
1946
  card: handle(strapi, async (ctx) => {
1851
1947
  const card2 = await svc("card").findOne(ctx.params.id);
@@ -1856,7 +1952,12 @@ const game = ({ strapi }) => {
1856
1952
  collection: handle(strapi, async (ctx) => {
1857
1953
  const season2 = await seasonOf(ctx);
1858
1954
  const page = parsePagination(ctx.query);
1859
- const { items, total } = await svc("collection").list(ctx.state.tcgPlayer, season2, { rarity: ctx.query.rarity }, page);
1955
+ const { items, total } = await svc("collection").list(
1956
+ ctx.state.tcgPlayer,
1957
+ season2,
1958
+ { rarity: ctx.query.rarity },
1959
+ page
1960
+ );
1860
1961
  ctx.body = paged(items, total, page);
1861
1962
  }),
1862
1963
  collectionStats: handle(strapi, async (ctx) => {
@@ -1881,17 +1982,34 @@ const game = ({ strapi }) => {
1881
1982
  // ── Trades ───────────────────────────────────────────────────────────
1882
1983
  createOffer: handle(strapi, async (ctx) => {
1883
1984
  const season2 = await seasonOf(ctx);
1884
- created(ctx, await svc("trade").createOffer(ctx.state.tcgPlayer, season2, { cardIds: body(ctx).cardIds }));
1985
+ created(
1986
+ ctx,
1987
+ await svc("trade").createOffer(ctx.state.tcgPlayer, season2, { cardIds: body(ctx).cardIds })
1988
+ );
1885
1989
  }),
1886
1990
  scanOffer: handle(strapi, async (ctx) => {
1887
1991
  ok(ctx, await svc("trade").scan(ctx.state.tcgPlayer, String(body(ctx).qrToken ?? "")));
1888
1992
  }),
1889
1993
  getOffer: handle(strapi, async (ctx) => {
1890
1994
  const offer = await svc("trade").findOffer(ctx.params.id);
1891
- ok(ctx, await svc("trade").describe(offer, offer.season));
1995
+ ok(ctx, await svc("trade").describe(offer, offer.season, ctx.state.tcgPlayer));
1996
+ }),
1997
+ joinOffer: handle(strapi, async (ctx) => {
1998
+ ok(ctx, await svc("trade").join(ctx.state.tcgPlayer, ctx.params.id));
1999
+ }),
2000
+ proposeOffer: handle(strapi, async (ctx) => {
2001
+ ok(
2002
+ ctx,
2003
+ await svc("trade").propose(ctx.state.tcgPlayer, ctx.params.id, {
2004
+ cardIds: body(ctx).cardIds
2005
+ })
2006
+ );
1892
2007
  }),
1893
- acceptOffer: handle(strapi, async (ctx) => {
1894
- ok(ctx, await svc("trade").accept(ctx.state.tcgPlayer, ctx.params.id, { cardIds: body(ctx).cardIds }));
2008
+ confirmOffer: handle(strapi, async (ctx) => {
2009
+ ok(ctx, await svc("trade").confirm(ctx.state.tcgPlayer, ctx.params.id));
2010
+ }),
2011
+ declineOffer: handle(strapi, async (ctx) => {
2012
+ ok(ctx, await svc("trade").decline(ctx.state.tcgPlayer, ctx.params.id));
1895
2013
  }),
1896
2014
  rejectOffer: handle(strapi, async (ctx) => {
1897
2015
  ok(ctx, await svc("trade").reject(ctx.state.tcgPlayer, ctx.params.id));
@@ -1913,10 +2031,15 @@ const game = ({ strapi }) => {
1913
2031
  createRedemption: handle(strapi, async (ctx) => {
1914
2032
  const season2 = await seasonOf(ctx);
1915
2033
  const { cardIds, rewardId } = body(ctx);
1916
- created(ctx, await svc("redemption").create(ctx.state.tcgPlayer, season2, { cardIds, rewardId }));
2034
+ created(
2035
+ ctx,
2036
+ await svc("redemption").create(ctx.state.tcgPlayer, season2, { cardIds, rewardId })
2037
+ );
1917
2038
  }),
1918
2039
  getRedemption: handle(strapi, async (ctx) => {
1919
- const rec = await svc("redemption").findOne(ctx.params.id, { forPlayer: ctx.state.tcgPlayer });
2040
+ const rec = await svc("redemption").findOne(ctx.params.id, {
2041
+ forPlayer: ctx.state.tcgPlayer
2042
+ });
1920
2043
  ok(ctx, svc("format").redemption(rec));
1921
2044
  }),
1922
2045
  redemptionHistory: handle(strapi, async (ctx) => {
@@ -2305,7 +2428,12 @@ const contentApi = [
2305
2428
  { method: "GET", path: "/cards/:id", handler: "game.card", config: CATALOG },
2306
2429
  { method: "GET", path: "/live", handler: "game.live", config: CATALOG },
2307
2430
  { method: "GET", path: "/rewards", handler: "game.rewards", config: CATALOG },
2308
- { method: "GET", path: "/rewards/eligibility", handler: "game.rewardEligibility", config: PLAYER },
2431
+ {
2432
+ method: "GET",
2433
+ path: "/rewards/eligibility",
2434
+ handler: "game.rewardEligibility",
2435
+ config: PLAYER
2436
+ },
2309
2437
  { method: "GET", path: "/sets", handler: "game.sets", config: PLAYER },
2310
2438
  { method: "GET", path: "/badges", handler: "game.badges", config: PLAYER },
2311
2439
  // collection
@@ -2321,12 +2449,35 @@ const contentApi = [
2321
2449
  { method: "GET", path: "/trades/active", handler: "game.activeOffers", config: PLAYER },
2322
2450
  { method: "GET", path: "/trades/history", handler: "game.tradeHistory", config: PLAYER },
2323
2451
  { method: "GET", path: "/trades/offer/:id", handler: "game.getOffer", config: PLAYER },
2324
- { method: "POST", path: "/trades/offer/:id/accept", handler: "game.acceptOffer", config: PLAYER },
2452
+ { method: "POST", path: "/trades/offer/:id/join", handler: "game.joinOffer", config: PLAYER },
2453
+ {
2454
+ method: "POST",
2455
+ path: "/trades/offer/:id/propose",
2456
+ handler: "game.proposeOffer",
2457
+ config: PLAYER
2458
+ },
2459
+ {
2460
+ method: "POST",
2461
+ path: "/trades/offer/:id/confirm",
2462
+ handler: "game.confirmOffer",
2463
+ config: PLAYER
2464
+ },
2465
+ {
2466
+ method: "POST",
2467
+ path: "/trades/offer/:id/decline",
2468
+ handler: "game.declineOffer",
2469
+ config: PLAYER
2470
+ },
2325
2471
  { method: "POST", path: "/trades/offer/:id/reject", handler: "game.rejectOffer", config: PLAYER },
2326
2472
  { method: "POST", path: "/trades/offer/:id/cancel", handler: "game.cancelOffer", config: PLAYER },
2327
2473
  // redemptions
2328
2474
  { method: "POST", path: "/redemptions", handler: "game.createRedemption", config: PLAYER },
2329
- { method: "GET", path: "/redemptions/history", handler: "game.redemptionHistory", config: PLAYER },
2475
+ {
2476
+ method: "GET",
2477
+ path: "/redemptions/history",
2478
+ handler: "game.redemptionHistory",
2479
+ config: PLAYER
2480
+ },
2330
2481
  { method: "GET", path: "/redemptions/:id", handler: "game.getRedemption", config: PLAYER }
2331
2482
  ];
2332
2483
  const can = (...actions) => ({
@@ -2495,34 +2646,55 @@ const qr = ({ strapi }) => ({
2495
2646
  },
2496
2647
  /** @param {Object} drop @param {number} ttlMs */
2497
2648
  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 };
2649
+ if (drop2.mode === DROP_MODE.STATIC)
2650
+ return {
2651
+ ...signStaticToken(drop2.documentId, this.secretOf(drop2)),
2652
+ mode: DROP_MODE.STATIC,
2653
+ expiresAt: null
2654
+ };
2655
+ return {
2656
+ ...signDropToken(drop2.documentId, this.secretOf(drop2), ttlMs),
2657
+ mode: DROP_MODE.ROTATING
2658
+ };
2500
2659
  },
2501
2660
  forTrade(offer, player2, ttlMs) {
2502
- return signTradeToken(offer.documentId, player2.documentId, strapi.plugin("tcg").service("settings").qrGlobalSecret(), ttlMs);
2661
+ return signTradeToken(
2662
+ offer.documentId,
2663
+ player2.documentId,
2664
+ strapi.plugin("tcg").service("settings").qrGlobalSecret(),
2665
+ ttlMs
2666
+ );
2503
2667
  },
2504
2668
  /**
2505
- * Verifies signature + expiry; burns the nonce for rotating/trade tokens. Throws TcgError.
2669
+ * Verifies signature + expiry; burns the nonce for rotating/trade tokens — per player for a drop code, so
2670
+ * the same displayed code serves everyone around the screen, once each. Throws TcgError.
2506
2671
  * @param {string} token
2507
2672
  * @param {string} secret
2508
2673
  * @param {'drop'|'trade'} kind
2674
+ * @param {{ playerId?: string }} [scope] - the scanning player's documentId (drops)
2509
2675
  */
2510
- async consume(token, secret, kind2) {
2676
+ async consume(token, secret, kind2, scope = {}) {
2511
2677
  const result = verifyToken(token, secret);
2512
2678
  if (!result.valid) {
2513
- if (result.reason === "expired") throw badRequest(ERROR_CODES.QR_EXPIRED, "Ce QR code a expiré.");
2679
+ if (result.reason === "expired")
2680
+ throw badRequest(ERROR_CODES.QR_EXPIRED, "Ce QR code a expiré.");
2514
2681
  throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
2515
2682
  }
2516
2683
  if (result.kind !== kind2) throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
2517
2684
  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é.");
2685
+ const key = kind2 === "drop" && scope.playerId ? `${result.nonce}:${scope.playerId}` : result.nonce;
2686
+ const seen = await strapi.db.query(UID.NONCE).findOne({ where: { nonce: key }, select: ["id"] });
2687
+ if (seen) throw badRequest(ERROR_CODES.QR_REUSED, "Vous avez déjà scanné ce QR code.");
2520
2688
  try {
2521
2689
  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) }
2690
+ data: {
2691
+ nonce: key,
2692
+ kind: kind2 === "drop" ? NONCE_KIND.DROP : NONCE_KIND.TRADE,
2693
+ expiresAt: new Date(result.expiresAt)
2694
+ }
2523
2695
  });
2524
2696
  } catch (err) {
2525
- throw badRequest(ERROR_CODES.QR_REUSED, "Ce QR code a déjà été utilisé.");
2697
+ throw badRequest(ERROR_CODES.QR_REUSED, "Vous avez déjà scanné ce QR code.");
2526
2698
  }
2527
2699
  return result;
2528
2700
  },
@@ -3041,44 +3213,91 @@ const pull = ({ strapi }) => ({
3041
3213
  async scan(player2, qrToken) {
3042
3214
  const plugin = strapi.plugin("tcg");
3043
3215
  const peek = peekToken(qrToken);
3044
- if (!peek || peek.kind !== "drop") throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3216
+ if (!peek || peek.kind !== "drop")
3217
+ throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3045
3218
  const drop2 = await plugin.service("drop").byDocumentId(peek.dropId);
3046
3219
  const pack2 = drop2.pack;
3047
3220
  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.");
3221
+ if (!pack2 || !pack2.isActive)
3222
+ throw unprocessable(ERROR_CODES.PACK_INACTIVE, "Ce pack n'est plus disponible.");
3223
+ if (!season2 || season2.status !== "active")
3224
+ throw unprocessable(ERROR_CODES.SEASON_INACTIVE, "Cette saison n'est pas en cours.");
3050
3225
  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.");
3226
+ if (!plugin.service("drop").isOpen(drop2))
3227
+ throw unprocessable(ERROR_CODES.DROP_CLOSED, "Ce point n'est pas ouvert en ce moment.", {
3228
+ startsAt: drop2.startsAt,
3229
+ endsAt: drop2.endsAt
3230
+ });
3231
+ if (drop2.maxScansTotal != null && drop2.scanCount >= drop2.maxScansTotal)
3232
+ throw unprocessable(
3233
+ ERROR_CODES.DROP_SOLD_OUT,
3234
+ "Toutes les cartes de ce point ont été distribuées."
3235
+ );
3053
3236
  const limits = plugin.service("season").limitsOf(season2);
3054
- await plugin.service("qr").consume(qrToken, plugin.service("qr").secretOf(drop2), "drop");
3237
+ await plugin.service("qr").consume(qrToken, plugin.service("qr").secretOf(drop2), "drop", {
3238
+ playerId: player2.documentId
3239
+ });
3055
3240
  const now = /* @__PURE__ */ new Date();
3056
3241
  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).`);
3242
+ if (owned >= limits.maxCollectionSize)
3243
+ throw unprocessable(
3244
+ ERROR_CODES.COLLECTION_FULL,
3245
+ `Collection pleine (${limits.maxCollectionSize} cartes max).`
3246
+ );
3058
3247
  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"] });
3248
+ const lastHere = await strapi.db.query(UID.PULL).findOne({
3249
+ where: { player: player2.id, drop: drop2.id, isExtra: false },
3250
+ orderBy: { pulledAt: "desc" },
3251
+ select: ["pulledAt"]
3252
+ });
3060
3253
  if (lastHere) {
3061
3254
  const elapsed = now.getTime() - new Date(lastHere.pulledAt).getTime();
3062
3255
  if (elapsed < cooldownMs) {
3063
3256
  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 });
3257
+ throw unprocessable(
3258
+ ERROR_CODES.COOLDOWN_ACTIVE,
3259
+ remainingSeconds > 3600 ? `Revenez dans ${Math.ceil(remainingSeconds / 3600)} h.` : `Patientez encore ${remainingSeconds} s.`,
3260
+ { remainingSeconds }
3261
+ );
3065
3262
  }
3066
3263
  }
3067
3264
  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.`);
3265
+ const mine = await strapi.db.query(UID.PULL).count({ where: { player: player2.id, drop: drop2.id, isExtra: false } });
3266
+ if (mine >= drop2.maxScansPerPlayer)
3267
+ throw unprocessable(
3268
+ ERROR_CODES.DROP_PLAYER_LIMIT,
3269
+ `Vous avez déjà obtenu vos ${drop2.maxScansPerPlayer} carte(s) ici.`
3270
+ );
3070
3271
  }
3071
3272
  const since = startOfToday$1();
3072
3273
  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 } } })
3274
+ strapi.db.query(UID.PULL).count({
3275
+ where: { player: player2.id, pack: pack2.id, pulledAt: { $gte: since }, isExtra: false }
3276
+ }),
3277
+ strapi.db.query(UID.PULL).count({
3278
+ where: {
3279
+ player: player2.id,
3280
+ season: season2.id,
3281
+ pulledAt: { $gte: since },
3282
+ isExtra: false
3283
+ }
3284
+ })
3075
3285
  ]);
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}).`);
3286
+ if (packToday >= limits.maxDailyPullsPerPack)
3287
+ throw unprocessable(
3288
+ ERROR_CODES.DAILY_LIMIT_PACK,
3289
+ `Limite atteinte pour ce pack aujourd'hui (${limits.maxDailyPullsPerPack}).`
3290
+ );
3291
+ if (totalToday >= limits.maxDailyPullsTotal)
3292
+ throw unprocessable(
3293
+ ERROR_CODES.DAILY_LIMIT_TOTAL,
3294
+ `Limite quotidienne atteinte (${limits.maxDailyPullsTotal}).`
3295
+ );
3078
3296
  const raw = await plugin.service("drop").rawPool(drop2, pack2);
3079
3297
  const exclusiveIds = await plugin.service("drop").exclusiveCardIds(season2);
3080
3298
  let pool = prunePool(raw, exclusiveIds);
3081
- if (pool.length === 0) throw unprocessable(ERROR_CODES.PACK_EMPTY, "Aucune carte disponible ici pour le moment.");
3299
+ if (pool.length === 0)
3300
+ throw unprocessable(ERROR_CODES.PACK_EMPTY, "Aucune carte disponible ici pour le moment.");
3082
3301
  const boosts = await plugin.service("boost").activeFor(season2, pack2, drop2);
3083
3302
  const multipliers = plugin.service("boost").combine(boosts);
3084
3303
  const recent = await strapi.db.query(UID.PULL).findMany({
@@ -3088,65 +3307,113 @@ const pull = ({ strapi }) => ({
3088
3307
  select: ["rarity"]
3089
3308
  });
3090
3309
  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
3310
+ const count = Math.max(1, Math.min(10, Number(drop2.cardsPerPull) || 1));
3311
+ const format2 = plugin.service("format");
3312
+ const cards = [];
3313
+ for (let n = 0; n < count; n += 1) {
3314
+ let result = null;
3315
+ let mintNumber = null;
3316
+ for (let attempt = 0; attempt < MAX_PULL_RETRIES && pool.length; attempt += 1) {
3317
+ result = pull$1({
3318
+ playerId: player2.documentId,
3319
+ dropId: drop2.documentId,
3320
+ pullNumber: totalToday + 1 + n * MAX_PULL_RETRIES + attempt,
3321
+ pool,
3322
+ rarities: season2.rarities ?? [],
3323
+ multipliers,
3324
+ minRarity: n === 0 ? minRarity : null
3325
+ });
3326
+ if (!result.card) break;
3327
+ mintNumber = await this.claimSlot(UID.CARD, result.card.id, "minted", "supplyCap");
3328
+ if (mintNumber != null) break;
3329
+ pool = pool.filter((c) => c.id !== result.card.id);
3330
+ result = null;
3331
+ }
3332
+ if (!result?.card || mintNumber == null) {
3333
+ if (n === 0) throw unprocessable(ERROR_CODES.PACK_EMPTY, "Plus de cartes disponibles ici.");
3334
+ break;
3335
+ }
3336
+ if (n === 0) {
3337
+ if (drop2.maxScansTotal != null) {
3338
+ const slot = await this.claimSlot(UID.DROP, drop2.id, "scanCount", "maxScansTotal");
3339
+ if (slot == null)
3340
+ throw unprocessable(
3341
+ ERROR_CODES.DROP_SOLD_OUT,
3342
+ "Toutes les cartes de ce point ont été distribuées."
3343
+ );
3344
+ } else {
3345
+ await this.claimSlot(UID.DROP, drop2.id, "scanCount", null);
3129
3346
  }
3347
+ }
3348
+ const alreadyOwned = await strapi.db.query(UID.PLAYER_CARD).count({ where: { player: player2.id, card: result.card.id } });
3349
+ const playerCard2 = await strapi.db.transaction(async () => {
3350
+ const pc = await strapi.db.query(UID.PLAYER_CARD).create({
3351
+ data: {
3352
+ player: player2.id,
3353
+ card: result.card.id,
3354
+ pack: pack2.id,
3355
+ acquiredFrom: ACQUISITION.PULL,
3356
+ acquiredAt: now,
3357
+ mintNumber
3358
+ }
3359
+ });
3360
+ await strapi.db.query(UID.PULL).create({
3361
+ data: {
3362
+ player: player2.id,
3363
+ pack: pack2.id,
3364
+ drop: drop2.id,
3365
+ card: result.card.id,
3366
+ season: season2.id,
3367
+ seed: String(result.seed),
3368
+ roll: result.roll,
3369
+ rarity: result.rarity,
3370
+ verificationHash: result.verificationHash,
3371
+ qrToken: peek.mode === DROP_MODE.STATIC ? null : qrToken,
3372
+ pulledAt: now,
3373
+ odds: result.odds,
3374
+ pity: result.pity,
3375
+ mintNumber,
3376
+ isExtra: n > 0
3377
+ }
3378
+ });
3379
+ await strapi.db.query(UID.PLAYER).update({
3380
+ where: { id: player2.id },
3381
+ data: { totalCards: (player2.totalCards ?? 0) + n + 1, lastActiveAt: now }
3382
+ });
3383
+ return pc;
3130
3384
  });
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: {
3385
+ cards.push({
3386
+ playerCardId: playerCard2.documentId,
3387
+ card: { ...format2.card(result.card, season2), mintNumber },
3141
3388
  rarity: result.rarity,
3142
- isNew: alreadyOwned === 0,
3389
+ isNew: alreadyOwned === 0 && !cards.some((c) => c.card.id === result.card.documentId),
3143
3390
  pity: result.pity,
3144
3391
  odds: result.odds,
3145
- boosts: boosts.map((b) => ({ name: b.name, multipliers: b.multipliers })),
3146
3392
  isExclusive: Boolean(result.card.isExclusive),
3393
+ verificationHash: result.verificationHash,
3394
+ mintNumber
3395
+ });
3396
+ strapi.log.info(
3397
+ `[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("+")}` : ""}`
3398
+ );
3399
+ }
3400
+ const first = cards[0];
3401
+ const unlocked = await plugin.service("badge").evaluate(player2, season2, { event: "pull", card: first.card.id });
3402
+ return {
3403
+ playerCardId: first.playerCardId,
3404
+ card: first.card,
3405
+ cards,
3406
+ pull: {
3407
+ rarity: first.rarity,
3408
+ isNew: first.isNew,
3409
+ pity: first.pity,
3410
+ odds: first.odds,
3411
+ boosts: boosts.map((b) => ({ name: b.name, multipliers: b.multipliers })),
3412
+ isExclusive: first.isExclusive,
3413
+ cardCount: cards.length,
3147
3414
  pack: { id: pack2.documentId, name: pack2.name, slug: pack2.slug },
3148
3415
  drop: { id: drop2.documentId, name: drop2.name, slug: drop2.slug },
3149
- verificationHash: result.verificationHash,
3416
+ verificationHash: first.verificationHash,
3150
3417
  pulledAt: now
3151
3418
  },
3152
3419
  unlocked
@@ -3155,7 +3422,13 @@ const pull = ({ strapi }) => ({
3155
3422
  async history(player2, season2, page) {
3156
3423
  const where = { player: player2.id, season: season2.id };
3157
3424
  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 }),
3425
+ strapi.db.query(UID.PULL).findMany({
3426
+ where,
3427
+ populate: { card: { populate: { image: true } }, pack: true, drop: true },
3428
+ orderBy: { pulledAt: "desc" },
3429
+ offset: page.offset,
3430
+ limit: page.limit
3431
+ }),
3159
3432
  strapi.db.query(UID.PULL).count({ where })
3160
3433
  ]);
3161
3434
  const format2 = strapi.plugin("tcg").service("format");
@@ -3180,7 +3453,11 @@ const pull = ({ strapi }) => ({
3180
3453
  const limits = plugin.service("season").limitsOf(season2);
3181
3454
  const packs = await plugin.service("pack").list(season2);
3182
3455
  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"] });
3456
+ const todays = await strapi.db.query(UID.PULL).findMany({
3457
+ where: { player: player2.id, season: season2.id, pulledAt: { $gte: since }, isExtra: false },
3458
+ populate: { pack: { select: ["id"] } },
3459
+ select: ["pulledAt"]
3460
+ });
3184
3461
  const countByPack = {};
3185
3462
  for (const p of todays) countByPack[p.pack?.id] = (countByPack[p.pack?.id] ?? 0) + 1;
3186
3463
  const totalToday = todays.length;
@@ -3190,10 +3467,21 @@ const pull = ({ strapi }) => ({
3190
3467
  const drops = await plugin.service("drop").list(pack2, { includeInactive: false });
3191
3468
  const dropStates = [];
3192
3469
  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"] });
3470
+ const last = await strapi.db.query(UID.PULL).findOne({
3471
+ where: { player: player2.id, drop: drop2.id, isExtra: false },
3472
+ orderBy: { pulledAt: "desc" },
3473
+ select: ["pulledAt"]
3474
+ });
3194
3475
  const cooldownMs = drop2.cooldownMs ?? (drop2.mode === DROP_MODE.STATIC ? Math.max(limits.pullCooldownMs, limits.staticScanCooldownMs) : limits.pullCooldownMs);
3195
3476
  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) });
3477
+ dropStates.push({
3478
+ drop: { id: drop2.documentId, name: drop2.name, slug: drop2.slug },
3479
+ isOpen: plugin.service("drop").isOpen(drop2),
3480
+ cooldownRemainingMs: remaining,
3481
+ cooldownRemainingSeconds: Math.ceil(remaining / 1e3),
3482
+ cooldownUntil: remaining > 0 ? new Date(now + remaining) : null,
3483
+ cardsPerPull: Math.max(1, Number(drop2.cardsPerPull) || 1)
3484
+ });
3197
3485
  }
3198
3486
  const pullsToday = countByPack[pack2.id] ?? 0;
3199
3487
  const anyReady = dropStates.some((d) => d.isOpen && d.cooldownRemainingMs === 0);
@@ -3331,28 +3619,46 @@ const trade = ({ strapi }) => ({
3331
3619
  /** Player-card rows owned by `player` among `documentIds`; throws if any is missing. */
3332
3620
  async ownedRows(player2, documentIds, season2) {
3333
3621
  const ids = [...new Set((documentIds ?? []).map(String))];
3334
- if (ids.length === 0) throw badRequest(ERROR_CODES.VALIDATION, "Sélectionnez au moins une carte.");
3622
+ if (ids.length === 0)
3623
+ throw badRequest(ERROR_CODES.VALIDATION, "Sélectionnez au moins une carte.");
3335
3624
  const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3336
- where: { player: player2.id, documentId: { $in: ids }, ...season2 ? { card: { season: season2.id } } : {} },
3625
+ where: {
3626
+ player: player2.id,
3627
+ documentId: { $in: ids },
3628
+ ...season2 ? { card: { season: season2.id } } : {}
3629
+ },
3337
3630
  populate: { card: { populate: { image: true } } }
3338
3631
  });
3339
- if (rows.length !== ids.length) throw badRequest(ERROR_CODES.CARD_NOT_OWNED, "Vous ne possédez pas toutes ces cartes.");
3632
+ if (rows.length !== ids.length)
3633
+ throw badRequest(ERROR_CODES.CARD_NOT_OWNED, "Vous ne possédez pas toutes ces cartes.");
3340
3634
  return rows;
3341
3635
  },
3342
3636
  async createOffer(player2, season2, { cardIds }) {
3343
3637
  const plugin = strapi.plugin("tcg");
3344
3638
  const limits = plugin.service("season").limitsOf(season2);
3345
3639
  if ((cardIds ?? []).length > limits.maxCardsPerTrade) {
3346
- throw unprocessable(ERROR_CODES.TRADE_TOO_MANY_CARDS, `Maximum ${limits.maxCardsPerTrade} cartes par échange.`);
3640
+ throw unprocessable(
3641
+ ERROR_CODES.TRADE_TOO_MANY_CARDS,
3642
+ `Maximum ${limits.maxCardsPerTrade} cartes par échange.`
3643
+ );
3347
3644
  }
3348
- const active = await strapi.db.query(UID.TRADE_OFFER).count({ where: { initiator: player2.id, status: TRADE_STATUS.PENDING } });
3645
+ const active = await strapi.db.query(UID.TRADE_OFFER).count({ where: { initiator: player2.id, status: { $in: TRADE_OPEN_STATUSES } } });
3349
3646
  if (active >= limits.maxActiveTradeOffers) {
3350
- throw unprocessable(ERROR_CODES.TRADE_LIMIT, `Maximum ${limits.maxActiveTradeOffers} offres actives.`);
3647
+ throw unprocessable(
3648
+ ERROR_CODES.TRADE_LIMIT,
3649
+ `Maximum ${limits.maxActiveTradeOffers} offres actives.`
3650
+ );
3351
3651
  }
3352
3652
  const rows = await this.ownedRows(player2, cardIds, season2);
3353
3653
  const expiresAt = new Date(Date.now() + limits.tradeOfferExpiryMs);
3354
3654
  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 }
3655
+ data: {
3656
+ initiator: player2.id,
3657
+ season: season2.id,
3658
+ status: TRADE_STATUS.PENDING,
3659
+ offeredCardIds: rows.map((r) => r.documentId),
3660
+ expiresAt
3661
+ }
3356
3662
  });
3357
3663
  const { token } = plugin.service("qr").forTrade(offer, player2, limits.tradeOfferExpiryMs);
3358
3664
  await strapi.db.query(UID.TRADE_OFFER).update({ where: { id: offer.id }, data: { qrToken: token } });
@@ -3368,68 +3674,184 @@ const trade = ({ strapi }) => ({
3368
3674
  async findOffer(documentId) {
3369
3675
  const offer = await strapi.db.query(UID.TRADE_OFFER).findOne({
3370
3676
  where: { documentId },
3371
- populate: { initiator: true, season: true }
3677
+ populate: { initiator: true, receiver: true, season: true }
3372
3678
  });
3373
3679
  if (!offer) throw notFound(ERROR_CODES.TRADE_NOT_FOUND, "Offre introuvable.");
3374
3680
  return offer;
3375
3681
  },
3682
+ /** Alive and not past its expiry; otherwise the status names why. */
3376
3683
  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é.");
3684
+ if (!TRADE_OPEN_STATUSES.includes(offer.status))
3685
+ throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, `Cette offre est ${offer.status}.`);
3686
+ if (/* @__PURE__ */ new Date() > new Date(offer.expiresAt))
3687
+ throw unprocessable(ERROR_CODES.TRADE_EXPIRED, "Cette offre a expiré.");
3379
3688
  },
3380
- async describe(offer, season2) {
3689
+ async cardsOf(ids, season2) {
3381
3690
  const format2 = strapi.plugin("tcg").service("format");
3691
+ if (!ids?.length) return [];
3382
3692
  const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3383
- where: { documentId: { $in: offer.offeredCardIds ?? [] } },
3693
+ where: { documentId: { $in: ids } },
3384
3694
  populate: { card: { populate: { image: true } } }
3385
3695
  });
3696
+ return rows.map((r) => format2.playerCard(r, season2));
3697
+ },
3698
+ /**
3699
+ * The offer as both players see it: who is on each side, what each side puts in, where the
3700
+ * handshake stands. `role` is the caller's seat when a player is given.
3701
+ */
3702
+ async describe(offer, season2, viewer = null) {
3703
+ const [offeredCards, receiverCards] = await Promise.all([
3704
+ this.cardsOf(offer.offeredCardIds, season2),
3705
+ this.cardsOf(offer.receiverCardIds, season2)
3706
+ ]);
3707
+ const who = (p) => p ? { code: p.code, displayName: p.displayName } : null;
3708
+ const role = !viewer ? null : offer.initiator?.id === viewer.id ? "initiator" : offer.receiver?.id === viewer.id ? "receiver" : null;
3386
3709
  return {
3387
3710
  id: offer.documentId,
3388
3711
  status: offer.status,
3389
3712
  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))
3713
+ initiator: who(offer.initiator),
3714
+ receiver: who(offer.receiver),
3715
+ offeredCards,
3716
+ receiverCards,
3717
+ joinedAt: offer.joinedAt ?? null,
3718
+ proposedAt: offer.proposedAt ?? null,
3719
+ declinedAt: offer.declinedAt ?? null,
3720
+ role
3392
3721
  };
3393
3722
  },
3394
- /** The receiver scans the initiator's QR. The token is NOT burned here; acceptance burns it. */
3723
+ /** The receiver scans the initiator's signed QR: same as joining by id. */
3395
3724
  async scan(player2, qrToken) {
3396
3725
  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.");
3726
+ if (!peek || peek.kind !== "trade")
3727
+ throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3399
3728
  const offer = await this.findOffer(peek.offerId);
3400
3729
  if (offer.qrToken !== qrToken) throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3401
- this.assertOpen(offer);
3402
- return this.describe(offer, offer.season);
3730
+ return this.join(player2, offer.documentId);
3403
3731
  },
3404
3732
  /**
3405
- * Atomic swap. `cardIds` are the receiver's player-card documentIds.
3733
+ * A receiver takes the seat opposite the initiator. Idempotent for the same receiver; refused
3734
+ * while another receiver holds the seat, so two phones cannot answer one QR at once.
3406
3735
  */
3407
- async accept(receiver, offerDocumentId, { cardIds }) {
3736
+ async join(player2, offerDocumentId) {
3737
+ const offer = await this.findOffer(offerDocumentId);
3738
+ if (offer.initiator?.id === player2.id)
3739
+ throw badRequest(ERROR_CODES.TRADE_SELF, "Vous ne pouvez pas rejoindre votre propre offre.");
3740
+ this.assertOpen(offer);
3741
+ if (offer.receiver && offer.receiver.id !== player2.id) {
3742
+ throw unprocessable(
3743
+ ERROR_CODES.TRADE_INVALID_STATUS,
3744
+ "Un autre joueur est déjà sur cette offre."
3745
+ );
3746
+ }
3747
+ if (!offer.receiver) {
3748
+ await strapi.db.query(UID.TRADE_OFFER).update({
3749
+ where: { id: offer.id },
3750
+ data: {
3751
+ receiver: player2.id,
3752
+ status: TRADE_STATUS.JOINED,
3753
+ joinedAt: /* @__PURE__ */ new Date(),
3754
+ receiverCardIds: null,
3755
+ declinedAt: null
3756
+ }
3757
+ });
3758
+ }
3759
+ return this.describe(await this.findOffer(offerDocumentId), offer.season, player2);
3760
+ },
3761
+ /** The receiver says what they give. Replaces a previous proposal; the initiator must confirm. */
3762
+ async propose(player2, offerDocumentId, { cardIds }) {
3408
3763
  const plugin = strapi.plugin("tcg");
3409
3764
  const offer = await this.findOffer(offerDocumentId);
3765
+ if (offer.initiator?.id === player2.id)
3766
+ throw badRequest(
3767
+ ERROR_CODES.TRADE_SELF,
3768
+ "Vous ne pouvez pas proposer sur votre propre offre."
3769
+ );
3410
3770
  this.assertOpen(offer);
3411
- if (offer.initiator?.id === receiver.id) throw badRequest(ERROR_CODES.TRADE_SELF, "Vous ne pouvez pas accepter votre propre offre.");
3771
+ if (offer.receiver && offer.receiver.id !== player2.id) {
3772
+ throw unprocessable(
3773
+ ERROR_CODES.TRADE_INVALID_STATUS,
3774
+ "Un autre joueur est déjà sur cette offre."
3775
+ );
3776
+ }
3412
3777
  const season2 = offer.season;
3413
3778
  const limits = plugin.service("season").limitsOf(season2);
3414
3779
  if ((cardIds ?? []).length > limits.maxCardsPerTrade) {
3415
- throw unprocessable(ERROR_CODES.TRADE_TOO_MANY_CARDS, `Maximum ${limits.maxCardsPerTrade} cartes par échange.`);
3780
+ throw unprocessable(
3781
+ ERROR_CODES.TRADE_TOO_MANY_CARDS,
3782
+ `Maximum ${limits.maxCardsPerTrade} cartes par échange.`
3783
+ );
3784
+ }
3785
+ const rows = await this.ownedRows(player2, cardIds, season2);
3786
+ await strapi.db.query(UID.TRADE_OFFER).update({
3787
+ where: { id: offer.id },
3788
+ data: {
3789
+ receiver: player2.id,
3790
+ status: TRADE_STATUS.PROPOSED,
3791
+ receiverCardIds: rows.map((r) => r.documentId),
3792
+ joinedAt: offer.joinedAt ?? /* @__PURE__ */ new Date(),
3793
+ proposedAt: /* @__PURE__ */ new Date(),
3794
+ declinedAt: null
3795
+ }
3796
+ });
3797
+ return this.describe(await this.findOffer(offerDocumentId), season2, player2);
3798
+ },
3799
+ /** The initiator sends the proposal back; the receiver keeps the seat and can propose again. */
3800
+ async decline(player2, offerDocumentId) {
3801
+ const offer = await this.findOffer(offerDocumentId);
3802
+ if (offer.initiator?.id !== player2.id)
3803
+ throw forbidden(
3804
+ ERROR_CODES.TRADE_FORBIDDEN,
3805
+ "Seul l’auteur de l’offre peut refuser une proposition."
3806
+ );
3807
+ this.assertOpen(offer);
3808
+ if (offer.status !== TRADE_STATUS.PROPOSED)
3809
+ throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, "Aucune proposition à refuser.");
3810
+ await strapi.db.query(UID.TRADE_OFFER).update({
3811
+ where: { id: offer.id },
3812
+ data: { status: TRADE_STATUS.JOINED, receiverCardIds: null, declinedAt: /* @__PURE__ */ new Date() }
3813
+ });
3814
+ return this.describe(await this.findOffer(offerDocumentId), offer.season, player2);
3815
+ },
3816
+ /**
3817
+ * The initiator confirms the proposal: the atomic swap. Both players have said yes by now —
3818
+ * the receiver by proposing, the initiator here.
3819
+ */
3820
+ async confirm(initiator, offerDocumentId) {
3821
+ const plugin = strapi.plugin("tcg");
3822
+ const offer = await this.findOffer(offerDocumentId);
3823
+ if (offer.initiator?.id !== initiator.id)
3824
+ throw forbidden(
3825
+ ERROR_CODES.TRADE_FORBIDDEN,
3826
+ "Seul l’auteur de l’offre peut confirmer l’échange."
3827
+ );
3828
+ this.assertOpen(offer);
3829
+ if (offer.status !== TRADE_STATUS.PROPOSED || !offer.receiver)
3830
+ throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, "Aucune proposition à confirmer.");
3831
+ const receiver = offer.receiver;
3832
+ const season2 = offer.season;
3833
+ const receiverRows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3834
+ where: { player: receiver.id, documentId: { $in: offer.receiverCardIds ?? [] } }
3835
+ });
3836
+ if (receiverRows.length !== (offer.receiverCardIds ?? []).length || receiverRows.length === 0) {
3837
+ throw unprocessable(ERROR_CODES.CARD_NOT_OWNED, "L'autre joueur ne possède plus ces cartes.");
3416
3838
  }
3417
- const receiverRows = await this.ownedRows(receiver, cardIds, season2);
3418
3839
  const initiatorRows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3419
- where: { player: offer.initiator.id, documentId: { $in: offer.offeredCardIds ?? [] } }
3840
+ where: { player: initiator.id, documentId: { $in: offer.offeredCardIds ?? [] } }
3420
3841
  });
3421
3842
  if (initiatorRows.length !== (offer.offeredCardIds ?? []).length) {
3422
- throw unprocessable(ERROR_CODES.CARD_NOT_OWNED, "L'autre joueur ne possède plus ces cartes.");
3843
+ throw unprocessable(ERROR_CODES.CARD_NOT_OWNED, "Vous ne possédez plus ces cartes.");
3423
3844
  }
3424
3845
  const now = /* @__PURE__ */ new Date();
3425
3846
  const trade2 = await strapi.db.transaction(async () => {
3426
3847
  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.");
3848
+ if (fresh.status !== TRADE_STATUS.PROPOSED)
3849
+ throw unprocessable(ERROR_CODES.TRADE_INVALID_STATUS, "Offre déjà traitée.");
3428
3850
  await strapi.db.query(UID.TRADE_OFFER).update({ where: { id: offer.id }, data: { status: TRADE_STATUS.ACCEPTED } });
3429
3851
  const t = await strapi.db.query(UID.TRADE).create({
3430
3852
  data: {
3431
3853
  offer: offer.id,
3432
- initiator: offer.initiator.id,
3854
+ initiator: initiator.id,
3433
3855
  receiver: receiver.id,
3434
3856
  season: season2.id,
3435
3857
  initiatorCardIds: initiatorRows.map((r) => r.documentId),
@@ -3438,53 +3860,127 @@ const trade = ({ strapi }) => ({
3438
3860
  }
3439
3861
  });
3440
3862
  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 } });
3863
+ await strapi.db.query(UID.PLAYER_CARD).update({
3864
+ where: { id: row.id },
3865
+ data: {
3866
+ player: receiver.id,
3867
+ acquiredFrom: ACQUISITION.TRADE,
3868
+ acquiredAt: now,
3869
+ sourceTrade: t.id
3870
+ }
3871
+ });
3442
3872
  }
3443
3873
  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 } });
3874
+ await strapi.db.query(UID.PLAYER_CARD).update({
3875
+ where: { id: row.id },
3876
+ data: {
3877
+ player: initiator.id,
3878
+ acquiredFrom: ACQUISITION.TRADE,
3879
+ acquiredAt: now,
3880
+ sourceTrade: t.id
3881
+ }
3882
+ });
3445
3883
  }
3446
3884
  const delta = receiverRows.length - initiatorRows.length;
3447
3885
  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 }
3886
+ where: { id: initiator.id },
3887
+ data: {
3888
+ totalTrades: (initiator.totalTrades ?? 0) + 1,
3889
+ totalCards: Math.max(0, (initiator.totalCards ?? 0) + delta),
3890
+ lastActiveAt: now
3891
+ }
3450
3892
  });
3451
3893
  await strapi.db.query(UID.PLAYER).update({
3452
3894
  where: { id: receiver.id },
3453
- data: { totalTrades: (receiver.totalTrades ?? 0) + 1, totalCards: Math.max(0, (receiver.totalCards ?? 0) - delta), lastActiveAt: now }
3895
+ data: {
3896
+ totalTrades: (receiver.totalTrades ?? 0) + 1,
3897
+ totalCards: Math.max(0, (receiver.totalCards ?? 0) - delta),
3898
+ lastActiveAt: now
3899
+ }
3454
3900
  });
3455
3901
  return t;
3456
3902
  });
3457
- strapi.log.info(`[tcg] trade ${offer.initiator.code} ⇄ ${receiver.code} (${initiatorRows.length}:${receiverRows.length})`);
3903
+ strapi.log.info(
3904
+ `[tcg] trade ${initiator.code} ⇄ ${receiver.code} (${initiatorRows.length}:${receiverRows.length})`
3905
+ );
3458
3906
  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 };
3907
+ const [unlocked, receiverUnlocked] = await Promise.all([
3908
+ badge2.evaluate(initiator, season2, { event: "trade" }),
3909
+ badge2.evaluate(receiver, season2, { event: "trade" })
3910
+ ]);
3911
+ return {
3912
+ id: trade2.documentId,
3913
+ offerId: offer.documentId,
3914
+ completedAt: now,
3915
+ received: receiverRows.length,
3916
+ given: initiatorRows.length,
3917
+ unlocked,
3918
+ receiverUnlocked
3919
+ };
3461
3920
  },
3921
+ /** The receiver leaves: the seat frees up, the initiator's QR stays valid for someone else. */
3462
3922
  async reject(player2, offerDocumentId) {
3463
3923
  const offer = await this.findOffer(offerDocumentId);
3924
+ if (offer.initiator?.id === player2.id)
3925
+ throw badRequest(ERROR_CODES.TRADE_SELF, "Utilisez « annuler » pour votre propre offre.");
3926
+ if (!offer.receiver || offer.receiver.id !== player2.id)
3927
+ throw forbidden(ERROR_CODES.TRADE_FORBIDDEN, "Vous n’êtes pas sur cette offre.");
3464
3928
  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 } });
3929
+ await strapi.db.query(UID.TRADE_OFFER).update({
3930
+ where: { id: offer.id },
3931
+ data: {
3932
+ status: TRADE_STATUS.PENDING,
3933
+ receiver: null,
3934
+ receiverCardIds: null,
3935
+ joinedAt: null,
3936
+ proposedAt: null,
3937
+ declinedAt: null
3938
+ }
3939
+ });
3467
3940
  return { ok: true };
3468
3941
  },
3469
3942
  async cancel(player2, offerDocumentId) {
3470
3943
  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}.`);
3944
+ if (offer.initiator?.id !== player2.id)
3945
+ throw forbidden(ERROR_CODES.TRADE_FORBIDDEN, "Vous ne pouvez annuler que vos offres.");
3946
+ if (!TRADE_OPEN_STATUSES.includes(offer.status))
3947
+ throw unprocessable(
3948
+ ERROR_CODES.TRADE_INVALID_STATUS,
3949
+ `Cette offre est déjà ${offer.status}.`
3950
+ );
3473
3951
  await strapi.db.query(UID.TRADE_OFFER).update({ where: { id: offer.id }, data: { status: TRADE_STATUS.CANCELLED } });
3474
3952
  return { ok: true };
3475
3953
  },
3954
+ /** The player's live offers, on either seat, newest first — what a reopened screen resumes. */
3476
3955
  async activeOffers(player2, season2) {
3477
3956
  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 },
3957
+ where: {
3958
+ season: season2.id,
3959
+ status: { $in: TRADE_OPEN_STATUSES },
3960
+ expiresAt: { $gt: /* @__PURE__ */ new Date() },
3961
+ $or: [{ initiator: player2.id }, { receiver: player2.id }]
3962
+ },
3963
+ populate: { initiator: true, receiver: true },
3480
3964
  orderBy: { createdAt: "desc" }
3481
3965
  });
3482
- return Promise.all(offers.map((o) => this.describe(o, season2).then((d) => ({ ...d, qrToken: o.qrToken }))));
3966
+ return Promise.all(
3967
+ offers.map(
3968
+ (o) => this.describe(o, season2, player2).then(
3969
+ (d) => o.initiator?.id === player2.id ? { ...d, qrToken: o.qrToken } : d
3970
+ )
3971
+ )
3972
+ );
3483
3973
  },
3484
3974
  async history(player2, season2, page) {
3485
3975
  const where = { season: season2.id, $or: [{ initiator: player2.id }, { receiver: player2.id }] };
3486
3976
  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 }),
3977
+ strapi.db.query(UID.TRADE).findMany({
3978
+ where,
3979
+ populate: { initiator: true, receiver: true },
3980
+ orderBy: { completedAt: "desc" },
3981
+ offset: page.offset,
3982
+ limit: page.limit
3983
+ }),
3488
3984
  strapi.db.query(UID.TRADE).count({ where })
3489
3985
  ]);
3490
3986
  return {
@@ -3492,17 +3988,20 @@ const trade = ({ strapi }) => ({
3492
3988
  id: t.documentId,
3493
3989
  completedAt: t.completedAt,
3494
3990
  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,
3991
+ counterpart: (t.initiator?.id === player2.id ? t.receiver : t.initiator) ? {
3992
+ code: (t.initiator?.id === player2.id ? t.receiver : t.initiator).code,
3993
+ displayName: (t.initiator?.id === player2.id ? t.receiver : t.initiator).displayName
3994
+ } : null,
3496
3995
  given: (t.initiator?.id === player2.id ? t.initiatorCardIds : t.receiverCardIds) ?? [],
3497
3996
  received: (t.initiator?.id === player2.id ? t.receiverCardIds : t.initiatorCardIds) ?? []
3498
3997
  })),
3499
3998
  total
3500
3999
  };
3501
4000
  },
3502
- /** Marks pending offers past their expiry. */
4001
+ /** Marks live offers past their expiry. */
3503
4002
  async sweep() {
3504
4003
  const { count } = await strapi.db.query(UID.TRADE_OFFER).updateMany({
3505
- where: { status: TRADE_STATUS.PENDING, expiresAt: { $lt: /* @__PURE__ */ new Date() } },
4004
+ where: { status: { $in: TRADE_OPEN_STATUSES }, expiresAt: { $lt: /* @__PURE__ */ new Date() } },
3506
4005
  data: { status: TRADE_STATUS.EXPIRED }
3507
4006
  });
3508
4007
  return count ?? 0;
@@ -3784,10 +4283,16 @@ const drop = ({ strapi }) => ({
3784
4283
  return drop2;
3785
4284
  },
3786
4285
  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.");
4286
+ if (data.mode && !Object.values(DROP_MODE).includes(data.mode))
4287
+ throw badRequest(ERROR_CODES.VALIDATION, "Mode invalide.");
4288
+ if (data.poolMode && !Object.values(POOL_MODE).includes(data.poolMode))
4289
+ throw badRequest(ERROR_CODES.VALIDATION, "Mode de pool invalide.");
3789
4290
  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.`);
4291
+ if (data[k] != null && data[k] !== "" && !(Number(data[k]) >= 0))
4292
+ throw badRequest(ERROR_CODES.VALIDATION, `${k} invalide.`);
4293
+ }
4294
+ if (data.cardsPerPull != null && data.cardsPerPull !== "" && !(Number.isInteger(Number(data.cardsPerPull)) && Number(data.cardsPerPull) >= 1 && Number(data.cardsPerPull) <= 10)) {
4295
+ throw badRequest(ERROR_CODES.VALIDATION, "cardsPerPull doit être entre 1 et 10.");
3791
4296
  }
3792
4297
  },
3793
4298
  async create(pack2, data) {
@@ -3810,13 +4315,19 @@ const drop = ({ strapi }) => ({
3810
4315
  async ensureDefault(pack2) {
3811
4316
  const existing = await strapi.db.query(UID.DROP).findOne({ where: { pack: pack2.id, isDefault: true } });
3812
4317
  if (existing) return existing;
3813
- return this.create(pack2, { name: DEFAULT_DROP_NAME, mode: DROP_MODE.STATIC, isDefault: true, poolMode: POOL_MODE.INHERIT });
4318
+ return this.create(pack2, {
4319
+ name: DEFAULT_DROP_NAME,
4320
+ mode: DROP_MODE.STATIC,
4321
+ isDefault: true,
4322
+ poolMode: POOL_MODE.INHERIT
4323
+ });
3814
4324
  },
3815
4325
  async update(pack2, ref, data) {
3816
4326
  const drop2 = await this.findOne(pack2, ref);
3817
4327
  this.validate(data);
3818
4328
  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 });
4329
+ if (rest.slug && rest.slug !== drop2.slug)
4330
+ rest.slug = await uniqueSlug(strapi, UID.DROP, rest.slug, { pack: pack2.id });
3820
4331
  const updated = await strapi.documents(UID.DROP).update({ documentId: drop2.documentId, data: rest });
3821
4332
  if (Array.isArray(cards)) await this.setCards(pack2, drop2.documentId, cards);
3822
4333
  return updated;
@@ -3828,9 +4339,17 @@ const drop = ({ strapi }) => ({
3828
4339
  },
3829
4340
  async remove(pack2, ref) {
3830
4341
  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.");
4342
+ if (drop2.isDefault)
4343
+ throw badRequest(
4344
+ ERROR_CODES.VALIDATION,
4345
+ "Le point de scan par défaut ne peut pas être supprimé : désactivez-le."
4346
+ );
3832
4347
  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.");
4348
+ if (pulls > 0)
4349
+ throw badRequest(
4350
+ ERROR_CODES.VALIDATION,
4351
+ "Ce point a des tirages : désactivez-le au lieu de le supprimer."
4352
+ );
3834
4353
  await strapi.db.query(UID.DROP_CARD).deleteMany({ where: { drop: drop2.id } });
3835
4354
  await strapi.documents(UID.DROP).delete({ documentId: drop2.documentId });
3836
4355
  return { ok: true };
@@ -3839,15 +4358,28 @@ const drop = ({ strapi }) => ({
3839
4358
  const drop2 = await this.findOne(pack2, dropRef);
3840
4359
  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
4360
  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.");
4361
+ const cards = docIds.length ? await strapi.db.query(UID.CARD).findMany({
4362
+ where: { documentId: { $in: docIds }, season: season2.id },
4363
+ select: ["id", "documentId"]
4364
+ }) : [];
4365
+ if (cards.length !== docIds.length)
4366
+ throw badRequest(
4367
+ ERROR_CODES.VALIDATION,
4368
+ "Certaines cartes ne font pas partie de cette saison."
4369
+ );
3844
4370
  const idByDoc = Object.fromEntries(cards.map((c) => [c.documentId, c.id]));
3845
4371
  await strapi.db.transaction(async () => {
3846
4372
  await strapi.db.query(UID.DROP_CARD).deleteMany({ where: { drop: drop2.id } });
3847
4373
  for (const e of entries) {
3848
4374
  const entry = typeof e === "string" ? { card: e } : e;
3849
4375
  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 }
4376
+ data: {
4377
+ drop: drop2.id,
4378
+ card: idByDoc[entry.card],
4379
+ weight: Number.isFinite(Number(entry.weight)) ? Number(entry.weight) : 100,
4380
+ isExclusive: Boolean(entry.isExclusive),
4381
+ isActive: entry.isActive !== false
4382
+ }
3851
4383
  });
3852
4384
  }
3853
4385
  });
@@ -3855,8 +4387,18 @@ const drop = ({ strapi }) => ({
3855
4387
  },
3856
4388
  async cards(pack2, ref) {
3857
4389
  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 }));
4390
+ const rows = await strapi.db.query(UID.DROP_CARD).findMany({
4391
+ where: { drop: drop2.id },
4392
+ populate: { card: { populate: { image: true } } },
4393
+ orderBy: { id: "asc" }
4394
+ });
4395
+ return rows.filter((r) => r.card).map((r) => ({
4396
+ id: r.documentId,
4397
+ weight: r.weight,
4398
+ isExclusive: r.isExclusive,
4399
+ isActive: r.isActive,
4400
+ card: r.card
4401
+ }));
3860
4402
  },
3861
4403
  /**
3862
4404
  * Effective raw pool for a drop (before exclusivity/supply pruning): pack pool, drop pool, or both.
@@ -3870,25 +4412,46 @@ const drop = ({ strapi }) => ({
3870
4412
  });
3871
4413
  const ownEntries = own.filter((r) => r.card).map((r) => ({ ...r.card, weight: r.weight, isExclusive: r.isExclusive, source: "drop" }));
3872
4414
  const seen = new Set(ownEntries.map((c) => c.id));
3873
- return [...ownEntries, ...packPool.filter((c) => !seen.has(c.id)).map((c) => ({ ...c, source: "pack" }))];
4415
+ return [
4416
+ ...ownEntries,
4417
+ ...packPool.filter((c) => !seen.has(c.id)).map((c) => ({ ...c, source: "pack" }))
4418
+ ];
3874
4419
  },
3875
4420
  /** Card ids flagged exclusive on any pack-card or drop-card of a season. */
3876
4421
  async exclusiveCardIds(season2) {
3877
4422
  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"] } } })
4423
+ strapi.db.query(UID.PACK_CARD).findMany({
4424
+ where: { isExclusive: true, isActive: true, pack: { season: season2.id } },
4425
+ populate: { card: { select: ["id"] } }
4426
+ }),
4427
+ strapi.db.query(UID.DROP_CARD).findMany({
4428
+ where: { isExclusive: true, isActive: true, drop: { pack: { season: season2.id } } },
4429
+ populate: { card: { select: ["id"] } }
4430
+ })
3880
4431
  ]);
3881
4432
  return new Set([...pc, ...dc].map((r) => r.card?.id).filter(Boolean));
3882
4433
  },
3883
4434
  /** Where a card is exclusive, for the admin: [{ kind:'pack'|'drop', name, slug }]. */
3884
4435
  async exclusivityOf(cardId) {
3885
4436
  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"] } } } } })
4437
+ strapi.db.query(UID.PACK_CARD).findMany({
4438
+ where: { card: cardId, isExclusive: true },
4439
+ populate: { pack: { select: ["name", "slug"] } }
4440
+ }),
4441
+ strapi.db.query(UID.DROP_CARD).findMany({
4442
+ where: { card: cardId, isExclusive: true },
4443
+ populate: {
4444
+ drop: { select: ["name", "slug"], populate: { pack: { select: ["name"] } } }
4445
+ }
4446
+ })
3888
4447
  ]);
3889
4448
  return [
3890
4449
  ...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 }))
4450
+ ...dc.filter((r) => r.drop).map((r) => ({
4451
+ kind: "drop",
4452
+ name: `${r.drop.pack?.name ?? ""} · ${r.drop.name}`,
4453
+ slug: r.drop.slug
4454
+ }))
3892
4455
  ];
3893
4456
  },
3894
4457
  /** Booth / poster token. */
@@ -3901,7 +4464,10 @@ const drop = ({ strapi }) => ({
3901
4464
  /** Public shape, exclusives revealed as count (+ names unless the drop is secret). */
3902
4465
  async publicView(drop2, pack2, season2) {
3903
4466
  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 } });
4467
+ const own = drop2.poolMode === POOL_MODE.INHERIT ? [] : await strapi.db.query(UID.DROP_CARD).findMany({
4468
+ where: { drop: drop2.id, isActive: true, isExclusive: true },
4469
+ populate: { card: true }
4470
+ });
3905
4471
  return {
3906
4472
  id: drop2.documentId,
3907
4473
  name: drop2.name,
@@ -3910,12 +4476,18 @@ const drop = ({ strapi }) => ({
3910
4476
  mode: drop2.mode,
3911
4477
  isDefault: drop2.isDefault,
3912
4478
  isOpen: this.isOpen(drop2),
4479
+ cardsPerPull: Math.max(1, Number(drop2.cardsPerPull) || 1),
3913
4480
  startsAt: drop2.startsAt ?? null,
3914
4481
  endsAt: drop2.endsAt ?? null,
3915
4482
  soldOut: drop2.maxScansTotal != null && drop2.scanCount >= drop2.maxScansTotal,
3916
4483
  remaining: drop2.maxScansTotal != null ? Math.max(0, drop2.maxScansTotal - drop2.scanCount) : null,
3917
4484
  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 })),
4485
+ exclusiveCards: drop2.isSecret ? [] : own.filter((r) => r.card).map((r) => ({
4486
+ id: r.card.documentId,
4487
+ name: r.card.name,
4488
+ rarity: r.card.rarity,
4489
+ rarityColor: format2.rarityMeta(season2, r.card.rarity).color
4490
+ })),
3919
4491
  geo: drop2.geo ?? null
3920
4492
  };
3921
4493
  }