diff --git a/commands.js b/commands.js index bde553a..32bdb7b 100644 --- a/commands.js +++ b/commands.js @@ -9,7 +9,7 @@ function createTimesChoices() { for (let choice of choices) { commandChoices.push({ name: capitalize(choice.name), - value: choice.value.toString(), + value: choice.value?.toString(), }); } diff --git a/game.js b/game.js index f49f667..9132bd3 100644 --- a/game.js +++ b/game.js @@ -10,7 +10,7 @@ import { getUserElo, insertElos, updateElo, - getAllSkins + getAllSkins, deleteSOTD, insertSOTD, clearSOTDStats, getAllSOTDStats } from './init_database.js' import {C4_COLS, C4_ROWS, skins} from "./index.js"; @@ -164,14 +164,14 @@ export async function eloHandler(p1, p2, p1score, p2score, type) { if (!p1elo) { await insertElos.run({ - id: p1.toString(), + id: p1?.toString(), elo: 100, }) p1elo = await getUserElo.get({ id: p1 }) } if (!p2elo) { await insertElos.run({ - id: p2.toString(), + id: p2?.toString(), elo: 100, }) p2elo = await getUserElo.get({ id: p2 }) @@ -179,7 +179,7 @@ export async function eloHandler(p1, p2, p1score, p2score, type) { if (p1score === p2score) { insertGame.run({ - id: p1.toString() + '-' + p2.toString() + '-' + Date.now().toString(), + id: p1?.toString() + '-' + p2?.toString() + '-' + Date.now()?.toString(), p1: p1, p2: p2, p1_score: p1score, @@ -206,7 +206,7 @@ export async function eloHandler(p1, p2, p1score, p2score, type) { updateElo.run({ id: p2, elo: p2newElo }) insertGame.run({ - id: p1.toString() + '-' + p2.toString() + '-' + Date.now().toString(), + id: p1?.toString() + '-' + p2?.toString() + '-' + Date.now()?.toString(), p1: p1, p2: p2, p1_score: p1score, @@ -263,7 +263,7 @@ export async function pokerEloHandler(room) { updateElo.run({ id: player.id, elo: newElo }) insertGame.run({ - id: player.id + '-' + Date.now().toString(), + id: player.id + '-' + Date.now()?.toString(), p1: player.id, p2: null, p1_score: actualScore, @@ -368,6 +368,20 @@ export function formatConnect4BoardForDiscord(board) { return board.map(row => row.map(cell => symbols[cell]).join('')).join('\n'); } +/** + * Creates a seedable pseudorandom number generator (PRNG) using the Mulberry32 algorithm. + * @param {number} seed - An initial number to seed the generator. + * @returns {function} A function that, when called, returns a pseudorandom number between 0 and 1. + */ +export function createSeededRNG(seed) { + return function() { + let t = seed += 0x6D2B79F5; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } +} + /** * Shuffles an array in place using the Fisher-Yates algorithm. * @param {Array} array - The array to shuffle. @@ -384,6 +398,31 @@ export function shuffle(array) { return array; } +/** + * Shuffles an array in place using a seedable PRNG via the Fisher-Yates algorithm. + * @param {Array} array - The array to shuffle. + * @param {function} rng - A seedable random number generator function. + * @returns {Array} The shuffled array. + */ +export function seededShuffle(array, rng) { + let currentIndex = array.length, + randomIndex; + + // While there remain elements to shuffle. + while (currentIndex !== 0) { + // Pick a remaining element using the seeded RNG. + randomIndex = Math.floor(rng() * currentIndex); + currentIndex--; + + // And swap it with the current element. + [array[currentIndex], array[randomIndex]] = [ + array[randomIndex], + array[currentIndex], + ]; + } + return array; +} + /** * Deals a shuffled deck into the initial Solitaire game state. * @param {Array} deck - A shuffled deck of cards. @@ -542,7 +581,7 @@ export function createDeck() { * @param {Object} gameState - The current state of the game. * @param {Object} moveData - The details of the move. */ -export function moveCard(gameState, moveData) { +export async function moveCard(gameState, moveData) { const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex } = moveData; // Identify the source pile array @@ -570,6 +609,14 @@ export function moveCard(gameState, moveData) { // Using the spread operator (...) to add all items from the cardsToMove array. destPile.push(...cardsToMove); + if (sourcePileType === 'foundationPiles') { + sotdMoveUpdate(gameState, -15) + } else if (destPileType === 'foundationPiles') { + sotdMoveUpdate(gameState, 10) + } else { + sotdMoveUpdate(gameState, 0) + } + // After moving, if the source was a tableau pile and it's not empty, // flip the new top card to be face-up. if (sourcePileType === 'tableauPiles' && sourcePile.length > 0) { @@ -581,7 +628,7 @@ export function moveCard(gameState, moveData) { * Moves a card from the stock to the waste pile. If stock is empty, resets it from the waste. * @param {Object} gameState - The current state of the game. */ -export function drawCard(gameState) { +export async function drawCard(gameState) { if (gameState.stockPile.length > 0) { const card = gameState.stockPile.pop(); card.faceUp = true; @@ -592,6 +639,7 @@ export function drawCard(gameState) { gameState.stockPile.forEach(card => (card.faceUp = false)); gameState.wastePile = []; } + sotdMoveUpdate(gameState, 0) } /** @@ -605,4 +653,55 @@ export function checkWinCondition(gameState) { 0 ); return foundationCardCount === 52; +} + +export function initTodaysSOTD() { + const rankings = getAllSOTDStats.all() + const firstPlaceId = rankings > 0 ? rankings[0].user_id : null + + if (firstPlaceId) { + const firstPlaceUser = getUser.get(firstPlaceId) + if (firstPlaceUser) { + updateUserCoins.run({ id: firstPlaceId, coins: firstPlaceUser.coins + 1000 }); + insertLog.run({ + id: firstPlaceId + '-' + Date.now(), + user_id: firstPlaceId, + action: 'SOTD_FIRST_PLACE', + target_user_id: null, + coins_amount: 1000, + user_new_amount: firstPlaceUser.coins + 1000, + }) + } + } + + const newRandomSeed = Date.now().toString(36) + Math.random().toString(36).substr(2); + let numericSeed = 0; + for (let i = 0; i < newRandomSeed.length; i++) { + numericSeed = (numericSeed + newRandomSeed.charCodeAt(i)) & 0xFFFFFFFF; + } + + const rng = createSeededRNG(numericSeed); + const deck = createDeck(); + const shuffledDeck = seededShuffle(deck, rng); + const todaysSOTD = deal(shuffledDeck); + todaysSOTD.seed = newRandomSeed; + + clearSOTDStats.run() + deleteSOTD.run() + insertSOTD.run({ + id: 0, + tableauPiles: JSON.stringify(todaysSOTD.tableauPiles), + foundationPiles: JSON.stringify(todaysSOTD.foundationPiles), + stockPile: JSON.stringify(todaysSOTD.stockPile), + wastePile: JSON.stringify(todaysSOTD.wastePile), + seed: todaysSOTD.seed, + }) + console.log('Today\'s SOTD is ready') +} + +export function sotdMoveUpdate(gameState, points) { + if (gameState.isSOTD) { + gameState.moves++ + gameState.score += points + } } \ No newline at end of file diff --git a/index.js b/index.js index e6a6d31..35e60dd 100644 --- a/index.js +++ b/index.js @@ -27,7 +27,7 @@ import { pokerEloHandler, randomSkinPrice, slowmodesHandler, - deal, isValidMove, moveCard, shuffle, drawCard, checkWinCondition, createDeck, + deal, isValidMove, moveCard, seededShuffle, drawCard, checkWinCondition, createDeck, initTodaysSOTD, createSeededRNG, } from './game.js'; import { Client, GatewayIntentBits, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import cron from 'node-cron'; @@ -50,9 +50,19 @@ import { getSkin, getAllAvailableSkins, getUserInventory, - getTopSkins, updateUserCoins, - insertLog, stmtLogs, - getLogs, getUserLogs, getUserElo, getUserGames, getUsersByElo, resetDailyReward, queryDailyReward, + getTopSkins, + updateUserCoins, + insertLog, + stmtLogs, + getLogs, + getUserLogs, + getUserElo, + getUserGames, + getUsersByElo, + resetDailyReward, + queryDailyReward, + deleteSOTD, + insertSOTD, getSOTD, insertSOTDStats, deleteUserSOTDStats, getUserSOTDStats, getAllSOTDStats, } from './init_database.js'; import { getValorantSkins, getSkinTiers } from './valo.js'; import {sleep} from "openai/core"; @@ -81,7 +91,7 @@ const activeInventories = {}; const activeSearchs = {}; const activeSlowmodes = {}; const activePredis = {}; -let todaysHydrateCron = '' +let todaysSOTD = {}; const SPAM_INTERVAL = process.env.SPAM_INTERVAL const client = new Client({ @@ -548,6 +558,9 @@ client.on('messageCreate', async (message) => { } console.log(`Result for ${amount} skins`) } + else if (message.content.toLowerCase().startsWith('?sotd')) { + initTodaysSOTD() + } else if (message.author.id === process.env.DEV_ID) { const prefix = process.env.DEV_SITE === 'true' ? 'dev' : 'flopo' if (message.content === prefix + ':add-coins-to-users') { @@ -635,10 +648,6 @@ client.on('messageCreate', async (message) => { client.once('ready', async () => { console.log(`Logged in as ${client.user.tag}`); console.log(`[Connected with ${FLAPI_URL}]`) - const randomMinute = Math.floor(Math.random() * 60); - const randomHour = Math.floor(Math.random() * (18 - 8 + 1)) + 8; - todaysHydrateCron = `${randomMinute} ${randomHour} * * *` - console.log(todaysHydrateCron) await getAkhys(); console.log('FlopoBOT marked as ready') @@ -683,13 +692,8 @@ client.once('ready', async () => { } }); - // ─── πŸ’€ Midnight Chaos Timer ────────────────────── + // at midnight cron.schedule(process.env.CRON_EXPR, async () => { - const randomMinute = Math.floor(Math.random() * 60); - const randomHour = Math.floor(Math.random() * (18 - 8 + 1)) + 8; - todaysHydrateCron = `${randomMinute} ${randomHour} * * *` - console.log(todaysHydrateCron) - try { const akhys = getAllUsers.all() akhys.forEach((akhy) => { @@ -698,6 +702,8 @@ client.once('ready', async () => { } catch (e) { console.log(e) } + + initTodaysSOTD() }); // users/skins dayly fetch at 7am @@ -3148,7 +3154,7 @@ app.post('/slowmode', async (req, res) => { return res.status(200).json({ message: 'Slowmode retirΓ©'}) } else { let timeLeft = (activeSlowmodes[userId].endAt - Date.now())/1000 - timeLeft = timeLeft > 60 ? (timeLeft/60).toFixed().toString() + 'min' : timeLeft.toFixed().toString() + 'sec' + timeLeft = timeLeft > 60 ? (timeLeft/60).toFixed()?.toString() + 'min' : timeLeft.toFixed()?.toString() + 'sec' return res.status(403).json({ message: `${user.globalName} est dΓ©jΓ  en slowmode (${timeLeft})`}) } } else if (userId === commandUserId) { @@ -3196,7 +3202,7 @@ app.post('/start-predi', async (req, res) => { } const startTime = Date.now() - const newPrediId = commandUserId.toString() + '-' + startTime.toString() + const newPrediId = commandUserId?.toString() + '-' + startTime?.toString() let msgId; try { @@ -4448,16 +4454,102 @@ async function updatePokerPlayersSolve(roomId) { for (const playerId in pokerRooms[roomId].players) { const player = pokerRooms[roomId].players[playerId] let fullHand = pokerRooms[roomId].tapis - player.solve = Hand.solve(fullHand.concat(player.hand), 'standard', false)?.descr + if (!fullHand && !player.hand) { + player.solve = Hand.solve([], 'standard', false)?.descr + } else if (!fullHand) { + player.solve = Hand.solve(player.hand, 'standard', false)?.descr + } else if (!player.hand) { + player.solve = Hand.solve(fullHand, 'standard', false)?.descr + } else { + player.solve = Hand.solve(fullHand.concat(player.hand), 'standard', false)?.descr + } } } +app.get('/solitaire/sotd/rankings', async (req, res) => { + const rankings = getAllSOTDStats.all() + + return res.json({ rankings }) +}) + app.post('/solitaire/start', async (req, res) => { const userId = req.body.userId; - const deck = shuffle(createDeck()); - const gameState = deal(deck); + let userSeed = req.body.userSeed; + + if (activeSolitaireGames[userId] && !activeSolitaireGames[userId].isSOTD) { + return res.json({ succes: true, gameState: activeSolitaireGames[userId]}) + } + + if (userSeed) { + let numericSeed = 0 + for (let i = 0; i < userSeed.length; i++) { + numericSeed = (numericSeed + userSeed.charCodeAt(i)) & 0xFFFFFFFF; + } + + const rng = createSeededRNG(numericSeed); + const deck = createDeck() + const shuffledDeck = seededShuffle(deck, rng); + const gameState = deal(shuffledDeck); + gameState.seed = userSeed; + + activeSolitaireGames[userId] = gameState; + + return res.json({ success: true, gameState }); + } else { + const newRandomSeed = Date.now()?.toString(36) + Math.random()?.toString(36).substr(2); + let numericSeed = 0; + for (let i = 0; i < newRandomSeed.length; i++) { + numericSeed = (numericSeed + newRandomSeed.charCodeAt(i)) & 0xFFFFFFFF; + } + + const rng = createSeededRNG(numericSeed); + const deck = createDeck(); + const shuffledDeck = seededShuffle(deck, rng); + const gameState = deal(shuffledDeck); + gameState.seed = newRandomSeed; + + activeSolitaireGames[userId] = gameState; + + return res.json({ success: true, gameState }); + } +}); + +app.post('/solitaire/start/sotd', async (req, res) => { + const userId = req.body.userId + const sotd = getSOTD.get(); + + const user = getUser.get(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + if (activeSolitaireGames[userId] && activeSolitaireGames[userId].isSOTD) { + return res.json({ success: true, gameState: activeSolitaireGames[userId]}) + } + + const gameState = { + tableauPiles: JSON.parse(sotd.tableauPiles), + foundationPiles: JSON.parse(sotd.foundationPiles), + stockPile: JSON.parse(sotd.stockPile), + wastePile: JSON.parse(sotd.wastePile), + isDone: false, + isSOTD: true, + hasFinToday: false, + startTime: Date.now(), + endTime: null, + moves: 0, + score: 0, + seed: sotd.seed, + } + activeSolitaireGames[userId] = gameState res.json({ success: true, gameState }); +}) + +app.post('/solitaire/reset', async (req, res) => { + const userId = req.body.userId; + delete activeSolitaireGames[userId] + res.json({ success: true }); }); /** @@ -4467,12 +4559,11 @@ app.post('/solitaire/start', async (req, res) => { app.get('/solitaire/state/:userId', (req, res) => { const { userId } = req.params; let gameState = activeSolitaireGames[userId]; - if (!gameState) { - console.log(`Creating new Solitaire game for user: ${userId}`); + /*if (!gameState) { const deck = shuffle(createDeck()); gameState = deal(deck); activeSolitaireGames[userId] = gameState; - } + }*/ res.json({ success: true, gameState }); }); @@ -4480,7 +4571,7 @@ app.get('/solitaire/state/:userId', (req, res) => { * POST /solitaire/move * Receives all necessary move data from the frontend. */ -app.post('/solitaire/move', (req, res) => { +app.post('/solitaire/move', async (req, res) => { // Destructure the complete move data from the request body // Frontend must send all these properties. const { @@ -4501,10 +4592,53 @@ app.post('/solitaire/move', (req, res) => { // Pass the entire data object to the validation function if (isValidMove(gameState, req.body)) { // If valid, mutate the state - moveCard(gameState, req.body); + await moveCard(gameState, req.body); const win = checkWinCondition(gameState); - if (win) gameState.isDone = true - res.json({ success: true, gameState, win }); + if (win) { + gameState.isDone = true + if (gameState.isSOTD) { + gameState.hasFinToday = true; + gameState.endTime = Date.now(); + const userStats = getUserSOTDStats.get(userId); + if (userStats) { + if ( + (gameState.score > userStats.score) || + (gameState.score === userStats.score && gameState.moves < userStats.moves) || + (gameState.score === userStats.score && gameState.moves === userStats.moves && gameState.time < userStats.time) + ) { + deleteUserSOTDStats.run(userId); + insertSOTDStats.run({ + id: userId, + user_id: userId, + time: gameState.endTime - gameState.startTime, + moves: gameState.moves, + score: gameState.score, + }) + } + } else { + insertSOTDStats.run({ + id: userId, + user_id: userId, + time: gameState.endTime - gameState.startTime, + moves: gameState.moves, + score: gameState.score, + }) + const user = getUser.get(userId) + if (user) { + updateUserCoins.run({ id: userId, coins: user.coins + 1000 }); + insertLog.run({ + id: userId + '-' + Date.now(), + user_id: userId, + action: 'SOTD_WIN', + target_user_id: null, + coins_amount: 1000, + user_new_amount: user.coins + 1000, + }) + } + } + } + } + res.json({ success: true, gameState, win, endTime: win ? Date.now() : null }); } else { // If the move is invalid, send a specific error message res.status(400).json({ error: 'Invalid move' }); @@ -4515,13 +4649,13 @@ app.post('/solitaire/move', (req, res) => { * POST /solitaire/draw * Draws a card from the stock pile to the waste pile. */ -app.post('/solitaire/draw', (req, res) => { +app.post('/solitaire/draw', async (req, res) => { const { userId } = req.body; const gameState = activeSolitaireGames[userId]; if (!gameState) { return res.status(404).json({ error: `Game not found for user ${userId}` }); } - drawCard(gameState); + await drawCard(gameState); res.json({ success: true, gameState }); }); diff --git a/init_database.js b/init_database.js index a5c5322..e104d36 100644 --- a/init_database.js +++ b/init_database.js @@ -120,4 +120,38 @@ export const getUserElo = flopoDB.prepare(`SELECT * FROM elos WHERE id = @id`); export const updateElo = flopoDB.prepare('UPDATE elos SET elo = @elo WHERE id = @id'); -export const getUsersByElo = flopoDB.prepare('SELECT * FROM users JOIN elos ON elos.id = users.id ORDER BY elos.elo DESC') \ No newline at end of file +export const getUsersByElo = flopoDB.prepare('SELECT * FROM users JOIN elos ON elos.id = users.id ORDER BY elos.elo DESC') + +export const stmtSOTD = flopoDB.prepare(` + CREATE TABLE IF NOT EXISTS sotd ( + id INT PRIMARY KEY, + tableauPiles TEXT, + foundationPiles TEXT, + stockPile TEXT, + wastePile TEXT, + isDone BOOLEAN DEFAULT false, + seed TEXT + ) +`); +stmtSOTD.run() + +export const getSOTD = flopoDB.prepare(`SELECT * FROM sotd WHERE id = '0'`) +export const insertSOTD = flopoDB.prepare(`INSERT INTO sotd (id, tableauPiles, foundationPiles, stockPile, wastePile, seed) VALUES (@id, @tableauPiles, @foundationPiles, @stockPile, @wastePile, @seed)`) +export const deleteSOTD = flopoDB.prepare(`DELETE FROM sotd WHERE id = '0'`) + +export const stmtSOTDStats = flopoDB.prepare(` + CREATE TABLE IF NOT EXISTS sotd_stats ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users, + time INTEGER, + moves INTEGER, + score INTEGER + ) +`); +stmtSOTDStats.run() + +export const getAllSOTDStats = flopoDB.prepare(`SELECT sotd_stats.*, users.globalName FROM sotd_stats JOIN users ON users.id = sotd_stats.user_id ORDER BY score DESC, moves ASC, time ASC`); +export const getUserSOTDStats = flopoDB.prepare(`SELECT * FROM sotd_stats WHERE user_id = ?`); +export const insertSOTDStats = flopoDB.prepare(`INSERT INTO sotd_stats (id, user_id, time, moves, score) VALUES (@id, @user_id, @time, @moves, @score)`); +export const clearSOTDStats = flopoDB.prepare(`DELETE FROM sotd_stats`); +export const deleteUserSOTDStats = flopoDB.prepare(`DELETE FROM sotd_stats WHERE user_id = ?`); \ No newline at end of file