Voting Reference
Architecture
Mafia bot stores votes via logs. Each and every vote creates a log in the databse. When votes are retrieved, votes are reconstructed with these logs. This architecture keeps everything consistent between current votes and vote history, and allows for more flexiblity with extensions.
export async function getVotes(day: number, transaction: Transaction | undefined = undefined) { const db = firebaseAdmin.getFirestore();
const ref = db.collection('day').doc(day.toString()).collection('votes'); const docs = transaction ? (await transaction.get(ref)).docs : (await ref.get()).docs; const logs = (docs.map(doc => doc.data()) as (Log | ResetLog | CustomLog)[]).filter(l => l.type != 'custom');
logs.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf());
let votes = [] as Vote[];
logs.forEach(log => { if(log.type == 'reset') { votes = [];
return; }
const existing = votes.findIndex(vote => log.vote.id == vote.id);
if(existing > -1) { votes.splice(existing, 1); }
if(log.vote.for != 'unvote') { votes.push(log.vote); } })
return votes;}Log
There are few different types of logs that Mafia Bot handles:
- Standard logs will cover the majority of logs, covering individual votes.
- Reset logs allow for votes to resetted mid-day.
- Custom logs are used by extensions to update votes seperate from a normal vote.
Log / StandardLog
The most standard of logs. board is the current vote board after this log. messageId is the id of the message in main chat that this log refers to.
export interface Log { //called StandardLog on MafiaWebsite vote: Vote, board: string, messageId: string | null, type: 'standard', timestamp: number,}ResetLog
Very similar to a standard log. Board will just be "" in most cases. message is what appears in vote history.
export interface ResetLog { message: string, board: string, messageId: string | null, type: 'reset', timestamp: number,}In most cases, you won’t need to directly use this. Extensions/commands can use this:
export async function wipe(global: Global, message: string) { const db = firebaseAdmin.getFirestore();
return await db.runTransaction(async (t) => { await getVotes(global.day, t); //just need to lock documents
const board = "";
const ref = db.collection('day').doc(global.day.toString()).collection('votes').doc();
t.create(ref, { messageId: null, message: message == "" ? "Votes have been reset." : message, board: board, type: "reset", timestamp: new Date().valueOf(), } satisfies ResetLog);
return async (id: string) => { await ref.update({ messageId: id, }); }; });}CustomLog
Since Mafia Website doesn’t know how to retrieve nicknames for custom logs, you must specify them in search.
export interface CustomLog { search: { //for vote history search, add nicknames <- NICKNAMES, not ids name: string, for?: string, replace?: string, }, message: string, prefix: boolean, //prefix nickname to the beginning of the name board: string, messageId: string | null, type: 'custom' timestamp: number,}Prefix is if you want search.name’s tag to appear before the message.

Implementation
The entire voting process takes place within a database transaction. This prevents other commands and extensions from adding vote logs at the same time. See Firestore Transactions. Here is the implementation of a standard vote:
export async function defaultVote(global: Global, setup: Setup, game: Signups, voter: User, voting: User | undefined, type: 'vote' | 'unvote', users: User[], transaction: Transaction): Promise<TransactionResult> { const { reply, vote, votes } = await flow.placeVote(transaction, voter, voting, type, users, global.day); // doesn't save vote yet since board needs to be created
if(vote == undefined) return { reply };
const board = flow.board(votes, users);
const setMessage = flow.finish(transaction, vote, board, global.day); // locks in vote
return { reply, hammer: flow.determineHammer(vote, votes, users), setMessage, }}Flow
Each part of the voting process is seperated and can be called via flow in src/utils/vote.ts. This allows extensions the flexiblity of keeping parts of the voting process it doesn’t need to override.
placeVote
- Retrieves votes from database.
- Removes old vote.
- Appends new vote.
- Returns new vote seperately, the updated votes array, and how Mafia Bot should reply to the user.
This does not add the vote log to database yet as the board still needs to be made.
placeVote: async (t: Transaction, voter: User, voting: User | undefined, type: 'unvote' | 'vote', users: User[], day: number): Promise<{ reply: { typed: string; emoji: string | ApplicationEmoji; }; vote: Vote | undefined; votes: Vote[];}>placeVote: async (t: Transaction, voter: User, voting: User | undefined, type: 'unvote' | 'vote', users: User[], day: number) => { const votes = await getVotes(day, t);
if(type != 'unvote' && voting == undefined) throw new Error("Voter must be specified!");
const existing = votes.findIndex(vote => vote.id == voter.id); let removed: undefined | Vote = undefined;
if(existing > -1) { removed = votes[existing]; votes.splice(existing, 1); }
let vote: Vote | undefined = undefined; let reply: { typed: string, emoji: string | ApplicationEmoji };
if(type != 'unvote' && voting) { vote = { for: voting.id, id: voter.id, timestamp: new Date().valueOf(), ...(removed ? { replace: removed.for } : {}) };
votes.push(vote);
if(removed?.for == vote.for) { reply = { typed: getNickname(voter.id, users) + "'s vote is unchanged!", emoji: process.env.NO_CHANGE ?? "✅" } } else if(removed) { reply = { typed: getNickname(voter.id, users) + " has changed their vote from " + getNickname(removed.for, users) + " to " + getNickname(voting.id, users) + "!", emoji: process.env.VOTE_SWAPPED ?? "✅" } } else { reply = { typed: getNickname(voter.id, users) + " has voted " + getNickname(voting.id, users) + "!", emoji: '✅' } } } else if(type == "unvote" && removed) { vote = { for: "unvote", id: voter.id, timestamp: new Date().valueOf(), replace: removed.for };
reply = { typed: getNickname(voter.id, users) + " has unvoted!", emoji: "✅", } } else { reply = { typed: getNickname(voter.id, users) + " has not voted!", emoji: process.env.FALSE ?? "⛔", } }
return { reply, vote, votes, }},board
Creates the vote board with the current votes.
board: (votes: Vote[], users: User[]): stringboard: (votes: Vote[], users: User[]) => { const counting = [] as { voting: string, voters: string[]}[];
const all = [...new Set(votes.map(vote => vote.for))];
all.forEach(votingId => { const voting = users.find(user => user.id == votingId)?.nickname ?? "<@" + votingId + ">";
counting.push({ voting, voters: votes.filter(vote => vote.for == votingId).sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf()).map(voter => users.find(user => user.id == voter.id)?.nickname ?? "<@" + votingId + ">"), }); });
counting.sort((a, b) => b.voters.length - a.voters.length);
const board = counting.reduce((prev, curr) => prev += (curr.voters.length + " - " + curr.voting + " « " + curr.voters.join(", ")) + "\n", "");
return board;},finish
Adds the vote log to the database and returns a callback to set messageId later outside of transaction.
finish: (t: Transaction, vote: Vote, board: string, day: number): async (id: string) => Promise<void>finish: (t: Transaction, vote: Vote, board: string, day: number) => { const db = firebaseAdmin.getFirestore();
const ref = db.collection('day').doc(day.toString()).collection('votes').doc();
t.create(ref, { board, vote, messageId: null, type: 'standard', timestamp: vote.timestamp, } satisfies Log);
return async (id: string) => { await ref.update({ messageId: id, }); }},determineHammer
The default implementation of determining a hammer.
determineHammer: (vote: Vote, votes: Vote[], users: User[]): { message: string; hammered: true; id: string; //id of who got hammered} | { message: null; hammered: false; id: null;}determineHammer: (vote: Vote, votes: Vote[], users: User[]) => { let votesForHammer = votes.filter(v => v.for == vote.for); let half = Math.floor(users.length / 2);
if(votesForHammer.length > half) { return { message: (users.find(user => vote.for == user.id)?.nickname ?? "<@" + vote.for + ">") + " has been hammered!", hammered: true as true, id: vote.for } } else { return { message: null, hammered: false as false, id: null } }}Transaction
Now for the transaction itself, what needs to be returned (in extensions/defaultVote) is as follows:
hammer and setMessage are optional for the cases in which the vote could not be placed.
export interface TransactionResult { reply: Awaited<ReturnType<(typeof flow)["placeVote"]>>["reply"], hammer?: ReturnType<(typeof flow)["determineHammer"]> setMessage?: ReturnType<(typeof flow)["finish"]>,}An example implementation within ./src/commands/vote.ts
const db = firebaseAdmin.getFirestore();
const result = await db.runTransaction(async t => { let result: undefined | TransactionResult = undefined;
if(extension) result = await extension.onVote(global, setup, game, voter, voting, type, users, t) ?? undefined;
if(result == undefined) result = await defaultVote(global, setup, game, voter, voting, type, users, t);
return result;}) satisfies TransactionResult;
if(interaction.type == 'text') { await interaction.message.react(result.reply.emoji);
if(result.setMessage) await result.setMessage(interaction.message.id);} else { const message = await interaction.editReply({ content: result.reply.typed });
if(result.setMessage) await result.setMessage(message.id);}
await handleHammer(result.hammer, global,setup, game);Extensions
An example of how an extension can modify voting behavior.
onVote
The mayors extension modifies the default voting behavior. It keeps the default placeVote and finish, but replaces board and determineHammer with it’s own counterparts that utilize mayors.
onVote: async (global, setup, game, voter, voting, type, users, transaction) => { const { reply, vote, votes } = await flow.placeVote(transaction, voter, voting, type, users, global.day); // doesn't save vote yet since board needs to be created
if(vote == undefined) return { reply };
const mayors = await getMayors();
const board = getBoard(votes, users, mayors, global.day);
const setMessage = flow.finish(transaction, vote, board, global.day); // locks in vote
return { reply, hammer: determineHammer(vote, votes, users, mayors, global.day), setMessage, }},async function getMayors(users: User[] | undefined = undefined, transaction: Transaction | undefined = undefined) { const db = firebaseAdmin.getFirestore();
const docs = transaction ? (await transaction.get(db.collection('mayor'))).docs : (await db.collection('mayor').get()).docs;
const mayors = new Array<Mayor>();
for(let i = 0; i < docs.length; i++) { const user = users ? users.find(user => user.id == docs[i].id) : undefined;
mayors.push({ nickname: user?.nickname, id: docs[i].id, reveal: docs[i].data().reveal, type: docs[i].data().type, weight: docs[i].data().weight, day: docs[i].data().day, }); }
return mayors;}The mayors extension has a special use-case for getBoard in which it specify if the request is coming from deadChat to show secret/hidden mayors.
function getBoard(votes: Vote[], users: User[], mayors: Awaited<ReturnType<typeof getMayors>>, day: number, deadChat: boolean = false,): string { const counting = [] as { voting: string, count: number, voters: string[]}[];
const all = [...new Set(votes.map(vote => vote.for))];
all.forEach(votingId => { const voting = users.find(user => user.id == votingId)?.nickname ?? "<@" + votingId + ">";
let count = 0;
const voters = votes.filter(vote => vote.for == votingId).sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf()).map(vote => { const mayor = mayors.find(mayor => mayor.id == vote.id && mayor.day == day);
if(mayor && (((mayor.type != 'hidden') && mayor.reveal == true) || (deadChat && ((mayor.type != "classic" && mayor.type != "public") || ((mayor.type == "classic" || mayor.type == "public") && mayor.reveal == true))))) { count += mayor.weight; } else { count++; }
const indicator = (() => { if(!mayor) { return "" } else if((mayor.type == "classic" || mayor.type == "public" || mayor.type == "secret") && mayor.reveal == true) { return " (" + mayor.weight + ")" } else if(deadChat && (mayor.type == "hidden" || (mayor.type == "secret" && mayor.reveal == false))) { return " ~~(" + mayor.weight + ")~~" } else if(deadChat && mayor.type == "classic" && mayor.reveal == false) { return " *(" + mayor.weight + ")*"; } else { return ""; }
})();
return (users.find(user => user.id == vote.id)?.nickname ?? "<@" + vote + ">") + indicator; });
counting.push({ voting, count, voters }); });
counting.sort((a, b) => b.voters.length - a.voters.length);
const board = counting.reduce((prev, curr) => prev += (curr.count + " - " + curr.voting + " « " + curr.voters.join(", ")) + "\n", "");
return board;}function determineHammer(vote: Vote, votes: Vote[], users: User[], mayors: Awaited<ReturnType<typeof getMayors>>, day: number): TransactionResult["hammer"] { if(vote.for == 'unvote') return { hammered: false, message: null, id: null };
let votesForHammer = votes.filter(v => v.for == vote.for).reduce((prev, vote) => { const mayor = mayors.find(mayor => mayor.id == vote.id && mayor.day == day);
if(mayor && (mayor.type != "classic" || (mayor.type == "classic" && mayor.reveal == true))) { return prev + mayor.weight; } else { return prev + 1; } }, 0);
const half = Math.floor(users.length / 2);
if(votesForHammer > half) { return { hammered: true, message: (users.find(user => user.id == vote.for)?.nickname ?? "<@" + vote.for + ">") + " has been hammered!", id: vote.for }; } else { return { hammered: false, message: null, id: null }; }}Seperate from onVote
The mayors extension also updates votes in cases seperate from a usual vote, such as when revealing or setting a mayor.
async function updateVotes(day: number, game: Signups, message: string, id: string) { const db = firebaseAdmin.getFirestore();
const users = await getUsersArray(game.signups);
await db.runTransaction(async t => { const votes = await getVotes(day, t);
const mayors = await getMayors(users, t);
const board = getBoard(votes, users, mayors, day);
const ref = db.collection('day').doc(day.toString()).collection('votes').doc();
t.create(ref, { board, search: { name: users.find(user => user.id == id)?.nickname ?? "<@" + id + ">", // who was the subject of the mayor change }, prefix: true, message: message, // why votes was updated messageId: null, type: 'custom', timestamp: new Date().valueOf(), } satisfies CustomLog); })}async function reveal(id: string, global: Global, game: Signups) { const db = firebaseAdmin.getFirestore();
const ref = db.collection('mayor').doc(id);
const users = await getUsersArray(global.players.map(player => player.id));
const result = await db.runTransaction(async t => { const votes = await getVotes(global.day, t); // no need to actually put a vote, just retrieve votes
const data = (await t.get(ref)).data(); if(!data || !(data.type == 'secret' || data.type == 'classic') || data.reveal == true) return; //check they are a mayor
const mayors = await getMayors(users, t);
t.update(ref, { reveal: true, });
const index = mayors.findIndex(mayor => mayor.id == id); if(index > -1) mayors[index].reveal = true; //update the mayor to revealed so we don't need to refetch (we can't refetch)
const board = getBoard(votes, users, mayors, global.day);
const logRef = db.collection('day').doc(global.day.toString()).collection('votes').doc();
const existing = votes.find(v => v.id == id);
t.create(logRef, { board, search: { name: users.find(user => user.id == id)?.nickname ?? "<@" + id + ">", ...(existing ? { for: users.find(user => user.id == existing.for)?.nickname ?? "<@" + existing.for + ">", } : {}) }, prefix: true, message: "has revealed they are a mayor!", messageId: null, type: 'custom', timestamp: new Date().valueOf(), } satisfies CustomLog); // use a custom log since not a real vote
const setMessage = async (id: string) => { await logRef.update({ messageId: id, }); };
return { hammer: existing ? determineHammer(existing, votes, users, mayors, global.day) : { hammered: false as false, message: null, id: null }, setMessage }
//spoof hammer check with existing vote });
return result;}onVotes
Change what shows up on the footer of ?votes.
onVotes: async (global, setup, game, board: string): string => { return "Example footer.";
/** * Return what is show in the footer in ?votes. */},