@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.
- package/README.md +143 -2
- package/dist/_chunks/App-DvvgeCGs.js +5190 -0
- package/dist/_chunks/App-j9qu2-To.mjs +5188 -0
- package/dist/_chunks/SettingsPage-BcJXkWnW.mjs +191 -0
- package/dist/_chunks/SettingsPage-Bxa_71iT.js +191 -0
- package/dist/_chunks/api-Aney2S9e.js +1011 -0
- package/dist/_chunks/api-DACN35Y5.mjs +1012 -0
- package/dist/_chunks/en-B2j8rFto.js +39 -0
- package/dist/_chunks/en-C33hmbtk.mjs +39 -0
- package/dist/_chunks/fr-B2Toyhf9.js +37 -0
- package/dist/_chunks/fr-JWwsoAzl.mjs +37 -0
- package/dist/_chunks/index-DSm3eEtn.mjs +108 -0
- package/dist/_chunks/index-DXuaf32J.js +107 -0
- package/dist/admin/index.js +3 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/server/index.js +4483 -0
- package/dist/server/index.mjs +4484 -0
- package/package.json +82 -4
|
@@ -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
|
+
};
|