@sonatel-os/strapi-plugin-tcg 0.0.0-stage → 0.2.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.
@@ -0,0 +1,4484 @@
1
+ import { createHash, timingSafeEqual, createHmac, randomBytes, randomInt } from "node:crypto";
2
+ const PLUGIN_ID = "tcg";
3
+ const PLUGIN_DISPLAY_NAME = "TCG";
4
+ const LOG_PREFIX = "[tcg]";
5
+ const UID = Object.freeze({
6
+ SEASON: "plugin::tcg.season",
7
+ PACK: "plugin::tcg.pack",
8
+ CARD: "plugin::tcg.card",
9
+ PACK_CARD: "plugin::tcg.pack-card",
10
+ PLAYER: "plugin::tcg.player",
11
+ PLAYER_CARD: "plugin::tcg.player-card",
12
+ PULL: "plugin::tcg.pull",
13
+ TRADE_OFFER: "plugin::tcg.trade-offer",
14
+ TRADE: "plugin::tcg.trade",
15
+ REDEMPTION: "plugin::tcg.redemption",
16
+ NONCE: "plugin::tcg.nonce",
17
+ DROP: "plugin::tcg.drop",
18
+ DROP_CARD: "plugin::tcg.drop-card",
19
+ BOOST: "plugin::tcg.boost",
20
+ REWARD: "plugin::tcg.reward",
21
+ CARD_SET: "plugin::tcg.card-set",
22
+ BADGE: "plugin::tcg.badge",
23
+ PLAYER_BADGE: "plugin::tcg.player-badge"
24
+ });
25
+ const RBAC = Object.freeze({
26
+ ACCESS: "access",
27
+ DASHBOARD_READ: "dashboard.read",
28
+ SEASONS_READ: "seasons.read",
29
+ SEASONS_WRITE: "seasons.write",
30
+ CARDS_READ: "cards.read",
31
+ CARDS_WRITE: "cards.write",
32
+ PACKS_READ: "packs.read",
33
+ PACKS_WRITE: "packs.write",
34
+ PACKS_QR: "packs.qr",
35
+ PLAYERS_READ: "players.read",
36
+ PLAYERS_WRITE: "players.write",
37
+ REDEMPTIONS_READ: "redemptions.read",
38
+ REDEMPTIONS_FULFILL: "redemptions.fulfill",
39
+ AUDIT_READ: "audit.read",
40
+ REWARDS_READ: "rewards.read",
41
+ REWARDS_WRITE: "rewards.write",
42
+ LIVE_READ: "live.read",
43
+ LIVE_WRITE: "live.write",
44
+ SETTINGS_READ: "settings.read",
45
+ SETTINGS_WRITE: "settings.write"
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" });
49
+ const ACQUISITION = Object.freeze({ PULL: "pull", TRADE: "trade", GRANT: "grant" });
50
+ const TRADE_STATUS = Object.freeze({
51
+ PENDING: "pending",
52
+ ACCEPTED: "accepted",
53
+ REJECTED: "rejected",
54
+ EXPIRED: "expired",
55
+ CANCELLED: "cancelled"
56
+ });
57
+ const REDEMPTION_STATUS = Object.freeze({ PENDING: "pending", FULFILLED: "fulfilled", CANCELLED: "cancelled" });
58
+ const NONCE_KIND = Object.freeze({ PACK: "pack", TRADE: "trade", DROP: "drop" });
59
+ const DROP_MODE = Object.freeze({ STATIC: "static", ROTATING: "rotating" });
60
+ const POOL_MODE = Object.freeze({ INHERIT: "inherit", EXTEND: "extend", OVERRIDE: "override" });
61
+ const DEFAULT_DROP_NAME = "Entrée";
62
+ const MAX_PULL_RETRIES = 3;
63
+ const COST_RULE = Object.freeze({
64
+ CARDS: "cards",
65
+ // { type:'cards', count, rarity?, minRarity? }
66
+ CARD: "card",
67
+ // { type:'card', card:<documentId>, count? }
68
+ SET: "set"
69
+ // { type:'set', set:<slug>, consume? } consume=false → owning the set is enough, no card handed in
70
+ });
71
+ const BADGE_RULE = Object.freeze({
72
+ PULLS: "pulls",
73
+ // { type:'pulls', count }
74
+ RARITY_PULLS: "rarity_pulls",
75
+ // { type:'rarity_pulls', rarity, count }
76
+ UNIQUE_CARDS: "unique_cards",
77
+ // { type:'unique_cards', count }
78
+ PACKS_VISITED: "packs_visited",
79
+ // { type:'packs_visited', count | 'all' }
80
+ DROPS_VISITED: "drops_visited",
81
+ // { type:'drops_visited', count }
82
+ TRADES: "trades",
83
+ // { type:'trades', count }
84
+ REDEMPTIONS: "redemptions",
85
+ // { type:'redemptions', count }
86
+ SET_COMPLETE: "set_complete",
87
+ // { type:'set_complete', set:<slug> | 'any' | 'all' }
88
+ STREAK_DAYS: "streak_days",
89
+ // { type:'streak_days', count }
90
+ COMPLETION: "completion",
91
+ // { type:'completion', percent }
92
+ MINT: "mint",
93
+ // { type:'mint', max } own a copy with mintNumber <= max
94
+ SCAN_DROP: "scan_drop"
95
+ // { type:'scan_drop', drop:<slug> }
96
+ });
97
+ const DEFAULT_RARITIES = Object.freeze([
98
+ { key: "common", label: "Common", probability: 0.5, color: "#7A7A86", scoreMin: 10, scoreMax: 30 },
99
+ { key: "rare", label: "Rare", probability: 0.3, color: "#00BFFF", scoreMin: 35, scoreMax: 55 },
100
+ { 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 }
102
+ ]);
103
+ const DEFAULT_EDITIONS = Object.freeze([
104
+ { key: "standard", label: "Standard" },
105
+ { key: "limited", label: "Limited" },
106
+ { key: "holographic", label: "Holographic" }
107
+ ]);
108
+ const DEFAULT_LIMITS = Object.freeze({
109
+ pullCooldownMs: 5 * 60 * 1e3,
110
+ maxDailyPullsPerPack: 3,
111
+ maxDailyPullsTotal: 15,
112
+ maxCollectionSize: 100,
113
+ tradeOfferExpiryMs: 10 * 60 * 1e3,
114
+ maxActiveTradeOffers: 5,
115
+ maxCardsPerTrade: 3,
116
+ minCardsForRedemption: 1,
117
+ qrTokenTtlMs: 5 * 60 * 1e3,
118
+ staticScanCooldownMs: 24 * 60 * 60 * 1e3,
119
+ pityRareAfter: 0,
120
+ pityLegendaryAfter: 0
121
+ });
122
+ const DEFAULT_RANK_TIERS = Object.freeze([
123
+ { key: "legend", label: "Legend", minPercent: 100 },
124
+ { key: "champion", label: "Champion", minPercent: 80 },
125
+ { key: "elite", label: "Elite", minPercent: 60 },
126
+ { key: "expert", label: "Expert", minPercent: 40 },
127
+ { key: "explorer", label: "Explorer", minPercent: 20 },
128
+ { key: "collector", label: "Collector", minPercent: 1 },
129
+ { key: "debutant", label: "Debutant", minPercent: 0 }
130
+ ]);
131
+ const CODE_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
132
+ const PLAYER_CODE_LENGTH = 4;
133
+ const REDEMPTION_CODE_LENGTH = 6;
134
+ const QR_NONCE_BYTES = 16;
135
+ const MAX_CODE_ATTEMPTS = 10;
136
+ const NONCE_SWEEP_INTERVAL_MS = 60 * 1e3;
137
+ const TRADE_SWEEP_INTERVAL_MS = 60 * 1e3;
138
+ const DEFAULT_PAGE_SIZE = 20;
139
+ const MAX_PAGE_SIZE = 100;
140
+ const ERROR_CODES = Object.freeze({
141
+ AUTH_REQUIRED: "AUTH_REQUIRED",
142
+ AUTH_INVALID: "AUTH_INVALID",
143
+ PLAYER_REQUIRED: "PLAYER_REQUIRED",
144
+ PLAYER_NOT_FOUND: "PLAYER_NOT_FOUND",
145
+ PLAYER_INACTIVE: "PLAYER_INACTIVE",
146
+ SEASON_NOT_FOUND: "SEASON_NOT_FOUND",
147
+ SEASON_INACTIVE: "SEASON_INACTIVE",
148
+ PACK_NOT_FOUND: "PACK_NOT_FOUND",
149
+ PACK_INACTIVE: "PACK_INACTIVE",
150
+ PACK_EMPTY: "PACK_EMPTY",
151
+ QR_INVALID: "QR_INVALID",
152
+ QR_EXPIRED: "QR_EXPIRED",
153
+ QR_REUSED: "QR_REUSED",
154
+ COOLDOWN_ACTIVE: "COOLDOWN_ACTIVE",
155
+ DAILY_LIMIT_PACK: "DAILY_LIMIT_PACK",
156
+ DAILY_LIMIT_TOTAL: "DAILY_LIMIT_TOTAL",
157
+ COLLECTION_FULL: "COLLECTION_FULL",
158
+ CARD_NOT_FOUND: "CARD_NOT_FOUND",
159
+ CARD_NOT_OWNED: "CARD_NOT_OWNED",
160
+ TRADE_NOT_FOUND: "TRADE_NOT_FOUND",
161
+ TRADE_SELF: "TRADE_SELF",
162
+ TRADE_LIMIT: "TRADE_LIMIT",
163
+ TRADE_TOO_MANY_CARDS: "TRADE_TOO_MANY_CARDS",
164
+ TRADE_INVALID_STATUS: "TRADE_INVALID_STATUS",
165
+ TRADE_EXPIRED: "TRADE_EXPIRED",
166
+ TRADE_FORBIDDEN: "TRADE_FORBIDDEN",
167
+ REDEMPTION_NOT_FOUND: "REDEMPTION_NOT_FOUND",
168
+ REDEMPTION_TOO_FEW_CARDS: "REDEMPTION_TOO_FEW_CARDS",
169
+ REDEMPTION_INVALID_STATUS: "REDEMPTION_INVALID_STATUS",
170
+ DROP_NOT_FOUND: "DROP_NOT_FOUND",
171
+ DROP_CLOSED: "DROP_CLOSED",
172
+ DROP_SOLD_OUT: "DROP_SOLD_OUT",
173
+ DROP_PLAYER_LIMIT: "DROP_PLAYER_LIMIT",
174
+ REWARD_NOT_FOUND: "REWARD_NOT_FOUND",
175
+ REWARD_UNAVAILABLE: "REWARD_UNAVAILABLE",
176
+ REWARD_SOLD_OUT: "REWARD_SOLD_OUT",
177
+ REWARD_PLAYER_LIMIT: "REWARD_PLAYER_LIMIT",
178
+ REWARD_COST_UNMET: "REWARD_COST_UNMET",
179
+ VALIDATION: "VALIDATION"
180
+ });
181
+ const CATEGORY = PLUGIN_DISPLAY_NAME;
182
+ const ACTIONS = [
183
+ { section: "plugins", uid: RBAC.ACCESS, displayName: `Access the ${PLUGIN_DISPLAY_NAME} module` },
184
+ { section: "plugins", uid: RBAC.DASHBOARD_READ, displayName: "Read the dashboard" },
185
+ { section: "plugins", uid: RBAC.SEASONS_READ, displayName: "Read seasons" },
186
+ { section: "plugins", uid: RBAC.SEASONS_WRITE, displayName: "Create, edit and delete seasons" },
187
+ { section: "plugins", uid: RBAC.CARDS_READ, displayName: "Read cards" },
188
+ { section: "plugins", uid: RBAC.CARDS_WRITE, displayName: "Create, edit and delete cards" },
189
+ { section: "plugins", uid: RBAC.PACKS_READ, displayName: "Read packs" },
190
+ { section: "plugins", uid: RBAC.PACKS_WRITE, displayName: "Create, edit and delete packs" },
191
+ { section: "plugins", uid: RBAC.PACKS_QR, displayName: "Generate pack QR codes (booth)" },
192
+ { section: "plugins", uid: RBAC.PLAYERS_READ, displayName: "Read players" },
193
+ { section: "plugins", uid: RBAC.PLAYERS_WRITE, displayName: "Deactivate players, grant cards" },
194
+ { section: "plugins", uid: RBAC.REDEMPTIONS_READ, displayName: "Read redemptions" },
195
+ { section: "plugins", uid: RBAC.REDEMPTIONS_FULFILL, displayName: "Fulfill or cancel redemptions (booth)" },
196
+ { section: "plugins", uid: RBAC.AUDIT_READ, displayName: "Read the pull audit log" },
197
+ { section: "plugins", uid: RBAC.REWARDS_READ, displayName: "Read the reward catalog" },
198
+ { section: "plugins", uid: RBAC.REWARDS_WRITE, displayName: "Edit the reward catalog" },
199
+ { section: "plugins", uid: RBAC.LIVE_READ, displayName: "Read live boosts" },
200
+ { section: "plugins", uid: RBAC.LIVE_WRITE, displayName: "Create and edit live boosts" },
201
+ { section: "settings", category: CATEGORY, uid: RBAC.SETTINGS_READ, displayName: "Read settings" },
202
+ { section: "settings", category: CATEGORY, uid: RBAC.SETTINGS_WRITE, displayName: "Edit settings" }
203
+ ].map((a) => ({ ...a, pluginName: PLUGIN_ID }));
204
+ const timers = [];
205
+ const UNIQUE_COLUMNS = [
206
+ ["plugin::tcg.nonce", "nonce"],
207
+ ["plugin::tcg.player", "code"],
208
+ ["plugin::tcg.redemption", "code"]
209
+ ];
210
+ const ensureUniqueIndexes = async (strapi) => {
211
+ for (const [uid, column] of UNIQUE_COLUMNS) {
212
+ const table = strapi.db.metadata.get(uid)?.tableName;
213
+ if (!table) continue;
214
+ const name = `${table}_${column}_tcg_uq`;
215
+ try {
216
+ await strapi.db.connection.schema.alterTable(table, (t) => t.unique([column], { indexName: name }));
217
+ } catch (err) {
218
+ if (!/exist|duplicate/i.test(err.message)) strapi.log.warn(`${LOG_PREFIX} unique index ${name}: ${err.message}`);
219
+ }
220
+ }
221
+ };
222
+ const bootstrap = async ({ strapi }) => {
223
+ await strapi.service("admin::permission").actionProvider.registerMany(ACTIONS);
224
+ await ensureUniqueIndexes(strapi);
225
+ const plugin = strapi.plugin(PLUGIN_ID);
226
+ if (plugin.service("settings").sweepsEnabled()) {
227
+ const safe = (label, fn) => async () => {
228
+ try {
229
+ const n = await fn();
230
+ if (n > 0) strapi.log.debug(`${LOG_PREFIX} swept ${n} ${label}`);
231
+ } catch (err) {
232
+ strapi.log.warn(`${LOG_PREFIX} sweep ${label} failed: ${err.message}`);
233
+ }
234
+ };
235
+ const nonceTimer = setInterval(safe("nonces", () => plugin.service("qr").sweep()), NONCE_SWEEP_INTERVAL_MS);
236
+ const tradeTimer = setInterval(safe("expired offers", () => plugin.service("trade").sweep()), TRADE_SWEEP_INTERVAL_MS);
237
+ nonceTimer.unref();
238
+ tradeTimer.unref();
239
+ timers.push(nonceTimer, tradeTimer);
240
+ }
241
+ strapi.log.info(`${LOG_PREFIX} ready — ${ACTIONS.length} RBAC actions, content API at /api/${PLUGIN_ID}`);
242
+ };
243
+ const stopTimers = () => {
244
+ while (timers.length) clearInterval(timers.pop());
245
+ };
246
+ const destroy = () => {
247
+ stopTimers();
248
+ };
249
+ const register = ({ strapi }) => {
250
+ strapi.log.info(`${LOG_PREFIX} registered`);
251
+ };
252
+ const fail = (message) => {
253
+ throw new Error(`${LOG_PREFIX} ${message}`);
254
+ };
255
+ const HEADER_NAME = /^[a-z0-9-]{2,64}$/i;
256
+ const config = {
257
+ default: {
258
+ playerCodePrefix: "TCG",
259
+ qr: { globalSecret: "" },
260
+ players: {
261
+ idHeader: "x-player-id",
262
+ nameHeader: "x-player-name",
263
+ avatarHeader: "x-player-avatar",
264
+ autoProvision: true
265
+ },
266
+ sweeps: { enabled: true }
267
+ },
268
+ /**
269
+ * @param {Object} config - resolved plugin config
270
+ */
271
+ validator(config2) {
272
+ if (config2.playerCodePrefix && !/^[A-Z0-9]{2,6}$/.test(config2.playerCodePrefix)) {
273
+ fail("playerCodePrefix must be 2 to 6 uppercase letters or digits.");
274
+ }
275
+ const players = config2.players ?? {};
276
+ for (const key of ["idHeader", "nameHeader", "avatarHeader"]) {
277
+ if (players[key] != null && !HEADER_NAME.test(String(players[key]))) {
278
+ fail(`players.${key} must be a valid header name.`);
279
+ }
280
+ }
281
+ }
282
+ };
283
+ const kind$h = "collectionType";
284
+ const collectionName$h = "tcg_seasons";
285
+ const info$h = {
286
+ singularName: "season",
287
+ pluralName: "seasons",
288
+ displayName: "TCG Season",
289
+ description: "A self-contained game: its card pool, packs, rarity table, limits and rank ladder."
290
+ };
291
+ const options$h = {
292
+ draftAndPublish: false
293
+ };
294
+ const pluginOptions$h = {
295
+ "content-manager": {
296
+ visible: false
297
+ },
298
+ "content-type-builder": {
299
+ visible: false
300
+ }
301
+ };
302
+ const attributes$h = {
303
+ name: {
304
+ type: "string",
305
+ required: true,
306
+ maxLength: 120
307
+ },
308
+ slug: {
309
+ type: "uid",
310
+ targetField: "name",
311
+ required: true
312
+ },
313
+ description: {
314
+ type: "text"
315
+ },
316
+ eventName: {
317
+ type: "string",
318
+ maxLength: 160
319
+ },
320
+ status: {
321
+ type: "enumeration",
322
+ "enum": [
323
+ "draft",
324
+ "active",
325
+ "archived"
326
+ ],
327
+ "default": "draft",
328
+ required: true
329
+ },
330
+ startsAt: {
331
+ type: "datetime"
332
+ },
333
+ endsAt: {
334
+ type: "datetime"
335
+ },
336
+ cover: {
337
+ type: "media",
338
+ multiple: false,
339
+ allowedTypes: [
340
+ "images"
341
+ ]
342
+ },
343
+ rarities: {
344
+ type: "json",
345
+ required: true
346
+ },
347
+ editions: {
348
+ type: "json"
349
+ },
350
+ limits: {
351
+ type: "json",
352
+ required: true
353
+ },
354
+ rankTiers: {
355
+ type: "json",
356
+ required: true
357
+ },
358
+ theme: {
359
+ type: "json"
360
+ },
361
+ cards: {
362
+ type: "relation",
363
+ relation: "oneToMany",
364
+ target: "plugin::tcg.card",
365
+ mappedBy: "season"
366
+ },
367
+ packs: {
368
+ type: "relation",
369
+ relation: "oneToMany",
370
+ target: "plugin::tcg.pack",
371
+ mappedBy: "season"
372
+ }
373
+ };
374
+ const schema$h = {
375
+ kind: kind$h,
376
+ collectionName: collectionName$h,
377
+ info: info$h,
378
+ options: options$h,
379
+ pluginOptions: pluginOptions$h,
380
+ attributes: attributes$h
381
+ };
382
+ const season$1 = { schema: schema$h };
383
+ const kind$g = "collectionType";
384
+ const collectionName$g = "tcg_packs";
385
+ const info$g = {
386
+ singularName: "pack",
387
+ pluralName: "packs",
388
+ displayName: "TCG Pack",
389
+ description: "Something a player opens by scanning a QR: a physical location, a booster, an event booth."
390
+ };
391
+ const options$g = {
392
+ draftAndPublish: false
393
+ };
394
+ const pluginOptions$g = {
395
+ "content-manager": {
396
+ visible: false
397
+ },
398
+ "content-type-builder": {
399
+ visible: false
400
+ }
401
+ };
402
+ const attributes$g = {
403
+ season: {
404
+ type: "relation",
405
+ relation: "manyToOne",
406
+ target: "plugin::tcg.season",
407
+ inversedBy: "packs"
408
+ },
409
+ name: {
410
+ type: "string",
411
+ required: true,
412
+ maxLength: 160
413
+ },
414
+ slug: {
415
+ type: "string",
416
+ required: true,
417
+ maxLength: 120
418
+ },
419
+ kind: {
420
+ type: "enumeration",
421
+ "enum": [
422
+ "location",
423
+ "booster",
424
+ "event"
425
+ ],
426
+ "default": "location",
427
+ required: true
428
+ },
429
+ description: {
430
+ type: "text"
431
+ },
432
+ location: {
433
+ type: "string",
434
+ maxLength: 300
435
+ },
436
+ image: {
437
+ type: "media",
438
+ multiple: false,
439
+ allowedTypes: [
440
+ "images"
441
+ ]
442
+ },
443
+ isActive: {
444
+ type: "boolean",
445
+ "default": true,
446
+ required: true
447
+ },
448
+ sortOrder: {
449
+ type: "integer",
450
+ "default": 0
451
+ },
452
+ meta: {
453
+ type: "json"
454
+ },
455
+ packCards: {
456
+ type: "relation",
457
+ relation: "oneToMany",
458
+ target: "plugin::tcg.pack-card",
459
+ mappedBy: "pack"
460
+ },
461
+ drops: {
462
+ type: "relation",
463
+ relation: "oneToMany",
464
+ target: "plugin::tcg.drop",
465
+ mappedBy: "pack"
466
+ }
467
+ };
468
+ const schema$g = {
469
+ kind: kind$g,
470
+ collectionName: collectionName$g,
471
+ info: info$g,
472
+ options: options$g,
473
+ pluginOptions: pluginOptions$g,
474
+ attributes: attributes$g
475
+ };
476
+ const pack$1 = { schema: schema$g };
477
+ const kind$f = "collectionType";
478
+ const collectionName$f = "tcg_drops";
479
+ const info$f = {
480
+ singularName: "drop",
481
+ pluralName: "drops",
482
+ displayName: "TCG Drop",
483
+ description: "A scannable point inside a pack: one QR code, its own window, caps and optional card pool."
484
+ };
485
+ const options$f = {
486
+ draftAndPublish: false
487
+ };
488
+ const pluginOptions$f = {
489
+ "content-manager": {
490
+ visible: false
491
+ },
492
+ "content-type-builder": {
493
+ visible: false
494
+ }
495
+ };
496
+ const attributes$f = {
497
+ pack: {
498
+ type: "relation",
499
+ relation: "manyToOne",
500
+ target: "plugin::tcg.pack",
501
+ inversedBy: "drops"
502
+ },
503
+ name: {
504
+ type: "string",
505
+ required: true,
506
+ maxLength: 160
507
+ },
508
+ slug: {
509
+ type: "string",
510
+ required: true,
511
+ maxLength: 120
512
+ },
513
+ description: {
514
+ type: "text"
515
+ },
516
+ mode: {
517
+ type: "enumeration",
518
+ "enum": [
519
+ "static",
520
+ "rotating"
521
+ ],
522
+ "default": "static",
523
+ required: true
524
+ },
525
+ secret: {
526
+ type: "string",
527
+ "private": true,
528
+ maxLength: 255
529
+ },
530
+ isDefault: {
531
+ type: "boolean",
532
+ "default": false,
533
+ required: true
534
+ },
535
+ isActive: {
536
+ type: "boolean",
537
+ "default": true,
538
+ required: true
539
+ },
540
+ isSecret: {
541
+ type: "boolean",
542
+ "default": false,
543
+ required: true
544
+ },
545
+ startsAt: {
546
+ type: "datetime"
547
+ },
548
+ endsAt: {
549
+ type: "datetime"
550
+ },
551
+ poolMode: {
552
+ type: "enumeration",
553
+ "enum": [
554
+ "inherit",
555
+ "extend",
556
+ "override"
557
+ ],
558
+ "default": "inherit",
559
+ required: true
560
+ },
561
+ maxScansTotal: {
562
+ type: "integer"
563
+ },
564
+ maxScansPerPlayer: {
565
+ type: "integer"
566
+ },
567
+ cooldownMs: {
568
+ type: "integer"
569
+ },
570
+ scanCount: {
571
+ type: "integer",
572
+ "default": 0,
573
+ required: true
574
+ },
575
+ geo: {
576
+ type: "json"
577
+ },
578
+ sortOrder: {
579
+ type: "integer",
580
+ "default": 0
581
+ },
582
+ dropCards: {
583
+ type: "relation",
584
+ relation: "oneToMany",
585
+ target: "plugin::tcg.drop-card",
586
+ mappedBy: "drop"
587
+ }
588
+ };
589
+ const schema$f = {
590
+ kind: kind$f,
591
+ collectionName: collectionName$f,
592
+ info: info$f,
593
+ options: options$f,
594
+ pluginOptions: pluginOptions$f,
595
+ attributes: attributes$f
596
+ };
597
+ const drop$1 = { schema: schema$f };
598
+ const kind$e = "collectionType";
599
+ const collectionName$e = "tcg_cards";
600
+ const info$e = {
601
+ singularName: "card",
602
+ pluralName: "cards",
603
+ displayName: "TCG Card",
604
+ description: "A collectible card belonging to one season."
605
+ };
606
+ const options$e = {
607
+ draftAndPublish: false
608
+ };
609
+ const pluginOptions$e = {
610
+ "content-manager": {
611
+ visible: false
612
+ },
613
+ "content-type-builder": {
614
+ visible: false
615
+ }
616
+ };
617
+ const attributes$e = {
618
+ season: {
619
+ type: "relation",
620
+ relation: "manyToOne",
621
+ target: "plugin::tcg.season",
622
+ inversedBy: "cards"
623
+ },
624
+ name: {
625
+ type: "string",
626
+ required: true,
627
+ maxLength: 200
628
+ },
629
+ number: {
630
+ type: "string",
631
+ maxLength: 10
632
+ },
633
+ description: {
634
+ type: "text"
635
+ },
636
+ rarity: {
637
+ type: "string",
638
+ required: true,
639
+ maxLength: 40
640
+ },
641
+ edition: {
642
+ type: "string",
643
+ "default": "standard",
644
+ maxLength: 50
645
+ },
646
+ score: {
647
+ type: "integer",
648
+ "default": 0
649
+ },
650
+ image: {
651
+ type: "media",
652
+ multiple: false,
653
+ allowedTypes: [
654
+ "images"
655
+ ]
656
+ },
657
+ attributes: {
658
+ type: "json"
659
+ },
660
+ isActive: {
661
+ type: "boolean",
662
+ "default": true,
663
+ required: true
664
+ },
665
+ packCards: {
666
+ type: "relation",
667
+ relation: "oneToMany",
668
+ target: "plugin::tcg.pack-card",
669
+ mappedBy: "card"
670
+ },
671
+ supplyCap: {
672
+ type: "integer"
673
+ },
674
+ minted: {
675
+ type: "integer",
676
+ "default": 0,
677
+ required: true
678
+ },
679
+ lore: {
680
+ type: "text"
681
+ },
682
+ artist: {
683
+ type: "string",
684
+ maxLength: 160
685
+ },
686
+ sets: {
687
+ type: "relation",
688
+ relation: "manyToMany",
689
+ target: "plugin::tcg.card-set",
690
+ mappedBy: "cards"
691
+ }
692
+ };
693
+ const schema$e = {
694
+ kind: kind$e,
695
+ collectionName: collectionName$e,
696
+ info: info$e,
697
+ options: options$e,
698
+ pluginOptions: pluginOptions$e,
699
+ attributes: attributes$e
700
+ };
701
+ const card$1 = { schema: schema$e };
702
+ const kind$d = "collectionType";
703
+ const collectionName$d = "tcg_pack_cards";
704
+ const info$d = {
705
+ singularName: "pack-card",
706
+ pluralName: "pack-cards",
707
+ displayName: "TCG Pack Card",
708
+ description: "Which cards a pack can drop, with per-card weight."
709
+ };
710
+ const options$d = {
711
+ draftAndPublish: false
712
+ };
713
+ const pluginOptions$d = {
714
+ "content-manager": {
715
+ visible: false
716
+ },
717
+ "content-type-builder": {
718
+ visible: false
719
+ }
720
+ };
721
+ const attributes$d = {
722
+ pack: {
723
+ type: "relation",
724
+ relation: "manyToOne",
725
+ target: "plugin::tcg.pack",
726
+ inversedBy: "packCards"
727
+ },
728
+ card: {
729
+ type: "relation",
730
+ relation: "manyToOne",
731
+ target: "plugin::tcg.card",
732
+ inversedBy: "packCards"
733
+ },
734
+ weight: {
735
+ type: "integer",
736
+ "default": 100,
737
+ required: true
738
+ },
739
+ isExclusive: {
740
+ type: "boolean",
741
+ "default": false
742
+ },
743
+ isActive: {
744
+ type: "boolean",
745
+ "default": true,
746
+ required: true
747
+ }
748
+ };
749
+ const schema$d = {
750
+ kind: kind$d,
751
+ collectionName: collectionName$d,
752
+ info: info$d,
753
+ options: options$d,
754
+ pluginOptions: pluginOptions$d,
755
+ attributes: attributes$d
756
+ };
757
+ const packCard = { schema: schema$d };
758
+ const kind$c = "collectionType";
759
+ const collectionName$c = "tcg_drop_cards";
760
+ const info$c = {
761
+ singularName: "drop-card",
762
+ pluralName: "drop-cards",
763
+ displayName: "TCG Drop Card",
764
+ description: "Cards a drop adds to (or replaces) its pack pool, with weight and exclusivity."
765
+ };
766
+ const options$c = {
767
+ draftAndPublish: false
768
+ };
769
+ const pluginOptions$c = {
770
+ "content-manager": {
771
+ visible: false
772
+ },
773
+ "content-type-builder": {
774
+ visible: false
775
+ }
776
+ };
777
+ const attributes$c = {
778
+ drop: {
779
+ type: "relation",
780
+ relation: "manyToOne",
781
+ target: "plugin::tcg.drop",
782
+ inversedBy: "dropCards"
783
+ },
784
+ card: {
785
+ type: "relation",
786
+ relation: "manyToOne",
787
+ target: "plugin::tcg.card"
788
+ },
789
+ weight: {
790
+ type: "integer",
791
+ "default": 100,
792
+ required: true
793
+ },
794
+ isExclusive: {
795
+ type: "boolean",
796
+ "default": false
797
+ },
798
+ isActive: {
799
+ type: "boolean",
800
+ "default": true,
801
+ required: true
802
+ }
803
+ };
804
+ const schema$c = {
805
+ kind: kind$c,
806
+ collectionName: collectionName$c,
807
+ info: info$c,
808
+ options: options$c,
809
+ pluginOptions: pluginOptions$c,
810
+ attributes: attributes$c
811
+ };
812
+ const dropCard = { schema: schema$c };
813
+ const kind$b = "collectionType";
814
+ const collectionName$b = "tcg_card_sets";
815
+ const info$b = {
816
+ singularName: "card-set",
817
+ pluralName: "card-sets",
818
+ displayName: "TCG Card Set",
819
+ description: "A named group of cards; completing it is a goal, a badge trigger and a reward cost."
820
+ };
821
+ const options$b = {
822
+ draftAndPublish: false
823
+ };
824
+ const pluginOptions$b = {
825
+ "content-manager": {
826
+ visible: false
827
+ },
828
+ "content-type-builder": {
829
+ visible: false
830
+ }
831
+ };
832
+ const attributes$b = {
833
+ season: {
834
+ type: "relation",
835
+ relation: "manyToOne",
836
+ target: "plugin::tcg.season"
837
+ },
838
+ name: {
839
+ type: "string",
840
+ required: true,
841
+ maxLength: 160
842
+ },
843
+ slug: {
844
+ type: "string",
845
+ required: true,
846
+ maxLength: 120
847
+ },
848
+ description: {
849
+ type: "text"
850
+ },
851
+ image: {
852
+ type: "media",
853
+ multiple: false,
854
+ allowedTypes: [
855
+ "images"
856
+ ]
857
+ },
858
+ cards: {
859
+ type: "relation",
860
+ relation: "manyToMany",
861
+ target: "plugin::tcg.card",
862
+ inversedBy: "sets"
863
+ },
864
+ sortOrder: {
865
+ type: "integer",
866
+ "default": 0
867
+ },
868
+ isActive: {
869
+ type: "boolean",
870
+ "default": true,
871
+ required: true
872
+ }
873
+ };
874
+ const schema$b = {
875
+ kind: kind$b,
876
+ collectionName: collectionName$b,
877
+ info: info$b,
878
+ options: options$b,
879
+ pluginOptions: pluginOptions$b,
880
+ attributes: attributes$b
881
+ };
882
+ const cardSet = { schema: schema$b };
883
+ const kind$a = "collectionType";
884
+ const collectionName$a = "tcg_boosts";
885
+ const info$a = {
886
+ singularName: "boost",
887
+ pluralName: "boosts",
888
+ displayName: "TCG Boost",
889
+ description: "A time window where rarity odds are multiplied, season-wide or on one pack / drop."
890
+ };
891
+ const options$a = {
892
+ draftAndPublish: false
893
+ };
894
+ const pluginOptions$a = {
895
+ "content-manager": {
896
+ visible: false
897
+ },
898
+ "content-type-builder": {
899
+ visible: false
900
+ }
901
+ };
902
+ const attributes$a = {
903
+ season: {
904
+ type: "relation",
905
+ relation: "manyToOne",
906
+ target: "plugin::tcg.season"
907
+ },
908
+ pack: {
909
+ type: "relation",
910
+ relation: "manyToOne",
911
+ target: "plugin::tcg.pack"
912
+ },
913
+ drop: {
914
+ type: "relation",
915
+ relation: "manyToOne",
916
+ target: "plugin::tcg.drop"
917
+ },
918
+ name: {
919
+ type: "string",
920
+ required: true,
921
+ maxLength: 160
922
+ },
923
+ description: {
924
+ type: "text"
925
+ },
926
+ multipliers: {
927
+ type: "json",
928
+ required: true
929
+ },
930
+ startsAt: {
931
+ type: "datetime"
932
+ },
933
+ endsAt: {
934
+ type: "datetime"
935
+ },
936
+ isActive: {
937
+ type: "boolean",
938
+ "default": true,
939
+ required: true
940
+ },
941
+ announce: {
942
+ type: "boolean",
943
+ "default": true,
944
+ required: true
945
+ }
946
+ };
947
+ const schema$a = {
948
+ kind: kind$a,
949
+ collectionName: collectionName$a,
950
+ info: info$a,
951
+ options: options$a,
952
+ pluginOptions: pluginOptions$a,
953
+ attributes: attributes$a
954
+ };
955
+ const boost$1 = { schema: schema$a };
956
+ const kind$9 = "collectionType";
957
+ const collectionName$9 = "tcg_rewards";
958
+ const info$9 = {
959
+ singularName: "reward",
960
+ pluralName: "rewards",
961
+ displayName: "TCG Reward",
962
+ description: "Something physical a player can claim at a booth by handing cards in."
963
+ };
964
+ const options$9 = {
965
+ draftAndPublish: false
966
+ };
967
+ const pluginOptions$9 = {
968
+ "content-manager": {
969
+ visible: false
970
+ },
971
+ "content-type-builder": {
972
+ visible: false
973
+ }
974
+ };
975
+ const attributes$9 = {
976
+ season: {
977
+ type: "relation",
978
+ relation: "manyToOne",
979
+ target: "plugin::tcg.season"
980
+ },
981
+ name: {
982
+ type: "string",
983
+ required: true,
984
+ maxLength: 160
985
+ },
986
+ slug: {
987
+ type: "string",
988
+ required: true,
989
+ maxLength: 120
990
+ },
991
+ description: {
992
+ type: "text"
993
+ },
994
+ image: {
995
+ type: "media",
996
+ multiple: false,
997
+ allowedTypes: [
998
+ "images"
999
+ ]
1000
+ },
1001
+ cost: {
1002
+ type: "json",
1003
+ required: true
1004
+ },
1005
+ stock: {
1006
+ type: "integer"
1007
+ },
1008
+ claimed: {
1009
+ type: "integer",
1010
+ "default": 0,
1011
+ required: true
1012
+ },
1013
+ perPlayerLimit: {
1014
+ type: "integer"
1015
+ },
1016
+ booth: {
1017
+ type: "string",
1018
+ maxLength: 200
1019
+ },
1020
+ isActive: {
1021
+ type: "boolean",
1022
+ "default": true,
1023
+ required: true
1024
+ },
1025
+ startsAt: {
1026
+ type: "datetime"
1027
+ },
1028
+ endsAt: {
1029
+ type: "datetime"
1030
+ },
1031
+ sortOrder: {
1032
+ type: "integer",
1033
+ "default": 0
1034
+ },
1035
+ tier: {
1036
+ type: "enumeration",
1037
+ "enum": [
1038
+ "bronze",
1039
+ "silver",
1040
+ "gold",
1041
+ "legend"
1042
+ ],
1043
+ "default": "bronze"
1044
+ }
1045
+ };
1046
+ const schema$9 = {
1047
+ kind: kind$9,
1048
+ collectionName: collectionName$9,
1049
+ info: info$9,
1050
+ options: options$9,
1051
+ pluginOptions: pluginOptions$9,
1052
+ attributes: attributes$9
1053
+ };
1054
+ const reward$1 = { schema: schema$9 };
1055
+ const kind$8 = "collectionType";
1056
+ const collectionName$8 = "tcg_badges";
1057
+ const info$8 = {
1058
+ singularName: "badge",
1059
+ pluralName: "badges",
1060
+ displayName: "TCG Badge",
1061
+ description: "An achievement with a rule; awarded automatically after pulls, trades and redemptions."
1062
+ };
1063
+ const options$8 = {
1064
+ draftAndPublish: false
1065
+ };
1066
+ const pluginOptions$8 = {
1067
+ "content-manager": {
1068
+ visible: false
1069
+ },
1070
+ "content-type-builder": {
1071
+ visible: false
1072
+ }
1073
+ };
1074
+ const attributes$8 = {
1075
+ season: {
1076
+ type: "relation",
1077
+ relation: "manyToOne",
1078
+ target: "plugin::tcg.season"
1079
+ },
1080
+ key: {
1081
+ type: "string",
1082
+ required: true,
1083
+ maxLength: 60
1084
+ },
1085
+ name: {
1086
+ type: "string",
1087
+ required: true,
1088
+ maxLength: 160
1089
+ },
1090
+ description: {
1091
+ type: "text"
1092
+ },
1093
+ icon: {
1094
+ type: "string",
1095
+ maxLength: 16
1096
+ },
1097
+ image: {
1098
+ type: "media",
1099
+ multiple: false,
1100
+ allowedTypes: [
1101
+ "images"
1102
+ ]
1103
+ },
1104
+ rule: {
1105
+ type: "json",
1106
+ required: true
1107
+ },
1108
+ tier: {
1109
+ type: "enumeration",
1110
+ "enum": [
1111
+ "bronze",
1112
+ "silver",
1113
+ "gold",
1114
+ "legend"
1115
+ ],
1116
+ "default": "bronze"
1117
+ },
1118
+ isSecret: {
1119
+ type: "boolean",
1120
+ "default": false,
1121
+ required: true
1122
+ },
1123
+ isActive: {
1124
+ type: "boolean",
1125
+ "default": true,
1126
+ required: true
1127
+ },
1128
+ sortOrder: {
1129
+ type: "integer",
1130
+ "default": 0
1131
+ }
1132
+ };
1133
+ const schema$8 = {
1134
+ kind: kind$8,
1135
+ collectionName: collectionName$8,
1136
+ info: info$8,
1137
+ options: options$8,
1138
+ pluginOptions: pluginOptions$8,
1139
+ attributes: attributes$8
1140
+ };
1141
+ const badge$1 = { schema: schema$8 };
1142
+ const kind$7 = "collectionType";
1143
+ const collectionName$7 = "tcg_players";
1144
+ const info$7 = {
1145
+ singularName: "player",
1146
+ pluralName: "players",
1147
+ displayName: "TCG Player",
1148
+ description: "A person playing, identified by the external id the host sends in the player header."
1149
+ };
1150
+ const options$7 = {
1151
+ draftAndPublish: false
1152
+ };
1153
+ const pluginOptions$7 = {
1154
+ "content-manager": {
1155
+ visible: false
1156
+ },
1157
+ "content-type-builder": {
1158
+ visible: false
1159
+ }
1160
+ };
1161
+ const attributes$7 = {
1162
+ externalId: {
1163
+ type: "string",
1164
+ unique: true,
1165
+ maxLength: 190
1166
+ },
1167
+ msisdn: {
1168
+ type: "string",
1169
+ "private": true,
1170
+ maxLength: 20
1171
+ },
1172
+ code: {
1173
+ type: "string",
1174
+ required: true,
1175
+ unique: true,
1176
+ maxLength: 16
1177
+ },
1178
+ displayName: {
1179
+ type: "string",
1180
+ maxLength: 100
1181
+ },
1182
+ avatarUrl: {
1183
+ type: "string",
1184
+ maxLength: 500
1185
+ },
1186
+ provider: {
1187
+ type: "string",
1188
+ "default": "api",
1189
+ maxLength: 40
1190
+ },
1191
+ isGuest: {
1192
+ type: "boolean",
1193
+ "default": false,
1194
+ required: true
1195
+ },
1196
+ isActive: {
1197
+ type: "boolean",
1198
+ "default": true,
1199
+ required: true
1200
+ },
1201
+ totalCards: {
1202
+ type: "integer",
1203
+ "default": 0,
1204
+ required: true
1205
+ },
1206
+ totalTrades: {
1207
+ type: "integer",
1208
+ "default": 0,
1209
+ required: true
1210
+ },
1211
+ totalRedemptions: {
1212
+ type: "integer",
1213
+ "default": 0,
1214
+ required: true
1215
+ },
1216
+ lastActiveAt: {
1217
+ type: "datetime"
1218
+ },
1219
+ meta: {
1220
+ type: "json"
1221
+ }
1222
+ };
1223
+ const schema$7 = {
1224
+ kind: kind$7,
1225
+ collectionName: collectionName$7,
1226
+ info: info$7,
1227
+ options: options$7,
1228
+ pluginOptions: pluginOptions$7,
1229
+ attributes: attributes$7
1230
+ };
1231
+ const player$2 = { schema: schema$7 };
1232
+ const kind$6 = "collectionType";
1233
+ const collectionName$6 = "tcg_player_cards";
1234
+ const info$6 = {
1235
+ singularName: "player-card",
1236
+ pluralName: "player-cards",
1237
+ displayName: "TCG Player Card",
1238
+ description: "One owned copy of a card."
1239
+ };
1240
+ const options$6 = {
1241
+ draftAndPublish: false
1242
+ };
1243
+ const pluginOptions$6 = {
1244
+ "content-manager": {
1245
+ visible: false
1246
+ },
1247
+ "content-type-builder": {
1248
+ visible: false
1249
+ }
1250
+ };
1251
+ const attributes$6 = {
1252
+ player: {
1253
+ type: "relation",
1254
+ relation: "manyToOne",
1255
+ target: "plugin::tcg.player"
1256
+ },
1257
+ card: {
1258
+ type: "relation",
1259
+ relation: "manyToOne",
1260
+ target: "plugin::tcg.card"
1261
+ },
1262
+ pack: {
1263
+ type: "relation",
1264
+ relation: "manyToOne",
1265
+ target: "plugin::tcg.pack"
1266
+ },
1267
+ acquiredFrom: {
1268
+ type: "enumeration",
1269
+ "enum": [
1270
+ "pull",
1271
+ "trade",
1272
+ "grant"
1273
+ ],
1274
+ "default": "pull",
1275
+ required: true
1276
+ },
1277
+ acquiredAt: {
1278
+ type: "datetime",
1279
+ required: true
1280
+ },
1281
+ sourceTrade: {
1282
+ type: "relation",
1283
+ relation: "manyToOne",
1284
+ target: "plugin::tcg.trade"
1285
+ },
1286
+ mintNumber: {
1287
+ type: "integer"
1288
+ }
1289
+ };
1290
+ const schema$6 = {
1291
+ kind: kind$6,
1292
+ collectionName: collectionName$6,
1293
+ info: info$6,
1294
+ options: options$6,
1295
+ pluginOptions: pluginOptions$6,
1296
+ attributes: attributes$6
1297
+ };
1298
+ const playerCard = { schema: schema$6 };
1299
+ const kind$5 = "collectionType";
1300
+ const collectionName$5 = "tcg_player_badges";
1301
+ const info$5 = {
1302
+ singularName: "player-badge",
1303
+ pluralName: "player-badges",
1304
+ displayName: "TCG Player Badge",
1305
+ description: "A badge earned by a player."
1306
+ };
1307
+ const options$5 = {
1308
+ draftAndPublish: false
1309
+ };
1310
+ const pluginOptions$5 = {
1311
+ "content-manager": {
1312
+ visible: false
1313
+ },
1314
+ "content-type-builder": {
1315
+ visible: false
1316
+ }
1317
+ };
1318
+ const attributes$5 = {
1319
+ player: {
1320
+ type: "relation",
1321
+ relation: "manyToOne",
1322
+ target: "plugin::tcg.player"
1323
+ },
1324
+ badge: {
1325
+ type: "relation",
1326
+ relation: "manyToOne",
1327
+ target: "plugin::tcg.badge"
1328
+ },
1329
+ earnedAt: {
1330
+ type: "datetime",
1331
+ required: true
1332
+ },
1333
+ context: {
1334
+ type: "json"
1335
+ }
1336
+ };
1337
+ const schema$5 = {
1338
+ kind: kind$5,
1339
+ collectionName: collectionName$5,
1340
+ info: info$5,
1341
+ options: options$5,
1342
+ pluginOptions: pluginOptions$5,
1343
+ attributes: attributes$5
1344
+ };
1345
+ const playerBadge = { schema: schema$5 };
1346
+ const kind$4 = "collectionType";
1347
+ const collectionName$4 = "tcg_pulls";
1348
+ const info$4 = {
1349
+ singularName: "pull",
1350
+ pluralName: "pulls",
1351
+ displayName: "TCG Pull",
1352
+ description: "Audit trail of every scan that produced a card."
1353
+ };
1354
+ const options$4 = {
1355
+ draftAndPublish: false
1356
+ };
1357
+ const pluginOptions$4 = {
1358
+ "content-manager": {
1359
+ visible: false
1360
+ },
1361
+ "content-type-builder": {
1362
+ visible: false
1363
+ }
1364
+ };
1365
+ const attributes$4 = {
1366
+ player: {
1367
+ type: "relation",
1368
+ relation: "manyToOne",
1369
+ target: "plugin::tcg.player"
1370
+ },
1371
+ pack: {
1372
+ type: "relation",
1373
+ relation: "manyToOne",
1374
+ target: "plugin::tcg.pack"
1375
+ },
1376
+ card: {
1377
+ type: "relation",
1378
+ relation: "manyToOne",
1379
+ target: "plugin::tcg.card"
1380
+ },
1381
+ season: {
1382
+ type: "relation",
1383
+ relation: "manyToOne",
1384
+ target: "plugin::tcg.season"
1385
+ },
1386
+ seed: {
1387
+ type: "string",
1388
+ required: true,
1389
+ maxLength: 100
1390
+ },
1391
+ roll: {
1392
+ type: "decimal",
1393
+ required: true
1394
+ },
1395
+ rarity: {
1396
+ type: "string",
1397
+ required: true,
1398
+ maxLength: 40
1399
+ },
1400
+ verificationHash: {
1401
+ type: "string",
1402
+ maxLength: 32
1403
+ },
1404
+ qrToken: {
1405
+ type: "string",
1406
+ maxLength: 500
1407
+ },
1408
+ pulledAt: {
1409
+ type: "datetime",
1410
+ required: true
1411
+ },
1412
+ drop: {
1413
+ type: "relation",
1414
+ relation: "manyToOne",
1415
+ target: "plugin::tcg.drop"
1416
+ },
1417
+ odds: {
1418
+ type: "json"
1419
+ },
1420
+ pity: {
1421
+ type: "boolean",
1422
+ "default": false
1423
+ },
1424
+ mintNumber: {
1425
+ type: "integer"
1426
+ }
1427
+ };
1428
+ const schema$4 = {
1429
+ kind: kind$4,
1430
+ collectionName: collectionName$4,
1431
+ info: info$4,
1432
+ options: options$4,
1433
+ pluginOptions: pluginOptions$4,
1434
+ attributes: attributes$4
1435
+ };
1436
+ const pull$2 = { schema: schema$4 };
1437
+ const kind$3 = "collectionType";
1438
+ const collectionName$3 = "tcg_trade_offers";
1439
+ const info$3 = {
1440
+ singularName: "trade-offer",
1441
+ pluralName: "trade-offers",
1442
+ displayName: "TCG Trade Offer",
1443
+ description: "A pending P2P proposal carried by a QR code."
1444
+ };
1445
+ const options$3 = {
1446
+ draftAndPublish: false
1447
+ };
1448
+ const pluginOptions$3 = {
1449
+ "content-manager": {
1450
+ visible: false
1451
+ },
1452
+ "content-type-builder": {
1453
+ visible: false
1454
+ }
1455
+ };
1456
+ const attributes$3 = {
1457
+ initiator: {
1458
+ type: "relation",
1459
+ relation: "manyToOne",
1460
+ target: "plugin::tcg.player"
1461
+ },
1462
+ season: {
1463
+ type: "relation",
1464
+ relation: "manyToOne",
1465
+ target: "plugin::tcg.season"
1466
+ },
1467
+ status: {
1468
+ type: "enumeration",
1469
+ "enum": [
1470
+ "pending",
1471
+ "accepted",
1472
+ "rejected",
1473
+ "expired",
1474
+ "cancelled"
1475
+ ],
1476
+ "default": "pending",
1477
+ required: true
1478
+ },
1479
+ offeredCardIds: {
1480
+ type: "json",
1481
+ required: true
1482
+ },
1483
+ qrToken: {
1484
+ type: "string",
1485
+ maxLength: 500
1486
+ },
1487
+ expiresAt: {
1488
+ type: "datetime",
1489
+ required: true
1490
+ }
1491
+ };
1492
+ const schema$3 = {
1493
+ kind: kind$3,
1494
+ collectionName: collectionName$3,
1495
+ info: info$3,
1496
+ options: options$3,
1497
+ pluginOptions: pluginOptions$3,
1498
+ attributes: attributes$3
1499
+ };
1500
+ const tradeOffer = { schema: schema$3 };
1501
+ const kind$2 = "collectionType";
1502
+ const collectionName$2 = "tcg_trades";
1503
+ const info$2 = {
1504
+ singularName: "trade",
1505
+ pluralName: "trades",
1506
+ displayName: "TCG Trade",
1507
+ description: "A completed swap between two players."
1508
+ };
1509
+ const options$2 = {
1510
+ draftAndPublish: false
1511
+ };
1512
+ const pluginOptions$2 = {
1513
+ "content-manager": {
1514
+ visible: false
1515
+ },
1516
+ "content-type-builder": {
1517
+ visible: false
1518
+ }
1519
+ };
1520
+ const attributes$2 = {
1521
+ offer: {
1522
+ type: "relation",
1523
+ relation: "oneToOne",
1524
+ target: "plugin::tcg.trade-offer"
1525
+ },
1526
+ initiator: {
1527
+ type: "relation",
1528
+ relation: "manyToOne",
1529
+ target: "plugin::tcg.player"
1530
+ },
1531
+ receiver: {
1532
+ type: "relation",
1533
+ relation: "manyToOne",
1534
+ target: "plugin::tcg.player"
1535
+ },
1536
+ season: {
1537
+ type: "relation",
1538
+ relation: "manyToOne",
1539
+ target: "plugin::tcg.season"
1540
+ },
1541
+ initiatorCardIds: {
1542
+ type: "json",
1543
+ required: true
1544
+ },
1545
+ receiverCardIds: {
1546
+ type: "json",
1547
+ required: true
1548
+ },
1549
+ completedAt: {
1550
+ type: "datetime",
1551
+ required: true
1552
+ }
1553
+ };
1554
+ const schema$2 = {
1555
+ kind: kind$2,
1556
+ collectionName: collectionName$2,
1557
+ info: info$2,
1558
+ options: options$2,
1559
+ pluginOptions: pluginOptions$2,
1560
+ attributes: attributes$2
1561
+ };
1562
+ const trade$1 = { schema: schema$2 };
1563
+ const kind$1 = "collectionType";
1564
+ const collectionName$1 = "tcg_redemptions";
1565
+ const info$1 = {
1566
+ singularName: "redemption",
1567
+ pluralName: "redemptions",
1568
+ displayName: "TCG Redemption",
1569
+ description: "Cards handed in for a physical reward, validated at a booth."
1570
+ };
1571
+ const options$1 = {
1572
+ draftAndPublish: false
1573
+ };
1574
+ const pluginOptions$1 = {
1575
+ "content-manager": {
1576
+ visible: false
1577
+ },
1578
+ "content-type-builder": {
1579
+ visible: false
1580
+ }
1581
+ };
1582
+ const attributes$1 = {
1583
+ player: {
1584
+ type: "relation",
1585
+ relation: "manyToOne",
1586
+ target: "plugin::tcg.player"
1587
+ },
1588
+ season: {
1589
+ type: "relation",
1590
+ relation: "manyToOne",
1591
+ target: "plugin::tcg.season"
1592
+ },
1593
+ code: {
1594
+ type: "string",
1595
+ required: true,
1596
+ unique: true,
1597
+ maxLength: 16
1598
+ },
1599
+ cards: {
1600
+ type: "json",
1601
+ required: true
1602
+ },
1603
+ rewardType: {
1604
+ type: "string",
1605
+ maxLength: 100
1606
+ },
1607
+ rewardDescription: {
1608
+ type: "string",
1609
+ maxLength: 500
1610
+ },
1611
+ status: {
1612
+ type: "enumeration",
1613
+ "enum": [
1614
+ "pending",
1615
+ "fulfilled",
1616
+ "cancelled"
1617
+ ],
1618
+ "default": "pending",
1619
+ required: true
1620
+ },
1621
+ redeemedAt: {
1622
+ type: "datetime",
1623
+ required: true
1624
+ },
1625
+ fulfilledAt: {
1626
+ type: "datetime"
1627
+ },
1628
+ fulfilledBy: {
1629
+ type: "string",
1630
+ maxLength: 255
1631
+ },
1632
+ note: {
1633
+ type: "text"
1634
+ },
1635
+ reward: {
1636
+ type: "relation",
1637
+ relation: "manyToOne",
1638
+ target: "plugin::tcg.reward"
1639
+ },
1640
+ rewardName: {
1641
+ type: "string",
1642
+ maxLength: 160
1643
+ }
1644
+ };
1645
+ const schema$1 = {
1646
+ kind: kind$1,
1647
+ collectionName: collectionName$1,
1648
+ info: info$1,
1649
+ options: options$1,
1650
+ pluginOptions: pluginOptions$1,
1651
+ attributes: attributes$1
1652
+ };
1653
+ const redemption$1 = { schema: schema$1 };
1654
+ const kind = "collectionType";
1655
+ const collectionName = "tcg_nonces";
1656
+ const info = {
1657
+ singularName: "nonce",
1658
+ pluralName: "nonces",
1659
+ displayName: "TCG QR Nonce",
1660
+ description: "Anti-replay ledger for consumed QR tokens."
1661
+ };
1662
+ const options = {
1663
+ draftAndPublish: false
1664
+ };
1665
+ const pluginOptions = {
1666
+ "content-manager": {
1667
+ visible: false
1668
+ },
1669
+ "content-type-builder": {
1670
+ visible: false
1671
+ }
1672
+ };
1673
+ const attributes = {
1674
+ nonce: {
1675
+ type: "string",
1676
+ required: true,
1677
+ unique: true,
1678
+ maxLength: 100
1679
+ },
1680
+ kind: {
1681
+ type: "string",
1682
+ maxLength: 20
1683
+ },
1684
+ expiresAt: {
1685
+ type: "datetime",
1686
+ required: true
1687
+ }
1688
+ };
1689
+ const schema = {
1690
+ kind,
1691
+ collectionName,
1692
+ info,
1693
+ options,
1694
+ pluginOptions,
1695
+ attributes
1696
+ };
1697
+ const nonce = { schema };
1698
+ const contentTypes = {
1699
+ season: season$1,
1700
+ pack: pack$1,
1701
+ drop: drop$1,
1702
+ card: card$1,
1703
+ "pack-card": packCard,
1704
+ "drop-card": dropCard,
1705
+ "card-set": cardSet,
1706
+ boost: boost$1,
1707
+ reward: reward$1,
1708
+ badge: badge$1,
1709
+ player: player$2,
1710
+ "player-card": playerCard,
1711
+ "player-badge": playerBadge,
1712
+ pull: pull$2,
1713
+ "trade-offer": tradeOffer,
1714
+ trade: trade$1,
1715
+ redemption: redemption$1,
1716
+ nonce
1717
+ };
1718
+ class TcgError extends Error {
1719
+ /**
1720
+ * @param {number} status HTTP status
1721
+ * @param {string} code stable machine code (see constants.ERROR_CODES)
1722
+ * @param {string} message human message (French is fine; the client branches on `code`)
1723
+ * @param {Object} [details]
1724
+ */
1725
+ constructor(status, code, message, details) {
1726
+ super(message);
1727
+ this.name = "TcgError";
1728
+ this.status = status;
1729
+ this.code = code;
1730
+ this.details = details;
1731
+ }
1732
+ }
1733
+ const badRequest = (code, message, details) => new TcgError(400, code, message, details);
1734
+ const forbidden = (code, message, details) => new TcgError(403, code, message, details);
1735
+ const notFound = (code, message, details) => new TcgError(404, code, message, details);
1736
+ const conflict = (code, message, details) => new TcgError(409, code, message, details);
1737
+ const unprocessable = (code, message, details) => new TcgError(422, code, message, details);
1738
+ const sendError = (ctx, err, strapi) => {
1739
+ if (err instanceof TcgError) {
1740
+ ctx.status = err.status;
1741
+ ctx.body = { error: { status: err.status, code: err.code, message: err.message, details: err.details } };
1742
+ return;
1743
+ }
1744
+ strapi?.log?.error(`[tcg] unexpected: ${err?.stack || err}`);
1745
+ ctx.status = 500;
1746
+ ctx.body = { error: { status: 500, code: "INTERNAL", message: "Une erreur interne est survenue." } };
1747
+ };
1748
+ const parsePagination = (query = {}) => {
1749
+ const page = Math.max(1, parseInt(query.page, 10) || 1);
1750
+ const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, parseInt(query.pageSize ?? query.limit, 10) || DEFAULT_PAGE_SIZE));
1751
+ return { page, pageSize, offset: (page - 1) * pageSize, limit: pageSize };
1752
+ };
1753
+ const paged = (items, total, { page, pageSize }) => ({
1754
+ data: items,
1755
+ meta: { pagination: { page, pageSize, total, pageCount: Math.max(1, Math.ceil(total / pageSize)) } }
1756
+ });
1757
+ const handle = (strapi, fn) => async (ctx) => {
1758
+ try {
1759
+ await fn(ctx);
1760
+ } catch (err) {
1761
+ sendError(ctx, err, strapi);
1762
+ }
1763
+ };
1764
+ const body = (ctx) => ctx.request.body && typeof ctx.request.body === "object" ? ctx.request.body.data ?? ctx.request.body : {};
1765
+ const ok = (ctx, data, meta) => {
1766
+ ctx.body = { data };
1767
+ };
1768
+ const created = (ctx, data) => {
1769
+ ctx.status = 201;
1770
+ ctx.body = { data };
1771
+ };
1772
+ const game = ({ strapi }) => {
1773
+ const svc = (name) => strapi.plugin("tcg").service(name);
1774
+ const seasonOf = (ctx) => svc("season").resolve(ctx.query.season);
1775
+ return {
1776
+ // ── Player ───────────────────────────────────────────────────────────
1777
+ me: handle(strapi, async (ctx) => {
1778
+ ok(ctx, { ...svc("format").player(ctx.state.tcgPlayer), isNew: Boolean(ctx.state.tcgPlayerIsNew) });
1779
+ }),
1780
+ updateMe: handle(strapi, async (ctx) => {
1781
+ const { displayName, avatarUrl } = body(ctx);
1782
+ const data = {};
1783
+ if (typeof displayName === "string") data.displayName = displayName.slice(0, 100);
1784
+ if (typeof avatarUrl === "string") data.avatarUrl = avatarUrl.slice(0, 500);
1785
+ const player2 = await strapi.db.query("plugin::tcg.player").update({ where: { id: ctx.state.tcgPlayer.id }, data });
1786
+ ok(ctx, svc("format").player(player2));
1787
+ }),
1788
+ // ── Season / catalog ─────────────────────────────────────────────────
1789
+ currentSeason: handle(strapi, async (ctx) => {
1790
+ const season2 = await seasonOf(ctx);
1791
+ ok(ctx, svc("format").season(season2));
1792
+ }),
1793
+ packs: handle(strapi, async (ctx) => {
1794
+ const season2 = await seasonOf(ctx);
1795
+ const packs = await svc("pack").list(season2);
1796
+ ok(ctx, packs.map((p) => svc("format").pack(p)));
1797
+ }),
1798
+ pack: handle(strapi, async (ctx) => {
1799
+ const season2 = await seasonOf(ctx);
1800
+ 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)]);
1802
+ ok(ctx, {
1803
+ ...svc("format").pack(pack2),
1804
+ cards: pool.filter((c) => !exclusiveIds.has(c.id) || c.isExclusive).map((c) => ({ ...svc("format").card(c, season2), isExclusive: c.isExclusive })),
1805
+ drops: await Promise.all(drops.map((d) => svc("drop").publicView(d, pack2, season2)))
1806
+ });
1807
+ }),
1808
+ /** Everything live right now: boosts, open limited drops, low-stock rewards. For the app's home screen. */
1809
+ live: handle(strapi, async (ctx) => {
1810
+ const season2 = await seasonOf(ctx);
1811
+ const packs = await svc("pack").list(season2);
1812
+ const windows = [];
1813
+ for (const pack2 of packs) {
1814
+ const drops = await svc("drop").list(pack2, { includeInactive: false });
1815
+ for (const d of drops) {
1816
+ if (d.isDefault && !d.startsAt && !d.endsAt && d.maxScansTotal == null) continue;
1817
+ const v = await svc("drop").publicView(d, pack2, season2);
1818
+ windows.push({ ...v, pack: { id: pack2.documentId, name: pack2.name, slug: pack2.slug } });
1819
+ }
1820
+ }
1821
+ ok(ctx, { boosts: await svc("boost").liveNow(season2), drops: windows, serverTime: /* @__PURE__ */ new Date() });
1822
+ }),
1823
+ sets: handle(strapi, async (ctx) => {
1824
+ const season2 = await seasonOf(ctx);
1825
+ const sets = await svc("set").list(season2, { includeInactive: false });
1826
+ const owned = ctx.state.tcgPlayer ? new Set(await svc("collection").ownedCardIds(ctx.state.tcgPlayer, season2)) : /* @__PURE__ */ new Set();
1827
+ ok(ctx, svc("set").progress(sets, owned, season2));
1828
+ }),
1829
+ badges: handle(strapi, async (ctx) => {
1830
+ const season2 = await seasonOf(ctx);
1831
+ ok(ctx, await svc("badge").forPlayer(ctx.state.tcgPlayer, season2));
1832
+ }),
1833
+ rewards: handle(strapi, async (ctx) => {
1834
+ const season2 = await seasonOf(ctx);
1835
+ 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)));
1838
+ }),
1839
+ rewardEligibility: handle(strapi, async (ctx) => {
1840
+ const season2 = await seasonOf(ctx);
1841
+ ok(ctx, await svc("reward").eligibility(ctx.state.tcgPlayer, season2));
1842
+ }),
1843
+ cards: handle(strapi, async (ctx) => {
1844
+ const season2 = await seasonOf(ctx);
1845
+ 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);
1848
+ }),
1849
+ card: handle(strapi, async (ctx) => {
1850
+ const card2 = await svc("card").findOne(ctx.params.id);
1851
+ const season2 = await svc("season").resolve(card2.season?.documentId);
1852
+ ok(ctx, svc("format").card(card2, season2));
1853
+ }),
1854
+ // ── Collection ───────────────────────────────────────────────────────
1855
+ collection: handle(strapi, async (ctx) => {
1856
+ const season2 = await seasonOf(ctx);
1857
+ const page = parsePagination(ctx.query);
1858
+ const { items, total } = await svc("collection").list(ctx.state.tcgPlayer, season2, { rarity: ctx.query.rarity }, page);
1859
+ ctx.body = paged(items, total, page);
1860
+ }),
1861
+ collectionStats: handle(strapi, async (ctx) => {
1862
+ const season2 = await seasonOf(ctx);
1863
+ ok(ctx, await svc("collection").stats(ctx.state.tcgPlayer, season2));
1864
+ }),
1865
+ // ── Pulls ────────────────────────────────────────────────────────────
1866
+ scan: handle(strapi, async (ctx) => {
1867
+ const { qrToken } = body(ctx);
1868
+ created(ctx, await svc("pull").scan(ctx.state.tcgPlayer, String(qrToken ?? "")));
1869
+ }),
1870
+ pullHistory: handle(strapi, async (ctx) => {
1871
+ const season2 = await seasonOf(ctx);
1872
+ const page = parsePagination(ctx.query);
1873
+ const { items, total } = await svc("pull").history(ctx.state.tcgPlayer, season2, page);
1874
+ ctx.body = paged(items, total, page);
1875
+ }),
1876
+ cooldowns: handle(strapi, async (ctx) => {
1877
+ const season2 = await seasonOf(ctx);
1878
+ ok(ctx, await svc("pull").cooldowns(ctx.state.tcgPlayer, season2));
1879
+ }),
1880
+ // ── Trades ───────────────────────────────────────────────────────────
1881
+ createOffer: handle(strapi, async (ctx) => {
1882
+ const season2 = await seasonOf(ctx);
1883
+ created(ctx, await svc("trade").createOffer(ctx.state.tcgPlayer, season2, { cardIds: body(ctx).cardIds }));
1884
+ }),
1885
+ scanOffer: handle(strapi, async (ctx) => {
1886
+ ok(ctx, await svc("trade").scan(ctx.state.tcgPlayer, String(body(ctx).qrToken ?? "")));
1887
+ }),
1888
+ getOffer: handle(strapi, async (ctx) => {
1889
+ const offer = await svc("trade").findOffer(ctx.params.id);
1890
+ ok(ctx, await svc("trade").describe(offer, offer.season));
1891
+ }),
1892
+ acceptOffer: handle(strapi, async (ctx) => {
1893
+ ok(ctx, await svc("trade").accept(ctx.state.tcgPlayer, ctx.params.id, { cardIds: body(ctx).cardIds }));
1894
+ }),
1895
+ rejectOffer: handle(strapi, async (ctx) => {
1896
+ ok(ctx, await svc("trade").reject(ctx.state.tcgPlayer, ctx.params.id));
1897
+ }),
1898
+ cancelOffer: handle(strapi, async (ctx) => {
1899
+ ok(ctx, await svc("trade").cancel(ctx.state.tcgPlayer, ctx.params.id));
1900
+ }),
1901
+ activeOffers: handle(strapi, async (ctx) => {
1902
+ const season2 = await seasonOf(ctx);
1903
+ ok(ctx, await svc("trade").activeOffers(ctx.state.tcgPlayer, season2));
1904
+ }),
1905
+ tradeHistory: handle(strapi, async (ctx) => {
1906
+ const season2 = await seasonOf(ctx);
1907
+ const page = parsePagination(ctx.query);
1908
+ const { items, total } = await svc("trade").history(ctx.state.tcgPlayer, season2, page);
1909
+ ctx.body = paged(items, total, page);
1910
+ }),
1911
+ // ── Redemptions ──────────────────────────────────────────────────────
1912
+ createRedemption: handle(strapi, async (ctx) => {
1913
+ const season2 = await seasonOf(ctx);
1914
+ const { cardIds, rewardId } = body(ctx);
1915
+ created(ctx, await svc("redemption").create(ctx.state.tcgPlayer, season2, { cardIds, rewardId }));
1916
+ }),
1917
+ getRedemption: handle(strapi, async (ctx) => {
1918
+ const rec = await svc("redemption").findOne(ctx.params.id, { forPlayer: ctx.state.tcgPlayer });
1919
+ ok(ctx, svc("format").redemption(rec));
1920
+ }),
1921
+ redemptionHistory: handle(strapi, async (ctx) => {
1922
+ const season2 = await seasonOf(ctx);
1923
+ const page = parsePagination(ctx.query);
1924
+ const { items, total } = await svc("redemption").history(ctx.state.tcgPlayer, season2, page);
1925
+ ctx.body = paged(items, total, page);
1926
+ })
1927
+ };
1928
+ };
1929
+ const createSeededRNG = (seed) => {
1930
+ let state = seed | 0;
1931
+ return () => {
1932
+ state = state + 1831565813 | 0;
1933
+ let t = Math.imul(state ^ state >>> 15, 1 | state);
1934
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
1935
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1936
+ };
1937
+ };
1938
+ const generateSeed = (playerId, dropId, pullNumber, timestamp) => {
1939
+ const hash = createHash("sha256").update(`${playerId}:${dropId}:${pullNumber}:${timestamp}`).digest("hex");
1940
+ return parseInt(hash.substring(0, 8), 16);
1941
+ };
1942
+ const effectiveRarities = (rarities, multipliers = {}) => {
1943
+ const weighted = rarities.map((r) => ({ ...r, probability: (Number(r.probability) || 0) * Math.max(0, Number(multipliers[r.key] ?? 1)) }));
1944
+ const sum = weighted.reduce((s, r) => s + r.probability, 0);
1945
+ if (sum <= 0) return rarities.map((r) => ({ ...r }));
1946
+ return weighted.map((r) => ({ ...r, probability: r.probability / sum }));
1947
+ };
1948
+ const rarityFromRoll = (roll, rarities, minRarity) => {
1949
+ let cumulative = 0;
1950
+ let picked = rarities[0]?.key;
1951
+ for (const tier of rarities) {
1952
+ cumulative += Number(tier.probability) || 0;
1953
+ if (roll < cumulative) {
1954
+ picked = tier.key;
1955
+ break;
1956
+ }
1957
+ }
1958
+ if (minRarity) {
1959
+ const keys = rarities.map((r) => r.key);
1960
+ if (keys.indexOf(picked) < keys.indexOf(minRarity)) return minRarity;
1961
+ }
1962
+ return picked;
1963
+ };
1964
+ const selectCard = (pool, rarity, rng, rarities = []) => {
1965
+ const keys = rarities.map((r) => r.key);
1966
+ const start = Math.max(0, keys.indexOf(rarity));
1967
+ const order = keys.length ? [...keys.slice(0, start + 1).reverse(), ...keys.slice(start + 1)] : [rarity];
1968
+ let list = [];
1969
+ for (const k of order) {
1970
+ list = pool.filter((c) => c.rarity === k);
1971
+ if (list.length) break;
1972
+ }
1973
+ if (!list.length) list = pool;
1974
+ if (list.length === 0) return null;
1975
+ const totalWeight = list.reduce((sum, c) => sum + (Number(c.weight) || 100), 0);
1976
+ let roll = rng() * totalWeight;
1977
+ for (const card2 of list) {
1978
+ roll -= Number(card2.weight) || 100;
1979
+ if (roll <= 0) return card2;
1980
+ }
1981
+ return list[list.length - 1];
1982
+ };
1983
+ const prunePool = (pool, exclusiveCardIds = /* @__PURE__ */ new Set()) => pool.filter((c) => {
1984
+ if (exclusiveCardIds.has(c.id) && !c.isExclusive) return false;
1985
+ if (c.supplyCap != null && Number(c.minted ?? 0) >= Number(c.supplyCap)) return false;
1986
+ return true;
1987
+ });
1988
+ const pull$1 = ({ playerId, dropId, pullNumber, pool, rarities, multipliers, minRarity, timestamp }) => {
1989
+ const ts = timestamp ?? Date.now();
1990
+ const seed = generateSeed(playerId, dropId, pullNumber, ts);
1991
+ const rng = createSeededRNG(seed);
1992
+ const odds = effectiveRarities(rarities, multipliers ?? {});
1993
+ const roll = rng();
1994
+ const rolled = rarityFromRoll(roll, odds);
1995
+ const rarity = rarityFromRoll(roll, odds, minRarity);
1996
+ const card2 = selectCard(pool, rarity, rng, odds);
1997
+ const verificationHash = createHash("sha256").update(`${seed}:${roll}:${rarity}:${card2?.id}`).digest("hex").substring(0, 16);
1998
+ return {
1999
+ seed,
2000
+ roll,
2001
+ rarity: card2?.rarity ?? rarity,
2002
+ requestedRarity: rarity,
2003
+ rolledRarity: rolled,
2004
+ pity: rolled !== rarity,
2005
+ odds: Object.fromEntries(odds.map((r) => [r.key, Math.round(r.probability * 1e4) / 1e4])),
2006
+ card: card2,
2007
+ timestamp: ts,
2008
+ verificationHash
2009
+ };
2010
+ };
2011
+ const distribution = (pulls, rarities) => {
2012
+ const total = pulls.length;
2013
+ const byRarity = {};
2014
+ for (const tier of rarities) {
2015
+ const actual = pulls.filter((p) => p.rarity === tier.key).length;
2016
+ const expected = Math.round(total * (Number(tier.probability) || 0));
2017
+ byRarity[tier.key] = { actual, expected, percentage: total ? Math.round(actual / total * 100) : 0, deviation: actual - expected };
2018
+ }
2019
+ return { total, byRarity };
2020
+ };
2021
+ const pityFloor = (recent, rarities, limits) => {
2022
+ const keys = rarities.map((r) => r.key);
2023
+ if (keys.length < 2) return void 0;
2024
+ const top = keys[keys.length - 1];
2025
+ const second = keys[1];
2026
+ const legendaryAfter = Number(limits.pityLegendaryAfter) || 0;
2027
+ if (legendaryAfter > 0 && recent.length >= legendaryAfter && recent.slice(0, legendaryAfter).every((p) => p.rarity !== top)) return top;
2028
+ const rareAfter = Number(limits.pityRareAfter) || 0;
2029
+ if (rareAfter > 0 && recent.length >= rareAfter && recent.slice(0, rareAfter).every((p) => p.rarity === keys[0])) return second;
2030
+ return void 0;
2031
+ };
2032
+ const validateRarities = (rarities) => {
2033
+ const problems = [];
2034
+ if (!Array.isArray(rarities) || rarities.length === 0) return ["rarities must be a non-empty array"];
2035
+ const keys = /* @__PURE__ */ new Set();
2036
+ let sum = 0;
2037
+ rarities.forEach((r, i) => {
2038
+ if (!r?.key || !/^[a-z0-9_-]{2,30}$/.test(r.key)) problems.push(`rarities[${i}].key is invalid`);
2039
+ if (keys.has(r?.key)) problems.push(`rarities[${i}].key "${r.key}" is duplicated`);
2040
+ keys.add(r?.key);
2041
+ const p = Number(r?.probability);
2042
+ if (!(p >= 0 && p <= 1)) problems.push(`rarities[${i}].probability must be between 0 and 1`);
2043
+ sum += p || 0;
2044
+ });
2045
+ if (Math.abs(sum - 1) > 1e-4) problems.push(`rarity probabilities sum to ${sum.toFixed(4)}, expected 1`);
2046
+ return problems;
2047
+ };
2048
+ const admin$1 = ({ strapi }) => {
2049
+ const svc = (name) => strapi.plugin("tcg").service(name);
2050
+ const seasonParam = (ctx) => svc("season").resolve(ctx.params.season ?? ctx.query.season);
2051
+ const operator = (ctx) => ctx.state.user?.email ?? ctx.state.user?.username ?? "admin";
2052
+ return {
2053
+ // ── Dashboard ────────────────────────────────────────────────────────
2054
+ dashboard: handle(strapi, async (ctx) => {
2055
+ const season2 = ctx.query.season ? await svc("season").resolve(ctx.query.season) : await svc("season").current();
2056
+ ok(ctx, await svc("stats").dashboard(season2));
2057
+ }),
2058
+ leaderboard: handle(strapi, async (ctx) => {
2059
+ const season2 = await seasonParam(ctx);
2060
+ ok(ctx, await svc("stats").leaderboard(season2, Math.min(50, parseInt(ctx.query.limit, 10) || 10)));
2061
+ }),
2062
+ pulls: handle(strapi, async (ctx) => {
2063
+ const season2 = await seasonParam(ctx);
2064
+ const page = parsePagination(ctx.query);
2065
+ const { items, total } = await svc("stats").pulls(season2, { playerCode: ctx.query.player, rarity: ctx.query.rarity }, page);
2066
+ ctx.body = paged(items, total, page);
2067
+ }),
2068
+ // ── Seasons ──────────────────────────────────────────────────────────
2069
+ seasons: handle(strapi, async (ctx) => ok(ctx, await svc("season").list())),
2070
+ seasonDefaults: handle(strapi, async (ctx) => ok(ctx, svc("season").defaults())),
2071
+ season: handle(strapi, async (ctx) => {
2072
+ const season2 = await seasonParam(ctx);
2073
+ const [cards, packs] = await Promise.all([svc("card").countByRarity(season2), svc("pack").list(season2, { includeInactive: true })]);
2074
+ ok(ctx, { ...season2, counts: { cardsByRarity: cards, packs: packs.length } });
2075
+ }),
2076
+ createSeason: handle(strapi, async (ctx) => created(ctx, await svc("season").create(body(ctx)))),
2077
+ updateSeason: handle(strapi, async (ctx) => ok(ctx, await svc("season").update(ctx.params.season, body(ctx)))),
2078
+ deleteSeason: handle(strapi, async (ctx) => ok(ctx, await svc("season").remove(ctx.params.season))),
2079
+ // ── Cards ────────────────────────────────────────────────────────────
2080
+ cards: handle(strapi, async (ctx) => {
2081
+ const season2 = await seasonParam(ctx);
2082
+ const page = parsePagination({ ...ctx.query, pageSize: ctx.query.pageSize ?? 100 });
2083
+ const { items, total } = await svc("card").list(season2, { rarity: ctx.query.rarity, edition: ctx.query.edition, q: ctx.query.q }, page);
2084
+ ctx.body = paged(await svc("card").withExclusivity(items), total, page);
2085
+ }),
2086
+ card: handle(strapi, async (ctx) => ok(ctx, await svc("card").findOne(ctx.params.id))),
2087
+ createCard: handle(strapi, async (ctx) => created(ctx, await svc("card").create(await seasonParam(ctx), body(ctx)))),
2088
+ updateCard: handle(strapi, async (ctx) => ok(ctx, await svc("card").update(await seasonParam(ctx), ctx.params.id, body(ctx)))),
2089
+ deleteCard: handle(strapi, async (ctx) => ok(ctx, await svc("card").remove(ctx.params.id))),
2090
+ // ── Packs ────────────────────────────────────────────────────────────
2091
+ packs: handle(strapi, async (ctx) => {
2092
+ const season2 = await seasonParam(ctx);
2093
+ const packs = await svc("pack").list(season2, { includeInactive: true });
2094
+ const withCounts = await Promise.all(packs.map(async (p) => ({ ...p, cardCount: (await svc("pack").pool(p)).length })));
2095
+ ok(ctx, withCounts);
2096
+ }),
2097
+ pack: handle(strapi, async (ctx) => {
2098
+ const season2 = await seasonParam(ctx);
2099
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2100
+ const drops = await svc("drop").list(pack2);
2101
+ ok(ctx, { ...pack2, cards: await svc("pack").cards(season2, pack2.documentId), drops: await Promise.all(drops.map(async (d) => ({ ...d, cardCount: (await svc("drop").cards(pack2, d.documentId)).length, isOpen: svc("drop").isOpen(d) }))) });
2102
+ }),
2103
+ // ── Drops ────────────────────────────────────────────────────────────
2104
+ drops: handle(strapi, async (ctx) => {
2105
+ const season2 = await seasonParam(ctx);
2106
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2107
+ const drops = await svc("drop").list(pack2);
2108
+ ok(ctx, drops.map((d) => ({ ...d, isOpen: svc("drop").isOpen(d) })));
2109
+ }),
2110
+ drop: handle(strapi, async (ctx) => {
2111
+ const season2 = await seasonParam(ctx);
2112
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2113
+ const drop2 = await svc("drop").findOne(pack2, ctx.params.dropId);
2114
+ const raw = await svc("drop").rawPool(drop2, { ...pack2, season: season2 });
2115
+ const exclusiveIds = await svc("drop").exclusiveCardIds(season2);
2116
+ ok(ctx, {
2117
+ ...drop2,
2118
+ isOpen: svc("drop").isOpen(drop2),
2119
+ cards: await svc("drop").cards(pack2, drop2.documentId),
2120
+ effectivePool: raw.map((c) => ({ id: c.documentId, name: c.name, number: c.number, rarity: c.rarity, weight: c.weight, isExclusive: Boolean(c.isExclusive), source: c.source, prunedByExclusivity: exclusiveIds.has(c.id) && !c.isExclusive, soldOut: c.supplyCap != null && (c.minted ?? 0) >= c.supplyCap }))
2121
+ });
2122
+ }),
2123
+ createDrop: handle(strapi, async (ctx) => {
2124
+ const season2 = await seasonParam(ctx);
2125
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2126
+ created(ctx, await svc("drop").create({ ...pack2, season: season2 }, body(ctx)));
2127
+ }),
2128
+ updateDrop: handle(strapi, async (ctx) => {
2129
+ const season2 = await seasonParam(ctx);
2130
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2131
+ ok(ctx, await svc("drop").update({ ...pack2, season: season2 }, ctx.params.dropId, body(ctx)));
2132
+ }),
2133
+ deleteDrop: handle(strapi, async (ctx) => {
2134
+ const season2 = await seasonParam(ctx);
2135
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2136
+ ok(ctx, await svc("drop").remove(pack2, ctx.params.dropId));
2137
+ }),
2138
+ setDropCards: handle(strapi, async (ctx) => {
2139
+ const season2 = await seasonParam(ctx);
2140
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2141
+ ok(ctx, await svc("drop").setCards({ ...pack2, season: season2 }, ctx.params.dropId, body(ctx).cards ?? []));
2142
+ }),
2143
+ dropQR: handle(strapi, async (ctx) => {
2144
+ const season2 = await seasonParam(ctx);
2145
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2146
+ const { token, expiresAt, mode, drop: drop2 } = await svc("drop").generateQR({ ...pack2, season: season2 }, ctx.params.dropId);
2147
+ ok(ctx, { token, expiresAt, mode, drop: { id: drop2.documentId, name: drop2.name, slug: drop2.slug }, pack: svc("format").pack(pack2) });
2148
+ }),
2149
+ rotateDropSecret: handle(strapi, async (ctx) => {
2150
+ const season2 = await seasonParam(ctx);
2151
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2152
+ ok(ctx, await svc("drop").rotateSecret(pack2, ctx.params.dropId));
2153
+ }),
2154
+ // ── Boosts (Live) ────────────────────────────────────────────────────
2155
+ boosts: handle(strapi, async (ctx) => ok(ctx, await svc("boost").list(await seasonParam(ctx)))),
2156
+ createBoost: handle(strapi, async (ctx) => created(ctx, await svc("boost").create(await seasonParam(ctx), body(ctx)))),
2157
+ updateBoost: handle(strapi, async (ctx) => ok(ctx, await svc("boost").update(await seasonParam(ctx), ctx.params.id, body(ctx)))),
2158
+ deleteBoost: handle(strapi, async (ctx) => ok(ctx, await svc("boost").remove(await seasonParam(ctx), ctx.params.id))),
2159
+ // ── Rewards ──────────────────────────────────────────────────────────
2160
+ rewards: handle(strapi, async (ctx) => {
2161
+ const season2 = await seasonParam(ctx);
2162
+ const sets = Object.fromEntries((await svc("set").list(season2)).map((x) => [x.slug, { name: x.name }]));
2163
+ ok(ctx, (await svc("reward").list(season2)).map((r) => ({ ...svc("reward").format(r, season2, sets), documentId: r.documentId })));
2164
+ }),
2165
+ createReward: handle(strapi, async (ctx) => created(ctx, await svc("reward").create(await seasonParam(ctx), body(ctx)))),
2166
+ updateReward: handle(strapi, async (ctx) => ok(ctx, await svc("reward").update(await seasonParam(ctx), ctx.params.id, body(ctx)))),
2167
+ deleteReward: handle(strapi, async (ctx) => ok(ctx, await svc("reward").remove(await seasonParam(ctx), ctx.params.id))),
2168
+ // ── Sets ─────────────────────────────────────────────────────────────
2169
+ sets: handle(strapi, async (ctx) => ok(ctx, await svc("set").list(await seasonParam(ctx)))),
2170
+ createSet: handle(strapi, async (ctx) => created(ctx, await svc("set").create(await seasonParam(ctx), body(ctx)))),
2171
+ updateSet: handle(strapi, async (ctx) => ok(ctx, await svc("set").update(await seasonParam(ctx), ctx.params.id, body(ctx)))),
2172
+ deleteSet: handle(strapi, async (ctx) => ok(ctx, await svc("set").remove(await seasonParam(ctx), ctx.params.id))),
2173
+ // ── Badges ───────────────────────────────────────────────────────────
2174
+ badges: handle(strapi, async (ctx) => ok(ctx, await svc("badge").list(await seasonParam(ctx)))),
2175
+ createBadge: handle(strapi, async (ctx) => created(ctx, await svc("badge").create(await seasonParam(ctx), body(ctx)))),
2176
+ updateBadge: handle(strapi, async (ctx) => ok(ctx, await svc("badge").update(await seasonParam(ctx), ctx.params.id, body(ctx)))),
2177
+ deleteBadge: handle(strapi, async (ctx) => ok(ctx, await svc("badge").remove(await seasonParam(ctx), ctx.params.id))),
2178
+ seedBadges: handle(strapi, async (ctx) => ok(ctx, { created: await svc("badge").seedStarters(await seasonParam(ctx)) })),
2179
+ createPack: handle(strapi, async (ctx) => created(ctx, await svc("pack").create(await seasonParam(ctx), body(ctx)))),
2180
+ updatePack: handle(strapi, async (ctx) => ok(ctx, await svc("pack").update(await seasonParam(ctx), ctx.params.id, body(ctx)))),
2181
+ deletePack: handle(strapi, async (ctx) => ok(ctx, await svc("pack").remove(await seasonParam(ctx), ctx.params.id))),
2182
+ setPackCards: handle(strapi, async (ctx) => ok(ctx, await svc("pack").setCards(await seasonParam(ctx), ctx.params.id, body(ctx).cards ?? []))),
2183
+ packQR: handle(strapi, async (ctx) => {
2184
+ const { token, expiresAt, mode, drop: drop2, pack: pack2 } = await svc("pack").generateQR(await seasonParam(ctx), ctx.params.id);
2185
+ ok(ctx, { token, expiresAt, mode, drop: { id: drop2.documentId, name: drop2.name, slug: drop2.slug }, pack: svc("format").pack(pack2) });
2186
+ }),
2187
+ rotatePackSecret: handle(strapi, async (ctx) => {
2188
+ const season2 = await seasonParam(ctx);
2189
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2190
+ const drop2 = await svc("drop").ensureDefault({ ...pack2, season: season2 });
2191
+ ok(ctx, await svc("drop").rotateSecret(pack2, drop2.documentId));
2192
+ }),
2193
+ /** Dry-run N pulls against a pack to eyeball the distribution. Nothing is written. */
2194
+ simulate: handle(strapi, async (ctx) => {
2195
+ const season2 = await seasonParam(ctx);
2196
+ const pack2 = await svc("pack").findOne(season2, ctx.params.id);
2197
+ const drop2 = ctx.query.drop ? await svc("drop").findOne(pack2, ctx.query.drop) : await svc("drop").ensureDefault({ ...pack2, season: season2 });
2198
+ const raw = await svc("drop").rawPool(drop2, pack2);
2199
+ const pool = prunePool(raw, await svc("drop").exclusiveCardIds(season2)).map((c) => ({ ...c, supplyCap: null }));
2200
+ const multipliers = svc("boost").combine(await svc("boost").activeFor(season2, pack2, drop2));
2201
+ const n = Math.min(1e4, Math.max(1, parseInt(ctx.query.n, 10) || 1e3));
2202
+ const results = [];
2203
+ for (let i = 0; i < n; i += 1) {
2204
+ results.push(pull$1({ playerId: "sim", dropId: drop2.documentId, pullNumber: i, pool, rarities: season2.rarities ?? [], multipliers, timestamp: i }));
2205
+ }
2206
+ const byCard = {};
2207
+ for (const r of results) {
2208
+ if (!r.card) continue;
2209
+ byCard[r.card.documentId] = byCard[r.card.documentId] ?? { name: r.card.name, rarity: r.card.rarity, count: 0 };
2210
+ byCard[r.card.documentId].count += 1;
2211
+ }
2212
+ ok(ctx, { n, drop: { id: drop2.documentId, name: drop2.name }, multipliers, poolSize: pool.length, distribution: distribution(results, effectiveRarities(season2.rarities ?? [], multipliers)), byCard: Object.values(byCard).sort((a, b) => b.count - a.count) });
2213
+ }),
2214
+ // ── Players ──────────────────────────────────────────────────────────
2215
+ players: handle(strapi, async (ctx) => {
2216
+ const page = parsePagination(ctx.query);
2217
+ const isActive = ctx.query.isActive === void 0 ? void 0 : ctx.query.isActive === "true";
2218
+ const { items, total } = await svc("player").search({ q: ctx.query.q, provider: ctx.query.provider, isActive }, page);
2219
+ ctx.body = paged(items.map((p) => svc("format").playerAdmin(p)), total, page);
2220
+ }),
2221
+ player: handle(strapi, async (ctx) => {
2222
+ const player2 = await svc("player").byDocumentId(ctx.params.id);
2223
+ const season2 = ctx.query.season ? await svc("season").resolve(ctx.query.season) : await svc("season").current();
2224
+ const stats2 = season2 ? await svc("collection").stats(player2, season2) : null;
2225
+ const page = parsePagination({ pageSize: 200 });
2226
+ const collection2 = season2 ? await svc("collection").list(player2, season2, {}, page) : { items: [] };
2227
+ ok(ctx, { player: svc("format").playerAdmin(player2), stats: stats2, collection: collection2.items });
2228
+ }),
2229
+ setPlayerActive: handle(strapi, async (ctx) => {
2230
+ const player2 = await svc("player").setActive(ctx.params.id, body(ctx).isActive);
2231
+ ok(ctx, svc("format").playerAdmin(player2));
2232
+ }),
2233
+ grantCard: handle(strapi, async (ctx) => {
2234
+ const player2 = await svc("player").byDocumentId(ctx.params.id);
2235
+ const card2 = await svc("card").findOne(body(ctx).cardId);
2236
+ await svc("collection").grant(player2, card2);
2237
+ strapi.log.info(`[tcg] grant ${card2.name} → ${player2.code} by ${operator(ctx)}`);
2238
+ ok(ctx, svc("format").playerAdmin(await svc("player").recount(player2.id)));
2239
+ }),
2240
+ // ── Redemptions (booth) ──────────────────────────────────────────────
2241
+ redemptions: handle(strapi, async (ctx) => {
2242
+ const season2 = ctx.query.season ? await svc("season").resolve(ctx.query.season) : null;
2243
+ const page = parsePagination(ctx.query);
2244
+ const { items, total } = await svc("redemption").search({ season: season2, status: ctx.query.status, q: ctx.query.q, reward: ctx.query.reward }, page);
2245
+ ctx.body = paged(items, total, page);
2246
+ }),
2247
+ redemption: handle(strapi, async (ctx) => ok(ctx, svc("format").redemption(await svc("redemption").findOne(ctx.params.code)))),
2248
+ fulfillRedemption: handle(strapi, async (ctx) => ok(ctx, await svc("redemption").fulfill(ctx.params.code, { operator: operator(ctx), note: body(ctx).note }))),
2249
+ cancelRedemption: handle(strapi, async (ctx) => ok(ctx, await svc("redemption").cancel(ctx.params.code, { operator: operator(ctx), note: body(ctx).note }))),
2250
+ // ── Settings (read-only view of the resolved config, secrets masked) ─
2251
+ settings: handle(strapi, async (ctx) => {
2252
+ const all = svc("settings").all();
2253
+ const mask = (v) => v ? "••••••" : "";
2254
+ ok(ctx, {
2255
+ playerCodePrefix: svc("settings").playerCodePrefix(),
2256
+ qr: { globalSecret: mask(all.qr?.globalSecret) },
2257
+ players: svc("settings").players(),
2258
+ sweeps: all.sweeps ?? { enabled: true }
2259
+ });
2260
+ })
2261
+ };
2262
+ };
2263
+ const controllers = { game, admin: admin$1 };
2264
+ const header = (ctx, name) => {
2265
+ const value = ctx.request.header[String(name).toLowerCase()];
2266
+ return typeof value === "string" ? value.trim() : "";
2267
+ };
2268
+ const player$1 = (_config, { strapi }) => async (ctx, next) => {
2269
+ try {
2270
+ const cfg = strapi.plugin("tcg").service("settings").players();
2271
+ const externalId = header(ctx, cfg.idHeader).slice(0, 190);
2272
+ if (!externalId) {
2273
+ throw badRequest(ERROR_CODES.PLAYER_REQUIRED, `En-tête ${cfg.idHeader} requis : l'identifiant du joueur.`);
2274
+ }
2275
+ const found = await strapi.plugin("tcg").service("player").resolve({
2276
+ externalId,
2277
+ displayName: header(ctx, cfg.nameHeader) || null,
2278
+ avatarUrl: header(ctx, cfg.avatarHeader) || null,
2279
+ autoProvision: cfg.autoProvision !== false
2280
+ });
2281
+ if (!found.isActive) throw forbidden(ERROR_CODES.PLAYER_INACTIVE, "Ce compte est désactivé.");
2282
+ ctx.state.tcgPlayer = found;
2283
+ ctx.state.tcgPlayerIsNew = Boolean(found.__created);
2284
+ ctx.set("Cache-Control", "private, no-store");
2285
+ } catch (err) {
2286
+ sendError(ctx, err, strapi);
2287
+ return;
2288
+ }
2289
+ await next();
2290
+ };
2291
+ const middlewares = { player: player$1 };
2292
+ const policies = {};
2293
+ const PLAYER = { middlewares: ["plugin::tcg.player"] };
2294
+ const CATALOG = {};
2295
+ const contentApi = [
2296
+ // player
2297
+ { method: "GET", path: "/me", handler: "game.me", config: PLAYER },
2298
+ { method: "PATCH", path: "/me", handler: "game.updateMe", config: PLAYER },
2299
+ // catalog
2300
+ { method: "GET", path: "/season", handler: "game.currentSeason", config: CATALOG },
2301
+ { method: "GET", path: "/packs", handler: "game.packs", config: CATALOG },
2302
+ { method: "GET", path: "/packs/:slug", handler: "game.pack", config: CATALOG },
2303
+ { method: "GET", path: "/cards", handler: "game.cards", config: CATALOG },
2304
+ { method: "GET", path: "/cards/:id", handler: "game.card", config: CATALOG },
2305
+ { method: "GET", path: "/live", handler: "game.live", config: CATALOG },
2306
+ { method: "GET", path: "/rewards", handler: "game.rewards", config: CATALOG },
2307
+ { method: "GET", path: "/rewards/eligibility", handler: "game.rewardEligibility", config: PLAYER },
2308
+ { method: "GET", path: "/sets", handler: "game.sets", config: PLAYER },
2309
+ { method: "GET", path: "/badges", handler: "game.badges", config: PLAYER },
2310
+ // collection
2311
+ { method: "GET", path: "/collection", handler: "game.collection", config: PLAYER },
2312
+ { method: "GET", path: "/collection/stats", handler: "game.collectionStats", config: PLAYER },
2313
+ // pulls
2314
+ { method: "POST", path: "/pulls/scan", handler: "game.scan", config: PLAYER },
2315
+ { method: "GET", path: "/pulls/history", handler: "game.pullHistory", config: PLAYER },
2316
+ { method: "GET", path: "/pulls/cooldowns", handler: "game.cooldowns", config: PLAYER },
2317
+ // trades
2318
+ { method: "POST", path: "/trades/offer", handler: "game.createOffer", config: PLAYER },
2319
+ { method: "POST", path: "/trades/scan", handler: "game.scanOffer", config: PLAYER },
2320
+ { method: "GET", path: "/trades/active", handler: "game.activeOffers", config: PLAYER },
2321
+ { method: "GET", path: "/trades/history", handler: "game.tradeHistory", config: PLAYER },
2322
+ { method: "GET", path: "/trades/offer/:id", handler: "game.getOffer", config: PLAYER },
2323
+ { method: "POST", path: "/trades/offer/:id/accept", handler: "game.acceptOffer", config: PLAYER },
2324
+ { method: "POST", path: "/trades/offer/:id/reject", handler: "game.rejectOffer", config: PLAYER },
2325
+ { method: "POST", path: "/trades/offer/:id/cancel", handler: "game.cancelOffer", config: PLAYER },
2326
+ // redemptions
2327
+ { method: "POST", path: "/redemptions", handler: "game.createRedemption", config: PLAYER },
2328
+ { method: "GET", path: "/redemptions/history", handler: "game.redemptionHistory", config: PLAYER },
2329
+ { method: "GET", path: "/redemptions/:id", handler: "game.getRedemption", config: PLAYER }
2330
+ ];
2331
+ const can = (...actions) => ({
2332
+ policies: [{ name: "admin::hasPermissions", config: { actions: actions.map((a) => `plugin::tcg.${a}`) } }]
2333
+ });
2334
+ const admin = [
2335
+ // dashboard & audit
2336
+ { method: "GET", path: "/dashboard", handler: "admin.dashboard", config: can(RBAC.DASHBOARD_READ) },
2337
+ { method: "GET", path: "/seasons/:season/leaderboard", handler: "admin.leaderboard", config: can(RBAC.DASHBOARD_READ) },
2338
+ { method: "GET", path: "/seasons/:season/pulls", handler: "admin.pulls", config: can(RBAC.AUDIT_READ) },
2339
+ // seasons
2340
+ { method: "GET", path: "/seasons", handler: "admin.seasons", config: can(RBAC.SEASONS_READ) },
2341
+ { method: "GET", path: "/seasons/defaults", handler: "admin.seasonDefaults", config: can(RBAC.SEASONS_READ) },
2342
+ { method: "POST", path: "/seasons", handler: "admin.createSeason", config: can(RBAC.SEASONS_WRITE) },
2343
+ { method: "GET", path: "/seasons/:season", handler: "admin.season", config: can(RBAC.SEASONS_READ) },
2344
+ { method: "PUT", path: "/seasons/:season", handler: "admin.updateSeason", config: can(RBAC.SEASONS_WRITE) },
2345
+ { method: "DELETE", path: "/seasons/:season", handler: "admin.deleteSeason", config: can(RBAC.SEASONS_WRITE) },
2346
+ // cards
2347
+ { method: "GET", path: "/seasons/:season/cards", handler: "admin.cards", config: can(RBAC.CARDS_READ) },
2348
+ { method: "POST", path: "/seasons/:season/cards", handler: "admin.createCard", config: can(RBAC.CARDS_WRITE) },
2349
+ { method: "GET", path: "/seasons/:season/cards/:id", handler: "admin.card", config: can(RBAC.CARDS_READ) },
2350
+ { method: "PUT", path: "/seasons/:season/cards/:id", handler: "admin.updateCard", config: can(RBAC.CARDS_WRITE) },
2351
+ { method: "DELETE", path: "/seasons/:season/cards/:id", handler: "admin.deleteCard", config: can(RBAC.CARDS_WRITE) },
2352
+ // packs
2353
+ { method: "GET", path: "/seasons/:season/packs", handler: "admin.packs", config: can(RBAC.PACKS_READ) },
2354
+ { method: "POST", path: "/seasons/:season/packs", handler: "admin.createPack", config: can(RBAC.PACKS_WRITE) },
2355
+ { method: "GET", path: "/seasons/:season/packs/:id", handler: "admin.pack", config: can(RBAC.PACKS_READ) },
2356
+ { method: "PUT", path: "/seasons/:season/packs/:id", handler: "admin.updatePack", config: can(RBAC.PACKS_WRITE) },
2357
+ { method: "DELETE", path: "/seasons/:season/packs/:id", handler: "admin.deletePack", config: can(RBAC.PACKS_WRITE) },
2358
+ { method: "PUT", path: "/seasons/:season/packs/:id/cards", handler: "admin.setPackCards", config: can(RBAC.PACKS_WRITE) },
2359
+ { method: "POST", path: "/seasons/:season/packs/:id/qr", handler: "admin.packQR", config: can(RBAC.PACKS_QR) },
2360
+ { method: "POST", path: "/seasons/:season/packs/:id/rotate-secret", handler: "admin.rotatePackSecret", config: can(RBAC.PACKS_WRITE) },
2361
+ { method: "GET", path: "/seasons/:season/packs/:id/simulate", handler: "admin.simulate", config: can(RBAC.PACKS_READ) },
2362
+ // drops
2363
+ { method: "GET", path: "/seasons/:season/packs/:id/drops", handler: "admin.drops", config: can(RBAC.PACKS_READ) },
2364
+ { method: "POST", path: "/seasons/:season/packs/:id/drops", handler: "admin.createDrop", config: can(RBAC.PACKS_WRITE) },
2365
+ { method: "GET", path: "/seasons/:season/packs/:id/drops/:dropId", handler: "admin.drop", config: can(RBAC.PACKS_READ) },
2366
+ { method: "PUT", path: "/seasons/:season/packs/:id/drops/:dropId", handler: "admin.updateDrop", config: can(RBAC.PACKS_WRITE) },
2367
+ { method: "DELETE", path: "/seasons/:season/packs/:id/drops/:dropId", handler: "admin.deleteDrop", config: can(RBAC.PACKS_WRITE) },
2368
+ { method: "PUT", path: "/seasons/:season/packs/:id/drops/:dropId/cards", handler: "admin.setDropCards", config: can(RBAC.PACKS_WRITE) },
2369
+ { method: "POST", path: "/seasons/:season/packs/:id/drops/:dropId/qr", handler: "admin.dropQR", config: can(RBAC.PACKS_QR) },
2370
+ { method: "POST", path: "/seasons/:season/packs/:id/drops/:dropId/rotate-secret", handler: "admin.rotateDropSecret", config: can(RBAC.PACKS_WRITE) },
2371
+ // live boosts
2372
+ { method: "GET", path: "/seasons/:season/boosts", handler: "admin.boosts", config: can(RBAC.LIVE_READ) },
2373
+ { method: "POST", path: "/seasons/:season/boosts", handler: "admin.createBoost", config: can(RBAC.LIVE_WRITE) },
2374
+ { method: "PUT", path: "/seasons/:season/boosts/:id", handler: "admin.updateBoost", config: can(RBAC.LIVE_WRITE) },
2375
+ { method: "DELETE", path: "/seasons/:season/boosts/:id", handler: "admin.deleteBoost", config: can(RBAC.LIVE_WRITE) },
2376
+ // rewards
2377
+ { method: "GET", path: "/seasons/:season/rewards", handler: "admin.rewards", config: can(RBAC.REWARDS_READ) },
2378
+ { method: "POST", path: "/seasons/:season/rewards", handler: "admin.createReward", config: can(RBAC.REWARDS_WRITE) },
2379
+ { method: "PUT", path: "/seasons/:season/rewards/:id", handler: "admin.updateReward", config: can(RBAC.REWARDS_WRITE) },
2380
+ { method: "DELETE", path: "/seasons/:season/rewards/:id", handler: "admin.deleteReward", config: can(RBAC.REWARDS_WRITE) },
2381
+ // sets & badges
2382
+ { method: "GET", path: "/seasons/:season/sets", handler: "admin.sets", config: can(RBAC.CARDS_READ) },
2383
+ { method: "POST", path: "/seasons/:season/sets", handler: "admin.createSet", config: can(RBAC.CARDS_WRITE) },
2384
+ { method: "PUT", path: "/seasons/:season/sets/:id", handler: "admin.updateSet", config: can(RBAC.CARDS_WRITE) },
2385
+ { method: "DELETE", path: "/seasons/:season/sets/:id", handler: "admin.deleteSet", config: can(RBAC.CARDS_WRITE) },
2386
+ { method: "GET", path: "/seasons/:season/badges", handler: "admin.badges", config: can(RBAC.CARDS_READ) },
2387
+ { method: "POST", path: "/seasons/:season/badges", handler: "admin.createBadge", config: can(RBAC.CARDS_WRITE) },
2388
+ { method: "POST", path: "/seasons/:season/badges/seed", handler: "admin.seedBadges", config: can(RBAC.CARDS_WRITE) },
2389
+ { method: "PUT", path: "/seasons/:season/badges/:id", handler: "admin.updateBadge", config: can(RBAC.CARDS_WRITE) },
2390
+ { method: "DELETE", path: "/seasons/:season/badges/:id", handler: "admin.deleteBadge", config: can(RBAC.CARDS_WRITE) },
2391
+ // players
2392
+ { method: "GET", path: "/players", handler: "admin.players", config: can(RBAC.PLAYERS_READ) },
2393
+ { method: "GET", path: "/players/:id", handler: "admin.player", config: can(RBAC.PLAYERS_READ) },
2394
+ { method: "PUT", path: "/players/:id/active", handler: "admin.setPlayerActive", config: can(RBAC.PLAYERS_WRITE) },
2395
+ { method: "POST", path: "/players/:id/grant", handler: "admin.grantCard", config: can(RBAC.PLAYERS_WRITE) },
2396
+ // redemptions
2397
+ { method: "GET", path: "/redemptions", handler: "admin.redemptions", config: can(RBAC.REDEMPTIONS_READ) },
2398
+ { method: "GET", path: "/redemptions/:code", handler: "admin.redemption", config: can(RBAC.REDEMPTIONS_READ) },
2399
+ { method: "POST", path: "/redemptions/:code/fulfill", handler: "admin.fulfillRedemption", config: can(RBAC.REDEMPTIONS_FULFILL) },
2400
+ { method: "POST", path: "/redemptions/:code/cancel", handler: "admin.cancelRedemption", config: can(RBAC.REDEMPTIONS_FULFILL) },
2401
+ // settings
2402
+ { method: "GET", path: "/settings", handler: "admin.settings", config: can(RBAC.SETTINGS_READ) }
2403
+ ];
2404
+ const routes = {
2405
+ "content-api": { type: "content-api", routes: contentApi },
2406
+ admin: { type: "admin", routes: admin }
2407
+ };
2408
+ let warnedQr = false;
2409
+ const DEFAULT_PLAYERS = Object.freeze({
2410
+ autoProvision: true,
2411
+ idHeader: "x-player-id",
2412
+ nameHeader: "x-player-name",
2413
+ avatarHeader: "x-player-avatar"
2414
+ });
2415
+ const settings = ({ strapi }) => ({
2416
+ /** @returns {Object} the full plugin config */
2417
+ all() {
2418
+ return strapi.config.get(`plugin::${PLUGIN_ID}`) ?? {};
2419
+ },
2420
+ /** Secret used for trade QR tokens and packs without their own secret. Falls back to the app keys. */
2421
+ qrGlobalSecret() {
2422
+ const configured = this.all().qr?.globalSecret;
2423
+ if (configured) return configured;
2424
+ const appKeys = strapi.config.get("server.app.keys") ?? [];
2425
+ if (!warnedQr) {
2426
+ strapi.log.warn(`${LOG_PREFIX} qr.globalSecret not set, deriving one from the app keys. Set TCG_QR_SECRET before going live.`);
2427
+ warnedQr = true;
2428
+ }
2429
+ return `${appKeys[0] ?? "tcg-dev"}:tcg-qr`;
2430
+ },
2431
+ playerCodePrefix() {
2432
+ return this.all().playerCodePrefix ?? "TCG";
2433
+ },
2434
+ /** How the caller names the player: header names and whether unknown ids create a player. */
2435
+ players() {
2436
+ const p = this.all().players ?? {};
2437
+ return {
2438
+ autoProvision: p.autoProvision !== false,
2439
+ idHeader: String(p.idHeader || DEFAULT_PLAYERS.idHeader).toLowerCase(),
2440
+ nameHeader: String(p.nameHeader || DEFAULT_PLAYERS.nameHeader).toLowerCase(),
2441
+ avatarHeader: String(p.avatarHeader || DEFAULT_PLAYERS.avatarHeader).toLowerCase()
2442
+ };
2443
+ },
2444
+ sweepsEnabled() {
2445
+ return this.all().sweeps?.enabled !== false;
2446
+ }
2447
+ });
2448
+ const sign = (payload, secret) => createHmac("sha256", secret).update(payload).digest("hex");
2449
+ const safeEqual = (a, b) => {
2450
+ const ba = Buffer.from(String(a));
2451
+ const bb = Buffer.from(String(b));
2452
+ return ba.length === bb.length && timingSafeEqual(ba, bb);
2453
+ };
2454
+ const newNonce = () => randomBytes(QR_NONCE_BYTES).toString("hex");
2455
+ const signDropToken = (dropId, secret, ttlMs, now = Date.now()) => {
2456
+ const nonce2 = newNonce();
2457
+ const expiresAt = now + ttlMs;
2458
+ const payload = `d.${dropId}.${nonce2}.${expiresAt}`;
2459
+ return { token: `${payload}.${sign(payload, secret)}`, nonce: nonce2, expiresAt };
2460
+ };
2461
+ const signStaticToken = (dropId, secret) => {
2462
+ const payload = `s.${dropId}`;
2463
+ return { token: `${payload}.${sign(payload, secret)}` };
2464
+ };
2465
+ const signTradeToken = (offerId, playerId, secret, ttlMs, now = Date.now()) => {
2466
+ const nonce2 = newNonce();
2467
+ const expiresAt = now + ttlMs;
2468
+ const payload = `t.${offerId}.${playerId}.${nonce2}.${expiresAt}`;
2469
+ return { token: `${payload}.${sign(payload, secret)}`, nonce: nonce2, expiresAt };
2470
+ };
2471
+ const peekToken = (token) => {
2472
+ const parts = String(token ?? "").split(".");
2473
+ if (parts[0] === "d" && parts.length === 5) return { kind: "drop", mode: "rotating", dropId: parts[1] };
2474
+ if (parts[0] === "s" && parts.length === 3) return { kind: "drop", mode: "static", dropId: parts[1] };
2475
+ if (parts[0] === "t" && parts.length === 6) return { kind: "trade", offerId: parts[1], playerId: parts[2] };
2476
+ return null;
2477
+ };
2478
+ const verifyToken = (token, secret, now = Date.now()) => {
2479
+ const peek = peekToken(token);
2480
+ if (!peek) return { valid: false, reason: "format" };
2481
+ const parts = String(token).split(".");
2482
+ const signature = parts[parts.length - 1];
2483
+ const payload = parts.slice(0, -1).join(".");
2484
+ if (!safeEqual(sign(payload, secret), signature)) return { valid: false, reason: "signature" };
2485
+ if (peek.kind === "drop" && peek.mode === "static") return { valid: true, ...peek };
2486
+ const expiresAt = Number(parts[parts.length - 2]);
2487
+ if (!Number.isFinite(expiresAt) || now > expiresAt) return { valid: false, reason: "expired" };
2488
+ const nonce2 = parts[parts.length - 3];
2489
+ return { valid: true, ...peek, nonce: nonce2, expiresAt };
2490
+ };
2491
+ const qr = ({ strapi }) => ({
2492
+ secretOf(drop2) {
2493
+ return drop2.secret || strapi.plugin("tcg").service("settings").qrGlobalSecret();
2494
+ },
2495
+ /** @param {Object} drop @param {number} ttlMs */
2496
+ 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 };
2499
+ },
2500
+ forTrade(offer, player2, ttlMs) {
2501
+ return signTradeToken(offer.documentId, player2.documentId, strapi.plugin("tcg").service("settings").qrGlobalSecret(), ttlMs);
2502
+ },
2503
+ /**
2504
+ * Verifies signature + expiry; burns the nonce for rotating/trade tokens. Throws TcgError.
2505
+ * @param {string} token
2506
+ * @param {string} secret
2507
+ * @param {'drop'|'trade'} kind
2508
+ */
2509
+ async consume(token, secret, kind2) {
2510
+ const result = verifyToken(token, secret);
2511
+ if (!result.valid) {
2512
+ if (result.reason === "expired") throw badRequest(ERROR_CODES.QR_EXPIRED, "Ce QR code a expiré.");
2513
+ throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
2514
+ }
2515
+ if (result.kind !== kind2) throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
2516
+ 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é.");
2519
+ try {
2520
+ 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) }
2522
+ });
2523
+ } catch (err) {
2524
+ throw badRequest(ERROR_CODES.QR_REUSED, "Ce QR code a déjà été utilisé.");
2525
+ }
2526
+ return result;
2527
+ },
2528
+ async sweep() {
2529
+ const { count } = await strapi.db.query(UID.NONCE).deleteMany({ where: { expiresAt: { $lt: /* @__PURE__ */ new Date() } } });
2530
+ return count ?? 0;
2531
+ }
2532
+ });
2533
+ const mediaUrl = (media) => media && typeof media === "object" ? media.url ?? null : null;
2534
+ const format = () => ({
2535
+ rarityMeta(season2, key) {
2536
+ return (season2?.rarities ?? []).find((r) => r.key === key) ?? { key, label: key, color: "#000000" };
2537
+ },
2538
+ card(card2, season2) {
2539
+ if (!card2) return null;
2540
+ const rarity = this.rarityMeta(season2, card2.rarity);
2541
+ return {
2542
+ id: card2.documentId,
2543
+ name: card2.name,
2544
+ number: card2.number ?? null,
2545
+ description: card2.description ?? null,
2546
+ rarity: card2.rarity,
2547
+ rarityLabel: rarity.label,
2548
+ rarityColor: rarity.color,
2549
+ edition: card2.edition ?? "standard",
2550
+ score: card2.score ?? 0,
2551
+ imageUrl: mediaUrl(card2.image),
2552
+ attributes: card2.attributes ?? {},
2553
+ lore: card2.lore ?? null,
2554
+ artist: card2.artist ?? null,
2555
+ supplyCap: card2.supplyCap ?? null,
2556
+ minted: card2.minted ?? 0,
2557
+ isSoldOut: card2.supplyCap != null && (card2.minted ?? 0) >= card2.supplyCap
2558
+ };
2559
+ },
2560
+ pack(pack2) {
2561
+ if (!pack2) return null;
2562
+ return {
2563
+ id: pack2.documentId,
2564
+ name: pack2.name,
2565
+ slug: pack2.slug,
2566
+ kind: pack2.kind,
2567
+ description: pack2.description ?? null,
2568
+ location: pack2.location ?? null,
2569
+ imageUrl: mediaUrl(pack2.image),
2570
+ isActive: pack2.isActive,
2571
+ sortOrder: pack2.sortOrder ?? 0,
2572
+ meta: pack2.meta ?? {}
2573
+ };
2574
+ },
2575
+ season(season2) {
2576
+ if (!season2) return null;
2577
+ return {
2578
+ id: season2.documentId,
2579
+ name: season2.name,
2580
+ slug: season2.slug,
2581
+ eventName: season2.eventName ?? null,
2582
+ description: season2.description ?? null,
2583
+ status: season2.status,
2584
+ startsAt: season2.startsAt ?? null,
2585
+ endsAt: season2.endsAt ?? null,
2586
+ coverUrl: mediaUrl(season2.cover),
2587
+ rarities: season2.rarities ?? [],
2588
+ editions: season2.editions ?? [],
2589
+ limits: season2.limits ?? {},
2590
+ rankTiers: season2.rankTiers ?? [],
2591
+ theme: season2.theme ?? {}
2592
+ };
2593
+ },
2594
+ player(player2) {
2595
+ if (!player2) return null;
2596
+ return {
2597
+ id: player2.documentId,
2598
+ code: player2.code,
2599
+ displayName: player2.displayName ?? null,
2600
+ avatarUrl: player2.avatarUrl ?? null,
2601
+ provider: player2.provider,
2602
+ isGuest: player2.isGuest,
2603
+ totalCards: player2.totalCards ?? 0,
2604
+ totalTrades: player2.totalTrades ?? 0,
2605
+ totalRedemptions: player2.totalRedemptions ?? 0,
2606
+ createdAt: player2.createdAt,
2607
+ lastActiveAt: player2.lastActiveAt ?? null
2608
+ };
2609
+ },
2610
+ /** Admin-only view: includes msisdn. */
2611
+ playerAdmin(player2) {
2612
+ return player2 ? { ...this.player(player2), externalId: player2.externalId ?? null, msisdn: player2.msisdn ?? null, isActive: player2.isActive, meta: player2.meta ?? {} } : null;
2613
+ },
2614
+ playerCard(pc, season2) {
2615
+ if (!pc) return null;
2616
+ return {
2617
+ id: pc.documentId,
2618
+ card: this.card(pc.card, season2),
2619
+ pack: pc.pack ? { id: pc.pack.documentId, name: pc.pack.name, slug: pc.pack.slug } : null,
2620
+ acquiredFrom: pc.acquiredFrom,
2621
+ acquiredAt: pc.acquiredAt,
2622
+ mintNumber: pc.mintNumber ?? null
2623
+ };
2624
+ },
2625
+ redemption(r) {
2626
+ if (!r) return null;
2627
+ return {
2628
+ id: r.documentId,
2629
+ code: r.code,
2630
+ status: r.status,
2631
+ cards: r.cards ?? [],
2632
+ rewardType: r.rewardType ?? null,
2633
+ rewardDescription: r.rewardDescription ?? null,
2634
+ redeemedAt: r.redeemedAt,
2635
+ fulfilledAt: r.fulfilledAt ?? null,
2636
+ fulfilledBy: r.fulfilledBy ?? null,
2637
+ note: r.note ?? null,
2638
+ player: r.player ? this.player(r.player) : void 0,
2639
+ season: r.season ? { id: r.season.documentId, name: r.season.name, slug: r.season.slug } : void 0,
2640
+ reward: r.reward ? { id: r.reward.documentId, name: r.reward.name, slug: r.reward.slug, tier: r.reward.tier ?? null, booth: r.reward.booth ?? null, imageUrl: mediaUrl(r.reward.image), stock: r.reward.stock ?? null, claimed: r.reward.claimed ?? 0 } : r.rewardName ? { name: r.rewardName } : null
2641
+ };
2642
+ }
2643
+ });
2644
+ const randomCode = (length) => {
2645
+ let out = "";
2646
+ for (let i = 0; i < length; i += 1) out += CODE_CHARSET[randomInt(CODE_CHARSET.length)];
2647
+ return out;
2648
+ };
2649
+ const player = ({ strapi }) => ({
2650
+ async generateCode() {
2651
+ const prefix = strapi.plugin("tcg").service("settings").playerCodePrefix();
2652
+ for (let i = 0; i < MAX_CODE_ATTEMPTS; i += 1) {
2653
+ const code = `${prefix}-${randomCode(PLAYER_CODE_LENGTH)}`;
2654
+ const exists = await strapi.db.query(UID.PLAYER).findOne({ where: { code }, select: ["id"] });
2655
+ if (!exists) return code;
2656
+ }
2657
+ throw conflict(ERROR_CODES.VALIDATION, "Impossible de générer un code joueur unique.");
2658
+ },
2659
+ /**
2660
+ * The player behind an external id (whatever the host uses: phone, user id, username), created on
2661
+ * first sight when `autoProvision` is on. Name and avatar are hints that refresh the record.
2662
+ * @param {{ externalId: string, displayName?: string|null, avatarUrl?: string|null, autoProvision?: boolean }} p
2663
+ * @returns {Promise<Object>} the player row (`__created` is true when it was just provisioned)
2664
+ */
2665
+ async resolve({ externalId, displayName = null, avatarUrl = null, autoProvision = true }) {
2666
+ const now = /* @__PURE__ */ new Date();
2667
+ const id = String(externalId).trim();
2668
+ const existing = await strapi.db.query(UID.PLAYER).findOne({ where: { externalId: id } });
2669
+ if (existing) {
2670
+ const patch = { lastActiveAt: now };
2671
+ if (displayName && displayName !== existing.displayName) patch.displayName = displayName.slice(0, 100);
2672
+ if (avatarUrl && avatarUrl !== existing.avatarUrl) patch.avatarUrl = avatarUrl.slice(0, 500);
2673
+ return strapi.db.query(UID.PLAYER).update({ where: { id: existing.id }, data: patch });
2674
+ }
2675
+ if (!autoProvision) throw notFound(ERROR_CODES.PLAYER_NOT_FOUND, "Joueur inconnu.");
2676
+ const player2 = await strapi.db.query(UID.PLAYER).create({
2677
+ data: {
2678
+ externalId: id,
2679
+ msisdn: /^\+?\d{8,15}$/.test(id) ? id : null,
2680
+ code: await this.generateCode(),
2681
+ displayName: displayName ? displayName.slice(0, 100) : null,
2682
+ avatarUrl: avatarUrl ? avatarUrl.slice(0, 500) : null,
2683
+ provider: "api",
2684
+ isGuest: false,
2685
+ isActive: true,
2686
+ lastActiveAt: now
2687
+ }
2688
+ });
2689
+ return { ...player2, __created: true };
2690
+ },
2691
+ /** @param {number} id internal id */
2692
+ async byId(id) {
2693
+ const player2 = await strapi.db.query(UID.PLAYER).findOne({ where: { id } });
2694
+ if (!player2) throw notFound(ERROR_CODES.PLAYER_NOT_FOUND, "Joueur introuvable.");
2695
+ return player2;
2696
+ },
2697
+ async byDocumentId(documentId) {
2698
+ const player2 = await strapi.db.query(UID.PLAYER).findOne({ where: { documentId } });
2699
+ if (!player2) throw notFound(ERROR_CODES.PLAYER_NOT_FOUND, "Joueur introuvable.");
2700
+ return player2;
2701
+ },
2702
+ async byCode(code) {
2703
+ return strapi.db.query(UID.PLAYER).findOne({ where: { code: String(code).toUpperCase().trim() } });
2704
+ },
2705
+ async touch(id) {
2706
+ await strapi.db.query(UID.PLAYER).update({ where: { id }, data: { lastActiveAt: /* @__PURE__ */ new Date() } });
2707
+ },
2708
+ /**
2709
+ * Admin search by code, msisdn fragment or display name.
2710
+ * @param {{ q?: string, provider?: string, isActive?: boolean }} filters
2711
+ * @param {{ offset: number, limit: number }} page
2712
+ */
2713
+ async search(filters, page) {
2714
+ const where = {};
2715
+ if (filters.q) {
2716
+ const q = String(filters.q).trim();
2717
+ where.$or = [{ code: { $containsi: q } }, { externalId: { $containsi: q } }, { msisdn: { $contains: q.replace(/[^\d]/g, "") || q } }, { displayName: { $containsi: q } }];
2718
+ }
2719
+ if (filters.provider) where.provider = filters.provider;
2720
+ if (typeof filters.isActive === "boolean") where.isActive = filters.isActive;
2721
+ const [items, total] = await Promise.all([
2722
+ strapi.db.query(UID.PLAYER).findMany({ where, orderBy: { lastActiveAt: "desc" }, offset: page.offset, limit: page.limit }),
2723
+ strapi.db.query(UID.PLAYER).count({ where })
2724
+ ]);
2725
+ return { items, total };
2726
+ },
2727
+ async setActive(documentId, isActive) {
2728
+ const player2 = await this.byDocumentId(documentId);
2729
+ return strapi.db.query(UID.PLAYER).update({ where: { id: player2.id }, data: { isActive: Boolean(isActive) } });
2730
+ },
2731
+ /** Recomputes the denormalised counters from the ledger tables. */
2732
+ async recount(id) {
2733
+ const [totalCards, totalRedemptions, asInitiator, asReceiver] = await Promise.all([
2734
+ strapi.db.query(UID.PLAYER_CARD).count({ where: { player: id } }),
2735
+ strapi.db.query(UID.REDEMPTION).count({ where: { player: id } }),
2736
+ strapi.db.query(UID.TRADE).count({ where: { initiator: id } }),
2737
+ strapi.db.query(UID.TRADE).count({ where: { receiver: id } })
2738
+ ]);
2739
+ return strapi.db.query(UID.PLAYER).update({
2740
+ where: { id },
2741
+ data: { totalCards, totalRedemptions, totalTrades: asInitiator + asReceiver }
2742
+ });
2743
+ }
2744
+ });
2745
+ const slugify = (value) => String(value ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "item";
2746
+ const uniqueSlug = async (strapi, uid, base, extraWhere = {}) => {
2747
+ const root = slugify(base);
2748
+ let candidate = root;
2749
+ for (let i = 2; i < 1e3; i += 1) {
2750
+ const exists = await strapi.db.query(uid).findOne({ where: { slug: candidate, ...extraWhere }, select: ["id"] });
2751
+ if (!exists) return candidate;
2752
+ candidate = `${root}-${i}`;
2753
+ }
2754
+ return `${root}-${Date.now()}`;
2755
+ };
2756
+ const POPULATE$8 = { cover: true };
2757
+ const season = ({ strapi }) => ({
2758
+ defaults() {
2759
+ return {
2760
+ rarities: DEFAULT_RARITIES.map((r) => ({ ...r })),
2761
+ editions: DEFAULT_EDITIONS.map((e) => ({ ...e })),
2762
+ limits: { ...DEFAULT_LIMITS },
2763
+ rankTiers: DEFAULT_RANK_TIERS.map((t) => ({ ...t }))
2764
+ };
2765
+ },
2766
+ /** Merges season limits over the defaults so a missing key never breaks the engine. */
2767
+ limitsOf(season2) {
2768
+ return { ...DEFAULT_LIMITS, ...season2?.limits ?? {} };
2769
+ },
2770
+ rankTiersOf(season2) {
2771
+ const tiers = Array.isArray(season2?.rankTiers) && season2.rankTiers.length ? season2.rankTiers : DEFAULT_RANK_TIERS;
2772
+ return [...tiers].sort((a, b) => b.minPercent - a.minPercent);
2773
+ },
2774
+ /** @returns {Promise<Object|null>} */
2775
+ async current() {
2776
+ const now = /* @__PURE__ */ new Date();
2777
+ const active = await strapi.db.query(UID.SEASON).findMany({
2778
+ where: { status: SEASON_STATUS.ACTIVE },
2779
+ populate: POPULATE$8,
2780
+ orderBy: [{ startsAt: "desc" }, { createdAt: "desc" }]
2781
+ });
2782
+ if (active.length === 0) return null;
2783
+ const inWindow = active.find(
2784
+ (s) => (!s.startsAt || new Date(s.startsAt) <= now) && (!s.endsAt || new Date(s.endsAt) >= now)
2785
+ );
2786
+ return inWindow ?? active[0];
2787
+ },
2788
+ async requireCurrent() {
2789
+ const season2 = await this.current();
2790
+ if (!season2) throw notFound(ERROR_CODES.SEASON_NOT_FOUND, "Aucune saison en cours.");
2791
+ return season2;
2792
+ },
2793
+ /** Resolves `?season=` (slug or documentId) or falls back to the current season. */
2794
+ async resolve(ref) {
2795
+ if (!ref) return this.requireCurrent();
2796
+ const season2 = await strapi.db.query(UID.SEASON).findOne({
2797
+ where: { $or: [{ slug: ref }, { documentId: ref }] },
2798
+ populate: POPULATE$8
2799
+ });
2800
+ if (!season2) throw notFound(ERROR_CODES.SEASON_NOT_FOUND, "Saison introuvable.");
2801
+ return season2;
2802
+ },
2803
+ async list() {
2804
+ return strapi.db.query(UID.SEASON).findMany({ populate: POPULATE$8, orderBy: [{ status: "asc" }, { startsAt: "desc" }] });
2805
+ },
2806
+ async findOne(ref) {
2807
+ return this.resolve(ref);
2808
+ },
2809
+ validate(data) {
2810
+ if (data.rarities !== void 0) {
2811
+ const problems = validateRarities(data.rarities);
2812
+ if (problems.length) throw badRequest(ERROR_CODES.VALIDATION, "Table de raretés invalide.", { problems });
2813
+ }
2814
+ if (data.limits !== void 0) {
2815
+ for (const [k, v] of Object.entries(data.limits)) {
2816
+ if (!(k in DEFAULT_LIMITS)) throw badRequest(ERROR_CODES.VALIDATION, `Limite inconnue "${k}".`);
2817
+ if (!(Number.isFinite(Number(v)) && Number(v) >= 0)) throw badRequest(ERROR_CODES.VALIDATION, `Limite "${k}" invalide.`);
2818
+ }
2819
+ }
2820
+ if (data.rankTiers !== void 0) {
2821
+ if (!Array.isArray(data.rankTiers) || data.rankTiers.some((t) => !t?.key || !Number.isFinite(Number(t.minPercent)))) {
2822
+ throw badRequest(ERROR_CODES.VALIDATION, "Paliers de rang invalides.");
2823
+ }
2824
+ }
2825
+ if (data.status !== void 0 && !Object.values(SEASON_STATUS).includes(data.status)) {
2826
+ throw badRequest(ERROR_CODES.VALIDATION, "Statut invalide.");
2827
+ }
2828
+ },
2829
+ async create(data) {
2830
+ const payload = { ...this.defaults(), status: SEASON_STATUS.DRAFT, ...data };
2831
+ if (!payload.name) throw badRequest(ERROR_CODES.VALIDATION, "Le nom est requis.");
2832
+ this.validate(payload);
2833
+ payload.slug = await uniqueSlug(strapi, UID.SEASON, payload.slug || payload.name);
2834
+ const { seedBadges = true, ...clean } = payload;
2835
+ const season2 = await strapi.documents(UID.SEASON).create({ data: clean, populate: POPULATE$8 });
2836
+ if (seedBadges) await strapi.plugin("tcg").service("badge").seedStarters(season2);
2837
+ return season2;
2838
+ },
2839
+ async update(ref, data) {
2840
+ const season2 = await this.resolve(ref);
2841
+ this.validate(data);
2842
+ if (data.slug && data.slug !== season2.slug) data.slug = await uniqueSlug(strapi, UID.SEASON, data.slug);
2843
+ return strapi.documents(UID.SEASON).update({ documentId: season2.documentId, data, populate: POPULATE$8 });
2844
+ },
2845
+ async remove(ref) {
2846
+ const season2 = await this.resolve(ref);
2847
+ const [cards, pulls] = await Promise.all([
2848
+ strapi.db.query(UID.CARD).count({ where: { season: season2.id } }),
2849
+ strapi.db.query(UID.PULL).count({ where: { season: season2.id } })
2850
+ ]);
2851
+ if (pulls > 0) throw badRequest(ERROR_CODES.VALIDATION, "Cette saison a déjà des tirages : archivez-la au lieu de la supprimer.");
2852
+ if (cards > 0) throw badRequest(ERROR_CODES.VALIDATION, "Supprimez ou déplacez les cartes de la saison avant.");
2853
+ await strapi.documents(UID.SEASON).delete({ documentId: season2.documentId });
2854
+ return { ok: true };
2855
+ },
2856
+ /** Rank tier for a completion percentage. */
2857
+ tierFor(season2, percent) {
2858
+ const tiers = this.rankTiersOf(season2);
2859
+ return tiers.find((t) => percent >= Number(t.minPercent)) ?? tiers[tiers.length - 1];
2860
+ }
2861
+ });
2862
+ const POPULATE$7 = { image: true, season: true };
2863
+ const card = ({ strapi }) => ({
2864
+ /**
2865
+ * @param {Object} season
2866
+ * @param {{ rarity?: string, edition?: string, q?: string, isActive?: boolean }} filters
2867
+ * @param {{ offset: number, limit: number }} [page]
2868
+ */
2869
+ async list(season2, filters = {}, page) {
2870
+ const where = { season: season2.id };
2871
+ if (filters.rarity) where.rarity = filters.rarity;
2872
+ if (filters.edition) where.edition = filters.edition;
2873
+ if (typeof filters.isActive === "boolean") where.isActive = filters.isActive;
2874
+ if (filters.q) where.name = { $containsi: String(filters.q) };
2875
+ const q = { where, populate: POPULATE$7, orderBy: [{ number: "asc" }, { name: "asc" }] };
2876
+ if (page) Object.assign(q, { offset: page.offset, limit: page.limit });
2877
+ const [items, total] = await Promise.all([strapi.db.query(UID.CARD).findMany(q), strapi.db.query(UID.CARD).count({ where })]);
2878
+ return { items, total };
2879
+ },
2880
+ async findOne(documentId) {
2881
+ const card2 = await strapi.db.query(UID.CARD).findOne({ where: { documentId }, populate: POPULATE$7 });
2882
+ if (!card2) throw notFound(ERROR_CODES.CARD_NOT_FOUND, "Carte introuvable.");
2883
+ return card2;
2884
+ },
2885
+ validate(season2, data) {
2886
+ if (data.rarity !== void 0) {
2887
+ const keys = (season2.rarities ?? []).map((r) => r.key);
2888
+ if (!keys.includes(data.rarity)) throw badRequest(ERROR_CODES.VALIDATION, `Rareté "${data.rarity}" inconnue pour cette saison.`, { allowed: keys });
2889
+ }
2890
+ if (data.supplyCap !== void 0 && data.supplyCap !== null && data.supplyCap !== "" && !(Number(data.supplyCap) >= 1)) {
2891
+ throw badRequest(ERROR_CODES.VALIDATION, "supplyCap doit être ≥ 1 (ou vide pour illimité).");
2892
+ }
2893
+ if (data.edition !== void 0 && Array.isArray(season2.editions) && season2.editions.length) {
2894
+ const keys = season2.editions.map((e) => e.key);
2895
+ if (!keys.includes(data.edition)) throw badRequest(ERROR_CODES.VALIDATION, `Édition "${data.edition}" inconnue.`, { allowed: keys });
2896
+ }
2897
+ },
2898
+ async create(season2, data) {
2899
+ if (!data.name) throw badRequest(ERROR_CODES.VALIDATION, "Le nom est requis.");
2900
+ if (!data.rarity) throw badRequest(ERROR_CODES.VALIDATION, "La rareté est requise.");
2901
+ this.validate(season2, data);
2902
+ return strapi.documents(UID.CARD).create({ data: { ...data, season: season2.id }, populate: POPULATE$7 });
2903
+ },
2904
+ async update(season2, documentId, data) {
2905
+ await this.findOne(documentId);
2906
+ this.validate(season2, data);
2907
+ const { season: _ignored, minted: _m, sets: _sets, ...rest } = data;
2908
+ if (rest.supplyCap === "") rest.supplyCap = null;
2909
+ return strapi.documents(UID.CARD).update({ documentId, data: rest, populate: POPULATE$7 });
2910
+ },
2911
+ async remove(documentId) {
2912
+ const card2 = await this.findOne(documentId);
2913
+ const owned = await strapi.db.query(UID.PLAYER_CARD).count({ where: { card: card2.id } });
2914
+ if (owned > 0) throw badRequest(ERROR_CODES.VALIDATION, "Des joueurs possèdent cette carte : désactivez-la au lieu de la supprimer.");
2915
+ await strapi.documents(UID.CARD).delete({ documentId });
2916
+ return { ok: true };
2917
+ },
2918
+ /** Admin list enrichment: where each card is exclusive. */
2919
+ async withExclusivity(cards) {
2920
+ const drop2 = strapi.plugin("tcg").service("drop");
2921
+ return Promise.all(cards.map(async (c) => ({ ...c, exclusiveTo: await drop2.exclusivityOf(c.id) })));
2922
+ },
2923
+ /** Count of cards per rarity for a season (admin). */
2924
+ async countByRarity(season2) {
2925
+ const rows = await strapi.db.query(UID.CARD).findMany({ where: { season: season2.id, isActive: true }, select: ["rarity"] });
2926
+ return rows.reduce((acc, r) => ({ ...acc, [r.rarity]: (acc[r.rarity] ?? 0) + 1 }), {});
2927
+ }
2928
+ });
2929
+ const POPULATE$6 = { image: true, season: true };
2930
+ const pack = ({ strapi }) => ({
2931
+ async list(season2, { includeInactive = false } = {}) {
2932
+ const where = { season: season2.id };
2933
+ if (!includeInactive) where.isActive = true;
2934
+ return strapi.db.query(UID.PACK).findMany({ where, populate: POPULATE$6, orderBy: [{ sortOrder: "asc" }, { name: "asc" }] });
2935
+ },
2936
+ async findOne(season2, ref) {
2937
+ const pack2 = await strapi.db.query(UID.PACK).findOne({ where: { season: season2.id, $or: [{ slug: ref }, { documentId: ref }] }, populate: POPULATE$6 });
2938
+ if (!pack2) throw notFound(ERROR_CODES.PACK_NOT_FOUND, "Pack introuvable.");
2939
+ return pack2;
2940
+ },
2941
+ /** Base pool: active pack cards with weights. */
2942
+ async pool(pack2) {
2943
+ const rows = await strapi.db.query(UID.PACK_CARD).findMany({
2944
+ where: { pack: pack2.id, isActive: true, card: { isActive: true } },
2945
+ populate: { card: { populate: { image: true } } }
2946
+ });
2947
+ return rows.filter((r) => r.card).map((r) => ({ ...r.card, weight: r.weight, isExclusive: r.isExclusive, packCardId: r.id }));
2948
+ },
2949
+ async create(season2, data) {
2950
+ if (!data.name) throw badRequest(ERROR_CODES.VALIDATION, "Le nom est requis.");
2951
+ if (data.kind && !Object.values(PACK_KIND).includes(data.kind)) throw badRequest(ERROR_CODES.VALIDATION, "Type de pack invalide.");
2952
+ const { cards, drops: _d, ...rest } = data;
2953
+ const slug = await uniqueSlug(strapi, UID.PACK, rest.slug || rest.name, { season: season2.id });
2954
+ const pack2 = await strapi.documents(UID.PACK).create({ data: { ...rest, slug, season: season2.id }, populate: POPULATE$6 });
2955
+ const full = { ...pack2, season: season2 };
2956
+ if (Array.isArray(cards)) await this.setCards(season2, pack2.documentId, cards);
2957
+ await strapi.plugin("tcg").service("drop").ensureDefault(full);
2958
+ return pack2;
2959
+ },
2960
+ async update(season2, ref, data) {
2961
+ const pack2 = await this.findOne(season2, ref);
2962
+ if (data.kind && !Object.values(PACK_KIND).includes(data.kind)) throw badRequest(ERROR_CODES.VALIDATION, "Type de pack invalide.");
2963
+ const { cards, season: _s, drops: _d, ...rest } = data;
2964
+ if (rest.slug && rest.slug !== pack2.slug) rest.slug = await uniqueSlug(strapi, UID.PACK, rest.slug, { season: season2.id });
2965
+ const updated = await strapi.documents(UID.PACK).update({ documentId: pack2.documentId, data: rest, populate: POPULATE$6 });
2966
+ if (Array.isArray(cards)) await this.setCards(season2, pack2.documentId, cards);
2967
+ return updated;
2968
+ },
2969
+ async remove(season2, ref) {
2970
+ const pack2 = await this.findOne(season2, ref);
2971
+ const pulls = await strapi.db.query(UID.PULL).count({ where: { pack: pack2.id } });
2972
+ if (pulls > 0) throw badRequest(ERROR_CODES.VALIDATION, "Ce pack a des tirages : désactivez-le au lieu de le supprimer.");
2973
+ const drops = await strapi.db.query(UID.DROP).findMany({ where: { pack: pack2.id }, select: ["id", "documentId"] });
2974
+ for (const d of drops) {
2975
+ await strapi.db.query(UID.DROP_CARD).deleteMany({ where: { drop: d.id } });
2976
+ await strapi.documents(UID.DROP).delete({ documentId: d.documentId });
2977
+ }
2978
+ await strapi.db.query(UID.PACK_CARD).deleteMany({ where: { pack: pack2.id } });
2979
+ await strapi.documents(UID.PACK).delete({ documentId: pack2.documentId });
2980
+ return { ok: true };
2981
+ },
2982
+ async setCards(season2, packDocumentId, entries) {
2983
+ const pack2 = await this.findOne(season2, packDocumentId);
2984
+ const docIds = [...new Set(entries.map((e) => typeof e === "string" ? e : e.card))];
2985
+ const cards = docIds.length ? await strapi.db.query(UID.CARD).findMany({ where: { documentId: { $in: docIds }, season: season2.id }, select: ["id", "documentId"] }) : [];
2986
+ if (cards.length !== docIds.length) throw badRequest(ERROR_CODES.VALIDATION, "Certaines cartes ne font pas partie de cette saison.");
2987
+ const idByDoc = Object.fromEntries(cards.map((c) => [c.documentId, c.id]));
2988
+ await strapi.db.transaction(async () => {
2989
+ await strapi.db.query(UID.PACK_CARD).deleteMany({ where: { pack: pack2.id } });
2990
+ for (const e of entries) {
2991
+ const entry = typeof e === "string" ? { card: e } : e;
2992
+ await strapi.db.query(UID.PACK_CARD).create({
2993
+ data: { pack: pack2.id, card: idByDoc[entry.card], weight: Number.isFinite(Number(entry.weight)) ? Number(entry.weight) : 100, isExclusive: Boolean(entry.isExclusive), isActive: entry.isActive !== false }
2994
+ });
2995
+ }
2996
+ });
2997
+ return this.cards(season2, packDocumentId);
2998
+ },
2999
+ async cards(season2, ref) {
3000
+ const pack2 = await this.findOne(season2, ref);
3001
+ const rows = await strapi.db.query(UID.PACK_CARD).findMany({ where: { pack: pack2.id }, populate: { card: { populate: { image: true } } }, orderBy: { id: "asc" } });
3002
+ return rows.filter((r) => r.card).map((r) => ({ id: r.documentId, weight: r.weight, isExclusive: r.isExclusive, isActive: r.isActive, card: r.card }));
3003
+ },
3004
+ /** Pack-level QR = the default drop's QR. */
3005
+ async generateQR(season2, ref) {
3006
+ const pack2 = await this.findOne(season2, ref);
3007
+ const drop2 = await strapi.plugin("tcg").service("drop").ensureDefault({ ...pack2, season: season2 });
3008
+ const out = await strapi.plugin("tcg").service("drop").generateQR({ ...pack2, season: season2 }, drop2.documentId);
3009
+ return { ...out, pack: pack2 };
3010
+ }
3011
+ });
3012
+ const startOfToday$1 = () => {
3013
+ const d = /* @__PURE__ */ new Date();
3014
+ d.setHours(0, 0, 0, 0);
3015
+ return d;
3016
+ };
3017
+ const pull = ({ strapi }) => ({
3018
+ /**
3019
+ * Conditional increment on a capped counter. Returns the new value, or null when the cap is reached.
3020
+ * Pass `trx` when called inside `strapi.db.transaction(async ({ trx }) => …)`: sqlite has a single connection
3021
+ * and a second one would wait forever.
3022
+ */
3023
+ async claimSlot(uid, id, counter, capColumn, trx = null) {
3024
+ const md = strapi.db.metadata.get(uid);
3025
+ const table = md.tableName;
3026
+ const col = md.attributes[counter].columnName ?? counter;
3027
+ const cap = capColumn ? md.attributes[capColumn].columnName ?? capColumn : null;
3028
+ const knex = trx ?? strapi.db.connection;
3029
+ let q = knex(table).where({ id });
3030
+ if (cap) q = q.andWhere((b) => b.whereNull(cap).orWhereRaw(`${col} < ${cap}`));
3031
+ const affected = await q.increment(col, 1);
3032
+ if (!affected) return null;
3033
+ const row = await knex(table).where({ id }).first(col);
3034
+ return Number(row[col]);
3035
+ },
3036
+ /**
3037
+ * @param {Object} player db row
3038
+ * @param {string} qrToken
3039
+ */
3040
+ async scan(player2, qrToken) {
3041
+ const plugin = strapi.plugin("tcg");
3042
+ const peek = peekToken(qrToken);
3043
+ if (!peek || peek.kind !== "drop") throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3044
+ const drop2 = await plugin.service("drop").byDocumentId(peek.dropId);
3045
+ const pack2 = drop2.pack;
3046
+ 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.");
3049
+ 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.");
3052
+ const limits = plugin.service("season").limitsOf(season2);
3053
+ await plugin.service("qr").consume(qrToken, plugin.service("qr").secretOf(drop2), "drop");
3054
+ const now = /* @__PURE__ */ new Date();
3055
+ 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).`);
3057
+ 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"] });
3059
+ if (lastHere) {
3060
+ const elapsed = now.getTime() - new Date(lastHere.pulledAt).getTime();
3061
+ if (elapsed < cooldownMs) {
3062
+ 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 });
3064
+ }
3065
+ }
3066
+ 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.`);
3069
+ }
3070
+ const since = startOfToday$1();
3071
+ 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 } } })
3074
+ ]);
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}).`);
3077
+ const raw = await plugin.service("drop").rawPool(drop2, pack2);
3078
+ const exclusiveIds = await plugin.service("drop").exclusiveCardIds(season2);
3079
+ let pool = prunePool(raw, exclusiveIds);
3080
+ if (pool.length === 0) throw unprocessable(ERROR_CODES.PACK_EMPTY, "Aucune carte disponible ici pour le moment.");
3081
+ const boosts = await plugin.service("boost").activeFor(season2, pack2, drop2);
3082
+ const multipliers = plugin.service("boost").combine(boosts);
3083
+ const recent = await strapi.db.query(UID.PULL).findMany({
3084
+ where: { player: player2.id, season: season2.id },
3085
+ orderBy: { pulledAt: "desc" },
3086
+ limit: Math.max(Number(limits.pityRareAfter) || 0, Number(limits.pityLegendaryAfter) || 0, 1),
3087
+ select: ["rarity"]
3088
+ });
3089
+ 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
3128
+ }
3129
+ });
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: {
3140
+ rarity: result.rarity,
3141
+ isNew: alreadyOwned === 0,
3142
+ pity: result.pity,
3143
+ odds: result.odds,
3144
+ boosts: boosts.map((b) => ({ name: b.name, multipliers: b.multipliers })),
3145
+ isExclusive: Boolean(result.card.isExclusive),
3146
+ pack: { id: pack2.documentId, name: pack2.name, slug: pack2.slug },
3147
+ drop: { id: drop2.documentId, name: drop2.name, slug: drop2.slug },
3148
+ verificationHash: result.verificationHash,
3149
+ pulledAt: now
3150
+ },
3151
+ unlocked
3152
+ };
3153
+ },
3154
+ async history(player2, season2, page) {
3155
+ const where = { player: player2.id, season: season2.id };
3156
+ 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 }),
3158
+ strapi.db.query(UID.PULL).count({ where })
3159
+ ]);
3160
+ const format2 = strapi.plugin("tcg").service("format");
3161
+ return {
3162
+ items: items.map((p) => ({
3163
+ id: p.documentId,
3164
+ rarity: p.rarity,
3165
+ pulledAt: p.pulledAt,
3166
+ pity: Boolean(p.pity),
3167
+ mintNumber: p.mintNumber ?? null,
3168
+ verificationHash: p.verificationHash,
3169
+ card: format2.card(p.card, season2),
3170
+ pack: p.pack ? { id: p.pack.documentId, name: p.pack.name, slug: p.pack.slug } : null,
3171
+ drop: p.drop ? { id: p.drop.documentId, name: p.drop.name, slug: p.drop.slug } : null
3172
+ })),
3173
+ total
3174
+ };
3175
+ },
3176
+ /** Per-pack and per-drop cooldown state for the player. */
3177
+ async cooldowns(player2, season2) {
3178
+ const plugin = strapi.plugin("tcg");
3179
+ const limits = plugin.service("season").limitsOf(season2);
3180
+ const packs = await plugin.service("pack").list(season2);
3181
+ 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"] });
3183
+ const countByPack = {};
3184
+ for (const p of todays) countByPack[p.pack?.id] = (countByPack[p.pack?.id] ?? 0) + 1;
3185
+ const totalToday = todays.length;
3186
+ const now = Date.now();
3187
+ const result = [];
3188
+ for (const pack2 of packs) {
3189
+ const drops = await plugin.service("drop").list(pack2, { includeInactive: false });
3190
+ const dropStates = [];
3191
+ 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"] });
3193
+ const cooldownMs = drop2.cooldownMs ?? (drop2.mode === DROP_MODE.STATIC ? Math.max(limits.pullCooldownMs, limits.staticScanCooldownMs) : limits.pullCooldownMs);
3194
+ 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) });
3196
+ }
3197
+ const pullsToday = countByPack[pack2.id] ?? 0;
3198
+ const anyReady = dropStates.some((d) => d.isOpen && d.cooldownRemainingMs === 0);
3199
+ result.push({
3200
+ pack: { id: pack2.documentId, name: pack2.name, slug: pack2.slug },
3201
+ pullsToday,
3202
+ maxPullsPerDay: limits.maxDailyPullsPerPack,
3203
+ canPull: anyReady && pullsToday < limits.maxDailyPullsPerPack && totalToday < limits.maxDailyPullsTotal,
3204
+ drops: dropStates
3205
+ });
3206
+ }
3207
+ return { totalPullsToday: totalToday, maxDailyPulls: limits.maxDailyPullsTotal, packs: result };
3208
+ }
3209
+ });
3210
+ const collection = ({ strapi }) => ({
3211
+ async list(player2, season2, { rarity } = {}, page) {
3212
+ const where = { player: player2.id, card: { season: season2.id } };
3213
+ if (rarity) where.card.rarity = rarity;
3214
+ const [items, total] = await Promise.all([
3215
+ strapi.db.query(UID.PLAYER_CARD).findMany({
3216
+ where,
3217
+ populate: { card: { populate: { image: true } }, pack: true },
3218
+ orderBy: { acquiredAt: "desc" },
3219
+ offset: page.offset,
3220
+ limit: page.limit
3221
+ }),
3222
+ strapi.db.query(UID.PLAYER_CARD).count({ where })
3223
+ ]);
3224
+ const format2 = strapi.plugin("tcg").service("format");
3225
+ return { items: items.map((pc) => format2.playerCard(pc, season2)), total };
3226
+ },
3227
+ /** Distinct card ids owned by a player in a season. */
3228
+ async ownedCardIds(player2, season2) {
3229
+ const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3230
+ where: { player: player2.id, card: { season: season2.id } },
3231
+ populate: { card: { select: ["id"] } }
3232
+ });
3233
+ return [...new Set(rows.map((r) => r.card?.id).filter(Boolean))];
3234
+ },
3235
+ /**
3236
+ * Global rank = 1 + number of players owning more distinct season cards. Uses the link tables
3237
+ * Strapi 5 creates for relations, resolved from metadata so table names never get hardcoded.
3238
+ */
3239
+ async rank(player2, season2, myUnique) {
3240
+ if (myUnique === 0) return null;
3241
+ const knex = strapi.db.connection;
3242
+ const md = strapi.db.metadata.get(UID.PLAYER_CARD);
3243
+ const pcTable = md.tableName;
3244
+ const playerLnk = md.attributes.player.joinTable;
3245
+ const cardLnk = md.attributes.card.joinTable;
3246
+ const cardMd = strapi.db.metadata.get(UID.CARD);
3247
+ const seasonLnk = cardMd.attributes.season.joinTable;
3248
+ const seasonId = season2.id;
3249
+ const sub = knex(`${pcTable} as pc`).join(`${playerLnk.name} as pl`, "pl." + playerLnk.joinColumn.name, "pc.id").join(`${cardLnk.name} as cl`, "cl." + cardLnk.joinColumn.name, "pc.id").join(`${seasonLnk.name} as sl`, "sl." + seasonLnk.joinColumn.name, "cl." + cardLnk.inverseJoinColumn.name).where("sl." + seasonLnk.inverseJoinColumn.name, seasonId).groupBy("pl." + playerLnk.inverseJoinColumn.name).select("pl." + playerLnk.inverseJoinColumn.name + " as player_id").countDistinct("cl." + cardLnk.inverseJoinColumn.name + " as unique_cards");
3250
+ const row = await knex.from(sub.as("ranked")).where("ranked.unique_cards", ">", myUnique).count("* as better").first();
3251
+ return Number(row?.better ?? 0) + 1;
3252
+ },
3253
+ async stats(player2, season2) {
3254
+ const plugin = strapi.plugin("tcg");
3255
+ const format2 = plugin.service("format");
3256
+ const [owned, catalog, packs, latest] = await Promise.all([
3257
+ strapi.db.query(UID.PLAYER_CARD).findMany({
3258
+ where: { player: player2.id, card: { season: season2.id } },
3259
+ populate: { card: { select: ["id", "rarity"] } }
3260
+ }),
3261
+ strapi.db.query(UID.CARD).findMany({ where: { season: season2.id, isActive: true }, select: ["id", "rarity"] }),
3262
+ plugin.service("pack").list(season2),
3263
+ strapi.db.query(UID.PLAYER_CARD).findOne({
3264
+ where: { player: player2.id, card: { season: season2.id } },
3265
+ populate: { card: { populate: { image: true } }, pack: true },
3266
+ orderBy: { acquiredAt: "desc" }
3267
+ })
3268
+ ]);
3269
+ const ownedIds = new Set(owned.map((o) => o.card?.id).filter(Boolean));
3270
+ const catalogTotal = catalog.length;
3271
+ const completion = catalogTotal ? Math.round(ownedIds.size / catalogTotal * 100) : 0;
3272
+ const byRarity = {};
3273
+ for (const r of season2.rarities ?? []) {
3274
+ byRarity[r.key] = {
3275
+ label: r.label,
3276
+ color: r.color,
3277
+ owned: owned.filter((o) => o.card?.rarity === r.key).length,
3278
+ unique: catalog.filter((c) => c.rarity === r.key && ownedIds.has(c.id)).length,
3279
+ total: catalog.filter((c) => c.rarity === r.key).length
3280
+ };
3281
+ }
3282
+ const packRows = await strapi.db.query(UID.PACK_CARD).findMany({
3283
+ where: { pack: { id: { $in: packs.map((p) => p.id) } }, isActive: true },
3284
+ populate: { pack: { select: ["id"] }, card: { select: ["id"] } }
3285
+ });
3286
+ const byPack = packs.map((p) => {
3287
+ const cardIds = [...new Set(packRows.filter((r) => r.pack?.id === p.id).map((r) => r.card?.id).filter(Boolean))];
3288
+ const collected = cardIds.filter((id) => ownedIds.has(id)).length;
3289
+ return { pack: format2.pack(p), collected, total: cardIds.length, isComplete: cardIds.length > 0 && collected === cardIds.length };
3290
+ });
3291
+ const tier = plugin.service("season").tierFor(season2, completion);
3292
+ const [sets, badges, metrics, live] = await Promise.all([
3293
+ plugin.service("set").list(season2, { includeInactive: false }),
3294
+ plugin.service("badge").forPlayer(player2, season2),
3295
+ plugin.service("badge").metrics(player2, season2),
3296
+ plugin.service("boost").liveNow(season2)
3297
+ ]);
3298
+ return {
3299
+ sets: plugin.service("set").progress(sets, ownedIds, season2),
3300
+ badges,
3301
+ streakDays: metrics.streakDays,
3302
+ liveBoosts: live,
3303
+ totalCards: owned.length,
3304
+ uniqueCards: ownedIds.size,
3305
+ catalogTotal,
3306
+ completionPercentage: completion,
3307
+ globalRank: await this.rank(player2, season2, ownedIds.size),
3308
+ rankTier: { key: tier.key, label: tier.label },
3309
+ season: format2.season(season2),
3310
+ byRarity,
3311
+ byPack,
3312
+ latestAcquisition: latest ? format2.playerCard(latest, season2) : null
3313
+ };
3314
+ },
3315
+ /** Admin: give a card to a player (events, support). Mints a copy; ignores the supply cap on purpose. */
3316
+ async grant(player2, card2, pack2 = null) {
3317
+ const now = /* @__PURE__ */ new Date();
3318
+ const mintNumber = await strapi.plugin("tcg").service("pull").claimSlot(UID.CARD, card2.id, "minted", null);
3319
+ const pc = await strapi.db.transaction(async () => {
3320
+ const row = await strapi.db.query(UID.PLAYER_CARD).create({
3321
+ data: { player: player2.id, card: card2.id, pack: pack2?.id ?? null, acquiredFrom: "grant", acquiredAt: now, mintNumber }
3322
+ });
3323
+ await strapi.db.query(UID.PLAYER).update({ where: { id: player2.id }, data: { totalCards: (player2.totalCards ?? 0) + 1 } });
3324
+ return row;
3325
+ });
3326
+ return pc;
3327
+ }
3328
+ });
3329
+ const trade = ({ strapi }) => ({
3330
+ /** Player-card rows owned by `player` among `documentIds`; throws if any is missing. */
3331
+ async ownedRows(player2, documentIds, season2) {
3332
+ const ids = [...new Set((documentIds ?? []).map(String))];
3333
+ if (ids.length === 0) throw badRequest(ERROR_CODES.VALIDATION, "Sélectionnez au moins une carte.");
3334
+ const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3335
+ where: { player: player2.id, documentId: { $in: ids }, ...season2 ? { card: { season: season2.id } } : {} },
3336
+ populate: { card: { populate: { image: true } } }
3337
+ });
3338
+ if (rows.length !== ids.length) throw badRequest(ERROR_CODES.CARD_NOT_OWNED, "Vous ne possédez pas toutes ces cartes.");
3339
+ return rows;
3340
+ },
3341
+ async createOffer(player2, season2, { cardIds }) {
3342
+ const plugin = strapi.plugin("tcg");
3343
+ const limits = plugin.service("season").limitsOf(season2);
3344
+ if ((cardIds ?? []).length > limits.maxCardsPerTrade) {
3345
+ throw unprocessable(ERROR_CODES.TRADE_TOO_MANY_CARDS, `Maximum ${limits.maxCardsPerTrade} cartes par échange.`);
3346
+ }
3347
+ const active = await strapi.db.query(UID.TRADE_OFFER).count({ where: { initiator: player2.id, status: TRADE_STATUS.PENDING } });
3348
+ if (active >= limits.maxActiveTradeOffers) {
3349
+ throw unprocessable(ERROR_CODES.TRADE_LIMIT, `Maximum ${limits.maxActiveTradeOffers} offres actives.`);
3350
+ }
3351
+ const rows = await this.ownedRows(player2, cardIds, season2);
3352
+ const expiresAt = new Date(Date.now() + limits.tradeOfferExpiryMs);
3353
+ 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 }
3355
+ });
3356
+ const { token } = plugin.service("qr").forTrade(offer, player2, limits.tradeOfferExpiryMs);
3357
+ await strapi.db.query(UID.TRADE_OFFER).update({ where: { id: offer.id }, data: { qrToken: token } });
3358
+ const format2 = plugin.service("format");
3359
+ return {
3360
+ id: offer.documentId,
3361
+ status: TRADE_STATUS.PENDING,
3362
+ qrToken: token,
3363
+ expiresAt,
3364
+ offeredCards: rows.map((r) => format2.playerCard(r, season2))
3365
+ };
3366
+ },
3367
+ async findOffer(documentId) {
3368
+ const offer = await strapi.db.query(UID.TRADE_OFFER).findOne({
3369
+ where: { documentId },
3370
+ populate: { initiator: true, season: true }
3371
+ });
3372
+ if (!offer) throw notFound(ERROR_CODES.TRADE_NOT_FOUND, "Offre introuvable.");
3373
+ return offer;
3374
+ },
3375
+ 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é.");
3378
+ },
3379
+ async describe(offer, season2) {
3380
+ const format2 = strapi.plugin("tcg").service("format");
3381
+ const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3382
+ where: { documentId: { $in: offer.offeredCardIds ?? [] } },
3383
+ populate: { card: { populate: { image: true } } }
3384
+ });
3385
+ return {
3386
+ id: offer.documentId,
3387
+ status: offer.status,
3388
+ 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))
3391
+ };
3392
+ },
3393
+ /** The receiver scans the initiator's QR. The token is NOT burned here; acceptance burns it. */
3394
+ async scan(player2, qrToken) {
3395
+ 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.");
3398
+ const offer = await this.findOffer(peek.offerId);
3399
+ if (offer.qrToken !== qrToken) throw badRequest(ERROR_CODES.QR_INVALID, "QR code invalide.");
3400
+ this.assertOpen(offer);
3401
+ return this.describe(offer, offer.season);
3402
+ },
3403
+ /**
3404
+ * Atomic swap. `cardIds` are the receiver's player-card documentIds.
3405
+ */
3406
+ async accept(receiver, offerDocumentId, { cardIds }) {
3407
+ const plugin = strapi.plugin("tcg");
3408
+ const offer = await this.findOffer(offerDocumentId);
3409
+ this.assertOpen(offer);
3410
+ if (offer.initiator?.id === receiver.id) throw badRequest(ERROR_CODES.TRADE_SELF, "Vous ne pouvez pas accepter votre propre offre.");
3411
+ const season2 = offer.season;
3412
+ const limits = plugin.service("season").limitsOf(season2);
3413
+ if ((cardIds ?? []).length > limits.maxCardsPerTrade) {
3414
+ throw unprocessable(ERROR_CODES.TRADE_TOO_MANY_CARDS, `Maximum ${limits.maxCardsPerTrade} cartes par échange.`);
3415
+ }
3416
+ const receiverRows = await this.ownedRows(receiver, cardIds, season2);
3417
+ const initiatorRows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3418
+ where: { player: offer.initiator.id, documentId: { $in: offer.offeredCardIds ?? [] } }
3419
+ });
3420
+ if (initiatorRows.length !== (offer.offeredCardIds ?? []).length) {
3421
+ throw unprocessable(ERROR_CODES.CARD_NOT_OWNED, "L'autre joueur ne possède plus ces cartes.");
3422
+ }
3423
+ const now = /* @__PURE__ */ new Date();
3424
+ const trade2 = await strapi.db.transaction(async () => {
3425
+ 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.");
3427
+ await strapi.db.query(UID.TRADE_OFFER).update({ where: { id: offer.id }, data: { status: TRADE_STATUS.ACCEPTED } });
3428
+ const t = await strapi.db.query(UID.TRADE).create({
3429
+ data: {
3430
+ offer: offer.id,
3431
+ initiator: offer.initiator.id,
3432
+ receiver: receiver.id,
3433
+ season: season2.id,
3434
+ initiatorCardIds: initiatorRows.map((r) => r.documentId),
3435
+ receiverCardIds: receiverRows.map((r) => r.documentId),
3436
+ completedAt: now
3437
+ }
3438
+ });
3439
+ 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 } });
3441
+ }
3442
+ 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 } });
3444
+ }
3445
+ const delta = receiverRows.length - initiatorRows.length;
3446
+ 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 }
3449
+ });
3450
+ await strapi.db.query(UID.PLAYER).update({
3451
+ where: { id: receiver.id },
3452
+ data: { totalTrades: (receiver.totalTrades ?? 0) + 1, totalCards: Math.max(0, (receiver.totalCards ?? 0) - delta), lastActiveAt: now }
3453
+ });
3454
+ return t;
3455
+ });
3456
+ strapi.log.info(`[tcg] trade ${offer.initiator.code} ⇄ ${receiver.code} (${initiatorRows.length}:${receiverRows.length})`);
3457
+ 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 };
3460
+ },
3461
+ async reject(player2, offerDocumentId) {
3462
+ const offer = await this.findOffer(offerDocumentId);
3463
+ 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 } });
3466
+ return { ok: true };
3467
+ },
3468
+ async cancel(player2, offerDocumentId) {
3469
+ 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}.`);
3472
+ await strapi.db.query(UID.TRADE_OFFER).update({ where: { id: offer.id }, data: { status: TRADE_STATUS.CANCELLED } });
3473
+ return { ok: true };
3474
+ },
3475
+ async activeOffers(player2, season2) {
3476
+ 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 },
3479
+ orderBy: { createdAt: "desc" }
3480
+ });
3481
+ return Promise.all(offers.map((o) => this.describe(o, season2).then((d) => ({ ...d, qrToken: o.qrToken }))));
3482
+ },
3483
+ async history(player2, season2, page) {
3484
+ const where = { season: season2.id, $or: [{ initiator: player2.id }, { receiver: player2.id }] };
3485
+ 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 }),
3487
+ strapi.db.query(UID.TRADE).count({ where })
3488
+ ]);
3489
+ return {
3490
+ items: items.map((t) => ({
3491
+ id: t.documentId,
3492
+ completedAt: t.completedAt,
3493
+ 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,
3495
+ given: (t.initiator?.id === player2.id ? t.initiatorCardIds : t.receiverCardIds) ?? [],
3496
+ received: (t.initiator?.id === player2.id ? t.receiverCardIds : t.initiatorCardIds) ?? []
3497
+ })),
3498
+ total
3499
+ };
3500
+ },
3501
+ /** Marks pending offers past their expiry. */
3502
+ async sweep() {
3503
+ const { count } = await strapi.db.query(UID.TRADE_OFFER).updateMany({
3504
+ where: { status: TRADE_STATUS.PENDING, expiresAt: { $lt: /* @__PURE__ */ new Date() } },
3505
+ data: { status: TRADE_STATUS.EXPIRED }
3506
+ });
3507
+ return count ?? 0;
3508
+ }
3509
+ });
3510
+ const POPULATE$5 = { player: true, season: true, reward: { populate: { image: true } } };
3511
+ const redemption = ({ strapi }) => ({
3512
+ async generateCode() {
3513
+ for (let i = 0; i < MAX_CODE_ATTEMPTS; i += 1) {
3514
+ const code = randomCode(REDEMPTION_CODE_LENGTH);
3515
+ const exists = await strapi.db.query(UID.REDEMPTION).findOne({ where: { code }, select: ["id"] });
3516
+ if (!exists) return code;
3517
+ }
3518
+ throw conflict(ERROR_CODES.VALIDATION, "Impossible de générer un code.");
3519
+ },
3520
+ async create(player2, season2, { rewardId, cardIds }) {
3521
+ const plugin = strapi.plugin("tcg");
3522
+ if (!rewardId) throw unprocessable(ERROR_CODES.VALIDATION, "Choisissez une récompense.");
3523
+ const reward2 = await plugin.service("reward").findOne(season2, rewardId);
3524
+ const rows = (cardIds ?? []).length ? await plugin.service("trade").ownedRows(player2, cardIds, season2) : [];
3525
+ await plugin.service("reward").check(player2, season2, reward2, rows);
3526
+ const format2 = plugin.service("format");
3527
+ const snapshot = rows.map((r) => ({ playerCardId: r.documentId, mintNumber: r.mintNumber ?? null, ...format2.card(r.card, season2) }));
3528
+ const now = /* @__PURE__ */ new Date();
3529
+ const record = await strapi.db.transaction(async ({ trx }) => {
3530
+ if (reward2.stock != null) {
3531
+ const slot = await plugin.service("pull").claimSlot(UID.REWARD, reward2.id, "claimed", "stock", trx);
3532
+ if (slot == null) throw unprocessable(ERROR_CODES.REWARD_SOLD_OUT, "Stock épuisé.");
3533
+ } else {
3534
+ await plugin.service("pull").claimSlot(UID.REWARD, reward2.id, "claimed", null, trx);
3535
+ }
3536
+ const rec = await strapi.db.query(UID.REDEMPTION).create({
3537
+ data: {
3538
+ player: player2.id,
3539
+ season: season2.id,
3540
+ reward: reward2.id,
3541
+ rewardName: reward2.name,
3542
+ code: await this.generateCode(),
3543
+ cards: snapshot,
3544
+ rewardType: reward2.tier ?? null,
3545
+ rewardDescription: reward2.name,
3546
+ status: REDEMPTION_STATUS.PENDING,
3547
+ redeemedAt: now
3548
+ }
3549
+ });
3550
+ if (rows.length) await strapi.db.query(UID.PLAYER_CARD).deleteMany({ where: { id: { $in: rows.map((r) => r.id) } } });
3551
+ await strapi.db.query(UID.PLAYER).update({
3552
+ where: { id: player2.id },
3553
+ data: { totalCards: Math.max(0, (player2.totalCards ?? 0) - rows.length), totalRedemptions: (player2.totalRedemptions ?? 0) + 1, lastActiveAt: now }
3554
+ });
3555
+ return rec;
3556
+ });
3557
+ strapi.log.info(`[tcg] redemption ${record.code} by ${player2.code}: ${reward2.name} (${rows.length} cards)`);
3558
+ const unlocked = await plugin.service("badge").evaluate(player2, season2, { event: "redemption", reward: reward2.documentId });
3559
+ const full = await this.findOne(record.code);
3560
+ return { ...format2.redemption(full), unlocked };
3561
+ },
3562
+ async findOne(ref, { forPlayer } = {}) {
3563
+ const where = { $or: [{ documentId: ref }, { code: String(ref).toUpperCase().trim() }] };
3564
+ if (forPlayer) where.player = forPlayer.id;
3565
+ const rec = await strapi.db.query(UID.REDEMPTION).findOne({ where, populate: POPULATE$5 });
3566
+ if (!rec) throw notFound(ERROR_CODES.REDEMPTION_NOT_FOUND, "Demande introuvable.");
3567
+ return rec;
3568
+ },
3569
+ async history(player2, season2, page) {
3570
+ const where = { player: player2.id, season: season2.id };
3571
+ const [items, total] = await Promise.all([
3572
+ strapi.db.query(UID.REDEMPTION).findMany({ where, populate: { reward: { populate: { image: true } } }, orderBy: { redeemedAt: "desc" }, offset: page.offset, limit: page.limit }),
3573
+ strapi.db.query(UID.REDEMPTION).count({ where })
3574
+ ]);
3575
+ const format2 = strapi.plugin("tcg").service("format");
3576
+ return { items: items.map((r) => format2.redemption(r)), total };
3577
+ },
3578
+ async fulfill(ref, { operator, note }) {
3579
+ const rec = await this.findOne(ref);
3580
+ if (rec.status === REDEMPTION_STATUS.FULFILLED) throw unprocessable(ERROR_CODES.REDEMPTION_INVALID_STATUS, "Déjà remise.");
3581
+ if (rec.status === REDEMPTION_STATUS.CANCELLED) throw unprocessable(ERROR_CODES.REDEMPTION_INVALID_STATUS, "Demande annulée.");
3582
+ const updated = await strapi.db.query(UID.REDEMPTION).update({
3583
+ where: { id: rec.id },
3584
+ data: { status: REDEMPTION_STATUS.FULFILLED, fulfilledAt: /* @__PURE__ */ new Date(), fulfilledBy: operator ?? null, note: note ?? rec.note },
3585
+ populate: POPULATE$5
3586
+ });
3587
+ return strapi.plugin("tcg").service("format").redemption(updated);
3588
+ },
3589
+ /** Booth cancel: cards go back (same mint numbers), stock is released. */
3590
+ async cancel(ref, { operator, note }) {
3591
+ const rec = await this.findOne(ref);
3592
+ if (rec.status !== REDEMPTION_STATUS.PENDING) throw unprocessable(ERROR_CODES.REDEMPTION_INVALID_STATUS, `Demande déjà ${rec.status}.`);
3593
+ const now = /* @__PURE__ */ new Date();
3594
+ const updated = await strapi.db.transaction(async ({ trx }) => {
3595
+ for (const snap of rec.cards ?? []) {
3596
+ const card2 = await strapi.db.query(UID.CARD).findOne({ where: { documentId: snap.id }, select: ["id"] });
3597
+ if (card2) await strapi.db.query(UID.PLAYER_CARD).create({ data: { player: rec.player.id, card: card2.id, acquiredFrom: "grant", acquiredAt: now, mintNumber: snap.mintNumber ?? null } });
3598
+ }
3599
+ if (rec.reward) {
3600
+ const md = strapi.db.metadata.get(UID.REWARD);
3601
+ await trx(md.tableName).where({ id: rec.reward.id }).andWhere("claimed", ">", 0).decrement("claimed", 1);
3602
+ }
3603
+ await strapi.db.query(UID.PLAYER).update({
3604
+ where: { id: rec.player.id },
3605
+ data: { totalCards: (rec.player.totalCards ?? 0) + (rec.cards?.length ?? 0), totalRedemptions: Math.max(0, (rec.player.totalRedemptions ?? 0) - 1) }
3606
+ });
3607
+ return strapi.db.query(UID.REDEMPTION).update({ where: { id: rec.id }, data: { status: REDEMPTION_STATUS.CANCELLED, fulfilledBy: operator ?? null, note: note ?? rec.note }, populate: POPULATE$5 });
3608
+ });
3609
+ return strapi.plugin("tcg").service("format").redemption(updated);
3610
+ },
3611
+ async search({ season: season2, status, q, reward: reward2 }, page) {
3612
+ const where = {};
3613
+ if (season2) where.season = season2.id;
3614
+ if (status) where.status = status;
3615
+ if (reward2) where.reward = { documentId: reward2 };
3616
+ if (q) where.$or = [{ code: { $containsi: q } }, { player: { code: { $containsi: q } } }, { player: { displayName: { $containsi: q } } }, { rewardName: { $containsi: q } }];
3617
+ const [items, total] = await Promise.all([
3618
+ strapi.db.query(UID.REDEMPTION).findMany({ where, populate: POPULATE$5, orderBy: { redeemedAt: "desc" }, offset: page.offset, limit: page.limit }),
3619
+ strapi.db.query(UID.REDEMPTION).count({ where })
3620
+ ]);
3621
+ const format2 = strapi.plugin("tcg").service("format");
3622
+ return { items: items.map((r) => format2.redemption(r)), total };
3623
+ }
3624
+ });
3625
+ const startOfToday = () => {
3626
+ const d = /* @__PURE__ */ new Date();
3627
+ d.setHours(0, 0, 0, 0);
3628
+ return d;
3629
+ };
3630
+ const daysAgo = (n) => {
3631
+ const d = startOfToday();
3632
+ d.setDate(d.getDate() - n);
3633
+ return d;
3634
+ };
3635
+ const stats = ({ strapi }) => ({
3636
+ async dashboard(season2) {
3637
+ const since = startOfToday();
3638
+ const week = daysAgo(6);
3639
+ const seasonWhere = season2 ? { season: season2.id } : {};
3640
+ const [
3641
+ players,
3642
+ playersToday,
3643
+ pullsToday,
3644
+ pullsTotal,
3645
+ tradesToday,
3646
+ pendingOffers,
3647
+ pendingRedemptions,
3648
+ fulfilledToday,
3649
+ cards,
3650
+ packs,
3651
+ weekPulls,
3652
+ recentPulls
3653
+ ] = await Promise.all([
3654
+ strapi.db.query(UID.PLAYER).count({}),
3655
+ strapi.db.query(UID.PLAYER).count({ where: { createdAt: { $gte: since } } }),
3656
+ strapi.db.query(UID.PULL).count({ where: { ...seasonWhere, pulledAt: { $gte: since } } }),
3657
+ strapi.db.query(UID.PULL).count({ where: seasonWhere }),
3658
+ strapi.db.query(UID.TRADE).count({ where: { ...seasonWhere, completedAt: { $gte: since } } }),
3659
+ strapi.db.query(UID.TRADE_OFFER).count({ where: { ...seasonWhere, status: TRADE_STATUS.PENDING, expiresAt: { $gt: /* @__PURE__ */ new Date() } } }),
3660
+ strapi.db.query(UID.REDEMPTION).count({ where: { ...seasonWhere, status: REDEMPTION_STATUS.PENDING } }),
3661
+ strapi.db.query(UID.REDEMPTION).count({ where: { ...seasonWhere, status: REDEMPTION_STATUS.FULFILLED, fulfilledAt: { $gte: since } } }),
3662
+ season2 ? strapi.db.query(UID.CARD).count({ where: { season: season2.id, isActive: true } }) : Promise.resolve(0),
3663
+ season2 ? strapi.db.query(UID.PACK).count({ where: { season: season2.id, isActive: true } }) : Promise.resolve(0),
3664
+ strapi.db.query(UID.PULL).findMany({ where: { ...seasonWhere, pulledAt: { $gte: week } }, select: ["pulledAt", "rarity"] }),
3665
+ strapi.db.query(UID.PULL).findMany({
3666
+ where: seasonWhere,
3667
+ populate: { player: { select: ["code", "displayName"] }, card: { select: ["name", "rarity"] }, pack: { select: ["name"] }, drop: { select: ["name"] } },
3668
+ orderBy: { pulledAt: "desc" },
3669
+ limit: 8
3670
+ })
3671
+ ]);
3672
+ const sparkline = Array.from({ length: 7 }, (_, i) => {
3673
+ const day = daysAgo(6 - i);
3674
+ const next = new Date(day);
3675
+ next.setDate(day.getDate() + 1);
3676
+ return { day: day.toISOString().slice(0, 10), pulls: weekPulls.filter((p) => new Date(p.pulledAt) >= day && new Date(p.pulledAt) < next).length };
3677
+ });
3678
+ const rarities = season2?.rarities ?? [];
3679
+ const plugin = strapi.plugin("tcg");
3680
+ const [liveBoosts, rewards, drops] = season2 ? await Promise.all([
3681
+ plugin.service("boost").liveNow(season2),
3682
+ plugin.service("reward").list(season2, { includeInactive: false }),
3683
+ strapi.db.query(UID.DROP).findMany({ where: { pack: { season: season2.id }, isActive: true }, populate: { pack: { select: ["name"] } } })
3684
+ ]) : [[], [], []];
3685
+ const lowStock = rewards.filter((r) => r.stock != null).map((r) => ({ id: r.documentId, name: r.name, remaining: Math.max(0, r.stock - (r.claimed ?? 0)), stock: r.stock })).filter((r) => r.remaining <= Math.max(3, Math.ceil(r.stock * 0.1))).sort((a, b) => a.remaining - b.remaining);
3686
+ const dropStates = drops.map((d) => ({ id: d.documentId, name: `${d.pack?.name ?? ""} · ${d.name}`, mode: d.mode, scans: d.scanCount ?? 0, cap: d.maxScansTotal ?? null, isOpen: plugin.service("drop").isOpen(d) }));
3687
+ return {
3688
+ liveBoosts,
3689
+ lowStock,
3690
+ drops: dropStates,
3691
+ season: season2 ? { id: season2.documentId, name: season2.name, slug: season2.slug, status: season2.status } : null,
3692
+ players: { total: players, today: playersToday },
3693
+ pulls: { today: pullsToday, total: pullsTotal, sparkline, distribution: distribution(weekPulls, rarities) },
3694
+ trades: { today: tradesToday, pendingOffers },
3695
+ redemptions: { pending: pendingRedemptions, fulfilledToday },
3696
+ catalog: { cards, packs },
3697
+ recentPulls: recentPulls.map((p) => ({
3698
+ id: p.documentId,
3699
+ pulledAt: p.pulledAt,
3700
+ rarity: p.rarity,
3701
+ player: p.player ? { code: p.player.code, displayName: p.player.displayName } : null,
3702
+ card: p.card ? { name: p.card.name } : null,
3703
+ pack: p.pack ? { name: p.pack.name } : null,
3704
+ drop: p.drop ? { name: p.drop.name } : null,
3705
+ mintNumber: p.mintNumber ?? null,
3706
+ pity: Boolean(p.pity)
3707
+ }))
3708
+ };
3709
+ },
3710
+ /** Top collectors by distinct cards owned in a season (admin leaderboard). */
3711
+ async leaderboard(season2, limit = 10) {
3712
+ const rows = await strapi.db.query(UID.PLAYER_CARD).findMany({
3713
+ where: { card: { season: season2.id } },
3714
+ populate: { player: { select: ["id", "documentId", "code", "displayName"] }, card: { select: ["id"] } }
3715
+ });
3716
+ const byPlayer = /* @__PURE__ */ new Map();
3717
+ for (const r of rows) {
3718
+ if (!r.player || !r.card) continue;
3719
+ const entry = byPlayer.get(r.player.id) ?? { player: r.player, unique: /* @__PURE__ */ new Set(), total: 0 };
3720
+ entry.unique.add(r.card.id);
3721
+ entry.total += 1;
3722
+ byPlayer.set(r.player.id, entry);
3723
+ }
3724
+ return [...byPlayer.values()].map((e) => ({ player: { id: e.player.documentId, code: e.player.code, displayName: e.player.displayName }, uniqueCards: e.unique.size, totalCards: e.total })).sort((a, b) => b.uniqueCards - a.uniqueCards || b.totalCards - a.totalCards).slice(0, limit).map((e, i) => ({ rank: i + 1, ...e }));
3725
+ },
3726
+ /** Recent pulls for the audit screen. */
3727
+ async pulls(season2, { playerCode, rarity }, page) {
3728
+ const where = { season: season2.id };
3729
+ if (rarity) where.rarity = rarity;
3730
+ if (playerCode) where.player = { code: { $containsi: playerCode } };
3731
+ const [items, total] = await Promise.all([
3732
+ strapi.db.query(UID.PULL).findMany({
3733
+ where,
3734
+ populate: { player: { select: ["code", "displayName"] }, card: { select: ["name", "rarity", "number"] }, pack: { select: ["name", "slug"] }, drop: { select: ["name", "slug"] } },
3735
+ orderBy: { pulledAt: "desc" },
3736
+ offset: page.offset,
3737
+ limit: page.limit
3738
+ }),
3739
+ strapi.db.query(UID.PULL).count({ where })
3740
+ ]);
3741
+ return {
3742
+ items: items.map((p) => ({
3743
+ id: p.documentId,
3744
+ pulledAt: p.pulledAt,
3745
+ rarity: p.rarity,
3746
+ seed: p.seed,
3747
+ roll: p.roll,
3748
+ verificationHash: p.verificationHash,
3749
+ odds: p.odds ?? null,
3750
+ pity: Boolean(p.pity),
3751
+ mintNumber: p.mintNumber ?? null,
3752
+ drop: p.drop ? { name: p.drop.name, slug: p.drop.slug } : null,
3753
+ player: p.player ? { code: p.player.code, displayName: p.player.displayName } : null,
3754
+ card: p.card ? { name: p.card.name, number: p.card.number, rarity: p.card.rarity } : null,
3755
+ pack: p.pack ? { name: p.pack.name, slug: p.pack.slug } : null
3756
+ })),
3757
+ total
3758
+ };
3759
+ }
3760
+ });
3761
+ const POPULATE$4 = { pack: { populate: { season: { populate: { cover: true } }, image: true } } };
3762
+ const drop = ({ strapi }) => ({
3763
+ isOpen(drop2, now = /* @__PURE__ */ new Date()) {
3764
+ if (!drop2.isActive) return false;
3765
+ if (drop2.startsAt && new Date(drop2.startsAt) > now) return false;
3766
+ if (drop2.endsAt && new Date(drop2.endsAt) < now) return false;
3767
+ return true;
3768
+ },
3769
+ async list(pack2, { includeInactive = true } = {}) {
3770
+ const where = { pack: pack2.id };
3771
+ if (!includeInactive) where.isActive = true;
3772
+ return strapi.db.query(UID.DROP).findMany({ where, orderBy: [{ isDefault: "desc" }, { sortOrder: "asc" }, { name: "asc" }] });
3773
+ },
3774
+ async findOne(pack2, ref) {
3775
+ const drop2 = await strapi.db.query(UID.DROP).findOne({ where: { pack: pack2.id, $or: [{ slug: ref }, { documentId: ref }] } });
3776
+ if (!drop2) throw notFound(ERROR_CODES.DROP_NOT_FOUND, "Point de scan introuvable.");
3777
+ return drop2;
3778
+ },
3779
+ /** Scan path: by documentId, with pack → season populated. */
3780
+ async byDocumentId(documentId) {
3781
+ const drop2 = await strapi.db.query(UID.DROP).findOne({ where: { documentId }, populate: POPULATE$4 });
3782
+ if (!drop2) throw notFound(ERROR_CODES.DROP_NOT_FOUND, "Point de scan introuvable.");
3783
+ return drop2;
3784
+ },
3785
+ 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.");
3788
+ 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.`);
3790
+ }
3791
+ },
3792
+ async create(pack2, data) {
3793
+ if (!data.name) throw badRequest(ERROR_CODES.VALIDATION, "Le nom est requis.");
3794
+ this.validate(data);
3795
+ const { cards, ...rest } = data;
3796
+ const drop2 = await strapi.documents(UID.DROP).create({
3797
+ data: {
3798
+ ...rest,
3799
+ slug: await uniqueSlug(strapi, UID.DROP, rest.slug || rest.name, { pack: pack2.id }),
3800
+ pack: pack2.id,
3801
+ secret: randomBytes(24).toString("hex"),
3802
+ isDefault: Boolean(rest.isDefault)
3803
+ }
3804
+ });
3805
+ if (Array.isArray(cards)) await this.setCards(pack2, drop2.documentId, cards);
3806
+ return drop2;
3807
+ },
3808
+ /** Every pack gets one default drop so a pack is scannable the moment it exists. */
3809
+ async ensureDefault(pack2) {
3810
+ const existing = await strapi.db.query(UID.DROP).findOne({ where: { pack: pack2.id, isDefault: true } });
3811
+ if (existing) return existing;
3812
+ return this.create(pack2, { name: DEFAULT_DROP_NAME, mode: DROP_MODE.STATIC, isDefault: true, poolMode: POOL_MODE.INHERIT });
3813
+ },
3814
+ async update(pack2, ref, data) {
3815
+ const drop2 = await this.findOne(pack2, ref);
3816
+ this.validate(data);
3817
+ 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 });
3819
+ const updated = await strapi.documents(UID.DROP).update({ documentId: drop2.documentId, data: rest });
3820
+ if (Array.isArray(cards)) await this.setCards(pack2, drop2.documentId, cards);
3821
+ return updated;
3822
+ },
3823
+ async rotateSecret(pack2, ref) {
3824
+ const drop2 = await this.findOne(pack2, ref);
3825
+ await strapi.db.query(UID.DROP).update({ where: { id: drop2.id }, data: { secret: randomBytes(24).toString("hex") } });
3826
+ return { ok: true };
3827
+ },
3828
+ async remove(pack2, ref) {
3829
+ 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.");
3831
+ 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.");
3833
+ await strapi.db.query(UID.DROP_CARD).deleteMany({ where: { drop: drop2.id } });
3834
+ await strapi.documents(UID.DROP).delete({ documentId: drop2.documentId });
3835
+ return { ok: true };
3836
+ },
3837
+ async setCards(pack2, dropRef, entries) {
3838
+ const drop2 = await this.findOne(pack2, dropRef);
3839
+ 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
+ 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.");
3843
+ const idByDoc = Object.fromEntries(cards.map((c) => [c.documentId, c.id]));
3844
+ await strapi.db.transaction(async () => {
3845
+ await strapi.db.query(UID.DROP_CARD).deleteMany({ where: { drop: drop2.id } });
3846
+ for (const e of entries) {
3847
+ const entry = typeof e === "string" ? { card: e } : e;
3848
+ 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 }
3850
+ });
3851
+ }
3852
+ });
3853
+ return this.cards(pack2, drop2.documentId);
3854
+ },
3855
+ async cards(pack2, ref) {
3856
+ 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 }));
3859
+ },
3860
+ /**
3861
+ * Effective raw pool for a drop (before exclusivity/supply pruning): pack pool, drop pool, or both.
3862
+ * Entries carry card fields + weight + isExclusive + source.
3863
+ */
3864
+ async rawPool(drop2, pack2) {
3865
+ const packPool = drop2.poolMode === POOL_MODE.OVERRIDE ? [] : await strapi.plugin("tcg").service("pack").pool(pack2);
3866
+ const own = drop2.poolMode === POOL_MODE.INHERIT ? [] : await strapi.db.query(UID.DROP_CARD).findMany({
3867
+ where: { drop: drop2.id, isActive: true, card: { isActive: true } },
3868
+ populate: { card: { populate: { image: true } } }
3869
+ });
3870
+ const ownEntries = own.filter((r) => r.card).map((r) => ({ ...r.card, weight: r.weight, isExclusive: r.isExclusive, source: "drop" }));
3871
+ const seen = new Set(ownEntries.map((c) => c.id));
3872
+ return [...ownEntries, ...packPool.filter((c) => !seen.has(c.id)).map((c) => ({ ...c, source: "pack" }))];
3873
+ },
3874
+ /** Card ids flagged exclusive on any pack-card or drop-card of a season. */
3875
+ async exclusiveCardIds(season2) {
3876
+ 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"] } } })
3879
+ ]);
3880
+ return new Set([...pc, ...dc].map((r) => r.card?.id).filter(Boolean));
3881
+ },
3882
+ /** Where a card is exclusive, for the admin: [{ kind:'pack'|'drop', name, slug }]. */
3883
+ async exclusivityOf(cardId) {
3884
+ 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"] } } } } })
3887
+ ]);
3888
+ return [
3889
+ ...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 }))
3891
+ ];
3892
+ },
3893
+ /** Booth / poster token. */
3894
+ async generateQR(pack2, ref) {
3895
+ const drop2 = await this.findOne(pack2, ref);
3896
+ const season2 = pack2.season ?? (await strapi.db.query(UID.PACK).findOne({ where: { id: pack2.id }, populate: { season: true } })).season;
3897
+ const limits = strapi.plugin("tcg").service("season").limitsOf(season2);
3898
+ return { ...strapi.plugin("tcg").service("qr").forDrop(drop2, limits.qrTokenTtlMs), drop: drop2 };
3899
+ },
3900
+ /** Public shape, exclusives revealed as count (+ names unless the drop is secret). */
3901
+ async publicView(drop2, pack2, season2) {
3902
+ 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 } });
3904
+ return {
3905
+ id: drop2.documentId,
3906
+ name: drop2.name,
3907
+ slug: drop2.slug,
3908
+ description: drop2.description ?? null,
3909
+ mode: drop2.mode,
3910
+ isDefault: drop2.isDefault,
3911
+ isOpen: this.isOpen(drop2),
3912
+ startsAt: drop2.startsAt ?? null,
3913
+ endsAt: drop2.endsAt ?? null,
3914
+ soldOut: drop2.maxScansTotal != null && drop2.scanCount >= drop2.maxScansTotal,
3915
+ remaining: drop2.maxScansTotal != null ? Math.max(0, drop2.maxScansTotal - drop2.scanCount) : null,
3916
+ 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 })),
3918
+ geo: drop2.geo ?? null
3919
+ };
3920
+ }
3921
+ });
3922
+ const POPULATE$3 = { pack: true, drop: true, season: true };
3923
+ const boost = ({ strapi }) => ({
3924
+ isLive(b, now = /* @__PURE__ */ new Date()) {
3925
+ if (!b.isActive) return false;
3926
+ if (b.startsAt && new Date(b.startsAt) > now) return false;
3927
+ if (b.endsAt && new Date(b.endsAt) < now) return false;
3928
+ return true;
3929
+ },
3930
+ async list(season2) {
3931
+ const rows = await strapi.db.query(UID.BOOST).findMany({ where: { season: season2.id }, populate: POPULATE$3, orderBy: [{ startsAt: "desc" }] });
3932
+ return rows.map((b) => ({ ...b, isLive: this.isLive(b) }));
3933
+ },
3934
+ /** Boosts live right now that apply to this drop (season-wide, its pack, or itself). */
3935
+ async activeFor(season2, pack2, drop2) {
3936
+ const rows = await strapi.db.query(UID.BOOST).findMany({ where: { season: season2.id, isActive: true }, populate: { pack: { select: ["id"] }, drop: { select: ["id"] } } });
3937
+ return rows.filter((b) => this.isLive(b) && (!b.pack || b.pack.id === pack2.id) && (!b.drop || b.drop.id === drop2.id));
3938
+ },
3939
+ /** Season-wide view for the mini-program: everything live now, with scope. */
3940
+ async liveNow(season2) {
3941
+ const rows = await strapi.db.query(UID.BOOST).findMany({ where: { season: season2.id, isActive: true, announce: true }, populate: { pack: true, drop: true } });
3942
+ return rows.filter((b) => this.isLive(b)).map((b) => this.format(b));
3943
+ },
3944
+ combine(boosts) {
3945
+ const m = {};
3946
+ for (const b of boosts) for (const [k, v] of Object.entries(b.multipliers ?? {})) m[k] = (m[k] ?? 1) * (Number(v) || 1);
3947
+ return m;
3948
+ },
3949
+ validate(season2, data) {
3950
+ if (data.multipliers !== void 0) {
3951
+ if (!data.multipliers || typeof data.multipliers !== "object") throw badRequest(ERROR_CODES.VALIDATION, "multipliers doit être un objet.");
3952
+ const keys = (season2.rarities ?? []).map((r) => r.key);
3953
+ for (const [k, v] of Object.entries(data.multipliers)) {
3954
+ if (!keys.includes(k)) throw badRequest(ERROR_CODES.VALIDATION, `Rareté inconnue "${k}".`);
3955
+ if (!(Number(v) >= 0)) throw badRequest(ERROR_CODES.VALIDATION, `Multiplicateur invalide pour "${k}".`);
3956
+ }
3957
+ }
3958
+ },
3959
+ async resolveScope(season2, data) {
3960
+ const out = {};
3961
+ if (data.pack !== void 0) {
3962
+ out.pack = data.pack ? (await strapi.plugin("tcg").service("pack").findOne(season2, data.pack)).id : null;
3963
+ }
3964
+ if (data.drop !== void 0) {
3965
+ if (!data.drop) out.drop = null;
3966
+ else {
3967
+ const drop2 = await strapi.db.query(UID.DROP).findOne({ where: { documentId: data.drop }, populate: { pack: { select: ["id"] } } });
3968
+ if (!drop2) throw notFound(ERROR_CODES.DROP_NOT_FOUND, "Point de scan introuvable.");
3969
+ out.drop = drop2.id;
3970
+ out.pack = drop2.pack?.id ?? out.pack;
3971
+ }
3972
+ }
3973
+ return out;
3974
+ },
3975
+ async create(season2, data) {
3976
+ if (!data.name) throw badRequest(ERROR_CODES.VALIDATION, "Le nom est requis.");
3977
+ if (!data.multipliers) throw badRequest(ERROR_CODES.VALIDATION, "multipliers est requis.");
3978
+ this.validate(season2, data);
3979
+ const scope = await this.resolveScope(season2, data);
3980
+ const row = await strapi.documents(UID.BOOST).create({ data: { ...data, ...scope, season: season2.id }, populate: POPULATE$3 });
3981
+ return { ...row, isLive: this.isLive(row) };
3982
+ },
3983
+ async update(season2, documentId, data) {
3984
+ const existing = await strapi.db.query(UID.BOOST).findOne({ where: { documentId, season: season2.id } });
3985
+ if (!existing) throw notFound(ERROR_CODES.VALIDATION, "Boost introuvable.");
3986
+ this.validate(season2, data);
3987
+ const scope = await this.resolveScope(season2, data);
3988
+ const { season: _s, ...rest } = data;
3989
+ const row = await strapi.documents(UID.BOOST).update({ documentId, data: { ...rest, ...scope }, populate: POPULATE$3 });
3990
+ return { ...row, isLive: this.isLive(row) };
3991
+ },
3992
+ async remove(season2, documentId) {
3993
+ const existing = await strapi.db.query(UID.BOOST).findOne({ where: { documentId, season: season2.id } });
3994
+ if (!existing) throw notFound(ERROR_CODES.VALIDATION, "Boost introuvable.");
3995
+ await strapi.documents(UID.BOOST).delete({ documentId });
3996
+ return { ok: true };
3997
+ },
3998
+ format(b) {
3999
+ return {
4000
+ id: b.documentId,
4001
+ name: b.name,
4002
+ description: b.description ?? null,
4003
+ multipliers: b.multipliers ?? {},
4004
+ startsAt: b.startsAt ?? null,
4005
+ endsAt: b.endsAt ?? null,
4006
+ scope: b.drop ? { kind: "drop", name: b.drop.name, slug: b.drop.slug } : b.pack ? { kind: "pack", name: b.pack.name, slug: b.pack.slug } : { kind: "season" }
4007
+ };
4008
+ }
4009
+ });
4010
+ const matchCost = (rules, handed, ctx) => {
4011
+ const rarities = ctx.rarities ?? [];
4012
+ const idx = (k) => rarities.indexOf(k);
4013
+ const pool = handed.map((c) => ({ ...c, rarityIndex: idx(c.rarity) }));
4014
+ const missing = [];
4015
+ const consumed = [];
4016
+ const ordered = [...rules ?? []].sort((a, b) => rank(a) - rank(b));
4017
+ for (const rule of ordered) {
4018
+ if (rule.type === COST_RULE.CARD) {
4019
+ const need = Number(rule.count) || 1;
4020
+ let got = 0;
4021
+ for (let i = pool.length - 1; i >= 0 && got < need; i -= 1) {
4022
+ if (pool[i].cardId === rule.card) {
4023
+ consumed.push(pool.splice(i, 1)[0]);
4024
+ got += 1;
4025
+ }
4026
+ }
4027
+ if (got < need) missing.push(`${need - got} × ${ctx.cardNames?.[rule.card] ?? `carte ${rule.card}`}`);
4028
+ } else if (rule.type === COST_RULE.SET) {
4029
+ const cards = ctx.sets?.[rule.set] ?? [];
4030
+ if (cards.length === 0) {
4031
+ missing.push(`set inconnu ${rule.set}`);
4032
+ continue;
4033
+ }
4034
+ if (rule.consume) {
4035
+ for (const cardId of cards) {
4036
+ const i = pool.findIndex((c) => c.cardId === cardId);
4037
+ if (i === -1) missing.push(`${ctx.cardNames?.[cardId] ?? cardId} (set ${ctx.setNames?.[rule.set] ?? rule.set})`);
4038
+ else consumed.push(pool.splice(i, 1)[0]);
4039
+ }
4040
+ } else if (!cards.every((cardId) => ctx.owned?.has(cardId))) {
4041
+ missing.push(`set ${rule.set} incomplet`);
4042
+ }
4043
+ } else if (rule.type === COST_RULE.CARDS) {
4044
+ const need = Number(rule.count) || 1;
4045
+ const minIdx = rule.minRarity ? idx(rule.minRarity) : -1;
4046
+ const candidates = pool.map((c, i) => ({ c, i })).filter(({ c }) => (rule.rarity ? c.rarity === rule.rarity : true) && c.rarityIndex >= minIdx).sort((a, b) => a.c.rarityIndex - b.c.rarityIndex);
4047
+ const take = candidates.slice(0, need);
4048
+ for (const { c } of take) {
4049
+ const i = pool.indexOf(c);
4050
+ consumed.push(pool.splice(i, 1)[0]);
4051
+ }
4052
+ if (take.length < need) {
4053
+ const label = rule.rarity ?? (rule.minRarity ? `≥ ${rule.minRarity}` : "carte(s)");
4054
+ missing.push(`${need - take.length} × ${label}`);
4055
+ }
4056
+ } else {
4057
+ missing.push(`règle inconnue ${rule.type}`);
4058
+ }
4059
+ }
4060
+ return { ok: missing.length === 0 && pool.length === 0, missing, leftover: pool.length, consumed };
4061
+ };
4062
+ const rank = (rule) => rule.type === COST_RULE.CARD ? 0 : rule.type === COST_RULE.SET ? 1 : 2;
4063
+ const describeCost = (rules, rarities = [], sets = {}) => (rules ?? []).map((r) => {
4064
+ const label = (k) => rarities.find((x) => x.key === k)?.label ?? k;
4065
+ if (r.type === COST_RULE.CARDS) return `${r.count} ${r.rarity ? label(r.rarity) : r.minRarity ? `≥ ${label(r.minRarity)}` : "carte(s)"}`;
4066
+ if (r.type === COST_RULE.CARD) return `${r.count ?? 1} × ${r.cardName ?? r.card}`;
4067
+ if (r.type === COST_RULE.SET) return `${r.consume ? "Remettre" : "Posséder"} le set ${sets[r.set]?.name ?? r.set}`;
4068
+ return "?";
4069
+ });
4070
+ const validateCost = (rules) => {
4071
+ const problems = [];
4072
+ if (!Array.isArray(rules) || rules.length === 0) return ["cost must be a non-empty array of rules"];
4073
+ rules.forEach((r, i) => {
4074
+ if (!Object.values(COST_RULE).includes(r?.type)) problems.push(`cost[${i}].type is invalid`);
4075
+ if (r?.type === COST_RULE.CARDS && !(Number(r.count) >= 1)) problems.push(`cost[${i}].count must be ≥ 1`);
4076
+ if (r?.type === COST_RULE.CARD && !r.card) problems.push(`cost[${i}].card is required`);
4077
+ if (r?.type === COST_RULE.SET && !r.set) problems.push(`cost[${i}].set is required`);
4078
+ });
4079
+ return problems;
4080
+ };
4081
+ const POPULATE$2 = { image: true };
4082
+ const reward = ({ strapi }) => ({
4083
+ isOpen(r, now = /* @__PURE__ */ new Date()) {
4084
+ if (!r.isActive) return false;
4085
+ if (r.startsAt && new Date(r.startsAt) > now) return false;
4086
+ if (r.endsAt && new Date(r.endsAt) < now) return false;
4087
+ return true;
4088
+ },
4089
+ async list(season2, { includeInactive = true } = {}) {
4090
+ const where = { season: season2.id };
4091
+ if (!includeInactive) where.isActive = true;
4092
+ return strapi.db.query(UID.REWARD).findMany({ where, populate: POPULATE$2, orderBy: [{ sortOrder: "asc" }, { name: "asc" }] });
4093
+ },
4094
+ async findOne(season2, ref) {
4095
+ const r = await strapi.db.query(UID.REWARD).findOne({ where: { season: season2.id, $or: [{ slug: ref }, { documentId: ref }] }, populate: POPULATE$2 });
4096
+ if (!r) throw notFound(ERROR_CODES.REWARD_NOT_FOUND, "Récompense introuvable.");
4097
+ return r;
4098
+ },
4099
+ validate(data) {
4100
+ if (data.cost !== void 0) {
4101
+ const problems = validateCost(data.cost);
4102
+ if (problems.length) throw badRequest(ERROR_CODES.VALIDATION, "Coût invalide.", { problems });
4103
+ }
4104
+ for (const k of ["stock", "perPlayerLimit"]) {
4105
+ if (data[k] != null && data[k] !== "" && !(Number(data[k]) >= 0)) throw badRequest(ERROR_CODES.VALIDATION, `${k} invalide.`);
4106
+ }
4107
+ },
4108
+ async create(season2, data) {
4109
+ if (!data.name) throw badRequest(ERROR_CODES.VALIDATION, "Le nom est requis.");
4110
+ if (!data.cost) throw badRequest(ERROR_CODES.VALIDATION, "Le coût est requis.");
4111
+ this.validate(data);
4112
+ return strapi.documents(UID.REWARD).create({
4113
+ data: { ...data, slug: await uniqueSlug(strapi, UID.REWARD, data.slug || data.name, { season: season2.id }), season: season2.id },
4114
+ populate: POPULATE$2
4115
+ });
4116
+ },
4117
+ async update(season2, ref, data) {
4118
+ const r = await this.findOne(season2, ref);
4119
+ this.validate(data);
4120
+ const { season: _s, claimed: _c, ...rest } = data;
4121
+ if (rest.slug && rest.slug !== r.slug) rest.slug = await uniqueSlug(strapi, UID.REWARD, rest.slug, { season: season2.id });
4122
+ return strapi.documents(UID.REWARD).update({ documentId: r.documentId, data: rest, populate: POPULATE$2 });
4123
+ },
4124
+ async remove(season2, ref) {
4125
+ const r = await this.findOne(season2, ref);
4126
+ const used = await strapi.db.query(UID.REDEMPTION).count({ where: { reward: r.id } });
4127
+ if (used > 0) throw badRequest(ERROR_CODES.VALIDATION, "Des retraits référencent cette récompense : désactivez-la.");
4128
+ await strapi.documents(UID.REWARD).delete({ documentId: r.documentId });
4129
+ return { ok: true };
4130
+ },
4131
+ /**
4132
+ * Checks a player's handed cards against a reward. Returns the matched consumption or throws.
4133
+ * @param {Object} player
4134
+ * @param {Object} season
4135
+ * @param {Object} reward
4136
+ * @param {Array} handedRows player-card rows (populate card)
4137
+ */
4138
+ async check(player2, season2, reward2, handedRows) {
4139
+ const plugin = strapi.plugin("tcg");
4140
+ if (!this.isOpen(reward2)) throw unprocessable(ERROR_CODES.REWARD_UNAVAILABLE, "Cette récompense n'est pas disponible.");
4141
+ if (reward2.stock != null && reward2.claimed >= reward2.stock) throw unprocessable(ERROR_CODES.REWARD_SOLD_OUT, "Stock épuisé.");
4142
+ if (reward2.perPlayerLimit != null) {
4143
+ const mine = await strapi.db.query(UID.REDEMPTION).count({ where: { player: player2.id, reward: reward2.id, status: { $ne: "cancelled" } } });
4144
+ if (mine >= reward2.perPlayerLimit) throw unprocessable(ERROR_CODES.REWARD_PLAYER_LIMIT, `Limite de ${reward2.perPlayerLimit} par joueur atteinte.`);
4145
+ }
4146
+ const rarities = (season2.rarities ?? []).map((r) => r.key);
4147
+ const [sets, names] = await Promise.all([plugin.service("set").slugMap(season2), this.names(season2)]);
4148
+ const ownedRows = await strapi.db.query(UID.PLAYER_CARD).findMany({ where: { player: player2.id, card: { season: season2.id } }, populate: { card: { select: ["documentId"] } } });
4149
+ const owned = new Set(ownedRows.map((r) => r.card?.documentId).filter(Boolean));
4150
+ const handed = handedRows.map((r) => ({ playerCardId: r.documentId, cardId: r.card?.documentId, rarity: r.card?.rarity }));
4151
+ const result = matchCost(reward2.cost ?? [], handed, { rarities, sets, owned, ...names });
4152
+ if (!result.ok) {
4153
+ const why = result.missing.length ? result.missing : [`${result.leftover} carte(s) en trop`];
4154
+ throw unprocessable(ERROR_CODES.REWARD_COST_UNMET, `Conditions non remplies : ${why.join(", ")}.`, { missing: result.missing, leftover: result.leftover });
4155
+ }
4156
+ return result;
4157
+ },
4158
+ /** Eligibility hints for the mini-program: can this player afford each reward with what they own? */
4159
+ async eligibility(player2, season2) {
4160
+ const plugin = strapi.plugin("tcg");
4161
+ const [rewards, ownedRows, sets, names] = await Promise.all([
4162
+ this.list(season2, { includeInactive: false }),
4163
+ strapi.db.query(UID.PLAYER_CARD).findMany({ where: { player: player2.id, card: { season: season2.id } }, populate: { card: { select: ["documentId", "rarity"] } } }),
4164
+ plugin.service("set").slugMap(season2),
4165
+ this.names(season2)
4166
+ ]);
4167
+ const rarities = (season2.rarities ?? []).map((r) => r.key);
4168
+ const owned = new Set(ownedRows.map((r) => r.card?.documentId).filter(Boolean));
4169
+ const all = ownedRows.map((r) => ({ playerCardId: r.documentId, cardId: r.card?.documentId, rarity: r.card?.rarity }));
4170
+ return rewards.map((r) => {
4171
+ const res = matchCost(r.cost ?? [], all, { rarities, sets, owned, ...names });
4172
+ const open = this.isOpen(r);
4173
+ const soldOut = r.stock != null && r.claimed >= r.stock;
4174
+ return { ...this.format(r, season2), eligible: open && !soldOut && res.missing.length === 0, missing: res.missing, suggestedCards: res.consumed.map((c) => c.playerCardId) };
4175
+ });
4176
+ },
4177
+ /** Human names for cost messages. */
4178
+ async names(season2) {
4179
+ const [cards, sets] = await Promise.all([
4180
+ strapi.db.query(UID.CARD).findMany({ where: { season: season2.id }, select: ["documentId", "name"] }),
4181
+ strapi.plugin("tcg").service("set").list(season2)
4182
+ ]);
4183
+ return { cardNames: Object.fromEntries(cards.map((c) => [c.documentId, c.name])), setNames: Object.fromEntries(sets.map((s) => [s.slug, s.name])) };
4184
+ },
4185
+ format(r, season2, setsByName = {}) {
4186
+ return {
4187
+ id: r.documentId,
4188
+ name: r.name,
4189
+ slug: r.slug,
4190
+ description: r.description ?? null,
4191
+ imageUrl: r.image?.url ?? null,
4192
+ tier: r.tier ?? "bronze",
4193
+ booth: r.booth ?? null,
4194
+ cost: r.cost ?? [],
4195
+ costLabel: describeCost(r.cost ?? [], season2?.rarities ?? [], setsByName),
4196
+ stock: r.stock ?? null,
4197
+ remaining: r.stock != null ? Math.max(0, r.stock - (r.claimed ?? 0)) : null,
4198
+ claimed: r.claimed ?? 0,
4199
+ perPlayerLimit: r.perPlayerLimit ?? null,
4200
+ isActive: r.isActive,
4201
+ isOpen: this.isOpen(r),
4202
+ startsAt: r.startsAt ?? null,
4203
+ endsAt: r.endsAt ?? null,
4204
+ sortOrder: r.sortOrder ?? 0
4205
+ };
4206
+ }
4207
+ });
4208
+ const POPULATE$1 = { image: true, cards: { populate: { image: true } } };
4209
+ const set = ({ strapi }) => ({
4210
+ async list(season2, { includeInactive = true } = {}) {
4211
+ const where = { season: season2.id };
4212
+ if (!includeInactive) where.isActive = true;
4213
+ return strapi.db.query(UID.CARD_SET).findMany({ where, populate: POPULATE$1, orderBy: [{ sortOrder: "asc" }, { name: "asc" }] });
4214
+ },
4215
+ async findOne(season2, ref) {
4216
+ const set2 = await strapi.db.query(UID.CARD_SET).findOne({ where: { season: season2.id, $or: [{ slug: ref }, { documentId: ref }] }, populate: POPULATE$1 });
4217
+ if (!set2) throw notFound(ERROR_CODES.VALIDATION, "Set introuvable.");
4218
+ return set2;
4219
+ },
4220
+ async resolveCards(season2, docIds = []) {
4221
+ const ids = [...new Set(docIds)];
4222
+ const cards = ids.length ? await strapi.db.query(UID.CARD).findMany({ where: { documentId: { $in: ids }, season: season2.id }, select: ["id"] }) : [];
4223
+ if (cards.length !== ids.length) throw badRequest(ERROR_CODES.VALIDATION, "Certaines cartes ne font pas partie de cette saison.");
4224
+ return cards.map((c) => c.id);
4225
+ },
4226
+ async create(season2, data) {
4227
+ if (!data.name) throw badRequest(ERROR_CODES.VALIDATION, "Le nom est requis.");
4228
+ const { cards, ...rest } = data;
4229
+ return strapi.documents(UID.CARD_SET).create({
4230
+ data: { ...rest, slug: await uniqueSlug(strapi, UID.CARD_SET, rest.slug || rest.name, { season: season2.id }), season: season2.id, cards: await this.resolveCards(season2, cards ?? []) },
4231
+ populate: POPULATE$1
4232
+ });
4233
+ },
4234
+ async update(season2, ref, data) {
4235
+ const set2 = await this.findOne(season2, ref);
4236
+ const { cards, season: _s, ...rest } = data;
4237
+ if (rest.slug && rest.slug !== set2.slug) rest.slug = await uniqueSlug(strapi, UID.CARD_SET, rest.slug, { season: season2.id });
4238
+ const payload = { ...rest };
4239
+ if (Array.isArray(cards)) payload.cards = await this.resolveCards(season2, cards);
4240
+ return strapi.documents(UID.CARD_SET).update({ documentId: set2.documentId, data: payload, populate: POPULATE$1 });
4241
+ },
4242
+ async remove(season2, ref) {
4243
+ const set2 = await this.findOne(season2, ref);
4244
+ await strapi.documents(UID.CARD_SET).delete({ documentId: set2.documentId });
4245
+ return { ok: true };
4246
+ },
4247
+ /** { [slug]: cardDocumentIds[] } for the cost matcher. */
4248
+ async slugMap(season2) {
4249
+ const sets = await this.list(season2, { includeInactive: false });
4250
+ return Object.fromEntries(sets.map((s) => [s.slug, (s.cards ?? []).map((c) => c.documentId)]));
4251
+ },
4252
+ /** Progress for a player given the set of owned card ids (internal ids). */
4253
+ progress(sets, ownedIds, season2) {
4254
+ const format2 = strapi.plugin("tcg").service("format");
4255
+ return sets.map((s) => {
4256
+ const cards = s.cards ?? [];
4257
+ const collected = cards.filter((c) => ownedIds.has(c.id)).length;
4258
+ return {
4259
+ id: s.documentId,
4260
+ name: s.name,
4261
+ slug: s.slug,
4262
+ description: s.description ?? null,
4263
+ imageUrl: s.image?.url ?? null,
4264
+ total: cards.length,
4265
+ collected,
4266
+ isComplete: cards.length > 0 && collected === cards.length,
4267
+ cards: cards.map((c) => ({ ...format2.card(c, season2), owned: ownedIds.has(c.id) }))
4268
+ };
4269
+ });
4270
+ }
4271
+ });
4272
+ const ruleMet = (rule, m) => {
4273
+ if (!rule || !m) return false;
4274
+ const n = Number(rule.count ?? 0);
4275
+ switch (rule.type) {
4276
+ case BADGE_RULE.PULLS:
4277
+ return m.pulls >= n;
4278
+ case BADGE_RULE.RARITY_PULLS:
4279
+ return (m.pullsByRarity?.[rule.rarity] ?? 0) >= n;
4280
+ case BADGE_RULE.UNIQUE_CARDS:
4281
+ return m.uniqueCards >= n;
4282
+ case BADGE_RULE.PACKS_VISITED:
4283
+ return rule.count === "all" ? m.packsTotal > 0 && m.packsVisited >= m.packsTotal : m.packsVisited >= n;
4284
+ case BADGE_RULE.DROPS_VISITED:
4285
+ return m.dropsVisited >= n;
4286
+ case BADGE_RULE.TRADES:
4287
+ return m.trades >= n;
4288
+ case BADGE_RULE.REDEMPTIONS:
4289
+ return m.redemptions >= n;
4290
+ case BADGE_RULE.SET_COMPLETE:
4291
+ if (rule.set === "any") return (m.setsComplete?.length ?? 0) > 0;
4292
+ if (rule.set === "all") return m.setsTotal > 0 && (m.setsComplete?.length ?? 0) >= m.setsTotal;
4293
+ return (m.setsComplete ?? []).includes(rule.set);
4294
+ case BADGE_RULE.STREAK_DAYS:
4295
+ return m.streakDays >= n;
4296
+ case BADGE_RULE.COMPLETION:
4297
+ return m.completionPercent >= Number(rule.percent ?? 100);
4298
+ case BADGE_RULE.MINT:
4299
+ return m.bestMint != null && m.bestMint <= Number(rule.max ?? 1);
4300
+ case BADGE_RULE.SCAN_DROP:
4301
+ return (m.dropsScanned ?? []).includes(rule.drop);
4302
+ default:
4303
+ return false;
4304
+ }
4305
+ };
4306
+ const streakDays = (pullDates, now = /* @__PURE__ */ new Date()) => {
4307
+ const days = new Set(pullDates.map((d) => new Date(d).toISOString().slice(0, 10)));
4308
+ if (days.size === 0) return 0;
4309
+ const cursor = new Date(now);
4310
+ cursor.setUTCHours(0, 0, 0, 0);
4311
+ const today = cursor.toISOString().slice(0, 10);
4312
+ if (!days.has(today)) cursor.setUTCDate(cursor.getUTCDate() - 1);
4313
+ let streak = 0;
4314
+ while (days.has(cursor.toISOString().slice(0, 10))) {
4315
+ streak += 1;
4316
+ cursor.setUTCDate(cursor.getUTCDate() - 1);
4317
+ }
4318
+ return streak;
4319
+ };
4320
+ const validateBadgeRule = (rule) => {
4321
+ if (!rule || !Object.values(BADGE_RULE).includes(rule.type)) return [`rule.type is invalid`];
4322
+ const needsCount = [BADGE_RULE.PULLS, BADGE_RULE.RARITY_PULLS, BADGE_RULE.UNIQUE_CARDS, BADGE_RULE.DROPS_VISITED, BADGE_RULE.TRADES, BADGE_RULE.REDEMPTIONS, BADGE_RULE.STREAK_DAYS];
4323
+ const problems = [];
4324
+ if (needsCount.includes(rule.type) && !(Number(rule.count) >= 1)) problems.push("rule.count must be ≥ 1");
4325
+ if (rule.type === BADGE_RULE.PACKS_VISITED && rule.count !== "all" && !(Number(rule.count) >= 1)) problems.push("rule.count must be ≥ 1 or 'all'");
4326
+ if (rule.type === BADGE_RULE.RARITY_PULLS && !rule.rarity) problems.push("rule.rarity is required");
4327
+ if (rule.type === BADGE_RULE.SET_COMPLETE && !rule.set) problems.push("rule.set is required");
4328
+ if (rule.type === BADGE_RULE.SCAN_DROP && !rule.drop) problems.push("rule.drop is required");
4329
+ return problems;
4330
+ };
4331
+ const STARTER_BADGES = [
4332
+ { key: "first-pull", name: "Première carte", icon: "🎴", tier: "bronze", rule: { type: BADGE_RULE.PULLS, count: 1 } },
4333
+ { key: "ten-pulls", name: "Collectionneur", icon: "📚", tier: "bronze", rule: { type: BADGE_RULE.PULLS, count: 10 } },
4334
+ { key: "explorer", name: "Explorateur", icon: "🧭", tier: "silver", rule: { type: BADGE_RULE.PACKS_VISITED, count: "all" } },
4335
+ { key: "first-trade", name: "Négociant", icon: "🤝", tier: "bronze", rule: { type: BADGE_RULE.TRADES, count: 1 } },
4336
+ { key: "streak-3", name: "Fidèle", icon: "🔥", tier: "silver", rule: { type: BADGE_RULE.STREAK_DAYS, count: 3 } },
4337
+ { key: "first-set", name: "Set complet", icon: "🧩", tier: "gold", rule: { type: BADGE_RULE.SET_COMPLETE, set: "any" } },
4338
+ { key: "half-way", name: "À mi-chemin", icon: "⛰️", tier: "silver", rule: { type: BADGE_RULE.COMPLETION, percent: 50 } },
4339
+ { key: "legend", name: "Légende", icon: "👑", tier: "legend", rule: { type: BADGE_RULE.COMPLETION, percent: 100 } },
4340
+ { key: "early-mint", name: "Pionnier", icon: "🥇", tier: "gold", rule: { type: BADGE_RULE.MINT, max: 10 }, isSecret: true }
4341
+ ];
4342
+ const POPULATE = { image: true };
4343
+ const badge = ({ strapi }) => ({
4344
+ async list(season2, { includeInactive = true } = {}) {
4345
+ const where = { $or: [{ season: season2.id }, { season: null }] };
4346
+ if (!includeInactive) where.isActive = true;
4347
+ return strapi.db.query(UID.BADGE).findMany({ where, populate: POPULATE, orderBy: [{ sortOrder: "asc" }, { tier: "asc" }, { name: "asc" }] });
4348
+ },
4349
+ async findOne(season2, ref) {
4350
+ const b = await strapi.db.query(UID.BADGE).findOne({ where: { $or: [{ documentId: ref }, { key: ref }], season: season2.id }, populate: POPULATE });
4351
+ if (!b) throw notFound(ERROR_CODES.VALIDATION, "Badge introuvable.");
4352
+ return b;
4353
+ },
4354
+ validate(data) {
4355
+ if (data.rule !== void 0) {
4356
+ const problems = validateBadgeRule(data.rule);
4357
+ if (problems.length) throw badRequest(ERROR_CODES.VALIDATION, "Règle invalide.", { problems });
4358
+ }
4359
+ if (data.key !== void 0 && !/^[a-z0-9-]{2,60}$/.test(data.key)) throw badRequest(ERROR_CODES.VALIDATION, "Clé invalide (a-z, 0-9, -).");
4360
+ },
4361
+ async create(season2, data) {
4362
+ if (!data.name || !data.key || !data.rule) throw badRequest(ERROR_CODES.VALIDATION, "name, key et rule sont requis.");
4363
+ this.validate(data);
4364
+ const dup = await strapi.db.query(UID.BADGE).findOne({ where: { key: data.key, season: season2.id }, select: ["id"] });
4365
+ if (dup) throw badRequest(ERROR_CODES.VALIDATION, `La clé "${data.key}" existe déjà dans cette saison.`);
4366
+ return strapi.documents(UID.BADGE).create({ data: { ...data, season: season2.id }, populate: POPULATE });
4367
+ },
4368
+ async update(season2, ref, data) {
4369
+ const b = await this.findOne(season2, ref);
4370
+ this.validate(data);
4371
+ const { season: _s, ...rest } = data;
4372
+ return strapi.documents(UID.BADGE).update({ documentId: b.documentId, data: rest, populate: POPULATE });
4373
+ },
4374
+ async remove(season2, ref) {
4375
+ const b = await this.findOne(season2, ref);
4376
+ await strapi.db.query(UID.PLAYER_BADGE).deleteMany({ where: { badge: b.id } });
4377
+ await strapi.documents(UID.BADGE).delete({ documentId: b.documentId });
4378
+ return { ok: true };
4379
+ },
4380
+ /** Seeds the starter set into a season (skips keys that exist). */
4381
+ async seedStarters(season2) {
4382
+ let created2 = 0;
4383
+ for (const b of STARTER_BADGES) {
4384
+ const exists = await strapi.db.query(UID.BADGE).findOne({ where: { key: b.key, season: season2.id }, select: ["id"] });
4385
+ if (exists) continue;
4386
+ await strapi.documents(UID.BADGE).create({ data: { ...b, description: b.description ?? null, isSecret: Boolean(b.isSecret), season: season2.id, sortOrder: created2 } });
4387
+ created2 += 1;
4388
+ }
4389
+ return created2;
4390
+ },
4391
+ /** One pass over the player's season activity. */
4392
+ async metrics(player2, season2) {
4393
+ const plugin = strapi.plugin("tcg");
4394
+ const [pulls, owned, packs, drops, trades, redemptions, sets, catalogTotal] = await Promise.all([
4395
+ strapi.db.query(UID.PULL).findMany({ where: { player: player2.id, season: season2.id }, select: ["rarity", "pulledAt", "mintNumber"], populate: { pack: { select: ["id"] }, drop: { select: ["id", "slug"] } } }),
4396
+ strapi.db.query(UID.PLAYER_CARD).findMany({ where: { player: player2.id, card: { season: season2.id } }, select: ["mintNumber"], populate: { card: { select: ["id"] } } }),
4397
+ strapi.db.query(UID.PACK).count({ where: { season: season2.id, isActive: true } }),
4398
+ strapi.db.query(UID.DROP).count({ where: { pack: { season: season2.id }, isActive: true } }),
4399
+ strapi.db.query(UID.TRADE).count({ where: { season: season2.id, $or: [{ initiator: player2.id }, { receiver: player2.id }] } }),
4400
+ strapi.db.query(UID.REDEMPTION).count({ where: { player: player2.id, season: season2.id, status: { $ne: "cancelled" } } }),
4401
+ plugin.service("set").list(season2, { includeInactive: false }),
4402
+ strapi.db.query(UID.CARD).count({ where: { season: season2.id, isActive: true } })
4403
+ ]);
4404
+ const ownedIds = new Set(owned.map((o) => o.card?.id).filter(Boolean));
4405
+ const pullsByRarity = {};
4406
+ for (const p of pulls) pullsByRarity[p.rarity] = (pullsByRarity[p.rarity] ?? 0) + 1;
4407
+ const mints = owned.map((o) => o.mintNumber).filter((n) => n != null);
4408
+ const setsComplete = sets.filter((s) => (s.cards ?? []).length > 0 && s.cards.every((c) => ownedIds.has(c.id))).map((s) => s.slug);
4409
+ return {
4410
+ pulls: pulls.length,
4411
+ pullsByRarity,
4412
+ uniqueCards: ownedIds.size,
4413
+ packsVisited: new Set(pulls.map((p) => p.pack?.id).filter(Boolean)).size,
4414
+ packsTotal: packs,
4415
+ dropsVisited: new Set(pulls.map((p) => p.drop?.id).filter(Boolean)).size,
4416
+ dropsTotal: drops,
4417
+ dropsScanned: [...new Set(pulls.map((p) => p.drop?.slug).filter(Boolean))],
4418
+ trades,
4419
+ redemptions,
4420
+ setsComplete,
4421
+ setsTotal: sets.length,
4422
+ streakDays: streakDays(pulls.map((p) => p.pulledAt)),
4423
+ completionPercent: catalogTotal ? Math.round(ownedIds.size / catalogTotal * 100) : 0,
4424
+ bestMint: mints.length ? Math.min(...mints) : null
4425
+ };
4426
+ },
4427
+ /**
4428
+ * Awards newly met badges. Never throws into the game flow.
4429
+ * @returns {Promise<Array>} newly earned badges (formatted)
4430
+ */
4431
+ async evaluate(player2, season2, context = {}) {
4432
+ try {
4433
+ const [badges, earned] = await Promise.all([
4434
+ this.list(season2, { includeInactive: false }),
4435
+ strapi.db.query(UID.PLAYER_BADGE).findMany({ where: { player: player2.id }, populate: { badge: { select: ["id"] } } })
4436
+ ]);
4437
+ const have = new Set(earned.map((e) => e.badge?.id).filter(Boolean));
4438
+ const candidates = badges.filter((b) => !have.has(b.id));
4439
+ if (candidates.length === 0) return [];
4440
+ const m = await this.metrics(player2, season2);
4441
+ const now = /* @__PURE__ */ new Date();
4442
+ const fresh = [];
4443
+ for (const b of candidates) {
4444
+ if (!ruleMet(b.rule, m)) continue;
4445
+ await strapi.db.query(UID.PLAYER_BADGE).create({ data: { player: player2.id, badge: b.id, earnedAt: now, context } });
4446
+ fresh.push(this.format(b, { earnedAt: now }));
4447
+ }
4448
+ if (fresh.length) strapi.log.info(`[tcg] badges ${player2.code}: ${fresh.map((b) => b.key).join(", ")}`);
4449
+ return fresh;
4450
+ } catch (err) {
4451
+ strapi.log.warn(`[tcg] badge evaluation failed for ${player2.code}: ${err.message}`);
4452
+ return [];
4453
+ }
4454
+ },
4455
+ /** Season badges with the player's earned state; secret ones hidden until earned. */
4456
+ async forPlayer(player2, season2) {
4457
+ const [badges, earned] = await Promise.all([
4458
+ this.list(season2, { includeInactive: false }),
4459
+ strapi.db.query(UID.PLAYER_BADGE).findMany({ where: { player: player2.id }, populate: { badge: { select: ["id"] } } })
4460
+ ]);
4461
+ const earnedAt = Object.fromEntries(earned.map((e) => [e.badge?.id, e.earnedAt]));
4462
+ return badges.filter((b) => !b.isSecret || earnedAt[b.id]).map((b) => this.format(b, { earnedAt: earnedAt[b.id] ?? null }));
4463
+ },
4464
+ format(b, { earnedAt } = {}) {
4465
+ return {
4466
+ id: b.documentId,
4467
+ key: b.key,
4468
+ name: b.name,
4469
+ description: b.description ?? null,
4470
+ icon: b.icon ?? null,
4471
+ imageUrl: b.image?.url ?? null,
4472
+ tier: b.tier ?? "bronze",
4473
+ isSecret: Boolean(b.isSecret),
4474
+ rule: b.rule,
4475
+ earnedAt: earnedAt ?? null,
4476
+ earned: Boolean(earnedAt)
4477
+ };
4478
+ }
4479
+ });
4480
+ const services = { settings, qr, format, player, season, card, pack, drop, boost, reward, set, badge, pull, collection, trade, redemption, stats };
4481
+ const index = { register, bootstrap, destroy, config, controllers, contentTypes, middlewares, policies, routes, services };
4482
+ export {
4483
+ index as default
4484
+ };