1219 lines
35 KiB
JavaScript
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;
|
|
}
|