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