Files
ShaiBot/utils/profileMaintenance.js
T
2026-08-27 19:34:16 +02:00

1411 lines
30 KiB
JavaScript

const {
getProfileCollections,
getProfileStats,
achievementRequirementsMet,
isStatBackfillableAchievement
} = require("./profileSystem");
const {
getUnixTime
} = require("./cookieEconomy");
function getActiveDefinitions(
achievementConfig
) {
return achievementConfig
.find({
active: {
$ne: false
}
})
.toArray();
}
async function reconcileUserAchievements({
guildId,
userId,
dryRun = false
}) {
const {
achievements,
achievementConfig
} = getProfileCollections();
const [
statsDocument,
definitions,
achievementDocument
] = await Promise.all([
getProfileStats(
userId,
guildId
),
getActiveDefinitions(
achievementConfig
),
achievements.findOne({
userId,
guildId
})
]);
const unlocked = {
...(achievementDocument?.unlocked ?? {})
};
const additions = {};
let skippedEventOnly = 0;
/*
* ========================================================
* NORMAL / STAT-BACKED ACHIEVEMENTS
* ========================================================
*/
const normalDefinitions =
definitions.filter(
definition =>
definition.measure !==
"achievementPercentage"
);
for (const definition of normalDefinitions) {
if (
unlocked[
definition.achievementId
]
) {
continue;
}
if (
!isStatBackfillableAchievement(
definition
)
) {
skippedEventOnly++;
continue;
}
if (
!achievementRequirementsMet(
statsDocument,
definition
)
) {
continue;
}
const timestamp =
getUnixTime();
additions[
definition.achievementId
] = timestamp;
unlocked[
definition.achievementId
] = timestamp;
}
/*
* ========================================================
* COLLECTOR ACHIEVEMENTS
* ========================================================
*
* Only stat-backed achievements count toward the
* collectible denominator.
*
* This intentionally matches profileSystem.js.
*/
const collectibleDefinitions =
definitions.filter(
definition =>
isStatBackfillableAchievement(
definition
)
);
const unlockedCollectible =
collectibleDefinitions.filter(
definition =>
Boolean(
unlocked[
definition.achievementId
]
)
).length;
const percentage =
collectibleDefinitions.length > 0
? unlockedCollectible /
collectibleDefinitions.length
: 0;
const collectorDefinitions =
definitions.filter(
definition =>
definition.measure ===
"achievementPercentage"
);
for (const definition of collectorDefinitions) {
if (
unlocked[
definition.achievementId
]
) {
continue;
}
if (
percentage <
Number(
definition.target ?? 0
)
) {
continue;
}
const timestamp =
getUnixTime();
additions[
definition.achievementId
] = timestamp;
unlocked[
definition.achievementId
] = timestamp;
}
/*
* ========================================================
* WRITE
* ========================================================
*/
const additionEntries =
Object.entries(
additions
);
if (
!dryRun &&
additionEntries.length > 0
) {
const set = {};
for (
const [
achievementId,
timestamp
]
of additionEntries
) {
set[
`unlocked.${achievementId}`
] = timestamp;
}
await achievements.updateOne(
{
userId,
guildId
},
{
$setOnInsert: {
userId,
guildId
},
$set: set
},
{
upsert: true
}
);
console.log(
`[ProfileMaintenance] Reconciled ${additionEntries.length} achievement(s) for ${userId}: ${additionEntries
.map(
([achievementId]) =>
achievementId
)
.join(", ")}`
);
}
if (
dryRun &&
additionEntries.length > 0
) {
console.log(
`[ProfileMaintenance] Dry run: ${userId} would unlock ${additionEntries.length} achievement(s): ${additionEntries
.map(
([achievementId]) =>
achievementId
)
.join(", ")}`
);
}
return {
userId,
checked:
definitions.length,
unlocked:
additionEntries.length,
skippedEventOnly,
dryRun,
achievementIds:
additionEntries.map(
([achievementId]) =>
achievementId
)
};
}
async function reconcileAllAchievements({
guildId,
dryRun = false
}) {
const {
stats
} = getProfileCollections();
const userIds =
await stats.distinct(
"userId",
{
guildId
}
);
const summary = {
users:
userIds.length,
unlocked:
0,
skippedEventOnly:
0,
results: []
};
for (const userId of userIds) {
const result =
await reconcileUserAchievements({
guildId,
userId,
dryRun
});
summary.unlocked +=
result.unlocked;
summary.skippedEventOnly +=
result.skippedEventOnly;
summary.results.push(
result
);
}
return summary;
}
async function auditProfileData(
guildId
) {
const {
stats,
achievements,
achievementConfig,
badges
} = getProfileCollections();
const [
statDocuments,
achievementDocuments,
activeDefinitions,
badgeDocuments
] = await Promise.all([
stats.find({
guildId
}).toArray(),
achievements.find({
guildId
}).toArray(),
achievementConfig
.find({
active: {
$ne: false
}
})
.toArray(),
badges.find({
guildId
}).toArray()
]);
const issues = [];
/*
* ========================================================
* STATS DOCUMENT VALIDATION
* ========================================================
*/
for (const document of statDocuments) {
if (!document.userId) {
issues.push(
"Stats document missing userId."
);
continue;
}
/*
* ----------------------------------------------------
* GAME TOTALS
* ----------------------------------------------------
*/
const total =
document.games?.total ?? {};
const played =
Number(
total.played ?? 0
);
const resolved =
Number(
total.won ?? 0
)
+
Number(
total.lost ?? 0
)
+
Number(
total.pushed ?? 0
);
if (
played !==
resolved
) {
issues.push(
`${document.userId}: games.total.played (${played}) does not match W/L/P total (${resolved}).`
);
}
for (
const game
of [
"slots",
"blackjack",
"roulette",
"heist"
]
) {
const value =
document.games?.[game] ?? {};
const gamePlayed =
Number(
value.played ?? 0
);
const gameResolved =
Number(
value.won ?? 0
)
+
Number(
value.lost ?? 0
)
+
Number(
value.pushed ?? 0
);
if (
gamePlayed !==
gameResolved
) {
issues.push(
`${document.userId}: games.${game}.played (${gamePlayed}) does not match W/L/P total (${gameResolved}).`
);
}
}
/*
* ----------------------------------------------------
* NON-NEGATIVE ECONOMY COUNTERS
* ----------------------------------------------------
*/
const nonNegativeFields = [
document.economy
?.cookiesObtainedTotal,
document.economy
?.cookiesLostTotal,
document.economy
?.cookiesGivenTotal,
document.economy
?.stolenTotal,
document.economy
?.stolenFromMeTotal,
document.economy
?.vaultStolenTotal
];
if (
nonNegativeFields.some(
value =>
Number(
value ?? 0
) < 0
)
) {
issues.push(
`${document.userId}: one or more economy counters are negative.`
);
}
/*
* ----------------------------------------------------
* ACTIVITY SCHEMA
* ----------------------------------------------------
*/
const activity =
document.activity;
if (!activity) {
issues.push(
`${document.userId}: activity object is missing.`
);
continue;
}
const currentDailyStreak =
Number(
activity.currentDailyStreak ?? 0
);
const bestDailyStreak =
Number(
activity.bestDailyStreak ?? 0
);
if (
currentDailyStreak < 0 ||
bestDailyStreak < 0
) {
issues.push(
`${document.userId}: activity streak counters cannot be negative.`
);
}
if (
currentDailyStreak >
bestDailyStreak
) {
issues.push(
`${document.userId}: currentDailyStreak (${currentDailyStreak}) exceeds bestDailyStreak (${bestDailyStreak}).`
);
}
/*
* ----------------------------------------------------
* DATE KEYS
* ----------------------------------------------------
*/
for (
const field
of [
"lastActiveDate",
"lastGameDate"
]
) {
const value =
activity[field];
if (
value !== null &&
value !== undefined &&
!isValidDateKey(
value
)
) {
issues.push(
`${document.userId}: activity.${field} has invalid date key "${value}".`
);
}
}
/*
* ----------------------------------------------------
* WEEK KEY
* ----------------------------------------------------
*/
if (
activity.currentWeekKey !==
null
&&
activity.currentWeekKey !==
undefined
&&
!isValidWeekKey(
activity.currentWeekKey
)
) {
issues.push(
`${document.userId}: activity.currentWeekKey has invalid value "${activity.currentWeekKey}".`
);
}
/*
* ----------------------------------------------------
* WEEKDAY COUNTERS
* ----------------------------------------------------
*/
const gamesByWeekday =
activity.gamesByWeekday ?? {};
const weekdays = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
];
for (const weekday of weekdays) {
const value =
Number(
gamesByWeekday[
weekday
] ?? 0
);
if (
!Number.isFinite(
value
)
||
value < 0
) {
issues.push(
`${document.userId}: activity.gamesByWeekday.${weekday} is invalid.`
);
}
}
/*
* ----------------------------------------------------
* CURRENT WEEK DAYS
* ----------------------------------------------------
*/
const currentWeekDays =
activity.currentWeekDays;
if (
!Array.isArray(
currentWeekDays
)
) {
issues.push(
`${document.userId}: activity.currentWeekDays is not an array.`
);
}
else {
const uniqueDays =
new Set(
currentWeekDays
);
if (
uniqueDays.size !==
currentWeekDays.length
) {
issues.push(
`${document.userId}: activity.currentWeekDays contains duplicate weekdays.`
);
}
for (
const weekday
of currentWeekDays
) {
if (
!weekdays.includes(
weekday
)
) {
issues.push(
`${document.userId}: activity.currentWeekDays contains invalid weekday "${weekday}".`
);
}
}
}
/*
* ----------------------------------------------------
* TIME BUCKETS
* ----------------------------------------------------
*/
const timeBuckets =
activity.gamesByTimeBucket ?? {};
for (
const bucket
of [
"overnight",
"morning",
"afternoon",
"evening"
]
) {
const value =
Number(
timeBuckets[
bucket
] ?? 0
);
if (
!Number.isFinite(
value
)
||
value < 0
) {
issues.push(
`${document.userId}: activity.gamesByTimeBucket.${bucket} is invalid.`
);
}
}
/*
* ----------------------------------------------------
* SCHEMA VERSION
* ----------------------------------------------------
*/
if (
Number(
document
.profileSchemaVersion ?? 0
) < 2
) {
issues.push(
`${document.userId}: profileSchemaVersion is below 2.`
);
}
}
/*
* ========================================================
* ACHIEVEMENT DEFINITION VALIDATION
* ========================================================
*/
const achievementIds =
new Set();
for (
const definition
of activeDefinitions
) {
/*
* ----------------------------------------------------
* ACHIEVEMENT ID
* ----------------------------------------------------
*/
if (
!definition
.achievementId
) {
issues.push(
"Active achievement definition missing achievementId."
);
continue;
}
if (
achievementIds.has(
definition.achievementId
)
) {
issues.push(
`Duplicate active achievementId: ${definition.achievementId}.`
);
}
achievementIds.add(
definition.achievementId
);
/*
* ----------------------------------------------------
* COLLECTOR ACHIEVEMENTS
* ----------------------------------------------------
*/
if (
definition.measure ===
"achievementPercentage"
) {
if (
definition.target ===
undefined
) {
issues.push(
`${definition.achievementId}: target is missing.`
);
continue;
}
const target =
Number(
definition.target
);
if (
!Number.isFinite(
target
)
||
target < 0
||
target > 1
) {
issues.push(
`${definition.achievementId}: achievementPercentage target must be between 0 and 1.`
);
}
continue;
}
/*
* ----------------------------------------------------
* EVENT ACHIEVEMENTS
* ----------------------------------------------------
*/
if (
definition.measure ===
"event"
) {
if (
!definition.eventType ||
typeof definition.eventType !==
"string"
) {
issues.push(
`${definition.achievementId}: event achievement has no eventType.`
);
}
if (
!Array.isArray(
definition.eventConditions
)
||
definition.eventConditions.length ===
0
) {
issues.push(
`${definition.achievementId}: event achievement has no eventConditions.`
);
continue;
}
const validEventOperators = [
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"in",
"not_in",
"truthy",
"falsy"
];
for (
const [
index,
condition
]
of definition
.eventConditions
.entries()
) {
if (
!condition ||
typeof condition !==
"object"
) {
issues.push(
`${definition.achievementId}: event condition ${index + 1} is invalid.`
);
continue;
}
if (
!condition.field
) {
issues.push(
`${definition.achievementId}: event condition ${index + 1} has no field.`
);
}
const operator =
String(
condition.operator ??
"eq"
).toLowerCase();
if (
!validEventOperators.includes(
operator
)
) {
issues.push(
`${definition.achievementId}: event condition ${index + 1} has unknown operator "${operator}".`
);
}
if (
[
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"in",
"not_in"
].includes(
operator
)
&&
condition.value ===
undefined
) {
issues.push(
`${definition.achievementId}: event condition ${index + 1} using "${operator}" is missing value.`
);
}
if (
[
"in",
"not_in"
].includes(
operator
)
&&
!Array.isArray(
condition.value
)
) {
issues.push(
`${definition.achievementId}: event condition ${index + 1} using "${operator}" requires an array value.`
);
}
if (
[
"gt",
"gte",
"lt",
"lte"
].includes(
operator
)
&&
!Number.isFinite(
Number(
condition.value
)
)
) {
issues.push(
`${definition.achievementId}: event condition ${index + 1} has a non-numeric comparison value.`
);
}
}
continue;
}
/*
* ----------------------------------------------------
* COMPOSITE ACHIEVEMENTS
* ----------------------------------------------------
*/
if (
definition.measure ===
"all"
||
definition.measure ===
"any"
) {
if (
!Array.isArray(
definition.conditions
)
||
definition.conditions.length ===
0
) {
issues.push(
`${definition.achievementId}: composite achievement has no conditions.`
);
continue;
}
const validOperators = [
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"includes",
"not_includes",
"truthy",
"falsy"
];
for (
const [
index,
condition
]
of definition
.conditions
.entries()
) {
if (
!condition
||
typeof condition !==
"object"
) {
issues.push(
`${definition.achievementId}: condition ${index + 1} is invalid.`
);
continue;
}
if (
!condition.stat
) {
issues.push(
`${definition.achievementId}: condition ${index + 1} has no stat path.`
);
}
const operator =
String(
condition.operator ??
"gte"
).toLowerCase();
if (
!validOperators.includes(
operator
)
) {
issues.push(
`${definition.achievementId}: condition ${index + 1} has unknown operator "${operator}".`
);
}
/*
* Operators that compare against a value
* require condition.value.
*/
if (
[
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"includes",
"not_includes"
].includes(
operator
)
&&
condition.value ===
undefined
) {
issues.push(
`${definition.achievementId}: condition ${index + 1} using "${operator}" is missing value.`
);
}
/*
* Numeric operators should use numeric
* comparison values.
*/
if (
[
"gt",
"gte",
"lt",
"lte"
].includes(
operator
)
&&
!Number.isFinite(
Number(
condition.value
)
)
) {
issues.push(
`${definition.achievementId}: condition ${index + 1} using "${operator}" has a non-numeric value.`
);
}
}
/*
* Composite achievements use their conditions
* instead of a top-level stat/target.
*/
continue;
}
/*
* ----------------------------------------------------
* NORMAL STAT-BACKED ACHIEVEMENTS
* ----------------------------------------------------
*/
if (
definition.stat
) {
if (
definition.target ===
undefined
) {
issues.push(
`${definition.achievementId}: target is missing.`
);
continue;
}
const target =
Number(
definition.target
);
if (
!Number.isFinite(
target
)
) {
issues.push(
`${definition.achievementId}: target is not numeric.`
);
}
continue;
}
/*
* ----------------------------------------------------
* EVENT-ONLY ACHIEVEMENTS
* ----------------------------------------------------
*
* These aren't considered structurally invalid.
*
* Stage 4 will introduce proper event-condition
* validation for them.
*/
continue;
}
/*
* ========================================================
* ORPHANED ACHIEVEMENT DOCUMENTS
* ========================================================
*/
const statUsers =
new Set(
statDocuments.map(
document =>
document.userId
)
);
const orphanAchievementDocs =
achievementDocuments.filter(
document =>
!statUsers.has(
document.userId
)
).length;
/*
* ========================================================
* BADGES
* ========================================================
*/
const claimedBadges =
badgeDocuments.filter(
badge =>
Boolean(
badge.ownerId
)
).length;
return {
statDocuments:
statDocuments.length,
achievementDocuments:
achievementDocuments.length,
activeAchievements:
activeDefinitions.length,
badgeDocuments:
badgeDocuments.length,
claimedBadges,
orphanAchievementDocs,
issueCount:
issues.length,
issues
};
}
function isValidDateKey(
value
) {
if (
typeof value !==
"string"
) {
return false;
}
if (
!/^\d{4}-\d{2}-\d{2}$/.test(
value
)
) {
return false;
}
const date =
new Date(
`${value}T00:00:00Z`
);
if (
Number.isNaN(
date.getTime()
)
) {
return false;
}
return (
date
.toISOString()
.slice(
0,
10
)
===
value
);
}
function isValidWeekKey(
value
) {
return (
typeof value ===
"string"
&&
/^\d{4}-W(?:0[1-9]|[1-4]\d|5[0-3])$/.test(
value
)
);
}
module.exports = {
reconcileUserAchievements,
reconcileAllAchievements,
auditProfileData
};