1013 lines
16 KiB
JavaScript
1013 lines
16 KiB
JavaScript
const {
|
|
MessageFlags
|
|
} = require("discord.js");
|
|
|
|
const {
|
|
mClient
|
|
} = require("../index");
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* DATABASE
|
|
* ============================================================
|
|
*/
|
|
|
|
const DATABASE_NAME =
|
|
process.env.M_DB;
|
|
|
|
|
|
/*
|
|
* Persistent user-owned Nut balance.
|
|
*
|
|
* Existing schema is preserved:
|
|
*
|
|
* {
|
|
* userId: "123456789",
|
|
* nuts: 500
|
|
* }
|
|
*/
|
|
const NUT_COLLECTION =
|
|
"items_nuts";
|
|
|
|
|
|
/*
|
|
* Unified per-user cooldown state.
|
|
*
|
|
* Example:
|
|
*
|
|
* {
|
|
* userId: "123456789",
|
|
*
|
|
* nut: 1787000000,
|
|
* steal: 1787000000,
|
|
* heist: 1787000000,
|
|
* blackjack: 1787000000,
|
|
* roulette: 1787000000
|
|
* }
|
|
*/
|
|
const COOLDOWN_COLLECTION =
|
|
"items_cooldowns";
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* COLLECTION ACCESS
|
|
* ============================================================
|
|
*/
|
|
|
|
function getCollections() {
|
|
if (!DATABASE_NAME) {
|
|
throw new Error(
|
|
"M_DB is not configured in .env"
|
|
);
|
|
}
|
|
|
|
|
|
const db =
|
|
mClient.db(
|
|
DATABASE_NAME
|
|
);
|
|
|
|
|
|
return {
|
|
db,
|
|
|
|
nuts:
|
|
db.collection(
|
|
NUT_COLLECTION
|
|
),
|
|
|
|
cooldowns:
|
|
db.collection(
|
|
COOLDOWN_COLLECTION
|
|
)
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* UNIX TIME
|
|
* ============================================================
|
|
*/
|
|
|
|
function getUnixTime() {
|
|
return Math.floor(
|
|
Date.now() /
|
|
1000
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* GET NUT BALANCE
|
|
* ============================================================
|
|
*/
|
|
|
|
async function getNutBalance(
|
|
userId
|
|
) {
|
|
const {
|
|
nuts
|
|
} =
|
|
getCollections();
|
|
|
|
|
|
const document =
|
|
await nuts.findOne({
|
|
userId:
|
|
userId
|
|
});
|
|
|
|
|
|
return Math.max(
|
|
0,
|
|
document?.nuts ??
|
|
0
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* CHANGE NUT BALANCE
|
|
* ============================================================
|
|
*
|
|
* Positive:
|
|
*
|
|
* changeNuts(userId, 50)
|
|
*
|
|
* Negative:
|
|
*
|
|
* changeNuts(userId, -50)
|
|
*
|
|
* Negative changes are protected against dropping
|
|
* the balance below zero.
|
|
*/
|
|
|
|
async function changeNuts(
|
|
userId,
|
|
amount
|
|
) {
|
|
if (
|
|
!Number.isSafeInteger(
|
|
amount
|
|
)
|
|
) {
|
|
throw new Error(
|
|
"INVALID_NUT_AMOUNT"
|
|
);
|
|
}
|
|
|
|
|
|
if (
|
|
amount ===
|
|
0
|
|
) {
|
|
return getNutBalance(
|
|
userId
|
|
);
|
|
}
|
|
|
|
|
|
const {
|
|
nuts
|
|
} =
|
|
getCollections();
|
|
|
|
|
|
/*
|
|
* Add Nuts.
|
|
*/
|
|
|
|
if (
|
|
amount >
|
|
0
|
|
) {
|
|
await nuts.updateOne(
|
|
{
|
|
userId:
|
|
userId
|
|
},
|
|
{
|
|
$inc: {
|
|
nuts:
|
|
amount
|
|
},
|
|
|
|
$setOnInsert: {
|
|
userId:
|
|
userId
|
|
}
|
|
},
|
|
{
|
|
upsert:
|
|
true
|
|
}
|
|
);
|
|
|
|
|
|
return getNutBalance(
|
|
userId
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* Remove Nuts atomically.
|
|
*/
|
|
|
|
const removeAmount =
|
|
Math.abs(
|
|
amount
|
|
);
|
|
|
|
|
|
const result =
|
|
await nuts.updateOne(
|
|
{
|
|
userId:
|
|
userId,
|
|
|
|
nuts: {
|
|
$gte:
|
|
removeAmount
|
|
}
|
|
},
|
|
{
|
|
$inc: {
|
|
nuts:
|
|
-removeAmount
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
if (
|
|
result.modifiedCount !==
|
|
1
|
|
) {
|
|
throw new Error(
|
|
"INSUFFICIENT_NUTS"
|
|
);
|
|
}
|
|
|
|
|
|
return getNutBalance(
|
|
userId
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* TRANSFER NUTS
|
|
* ============================================================
|
|
*
|
|
* No Mongo transaction required.
|
|
*
|
|
* Source debit is atomic.
|
|
*
|
|
* If destination credit fails, a best-effort
|
|
* compensating credit is sent back to the source.
|
|
*/
|
|
|
|
async function transferNuts(
|
|
fromuserId,
|
|
touserId,
|
|
amount
|
|
) {
|
|
if (
|
|
fromuserId ===
|
|
touserId
|
|
) {
|
|
throw new Error(
|
|
"SAME_ACCOUNT"
|
|
);
|
|
}
|
|
|
|
|
|
if (
|
|
!Number.isSafeInteger(
|
|
amount
|
|
)
|
|
||
|
|
amount <=
|
|
0
|
|
) {
|
|
throw new Error(
|
|
"INVALID_NUT_AMOUNT"
|
|
);
|
|
}
|
|
|
|
|
|
const {
|
|
nuts
|
|
} =
|
|
getCollections();
|
|
|
|
|
|
/*
|
|
* Atomic source debit.
|
|
*/
|
|
|
|
const debit =
|
|
await nuts.updateOne(
|
|
{
|
|
userId:
|
|
fromuserId,
|
|
|
|
nuts: {
|
|
$gte:
|
|
amount
|
|
}
|
|
},
|
|
{
|
|
$inc: {
|
|
nuts:
|
|
-amount
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
if (
|
|
debit.modifiedCount !==
|
|
1
|
|
) {
|
|
throw new Error(
|
|
"INSUFFICIENT_NUTS"
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* Destination credit.
|
|
*/
|
|
|
|
try {
|
|
await nuts.updateOne(
|
|
{
|
|
userId:
|
|
touserId
|
|
},
|
|
{
|
|
$inc: {
|
|
nuts:
|
|
amount
|
|
},
|
|
|
|
$setOnInsert: {
|
|
userId:
|
|
touserId
|
|
}
|
|
},
|
|
{
|
|
upsert:
|
|
true
|
|
}
|
|
);
|
|
|
|
} catch (error) {
|
|
/*
|
|
* Best-effort compensation.
|
|
*/
|
|
|
|
try {
|
|
await nuts.updateOne(
|
|
{
|
|
userId:
|
|
fromuserId
|
|
},
|
|
{
|
|
$inc: {
|
|
nuts:
|
|
amount
|
|
},
|
|
|
|
$setOnInsert: {
|
|
userId:
|
|
fromuserId
|
|
}
|
|
},
|
|
{
|
|
upsert:
|
|
true
|
|
}
|
|
);
|
|
|
|
} catch (refundError) {
|
|
console.error(
|
|
"CRITICAL: Nut transfer compensation failed:",
|
|
{
|
|
fromuserId,
|
|
touserId,
|
|
amount,
|
|
refundError
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
throw error;
|
|
}
|
|
|
|
|
|
return {
|
|
from:
|
|
fromuserId,
|
|
|
|
to:
|
|
touserId,
|
|
|
|
amount
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* GET COOLDOWN
|
|
* ============================================================
|
|
*
|
|
* Returns the Unix timestamp at which the named
|
|
* cooldown expires.
|
|
*
|
|
* Returns 0 if it does not exist.
|
|
*/
|
|
|
|
async function getCooldown(
|
|
userId,
|
|
cooldownName
|
|
) {
|
|
validateCooldownName(
|
|
cooldownName
|
|
);
|
|
|
|
|
|
const {
|
|
cooldowns
|
|
} =
|
|
getCollections();
|
|
|
|
|
|
const document =
|
|
await cooldowns.findOne(
|
|
{
|
|
userId
|
|
},
|
|
{
|
|
projection: {
|
|
[cooldownName]:
|
|
1
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
return Number(
|
|
document?.[
|
|
cooldownName
|
|
]
|
|
??
|
|
0
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* SET COOLDOWN
|
|
* ============================================================
|
|
*/
|
|
|
|
async function setCooldown(
|
|
userId,
|
|
cooldownName,
|
|
durationSeconds
|
|
) {
|
|
validateCooldownName(
|
|
cooldownName
|
|
);
|
|
|
|
|
|
validateCooldownDuration(
|
|
durationSeconds
|
|
);
|
|
|
|
|
|
const {
|
|
cooldowns
|
|
} =
|
|
getCollections();
|
|
|
|
|
|
const next =
|
|
getUnixTime() +
|
|
durationSeconds;
|
|
|
|
|
|
await cooldowns.updateOne(
|
|
{
|
|
userId
|
|
},
|
|
{
|
|
$set: {
|
|
[cooldownName]:
|
|
next
|
|
},
|
|
|
|
$setOnInsert: {
|
|
userId
|
|
}
|
|
},
|
|
{
|
|
upsert:
|
|
true
|
|
}
|
|
);
|
|
|
|
|
|
return next;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* CLAIM COOLDOWN
|
|
* ============================================================
|
|
*
|
|
* Atomic helper for actions where double-clicking must
|
|
* not allow the same action to resolve twice.
|
|
*
|
|
* Returns:
|
|
*
|
|
* {
|
|
* claimed: true,
|
|
* next: 1787000000
|
|
* }
|
|
*
|
|
* or:
|
|
*
|
|
* {
|
|
* claimed: false,
|
|
* next: 1787000000
|
|
* }
|
|
*/
|
|
|
|
async function claimCooldown(
|
|
userId,
|
|
cooldownName,
|
|
durationSeconds
|
|
) {
|
|
validateCooldownName(
|
|
cooldownName
|
|
);
|
|
|
|
|
|
validateCooldownDuration(
|
|
durationSeconds
|
|
);
|
|
|
|
|
|
const {
|
|
cooldowns
|
|
} =
|
|
getCollections();
|
|
|
|
|
|
const now =
|
|
getUnixTime();
|
|
|
|
|
|
const next =
|
|
now +
|
|
durationSeconds;
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* ENSURE USER DOCUMENT EXISTS
|
|
* ========================================================
|
|
*
|
|
* This creates:
|
|
*
|
|
* {
|
|
* userId: "..."
|
|
* }
|
|
*
|
|
* only when the user does not already have a
|
|
* cooldown document.
|
|
*/
|
|
|
|
await cooldowns.updateOne(
|
|
{
|
|
userId
|
|
},
|
|
{
|
|
$setOnInsert: {
|
|
userId
|
|
}
|
|
},
|
|
{
|
|
upsert:
|
|
true
|
|
}
|
|
);
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* ATOMIC COOLDOWN CLAIM
|
|
* ========================================================
|
|
*
|
|
* Mongo will only update the document when:
|
|
*
|
|
* - the cooldown does not exist
|
|
* OR
|
|
* - the cooldown has expired
|
|
*/
|
|
|
|
const result =
|
|
await cooldowns.updateOne(
|
|
{
|
|
userId,
|
|
|
|
$or: [
|
|
{
|
|
[cooldownName]: {
|
|
$exists:
|
|
false
|
|
}
|
|
},
|
|
|
|
{
|
|
[cooldownName]: {
|
|
$lte:
|
|
now
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
$set: {
|
|
[cooldownName]:
|
|
next
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
/*
|
|
* We successfully claimed it.
|
|
*/
|
|
|
|
if (
|
|
result.modifiedCount ===
|
|
1
|
|
) {
|
|
return {
|
|
claimed:
|
|
true,
|
|
|
|
next
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* COOLDOWN IS ACTIVE
|
|
* ========================================================
|
|
*/
|
|
|
|
const current =
|
|
await getCooldown(
|
|
userId,
|
|
cooldownName
|
|
);
|
|
|
|
|
|
return {
|
|
claimed:
|
|
false,
|
|
|
|
next:
|
|
current
|
|
};
|
|
}
|
|
|
|
/*
|
|
* ============================================================
|
|
* CLEAR COOLDOWN
|
|
* ============================================================
|
|
*/
|
|
|
|
async function clearCooldown(
|
|
userId,
|
|
cooldownName
|
|
) {
|
|
validateCooldownName(
|
|
cooldownName
|
|
);
|
|
|
|
|
|
const {
|
|
cooldowns
|
|
} =
|
|
getCollections();
|
|
|
|
|
|
await cooldowns.updateOne(
|
|
{
|
|
userId
|
|
},
|
|
{
|
|
$unset: {
|
|
[cooldownName]:
|
|
""
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* TEMPORARY EPHEMERAL REPLY
|
|
* ============================================================
|
|
*/
|
|
|
|
async function temporaryEphemeralReply(
|
|
interaction,
|
|
options,
|
|
deleteAfter =
|
|
6000
|
|
) {
|
|
if (
|
|
interaction.replied ||
|
|
interaction.deferred
|
|
) {
|
|
return temporaryEphemeralFollowUp(
|
|
interaction,
|
|
options,
|
|
deleteAfter
|
|
);
|
|
}
|
|
|
|
|
|
await interaction.reply({
|
|
...options,
|
|
|
|
flags:
|
|
MessageFlags.Ephemeral
|
|
});
|
|
|
|
|
|
if (
|
|
deleteAfter >
|
|
0
|
|
) {
|
|
setTimeout(
|
|
async () => {
|
|
try {
|
|
await interaction.deleteReply();
|
|
} catch {}
|
|
},
|
|
deleteAfter
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* TEMPORARY EPHEMERAL FOLLOW-UP
|
|
* ============================================================
|
|
*/
|
|
|
|
async function temporaryEphemeralFollowUp(
|
|
interaction,
|
|
options,
|
|
deleteAfter =
|
|
6000
|
|
) {
|
|
const message =
|
|
await interaction.followUp({
|
|
...options,
|
|
|
|
flags:
|
|
MessageFlags.Ephemeral,
|
|
|
|
fetchReply:
|
|
true
|
|
});
|
|
|
|
|
|
if (
|
|
deleteAfter >
|
|
0
|
|
) {
|
|
setTimeout(
|
|
async () => {
|
|
try {
|
|
await interaction.webhook.deleteMessage(
|
|
message.id
|
|
);
|
|
} catch {}
|
|
},
|
|
deleteAfter
|
|
);
|
|
}
|
|
|
|
|
|
return message;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* RANDOM INTEGER
|
|
* ============================================================
|
|
*
|
|
* Inclusive.
|
|
*/
|
|
|
|
function randomInteger(
|
|
min,
|
|
max
|
|
) {
|
|
const minimum =
|
|
Math.ceil(
|
|
min
|
|
);
|
|
|
|
|
|
const maximum =
|
|
Math.floor(
|
|
max
|
|
);
|
|
|
|
|
|
return Math.floor(
|
|
Math.random() *
|
|
(
|
|
maximum -
|
|
minimum +
|
|
1
|
|
)
|
|
)
|
|
+
|
|
minimum;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* RANDOM FLOAT
|
|
* ============================================================
|
|
*/
|
|
|
|
function randomFloat(
|
|
min,
|
|
max
|
|
) {
|
|
return (
|
|
Math.random() *
|
|
(
|
|
max -
|
|
min
|
|
)
|
|
)
|
|
+
|
|
min;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* RANDOM ITEM
|
|
* ============================================================
|
|
*/
|
|
|
|
function randomItem(
|
|
items
|
|
) {
|
|
if (
|
|
!Array.isArray(
|
|
items
|
|
)
|
|
||
|
|
items.length ===
|
|
0
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
|
|
return items[
|
|
Math.floor(
|
|
Math.random() *
|
|
items.length
|
|
)
|
|
];
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* FORMAT PERCENTAGE
|
|
* ============================================================
|
|
*/
|
|
|
|
function formatPercentage(
|
|
value,
|
|
decimals =
|
|
1
|
|
) {
|
|
return (
|
|
(
|
|
value *
|
|
100
|
|
)
|
|
.toFixed(
|
|
decimals
|
|
)
|
|
+
|
|
"%"
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* VALIDATION
|
|
* ============================================================
|
|
*/
|
|
|
|
function validateCooldownName(
|
|
cooldownName
|
|
) {
|
|
if (
|
|
typeof cooldownName !==
|
|
"string"
|
|
||
|
|
!/^[A-Za-z0-9_]+$/.test(
|
|
cooldownName
|
|
)
|
|
) {
|
|
throw new Error(
|
|
"INVALID_COOLDOWN_NAME"
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
function validateCooldownDuration(
|
|
durationSeconds
|
|
) {
|
|
if (
|
|
!Number.isSafeInteger(
|
|
durationSeconds
|
|
)
|
|
||
|
|
durationSeconds <
|
|
0
|
|
) {
|
|
throw new Error(
|
|
"INVALID_COOLDOWN_DURATION"
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* EXPORTS
|
|
* ============================================================
|
|
*/
|
|
|
|
module.exports = {
|
|
getCollections,
|
|
|
|
getNutBalance,
|
|
changeNuts,
|
|
transferNuts,
|
|
|
|
getCooldown,
|
|
setCooldown,
|
|
claimCooldown,
|
|
clearCooldown,
|
|
|
|
temporaryEphemeralReply,
|
|
temporaryEphemeralFollowUp,
|
|
|
|
getUnixTime,
|
|
|
|
randomInteger,
|
|
randomFloat,
|
|
randomItem,
|
|
formatPercentage
|
|
}; |