Skip to content

Commit

Permalink
Show survivor count and vote events on infoscreen (#29)
Browse files Browse the repository at this point in the history
Creates an infoscreen entry each approved vote, and another one
that contains results when the vote ends.

Adds an infoscreen entry that shows the current survivor count.

---------

Co-authored-by: Nico Hagelberg <[email protected]>
  • Loading branch information
tiittapauppi and nicou authored Jun 15, 2024
1 parent fef58e2 commit 83e07f3
Show file tree
Hide file tree
Showing 10 changed files with 228 additions and 20 deletions.
15 changes: 15 additions & 0 deletions db/migrations/20240509151944_add-active-until-infoboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Knex } from "knex";


export async function up(knex: Knex): Promise<void> {
return knex.schema.alterTable('infoboard_entry', (table) => {
table.timestamp('active_until').nullable();
});
}


export async function down(knex: Knex): Promise<void> {
return knex.schema.alterTable('infoboard_entry', (table) => {
table.dropColumn('active_until');
});
}
15 changes: 15 additions & 0 deletions db/migrations/20240615063627_add-infoboard-entry-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
return knex.schema.alterTable('infoboard_entry', table => {
table.string('identifier').nullable().unique();
table.jsonb('metadata').nullable();
});
}

export async function down(knex: Knex): Promise<void> {
return knex.schema.alterTable('infoboard_entry', table => {
table.dropColumn('metadata');
table.dropColumn('identifier');
});
}
15 changes: 11 additions & 4 deletions db/seeds/05-infoboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,27 @@ const infos = [
priority: 7,
enabled: true,
title: 'Jump prep started',
body: '<p text-align="center">The ship is now preparing for a jump.</p>'
body: '<p text-align="center">The ship is now preparing for a jump.</p>',
},
{
priority: 8,
enabled: true,
title: 'Jump prep completed',
body: '<b text-align="center">The ship is now ready to jump at a short notice.</b>'
body: '<b text-align="center">The ship is now ready to jump at a short notice.</b>',
},
{
priority: 9,
enabled: true,
title: 'Jump sequence initiated',
body: '<b text-align="center">Jump in %%JUMP%% seconds.</b>'
}
body: '<b text-align="center">Jump in %%JUMP%% seconds.</b>',
},
{
priority: 1,
enabled: true,
title: 'Public announcement',
identifier: "survivors-count",
body: '<div style="width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 5vw;">Total souls alive <p style="font-size: 7vw; font-family: Oxanium;"><span class="survivors">%%survivor_count%%</span></p></div>',
},
];

