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