Lumi/plugins/echonomy-games/index.js
2026-05-30 20:37:42 +02:00

1219 lines
35 KiB
JavaScript

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} <amount>."
],
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;
}