Skip to content

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.

./src/utils/vote.ts
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.

./src/utils/vote.ts
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.

./src/utils/vote.ts
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:

./src/utils/vote.ts
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.

./src/utils/vote.ts
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.

Example of prefix

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:

./src/utils/vote.ts
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

  1. Retrieves votes from database.
  2. Removes old vote.
  3. Appends new vote.
  4. 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[];
}>

board

Creates the vote board with the current votes.

board: (votes: Vote[], users: User[]): string

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>

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;
}

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

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.

./src/extensions/mayor.ts
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,
}
},

Seperate from onVote

The mayors extension also updates votes in cases seperate from a usual vote, such as when revealing or setting a mayor.

./src/extensions/mayor.ts
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);
})
}

onVotes

Change what shows up on the footer of ?votes.

./src/extensions/extension.ts
onVotes: async (global, setup, game, board: string): string => {
return "Example footer.";
/**
* Return what is show in the footer in ?votes.
*/
},