From 48658997a38c89e3211688ee741ace441a8f21a5 Mon Sep 17 00:00:00 2001 From: Harambe Date: Sat, 4 Jul 2026 21:14:21 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20task-000:=20add=20VotingSystem=20mo?= =?UTF-8?q?dule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/VotingSystem/module.json | 163 +++++++++++++ .../VotingSystem/src/commands/vote/index.js | 226 ++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 modules/VotingSystem/module.json create mode 100644 modules/VotingSystem/src/commands/vote/index.js diff --git a/modules/VotingSystem/module.json b/modules/VotingSystem/module.json new file mode 100644 index 0000000..ff5d320 --- /dev/null +++ b/modules/VotingSystem/module.json @@ -0,0 +1,163 @@ +{ + "name": "VotingSystem", + "author": "Unknown", + "description": "Run vote rewards for your game server community through Takaro. VotingSystem gives players a /vote command and gives admins a simple module foundation for rewarding voting activity.\n\nUse this module to:\n- Add a player-facing vote command.\n- Connect voting behavior to a community reward workflow.\n- Encourage players to support server visibility.\n- Keep vote-related commands inside Takaro's module system.\n\nGood fit for server owners who use voting sites, vote rewards, or community growth loops and want that workflow represented in Takaro.", + "version": "latest", + "supportedGames": [ + "all" + ], + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "votingsite", + "votekey", + "novote", + "alreadyclaimed" + ], + "additionalProperties": false, + "properties": { + "votingsite": { + "title": "votingsite", + "description": "Site where players vote. Example: \nhttps://7daystodie-servers.com for 7 Days to Die;\nhttps://rust-servers.net for RUST\n\n", + "default": "https://7daystodie-servers.com", + "type": "string" + }, + "votekey": { + "title": "votekey", + "description": "Vote key provided by the voting site", + "type": "string" + }, + "novote": { + "title": "novote", + "description": "Message to send to the player when voting site reports no vote was cast. ", + "default": "You haven't voted yet. Please vote at https://7daystodie-servers.com first.", + "type": "string" + }, + "alreadyclaimed": { + "title": "alreadyclaimed", + "description": "Message to send to the player when the voting site reports the vote reward was already claimed.", + "default": "You have already claimed your last vote.", + "type": "string" + }, + "privatemessage": { + "title": "privatemessage", + "description": "Optional message to send to the player when the voting site reports a vote with no reward claimed yet. \nExample: Thanks for voting.\nLeave it blank if you want nothing to be sent instead.", + "default": "", + "type": "string" + }, + "publicmessage": { + "title": "publicmessage", + "description": "Optional message to broadcast to all players online when vote is successful. Use {name} in the message to refer to the player name.\nExample: [00FF00]{name}[-] voted at 7daystodie-servers.com and was rewarded Takaro Coins!\nLeave it blank if you want nothing to be sent instead.\n", + "default": "", + "type": "string" + }, + "currency": { + "title": "currency", + "description": "How much currency to add to the player in Takaro, if any.\nMake sure to enable economy for this server.", + "default": 0, + "type": "number", + "minimum": 0 + }, + "randomitemnumber": { + "title": "randomitemnumber", + "description": "Defines how many random items will be rewarded to the player, picked from randomitemlist config field.\nThe quantities for each item picked is defined on the mentioned config field.", + "default": 0, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "randomitemlist": { + "title": "Items", + "description": "List of items that will be used to select a random reward for the vote, as many times as defined at randomitemnumber.", + "x-component": "item", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "title": "Item", + "properties": { + "item": { + "type": "string", + "title": "Item" + }, + "amount": { + "type": "number", + "title": "Amount", + "minimum": 1, + "default": 1, + "multipleOf": 1 + }, + "quality": { + "type": "string", + "title": "Quality" + } + }, + "required": [ + "item" + ], + "additionalProperties": false + }, + "default": [] + }, + "fixedrewards": { + "title": "Items", + "description": "All the items in this list will be given as a reward for the vote.", + "x-component": "item", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "title": "Item", + "properties": { + "item": { + "type": "string", + "title": "Item" + }, + "amount": { + "type": "number", + "title": "Amount", + "minimum": 1, + "default": 1, + "multipleOf": 1 + }, + "quality": { + "type": "string", + "title": "Quality" + } + }, + "required": [ + "item" + ], + "additionalProperties": false + }, + "default": [] + } + } + }, + "uiSchema": { + "randomitemlist": { + "items": { + "item": { + "ui:widget": "item" + } + } + }, + "fixedrewards": { + "items": { + "item": { + "ui:widget": "item" + } + } + } + }, + "commands": { + "vote": { + "trigger": "vote", + "description": "Claim rewards for voting for the server", + "helpText": "Claim rewards for voting for the server", + "function": "src/commands/vote/index.js", + "arguments": [] + } + } +} diff --git a/modules/VotingSystem/src/commands/vote/index.js b/modules/VotingSystem/src/commands/vote/index.js new file mode 100644 index 0000000..a5d6c7e --- /dev/null +++ b/modules/VotingSystem/src/commands/vote/index.js @@ -0,0 +1,226 @@ +import { takaro, data, axios } from '@takaro/helpers'; + +function buildVoteApiUrl(votingsite, params) { + const baseUrl = new URL(votingsite); + if (!['http:', 'https:'].includes(baseUrl.protocol)) { + throw new Error('Voting site URL must use http or https.'); + } + + const apiUrl = new URL('/api/', baseUrl); + for (const [key, value] of Object.entries(params)) { + apiUrl.searchParams.set(key, String(value)); + } + + return apiUrl.toString(); +} + +function sanitizeError(error) { + return { + message: error?.message, + status: error?.response?.status, + statusText: error?.response?.statusText, + responseData: + typeof error?.response?.data === 'string' + ? error.response.data.slice(0, 200) + : error?.response?.data, + }; +} + +function getRewardAmount(itemConfig, fallbackAmount = 1) { + const amount = itemConfig?.amount ?? fallbackAmount; + if (!Number.isInteger(amount) || amount < 1) { + throw new Error('Vote reward item amounts must be whole numbers of at least 1.'); + } + + return amount; +} + +async function resolveConfiguredItem(itemConfig, fallbackAmount = 1) { + if (typeof itemConfig === 'string') { + return { + gameItemName: itemConfig, + displayName: itemConfig, + amount: fallbackAmount, + quality: '0', + }; + } + + if (!itemConfig?.item) { + throw new Error('Vote reward item config is missing an item.'); + } + + const item = (await takaro.item.itemControllerFindOne(itemConfig.item)).data.data; + const amount = getRewardAmount(itemConfig, fallbackAmount); + + return { + gameItemName: item.id, + displayName: item.name, + amount, + quality: itemConfig.quality ?? '', + }; +} + +async function giveResolvedItem(item) { + try { + await takaro.gameserver.gameServerControllerGiveItem(data.gameServerId, data.player.id, { + name: item.gameItemName, + amount: item.amount, + quality: item.quality, + }); + } catch (error) { + // Some games do not support item quality. Retry without quality before failing the reward. + await takaro.gameserver.gameServerControllerGiveItem(data.gameServerId, data.player.id, { + name: item.gameItemName, + amount: item.amount, + quality: '', + }); + } +} + +function selectRandomRewards(randomitemnumber, randomitemlist = []) { + const availableRandomRewards = [...randomitemlist]; + const requestedCount = Number.isInteger(randomitemnumber) ? randomitemnumber : 0; + const actualRandomItemNumber = Math.min(Math.max(requestedCount, 0), availableRandomRewards.length); + const selectedRewards = []; + + for (let i = 0; i < actualRandomItemNumber; i++) { + const randomIndex = Math.floor(Math.random() * availableRandomRewards.length); + selectedRewards.push(availableRandomRewards.splice(randomIndex, 1)[0]); + } + + return selectedRewards; +} + +async function main() { + const { + votingsite, + votekey, + novote, + alreadyclaimed, + privatemessage, + publicmessage, + currency, + randomitemnumber, + randomitemlist = [], + fixedrewards = [], + } = data.module.userConfig; + const { steamId } = data.player; + + const voteIdentity = steamId ? { steamid: steamId } : { username: data.player.name }; + + let votingSiteResponse; + try { + votingSiteResponse = String( + ( + await axios.get( + buildVoteApiUrl(votingsite, { + object: 'votes', + element: 'claim', + key: votekey, + ...voteIdentity, + }), + ) + ).data, + ); + } catch (error) { + console.log('Failed to check vote status:', sanitizeError(error)); + await data.player.pm('Could not check your vote right now. Please try again later.'); + return; + } + + if (votingSiteResponse === 'Error: server key not found') { + await data.player.pm('The server vote key is invalid, please notify the admins.'); + return; + } + if (votingSiteResponse === '0') { + await data.player.pm(novote); + return; + } + if (votingSiteResponse === '2') { + await data.player.pm(alreadyclaimed); + return; + } + if (votingSiteResponse !== '1') { + console.log('Unexpected vote status response:', votingSiteResponse); + await data.player.pm('The voting site returned an unexpected response. Please notify the admins.'); + return; + } + + let itemRewards; + try { + const randomRewards = selectRandomRewards(randomitemnumber, randomitemlist); + itemRewards = await Promise.all([...randomRewards, ...fixedrewards].map((reward) => resolveConfiguredItem(reward))); + } catch (error) { + console.log('Vote reward configuration is invalid:', sanitizeError(error)); + await data.player.pm('Vote rewards are not configured correctly. Please notify the admins.'); + return; + } + + let claimResponse; + try { + claimResponse = String( + ( + await axios.post( + buildVoteApiUrl(votingsite, { + action: 'post', + object: 'votes', + element: 'claim', + key: votekey, + ...voteIdentity, + }), + ) + ).data, + ); + } catch (error) { + console.log('Failed to claim vote reward:', sanitizeError(error)); + await data.player.pm('Something went wrong while claiming your vote reward. Please try again later.'); + return; + } + + if (claimResponse !== '1') { + await data.player.pm('Something went wrong while claiming your vote reward.'); + console.log('Unexpected vote claim response:', claimResponse); + return; + } + + if (privatemessage) { + await data.player.pm(privatemessage); + } + if (publicmessage) { + await takaro.gameserver.gameServerControllerSendMessage(data.gameServerId, { + message: publicmessage.replace('{name}', data.player.name), + }); + } + + const rewardErrors = []; + const totalItemRewards = itemRewards.length; + for (let i = 0; i < itemRewards.length; i++) { + const item = itemRewards[i]; + try { + await giveResolvedItem(item); + await data.player.pm(`You received ${item.amount}x ${item.displayName}! (item ${i + 1}/${totalItemRewards})`); + } catch (error) { + rewardErrors.push(`item ${item.displayName}`); + console.log('Failed to give vote item reward:', sanitizeError(error)); + } + } + + if (currency > 0) { + try { + const currencyName = (await takaro.settings.settingsControllerGetOne('currencyName', data.gameServerId)).data.data; + await takaro.playerOnGameserver.playerOnGameServerControllerAddCurrency(data.gameServerId, data.player.id, { + currency, + }); + await data.player.pm(`You received ${currency} ${currencyName.value}.`); + } catch (error) { + rewardErrors.push('currency'); + console.log('Failed to give vote currency reward:', sanitizeError(error)); + } + } + + if (rewardErrors.length > 0) { + await data.player.pm('Your vote was claimed, but some rewards failed. Please notify the admins.'); + } +} + +await main();