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