Files
ShaiBot/utils/pokerProfile.js
2026-09-08 21:49:43 +02:00

1289 lines
23 KiB
JavaScript

const {
EmbedBuilder
} = require("discord.js");
const {
getProfileCollections,
getDefaultStats,
trackProfileEvent,
trackGameActivity
} = require("./profileSystem");
const {
getUnixTime
} = require("./cookieEconomy");
const POKER_GAME_KEY =
"poker";
const PROFILE_HAND_HISTORY_LIMIT =
100;
const PROFILE_ACTION_HISTORY_LIMIT =
250;
const ACTION_STAT_PATHS = {
fold:
"games.poker.folds",
check:
"games.poker.checks",
call:
"games.poker.calls",
bet:
"games.poker.bets",
raise:
"games.poker.raises",
all_in:
"games.poker.allInActions"
};
const HAND_RANK_STAT_PATHS = {
"High Card":
"games.poker.handRanks.highCard",
Pair:
"games.poker.handRanks.pair",
"Two Pair":
"games.poker.handRanks.twoPair",
"Three of a Kind":
"games.poker.handRanks.threeOfAKind",
Straight:
"games.poker.handRanks.straight",
Flush:
"games.poker.handRanks.flush",
"Full House":
"games.poker.handRanks.fullHouse",
"Four of a Kind":
"games.poker.handRanks.fourOfAKind",
"Straight Flush":
"games.poker.handRanks.straightFlush",
"Royal Flush":
"games.poker.handRanks.royalFlush"
};
async function ensurePokerProfileDocument(
userId,
guildId
) {
const {
stats
} =
getProfileCollections();
await stats.updateOne(
{
userId,
guildId
},
{
$setOnInsert:
getDefaultStats(
userId,
guildId
)
},
{
upsert: true
}
);
}
function getPokerHandId(
game
) {
if (
!game?.gameId
||
!Number.isSafeInteger(
Number(
game?.data?.handNumber
)
)
) {
return null;
}
return (
`${game.gameId}:hand:${Number(game.data.handNumber)}`
);
}
function getPokerActionId(
game
) {
if (
!game?.gameId
||
!Number.isSafeInteger(
Number(game.version)
)
) {
return null;
}
return (
`${game.gameId}:action:v${Number(game.version)}`
);
}
function normalizeActionType(
action
) {
return String(
action?.type ?? ""
).toLowerCase();
}
function normalizeCardRank(
card
) {
return String(
card?.rank ?? ""
).toUpperCase();
}
function isPocketAces(
holeCards
) {
return (
Array.isArray(holeCards)
&&
holeCards.length === 2
&&
holeCards.every(
card =>
normalizeCardRank(card) ===
"A"
)
);
}
function isSevenDeuce(
holeCards
) {
if (
!Array.isArray(holeCards)
||
holeCards.length !== 2
) {
return false;
}
const ranks =
holeCards
.map(
normalizeCardRank
)
.sort();
return (
ranks[0] === "2"
&&
ranks[1] === "7"
);
}
function getPotSizeFromResult(
result
) {
return Object.values(
result?.payouts ?? {}
).reduce(
(sum, value) =>
sum +
Number(value ?? 0),
0
);
}
function getPlayerOutcome(
game,
handPlayer
) {
const hand =
game?.data?.hand;
const result =
hand?.result ??
game?.data?.lastHandResult;
const winnerSeats =
Array.isArray(
result?.winnerSeats
)
? result.winnerSeats
: [];
const isWinner =
winnerSeats.includes(
handPlayer.seat
);
const isSplitWinner =
isWinner
&&
winnerSeats.length > 1;
const finalStack =
Number(
handPlayer.stack ?? 0
);
const startingStack =
Number(
handPlayer.startingStack ?? 0
);
const profit =
finalStack -
startingStack;
const potSize =
getPotSizeFromResult(
result
);
let resultName =
"loss";
if (isSplitWinner) {
resultName =
"push";
}
else if (isWinner) {
resultName =
"win";
}
const reachedShowdown =
result?.type ===
"showdown"
&&
handPlayer.folded !==
true;
return {
result,
winnerSeats,
isWinner,
isSplitWinner,
resultName,
profit,
potSize,
reachedShowdown,
allIn:
handPlayer.allIn ===
true,
handRank:
handPlayer.handRank ??
null,
pocketAces:
isPocketAces(
handPlayer.holeCards
),
sevenDeuce:
isSevenDeuce(
handPlayer.holeCards
),
foldWin:
isWinner
&&
result?.type ===
"fold",
fullTableWin:
isWinner
&&
Array.isArray(hand?.players)
&&
hand.players.length === 6
};
}
async function recordPokerActionProfile(
game,
userId,
action
) {
const guildId =
game?.guildId;
const actionType =
normalizeActionType(
action
);
const statPath =
ACTION_STAT_PATHS[
actionType
];
const actionId =
getPokerActionId(
game
);
if (
!guildId
||
!userId
||
!statPath
||
!actionId
) {
return false;
}
await ensurePokerProfileDocument(
String(userId),
String(guildId)
);
const {
stats
} =
getProfileCollections();
const result =
await stats.updateOne(
{
userId:
String(userId),
guildId:
String(guildId),
"games.poker.processedActions": {
$ne:
actionId
}
},
{
$inc: {
[statPath]:
1
},
$push: {
"games.poker.processedActions": {
$each: [
actionId
],
$slice:
-PROFILE_ACTION_HISTORY_LIMIT
}
}
}
);
return (
result.modifiedCount ===
1
);
}
async function repairProfileBestStreaks(
userId,
guildId
) {
const {
stats
} =
getProfileCollections();
const current =
await stats.findOne(
{
userId,
guildId
},
{
projection: {
"games.poker.currentWinStreak":
1,
"games.poker.currentLossStreak":
1,
"streaks.currentWin":
1,
"streaks.currentLoss":
1
}
}
);
await stats.updateOne(
{
userId,
guildId
},
{
$max: {
"games.poker.bestWinStreak":
Number(
current
?.games
?.poker
?.currentWinStreak ??
0
),
"games.poker.bestLossStreak":
Number(
current
?.games
?.poker
?.currentLossStreak ??
0
),
"streaks.bestWin":
Number(
current
?.streaks
?.currentWin ??
0
),
"streaks.bestLoss":
Number(
current
?.streaks
?.currentLoss ??
0
)
}
}
);
}
async function recordPokerHandForPlayer(
game,
handPlayer
) {
const guildId =
String(
game.guildId
);
const userId =
String(
handPlayer.userId
);
const handId =
getPokerHandId(
game
);
if (!handId) {
return {
processed: false,
userId,
reason:
"INVALID_HAND_ID"
};
}
await ensurePokerProfileDocument(
userId,
guildId
);
const outcome =
getPlayerOutcome(
game,
handPlayer
);
const inc = {
"games.total.played":
1,
"games.poker.played":
1,
"games.poker.handsPlayed":
1
};
if (
outcome.resultName ===
"win"
) {
inc[
"games.total.won"
] = 1;
inc[
"games.poker.won"
] = 1;
inc[
"games.poker.handsWon"
] = 1;
}
else if (
outcome.resultName ===
"loss"
) {
inc[
"games.total.lost"
] = 1;
inc[
"games.poker.lost"
] = 1;
inc[
"games.poker.handsLost"
] = 1;
}
else {
inc[
"games.total.pushed"
] = 1;
inc[
"games.poker.pushed"
] = 1;
inc[
"games.poker.handsPushed"
] = 1;
}
const set = {};
if (
outcome.resultName ===
"win"
) {
inc[
"games.poker.currentWinStreak"
] = 1;
inc[
"streaks.currentWin"
] = 1;
set[
"games.poker.currentLossStreak"
] = 0;
set[
"streaks.currentLoss"
] = 0;
}
else if (
outcome.resultName ===
"loss"
) {
inc[
"games.poker.currentLossStreak"
] = 1;
inc[
"streaks.currentLoss"
] = 1;
set[
"games.poker.currentWinStreak"
] = 0;
set[
"streaks.currentWin"
] = 0;
}
if (
outcome.profit > 0
) {
inc[
"games.poker.cookiesWon"
] =
outcome.profit;
inc[
"economy.cookiesObtainedTotal"
] =
outcome.profit;
}
else if (
outcome.profit < 0
) {
const loss =
Math.abs(
outcome.profit
);
inc[
"games.poker.cookiesLost"
] =
loss;
inc[
"economy.cookiesLostTotal"
] =
loss;
}
if (
outcome.reachedShowdown
) {
inc[
"games.poker.showdowns"
] = 1;
if (outcome.isWinner) {
inc[
"games.poker.showdownWins"
] = 1;
}
}
if (
outcome.allIn
) {
inc[
"games.poker.allIns"
] = 1;
if (outcome.isWinner) {
inc[
"games.poker.allInWins"
] = 1;
}
}
if (
outcome.foldWin
) {
inc[
"games.poker.foldWins"
] = 1;
}
if (
outcome.fullTableWin
) {
inc[
"games.poker.fullTableWins"
] = 1;
}
if (
outcome.isWinner
&&
outcome.pocketAces
) {
inc[
"games.poker.pocketAcesWins"
] = 1;
}
if (
outcome.isWinner
&&
outcome.sevenDeuce
) {
inc[
"games.poker.sevenDeuceWins"
] = 1;
}
const handRankPath =
HAND_RANK_STAT_PATHS[
outcome.handRank?.name
];
if (
outcome.reachedShowdown
&&
handRankPath
) {
inc[
handRankPath
] = 1;
}
const {
stats
} =
getProfileCollections();
const update = {
$inc:
inc,
...(Object.keys(set).length
? {
$set:
set
}
: {}),
$push: {
"games.poker.processedHands": {
$each: [
handId
],
$slice:
-PROFILE_HAND_HISTORY_LIMIT
}
}
};
if (outcome.isWinner) {
update.$max = {
"games.poker.largestPotWon":
outcome.potSize
};
}
const write =
await stats.updateOne(
{
userId,
guildId,
"games.poker.processedHands": {
$ne:
handId
}
},
update
);
if (
write.modifiedCount !==
1
) {
await repairProfileBestStreaks(
userId,
guildId
);
return {
processed: false,
userId,
handId,
outcome
};
}
await repairProfileBestStreaks(
userId,
guildId
);
try {
await trackGameActivity(
userId,
guildId
);
}
catch (error) {
console.error(
`[PokerProfile] Activity tracking failed for ${userId}:`,
error
);
}
return {
processed: true,
userId,
handId,
outcome
};
}
async function recordPokerHandProfiles(
game
) {
const hand =
game?.data?.hand;
if (
!hand
||
hand.state !==
"PAYOUT"
||
!hand.result
||
!Array.isArray(
hand.players
)
) {
return [];
}
const results = [];
for (
const handPlayer
of hand.players
) {
try {
results.push(
await recordPokerHandForPlayer(
game,
handPlayer
)
);
}
catch (error) {
console.error(
`[PokerProfile] Failed to record hand ${getPokerHandId(game) ?? "unknown"} for ${handPlayer.userId}:`,
error
);
results.push({
processed: false,
userId:
String(
handPlayer.userId
),
error
});
}
}
return results;
}
async function claimPokerBadge(
context,
userId,
badgeId,
metadata = null
) {
const guildId =
context?.guildId;
if (!guildId) {
return false;
}
const {
badges
} =
getProfileCollections();
const set = {
ownerId:
String(userId),
unlockedAt:
getUnixTime()
};
if (
metadata
&&
typeof metadata ===
"object"
) {
set.metadata =
metadata;
}
const badge =
await badges.findOneAndUpdate(
{
guildId:
String(guildId),
badgeId,
ownerId:
null
},
{
$set:
set
},
{
returnDocument:
"after"
}
);
if (
!badge
||
badge.ownerId !==
String(userId)
) {
return false;
}
if (
!context.channel
) {
return true;
}
try {
await context.channel.send({
embeds: [
new EmbedBuilder()
.setTitle(
"🏅 Unique Badge Claimed!"
)
.setDescription(
[
`${badge.emoji ?? "♠️"} **${badge.name ?? badge.badgeId}**`,
"",
`<@${userId}> claimed a guild-unique Poker badge!`,
"",
badge.description ??
"A unique Poker badge has been claimed.",
"",
"*This badge can no longer be earned by another player.*"
].join("\n")
)
.setFooter({
text:
`badge:${badge.badgeId}`
})
.setTimestamp()
]
});
}
catch (error) {
console.error(
`[PokerBadge] Failed to announce ${badgeId} for ${userId}:`,
error
);
}
return true;
}
async function evaluatePokerBadges(
context,
userId,
outcome
) {
const {
stats
} =
getProfileCollections();
const document =
await stats.findOne({
userId:
String(userId),
guildId:
String(context.guildId)
});
const poker =
document?.games?.poker ?? {};
if (
Number(
poker.won ?? 0
) >= 1
) {
await claimPokerBadge(
context,
userId,
"first_poker_win"
);
}
if (
Number(
poker.allInWins ?? 0
) >= 1
) {
await claimPokerBadge(
context,
userId,
"first_poker_allin_win"
);
}
if (
Number(
poker.pocketAcesWins ?? 0
) >= 1
) {
await claimPokerBadge(
context,
userId,
"first_poker_pocket_aces_win"
);
}
if (
Number(
poker.handRanks
?.royalFlush ?? 0
) >= 1
) {
await claimPokerBadge(
context,
userId,
"first_poker_royal_flush"
);
}
if (
Number(
poker.fullTableWins ?? 0
) >= 1
) {
await claimPokerBadge(
context,
userId,
"first_poker_full_table_win"
);
}
if (
Number(
poker.largestPotWon ?? 0
) >= 5000
) {
await claimPokerBadge(
context,
userId,
"first_poker_5000_pot",
{
pot:
outcome?.potSize ??
poker.largestPotWon
}
);
}
}
async function claimPokerAnnouncement(
game,
userId
) {
const handId =
getPokerHandId(
game
);
if (!handId) {
return false;
}
const {
stats
} =
getProfileCollections();
const result =
await stats.updateOne(
{
userId:
String(userId),
guildId:
String(game.guildId),
"games.poker.announcedHands": {
$ne:
handId
}
},
{
$push: {
"games.poker.announcedHands": {
$each: [
handId
],
$slice:
-PROFILE_HAND_HISTORY_LIMIT
}
}
}
);
return (
result.modifiedCount ===
1
);
}
async function announcePokerHandProgress(
context,
game
) {
if (
!context?.guildId
||
!game?.data?.hand
||
game.data.hand.state !==
"PAYOUT"
||
!Array.isArray(
game.data.hand.players
)
) {
return [];
}
const results = [];
for (
const handPlayer
of game.data.hand.players
) {
const userId =
String(
handPlayer.userId
);
try {
const claimed =
await claimPokerAnnouncement(
game,
userId
);
if (!claimed) {
continue;
}
const outcome =
getPlayerOutcome(
game,
handPlayer
);
await trackProfileEvent(
context,
userId,
{
type:
"profile_refresh",
source:
"poker",
game:
POKER_GAME_KEY,
result:
outcome.resultName,
profit:
outcome.profit,
allIn:
outcome.allIn,
handRank:
outcome.handRank?.name ??
null,
pocketAces:
outcome.pocketAces,
sevenDeuce:
outcome.sevenDeuce,
foldWin:
outcome.foldWin,
fullTableWin:
outcome.fullTableWin,
pot:
outcome.potSize
}
);
await evaluatePokerBadges(
context,
userId,
outcome
);
results.push({
userId,
announced: true
});
}
catch (error) {
console.error(
`[PokerProfile] Failed to refresh achievements/badges for ${userId}:`,
error
);
results.push({
userId,
announced: false,
error
});
}
}
return results;
}
async function createPokerProfileContext(
client,
game
) {
if (
!client
||
!game?.guildId
) {
return null;
}
let channel =
null;
if (
game.channelId
) {
try {
channel =
await client.channels.fetch(
game.channelId
);
}
catch {}
}
return {
guildId:
String(game.guildId),
channel,
replied:
false,
deferred:
false
};
}
module.exports = {
ensurePokerProfileDocument,
getPokerHandId,
getPokerActionId,
getPlayerOutcome,
recordPokerActionProfile,
recordPokerHandProfiles,
announcePokerHandProgress,
createPokerProfileContext,
claimPokerBadge,
evaluatePokerBadges
};