2800 lines
59 KiB
JavaScript
2800 lines
59 KiB
JavaScript
const {
|
|
startHand,
|
|
applyAction,
|
|
getLegalActions,
|
|
getPrivateCards,
|
|
nextSeatAfter,
|
|
STREETS,
|
|
PokerEngineError
|
|
} = require("./pokerEngine");
|
|
|
|
/*
|
|
* ============================================================
|
|
* CONFIG
|
|
* ============================================================
|
|
*/
|
|
|
|
const SPECIAL_GAME_COLLECTION =
|
|
"items_special_games";
|
|
|
|
const POKER_GAME_TYPE =
|
|
"poker";
|
|
|
|
const POKER_MEMBERSHIP_TYPE =
|
|
"poker_membership";
|
|
|
|
const POKER_MIN_PLAYERS =
|
|
2;
|
|
|
|
const POKER_MAX_PLAYERS =
|
|
6;
|
|
|
|
const POKER_DEFAULT_SMALL_BLIND =
|
|
100;
|
|
|
|
const POKER_DEFAULT_BIG_BLIND =
|
|
200;
|
|
|
|
const POKER_DEFAULT_BUY_IN =
|
|
1000;
|
|
|
|
const POKER_TURN_TIMEOUT =
|
|
30;
|
|
|
|
const POKER_LOBBY_TIMEOUT =
|
|
15 * 60;
|
|
|
|
const POKER_MEMBERSHIP_RECOVERY_AGE =
|
|
60;
|
|
|
|
const POKER_ACTIVE_STATES = [
|
|
"PREFLOP",
|
|
"FLOP",
|
|
"TURN",
|
|
"RIVER"
|
|
];
|
|
|
|
const POKER_OPEN_STATES = [
|
|
"WAITING",
|
|
"NEXT_HAND"
|
|
];
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* ERROR TYPE
|
|
* ============================================================
|
|
*/
|
|
|
|
class PokerTableError extends Error {
|
|
constructor(
|
|
code,
|
|
message
|
|
) {
|
|
super(message);
|
|
|
|
this.name =
|
|
"PokerTableError";
|
|
|
|
this.code =
|
|
code;
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* GENERIC HELPERS
|
|
* ============================================================
|
|
*/
|
|
|
|
function clone(
|
|
value
|
|
) {
|
|
return value === undefined
|
|
? undefined
|
|
: structuredClone(
|
|
value
|
|
);
|
|
}
|
|
|
|
function nowSeconds() {
|
|
return Math.floor(
|
|
Date.now() /
|
|
1000
|
|
);
|
|
}
|
|
|
|
function createId(
|
|
prefix = "poker"
|
|
) {
|
|
const random =
|
|
Math.random()
|
|
.toString(36)
|
|
.slice(2, 10);
|
|
|
|
return [
|
|
prefix,
|
|
Date.now().toString(36),
|
|
random
|
|
].join("_");
|
|
}
|
|
|
|
function membershipGameId(
|
|
guildId,
|
|
userId
|
|
) {
|
|
return `poker_membership:${guildId}:${userId}`;
|
|
}
|
|
|
|
function buyInOperationId(
|
|
gameId,
|
|
sessionId
|
|
) {
|
|
return `poker_buyin:${gameId}:${sessionId}`;
|
|
}
|
|
|
|
function buyInRefundOperationId(
|
|
gameId,
|
|
sessionId
|
|
) {
|
|
return `poker_buyin_refund:${gameId}:${sessionId}`;
|
|
}
|
|
|
|
function cashOutOperationId(
|
|
gameId,
|
|
sessionId
|
|
) {
|
|
return `poker_cashout:${gameId}:${sessionId}`;
|
|
}
|
|
|
|
function validatePositiveInteger(
|
|
value,
|
|
code,
|
|
message
|
|
) {
|
|
if (
|
|
!Number.isSafeInteger(
|
|
value
|
|
)
|
|
||
|
|
value <= 0
|
|
) {
|
|
throw new PokerTableError(
|
|
code,
|
|
message
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateTableConfig({
|
|
buyIn,
|
|
smallBlind,
|
|
bigBlind,
|
|
maxPlayers
|
|
}) {
|
|
validatePositiveInteger(
|
|
buyIn,
|
|
"INVALID_BUY_IN",
|
|
"Poker buy-in must be a positive integer."
|
|
);
|
|
|
|
validatePositiveInteger(
|
|
smallBlind,
|
|
"INVALID_SMALL_BLIND",
|
|
"Small blind must be a positive integer."
|
|
);
|
|
|
|
validatePositiveInteger(
|
|
bigBlind,
|
|
"INVALID_BIG_BLIND",
|
|
"Big blind must be a positive integer."
|
|
);
|
|
|
|
if (
|
|
smallBlind >=
|
|
bigBlind
|
|
) {
|
|
throw new PokerTableError(
|
|
"INVALID_BLINDS",
|
|
"Small blind must be lower than the big blind."
|
|
);
|
|
}
|
|
|
|
if (
|
|
buyIn <
|
|
bigBlind
|
|
) {
|
|
throw new PokerTableError(
|
|
"BUY_IN_TOO_SMALL",
|
|
"Poker buy-in must be at least one big blind."
|
|
);
|
|
}
|
|
|
|
if (
|
|
!Number.isSafeInteger(
|
|
maxPlayers
|
|
)
|
|
||
|
|
maxPlayers <
|
|
POKER_MIN_PLAYERS
|
|
||
|
|
maxPlayers >
|
|
POKER_MAX_PLAYERS
|
|
) {
|
|
throw new PokerTableError(
|
|
"INVALID_MAX_PLAYERS",
|
|
"Poker tables support between 2 and 6 players."
|
|
);
|
|
}
|
|
}
|
|
|
|
function isHandActive(
|
|
game
|
|
) {
|
|
return POKER_ACTIVE_STATES.includes(
|
|
game?.state
|
|
);
|
|
}
|
|
|
|
function isTableOpenForJoin(
|
|
game
|
|
) {
|
|
return POKER_OPEN_STATES.includes(
|
|
game?.state
|
|
);
|
|
}
|
|
|
|
function getPlayer(
|
|
game,
|
|
userId
|
|
) {
|
|
return game?.data?.players?.find(
|
|
player =>
|
|
player.userId ===
|
|
String(userId)
|
|
) ?? null;
|
|
}
|
|
|
|
function getPlayerBySession(
|
|
game,
|
|
sessionId
|
|
) {
|
|
return game?.data?.players?.find(
|
|
player =>
|
|
player.sessionId ===
|
|
sessionId
|
|
) ?? null;
|
|
}
|
|
|
|
function nextAvailableSeat(
|
|
players,
|
|
maxPlayers
|
|
) {
|
|
const occupied =
|
|
new Set(
|
|
players.map(
|
|
player =>
|
|
player.seat
|
|
)
|
|
);
|
|
|
|
for (
|
|
let seat = 0;
|
|
seat < maxPlayers;
|
|
seat++
|
|
) {
|
|
if (
|
|
!occupied.has(
|
|
seat
|
|
)
|
|
) {
|
|
return seat;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function nextDealerSeat(
|
|
players,
|
|
previousDealerSeat
|
|
) {
|
|
if (
|
|
players.length === 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
previousDealerSeat ===
|
|
null
|
|
||
|
|
previousDealerSeat ===
|
|
undefined
|
|
) {
|
|
return players
|
|
.map(
|
|
player =>
|
|
player.seat
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
a - b
|
|
)[0];
|
|
}
|
|
|
|
return nextSeatAfter(
|
|
players,
|
|
previousDealerSeat,
|
|
() => true
|
|
);
|
|
}
|
|
|
|
function sanitizeGameForCaller(
|
|
game,
|
|
userId = null
|
|
) {
|
|
if (!game) {
|
|
return null;
|
|
}
|
|
|
|
const result =
|
|
clone(game);
|
|
|
|
if (
|
|
result.data?.hand
|
|
) {
|
|
for (
|
|
const player
|
|
of result.data.hand.players
|
|
) {
|
|
if (
|
|
String(player.userId) !==
|
|
String(userId)
|
|
) {
|
|
player.holeCards = [];
|
|
}
|
|
}
|
|
|
|
result.data.hand.deck = [];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function gameHasUser(
|
|
game,
|
|
userId
|
|
) {
|
|
return Boolean(
|
|
getPlayer(
|
|
game,
|
|
userId
|
|
)
|
|
);
|
|
}
|
|
|
|
function activeEnginePlayers(
|
|
game
|
|
) {
|
|
return game.data.players
|
|
.filter(
|
|
player =>
|
|
player.status ===
|
|
"ACTIVE"
|
|
&&
|
|
player.stack >=
|
|
game.data.bigBlind
|
|
)
|
|
.map(
|
|
player => ({
|
|
userId:
|
|
player.userId,
|
|
seat:
|
|
player.seat,
|
|
stack:
|
|
player.stack
|
|
})
|
|
);
|
|
}
|
|
|
|
function applyTurnDeadline(
|
|
game,
|
|
now,
|
|
timeoutSeconds
|
|
) {
|
|
const hand =
|
|
game.data.hand;
|
|
|
|
if (
|
|
hand
|
|
&&
|
|
STREETS.includes(
|
|
hand.state
|
|
)
|
|
&&
|
|
hand.currentSeat !==
|
|
null
|
|
) {
|
|
game.data.turnStartedAt =
|
|
now;
|
|
|
|
game.data.turnExpiresAt =
|
|
now +
|
|
timeoutSeconds;
|
|
}
|
|
|
|
else {
|
|
game.data.turnStartedAt =
|
|
null;
|
|
|
|
game.data.turnExpiresAt =
|
|
null;
|
|
}
|
|
}
|
|
|
|
function syncCompletedHand(
|
|
game,
|
|
now
|
|
) {
|
|
const hand =
|
|
game.data.hand;
|
|
|
|
if (
|
|
!hand
|
|
||
|
|
hand.state !==
|
|
"PAYOUT"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
for (
|
|
const handPlayer
|
|
of hand.players
|
|
) {
|
|
const tablePlayer =
|
|
game.data.players.find(
|
|
player =>
|
|
player.userId ===
|
|
handPlayer.userId
|
|
);
|
|
|
|
if (!tablePlayer) {
|
|
continue;
|
|
}
|
|
|
|
tablePlayer.stack =
|
|
handPlayer.stack;
|
|
|
|
tablePlayer.status =
|
|
tablePlayer.stack >=
|
|
game.data.bigBlind
|
|
? "ACTIVE"
|
|
: "BUSTED";
|
|
}
|
|
|
|
game.data.lastHandResult =
|
|
clone(
|
|
hand.result
|
|
);
|
|
|
|
game.data.handCompletedAt =
|
|
now;
|
|
|
|
game.state =
|
|
"NEXT_HAND";
|
|
|
|
game.data.turnStartedAt =
|
|
null;
|
|
|
|
game.data.turnExpiresAt =
|
|
null;
|
|
|
|
return true;
|
|
}
|
|
|
|
function publicTableState(
|
|
game
|
|
) {
|
|
if (!game) {
|
|
return null;
|
|
}
|
|
|
|
const result = {
|
|
gameId:
|
|
game.gameId,
|
|
gameType:
|
|
game.gameType,
|
|
guildId:
|
|
game.guildId,
|
|
channelId:
|
|
game.channelId,
|
|
messageId:
|
|
game.messageId,
|
|
state:
|
|
game.state,
|
|
version:
|
|
game.version,
|
|
createdAt:
|
|
game.createdAt,
|
|
updatedAt:
|
|
game.updatedAt,
|
|
data: {
|
|
ownerId:
|
|
game.data.ownerId,
|
|
buyIn:
|
|
game.data.buyIn,
|
|
smallBlind:
|
|
game.data.smallBlind,
|
|
bigBlind:
|
|
game.data.bigBlind,
|
|
maxPlayers:
|
|
game.data.maxPlayers,
|
|
lobbyExpiresAt:
|
|
game.data.lobbyExpiresAt,
|
|
dealerSeat:
|
|
game.data.dealerSeat,
|
|
handNumber:
|
|
game.data.handNumber,
|
|
turnStartedAt:
|
|
game.data.turnStartedAt,
|
|
turnExpiresAt:
|
|
game.data.turnExpiresAt,
|
|
players:
|
|
game.data.players.map(
|
|
player => ({
|
|
userId:
|
|
player.userId,
|
|
seat:
|
|
player.seat,
|
|
stack:
|
|
player.stack,
|
|
status:
|
|
player.status,
|
|
joinedAt:
|
|
player.joinedAt
|
|
})
|
|
),
|
|
lastHandResult:
|
|
clone(
|
|
game.data.lastHandResult
|
|
),
|
|
hand:
|
|
null
|
|
}
|
|
};
|
|
|
|
if (
|
|
game.data.hand
|
|
) {
|
|
const hand =
|
|
game.data.hand;
|
|
|
|
result.data.hand = {
|
|
state:
|
|
hand.state,
|
|
street:
|
|
hand.street,
|
|
dealerSeat:
|
|
hand.dealerSeat,
|
|
smallBlindSeat:
|
|
hand.smallBlindSeat,
|
|
bigBlindSeat:
|
|
hand.bigBlindSeat,
|
|
currentSeat:
|
|
hand.currentSeat,
|
|
smallBlind:
|
|
hand.smallBlind,
|
|
bigBlind:
|
|
hand.bigBlind,
|
|
handCap:
|
|
hand.handCap,
|
|
pot:
|
|
hand.pot,
|
|
currentBet:
|
|
hand.currentBet,
|
|
communityCards:
|
|
clone(
|
|
hand.communityCards
|
|
),
|
|
players:
|
|
hand.players.map(
|
|
player => ({
|
|
userId:
|
|
player.userId,
|
|
seat:
|
|
player.seat,
|
|
stack:
|
|
player.stack,
|
|
folded:
|
|
player.folded,
|
|
allIn:
|
|
player.allIn,
|
|
streetBet:
|
|
player.streetBet,
|
|
totalBet:
|
|
player.totalBet
|
|
})
|
|
),
|
|
result:
|
|
clone(
|
|
hand.result
|
|
)
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* MONGO STORE
|
|
* ============================================================
|
|
*/
|
|
|
|
function getSpecialGameCollection() {
|
|
const {
|
|
getCollections
|
|
} = require(
|
|
"./cookieEconomy"
|
|
);
|
|
|
|
const {
|
|
db
|
|
} =
|
|
getCollections();
|
|
|
|
return db.collection(
|
|
SPECIAL_GAME_COLLECTION
|
|
);
|
|
}
|
|
|
|
function stripMongoId(
|
|
document
|
|
) {
|
|
if (!document) {
|
|
return null;
|
|
}
|
|
|
|
const {
|
|
_id,
|
|
...rest
|
|
} = document;
|
|
|
|
return rest;
|
|
}
|
|
|
|
|
|
function createMongoPokerStore(
|
|
collection =
|
|
getSpecialGameCollection()
|
|
) {
|
|
return {
|
|
async getGame(
|
|
gameId
|
|
) {
|
|
const document =
|
|
await collection.findOne({
|
|
gameId,
|
|
gameType:
|
|
POKER_GAME_TYPE,
|
|
recordType:
|
|
"game"
|
|
});
|
|
|
|
return stripMongoId(
|
|
document
|
|
);
|
|
},
|
|
|
|
async insertGame(
|
|
game
|
|
) {
|
|
await collection.insertOne(
|
|
clone(game)
|
|
);
|
|
|
|
return clone(game);
|
|
},
|
|
|
|
async updateGameVersion(
|
|
gameId,
|
|
expectedVersion,
|
|
nextGame
|
|
) {
|
|
const replacement =
|
|
clone(nextGame);
|
|
|
|
/*
|
|
* MongoDB owns _id. Domain game objects must never
|
|
* replace it, especially after structuredClone()
|
|
* has converted a BSON ObjectId into a plain object.
|
|
*/
|
|
|
|
delete replacement._id;
|
|
|
|
const result =
|
|
await collection.replaceOne(
|
|
{
|
|
gameId,
|
|
gameType:
|
|
POKER_GAME_TYPE,
|
|
recordType:
|
|
"game",
|
|
version:
|
|
expectedVersion
|
|
},
|
|
replacement
|
|
);
|
|
|
|
return result.modifiedCount ===
|
|
1;
|
|
},
|
|
|
|
async findActiveGameByPlayer(
|
|
guildId,
|
|
userId
|
|
) {
|
|
const document =
|
|
await collection.findOne({
|
|
gameType:
|
|
POKER_GAME_TYPE,
|
|
recordType:
|
|
"game",
|
|
guildId,
|
|
state: {
|
|
$nin: [
|
|
"CLOSED"
|
|
]
|
|
},
|
|
"data.players": {
|
|
$elemMatch: {
|
|
userId:
|
|
String(userId)
|
|
}
|
|
}
|
|
});
|
|
|
|
return stripMongoId(
|
|
document
|
|
);
|
|
},
|
|
|
|
async listJoinableGames(
|
|
guildId,
|
|
channelId = null,
|
|
limit = 25
|
|
) {
|
|
const filter = {
|
|
gameType:
|
|
POKER_GAME_TYPE,
|
|
recordType:
|
|
"game",
|
|
guildId,
|
|
state: {
|
|
$in:
|
|
POKER_OPEN_STATES
|
|
}
|
|
};
|
|
|
|
if (channelId) {
|
|
filter.channelId =
|
|
channelId;
|
|
}
|
|
|
|
const documents =
|
|
await collection
|
|
.find(filter)
|
|
.sort({
|
|
updatedAt: -1
|
|
})
|
|
.limit(
|
|
Math.max(
|
|
1,
|
|
Math.min(
|
|
25,
|
|
Number(limit) || 25
|
|
)
|
|
)
|
|
)
|
|
.toArray();
|
|
|
|
return documents.map(
|
|
stripMongoId
|
|
);
|
|
},
|
|
|
|
async listActiveGames() {
|
|
const documents =
|
|
await collection
|
|
.find({
|
|
gameType:
|
|
POKER_GAME_TYPE,
|
|
recordType:
|
|
"game",
|
|
state: {
|
|
$nin: [
|
|
"CLOSED"
|
|
]
|
|
}
|
|
})
|
|
.sort({
|
|
updatedAt: -1
|
|
})
|
|
.toArray();
|
|
|
|
return documents.map(
|
|
stripMongoId
|
|
);
|
|
},
|
|
|
|
async claimMembership({
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
sessionId,
|
|
buyIn,
|
|
createdAt
|
|
}) {
|
|
const document = {
|
|
gameId:
|
|
membershipGameId(
|
|
guildId,
|
|
userId
|
|
),
|
|
gameType:
|
|
POKER_MEMBERSHIP_TYPE,
|
|
recordType:
|
|
"membership",
|
|
guildId,
|
|
channelId:
|
|
null,
|
|
messageId:
|
|
null,
|
|
state:
|
|
"PENDING",
|
|
version:
|
|
1,
|
|
createdAt,
|
|
updatedAt:
|
|
createdAt,
|
|
data: {
|
|
userId:
|
|
String(userId),
|
|
tableGameId:
|
|
gameId,
|
|
sessionId,
|
|
buyIn
|
|
}
|
|
};
|
|
|
|
try {
|
|
await collection.insertOne(
|
|
document
|
|
);
|
|
|
|
return clone(document);
|
|
}
|
|
|
|
catch (error) {
|
|
if (
|
|
error?.code ===
|
|
11000
|
|
) {
|
|
throw new PokerTableError(
|
|
"PLAYER_ALREADY_SEATED",
|
|
"Player is already seated at a Poker table."
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async getMembership(
|
|
guildId,
|
|
userId
|
|
) {
|
|
return collection.findOne({
|
|
gameId:
|
|
membershipGameId(
|
|
guildId,
|
|
userId
|
|
),
|
|
gameType:
|
|
POKER_MEMBERSHIP_TYPE,
|
|
recordType:
|
|
"membership"
|
|
});
|
|
},
|
|
|
|
async activateMembership(
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
sessionId,
|
|
updatedAt
|
|
) {
|
|
const result =
|
|
await collection.updateOne(
|
|
{
|
|
gameId:
|
|
membershipGameId(
|
|
guildId,
|
|
userId
|
|
),
|
|
gameType:
|
|
POKER_MEMBERSHIP_TYPE,
|
|
recordType:
|
|
"membership",
|
|
"data.tableGameId":
|
|
gameId,
|
|
"data.sessionId":
|
|
sessionId
|
|
},
|
|
{
|
|
$set: {
|
|
state:
|
|
"ACTIVE",
|
|
updatedAt
|
|
},
|
|
$inc: {
|
|
version:
|
|
1
|
|
}
|
|
}
|
|
);
|
|
|
|
return result.modifiedCount ===
|
|
1;
|
|
},
|
|
|
|
async releaseMembership(
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
sessionId
|
|
) {
|
|
const result =
|
|
await collection.deleteOne({
|
|
gameId:
|
|
membershipGameId(
|
|
guildId,
|
|
userId
|
|
),
|
|
gameType:
|
|
POKER_MEMBERSHIP_TYPE,
|
|
recordType:
|
|
"membership",
|
|
"data.tableGameId":
|
|
gameId,
|
|
"data.sessionId":
|
|
sessionId
|
|
});
|
|
|
|
return result.deletedCount ===
|
|
1;
|
|
},
|
|
|
|
async listPendingMemberships(
|
|
olderThan
|
|
) {
|
|
return collection.find({
|
|
gameType:
|
|
POKER_MEMBERSHIP_TYPE,
|
|
recordType:
|
|
"membership",
|
|
state:
|
|
"PENDING",
|
|
updatedAt: {
|
|
$lte:
|
|
olderThan
|
|
}
|
|
}).toArray();
|
|
},
|
|
|
|
async listExpiredTurnGames(
|
|
now
|
|
) {
|
|
return collection.find({
|
|
gameType:
|
|
POKER_GAME_TYPE,
|
|
recordType:
|
|
"game",
|
|
state: {
|
|
$in:
|
|
POKER_ACTIVE_STATES
|
|
},
|
|
"data.turnExpiresAt": {
|
|
$lte:
|
|
now,
|
|
$ne:
|
|
null
|
|
}
|
|
}).toArray();
|
|
},
|
|
|
|
async listExpiredLobbies(
|
|
now
|
|
) {
|
|
return collection.find({
|
|
gameType:
|
|
POKER_GAME_TYPE,
|
|
recordType:
|
|
"game",
|
|
state:
|
|
"WAITING",
|
|
"data.lobbyExpiresAt": {
|
|
$lte:
|
|
now
|
|
}
|
|
}).toArray();
|
|
},
|
|
|
|
async listPendingCashoutGames() {
|
|
return collection.find({
|
|
gameType:
|
|
POKER_GAME_TYPE,
|
|
recordType:
|
|
"game",
|
|
"data.players": {
|
|
$elemMatch: {
|
|
cashOutState:
|
|
"PENDING"
|
|
}
|
|
}
|
|
}).toArray();
|
|
},
|
|
|
|
async listClosingGames() {
|
|
return collection.find({
|
|
gameType:
|
|
POKER_GAME_TYPE,
|
|
recordType:
|
|
"game",
|
|
state:
|
|
"CLOSING"
|
|
}).toArray();
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* DEFAULT ECONOMY ADAPTER
|
|
* ============================================================
|
|
*/
|
|
|
|
function createDefaultPokerEconomy() {
|
|
return {
|
|
async changeOnce(
|
|
userId,
|
|
amount,
|
|
operationId
|
|
) {
|
|
const {
|
|
changeCookiesOnce
|
|
} = require(
|
|
"./cookieEconomy"
|
|
);
|
|
|
|
return changeCookiesOnce(
|
|
userId,
|
|
amount,
|
|
operationId
|
|
);
|
|
},
|
|
|
|
async hasOperation(
|
|
userId,
|
|
operationId
|
|
) {
|
|
const {
|
|
hasCookieOperation
|
|
} = require(
|
|
"./cookieEconomy"
|
|
);
|
|
|
|
return hasCookieOperation(
|
|
userId,
|
|
operationId
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* POKER SERVICE
|
|
* ============================================================
|
|
*/
|
|
|
|
function createPokerService({
|
|
store =
|
|
createMongoPokerStore(),
|
|
economy =
|
|
createDefaultPokerEconomy(),
|
|
now =
|
|
nowSeconds,
|
|
idFactory =
|
|
createId,
|
|
turnTimeout =
|
|
POKER_TURN_TIMEOUT,
|
|
lobbyTimeout =
|
|
POKER_LOBBY_TIMEOUT
|
|
} = {}) {
|
|
async function readGame(
|
|
gameId
|
|
) {
|
|
const game =
|
|
await store.getGame(
|
|
gameId
|
|
);
|
|
|
|
if (!game) {
|
|
throw new PokerTableError(
|
|
"TABLE_NOT_FOUND",
|
|
"This Poker table is no longer active."
|
|
);
|
|
}
|
|
|
|
return game;
|
|
}
|
|
|
|
async function claimAndDebitSeat({
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
buyIn
|
|
}) {
|
|
const sessionId =
|
|
idFactory(
|
|
"poker_session"
|
|
);
|
|
|
|
const timestamp =
|
|
now();
|
|
|
|
await store.claimMembership({
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
sessionId,
|
|
buyIn,
|
|
createdAt:
|
|
timestamp
|
|
});
|
|
|
|
try {
|
|
await economy.changeOnce(
|
|
userId,
|
|
-buyIn,
|
|
buyInOperationId(
|
|
gameId,
|
|
sessionId
|
|
)
|
|
);
|
|
}
|
|
|
|
catch (error) {
|
|
await store.releaseMembership(
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
sessionId
|
|
);
|
|
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
sessionId,
|
|
timestamp
|
|
};
|
|
}
|
|
|
|
async function refundFailedSeat({
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
sessionId,
|
|
buyIn
|
|
}) {
|
|
try {
|
|
await economy.changeOnce(
|
|
userId,
|
|
buyIn,
|
|
buyInRefundOperationId(
|
|
gameId,
|
|
sessionId
|
|
)
|
|
);
|
|
}
|
|
|
|
finally {
|
|
await store.releaseMembership(
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
sessionId
|
|
);
|
|
}
|
|
}
|
|
|
|
async function createTable({
|
|
guildId,
|
|
channelId,
|
|
ownerId,
|
|
buyIn =
|
|
POKER_DEFAULT_BUY_IN,
|
|
smallBlind =
|
|
POKER_DEFAULT_SMALL_BLIND,
|
|
bigBlind =
|
|
POKER_DEFAULT_BIG_BLIND,
|
|
maxPlayers =
|
|
POKER_MAX_PLAYERS
|
|
}) {
|
|
validateTableConfig({
|
|
buyIn,
|
|
smallBlind,
|
|
bigBlind,
|
|
maxPlayers
|
|
});
|
|
|
|
if (
|
|
!guildId
|
|
||
|
|
!channelId
|
|
||
|
|
!ownerId
|
|
) {
|
|
throw new PokerTableError(
|
|
"INVALID_TABLE_CONTEXT",
|
|
"Guild, channel, and owner are required."
|
|
);
|
|
}
|
|
|
|
const gameId =
|
|
idFactory(
|
|
"poker"
|
|
);
|
|
|
|
const {
|
|
sessionId,
|
|
timestamp
|
|
} =
|
|
await claimAndDebitSeat({
|
|
guildId,
|
|
userId:
|
|
ownerId,
|
|
gameId,
|
|
buyIn
|
|
});
|
|
|
|
const game = {
|
|
gameId,
|
|
gameType:
|
|
POKER_GAME_TYPE,
|
|
recordType:
|
|
"game",
|
|
guildId,
|
|
channelId,
|
|
messageId:
|
|
null,
|
|
state:
|
|
"WAITING",
|
|
version:
|
|
1,
|
|
createdAt:
|
|
timestamp,
|
|
updatedAt:
|
|
timestamp,
|
|
data: {
|
|
ownerId:
|
|
String(ownerId),
|
|
buyIn,
|
|
smallBlind,
|
|
bigBlind,
|
|
maxPlayers,
|
|
lobbyExpiresAt:
|
|
timestamp +
|
|
lobbyTimeout,
|
|
dealerSeat:
|
|
null,
|
|
handNumber:
|
|
0,
|
|
hand:
|
|
null,
|
|
lastHandResult:
|
|
null,
|
|
handCompletedAt:
|
|
null,
|
|
turnStartedAt:
|
|
null,
|
|
turnExpiresAt:
|
|
null,
|
|
players: [
|
|
{
|
|
userId:
|
|
String(ownerId),
|
|
seat:
|
|
0,
|
|
stack:
|
|
buyIn,
|
|
buyIn,
|
|
sessionId,
|
|
status:
|
|
"ACTIVE",
|
|
cashOutState:
|
|
"NONE",
|
|
joinedAt:
|
|
timestamp
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
try {
|
|
await store.insertGame(
|
|
game
|
|
);
|
|
|
|
await store.activateMembership(
|
|
guildId,
|
|
ownerId,
|
|
gameId,
|
|
sessionId,
|
|
now()
|
|
);
|
|
}
|
|
|
|
catch (error) {
|
|
await refundFailedSeat({
|
|
guildId,
|
|
userId:
|
|
ownerId,
|
|
gameId,
|
|
sessionId,
|
|
buyIn
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
|
|
return clone(game);
|
|
}
|
|
|
|
async function joinTable({
|
|
gameId,
|
|
guildId,
|
|
userId
|
|
}) {
|
|
const game =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
if (
|
|
game.guildId !==
|
|
guildId
|
|
) {
|
|
throw new PokerTableError(
|
|
"WRONG_GUILD",
|
|
"Poker table belongs to another guild."
|
|
);
|
|
}
|
|
|
|
if (
|
|
!isTableOpenForJoin(
|
|
game
|
|
)
|
|
) {
|
|
throw new PokerTableError(
|
|
"TABLE_NOT_JOINABLE",
|
|
"Players can only join while the table is waiting."
|
|
);
|
|
}
|
|
|
|
if (
|
|
gameHasUser(
|
|
game,
|
|
userId
|
|
)
|
|
) {
|
|
throw new PokerTableError(
|
|
"PLAYER_ALREADY_SEATED",
|
|
"Player is already seated at this Poker table."
|
|
);
|
|
}
|
|
|
|
if (
|
|
game.data.players.length >=
|
|
game.data.maxPlayers
|
|
) {
|
|
throw new PokerTableError(
|
|
"TABLE_FULL",
|
|
"Poker table is full."
|
|
);
|
|
}
|
|
|
|
const seat =
|
|
nextAvailableSeat(
|
|
game.data.players,
|
|
game.data.maxPlayers
|
|
);
|
|
|
|
if (
|
|
seat ===
|
|
null
|
|
) {
|
|
throw new PokerTableError(
|
|
"TABLE_FULL",
|
|
"Poker table is full."
|
|
);
|
|
}
|
|
|
|
const {
|
|
sessionId,
|
|
timestamp
|
|
} =
|
|
await claimAndDebitSeat({
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
buyIn:
|
|
game.data.buyIn
|
|
});
|
|
|
|
const nextGame =
|
|
clone(game);
|
|
|
|
nextGame.data.players.push({
|
|
userId:
|
|
String(userId),
|
|
seat,
|
|
stack:
|
|
game.data.buyIn,
|
|
buyIn:
|
|
game.data.buyIn,
|
|
sessionId,
|
|
status:
|
|
"ACTIVE",
|
|
cashOutState:
|
|
"NONE",
|
|
joinedAt:
|
|
timestamp
|
|
});
|
|
|
|
nextGame.version =
|
|
game.version +
|
|
1;
|
|
|
|
nextGame.updatedAt =
|
|
timestamp;
|
|
|
|
const committed =
|
|
await store.updateGameVersion(
|
|
gameId,
|
|
game.version,
|
|
nextGame
|
|
);
|
|
|
|
if (!committed) {
|
|
await refundFailedSeat({
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
sessionId,
|
|
buyIn:
|
|
game.data.buyIn
|
|
});
|
|
|
|
throw new PokerTableError(
|
|
"STATE_CONFLICT",
|
|
"Poker table changed while you were joining. Please try again."
|
|
);
|
|
}
|
|
|
|
try {
|
|
await store.activateMembership(
|
|
guildId,
|
|
userId,
|
|
gameId,
|
|
sessionId,
|
|
now()
|
|
);
|
|
}
|
|
|
|
catch (error) {
|
|
console.error(
|
|
"[Poker] Failed to activate membership after successful join:",
|
|
{
|
|
gameId,
|
|
userId,
|
|
sessionId,
|
|
error
|
|
}
|
|
);
|
|
}
|
|
|
|
return clone(nextGame);
|
|
}
|
|
|
|
async function getTable({
|
|
gameId
|
|
}) {
|
|
return readGame(
|
|
gameId
|
|
);
|
|
}
|
|
|
|
async function listJoinableTables({
|
|
guildId,
|
|
channelId = null,
|
|
limit = 25
|
|
}) {
|
|
if (!guildId) {
|
|
throw new PokerTableError(
|
|
"INVALID_TABLE_CONTEXT",
|
|
"Guild is required."
|
|
);
|
|
}
|
|
|
|
const games =
|
|
await store.listJoinableGames(
|
|
guildId,
|
|
channelId,
|
|
limit
|
|
);
|
|
|
|
return games.map(
|
|
game =>
|
|
publicTableState(
|
|
game
|
|
)
|
|
);
|
|
}
|
|
|
|
async function getMyTable({
|
|
guildId,
|
|
userId
|
|
}) {
|
|
const membership =
|
|
await store.getMembership(
|
|
guildId,
|
|
userId
|
|
);
|
|
|
|
if (
|
|
!membership
|
|
||
|
|
!membership.data
|
|
?.tableGameId
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return store.getGame(
|
|
membership.data
|
|
.tableGameId
|
|
);
|
|
}
|
|
|
|
async function setMessageId({
|
|
gameId,
|
|
messageId
|
|
}) {
|
|
const game =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
const nextGame =
|
|
clone(game);
|
|
|
|
nextGame.messageId =
|
|
messageId;
|
|
|
|
nextGame.version =
|
|
game.version +
|
|
1;
|
|
|
|
nextGame.updatedAt =
|
|
now();
|
|
|
|
const committed =
|
|
await store.updateGameVersion(
|
|
gameId,
|
|
game.version,
|
|
nextGame
|
|
);
|
|
|
|
if (!committed) {
|
|
throw new PokerTableError(
|
|
"STATE_CONFLICT",
|
|
"Poker table changed while setting its Discord message."
|
|
);
|
|
}
|
|
|
|
return clone(nextGame);
|
|
}
|
|
|
|
async function startTableHand({
|
|
gameId,
|
|
userId
|
|
}) {
|
|
const game =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
if (
|
|
String(game.data.ownerId) !==
|
|
String(userId)
|
|
) {
|
|
throw new PokerTableError(
|
|
"NOT_TABLE_OWNER",
|
|
"Only the Poker table owner can start the next hand."
|
|
);
|
|
}
|
|
|
|
if (
|
|
!POKER_OPEN_STATES.includes(
|
|
game.state
|
|
)
|
|
) {
|
|
throw new PokerTableError(
|
|
"HAND_ALREADY_ACTIVE",
|
|
"Poker hand is already active."
|
|
);
|
|
}
|
|
|
|
const players =
|
|
activeEnginePlayers(
|
|
game
|
|
);
|
|
|
|
if (
|
|
players.length <
|
|
POKER_MIN_PLAYERS
|
|
) {
|
|
throw new PokerTableError(
|
|
"NOT_ENOUGH_PLAYERS",
|
|
"At least two players with one big blind are required."
|
|
);
|
|
}
|
|
|
|
const dealerSeat =
|
|
nextDealerSeat(
|
|
players,
|
|
game.data.dealerSeat
|
|
);
|
|
|
|
const hand =
|
|
startHand({
|
|
players,
|
|
dealerSeat,
|
|
smallBlind:
|
|
game.data.smallBlind,
|
|
bigBlind:
|
|
game.data.bigBlind
|
|
});
|
|
|
|
const nextGame =
|
|
clone(game);
|
|
|
|
nextGame.state =
|
|
hand.state;
|
|
|
|
nextGame.data.hand =
|
|
hand;
|
|
|
|
nextGame.data.dealerSeat =
|
|
dealerSeat;
|
|
|
|
nextGame.data.handNumber =
|
|
game.data.handNumber +
|
|
1;
|
|
|
|
nextGame.data.lastHandResult =
|
|
null;
|
|
|
|
nextGame.data.handCompletedAt =
|
|
null;
|
|
|
|
applyTurnDeadline(
|
|
nextGame,
|
|
now(),
|
|
turnTimeout
|
|
);
|
|
|
|
nextGame.version =
|
|
game.version +
|
|
1;
|
|
|
|
nextGame.updatedAt =
|
|
now();
|
|
|
|
const committed =
|
|
await store.updateGameVersion(
|
|
gameId,
|
|
game.version,
|
|
nextGame
|
|
);
|
|
|
|
if (!committed) {
|
|
throw new PokerTableError(
|
|
"STATE_CONFLICT",
|
|
"Poker table changed while the hand was starting."
|
|
);
|
|
}
|
|
|
|
return clone(nextGame);
|
|
}
|
|
|
|
async function commitAction({
|
|
game,
|
|
userId,
|
|
action,
|
|
allowExpired = false
|
|
}) {
|
|
if (
|
|
!isHandActive(
|
|
game
|
|
)
|
|
) {
|
|
throw new PokerTableError(
|
|
"HAND_NOT_ACTIVE",
|
|
"Poker hand is not accepting actions."
|
|
);
|
|
}
|
|
|
|
const timestamp =
|
|
now();
|
|
|
|
if (
|
|
!allowExpired
|
|
&&
|
|
game.data.turnExpiresAt !==
|
|
null
|
|
&&
|
|
timestamp >
|
|
game.data.turnExpiresAt
|
|
) {
|
|
throw new PokerTableError(
|
|
"TURN_EXPIRED",
|
|
"Your Poker turn has already expired."
|
|
);
|
|
}
|
|
|
|
let hand;
|
|
|
|
try {
|
|
hand =
|
|
applyAction(
|
|
game.data.hand,
|
|
userId,
|
|
action
|
|
);
|
|
}
|
|
|
|
catch (error) {
|
|
if (
|
|
error instanceof
|
|
PokerEngineError
|
|
) {
|
|
throw new PokerTableError(
|
|
error.code,
|
|
error.message
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
const nextGame =
|
|
clone(game);
|
|
|
|
nextGame.data.hand =
|
|
hand;
|
|
|
|
nextGame.state =
|
|
hand.state;
|
|
|
|
if (
|
|
hand.state ===
|
|
"PAYOUT"
|
|
) {
|
|
syncCompletedHand(
|
|
nextGame,
|
|
timestamp
|
|
);
|
|
}
|
|
|
|
else {
|
|
applyTurnDeadline(
|
|
nextGame,
|
|
timestamp,
|
|
turnTimeout
|
|
);
|
|
}
|
|
|
|
nextGame.version =
|
|
game.version +
|
|
1;
|
|
|
|
nextGame.updatedAt =
|
|
timestamp;
|
|
|
|
const committed =
|
|
await store.updateGameVersion(
|
|
game.gameId,
|
|
game.version,
|
|
nextGame
|
|
);
|
|
|
|
if (!committed) {
|
|
throw new PokerTableError(
|
|
"STATE_CONFLICT",
|
|
"Poker table changed before this action could be committed."
|
|
);
|
|
}
|
|
|
|
return clone(nextGame);
|
|
}
|
|
|
|
async function act({
|
|
gameId,
|
|
userId,
|
|
action
|
|
}) {
|
|
const game =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
return commitAction({
|
|
game,
|
|
userId,
|
|
action,
|
|
allowExpired:
|
|
false
|
|
});
|
|
}
|
|
|
|
async function getLegalPlayerActions({
|
|
gameId,
|
|
userId
|
|
}) {
|
|
const game =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
if (
|
|
!game.data.hand
|
|
) {
|
|
return {
|
|
actions: [],
|
|
reason:
|
|
"HAND_NOT_ACTIVE"
|
|
};
|
|
}
|
|
|
|
return getLegalActions(
|
|
game.data.hand,
|
|
userId
|
|
);
|
|
}
|
|
|
|
async function viewCards({
|
|
gameId,
|
|
userId
|
|
}) {
|
|
const game =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
if (
|
|
!gameHasUser(
|
|
game,
|
|
userId
|
|
)
|
|
) {
|
|
throw new PokerTableError(
|
|
"NOT_TABLE_PLAYER",
|
|
"You are not seated at this Poker table."
|
|
);
|
|
}
|
|
|
|
if (
|
|
!game.data.hand
|
|
) {
|
|
throw new PokerTableError(
|
|
"HAND_NOT_ACTIVE",
|
|
"There is no active Poker hand."
|
|
);
|
|
}
|
|
|
|
try {
|
|
return getPrivateCards(
|
|
game.data.hand,
|
|
userId
|
|
);
|
|
}
|
|
|
|
catch (error) {
|
|
if (
|
|
error instanceof
|
|
PokerEngineError
|
|
) {
|
|
throw new PokerTableError(
|
|
error.code,
|
|
error.message
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function processTurnTimeout(
|
|
gameId
|
|
) {
|
|
const game =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
if (
|
|
!isHandActive(
|
|
game
|
|
)
|
|
) {
|
|
return {
|
|
processed:
|
|
false,
|
|
reason:
|
|
"HAND_NOT_ACTIVE"
|
|
};
|
|
}
|
|
|
|
const timestamp =
|
|
now();
|
|
|
|
if (
|
|
game.data.turnExpiresAt ===
|
|
null
|
|
||
|
|
timestamp <
|
|
game.data.turnExpiresAt
|
|
) {
|
|
return {
|
|
processed:
|
|
false,
|
|
reason:
|
|
"NOT_EXPIRED"
|
|
};
|
|
}
|
|
|
|
const currentSeat =
|
|
game.data.hand.currentSeat;
|
|
|
|
const player =
|
|
game.data.hand.players.find(
|
|
candidate =>
|
|
candidate.seat ===
|
|
currentSeat
|
|
);
|
|
|
|
if (!player) {
|
|
return {
|
|
processed:
|
|
false,
|
|
reason:
|
|
"NO_CURRENT_PLAYER"
|
|
};
|
|
}
|
|
|
|
const nextGame =
|
|
await commitAction({
|
|
game,
|
|
userId:
|
|
player.userId,
|
|
action: {
|
|
type:
|
|
"fold"
|
|
},
|
|
allowExpired:
|
|
true
|
|
});
|
|
|
|
return {
|
|
processed:
|
|
true,
|
|
userId:
|
|
player.userId,
|
|
game:
|
|
nextGame
|
|
};
|
|
}
|
|
|
|
async function processExpiredTurns() {
|
|
const timestamp =
|
|
now();
|
|
|
|
const games =
|
|
await store.listExpiredTurnGames(
|
|
timestamp
|
|
);
|
|
|
|
const results = [];
|
|
|
|
for (const game of games) {
|
|
try {
|
|
results.push(
|
|
await processTurnTimeout(
|
|
game.gameId
|
|
)
|
|
);
|
|
}
|
|
|
|
catch (error) {
|
|
if (
|
|
error?.code ===
|
|
"STATE_CONFLICT"
|
|
) {
|
|
results.push({
|
|
processed:
|
|
false,
|
|
reason:
|
|
"STATE_CONFLICT",
|
|
gameId:
|
|
game.gameId
|
|
});
|
|
|
|
continue;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function markCashOutPending({
|
|
game,
|
|
userId
|
|
}) {
|
|
if (
|
|
isHandActive(
|
|
game
|
|
)
|
|
) {
|
|
throw new PokerTableError(
|
|
"HAND_ACTIVE",
|
|
"You cannot cash out during an active Poker hand."
|
|
);
|
|
}
|
|
|
|
const player =
|
|
getPlayer(
|
|
game,
|
|
userId
|
|
);
|
|
|
|
if (!player) {
|
|
throw new PokerTableError(
|
|
"NOT_TABLE_PLAYER",
|
|
"You are not seated at this Poker table."
|
|
);
|
|
}
|
|
|
|
if (
|
|
player.cashOutState ===
|
|
"PENDING"
|
|
) {
|
|
return game;
|
|
}
|
|
|
|
const nextGame =
|
|
clone(game);
|
|
|
|
const nextPlayer =
|
|
getPlayer(
|
|
nextGame,
|
|
userId
|
|
);
|
|
|
|
nextPlayer.cashOutState =
|
|
"PENDING";
|
|
|
|
nextGame.version =
|
|
game.version +
|
|
1;
|
|
|
|
nextGame.updatedAt =
|
|
now();
|
|
|
|
const committed =
|
|
await store.updateGameVersion(
|
|
game.gameId,
|
|
game.version,
|
|
nextGame
|
|
);
|
|
|
|
if (!committed) {
|
|
throw new PokerTableError(
|
|
"STATE_CONFLICT",
|
|
"Poker table changed while cash-out was starting."
|
|
);
|
|
}
|
|
|
|
return nextGame;
|
|
}
|
|
|
|
async function finalizeCashOut({
|
|
gameId,
|
|
userId,
|
|
sessionId
|
|
}) {
|
|
for (
|
|
let attempt = 0;
|
|
attempt < 5;
|
|
attempt++
|
|
) {
|
|
const game =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
const player =
|
|
getPlayerBySession(
|
|
game,
|
|
sessionId
|
|
);
|
|
|
|
if (!player) {
|
|
return game;
|
|
}
|
|
|
|
if (
|
|
player.userId !==
|
|
String(userId)
|
|
) {
|
|
throw new PokerTableError(
|
|
"CASHOUT_SESSION_MISMATCH",
|
|
"Cash-out session does not belong to this player."
|
|
);
|
|
}
|
|
|
|
const nextGame =
|
|
clone(game);
|
|
|
|
nextGame.data.players =
|
|
nextGame.data.players.filter(
|
|
candidate =>
|
|
candidate.sessionId !==
|
|
sessionId
|
|
);
|
|
|
|
if (
|
|
nextGame.data.ownerId ===
|
|
String(userId)
|
|
) {
|
|
nextGame.data.ownerId =
|
|
nextGame.data.players
|
|
.slice()
|
|
.sort(
|
|
(a, b) =>
|
|
a.seat - b.seat
|
|
)[0]
|
|
?.userId ??
|
|
null;
|
|
}
|
|
|
|
if (
|
|
nextGame.data.players.length ===
|
|
0
|
|
) {
|
|
nextGame.state =
|
|
"CLOSED";
|
|
|
|
nextGame.data.turnStartedAt =
|
|
null;
|
|
|
|
nextGame.data.turnExpiresAt =
|
|
null;
|
|
}
|
|
|
|
nextGame.version =
|
|
game.version +
|
|
1;
|
|
|
|
nextGame.updatedAt =
|
|
now();
|
|
|
|
const committed =
|
|
await store.updateGameVersion(
|
|
gameId,
|
|
game.version,
|
|
nextGame
|
|
);
|
|
|
|
if (committed) {
|
|
return nextGame;
|
|
}
|
|
}
|
|
|
|
throw new PokerTableError(
|
|
"STATE_CONFLICT",
|
|
"Poker table kept changing while cash-out was finalizing."
|
|
);
|
|
}
|
|
|
|
async function cashOut({
|
|
gameId,
|
|
userId
|
|
}) {
|
|
let game =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
game =
|
|
await markCashOutPending({
|
|
game,
|
|
userId
|
|
});
|
|
|
|
const player =
|
|
getPlayer(
|
|
game,
|
|
userId
|
|
);
|
|
|
|
if (!player) {
|
|
throw new PokerTableError(
|
|
"NOT_TABLE_PLAYER",
|
|
"You are not seated at this Poker table."
|
|
);
|
|
}
|
|
|
|
const amount =
|
|
player.stack;
|
|
|
|
if (
|
|
amount > 0
|
|
) {
|
|
await economy.changeOnce(
|
|
userId,
|
|
amount,
|
|
cashOutOperationId(
|
|
gameId,
|
|
player.sessionId
|
|
)
|
|
);
|
|
}
|
|
|
|
const nextGame =
|
|
await finalizeCashOut({
|
|
gameId,
|
|
userId,
|
|
sessionId:
|
|
player.sessionId
|
|
});
|
|
|
|
await store.releaseMembership(
|
|
game.guildId,
|
|
userId,
|
|
gameId,
|
|
player.sessionId
|
|
);
|
|
|
|
return {
|
|
amount,
|
|
game:
|
|
clone(nextGame)
|
|
};
|
|
}
|
|
|
|
async function finishClosingTable(
|
|
gameId
|
|
) {
|
|
for (
|
|
let attempt = 0;
|
|
attempt < 5;
|
|
attempt++
|
|
) {
|
|
const current =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
if (
|
|
current.state ===
|
|
"CLOSED"
|
|
) {
|
|
return current;
|
|
}
|
|
|
|
if (
|
|
current.state !==
|
|
"CLOSING"
|
|
) {
|
|
throw new PokerTableError(
|
|
"TABLE_NOT_CLOSING",
|
|
"Poker table is not in a closing state."
|
|
);
|
|
}
|
|
|
|
for (
|
|
const player
|
|
of current.data.players
|
|
.slice()
|
|
) {
|
|
if (
|
|
player.stack > 0
|
|
) {
|
|
await economy.changeOnce(
|
|
player.userId,
|
|
player.stack,
|
|
cashOutOperationId(
|
|
gameId,
|
|
player.sessionId
|
|
)
|
|
);
|
|
}
|
|
|
|
await store.releaseMembership(
|
|
current.guildId,
|
|
player.userId,
|
|
gameId,
|
|
player.sessionId
|
|
);
|
|
}
|
|
|
|
const closed =
|
|
clone(current);
|
|
|
|
closed.state =
|
|
"CLOSED";
|
|
|
|
closed.data.players = [];
|
|
closed.data.ownerId = null;
|
|
closed.data.turnStartedAt = null;
|
|
closed.data.turnExpiresAt = null;
|
|
closed.version =
|
|
current.version +
|
|
1;
|
|
closed.updatedAt =
|
|
now();
|
|
|
|
if (
|
|
await store.updateGameVersion(
|
|
gameId,
|
|
current.version,
|
|
closed
|
|
)
|
|
) {
|
|
return closed;
|
|
}
|
|
}
|
|
|
|
throw new PokerTableError(
|
|
"STATE_CONFLICT",
|
|
"Poker table kept changing while it was closing."
|
|
);
|
|
}
|
|
|
|
async function expireLobby(
|
|
gameId
|
|
) {
|
|
const game =
|
|
await readGame(
|
|
gameId
|
|
);
|
|
|
|
if (
|
|
game.state ===
|
|
"CLOSING"
|
|
) {
|
|
return {
|
|
expired:
|
|
true,
|
|
game:
|
|
await finishClosingTable(
|
|
gameId
|
|
)
|
|
};
|
|
}
|
|
|
|
if (
|
|
game.state !==
|
|
"WAITING"
|
|
) {
|
|
return {
|
|
expired:
|
|
false,
|
|
reason:
|
|
"NOT_WAITING"
|
|
};
|
|
}
|
|
|
|
if (
|
|
now() <
|
|
game.data.lobbyExpiresAt
|
|
) {
|
|
return {
|
|
expired:
|
|
false,
|
|
reason:
|
|
"NOT_EXPIRED"
|
|
};
|
|
}
|
|
|
|
const nextGame =
|
|
clone(game);
|
|
|
|
nextGame.state =
|
|
"CLOSING";
|
|
|
|
nextGame.version =
|
|
game.version +
|
|
1;
|
|
|
|
nextGame.updatedAt =
|
|
now();
|
|
|
|
const committed =
|
|
await store.updateGameVersion(
|
|
gameId,
|
|
game.version,
|
|
nextGame
|
|
);
|
|
|
|
if (!committed) {
|
|
return {
|
|
expired:
|
|
false,
|
|
reason:
|
|
"STATE_CONFLICT"
|
|
};
|
|
}
|
|
|
|
return {
|
|
expired:
|
|
true,
|
|
game:
|
|
await finishClosingTable(
|
|
gameId
|
|
)
|
|
};
|
|
}
|
|
|
|
async function processExpiredLobbies() {
|
|
const games =
|
|
await store.listExpiredLobbies(
|
|
now()
|
|
);
|
|
|
|
const results = [];
|
|
|
|
for (const game of games) {
|
|
results.push(
|
|
await expireLobby(
|
|
game.gameId
|
|
)
|
|
);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function recoverPendingCashouts() {
|
|
const games =
|
|
await store.listPendingCashoutGames();
|
|
|
|
const results = [];
|
|
|
|
for (
|
|
const game
|
|
of games
|
|
) {
|
|
const pendingPlayers =
|
|
game.data.players
|
|
.filter(
|
|
player =>
|
|
player.cashOutState ===
|
|
"PENDING"
|
|
)
|
|
.map(clone);
|
|
|
|
for (
|
|
const player
|
|
of pendingPlayers
|
|
) {
|
|
if (
|
|
player.stack > 0
|
|
) {
|
|
await economy.changeOnce(
|
|
player.userId,
|
|
player.stack,
|
|
cashOutOperationId(
|
|
game.gameId,
|
|
player.sessionId
|
|
)
|
|
);
|
|
}
|
|
|
|
await finalizeCashOut({
|
|
gameId:
|
|
game.gameId,
|
|
userId:
|
|
player.userId,
|
|
sessionId:
|
|
player.sessionId
|
|
});
|
|
|
|
await store.releaseMembership(
|
|
game.guildId,
|
|
player.userId,
|
|
game.gameId,
|
|
player.sessionId
|
|
);
|
|
|
|
results.push({
|
|
gameId:
|
|
game.gameId,
|
|
userId:
|
|
player.userId,
|
|
action:
|
|
"cashout_finalized"
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function recoverClosingTables() {
|
|
const games =
|
|
await store.listClosingGames();
|
|
|
|
const results = [];
|
|
|
|
for (
|
|
const game
|
|
of games
|
|
) {
|
|
results.push({
|
|
gameId:
|
|
game.gameId,
|
|
game:
|
|
await finishClosingTable(
|
|
game.gameId
|
|
)
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function recoverPendingMemberships() {
|
|
const olderThan =
|
|
now() -
|
|
POKER_MEMBERSHIP_RECOVERY_AGE;
|
|
|
|
const memberships =
|
|
await store.listPendingMemberships(
|
|
olderThan
|
|
);
|
|
|
|
const results = [];
|
|
|
|
for (
|
|
const membership
|
|
of memberships
|
|
) {
|
|
const {
|
|
userId,
|
|
tableGameId,
|
|
sessionId,
|
|
buyIn
|
|
} = membership.data;
|
|
|
|
const game =
|
|
await store.getGame(
|
|
tableGameId
|
|
);
|
|
|
|
const player =
|
|
game
|
|
? getPlayerBySession(
|
|
game,
|
|
sessionId
|
|
)
|
|
: null;
|
|
|
|
if (player) {
|
|
await store.activateMembership(
|
|
membership.guildId,
|
|
userId,
|
|
tableGameId,
|
|
sessionId,
|
|
now()
|
|
);
|
|
|
|
results.push({
|
|
userId,
|
|
gameId:
|
|
tableGameId,
|
|
action:
|
|
"activated"
|
|
});
|
|
|
|
continue;
|
|
}
|
|
|
|
const debitOperationId =
|
|
buyInOperationId(
|
|
tableGameId,
|
|
sessionId
|
|
);
|
|
|
|
const debitApplied =
|
|
await economy.hasOperation(
|
|
userId,
|
|
debitOperationId
|
|
);
|
|
|
|
if (debitApplied) {
|
|
await economy.changeOnce(
|
|
userId,
|
|
buyIn,
|
|
buyInRefundOperationId(
|
|
tableGameId,
|
|
sessionId
|
|
)
|
|
);
|
|
}
|
|
|
|
await store.releaseMembership(
|
|
membership.guildId,
|
|
userId,
|
|
tableGameId,
|
|
sessionId
|
|
);
|
|
|
|
results.push({
|
|
userId,
|
|
gameId:
|
|
tableGameId,
|
|
action:
|
|
debitApplied
|
|
? "refunded"
|
|
: "released"
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function listActiveTables() {
|
|
return store.listActiveGames();
|
|
}
|
|
|
|
|
|
async function recover() {
|
|
const [
|
|
memberships,
|
|
cashouts,
|
|
closingTables,
|
|
turns,
|
|
lobbies
|
|
] =
|
|
await Promise.all([
|
|
recoverPendingMemberships(),
|
|
recoverPendingCashouts(),
|
|
recoverClosingTables(),
|
|
processExpiredTurns(),
|
|
processExpiredLobbies()
|
|
]);
|
|
|
|
return {
|
|
memberships,
|
|
cashouts,
|
|
closingTables,
|
|
turns,
|
|
lobbies
|
|
};
|
|
}
|
|
|
|
return {
|
|
createTable,
|
|
joinTable,
|
|
getTable,
|
|
listJoinableTables,
|
|
getMyTable,
|
|
setMessageId,
|
|
startTableHand,
|
|
act,
|
|
getLegalPlayerActions,
|
|
viewCards,
|
|
processTurnTimeout,
|
|
processExpiredTurns,
|
|
cashOut,
|
|
finishClosingTable,
|
|
expireLobby,
|
|
processExpiredLobbies,
|
|
recoverPendingCashouts,
|
|
recoverClosingTables,
|
|
recoverPendingMemberships,
|
|
recover,
|
|
listActiveTables,
|
|
|
|
publicTableState,
|
|
sanitizeGameForCaller
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* DEFAULT SERVICE
|
|
* ============================================================
|
|
*/
|
|
|
|
let defaultService = null;
|
|
|
|
function getPokerService() {
|
|
if (!defaultService) {
|
|
defaultService =
|
|
createPokerService();
|
|
}
|
|
|
|
return defaultService;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* EXPORTS
|
|
* ============================================================
|
|
*/
|
|
|
|
module.exports = {
|
|
SPECIAL_GAME_COLLECTION,
|
|
POKER_GAME_TYPE,
|
|
POKER_MEMBERSHIP_TYPE,
|
|
|
|
POKER_MIN_PLAYERS,
|
|
POKER_MAX_PLAYERS,
|
|
POKER_DEFAULT_SMALL_BLIND,
|
|
POKER_DEFAULT_BIG_BLIND,
|
|
POKER_DEFAULT_BUY_IN,
|
|
POKER_TURN_TIMEOUT,
|
|
POKER_LOBBY_TIMEOUT,
|
|
|
|
PokerTableError,
|
|
|
|
createMongoPokerStore,
|
|
createDefaultPokerEconomy,
|
|
createPokerService,
|
|
getPokerService,
|
|
|
|
publicTableState,
|
|
sanitizeGameForCaller,
|
|
membershipGameId,
|
|
buyInOperationId,
|
|
buyInRefundOperationId,
|
|
cashOutOperationId
|
|
};
|