Files
Arthonor-Neo/utils/nutEconomy.js
T

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
};