// Simple implementation of an up-down (think Reddit) voting // system for Graal // basic configuration const VOTE_OPEN = true; // anti-cheating const VOTE_SUPPRESS_THRESHOLD_SCORE = 0.25; // 25% of votes must be upvotes const VOTE_SUPPRESS_THRESHOLD_UP = 20; // most upvotes allowed in one hour const VOTE_SUPPRESS_THRESHOLD_DOWN = 5; // most downvotes allowed in one hour const VOTE_MAXPERHOUR = 5; // max responses allowed per hour // display options const VOTE_DISPLAY_THRESHOLD = (- 5); // don't show below this score const VOTE_SHOW_SUBMITTER = true; // show submitter to everyone (or just staff) // current topic const VOTE_TOPIC = "PK Bonuses"; const VOTE_TOPIC_DESCRIPTION = "We plan to add bonuses to the PK system. " @ "Bonuses will be awarded upon PK " @ "achievements. For example, killing someone while blind might " @ "give you the bonus called \"White-Hot\" which makes you immune " @ "to concussion grenades for 5 minutes. " @ "We want you to help us come up with more bonuses. Try to keep " @ "your suggestions short and simple. Complex ones won't be " @ "used."; function onCreated() { this.join("func_sql"); this.join("func_strings"); this.createTables(); } for (temp.pl : allplayers) temp.pl.addWeapon(name); function createTables() { req2("default", "CREATE TABLE IF NOT EXISTS voting_responses ( id INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL, vote_cache INT NOT NULL DEFAULT 0, deleted INT NOT NULL DEFAULT 0, deleted_by TEXT DEFAULT NULL, time REAL NOT NULL )", true); req2("default", "CREATE TABLE IF NOT EXISTS voting_votes ( account TEXT NOT NULL, response_id INT NOT NULL, score INT NOT NULL, time REAL NOT NULL, suppressed INT NOT NULL DEFAULT 0, suppress_reason TEXT DEFAULT NULL )", true); } function onActionServerSide(temp.cmd, temp.data) { if (player.level.name == "era_jail.nw") { return; } if (temp.cmd == "requestVotingData") { if (VOTE_OPEN || this.isAdmin(player)) { temp.responses = this.getResponses(player); triggerClient("gui", this.name, "returnVotingData", {VOTE_TOPIC, VOTE_TOPIC_DESCRIPTION, this.isAdmin(player), temp.responses}); } } else if (temp.cmd == "submit") { temp.title = temp.data[0]; temp.description = temp.data[1]; temp.failReason = null; if (temp.title.length() < 3 || temp.title.length() > 25) { temp.failReason = "Your title must be between 3 and 25 characters long."; } temp.description = replaceText(temp.description, "\\n", " "); // remove newlines if (temp.description.length() < 10 || temp.description.length() > 100) { temp.failReason = "Your description must be between 10 and 100 characters long."; } // get rid of any HTML temp.description = replaceText(temp.description, "<", "<"); temp.description = replaceText(temp.description, ">", ">"); // try to add the response if (temp.failReason == null) { temp.worked = this.addResponse(player, temp.title, temp.description); if (! temp.worked[0]) { if (temp.worked[1] == "maxperhour") { temp.failReason = "You can't submit that many ideas per hour."; } else { temp.failReason = "There was an unknown error."; } } else { triggerClient("gui", this.name, "destroySubmitWindow"); } } // was there an error? if (temp.failReason != null) { player.addMessage("There was an error submitting your idea:", "b", 3); player.addMessage(temp.failReason, "b", 3); triggerClient("gui", this.name, "enableSubmitWindow"); } } else if (temp.cmd == "delete") { temp.responseID = int(temp.data); if (this.isAdmin(player)) { this.deleteResponse(player, temp.responseID); } } else if (temp.cmd == "getResponses") { triggerClient("gui", this.name, "returnResponses", {this.isAdmin(player), this.getResponses(player)}); } else if (temp.cmd == "vote") { temp.responseID = int(temp.data[0]); temp.score = int(temp.data[1]); if (! (temp.score in {1, (- 1)})) { return echo("Hacker?: " @ player.getLogName() @ " is trying to vote too much (" @ temp.score @ ")!"); } this.addVote(player, temp.responseID, temp.score); } } // these functions do not validate input; the caller is responsible for // ensuring that all variables are of proper type and that all actions // are authorized (e.g. deleting) function getResponses(temp.pl) { temp.isAdmin = this.isAdmin(temp.pl); temp.responses = req2("default", "SELECT * FROM voting_responses WHERE vote_cache >= " @ VOTE_DISPLAY_THRESHOLD @ " AND voting_responses.deleted = 0", true).rows; temp.plResponses = null; // hide the actual votes a little bit to make gaming it harder for (temp.i = 0; temp.i < temp.responses.size(); temp.i ++) { if (abs(temp.responses[temp.i].vote_cache) > 1) { temp.responses[temp.i].vote_cache += int(random((- 3), 3)); } } temp.responses.sortByValue("vote_cache", "float", false); // organize the responses to send back to the client for (temp.response : temp.responses) { temp.response.my_score = req2("default", "SELECT score FROM voting_votes WHERE account = '" @ temp.pl.account.escape() @ "' AND response_id = " @ temp.response.id, true).rows[0][0]; temp.plResponses.add({ // responseID, title, description, account (possibly null), response score, my score temp.response.id, temp.response.title, temp.response.description, (temp.isAdmin || VOTE_SHOW_SUBMITTER || temp.response.account == temp.pl.account) ? temp.response.account : null, temp.response.vote_cache, temp.response.my_score + 0 }); } return temp.plResponses; } function addResponse(temp.pl, temp.title, temp.description) { if (this.getPlayerResponsesPastHour(temp.pl) >= VOTE_MAXPERHOUR && ! this.isAdmin(temp.pl)) { return {false, "maxperhour"}; } temp.responseID = req2("default", "INSERT INTO voting_responses (account, title, description, time) VALUES ('" @ temp.pl.account.escape() @ "', '" @ temp.title.escape() @ "', '" @ temp.description.escape() @ "', " @ timevar2 @ ")", true).lastinsertid; this.addVote(temp.pl, temp.responseID, 1); return {true}; } function deleteResponse(temp.pl, temp.responseID) { req2("default", "UPDATE voting_responses SET deleted = 1, deleted_by = '" @ temp.pl.account.escape() @ "' WHERE id = " @ temp.responseID); } function addVote(temp.pl, temp.responseID, temp.score) { this.removeVote(temp.pl, temp.responseID, true); req2("default", "INSERT INTO voting_votes (account, response_id, score, time) VALUES ('" @ temp.pl.account.escape() @ "', " @ temp.responseID @ ", " @ temp.score @ ", " @ timevar2 @ ")", true); // test for suppression if (this.getPlayerUpvotesPastHour(temp.pl) > VOTE_SUPPRESS_THRESHOLD_UP) { this.suppressVote(temp.pl, temp.responseID, "threshold_up"); } else if (this.getPlayerDownvotesPastHour(temp.pl) > VOTE_SUPPRESS_THRESHOLD_DOWN) { this.suppressVote(temp.pl, temp.responseID, "threshold_down"); } else if (this.getPlayerVotingPercent(temp.pl) < VOTE_SUPPRESS_THRESHOLD_SCORE) { this.suppressVote(temp.pl, temp.responseID, "threshold_score_low"); } // update vote cache this.updateVoteCache(temp.responseID); } function suppressVote(temp.pl, temp.responseID, temp.reason) { req2("default", "UPDATE voting_votes SET suppressed = 1, suppress_reason = '" @ temp.reason.escape() @ "' WHERE account = '" @ temp.pl.account.escape() @ "' AND response_id = " @ temp.responseID); } function removeVote(temp.pl, temp.responseID, temp.dontUpdate) { req2("default", "DELETE FROM voting_votes WHERE account = '" @ temp.pl.account.escape() @ "' AND response_id = " @ temp.responseID); if (! temp.dontUpdate) { this.updateVoteCache(temp.responseID); } } function updateVoteCache(temp.responseID) { temp.score = req2("default", "SELECT SUM(score) AS Score FROM voting_votes WHERE suppressed = 0 AND response_id = " @ temp.responseID, true).rows[0].Score + 0; req2("default", "UPDATE voting_responses SET vote_cache = " @ temp.score @ " WHERE id = " @ temp.responseID, true); } function getPlayerResponsesPastHour(temp.pl) { return req2("default", "SELECT count(*) AS ResponsesPastHour FROM voting_responses WHERE account = '" @ temp.pl.account.escape() @ "' AND time > " @ (timevar2 - (60 * 60)), true).rows[0].ResponsesPastHour; } function getPlayerUpvotesPastHour(temp.pl) { return req2("default", "SELECT count(*) AS UpvotesPastHour FROM voting_votes WHERE account = '" @ temp.pl.account.escape() @ "' AND score = 1 AND time > " @ (timevar2 - (60 * 60)), true).rows[0].UpvotesPastHour; } function getPlayerDownvotesPastHour(temp.pl) { return req2("default", "SELECT count(*) AS DownvotesPastHour FROM voting_votes WHERE account = '" @ temp.pl.account.escape() @ "' AND score = (- 1) AND time > " @ (timevar2 - (60 * 60)), true).rows[0].DownvotesPastHour; } function getPlayerVotingPercent(temp.pl) { temp.totalVotes = req2("default", "SELECT count(*) AS TotalVotes FROM voting_votes WHERE account = '" @ temp.pl.account.escape() @ "'", true).rows[0].TotalVotes; temp.totalUpvotes = req2("default", "SELECT count(*) AS TotalUpvotes FROM voting_votes WHERE account = '" @ temp.pl.account.escape() @ "' AND score = 1", true).rows[0].TotalUpvotes; return (temp.totalUpvotes / temp.totalVotes); } function isAdmin(temp.pl) { return temp.pl.clientr.staff >= 2; } //#CLIENTSIDE function onCreated() { // un-comment this to open the voting window on login // this.openVotingWindow(); } public function openVotingWindow() { triggerServer("gui", this.name, "requestVotingData"); } function openWindow(temp.isAdmin, temp.topic, temp.description) { if (UpDownVote_Window != null) { UpDownVote_Window.destroy(); } new GuiWindowCtrl("UpDownVote_Window") { profile = GuiBlueWindowProfile; canClose = canMinimize = canMaximize = canResize = clientRelative = visible = true; clientWidth = 500; clientHeight = 400; x = (GraalControl.width - width) / 2; y = (GraalControl.height - height) / 2; text = temp.topic; new GuiControl("UpDownVote_Topic") { useOwnProfile = true; profile.border = 1; profile.borderColor = {0, 0, 0}; profile.opaque = true; profile.fillColor = {200, 200, 235, 180}; x = 5; y = 5; width = UpDownVote_Window.clientWidth - (x * 2); height = 100; horizSizing = "width"; new GuiMLTextCtrl("UpDownVote_Topic_Text") { profile = GuiBlueMLTextProfile; useOwnProfile = true; profile.fontColor = {0, 0, 0, 190}; x = 5; y = 5; width = UpDownVote_Topic.width - (x * 2); text = "