@pixels-online/pixels-client-js-sdk 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/OfferwallClient.d.ts +28 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +531 -73
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +533 -72
- package/dist/index.js.map +1 -1
- package/dist/offerwall-sdk.umd.js +533 -72
- package/dist/offerwall-sdk.umd.js.map +1 -1
- package/dist/types/blockchain/user_wallet.d.ts +20 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/linking.d.ts +17 -0
- package/dist/types/offer.d.ts +140 -21
- package/dist/types/player.d.ts +132 -32
- package/dist/types/reward.d.ts +10 -9
- package/dist/utils/conditions.d.ts +31 -3
- package/dist/utils/template.d.ts +2 -0
- package/package.json +2 -1
|
@@ -896,6 +896,7 @@
|
|
|
896
896
|
class OfferwallClient {
|
|
897
897
|
constructor(config) {
|
|
898
898
|
this.isInitializing = false;
|
|
899
|
+
this.pendingStackedToken = null;
|
|
899
900
|
this.config = {
|
|
900
901
|
autoConnect: config.autoConnect ?? false,
|
|
901
902
|
reconnect: config.reconnect ?? true,
|
|
@@ -913,12 +914,24 @@
|
|
|
913
914
|
this.assetHelper = new AssetHelper(this.config);
|
|
914
915
|
this.sseConnection = new SSEConnection(this.config, this.eventEmitter, this.tokenManager);
|
|
915
916
|
this.setupInternalListeners();
|
|
917
|
+
this.setupStackedLinkDetection();
|
|
916
918
|
if (this.config.autoConnect) {
|
|
917
919
|
this.initialize().catch((err) => {
|
|
918
920
|
this.logger.error('Auto-initialization failed:', err);
|
|
919
921
|
});
|
|
920
922
|
}
|
|
921
923
|
}
|
|
924
|
+
setupStackedLinkDetection() {
|
|
925
|
+
if (!this.config.stackedLink?.autoConsume) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const token = this.detectStackedLinkToken();
|
|
929
|
+
if (token) {
|
|
930
|
+
this.logger.log('Detected stackedToken in URL');
|
|
931
|
+
this.pendingStackedToken = token;
|
|
932
|
+
this.clearStackedTokenFromUrl();
|
|
933
|
+
}
|
|
934
|
+
}
|
|
922
935
|
/**
|
|
923
936
|
* Get the offer store instance
|
|
924
937
|
*/
|
|
@@ -949,9 +962,19 @@
|
|
|
949
962
|
this.isInitializing = true;
|
|
950
963
|
await this.refreshOffersAndPlayer();
|
|
951
964
|
await this.connect();
|
|
965
|
+
// Process pending Stacked link token if exists and autoConsume is enabled
|
|
966
|
+
if (this.pendingStackedToken && this.config.stackedLink?.autoConsume) {
|
|
967
|
+
this.logger.log('Processing pending Stacked link token');
|
|
968
|
+
const result = await this.consumeStackedLinkToken(this.pendingStackedToken);
|
|
969
|
+
this.pendingStackedToken = null;
|
|
970
|
+
if (this.config.stackedLink?.onComplete) {
|
|
971
|
+
this.config.stackedLink.onComplete(result);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
952
974
|
this.isInitializing = false;
|
|
953
975
|
}
|
|
954
976
|
catch (error) {
|
|
977
|
+
this.isInitializing = false;
|
|
955
978
|
this.handleError(error, 'initialize');
|
|
956
979
|
throw error;
|
|
957
980
|
}
|
|
@@ -1167,6 +1190,91 @@
|
|
|
1167
1190
|
return null;
|
|
1168
1191
|
return `${dashboardBaseUrl}/auth/enter?token=${token}&gameId=${gameId}`;
|
|
1169
1192
|
}
|
|
1193
|
+
// ==================== Stacked Link Methods ====================
|
|
1194
|
+
/**
|
|
1195
|
+
* Detect if there's a Stacked link token in the current URL
|
|
1196
|
+
* @returns The token string if found, null otherwise
|
|
1197
|
+
*/
|
|
1198
|
+
detectStackedLinkToken() {
|
|
1199
|
+
if (typeof window === 'undefined')
|
|
1200
|
+
return null;
|
|
1201
|
+
try {
|
|
1202
|
+
const params = new URLSearchParams(window.location.search);
|
|
1203
|
+
const token = params.get('stackedToken');
|
|
1204
|
+
if (token && token.length > 0) {
|
|
1205
|
+
return token;
|
|
1206
|
+
}
|
|
1207
|
+
// for SPA routers that use hash routing
|
|
1208
|
+
if (window.location.hash) {
|
|
1209
|
+
const hashQuery = window.location.hash.split('?')[1];
|
|
1210
|
+
if (hashQuery) {
|
|
1211
|
+
const hashParams = new URLSearchParams(hashQuery);
|
|
1212
|
+
const hashToken = hashParams.get('stackedToken');
|
|
1213
|
+
if (hashToken && hashToken.length > 0) {
|
|
1214
|
+
return hashToken;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
catch (error) {
|
|
1221
|
+
this.logger.error('Error detecting stacked token:', error);
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Clear the stackedToken from the URL without page reload
|
|
1227
|
+
*/
|
|
1228
|
+
clearStackedTokenFromUrl() {
|
|
1229
|
+
if (typeof window === 'undefined')
|
|
1230
|
+
return;
|
|
1231
|
+
try {
|
|
1232
|
+
const url = new URL(window.location.href);
|
|
1233
|
+
url.searchParams.delete('stackedToken');
|
|
1234
|
+
window.history.replaceState({}, '', url.toString());
|
|
1235
|
+
this.logger.log('Cleared stackedToken from URL');
|
|
1236
|
+
}
|
|
1237
|
+
catch (error) {
|
|
1238
|
+
this.logger.error('Error clearing stacked token from URL:', error);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Consume a Stacked link token to link the current game player
|
|
1243
|
+
* to a Stacked unified user account.
|
|
1244
|
+
*
|
|
1245
|
+
* IMPORTANT: The player must be authenticated (have a valid JWT from tokenProvider)
|
|
1246
|
+
* before calling this method.
|
|
1247
|
+
*
|
|
1248
|
+
* @param token The Stacked link token from the URL
|
|
1249
|
+
* @returns Promise resolving to the link result
|
|
1250
|
+
*/
|
|
1251
|
+
async consumeStackedLinkToken(token) {
|
|
1252
|
+
try {
|
|
1253
|
+
const data = await this.postWithAuth('/v1/auth/stacked_link/exchange', { stackedToken: token });
|
|
1254
|
+
return {
|
|
1255
|
+
linked: data.linked,
|
|
1256
|
+
alreadyLinked: data.alreadyLinked,
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
catch (error) {
|
|
1260
|
+
this.logger.error('Error consuming stacked link token:', error);
|
|
1261
|
+
return {
|
|
1262
|
+
linked: false,
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Detects the token from URL, consumes it if player is authenticated.
|
|
1268
|
+
* @returns Promise resolving to the link result, or null if no token found
|
|
1269
|
+
*/
|
|
1270
|
+
async processStackedLinkToken() {
|
|
1271
|
+
const token = this.detectStackedLinkToken();
|
|
1272
|
+
if (!token) {
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
this.clearStackedTokenFromUrl();
|
|
1276
|
+
return this.consumeStackedLinkToken(token);
|
|
1277
|
+
}
|
|
1170
1278
|
handleError(error, context) {
|
|
1171
1279
|
this.logger.error(`Error in ${context}:`, error);
|
|
1172
1280
|
if (this.hooks.onError) {
|
|
@@ -1175,6 +1283,19 @@
|
|
|
1175
1283
|
}
|
|
1176
1284
|
}
|
|
1177
1285
|
|
|
1286
|
+
const keyPattern = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
|
|
1287
|
+
// renders template by replacing {keyName} with values from dynamic
|
|
1288
|
+
function renderTemplate(template, dynamic) {
|
|
1289
|
+
if (!template)
|
|
1290
|
+
return '';
|
|
1291
|
+
return template.replace(keyPattern, (match, key) => {
|
|
1292
|
+
if (dynamic && dynamic[key] !== undefined) {
|
|
1293
|
+
return String(dynamic[key]);
|
|
1294
|
+
}
|
|
1295
|
+
return '{?}'; // indicate missing key
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1178
1299
|
const meetsBaseConditions = ({ conditions, playerSnap, addDetails, }) => {
|
|
1179
1300
|
const conditionData = [];
|
|
1180
1301
|
let isValid = true;
|
|
@@ -1500,6 +1621,92 @@
|
|
|
1500
1621
|
}
|
|
1501
1622
|
}
|
|
1502
1623
|
}
|
|
1624
|
+
// Validate link count conditions
|
|
1625
|
+
if (conditions?.links && 'entityLinks' in playerSnap) {
|
|
1626
|
+
for (const [linkType, constraint] of Object.entries(conditions.links)) {
|
|
1627
|
+
const linkCount = playerSnap.entityLinks?.filter((link) => link.kind === linkType).length || 0;
|
|
1628
|
+
if (constraint.min !== undefined) {
|
|
1629
|
+
const isDisqualify = linkCount < constraint.min;
|
|
1630
|
+
if (addDetails) {
|
|
1631
|
+
conditionData.push({
|
|
1632
|
+
isMet: !isDisqualify,
|
|
1633
|
+
kind: 'links',
|
|
1634
|
+
trackerAmount: linkCount,
|
|
1635
|
+
text: `At least ${constraint.min} ${linkType} link(s)`,
|
|
1636
|
+
});
|
|
1637
|
+
if (isDisqualify)
|
|
1638
|
+
isValid = false;
|
|
1639
|
+
}
|
|
1640
|
+
else {
|
|
1641
|
+
if (isDisqualify)
|
|
1642
|
+
return { isValid: false };
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
if (constraint.max !== undefined) {
|
|
1646
|
+
const isDisqualify = linkCount > constraint.max;
|
|
1647
|
+
if (addDetails) {
|
|
1648
|
+
conditionData.push({
|
|
1649
|
+
isMet: !isDisqualify,
|
|
1650
|
+
kind: 'links',
|
|
1651
|
+
trackerAmount: linkCount,
|
|
1652
|
+
text: `At most ${constraint.max} ${linkType} link(s)`,
|
|
1653
|
+
});
|
|
1654
|
+
if (isDisqualify)
|
|
1655
|
+
isValid = false;
|
|
1656
|
+
}
|
|
1657
|
+
else {
|
|
1658
|
+
if (isDisqualify)
|
|
1659
|
+
return { isValid: false };
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
// Evaluate dynamic conditions
|
|
1665
|
+
if (conditions?.dynamic?.conditions?.length) {
|
|
1666
|
+
const dynamicResult = meetsDynamicConditions(playerSnap.dynamic || {}, conditions.dynamic);
|
|
1667
|
+
if (addDetails) {
|
|
1668
|
+
conditionData.push({
|
|
1669
|
+
isMet: dynamicResult,
|
|
1670
|
+
kind: 'dynamic',
|
|
1671
|
+
text: renderTemplate(conditions.dynamic.template, playerSnap.dynamic) ||
|
|
1672
|
+
'Dynamic conditions',
|
|
1673
|
+
});
|
|
1674
|
+
if (!dynamicResult)
|
|
1675
|
+
isValid = false;
|
|
1676
|
+
}
|
|
1677
|
+
else {
|
|
1678
|
+
if (!dynamicResult)
|
|
1679
|
+
return { isValid: false };
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
if (conditions?.identifiers?.platforms?.length && 'identifiers' in playerSnap) {
|
|
1683
|
+
const playerPlatforms = new Set(playerSnap.identifiers?.map((i) => i.platform.toLowerCase()) || []);
|
|
1684
|
+
const isAndBehaviour = conditions.identifiers.behaviour === 'AND';
|
|
1685
|
+
const platformsToCheck = conditions.identifiers.platforms;
|
|
1686
|
+
let isMet;
|
|
1687
|
+
let displayText;
|
|
1688
|
+
if (isAndBehaviour) {
|
|
1689
|
+
isMet = platformsToCheck.every((platform) => playerPlatforms.has(platform.toLowerCase()));
|
|
1690
|
+
displayText = `Link all: ${platformsToCheck.join(', ')}`;
|
|
1691
|
+
}
|
|
1692
|
+
else {
|
|
1693
|
+
isMet = platformsToCheck.some((platform) => playerPlatforms.has(platform.toLowerCase()));
|
|
1694
|
+
displayText = `Link any: ${platformsToCheck.join(', ')}`;
|
|
1695
|
+
}
|
|
1696
|
+
if (addDetails) {
|
|
1697
|
+
conditionData.push({
|
|
1698
|
+
isMet,
|
|
1699
|
+
kind: 'identifiers',
|
|
1700
|
+
text: displayText,
|
|
1701
|
+
});
|
|
1702
|
+
if (!isMet)
|
|
1703
|
+
isValid = false;
|
|
1704
|
+
}
|
|
1705
|
+
else {
|
|
1706
|
+
if (!isMet)
|
|
1707
|
+
return { isValid: false };
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1503
1710
|
return { isValid, conditionData: addDetails ? conditionData : undefined };
|
|
1504
1711
|
};
|
|
1505
1712
|
const meetsSurfacingConditions = ({ surfacingConditions, playerSnap, context, playerOffers, }) => {
|
|
@@ -1508,6 +1715,13 @@
|
|
|
1508
1715
|
// context is not in the list of surfacing contexts, so we don't want to surface this offer
|
|
1509
1716
|
return { isValid: false };
|
|
1510
1717
|
}
|
|
1718
|
+
if (surfacingConditions?.targetEntityTypes?.length) {
|
|
1719
|
+
const playerTarget = playerSnap.entityKind || 'default';
|
|
1720
|
+
// check if entity type is allowed
|
|
1721
|
+
if (!surfacingConditions.targetEntityTypes.includes(playerTarget)) {
|
|
1722
|
+
return { isValid: false };
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1511
1725
|
const conditions = surfacingConditions;
|
|
1512
1726
|
if (conditions?.andTags?.length) {
|
|
1513
1727
|
// check if player has all of the tags
|
|
@@ -1546,12 +1760,6 @@
|
|
|
1546
1760
|
(!playerSnap.dateSignedUp || playerSnap.dateSignedUp > conditions.maxDateSignedUp)) {
|
|
1547
1761
|
return { isValid: false };
|
|
1548
1762
|
}
|
|
1549
|
-
// Check dynamic conditions if present
|
|
1550
|
-
if (conditions.dynamic?.conditions?.length) {
|
|
1551
|
-
if (!meetsDynamicConditions(playerSnap.dynamic || {}, conditions.dynamic)) {
|
|
1552
|
-
return { isValid: false };
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
1763
|
const completedOfferIds = new Set();
|
|
1556
1764
|
for (const pOffer of playerOffers?.values() || []) {
|
|
1557
1765
|
if (pOffer.status === 'claimed' || pOffer.status === 'claimable') {
|
|
@@ -1587,6 +1795,10 @@
|
|
|
1587
1795
|
return true;
|
|
1588
1796
|
if (conditions.minDaysInGame)
|
|
1589
1797
|
return true;
|
|
1798
|
+
if (conditions.dynamic?.conditions?.length)
|
|
1799
|
+
return true;
|
|
1800
|
+
if (conditions.identifiers?.platforms?.length)
|
|
1801
|
+
return true;
|
|
1590
1802
|
const surCond = conditions;
|
|
1591
1803
|
if (surCond.contexts?.length)
|
|
1592
1804
|
return true;
|
|
@@ -1606,6 +1818,12 @@
|
|
|
1606
1818
|
return true;
|
|
1607
1819
|
if (surCond.completedOffers?.length)
|
|
1608
1820
|
return true;
|
|
1821
|
+
if (surCond.programmatic)
|
|
1822
|
+
return true;
|
|
1823
|
+
if (surCond.targetEntityTypes?.length)
|
|
1824
|
+
return true;
|
|
1825
|
+
if (surCond.links && Object.keys(surCond.links).length > 0)
|
|
1826
|
+
return true;
|
|
1609
1827
|
const compCond = conditions;
|
|
1610
1828
|
if (compCond.context)
|
|
1611
1829
|
return true;
|
|
@@ -1621,6 +1839,10 @@
|
|
|
1621
1839
|
return true;
|
|
1622
1840
|
if (compCond.loginStreak)
|
|
1623
1841
|
return true;
|
|
1842
|
+
if (compCond.linkedCompletions)
|
|
1843
|
+
return true;
|
|
1844
|
+
if (compCond.dynamicTracker?.conditions?.length)
|
|
1845
|
+
return true;
|
|
1624
1846
|
return false;
|
|
1625
1847
|
};
|
|
1626
1848
|
const meetsCompletionConditions = ({ completionConditions, completionTrackers, playerSnap, addDetails = false, }) => {
|
|
@@ -1734,51 +1956,107 @@
|
|
|
1734
1956
|
}
|
|
1735
1957
|
}
|
|
1736
1958
|
if (conditions?.social) {
|
|
1737
|
-
const hasAttachedContent = !!completionTrackers?.social?.videoId;
|
|
1738
1959
|
const tSocial = completionTrackers?.social;
|
|
1739
1960
|
const cSocial = completionConditions.social;
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1961
|
+
const mode = cSocial?.mode || 'attach';
|
|
1962
|
+
const hasContent = Boolean(mode === 'accumulate'
|
|
1963
|
+
? tSocial?.mode === 'accumulate'
|
|
1964
|
+
: tSocial && tSocial.mode !== 'accumulate' && !!tSocial.videoId);
|
|
1965
|
+
const minLikes = cSocial?.minLikes || 0;
|
|
1966
|
+
const minViews = cSocial?.minViews || 0;
|
|
1967
|
+
const minComments = cSocial?.minComments || 0;
|
|
1968
|
+
const likes = tSocial?.likes || 0;
|
|
1969
|
+
const views = tSocial?.views || 0;
|
|
1970
|
+
const comments = tSocial?.comments || 0;
|
|
1971
|
+
let isDisqualify = !hasContent;
|
|
1972
|
+
if (likes < minLikes || views < minViews || comments < minComments) {
|
|
1748
1973
|
isDisqualify = true;
|
|
1749
1974
|
}
|
|
1750
1975
|
if (addDetails) {
|
|
1751
|
-
// Build detailed text about requirements
|
|
1752
1976
|
const platformMap = {
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1977
|
+
tiktok: 'TikTok',
|
|
1978
|
+
instagram: 'Instagram',
|
|
1979
|
+
youtube: 'YouTube',
|
|
1756
1980
|
};
|
|
1757
|
-
const platformText = conditions.social.platforms
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1981
|
+
const platformText = conditions.social.platforms
|
|
1982
|
+
.map((platform) => platformMap[platform])
|
|
1983
|
+
.join(' | ');
|
|
1984
|
+
const requiredWords = cSocial?.requiredWords ?? [];
|
|
1985
|
+
if (mode === 'accumulate') {
|
|
1986
|
+
const matchCount = (tSocial?.mode === 'accumulate' && tSocial.matchCount) || 0;
|
|
1987
|
+
conditionData.push({
|
|
1988
|
+
isMet: hasContent,
|
|
1989
|
+
kind: 'social',
|
|
1990
|
+
trackerAmount: matchCount,
|
|
1991
|
+
text: hasContent
|
|
1992
|
+
? `Found ${matchCount} matching ${platformText} post${matchCount !== 1 ? 's' : ''}`
|
|
1993
|
+
: requiredWords.length > 0
|
|
1994
|
+
? `Post ${platformText} content with ${requiredWords.map((w) => `"${w}"`).join(', ')}`
|
|
1995
|
+
: `Post ${platformText} content`,
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
else {
|
|
1999
|
+
const title = (tSocial && tSocial.mode !== 'accumulate' && tSocial.title) || undefined;
|
|
2000
|
+
conditionData.push({
|
|
2001
|
+
isMet: hasContent,
|
|
2002
|
+
kind: 'social',
|
|
2003
|
+
trackerAmount: 0,
|
|
2004
|
+
text: !hasContent
|
|
2005
|
+
? requiredWords.length > 0
|
|
2006
|
+
? `Attach a ${platformText} post with ${requiredWords.map((w) => `"${w}"`).join(', ')} in the title`
|
|
2007
|
+
: `Attach a ${platformText} post`
|
|
2008
|
+
: `Attached: ${title}`,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
if (minLikes > 0) {
|
|
2012
|
+
conditionData.push({
|
|
2013
|
+
isMet: hasContent && likes >= minLikes,
|
|
2014
|
+
kind: 'social',
|
|
2015
|
+
trackerAmount: likes,
|
|
2016
|
+
text: mode === 'accumulate'
|
|
2017
|
+
? `Combined ${minLikes} Likes`
|
|
2018
|
+
: `Reach ${minLikes} Likes`,
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
if (minViews > 0) {
|
|
2022
|
+
conditionData.push({
|
|
2023
|
+
isMet: hasContent && views >= minViews,
|
|
2024
|
+
kind: 'social',
|
|
2025
|
+
trackerAmount: views,
|
|
2026
|
+
text: mode === 'accumulate'
|
|
2027
|
+
? `Combined ${minViews} Views`
|
|
2028
|
+
: `Reach ${minViews} Views`,
|
|
2029
|
+
});
|
|
1771
2030
|
}
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
:
|
|
2031
|
+
if (minComments > 0) {
|
|
2032
|
+
conditionData.push({
|
|
2033
|
+
isMet: hasContent && comments >= minComments,
|
|
2034
|
+
kind: 'social',
|
|
2035
|
+
trackerAmount: comments,
|
|
2036
|
+
text: mode === 'accumulate'
|
|
2037
|
+
? `Combined ${minComments} Comments`
|
|
2038
|
+
: `Reach ${minComments} Comments`,
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
if (isDisqualify)
|
|
2042
|
+
isValid = false;
|
|
2043
|
+
}
|
|
2044
|
+
else {
|
|
2045
|
+
if (isDisqualify)
|
|
2046
|
+
return { isValid: false };
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
// Linked completions - wait for N linked entities to complete
|
|
2050
|
+
if (conditions?.linkedCompletions?.min) {
|
|
2051
|
+
const currentCount = completionTrackers?.linkedCompletions || 0;
|
|
2052
|
+
const requiredCount = conditions.linkedCompletions.min;
|
|
2053
|
+
const isDisqualify = currentCount < requiredCount;
|
|
2054
|
+
if (addDetails) {
|
|
1777
2055
|
conditionData.push({
|
|
1778
|
-
isMet:
|
|
1779
|
-
kind: '
|
|
1780
|
-
trackerAmount,
|
|
1781
|
-
text:
|
|
2056
|
+
isMet: !isDisqualify,
|
|
2057
|
+
kind: 'linkedCompletions',
|
|
2058
|
+
trackerAmount: currentCount,
|
|
2059
|
+
text: `Wait for ${requiredCount} linked ${requiredCount === 1 ? 'entity' : 'entities'} to complete`,
|
|
1782
2060
|
});
|
|
1783
2061
|
if (isDisqualify)
|
|
1784
2062
|
isValid = false;
|
|
@@ -1788,6 +2066,22 @@
|
|
|
1788
2066
|
return { isValid: false };
|
|
1789
2067
|
}
|
|
1790
2068
|
}
|
|
2069
|
+
if (conditions?.dynamicTracker?.conditions?.length) {
|
|
2070
|
+
const dynamicResult = meetsDynamicConditions(completionTrackers?.dynamicTracker || {}, conditions.dynamicTracker);
|
|
2071
|
+
if (addDetails) {
|
|
2072
|
+
conditionData.push({
|
|
2073
|
+
isMet: dynamicResult,
|
|
2074
|
+
kind: 'dynamic',
|
|
2075
|
+
text: renderTemplate(conditions.dynamicTracker.template, completionTrackers?.dynamicTracker) || 'Dynamic conditions',
|
|
2076
|
+
});
|
|
2077
|
+
if (!dynamicResult)
|
|
2078
|
+
isValid = false;
|
|
2079
|
+
}
|
|
2080
|
+
else {
|
|
2081
|
+
if (!dynamicResult)
|
|
2082
|
+
return { isValid: false };
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
1791
2085
|
const r = meetsBaseConditions({
|
|
1792
2086
|
conditions,
|
|
1793
2087
|
playerSnap,
|
|
@@ -1799,6 +2093,136 @@
|
|
|
1799
2093
|
}
|
|
1800
2094
|
return { isValid: true, conditionData: [] };
|
|
1801
2095
|
};
|
|
2096
|
+
/**
|
|
2097
|
+
* Checks if completion conditions were met before a specific expiry time.
|
|
2098
|
+
* Returns true if all relevant condition fields were updated before expiryTime.
|
|
2099
|
+
*
|
|
2100
|
+
* @param completionConditions - The completion conditions to check
|
|
2101
|
+
* @param completionTrackers - The completion trackers (for buyItem, spendCurrency, etc.)
|
|
2102
|
+
* @param playerSnap - The player snapshot with field timestamps
|
|
2103
|
+
* @param expiryTime - The expiry timestamp in milliseconds
|
|
2104
|
+
* @returns true if all conditions were met before expiry, false otherwise
|
|
2105
|
+
*/
|
|
2106
|
+
const meetsCompletionConditionsBeforeExpiry = ({ completionConditions, completionTrackers, playerSnap, expiryTime, }) => {
|
|
2107
|
+
if (!completionConditions)
|
|
2108
|
+
return false;
|
|
2109
|
+
// Check if there are actually any conditions to evaluate
|
|
2110
|
+
if (!hasConditions(completionConditions))
|
|
2111
|
+
return false;
|
|
2112
|
+
// First check if conditions are actually met
|
|
2113
|
+
const conditionsMet = meetsCompletionConditions({
|
|
2114
|
+
completionConditions,
|
|
2115
|
+
completionTrackers,
|
|
2116
|
+
playerSnap,
|
|
2117
|
+
});
|
|
2118
|
+
if (!conditionsMet.isValid)
|
|
2119
|
+
return false;
|
|
2120
|
+
const lastSnapshotUpdate = new Date(playerSnap.snapshotLastUpdated).getTime();
|
|
2121
|
+
/**
|
|
2122
|
+
* Checks if a field was updated after the expiry time.
|
|
2123
|
+
* Returns true if updated AFTER or AT expiry (violates grace period).
|
|
2124
|
+
* Returns false if updated BEFORE expiry (allows grace period).
|
|
2125
|
+
*/
|
|
2126
|
+
function wasUpdatedAfterExpiry(data) {
|
|
2127
|
+
let lastUpdated;
|
|
2128
|
+
if (typeof data === 'object' && data !== null && !(data instanceof Date)) {
|
|
2129
|
+
// Object with optional lastUpdated field
|
|
2130
|
+
lastUpdated = data.lastUpdated
|
|
2131
|
+
? new Date(data.lastUpdated).getTime()
|
|
2132
|
+
: lastSnapshotUpdate;
|
|
2133
|
+
}
|
|
2134
|
+
else if (data instanceof Date) {
|
|
2135
|
+
lastUpdated = data.getTime();
|
|
2136
|
+
}
|
|
2137
|
+
else if (typeof data === 'string' || typeof data === 'number') {
|
|
2138
|
+
lastUpdated = new Date(data).getTime();
|
|
2139
|
+
}
|
|
2140
|
+
else {
|
|
2141
|
+
// No data provided, use snapshot timestamp
|
|
2142
|
+
lastUpdated = lastSnapshotUpdate;
|
|
2143
|
+
}
|
|
2144
|
+
return lastUpdated >= expiryTime;
|
|
2145
|
+
}
|
|
2146
|
+
if (completionConditions.currencies) {
|
|
2147
|
+
for (const currencyId in completionConditions.currencies) {
|
|
2148
|
+
const currency = playerSnap.currencies?.[currencyId];
|
|
2149
|
+
if (!currency)
|
|
2150
|
+
continue;
|
|
2151
|
+
if (wasUpdatedAfterExpiry(currency))
|
|
2152
|
+
return false;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
if (completionConditions.levels) {
|
|
2156
|
+
for (const skillId in completionConditions.levels) {
|
|
2157
|
+
const level = playerSnap.levels?.[skillId];
|
|
2158
|
+
if (!level)
|
|
2159
|
+
continue;
|
|
2160
|
+
if (wasUpdatedAfterExpiry(level))
|
|
2161
|
+
return false;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
if (completionConditions.quests) {
|
|
2165
|
+
for (const questId in completionConditions.quests) {
|
|
2166
|
+
const quest = playerSnap.quests?.[questId];
|
|
2167
|
+
if (!quest)
|
|
2168
|
+
continue;
|
|
2169
|
+
if (wasUpdatedAfterExpiry(quest))
|
|
2170
|
+
return false;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
if (completionConditions.memberships) {
|
|
2174
|
+
for (const membershipId in completionConditions.memberships) {
|
|
2175
|
+
const membership = playerSnap.memberships?.[membershipId];
|
|
2176
|
+
if (!membership)
|
|
2177
|
+
continue;
|
|
2178
|
+
if (wasUpdatedAfterExpiry(membership))
|
|
2179
|
+
return false;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
if (completionConditions.achievements) {
|
|
2183
|
+
for (const achievementId in completionConditions.achievements) {
|
|
2184
|
+
const achievement = playerSnap.achievements?.[achievementId];
|
|
2185
|
+
if (!achievement)
|
|
2186
|
+
continue;
|
|
2187
|
+
if (wasUpdatedAfterExpiry(achievement))
|
|
2188
|
+
return false;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
if (completionConditions.stakedTokens) {
|
|
2192
|
+
for (const tokenId in completionConditions.stakedTokens) {
|
|
2193
|
+
const stakedToken = playerSnap.stakedTokens?.[tokenId];
|
|
2194
|
+
if (!stakedToken)
|
|
2195
|
+
continue;
|
|
2196
|
+
const lastStakeTime = new Date(stakedToken.lastStake ?? 0).getTime();
|
|
2197
|
+
const lastUnstakeTime = new Date(stakedToken.lastUnstake ?? 0).getTime();
|
|
2198
|
+
const lastUpdated = Math.max(lastStakeTime, lastUnstakeTime);
|
|
2199
|
+
if (lastUpdated >= expiryTime)
|
|
2200
|
+
return false;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
if (completionConditions.minTrustScore !== undefined ||
|
|
2204
|
+
completionConditions.maxTrustScore !== undefined) {
|
|
2205
|
+
if (wasUpdatedAfterExpiry(playerSnap.trustLastUpdated))
|
|
2206
|
+
return false;
|
|
2207
|
+
}
|
|
2208
|
+
if (completionConditions.minDaysInGame !== undefined) {
|
|
2209
|
+
if (wasUpdatedAfterExpiry(playerSnap.daysInGameLastUpdated))
|
|
2210
|
+
return false;
|
|
2211
|
+
}
|
|
2212
|
+
if (completionConditions.login || completionConditions.loginStreak) {
|
|
2213
|
+
if (wasUpdatedAfterExpiry())
|
|
2214
|
+
return false;
|
|
2215
|
+
}
|
|
2216
|
+
if (completionConditions.social) {
|
|
2217
|
+
// Check if social content was attached/validated after expiry
|
|
2218
|
+
if (completionTrackers?.social?.lastChecked) {
|
|
2219
|
+
if (wasUpdatedAfterExpiry(completionTrackers.social.lastChecked))
|
|
2220
|
+
return false;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
// All conditions were met before expiry
|
|
2224
|
+
return true;
|
|
2225
|
+
};
|
|
1802
2226
|
/**
|
|
1803
2227
|
* Checks if a dynamic object meets a set of dynamic field conditions.
|
|
1804
2228
|
* @param dynamicObj - The object with any key and string or number value.
|
|
@@ -1812,38 +2236,35 @@
|
|
|
1812
2236
|
const val = dynamicObj[cond.key];
|
|
1813
2237
|
if (val === undefined)
|
|
1814
2238
|
return false;
|
|
2239
|
+
const isNumber = typeof val === 'number';
|
|
2240
|
+
const compareTo = isNumber ? Number(cond.compareTo) : String(cond.compareTo);
|
|
1815
2241
|
switch (cond.operator) {
|
|
1816
2242
|
case '==':
|
|
1817
|
-
return val ===
|
|
2243
|
+
return val === compareTo;
|
|
1818
2244
|
case '!=':
|
|
1819
|
-
return val !==
|
|
1820
|
-
case '>':
|
|
1821
|
-
return (typeof val === 'number' &&
|
|
1822
|
-
typeof cond.compareTo === 'number' &&
|
|
1823
|
-
val > cond.compareTo);
|
|
1824
|
-
case '>=':
|
|
1825
|
-
return (typeof val === 'number' &&
|
|
1826
|
-
typeof cond.compareTo === 'number' &&
|
|
1827
|
-
val >= cond.compareTo);
|
|
1828
|
-
case '<':
|
|
1829
|
-
return (typeof val === 'number' &&
|
|
1830
|
-
typeof cond.compareTo === 'number' &&
|
|
1831
|
-
val < cond.compareTo);
|
|
1832
|
-
case '<=':
|
|
1833
|
-
return (typeof val === 'number' &&
|
|
1834
|
-
typeof cond.compareTo === 'number' &&
|
|
1835
|
-
val <= cond.compareTo);
|
|
1836
|
-
case 'has':
|
|
1837
|
-
return (typeof val === 'string' &&
|
|
1838
|
-
typeof cond.compareTo === 'string' &&
|
|
1839
|
-
val.includes(cond.compareTo));
|
|
1840
|
-
case 'not_has':
|
|
1841
|
-
return (typeof val === 'string' &&
|
|
1842
|
-
typeof cond.compareTo === 'string' &&
|
|
1843
|
-
!val.includes(cond.compareTo));
|
|
1844
|
-
default:
|
|
1845
|
-
return false;
|
|
2245
|
+
return val !== compareTo;
|
|
1846
2246
|
}
|
|
2247
|
+
if (isNumber && typeof compareTo === 'number') {
|
|
2248
|
+
switch (cond.operator) {
|
|
2249
|
+
case '>':
|
|
2250
|
+
return val > compareTo;
|
|
2251
|
+
case '>=':
|
|
2252
|
+
return val >= compareTo;
|
|
2253
|
+
case '<':
|
|
2254
|
+
return val < compareTo;
|
|
2255
|
+
case '<=':
|
|
2256
|
+
return val <= compareTo;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
else if (!isNumber && typeof compareTo === 'string') {
|
|
2260
|
+
switch (cond.operator) {
|
|
2261
|
+
case 'has':
|
|
2262
|
+
return val.includes(compareTo);
|
|
2263
|
+
case 'not_has':
|
|
2264
|
+
return !val.includes(compareTo);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
return false;
|
|
1847
2268
|
}
|
|
1848
2269
|
/**
|
|
1849
2270
|
* Evaluates a group of dynamic conditions with logical links (AND, OR, AND NOT).
|
|
@@ -1876,11 +2297,34 @@
|
|
|
1876
2297
|
}
|
|
1877
2298
|
return result;
|
|
1878
2299
|
}
|
|
2300
|
+
/**
|
|
2301
|
+
* Checks if a PlayerOffer meets its claimable conditions (completed -> claimable transition).
|
|
2302
|
+
* @param claimableConditions - The offer's claimableConditions (from IOffer)
|
|
2303
|
+
* @param claimableTrackers - The player offer's claimableTrackers
|
|
2304
|
+
*/
|
|
2305
|
+
function meetsClaimableConditions({ claimableConditions, playerOfferTrackers, claimableTrackers, }) {
|
|
2306
|
+
if (!claimableConditions) {
|
|
2307
|
+
return { isValid: true };
|
|
2308
|
+
}
|
|
2309
|
+
if (claimableConditions.siblingCompletions) {
|
|
2310
|
+
const siblingCount = playerOfferTrackers?.siblingPlayerOffer_ids?.length ?? 0;
|
|
2311
|
+
let completedCount = claimableTrackers?.siblingCompletions ?? 0;
|
|
2312
|
+
if (completedCount == -1)
|
|
2313
|
+
completedCount = siblingCount; // treat -1 as all completed
|
|
2314
|
+
// if siblings exist but not all are completed, return false
|
|
2315
|
+
if (siblingCount > 0 && completedCount < siblingCount) {
|
|
2316
|
+
return { isValid: false };
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
return { isValid: true };
|
|
2320
|
+
}
|
|
1879
2321
|
|
|
2322
|
+
const offerListenerEvents = ['claim_offer'];
|
|
1880
2323
|
const PlayerOfferStatuses = [
|
|
1881
|
-
'inQueue',
|
|
2324
|
+
// 'inQueue', // fuck this shit. just don't surface offers if their offer plate is full.
|
|
1882
2325
|
'surfaced',
|
|
1883
2326
|
'viewed',
|
|
2327
|
+
'completed', // Individual completionConditions met, waiting for claimableConditions (e.g., siblings)
|
|
1884
2328
|
'claimable',
|
|
1885
2329
|
'claimed',
|
|
1886
2330
|
'expired',
|
|
@@ -1893,12 +2337,26 @@
|
|
|
1893
2337
|
'exp',
|
|
1894
2338
|
'trust_points',
|
|
1895
2339
|
'loyalty_currency', // loyalty currency that the player can exchange for rewards like on-chain via withdraw, etc.
|
|
2340
|
+
'discount', // handled by the external dev, using the rewardId to identify what it is for in their system
|
|
1896
2341
|
/** on-chain rewards require the builder to send funds to a custodial wallet that we use to send to player wallets*/
|
|
1897
2342
|
];
|
|
1898
2343
|
const rewardSchema = {
|
|
1899
2344
|
_id: false,
|
|
1900
2345
|
kind: { type: String, enum: rewardKinds },
|
|
1901
|
-
rewardId:
|
|
2346
|
+
rewardId: {
|
|
2347
|
+
type: String,
|
|
2348
|
+
validate: {
|
|
2349
|
+
validator: function (value) {
|
|
2350
|
+
// Require rewardId for item, coins, loyalty_currency, exp, and discount kinds
|
|
2351
|
+
const requiresRewardId = ['item', 'coins', 'loyalty_currency', 'exp', 'discount'].includes(this.kind);
|
|
2352
|
+
if (requiresRewardId) {
|
|
2353
|
+
return !!value;
|
|
2354
|
+
}
|
|
2355
|
+
return true;
|
|
2356
|
+
},
|
|
2357
|
+
message: 'rewardId is required for reward kinds: item, coins, loyalty_currency, exp, and discount',
|
|
2358
|
+
},
|
|
2359
|
+
},
|
|
1902
2360
|
skillId: String,
|
|
1903
2361
|
currencyId: String, // could be a loyalty currency
|
|
1904
2362
|
itemId: String,
|
|
@@ -1915,9 +2373,12 @@
|
|
|
1915
2373
|
exports.SSEConnection = SSEConnection;
|
|
1916
2374
|
exports.hasConditions = hasConditions;
|
|
1917
2375
|
exports.meetsBaseConditions = meetsBaseConditions;
|
|
2376
|
+
exports.meetsClaimableConditions = meetsClaimableConditions;
|
|
1918
2377
|
exports.meetsCompletionConditions = meetsCompletionConditions;
|
|
2378
|
+
exports.meetsCompletionConditionsBeforeExpiry = meetsCompletionConditionsBeforeExpiry;
|
|
1919
2379
|
exports.meetsDynamicConditions = meetsDynamicConditions;
|
|
1920
2380
|
exports.meetsSurfacingConditions = meetsSurfacingConditions;
|
|
2381
|
+
exports.offerListenerEvents = offerListenerEvents;
|
|
1921
2382
|
exports.rewardKinds = rewardKinds;
|
|
1922
2383
|
exports.rewardSchema = rewardSchema;
|
|
1923
2384
|
|