const path = require("path"); const crypto = require("crypto"); const { ensureUserForIdentity } = require("../../src/services/users"); const PLUGIN_ID = "echonomy-games"; const DEFAULT_SETTINGS = { hotpotato_enabled: "1", hotpotato_platform_discord: "1", hotpotato_platform_twitch: "1", hotpotato_platform_youtube: "1", hotpotato_name: "Hot Potato", hotpotato_trigger: "hotpotato", hotpotato_aliases: "potato", hotpotato_min_cost: "10", hotpotato_max_cost: "250", hotpotato_toss_min: "10", hotpotato_toss_max: "25", hotpotato_loss_multiplier: "1", hotpotato_loss_additive: "0", hotpotato_presence_window: "300", coinflip_enabled: "1", coinflip_platform_discord: "1", coinflip_platform_twitch: "1", coinflip_platform_youtube: "1", coinflip_name: "Coinflip", coinflip_trigger: "coinflip", coinflip_aliases: "flip", coinflip_min_bet: "10", coinflip_max_bet: "500", coinflip_multiplier: "2", coinflip_cooldown: "10", mystery_enabled: "1", mystery_platform_discord: "1", mystery_platform_twitch: "1", mystery_platform_youtube: "1", mystery_name: "Mystery Box", mystery_trigger: "mysterybox", mystery_aliases: "box", mystery_min_bet: "10", mystery_max_bet: "500", mystery_multiplier: "2", mystery_cooldown: "10", responses_json: "" }; const DEFAULT_RESPONSES = { hotpotato_start: [ "{user} started {game} with {amount}. {target} has the potato! Toss within {seconds}s." ], hotpotato_toss: [ "{user} tossed the potato to {target}. Toss within {seconds}s!" ], hotpotato_timeout: [ "{loser} ran out of time and paid {loss} total. Winners: {winners}." ], hotpotato_no_targets: [ "No active users to pass the potato to yet." ], hotpotato_already_active: [ "{game} is already active. {holder} has the potato." ], hotpotato_not_holder: [ "Only the current holder can toss the potato." ], hotpotato_not_active: [ "{game} is not active yet. Start it with {trigger} ." ], hotpotato_invalid_amount: [ "Enter an amount between {min} and {max}." ], coinflip_win: [ "{user} flipped heads and won {payout}!" ], coinflip_lose: [ "{user} flipped tails and lost {amount}." ], coinflip_invalid: [ "Enter a bet between {min} and {max}." ], coinflip_cooldown: [ "Wait {seconds}s before flipping again." ], coinflip_insufficient: [ "{reason}" ], mystery_result: [ "{user} opened a box and got {payout} (from {amount})." ], mystery_invalid: [ "Enter a bet between {min} and {max}." ], mystery_cooldown: [ "Wait {seconds}s before opening another box." ], mystery_insufficient: [ "{reason}" ] }; const presence = { discord: new Map(), twitch: new Map(), youtube: new Map() }; const hotPotatoGames = new Map(); const cooldowns = new Map(); let cachedConfig = null; let cachedConfigAt = 0; module.exports = { id: PLUGIN_ID, init({ web, settings, db, commandRouter, discordClient, twitchClient }) { ensureDefaults(db); ensureStatsTable(db); const refreshCommands = registerCommands({ db, commandRouter, settings }); attachDiscordPresence({ discordClient }); attachTwitchPresence({ twitchClient }); const router = web.createRouter(); router.get("/", (req, res) => { const config = getConfig(db); const responses = buildResponses(db); const responsesByGame = { hotpotato: Object.values(responses).filter((entry) => entry.key.startsWith("hotpotato_")), coinflip: Object.values(responses).filter((entry) => entry.key.startsWith("coinflip_")), mystery: Object.values(responses).filter((entry) => entry.key.startsWith("mystery_")) }; const framework = getFramework(); const currencyLabel = framework?.getConfig?.().currency?.plural || framework?.getConfig?.().currency?.name || "Coins"; const stats = { hotpotato: getGameStatsView(db, "hotpotato"), coinflip: getGameStatsView(db, "coinflip"), mystery: getGameStatsView(db, "mystery") }; res.render(path.join(__dirname, "views", "games.ejs"), { title: "Echonomy Games", config, responses, responsesByGame, frameworkReady: Boolean(framework), currencyLabel, stats }); }); router.post("/settings/hotpotato", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } setPluginSetting(db, "hotpotato_enabled", req.body.hotpotato_enabled ? "1" : "0"); setPluginSetting( db, "hotpotato_platform_discord", req.body.hotpotato_platform_discord ? "1" : "0" ); setPluginSetting( db, "hotpotato_platform_twitch", req.body.hotpotato_platform_twitch ? "1" : "0" ); setPluginSetting( db, "hotpotato_platform_youtube", req.body.hotpotato_platform_youtube ? "1" : "0" ); setPluginSetting(db, "hotpotato_name", (req.body.hotpotato_name || "").trim()); setPluginSetting( db, "hotpotato_trigger", (req.body.hotpotato_trigger || "").trim() ); setPluginSetting( db, "hotpotato_aliases", (req.body.hotpotato_aliases || "").trim() ); setPluginSetting(db, "hotpotato_min_cost", (req.body.hotpotato_min_cost || "0").trim()); setPluginSetting(db, "hotpotato_max_cost", (req.body.hotpotato_max_cost || "0").trim()); setPluginSetting(db, "hotpotato_toss_min", (req.body.hotpotato_toss_min || "1").trim()); setPluginSetting(db, "hotpotato_toss_max", (req.body.hotpotato_toss_max || "1").trim()); setPluginSetting( db, "hotpotato_loss_multiplier", (req.body.hotpotato_loss_multiplier || "1").trim() ); setPluginSetting( db, "hotpotato_loss_additive", (req.body.hotpotato_loss_additive || "0").trim() ); setPluginSetting( db, "hotpotato_presence_window", (req.body.hotpotato_presence_window || "300").trim() ); invalidateConfigCache(); refreshCommands?.(); req.session.flash = { type: "success", message: "Hot potato updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/settings/coinflip", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } setPluginSetting(db, "coinflip_enabled", req.body.coinflip_enabled ? "1" : "0"); setPluginSetting( db, "coinflip_platform_discord", req.body.coinflip_platform_discord ? "1" : "0" ); setPluginSetting( db, "coinflip_platform_twitch", req.body.coinflip_platform_twitch ? "1" : "0" ); setPluginSetting( db, "coinflip_platform_youtube", req.body.coinflip_platform_youtube ? "1" : "0" ); setPluginSetting(db, "coinflip_name", (req.body.coinflip_name || "").trim()); setPluginSetting( db, "coinflip_trigger", (req.body.coinflip_trigger || "").trim() ); setPluginSetting( db, "coinflip_aliases", (req.body.coinflip_aliases || "").trim() ); setPluginSetting(db, "coinflip_min_bet", (req.body.coinflip_min_bet || "0").trim()); setPluginSetting(db, "coinflip_max_bet", (req.body.coinflip_max_bet || "0").trim()); setPluginSetting( db, "coinflip_multiplier", (req.body.coinflip_multiplier || "2").trim() ); setPluginSetting( db, "coinflip_cooldown", (req.body.coinflip_cooldown || "10").trim() ); invalidateConfigCache(); refreshCommands?.(); req.session.flash = { type: "success", message: "Coinflip updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/settings/mystery", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } setPluginSetting(db, "mystery_enabled", req.body.mystery_enabled ? "1" : "0"); setPluginSetting( db, "mystery_platform_discord", req.body.mystery_platform_discord ? "1" : "0" ); setPluginSetting( db, "mystery_platform_twitch", req.body.mystery_platform_twitch ? "1" : "0" ); setPluginSetting( db, "mystery_platform_youtube", req.body.mystery_platform_youtube ? "1" : "0" ); setPluginSetting(db, "mystery_name", (req.body.mystery_name || "").trim()); setPluginSetting( db, "mystery_trigger", (req.body.mystery_trigger || "").trim() ); setPluginSetting( db, "mystery_aliases", (req.body.mystery_aliases || "").trim() ); setPluginSetting(db, "mystery_min_bet", (req.body.mystery_min_bet || "0").trim()); setPluginSetting(db, "mystery_max_bet", (req.body.mystery_max_bet || "0").trim()); setPluginSetting( db, "mystery_multiplier", (req.body.mystery_multiplier || "2").trim() ); setPluginSetting( db, "mystery_cooldown", (req.body.mystery_cooldown || "10").trim() ); invalidateConfigCache(); refreshCommands?.(); req.session.flash = { type: "success", message: "Mystery box updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/settings/responses", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } const existing = loadCustomResponses(db); const payload = { ...existing }; for (const key of Object.keys(DEFAULT_RESPONSES)) { const field = `response_${key}`; if (!Object.prototype.hasOwnProperty.call(req.body, field)) { continue; } const raw = (req.body[field] || "").toString(); const lines = raw .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); if (lines.length) { payload[key] = lines; } else { delete payload[key]; } } setPluginSetting(db, "responses_json", JSON.stringify(payload)); invalidateConfigCache(); req.session.flash = { type: "success", message: "Responses updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); web.mount(`/plugins/${PLUGIN_ID}`, router, { label: "Echonomy Games", role: "admin", section: "plugins" }); } }; function deny(res) { return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that page." }); } function ensureDefaults(db) { const existing = getPluginSettings(db); for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { if (existing[key] === undefined) { setPluginSetting(db, key, value); } } } function getPluginSettings(db) { const rows = db .prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?") .all(PLUGIN_ID); return rows.reduce((acc, row) => { acc[row.key] = row.value; return acc; }, {}); } function setPluginSetting(db, key, value) { db.prepare( "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" ).run(PLUGIN_ID, key, value, Date.now()); } function parseBoolean(value, fallback) { if (value === undefined || value === null || value === "") { return fallback; } if (typeof value === "boolean") { return value; } const normalized = value.toString().toLowerCase(); return ["1", "true", "yes", "on"].includes(normalized); } function parseNumber(value, fallback) { if (value === undefined || value === null || value === "") { return fallback; } const number = Number(value); if (!Number.isFinite(number)) { return fallback; } return number; } function parseList(value) { return (value || "") .split(/[,\s]+/) .map((item) => item.trim()) .filter(Boolean); } function normalizeCommand(value, fallback) { const raw = (value || fallback || "").trim().replace(/^!+/, ""); return raw.toLowerCase().replace(/\s+/g, "-"); } function getConfig(db) { const now = Date.now(); if (cachedConfig && now - cachedConfigAt < 2000) { return cachedConfig; } const settings = getPluginSettings(db); const config = { hotpotato: { enabled: parseBoolean(settings.hotpotato_enabled, true), name: settings.hotpotato_name || "Hot Potato", trigger: normalizeCommand(settings.hotpotato_trigger, "hotpotato"), aliases: parseList(settings.hotpotato_aliases), minCost: parseNumber(settings.hotpotato_min_cost, 10), maxCost: parseNumber(settings.hotpotato_max_cost, 250), tossMin: parseNumber(settings.hotpotato_toss_min, 10), tossMax: parseNumber(settings.hotpotato_toss_max, 25), lossMultiplier: parseNumber(settings.hotpotato_loss_multiplier, 1), lossAdditive: parseNumber(settings.hotpotato_loss_additive, 0), presenceWindow: parseNumber(settings.hotpotato_presence_window, 300), platforms: { discord: parseBoolean(settings.hotpotato_platform_discord, true), twitch: parseBoolean(settings.hotpotato_platform_twitch, true), youtube: parseBoolean(settings.hotpotato_platform_youtube, true) } }, coinflip: { enabled: parseBoolean(settings.coinflip_enabled, true), name: settings.coinflip_name || "Coinflip", trigger: normalizeCommand(settings.coinflip_trigger, "coinflip"), aliases: parseList(settings.coinflip_aliases), minBet: parseNumber(settings.coinflip_min_bet, 10), maxBet: parseNumber(settings.coinflip_max_bet, 500), multiplier: parseNumber(settings.coinflip_multiplier, 2), cooldown: parseNumber(settings.coinflip_cooldown, 10), platforms: { discord: parseBoolean(settings.coinflip_platform_discord, true), twitch: parseBoolean(settings.coinflip_platform_twitch, true), youtube: parseBoolean(settings.coinflip_platform_youtube, true) } }, mystery: { enabled: parseBoolean(settings.mystery_enabled, true), name: settings.mystery_name || "Mystery Box", trigger: normalizeCommand(settings.mystery_trigger, "mysterybox"), aliases: parseList(settings.mystery_aliases), minBet: parseNumber(settings.mystery_min_bet, 10), maxBet: parseNumber(settings.mystery_max_bet, 500), multiplier: parseNumber(settings.mystery_multiplier, 2), cooldown: parseNumber(settings.mystery_cooldown, 10), platforms: { discord: parseBoolean(settings.mystery_platform_discord, true), twitch: parseBoolean(settings.mystery_platform_twitch, true), youtube: parseBoolean(settings.mystery_platform_youtube, true) } } }; cachedConfig = config; cachedConfigAt = now; return config; } function invalidateConfigCache() { cachedConfig = null; cachedConfigAt = 0; } function buildResponses(db) { const settings = getPluginSettings(db); const custom = loadCustomResponses(db); const responses = {}; for (const [key, list] of Object.entries(DEFAULT_RESPONSES)) { const override = Array.isArray(custom[key]) ? custom[key] : []; responses[key] = { key, label: toLabel(key), lines: override.length ? override : list }; } return responses; } function getResponseLines(db, key) { const custom = loadCustomResponses(db); if (Array.isArray(custom[key]) && custom[key].length) { return custom[key]; } return DEFAULT_RESPONSES[key] || []; } function loadCustomResponses(db) { const settings = getPluginSettings(db); let custom = {}; try { custom = JSON.parse(settings.responses_json || "{}"); } catch { custom = {}; } return custom || {}; } function ensureStatsTable(db) { db.prepare( "CREATE TABLE IF NOT EXISTS echonomy_game_stats (" + "game_key TEXT PRIMARY KEY," + "plays INTEGER NOT NULL DEFAULT 0," + "coins_won INTEGER NOT NULL DEFAULT 0," + "coins_lost INTEGER NOT NULL DEFAULT 0," + "last_played_at INTEGER," + "last_played_user_id TEXT," + "last_played_username TEXT" + ")" ).run(); } function getGameStats(db, gameKey) { return db .prepare( "SELECT game_key, plays, coins_won, coins_lost, last_played_at, last_played_user_id, last_played_username " + "FROM echonomy_game_stats WHERE game_key = ?" ) .get(gameKey); } function getGameStatsView(db, gameKey) { const stats = getGameStats(db, gameKey); if (!stats) { return { plays: 0, coinsWon: 0, coinsLost: 0, lastPlayedLabel: "Never", lastPlayedUser: null }; } return { plays: stats.plays || 0, coinsWon: stats.coins_won || 0, coinsLost: stats.coins_lost || 0, lastPlayedLabel: stats.last_played_at ? new Date(stats.last_played_at).toLocaleString() : "Never", lastPlayedUser: stats.last_played_username || null }; } function recordGamePlay(db, gameKey, { userId, username }) { const now = Date.now(); db.prepare( "INSERT INTO echonomy_game_stats " + "(game_key, plays, coins_won, coins_lost, last_played_at, last_played_user_id, last_played_username) " + "VALUES (?, 1, 0, 0, ?, ?, ?) " + "ON CONFLICT(game_key) DO UPDATE SET " + "plays = plays + 1, " + "last_played_at = excluded.last_played_at, " + "last_played_user_id = excluded.last_played_user_id, " + "last_played_username = excluded.last_played_username" ).run(gameKey, now, userId || null, username || null); } function recordGameTotals(db, gameKey, { coinsWon = 0, coinsLost = 0 }) { const won = Math.floor(coinsWon || 0); const lost = Math.floor(coinsLost || 0); if (!won && !lost) { return; } db.prepare( "INSERT INTO echonomy_game_stats (game_key, plays, coins_won, coins_lost) " + "VALUES (?, 0, ?, ?) " + "ON CONFLICT(game_key) DO UPDATE SET " + "coins_won = coins_won + ?, " + "coins_lost = coins_lost + ?" ).run(gameKey, won, lost, won, lost); } function pickReply(lines) { if (!lines || !lines.length) { return ""; } return lines[Math.floor(Math.random() * lines.length)]; } function render(template, tokens) { return (template || "").replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => { if (Object.prototype.hasOwnProperty.call(tokens, key)) { return tokens[key]; } return `{${key}}`; }); } function replyWith(db, ctx, key, tokens) { const line = pickReply(getResponseLines(db, key)); if (!line) { return null; } return render(line, tokens); } function toLabel(key) { return key .replace(/_/g, " ") .replace(/\b\w/g, (match) => match.toUpperCase()); } function getFramework() { return global.lumiFrameworks?.echonomy || null; } function registerCommands({ db, commandRouter, settings }) { if (!commandRouter) { return null; } const rebuild = () => { const config = getConfig(db); const commands = []; if (config.hotpotato.enabled && config.hotpotato.trigger) { const triggers = [config.hotpotato.trigger, ...config.hotpotato.aliases]; const platforms = platformsFromConfig(config.hotpotato.platforms); if (platforms.length && triggers.length) { commands.push({ id: "echonomy-games:hotpotato", triggers, platforms, handler: (ctx) => handleHotPotato({ ctx, db, settings }) }); } } if (config.coinflip.enabled && config.coinflip.trigger) { const triggers = [config.coinflip.trigger, ...config.coinflip.aliases]; const platforms = platformsFromConfig(config.coinflip.platforms); if (platforms.length && triggers.length) { commands.push({ id: "echonomy-games:coinflip", triggers, platforms, handler: (ctx) => handleCoinflip({ ctx, db, settings }) }); } } if (config.mystery.enabled && config.mystery.trigger) { const triggers = [config.mystery.trigger, ...config.mystery.aliases]; const platforms = platformsFromConfig(config.mystery.platforms); if (platforms.length && triggers.length) { commands.push({ id: "echonomy-games:mystery", triggers, platforms, handler: (ctx) => handleMystery({ ctx, db, settings }) }); } } commandRouter.registerCommands(PLUGIN_ID, commands); }; rebuild(); return rebuild; } function platformsFromConfig(platforms) { return Object.entries(platforms || {}) .filter(([, enabled]) => enabled) .map(([platform]) => platform); } function getChannelKey(ctx) { if (ctx.platform === "discord") { return ctx.meta?.message?.channelId || "discord"; } if (ctx.platform === "twitch") { return ctx.meta?.channel || "twitch"; } if (ctx.platform === "youtube") { return ctx.meta?.liveChatId || "youtube"; } return "default"; } function recordPresence(platform, channelKey, userId, name) { if (!platform || !channelKey || !userId) { return; } const channelMap = presence[platform] || new Map(); if (!presence[platform]) { presence[platform] = channelMap; } const users = channelMap.get(channelKey) || new Map(); users.set(userId, { name: name || "User", lastSeen: Date.now() }); channelMap.set(channelKey, users); } function getActiveUsers(platform, channelKey, windowSeconds) { const channelMap = presence[platform]; if (!channelMap) { return []; } const users = channelMap.get(channelKey); if (!users) { return []; } const now = Date.now(); const cutoff = now - windowSeconds * 1000; const list = []; for (const [userId, info] of users.entries()) { if (info.lastSeen < cutoff) { users.delete(userId); continue; } list.push({ id: userId, name: info.name || "User" }); } return list; } function attachDiscordPresence({ discordClient }) { if (!discordClient) { return; } discordClient.on("messageCreate", (message) => { if (!message || message.author?.bot) { return; } const display = message.member?.displayName || message.author.globalName || message.author.username || message.author.tag; const profile = ensureUserForIdentity({ provider: "discord", providerUserId: message.author.id, displayName: display, avatar: message.author.avatar ? `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png?size=128` : null }); recordPresence("discord", message.channelId, profile.id, display); }); } function attachTwitchPresence({ twitchClient }) { if (!twitchClient) { return; } twitchClient.on("message", (channel, tags, _message, self) => { if (self) { return; } const userId = tags["user-id"]; if (!userId) { return; } const display = tags["display-name"] || tags.username || "Twitch User"; const profile = ensureUserForIdentity({ provider: "twitch", providerUserId: userId, displayName: display }); recordPresence("twitch", channel, profile.id, display); }); } function recordPresenceFromCtx(ctx) { const platform = ctx.platform; const channelKey = getChannelKey(ctx); const name = ctx.user.displayName || ctx.user.username || "User"; recordPresence(platform, channelKey, ctx.user.id, name); } function randomBetween(min, max) { const low = Math.min(min, max); const high = Math.max(min, max); return Math.floor(Math.random() * (high - low + 1)) + low; } function parseAmount(value) { const number = Number(value); if (!Number.isFinite(number)) { return NaN; } if (number <= 0) { return NaN; } return Math.floor(number); } function getCooldownKey(ctx, key) { return `${ctx.platform}:${ctx.user.id}:${key}`; } function getCooldownLeft(ctx, key, cooldownSeconds) { const lookup = getCooldownKey(ctx, key); const last = cooldowns.get(lookup) || 0; const diff = cooldownSeconds * 1000 - (Date.now() - last); return diff > 0 ? Math.ceil(diff / 1000) : 0; } function setCooldown(ctx, key) { cooldowns.set(getCooldownKey(ctx, key), Date.now()); } async function handleHotPotato({ ctx, db }) { recordPresenceFromCtx(ctx); const config = getConfig(db); const framework = getFramework(); if (!framework) { await ctx.reply("Echonomy framework is not available."); return true; } if (!config.hotpotato.enabled || !config.hotpotato.platforms[ctx.platform]) { return false; } const channelKey = getChannelKey(ctx); const gameKey = `${ctx.platform}:${channelKey}`; const current = hotPotatoGames.get(gameKey); const sub = (ctx.args[0] || "").toLowerCase(); const isToss = ["toss", "pass", "throw", "retoss"].includes(sub); if (isToss) { if (!current) { const msg = replyWith(db, ctx, "hotpotato_not_active", { game: config.hotpotato.name, trigger: config.hotpotato.trigger }); if (msg) { await ctx.reply(msg); } return true; } if (current.holderId !== ctx.user.id) { const msg = replyWith(db, ctx, "hotpotato_not_holder", {}); if (msg) { await ctx.reply(msg); } return true; } const next = pickRandomUser({ platform: ctx.platform, channelKey, exclude: [ctx.user.id], windowSeconds: config.hotpotato.presenceWindow }); if (!next) { const msg = replyWith(db, ctx, "hotpotato_no_targets", {}); if (msg) { await ctx.reply(msg); } return true; } touchUser(current, ctx.user.id, ctx.user.displayName || ctx.user.username); touchUser(current, next.id, next.name); current.holderId = next.id; current.holderName = next.name; current.reply = ctx.reply; resetHotPotatoTimer(gameKey, current, config); const msg = replyWith(db, ctx, "hotpotato_toss", { user: ctx.user.displayName || ctx.user.username, target: next.name, seconds: current.seconds }); if (msg) { await ctx.reply(msg); } return true; } if (current) { const msg = replyWith(db, ctx, "hotpotato_already_active", { game: config.hotpotato.name, holder: current.holderName || "Someone" }); if (msg) { await ctx.reply(msg); } return true; } const amount = parseAmount(ctx.args[0]); if (!Number.isFinite(amount) || amount < config.hotpotato.minCost || amount > config.hotpotato.maxCost) { const msg = replyWith(db, ctx, "hotpotato_invalid_amount", { min: config.hotpotato.minCost, max: config.hotpotato.maxCost }); if (msg) { await ctx.reply(msg); } return true; } const stakeResult = framework.removeBalance({ userId: ctx.user.id, amount, note: `${config.hotpotato.name} entry` }); if (stakeResult?.ok === false) { const msg = replyWith(db, ctx, "coinflip_insufficient", { reason: stakeResult.message || "Insufficient balance." }); if (msg) { await ctx.reply(msg); } return true; } const target = pickRandomUser({ platform: ctx.platform, channelKey, exclude: [ctx.user.id], windowSeconds: config.hotpotato.presenceWindow }); if (!target) { const msg = replyWith(db, ctx, "hotpotato_no_targets", {}); if (msg) { await ctx.reply(msg); } return true; } const displayName = ctx.user.displayName || ctx.user.username || "User"; recordGamePlay(db, "hotpotato", { userId: ctx.user.id, username: displayName }); recordGameTotals(db, "hotpotato", { coinsLost: amount }); const state = { id: crypto.randomUUID(), amount, holderId: target.id, holderName: target.name, touched: new Map(), reply: ctx.reply, platform: ctx.platform, channelKey, db }; touchUser(state, ctx.user.id, displayName); touchUser(state, target.id, target.name); hotPotatoGames.set(gameKey, state); resetHotPotatoTimer(gameKey, state, config); const msg = replyWith(db, ctx, "hotpotato_start", { user: ctx.user.displayName || ctx.user.username, target: target.name, amount, game: config.hotpotato.name, seconds: state.seconds }); if (msg) { await ctx.reply(msg); } return true; } function resetHotPotatoTimer(gameKey, state, config) { if (state.timer) { clearTimeout(state.timer); } const seconds = randomBetween(config.hotpotato.tossMin, config.hotpotato.tossMax); state.seconds = seconds; state.deadlineAt = Date.now() + seconds * 1000; state.timer = setTimeout(() => resolveHotPotato(gameKey, state.id), seconds * 1000); } function touchUser(state, userId, name) { if (!state.touched.has(userId)) { state.touched.set(userId, name || "User"); } } function pickRandomUser({ platform, channelKey, exclude, windowSeconds }) { const list = getActiveUsers(platform, channelKey, windowSeconds).filter( (user) => !exclude.includes(user.id) ); if (!list.length) { return null; } return list[Math.floor(Math.random() * list.length)]; } async function resolveHotPotato(gameKey, gameId) { const state = hotPotatoGames.get(gameKey); if (!state || state.id !== gameId) { return; } const framework = getFramework(); if (!framework) { hotPotatoGames.delete(gameKey); return; } const config = getConfig(state.db); const penaltyBase = state.amount || 0; const lossTotal = Math.max( 0, Math.floor(penaltyBase * config.hotpotato.lossMultiplier + config.hotpotato.lossAdditive) ); const recipients = Array.from(state.touched.entries()).filter( ([id]) => id !== state.holderId ); const perRecipient = recipients.length && lossTotal > 0 ? Math.floor(lossTotal / recipients.length) : 0; const winners = []; if (perRecipient > 0 && recipients.length) { for (const [userId, name] of recipients) { try { framework.createTransaction({ type: "hotpotato_payout", amount: perRecipient, fromUserId: state.holderId, toUserId: userId, note: "Hot potato penalty", meta: { game: "hotpotato" }, allowNegative: true }); winners.push(name); } catch { // ignore individual payout failures } } recordGameTotals(state.db, "hotpotato", { coinsLost: lossTotal, coinsWon: perRecipient * recipients.length }); } else if (lossTotal > 0) { try { framework.createTransaction({ type: "hotpotato_penalty", amount: lossTotal, fromUserId: state.holderId, toUserId: null, note: "Hot potato penalty", meta: { game: "hotpotato" }, allowNegative: true }); } catch { // ignore } recordGameTotals(state.db, "hotpotato", { coinsLost: lossTotal }); } const msg = replyWith(state.db, { reply: state.reply }, "hotpotato_timeout", { loser: state.holderName || "Someone", loss: lossTotal, winners: winners.length ? winners.join(", ") : "No one" }); if (msg && state.reply) { try { await state.reply(msg); } catch { // ignore } } hotPotatoGames.delete(gameKey); } async function handleCoinflip({ ctx, db }) { recordPresenceFromCtx(ctx); const config = getConfig(db); const framework = getFramework(); if (!framework) { await ctx.reply("Echonomy framework is not available."); return true; } if (!config.coinflip.enabled || !config.coinflip.platforms[ctx.platform]) { return false; } const amount = parseAmount(ctx.args[0]); if (!Number.isFinite(amount) || amount < config.coinflip.minBet || amount > config.coinflip.maxBet) { const msg = replyWith(db, ctx, "coinflip_invalid", { min: config.coinflip.minBet, max: config.coinflip.maxBet }); if (msg) { await ctx.reply(msg); } return true; } const cooldownLeft = getCooldownLeft(ctx, "coinflip", config.coinflip.cooldown); if (cooldownLeft > 0) { const msg = replyWith(db, ctx, "coinflip_cooldown", { seconds: cooldownLeft }); if (msg) { await ctx.reply(msg); } return true; } const stakeResult = framework.removeBalance({ userId: ctx.user.id, amount, note: `${config.coinflip.name} bet` }); if (stakeResult?.ok === false) { const msg = replyWith(db, ctx, "coinflip_insufficient", { reason: stakeResult.message || "Insufficient balance." }); if (msg) { await ctx.reply(msg); } return true; } setCooldown(ctx, "coinflip"); const displayName = ctx.user.displayName || ctx.user.username || "User"; recordGamePlay(db, "coinflip", { userId: ctx.user.id, username: displayName }); const win = Math.random() >= 0.5; if (win) { const payout = Math.floor(amount * config.coinflip.multiplier); framework.addBalance({ userId: ctx.user.id, amount: payout, note: `${config.coinflip.name} win` }); recordGameTotals(db, "coinflip", { coinsLost: amount, coinsWon: payout }); const msg = replyWith(db, ctx, "coinflip_win", { user: displayName, payout }); if (msg) { await ctx.reply(msg); } return true; } recordGameTotals(db, "coinflip", { coinsLost: amount, coinsWon: 0 }); const msg = replyWith(db, ctx, "coinflip_lose", { user: displayName, amount }); if (msg) { await ctx.reply(msg); } return true; } async function handleMystery({ ctx, db }) { recordPresenceFromCtx(ctx); const config = getConfig(db); const framework = getFramework(); if (!framework) { await ctx.reply("Echonomy framework is not available."); return true; } if (!config.mystery.enabled || !config.mystery.platforms[ctx.platform]) { return false; } const amount = parseAmount(ctx.args[0]); if (!Number.isFinite(amount) || amount < config.mystery.minBet || amount > config.mystery.maxBet) { const msg = replyWith(db, ctx, "mystery_invalid", { min: config.mystery.minBet, max: config.mystery.maxBet }); if (msg) { await ctx.reply(msg); } return true; } const cooldownLeft = getCooldownLeft(ctx, "mystery", config.mystery.cooldown); if (cooldownLeft > 0) { const msg = replyWith(db, ctx, "mystery_cooldown", { seconds: cooldownLeft }); if (msg) { await ctx.reply(msg); } return true; } const stakeResult = framework.removeBalance({ userId: ctx.user.id, amount, note: `${config.mystery.name} entry` }); if (stakeResult?.ok === false) { const msg = replyWith(db, ctx, "mystery_insufficient", { reason: stakeResult.message || "Insufficient balance." }); if (msg) { await ctx.reply(msg); } return true; } setCooldown(ctx, "mystery"); const displayName = ctx.user.displayName || ctx.user.username || "User"; const maxPayout = Math.max(0, Math.floor(amount * config.mystery.multiplier)); const payout = Math.floor(Math.random() * (maxPayout + 1)); if (payout > 0) { framework.addBalance({ userId: ctx.user.id, amount: payout, note: `${config.mystery.name} payout` }); } recordGamePlay(db, "mystery", { userId: ctx.user.id, username: displayName }); recordGameTotals(db, "mystery", { coinsLost: amount, coinsWon: payout }); const msg = replyWith(db, ctx, "mystery_result", { user: displayName, amount, payout }); if (msg) { await ctx.reply(msg); } return true; }