From a4bc499cbce3683303140193534c3299bc38e8af Mon Sep 17 00:00:00 2001 From: DeathByDenim Date: Sat, 16 Sep 2023 15:28:56 -0400 Subject: [PATCH] Add score recording to SuperTuxKart --- configs/stkranking.patch | 287 ++++++++++++++++++++++++++++++++ scripts/deploy_supertuxkart.sh | 50 +++++- services/supertuxkartscores.py | 39 +++++ website/_data/events.yml | 188 ++++++++++++++++++++- website/js/supertuxkartscore.js | 111 ++++++++++++ website/tournament.html | 22 +-- 6 files changed, 683 insertions(+), 14 deletions(-) create mode 100644 configs/stkranking.patch create mode 100755 services/supertuxkartscores.py create mode 100644 website/js/supertuxkartscore.js diff --git a/configs/stkranking.patch b/configs/stkranking.patch new file mode 100644 index 0000000..6cb2410 --- /dev/null +++ b/configs/stkranking.patch @@ -0,0 +1,287 @@ +diff --git a/src/network/protocols/server_lobby.cpp b/src/network/protocols/server_lobby.cpp +index 3516ffe03..aa3fd698b 100644 +--- a/src/network/protocols/server_lobby.cpp ++++ b/src/network/protocols/server_lobby.cpp +@@ -249,6 +249,7 @@ ServerLobby::ServerLobby() : LobbyProtocol() + m_game_mode.store(ServerConfig::m_server_mode); + m_default_vote = new PeerVote(); + m_player_reports_table_exists = false; ++ m_grand_prix_rowid = -1; + initDatabase(); + } // ServerLobby + +@@ -389,6 +390,51 @@ void ServerLobby::initServerStatsTable() + ") WITHOUT ROWID;", country_table_name.c_str()); + easySQLQuery(query); + ++ // Extra default table _grandprixresults: ++ std::string grandprixresults_table_name = std::string("v") + ++ StringUtils::toString(ServerConfig::m_server_db_version) + "_" + ++ ServerConfig::m_server_uid + "_grandprixresults"; ++ query = StringUtils::insertValues( ++ "CREATE TABLE IF NOT EXISTS %s (\n" ++ " num_races INTEGER NOT NULL, -- Number of races completed\n" ++ " total_races INTEGER NOT NULL -- Total number of races\n" ++ ");", grandprixresults_table_name.c_str()); ++ if(easySQLQuery(query)) { ++ m_server_grandprixresults_table = grandprixresults_table_name; ++ } ++ ++ // Extra default table _raceresults: ++ std::string raceresults_table_name = std::string("v") + ++ StringUtils::toString(ServerConfig::m_server_db_version) + "_" + ++ ServerConfig::m_server_uid + "_raceresults"; ++ query = StringUtils::insertValues( ++ "CREATE TABLE IF NOT EXISTS %s (\n" ++ " race_finished TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Time when race was completed\n" ++ " grandprix_rowid INTEGER, -- ROWID of the grand prix this race is part of (optional)\n" ++ " track_name TEXT NOT NULL, -- Number of laps completed\n" ++ " num_laps INTEGER NOT NULL, -- Number of laps completed\n" ++ " total_laps INTEGER NOT NULL, -- Total number of laps\n" ++ " fastest_lap_time FLOAT NOT NULL, -- Fastest lap so far\n" ++ " fastest_lap_player TEXT NOT NULL -- Fastest lap so far\n" ++ ");", raceresults_table_name.c_str()); ++ if(easySQLQuery(query)) { ++ m_server_raceresults_table = raceresults_table_name; ++ } ++ ++ // Extra default table _raceresults: ++ std::string playerresults_table_name = std::string("v") + ++ StringUtils::toString(ServerConfig::m_server_db_version) + "_" + ++ ServerConfig::m_server_uid + "_playerresults"; ++ query = StringUtils::insertValues( ++ "CREATE TABLE IF NOT EXISTS %s (\n" ++ " race_rowid INTEGER NOT NULL, -- ROWID of the race\n" ++ " elapsed_time FLOAT NOT NULL, -- Total race time\n" ++ " player_name TEXT NOT NULL -- Player name\n" ++ ") WITHOUT ROWID;", playerresults_table_name.c_str()); ++ if(easySQLQuery(query)) { ++ m_server_playerresults_table = playerresults_table_name; ++ } ++ + // Default views: + // _full_stats + // Full stats with ip in human readable format and time played of each +@@ -2884,6 +2930,7 @@ void ServerLobby::checkRaceFinished() + RaceEventManager::get()->getProtocol()->requestTerminate(); + GameProtocol::lock()->requestTerminate(); + ++ + // Save race result before delete the world + m_result_ns->clear(); + m_result_ns->addUInt8(LE_RACE_FINISHED); +@@ -2895,10 +2942,13 @@ void ServerLobby::checkRaceFinished() + m_result_ns->addUInt32(fastest_lap); + m_result_ns->encodeString(static_cast(World::getWorld()) + ->getFastestLapKartName()); ++ std::cout << static_cast(World::getWorld())->getFastestLapKartName().c_str() << std::endl; ++ Log::info("RaceResults", "Fastest lap by %s", StringUtils::wideToUtf8(static_cast(World::getWorld())->getFastestLapKartName()).c_str()); + + // all gp tracks + m_result_ns->addUInt8((uint8_t)m_game_setup->getTotalGrandPrixTracks()) + .addUInt8((uint8_t)m_game_setup->getAllTracks().size()); ++ Log::info("RaceResults", "Track number %d / %d", m_game_setup->getAllTracks().size(), m_game_setup->getTotalGrandPrixTracks()); + for (const std::string& gp_track : m_game_setup->getAllTracks()) + m_result_ns->encodeString(gp_track); + +@@ -2917,9 +2967,13 @@ void ServerLobby::checkRaceFinished() + overall_time = overall_time + player->getOverallTime(); + player->setScore(cur_score); + player->setOverallTime(overall_time); ++ Log::info("RaceResults", "Score for %s from %d to %d (%f)", StringUtils::wideToUtf8(player->getName()).c_str(), last_score, cur_score, overall_time); ++ } ++ else { ++ Log::info("RaceResults", "Score for player %d from %d to %d (%f)", i, last_score, cur_score, overall_time); + } + m_result_ns->addUInt32(last_score).addUInt32(cur_score) +- .addFloat(overall_time); ++ .addFloat(overall_time); + } + } + else if (RaceManager::get()->modeHasLaps()) +@@ -2936,6 +2990,8 @@ void ServerLobby::checkRaceFinished() + ranking_changes_indication = 1; + m_result_ns->addUInt8(ranking_changes_indication); + ++ writeRaceResults(); ++ + if (ServerConfig::m_ranked) + { + computeNewRankings(); +@@ -2944,6 +3000,148 @@ void ServerLobby::checkRaceFinished() + m_state.store(WAIT_FOR_RACE_STOPPED); + } // checkRaceFinished + ++int ServerLobby::getLastRowID(const std::string &table) { ++ int rowid = -1; ++ std::string query = StringUtils::insertValues("SELECT ROWID from %s ORDER BY ROWID DESC LIMIT 1;", table); ++ ++ sqlite3_stmt* stmt = NULL; ++ int ret = sqlite3_prepare_v2(m_db, query.c_str(), -1, &stmt, 0); ++ if (ret == SQLITE_OK) ++ { ++ ret = sqlite3_step(stmt); ++ if (ret == SQLITE_ROW) ++ { ++ rowid = sqlite3_column_int(stmt, 0); ++ } ++ ret = sqlite3_finalize(stmt); ++ if (ret != SQLITE_OK) ++ { ++ Log::error("ServerLobby", ++ "Error rowid database for query %s: %s", ++ query.c_str(), sqlite3_errmsg(m_db)); ++ } ++ } ++ else ++ { ++ Log::error("ServerLobby", "Error preparing database for query %s: %s", ++ query.c_str(), sqlite3_errmsg(m_db)); ++ return -1; ++ } ++ return rowid; ++ ++} ++ ++//----------------------------------------------------------------------------- ++/** Write the results of the race ++ */ ++void ServerLobby::writeRaceResults() ++{ ++#ifdef ENABLE_SQLITE3 ++ std::string track_name = RaceManager::get()->getTrackName(); ++ bool track_reverse = RaceManager::get()->getReverseTrack(); ++ int player_count = RaceManager::get()->getNumPlayers(); ++ int laps_number = RaceManager::get()->getNumLaps(); ++ ++ if (RaceManager::get()->getMinorMode() == RaceManager::MINOR_MODE_NORMAL_RACE) { ++ std::string query; ++ if (m_game_setup->isGrandPrix()) { ++ int race_number = m_game_setup->getAllTracks().size(); ++ int total_races = m_game_setup->getTotalGrandPrixTracks(); ++ if(race_number == 1) { ++ query = StringUtils::insertValues( ++ "INSERT INTO %s " ++ "(num_races, total_races) " ++ "VALUES (%d, %d);", ++ m_server_grandprixresults_table, ++ race_number, ++ total_races ++ ); ++ if(easySQLQuery(query)) { ++ m_grand_prix_rowid = getLastRowID(m_server_grandprixresults_table); ++ } ++ } ++ else { ++ query = StringUtils::insertValues( ++ "UPDATE %s " ++ "SET num_races = %d " ++ "WHERE ROWID = %d;", ++ m_server_grandprixresults_table, ++ race_number, ++ m_grand_prix_rowid ++ ); ++ easySQLQuery(query); ++ } ++ } ++ ++ if(m_grand_prix_rowid >= 0) { ++ query = StringUtils::insertValues( ++ "INSERT INTO %s " ++ "(grandprix_rowid, track_name, num_laps, total_laps, fastest_lap_time, fastest_lap_player) " ++ "VALUES (%d, \"%s\", %d, %d, %f, \"%s\");", ++ m_server_raceresults_table, ++ m_grand_prix_rowid, ++ track_name, ++ laps_number, ++ 0, ++ 0., ++ "" ++ ); ++ } ++ else { ++ query = StringUtils::insertValues( ++ "INSERT INTO %s " ++ "(track_name, num_laps, total_laps, fastest_lap_time, fastest_lap_player) " ++ "VALUES (\"%s\", %d, %d, %f, \"%s\");", ++ m_server_raceresults_table, ++ track_name, ++ laps_number, ++ 0, ++ 0., ++ "" ++ ); ++ } ++ if(easySQLQuery(query)) { ++ int race_rowid = getLastRowID(m_server_raceresults_table); ++ Log::info("RaceResults", "Row ID: %d", race_rowid); ++ if(race_rowid >= 0) { ++ for (int i = 0; i < player_count; i++) { ++ double elapsed_time = RaceManager::get()->getKartRaceTime(i); ++ std::string player_name = StringUtils::wideToUtf8( ++ RaceManager::get()->getKartInfo(i).getPlayerName()); ++ //int grand_prix_rank = RaceManager::get()->getKartGPRank(i); ++ query = StringUtils::insertValues( ++ "INSERT INTO %s " ++ "(race_rowid, elapsed_time, player_name) " ++ "VALUES (%d, %f, ?);", ++ m_server_playerresults_table, ++ race_rowid, ++ elapsed_time ++ ); ++ easySQLQuery(query, [player_name](sqlite3_stmt* stmt) { ++ if (sqlite3_bind_text(stmt, 1, player_name.c_str(), ++ -1, SQLITE_TRANSIENT) != SQLITE_OK) ++ { ++ Log::error("easySQLQuery", "Failed to bind %s.", ++ player_name.c_str()); ++ } ++ }); ++ } ++ } ++ } ++ ++ if(m_game_setup->isGrandPrix()) { ++ int race_number = m_game_setup->getAllTracks().size(); ++ int total_races = m_game_setup->getTotalGrandPrixTracks(); ++ ++ if(race_number == total_races) { ++ m_grand_prix_rowid = -1; ++ } ++ } ++ } ++#endif ++} ++ ++ + //----------------------------------------------------------------------------- + /** Compute the new player's rankings used in ranked servers + */ +diff --git a/src/network/protocols/server_lobby.hpp b/src/network/protocols/server_lobby.hpp +index 53d3aceda..780ef2d4e 100644 +--- a/src/network/protocols/server_lobby.hpp ++++ b/src/network/protocols/server_lobby.hpp +@@ -85,6 +85,14 @@ private: + + std::string m_server_stats_table; + ++ std::string m_server_grandprixresults_table; ++ ++ std::string m_server_raceresults_table; ++ ++ std::string m_server_playerresults_table; ++ ++ int m_grand_prix_rowid; ++ + bool m_ip_ban_table_exists; + + bool m_ipv6_ban_table_exists; +@@ -377,6 +385,8 @@ private: + void testBannedForOnlineId(STKPeer* peer, uint32_t online_id) const; + void writeDisconnectInfoTable(STKPeer* peer); + void writePlayerReport(Event* event); ++ int getLastRowID(const std::string &table); ++ void writeRaceResults(); + bool supportsAI(); + void updateAddons(); + public: diff --git a/scripts/deploy_supertuxkart.sh b/scripts/deploy_supertuxkart.sh index 81dba74..a82e65d 100755 --- a/scripts/deploy_supertuxkart.sh +++ b/scripts/deploy_supertuxkart.sh @@ -19,6 +19,11 @@ set -e echo "Installing SuperTuxKart ${stk_version}" +apt-get install --assume-yes build-essential cmake libbluetooth-dev libsdl2-dev \ +libcurl4-openssl-dev libenet-dev libfreetype6-dev libharfbuzz-dev \ +libjpeg-dev libogg-dev libopenal-dev libpng-dev \ +libssl-dev libvorbis-dev libmbedtls-dev pkg-config zlib1g-dev subversion + if [ -e /etc/systemd/system/supertuxkart.service ]; then systemctl stop supertuxkart fi @@ -31,10 +36,28 @@ fi stk_dir="/opt/SuperTuxKart-${stk_version}" mkdir -p ${stk_dir} curl --location "https://github.com/supertuxkart/stk-code/releases/download/${stk_version}/SuperTuxKart-${stk_version}-linux-x86_64.tar.xz" | tar --extract --xz --no-same-owner --strip-components=1 --directory=${stk_dir} + +builddir=""${TMPDIR:-/tmp}/stk-build"" +if [ -d "$builddir" ]; + rm -rf "$builddir" +fi +mkdir -p "$builddir" +curl --location "https://github.com/supertuxkart/stk-code/archive/refs/tags/${stk_version}.tar.gz" | tar --extract --gz --no-same-owner --directory=$builddir +svn co https://svn.code.sf.net/p/supertuxkart/code/stk-assets ${builddir}/stk-assets +patch -p1 < ~jarno/stkranking.patch +mkdir -p "$builddir"/build +cd "$builddir"/build +cmake ../stk-code-${stk_version}/ -DCMAKE_INSTALL_PREFIX=/opt/SuperTuxKart-${stk_version}/ -DSERVER_ONLY=On +make -j 2 +make install + ln -s ${stk_dir}/bin/supertuxkart /usr/games/supertuxkart # Configuration -cp $(dirname $0)/../configs/supertuxkart.xml /etc/supertuxkart.xml +mkdir -p /etc/supertuxkart +cp "$(dirname $0)"/../configs/supertuxkart.xml /etc/supertuxkart/supertuxkart.xml +touch /etc/supertuxkart/stkservers.db +chown -R ${systemuser}: /etc/supertuxkart # Create SystemD unit cat > /etc/systemd/system/supertuxkart.service < /etc/nginx/gameserver.d/supertuxkart.conf < /etc/systemd/system/supertuxkartscores.service <Anonymous: €40 - date: 2022-02-12T15:00UTC - intro: "Session to test if it all works as intended" + intro: "Session to test if it all works as intended! hosted by DeathByDenim, first time hosted by someone else" games: - title: "OpenSpades" - title: "OpenHV" - title: "Xonotic" +- date: 2022-01-10T12:00:00UTC + intro: "onFOSS-LAN: powered by LibreGaming! hosted by LibreGaming, first group-effort event" + games: + - time: "12:00" + title: "Meetup on Mumble and chill gaming" + - time: "13:00" + title: "Break" + - time: "14:00" + title: "New to onFOSS-LAN (UFO:AI, Soldat)" + - time: "15:30" + title: "Break" + - time: "16:00" + title: "SuperTuxKart Tournament" + - time: "18:00" + title: "Break" + - time: "18:30" + title: "Good old classics (Lix, Hedgewars)" + - time: "20:00" + title: "Late night gaming" + extras: ["Soldat", "UFO:AI","Lix","Hedgewars","SuperTuxKart"] +- date: 2021-12-19T11:00:00UTC + intro: "onFOSS-LAN: Double the fun! hosted by hribhrib, first event over two days, livestream by opensource_gaming" + games: + - time: "13:00" + title: "Meetup on Mumble and chill gaming" + - time: "15:30" + title: "Break" + - time: "16:00" + title: "Teeworlds Tournament - Finale LIVESTREAM" +- date: 2021-12-18T11:00:00UTC + intro: "onFOSS-LAN: Double the fun! hosted by hribhrib, first event over two days, livestream by opensource_gaming" + games: + - time: "11:00" + title: "Meetup on Mumble and chill gaming" + - time: "14:00" + title: "Break" + - time: "15:00" + title: "Teeworlds Tournament - Playoffs LIVESTREAM" + - time: "18:00" + title: "Break" + - time: "19:00" + title: "Late night gaming" + extras: ["Teeworlds", "OpenHV","Hedgewars","SuperTuxKart","Mindustry","OpenSpades","Unvanquished"] +- date: 2021-11-13T10:00:00UTC + intro: "onFOSS-LAN: Casual Saturday! hosted by hribhrib" + games: + - time: "10:00" + title: "Meetup on Mumble and chill gaming" + - time: "13:00" + title: "Break" + - time: "14:00" + title: "Casual gaming" + - time: "17:00" + title: "Break" + - time: "18:00" + title: "Late night gaming" + extras: ["BZFlag", "Hedgewars","Mindustry","SuperTuxKart"] +- date: 2021-09-11T10:00:00UTC + intro: "onFOSS-LAN: spoRTSmanship! hosted by hribhrib, livestream by opensource_gaming" + games: + - time: "10:00" + title: "Meetup on Mumble and chill gaming" + - time: "13:00" + title: "Break" + - time: "14:00" + title: "OpenHV tournament (LIVESTREAM)" + - time: "17:00" + title: "Break" + - time: "17:30" + title: "OpenHV finals (LIVESTREAM)" + - time: "19:00" + title: "Break" + - time: "20:00" + title: "Late night gaming" + extras: ["0ad", "OpenHV","Widelands"] +- date: 2021-07-10T10:00:00UTC + intro: "onFOSS-LAN: Race for the loot! hosted by hribhrib" + games: + - time: "10:00" + title: "Meetup on Mumble and chill gaming" + - time: "13:00" + title: "Break" + - time: "14:00" + title: "Veloren Loot and Level" + - time: "17:00" + title: "Break" + - time: "18:00" + title: "Veloren final Dungeon (LIVESTREAM)" + - time: "20:00" + title: "Late night gaming" + extras: ["SuperTuxKart", "armagetron","Minetest"] +- date: 2021-05-22T10:00:00UTC + intro: "onFOSS-LAN: Live and Reloaded! hosted by hribhrib, first livestream by Murks" + games: + - time: "10:00" + title: "Meetup on Mumble and chill gaming" + - time: "13:00" + title: "Break" + - time: "14:00" + title: "Teeworlds tournament (LIVESTREAM)" + - time: "17:00" + title: "Break" + - time: "17:30" + title: "Teeworlds finals (LIVESTREAM)" + - time: "19:00" + title: "Break" + - time: "20:00" + title: "Late night gaming" + extras: ["OpenSpades", "Xonotic","Minetest"] +- date: 2021-04-17T10:30:00UTC + intro: "onFOSS-LAN hosted by hribhrib, first public onFOSS-LAN!" + games: + - time: "10:30" + title: "Meetup on Mumble and chill gaming" + - time: "13:00" + title: "OpenRA tournament" + - time: "16:00" + title: "Break" + - time: "17:00" + title: "Hedgewars tournament" + - time: "20:00" + title: "Break" + - time: "21:00" + title: "Late night gaming" + extras: ["Minetest", "SuperTuxKart (Race and Soccer modes)","Teeworlds"] +- date: 2021-03-06T10:30:00UTC + intro: "onFOSS-LAN hosted by hribhrib, private" + games: + - time: "10:30" + title: "Meetup on Mumble and chill gaming" + - time: "13:00" + title: "test warzone2100" + - time: "15:00" + title: "OpenRA - Free for all (12player max)" + - time: "17:00" + title: "OpenRA - 1v1/2v2/3v3 tournaments" + - time: "21:00" + title: "Late night gaming" +- date: 2021-01-23T10:30:00UTC + intro: "onFOSS-LAN hosted by hribhrib, private" + games: + - time: "10:30" + title: "Meetup on Mumble and chill gaming" + - time: "13:00" + title: "supertuxkart turnament (8player max)" + - time: "15:00" + title: "hedgewars turnament (8player max)" + - time: "17:00" + title: "teeworlds together" + - time: "19:00" + title: "0ad US vs BOTS (6player max or 8 without bots)" + - time: "21:00" + title: "Late night gaming" +- date: 2021-01-02T10:30:00UTC + intro: "onFOSS-LAN hosted by hribhrib, private" + games: + - time: "10:30" + title: "Doors open, Welcoming and troubleshooting games if not running, talk, play" + - time: "12:00" + title: "Lunch together (or alone)" + - time: "13:00" + title: "GAMES" diff --git a/website/js/supertuxkartscore.js b/website/js/supertuxkartscore.js new file mode 100644 index 0000000..10a4922 --- /dev/null +++ b/website/js/supertuxkartscore.js @@ -0,0 +1,111 @@ +let grandprix_blocks; +let races; + +function positionToScore(i) { + switch(i) { + case 0: + return 4; + case 1: + return 2; + case 2: + return 1; + default: + return 0; + } +} + +function supertuxkartScoreUpdate() { + d3.json("/dynamic/supertuxkartscore.json").then((data) => { + grandprix_blocks = d3.select('#supertuxkart-results') + .selectAll('div.grandprix-div') + .data(data) + .join( + (enter) => { + const e = enter.append('div') + .classed('grandprix-div', true); + e.append('h3') + .text((d, i) => 'Grand prix ' + (i+1)); + e.append('div').classed('races', true); + return e; + }, + (update) => { + update.select('h3') + .text((d, i) => 'Grand prix ' + (i+1)); + return update; + }, + (exit) => { + exit.remove(); + } + ); + + races = grandprix_blocks.select('div.races') + .selectAll('table') + .data((d) => { + return d.races.map((x) => { + x["total"] = d.total_races; + return x; + }); + }) + .join( + (enter) => { + const table = enter.append('table') + .classed('table', true); + let thead = table.append('thead'); + thead.append('tr') + .append('th') + .attr('colspan', 4) + .text((d, i) => { + return "Race " + (i+1) + " out of " + d.total + " on " + d.track; + }); + let headerrows = thead.append('tr'); + ["Rank", "Name", "Time", "Points"].forEach((col) => { + headerrows.append('th').text(col); + }); + table.append('tbody') + .classed('grandprix-tbody', true); + return table; + }, + (update) => { + const u = update; + update.select('th[colspan="4"]') + .text((d, i) => { + return "Race " + (i+1) + " out of " + d.total + " on " + d.track; + }); + return u; + }, + (exit) => { + exit.remove(); + } + ); + + let tbodies = races.select('tbody.grandprix-tbody') + .selectAll('tr') + .data((d) => d.players) + .join( + (enter) => { + let e = enter.append('tr'); + e.append('td') + .text((d,i) => (i+1) + '.'); + e.append('td') + .text((d,i) => d.name); + e.append('td') + .text((d,i) => d.time); + e.append('td') + .text((d,i) => positionToScore(i)); + return e; + }, + (update) => { + update.select('td:nth-child(1)') + .text((d,i) => {console.log(d); return (i+1) + '.'}); + update.select('td:nth-child(2)') + .text((d,i) => d.name); + update.select('td:nth-child(3)') + .text((d,i) => d.time); + update.select('td:nth-child(4)') + .text((d,i) => positionToScore(i)); + return update; + }, + (exit) => exit.remove() + ) + }); +} diff --git a/website/tournament.html b/website/tournament.html index 5d8677d..9ae17a4 100644 --- a/website/tournament.html +++ b/website/tournament.html @@ -3,6 +3,7 @@ layout: default nav_pill: tournament --- +

Tournament

{% assign sitetime = site.time | date: "%FT%T" %} {% assign nextevent = site.data.events | where_exp: "item", "item.date >= sitetime" | last %} @@ -25,19 +26,20 @@ nav_pill: tournament {% endfor %}

Ranking

+
+ +

    Rounds

    - - {% else %}

    No tournaments have been planned for the next event.

    {% endif %}