exports.seed = async knex => {
Expand Down
3 changes: 3 additions & 0 deletions src/models/infoentry.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import Bookshelf from '../../db';
* @property {boolean} enabled.required - Enable the entry in display sequence
* @property {string} title.required - Title of the entry
* @property {string} body.required - Body of the entry, may contain HTML
* @property {string} type - Type of the entry (e.g. survivors-count)
* @property {object} meta - JSON formatted metadata
* @property {string} created_at - ISO 8601 String Date-time when object was created
* @property {string} updated_at - ISO 8601 String Date-time when object was last updated
* @property {string} active_until - ISO 8601 String Date-time when infoentry will expire
*/
export const InfoEntry = Bookshelf.Model.extend({
tableName: 'infoboard_entry',
Expand Down
12 changes: 12 additions & 0 deletions src/models/ship.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,18 @@ export function setShipsVisible() {
});
}

export async function getTotalSoulsAlive() {
const fleet = await Ship.forge().where({ is_visible: true }).fetchAll();
let totalSouls = 0;
for (const ship of fleet.models) {
const personCount = parseInt(ship.get("person_count"), 10);
if (Number.isFinite(personCount)) {
totalSouls += personCount;
}
}
return totalSouls
}

/**
* @typedef MoveShipsInput
* @property {Array.<string>} shipIds.required - IDs of the ships that should be moved
Expand Down
6 changes: 2 additions & 4 deletions src/routes/infoboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ router.get('/display', handleAsyncErrors(async (req, res) => {
minuteAgo.setMinutes(minuteAgo.getMinutes()-1);
const selector = parseInt((now.getMinutes() * 6 + now.getSeconds() / 10), 10);
const priority = await InfoPriority.forge().fetch();
const entries = await InfoEntry.forge().where({ priority: priority.attributes.priority }).fetchAll();
const entries = await InfoEntry.forge().where({ priority: priority.attributes.priority, enabled: true }).fetchAll();
let news = await Post.forge()
.where({ type: 'NEWS', status: 'APPROVED' })
.orderBy('created_at', 'DESC')
Expand Down Expand Up @@ -111,7 +111,7 @@ router.put('/:id', handleAsyncErrors(async (req, res) => {
const { id } = req.params;
// TODO: Validate input
const info = await InfoEntry.forge({ id }).fetch();
if (!info) throw new Error('Infoboard entry not found');
if (!info) throw new httpErrors.NotFound('Infoboard entry not found');
await info.save(req.body, { method: 'update', patch: true });
res.json(info);
}));
Expand All @@ -132,6 +132,4 @@ router.delete('/:id', handleAsyncErrors(async (req, res) => {
res.sendStatus(204);
}));



export default router;
60 changes: 60 additions & 0 deletions src/routes/vote.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { Vote, VoteEntry, VoteOption } from '../models/vote';
import { InfoEntry } from '../models/infoentry';
import { STATUS_PENDING } from '../models';
import { handleAsyncErrors } from './helpers';
import { adminSendMessage } from '../messaging';
Expand All @@ -23,6 +24,64 @@ const VOTE_FIELDS = [
'status'
];

const voteFilterToTextMap = {
'PARTY:BLUE_PARTY': 'Members of the Blue Party',
'PARTY:PURPLE_PARTY': 'Members of the Purple Party',
'PARTY:YELLOW_PARTY': 'Members of the Yellow Party',

'RELIGION:OLD_WAYS': 'Followers of the Old Ways',
'RELIGION:OTHER': 'Followers of the Other',
'RELIGION:FAITH_OF_THE_HIGH_SCIENCE': 'Followers of the Faith of the High Science',

'DYNASTY:TENACITY': 'Members of the Tenacity dynasty',
'DYNASTY:LOGIC': 'Members of the Logic dynasty',
'DYNASTY:GENEROSITY': 'Members of the Generosity dynasty',
'DYNASTY:CONFIDENCE': 'Members of the Confidence dynasty',
'DYNASTY:LOYALTY': 'Members of the Loyalty dynasty',
'DYNASTY:UNITY': 'Members of the Unity dynasty',
'DYNASTY:PURITY': 'Members of the Purity dynasty',
'DYNASTY:TRANQUILITY': 'Members of the Tranquility dynasty',
'DYNASTY:DEFIANCE': 'Members of the Defiance dynasty',
'DYNASTY:KINDNESS': 'Members of the Kindness dynasty',
'DYNASTY:DEDICATION': 'Members of the Dedication dynasty',
'DYNASTY:INTELLIGENCE': 'Members of the Intelligence dynasty',
'DYNASTY:COMPASSION': 'Members of the Compassion dynasty',
'DYNASTY:STRENGTH': 'Members of the Strength dynasty',
'DYNASTY:JUSTICE': 'Members of the Justice dynasty',
'DYNASTY:EXCELLENCE': 'Members of the Excellence dynasty',
'DYNASTY:MERCY': 'Members of the Mercy dynasty',
'DYNASTY:FLOATER': 'Members of the Floater dynasty',
'DYNASTY:FAIRNESS': 'Members of the Fairness dynasty',
'DYNASTY:HOPE': 'Members of the Hope dynasty',
'DYNASTY:INDUSTRY': 'Members of the Industry dynasty',
'DYNASTY:AMBITION': 'Members of the Ambition dynasty',

'HIGH_RANKING_OFFICER': 'High ranking military officers',

'EVERYONE': 'Everyone',
};

const ONE_HOUR = 1000 * 60 * 60;

async function createVoteCreatedInfoboardEntry(vote) {
const voteActiveUntilFormatted = moment(vote.get('active_until')).format('HH:mm');

const voteActiveUntilMs = new Date(vote.get('active_until')).getTime();
const oneHourMs = new Date(Date.now() + ONE_HOUR).getTime();
const activeUntil = new Date(Math.min(voteActiveUntilMs, oneHourMs));

const title = `Vote: ${vote.get('title')}`;
const votingAllowedFor = voteFilterToTextMap[vote.get('allowed_voters')] || vote.get('allowed_voters');
const body = `${votingAllowedFor} can now cast their vote in EOC Datahub.<br><br>Voting ends at ${voteActiveUntilFormatted}.`;
await InfoEntry.forge().save({
priority: 1,
enabled: true,
title,
body,
active_until: activeUntil,
}, { method: 'insert' });
}

/**
* Get a list of all votes
* @route GET /vote
Expand Down Expand Up @@ -111,6 +170,7 @@ router.put('/:id', handleAsyncErrors(async (req, res) => {
}
if (wasChangedToApproved) {
dmx.fireEvent(dmx.CHANNELS.DataHubVoteApproved);
await createVoteCreatedInfoboardEntry(vote);
}
adminSendMessage(process.env.FLEET_SECRETARY_ID, {
target: vote.get('person_id'),
Expand Down
59 changes: 59 additions & 0 deletions src/rules/social/infoboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { interval } from '../helpers';
import { logger } from '../../logger';
import { InfoEntry } from '../../models/infoentry';
import { getTotalSoulsAlive } from '../../models/ship';

const POLL_FREQUENCY_MS = 10000;

const closeInfoEntryTimers = new Map();

async function closeInfoEntry(infoEntry) {
logger.info(`Setting InfoEntry '${infoEntry.get('title')}' as inactive`);
await infoEntry.save({ enabled: false }, { method: 'update', patch: true });
}

async function processScheduledInfoEntries() {
const activeInfoEntries = await new InfoEntry()
.where('enabled', true)
.where('active_until', 'is not', null)
.fetchAll();
closeInfoEntryTimers.forEach(clearTimeout);
closeInfoEntryTimers.clear();
for (const infoEntry of activeInfoEntries.models) {
const closesIn = new Date(infoEntry.get('active_until')) - Date.now();

// Close right away if already expired
if (closesIn < 1) return await closeInfoEntry(infoEntry);

// If there's more than 2x poll frequency remaining, don't bother with the interval
if (closesIn > POLL_FREQUENCY_MS * 2) return;

// Set an interval that closes the infoentry right when it's supposed to
closeInfoEntryTimers.set(
infoEntry.get('id'),
setTimeout(() => closeInfoEntry(infoEntry), closesIn)
);
}
}

async function updateSurvivorsCount() {
const [entry, totalSoulsAlive] = await Promise.all([
InfoEntry.forge({ identifier: 'survivors-count' }).fetch(),
getTotalSoulsAlive(),
]);
if (!entry) {
logger.warn("Survivors count info entry not found, can't update survivors count");
return;
}
const body = entry.get('body');
const replacement = `<span class="survivors">${totalSoulsAlive}</span>`;
const pattern = /<span class="survivors">.*?<\/span>/;
const updatedBody = body.replace(pattern, replacement);
await entry.save({ body: updatedBody }, { method: 'update', patch: true });
}

// Process info entries that are scheduled to close
interval(processScheduledInfoEntries, POLL_FREQUENCY_MS);

// Update survivors count periodically
interval(updateSurvivorsCount, POLL_FREQUENCY_MS);
61 changes: 51 additions & 10 deletions src/rules/social/votes.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,74 @@
import { interval } from '../helpers';
import { logger } from '../../logger';
import { Vote } from '../../models/vote';
import { Vote, VoteEntry, VoteOption } from '../../models/vote';
import { InfoEntry } from '../../models/infoentry';
import moment from 'moment';

const POLL_FREQUENCY_MS = 10000;

const closeVoteTimers = new Map();

function closeVote(vote) {
async function closeVote(vote) {
logger.info('Closing vote', vote.get('id'));
return vote.save({ is_active: false }, { method: 'update', patch: true });
await vote.save({ is_active: false }, { method: 'update', patch: true });
await createVoteResultsInfoEntry(vote);
}

async function updateVotesScheduledToClose() {
async function createVoteResultsInfoEntry(vote) {
const infoEntry = new InfoEntry();
const activeMinutes = 60;
const activeUntil = moment().add(activeMinutes, 'minutes').toDate();
const voteEntries = await new VoteEntry().where('vote_id', vote.get('id')).fetchAll();
const voteOptions = await new VoteOption().where('vote_id', vote.get('id')).fetchAll();

const title = 'Vote results: ' + vote.get('title');
let body = 'No votes were cast.';
if (voteEntries.length > 0) {
body = 'Votes in total: ' + voteEntries.length + '</br>Winning vote option(s):';
const resultArray = [];
let winnerVotes = 0;
voteOptions.forEach(option => {
const votes = voteEntries.filter(single => single.get('vote_option_id') === option.get('id')).length;
if (votes > winnerVotes) {
winnerVotes = votes;
}
resultArray.push({ result: votes, option: option.get('text') });
});

const winningVote = resultArray.find(({ result }) => result === winnerVotes);
body += '</br>' + winningVote.option + ' : ' + winningVote.result;
}

const postData = {
priority: 1,
enabled: true,
title: title,
body: body,
active_until: activeUntil,
};
await infoEntry.save(postData, { method: 'insert' });
}

async function processVotesScheduledToClose() {
const activeVotes = await new Vote().where('is_active', true).fetchAll();
closeVoteTimers.forEach(timeout => clearTimeout(timeout));
closeVoteTimers.forEach(clearTimeout);
closeVoteTimers.clear();
activeVotes.forEach(vote => {
for (const vote of activeVotes.models) {
const closesIn = new Date(vote.get('active_until')) - Date.now();

// Close right away if already expired
if (closesIn < 1) return closeVote(vote);
if (closesIn < 1) return await closeVote(vote);

// If there's more than 2x poll frequency remaining, don't bother with the interval
if (closesIn > POLL_FREQUENCY_MS * 2) return;

// Set an interval that closes the vote right when it's supposed to
closeVoteTimers.set(vote.get('id'), setTimeout(() => closeVote(vote), closesIn));
});
closeVoteTimers.set(
vote.get('id'),
setTimeout(() => closeVote(vote), closesIn)
);
}
}

// Update votes that are scheduled to close
interval(updateVotesScheduledToClose, 10000);
interval(processVotesScheduledToClose, 10000);
2 changes: 0 additions & 2 deletions src/store/storeSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ function sendDataChanges(io: SocketIoServer | SocketIoNamespace) {
}
const throttledSendDataChanges = throttle(sendDataChanges, 100, { leading: false, trailing: true });


export function initStoreSocket(io: SocketIoServer) {

// Use /data namespace and 'room' query parameter to subscribe
const nsp = io.of('/data');
nsp.on('connection', socket => {
Expand Down

0 comments on commit 83e07f3

Please sign in to comment.