diff options
| author | metamuffin <metamuffin@disroot.org> | 2026-03-13 03:20:46 +0100 |
|---|---|---|
| committer | metamuffin <metamuffin@disroot.org> | 2026-03-13 03:20:50 +0100 |
| commit | 5726d34f31d0129d314dc447340b0feb56534060 (patch) | |
| tree | 4001955680a353ff763afd6d8f5525420c005acf /server | |
| parent | 1a55a5d74bb3e57eb6e77286b86a5be559d43589 (diff) | |
| download | hurrycurry-5726d34f31d0129d314dc447340b0feb56534060.tar hurrycurry-5726d34f31d0129d314dc447340b0feb56534060.tar.bz2 hurrycurry-5726d34f31d0129d314dc447340b0feb56534060.tar.zst | |
add server-side voting logic
Diffstat (limited to 'server')
| -rw-r--r-- | server/book-export/src/book_html.rs | 2 | ||||
| -rw-r--r-- | server/editor/src/main.rs | 1 | ||||
| -rw-r--r-- | server/game-core/src/network/sync.rs | 16 | ||||
| -rw-r--r-- | server/game-core/src/network/tokio.rs | 18 | ||||
| -rw-r--r-- | server/locale/src/message.rs | 33 | ||||
| -rw-r--r-- | server/protocol/src/lib.rs | 47 | ||||
| -rw-r--r-- | server/registry/src/lobby.rs | 1 | ||||
| -rw-r--r-- | server/src/commands.rs | 117 | ||||
| -rw-r--r-- | server/src/lib.rs | 1 | ||||
| -rw-r--r-- | server/src/server.rs | 42 | ||||
| -rw-r--r-- | server/src/state.rs | 18 | ||||
| -rw-r--r-- | server/src/vote.rs | 183 |
12 files changed, 344 insertions, 135 deletions
diff --git a/server/book-export/src/book_html.rs b/server/book-export/src/book_html.rs index bebb37f7..0cbb42e9 100644 --- a/server/book-export/src/book_html.rs +++ b/server/book-export/src/book_html.rs @@ -81,7 +81,7 @@ markup::define! { } MessageR<'a>(data: &'a Gamedata, locale: &'a Locale, message: &'a Message) { - @message.display_message(locale, data, &PLAIN) + @message.display(locale, data, &PLAIN) } DiagramR<'a>(data: &'a Gamedata, diagram: &'a Diagram) { diff --git a/server/editor/src/main.rs b/server/editor/src/main.rs index 4074463a..8ed66e11 100644 --- a/server/editor/src/main.rs +++ b/server/editor/src/main.rs @@ -139,7 +139,6 @@ async fn handle_conn( state.out.push(PacketC::Version { major: VERSION.0, minor: VERSION.1, - supports_bincode: false, }); state.out.push(PacketC::GameData(Box::new(Gamedata { tile_collide: (0..TILES.len()).map(TileIndex).collect(), diff --git a/server/game-core/src/network/sync.rs b/server/game-core/src/network/sync.rs index 9854b58e..3d36881e 100644 --- a/server/game-core/src/network/sync.rs +++ b/server/game-core/src/network/sync.rs @@ -16,7 +16,7 @@ */ use anyhow::Result; -use hurrycurry_protocol::{PacketC, PacketS, VERSION}; +use hurrycurry_protocol::{PacketC, PacketS}; use log::{debug, info, warn}; use std::{collections::VecDeque, net::TcpStream}; use tungstenite::{ @@ -32,7 +32,6 @@ pub struct Network { sock: WebSocket<MaybeTlsStream<TcpStream>>, pub queue_in: VecDeque<PacketC>, pub queue_out: VecDeque<PacketS>, - use_bincode: bool, } impl Network { @@ -74,7 +73,6 @@ impl Network { info!("Handshake complete."); Ok(Self { sock, - use_bincode: false, queue_in: VecDeque::new(), queue_out: VecDeque::new(), }) @@ -86,18 +84,6 @@ impl Network { Ok(Message::Text(packet)) => match serde_json::from_str(&packet) { Ok(packet) => { debug!("<- {packet:?}"); - if let PacketC::Version { - minor, - major, - supports_bincode, - } = &packet - && *minor == VERSION.0 - && *major == VERSION.1 - && *supports_bincode - { - info!("Binary protocol format enabled."); - self.use_bincode = true; - } Some(packet) } Err(e) => { diff --git a/server/game-core/src/network/tokio.rs b/server/game-core/src/network/tokio.rs index 6e7f0902..5d7f6b07 100644 --- a/server/game-core/src/network/tokio.rs +++ b/server/game-core/src/network/tokio.rs @@ -20,9 +20,8 @@ use futures_util::{ SinkExt, TryStreamExt, stream::{SplitSink, SplitStream, StreamExt}, }; -use hurrycurry_protocol::{PacketC, PacketS, VERSION}; +use hurrycurry_protocol::{PacketC, PacketS}; use log::{debug, info, warn}; -use std::sync::atomic::{AtomicBool, Ordering}; use tokio::{net::TcpStream, sync::RwLock}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, client_async_tls_with_config}; use tungstenite::{ @@ -35,7 +34,6 @@ use tungstenite::{ pub struct Network { sock_recv: RwLock<SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>>, sock_send: RwLock<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>, - use_binary: AtomicBool, } impl Network { @@ -82,7 +80,6 @@ impl Network { Ok(Self { sock_recv: RwLock::new(sock_recv), sock_send: RwLock::new(sock_send), - use_binary: false.into(), }) } @@ -93,18 +90,7 @@ impl Network { Some(Message::Text(packet)) => match serde_json::from_str(&packet) { Ok(packet) => { debug!("<- {packet:?}"); - if let PacketC::Version { - minor, - major, - supports_bincode, - } = &packet - && *minor == VERSION.0 - && *major == VERSION.1 - && *supports_bincode - { - info!("Binary protocol format enabled."); - self.use_binary.store(true, Ordering::Relaxed); - } + return Ok(Some(packet)); } Err(e) => { diff --git a/server/locale/src/message.rs b/server/locale/src/message.rs index 798986fc..8d01fe19 100644 --- a/server/locale/src/message.rs +++ b/server/locale/src/message.rs @@ -20,17 +20,21 @@ use crate::Locale; use hurrycurry_protocol::{Gamedata, Message}; pub trait MessageDisplayExt { - fn display_message(&self, locale: &Locale, data: &Gamedata, style: &DisplayStyle) -> String; + fn display(&self, locale: &Locale, data: &Gamedata, style: &DisplayStyle) -> String; + fn display_nodata(&self, locale: &Locale, style: &DisplayStyle) -> String; } impl MessageDisplayExt for Message { - fn display_message(&self, locale: &Locale, data: &Gamedata, style: &DisplayStyle) -> String { - display_message_inner(self, locale, data, style) + fn display(&self, locale: &Locale, data: &Gamedata, style: &DisplayStyle) -> String { + display_message_inner(self, locale, Some(data), style) + } + fn display_nodata(&self, locale: &Locale, style: &DisplayStyle) -> String { + display_message_inner(self, locale, None, style) } } fn display_message_inner( message: &Message, locale: &Locale, - data: &Gamedata, + data: Option<&Gamedata>, style: &DisplayStyle, ) -> String { let DisplayStyle { @@ -46,13 +50,28 @@ fn display_message_inner( }; let mut s = template.to_string(); for (i, p) in params.iter().enumerate() { - s = s.replace(&format!("{{{i}}}"), &p.display_message(locale, data, style)); + s = s.replace( + &format!("{{{i}}}"), + &display_message_inner(p, locale, data, style), + ); } s } Message::Text(s) => format!("{highlighted}{s}{default}"), - Message::Item(item_index) => format!("{tile_item}{}{default}", data.item_name(*item_index)), - Message::Tile(tile_index) => format!("{tile_item}{}{default}", data.tile_name(*tile_index)), + Message::Item(item_index) => { + format!( + "{tile_item}{}{default}", + data.map(|d| d.item_name(*item_index)) + .unwrap_or("(no data)") + ) + } + Message::Tile(tile_index) => { + format!( + "{tile_item}{}{default}", + data.map(|d| d.tile_name(*tile_index)) + .unwrap_or("(no data)") + ) + } } } diff --git a/server/protocol/src/lib.rs b/server/protocol/src/lib.rs index 9dfcc4dd..903f707a 100644 --- a/server/protocol/src/lib.rs +++ b/server/protocol/src/lib.rs @@ -114,6 +114,7 @@ pub struct GamedataFlags { fn chef_class() -> PlayerClass { PlayerClass::Chef } + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] pub enum PacketS { @@ -153,6 +154,15 @@ pub enum PacketS { Keepalive, Ready, + InitiateVote { + player: PlayerID, + subject: VoteSubject, + }, + CastVote { + player: PlayerID, + agree: bool, + }, + /// For use in replay sessions only ReplayTick { dt: f64, @@ -210,13 +220,19 @@ pub enum Message { Tile(TileIndex), } +pub fn version() -> PacketC { + PacketC::Version { + major: VERSION.0, + minor: VERSION.1, + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] pub enum PacketC { Version { minor: u32, major: u32, - supports_bincode: bool, }, Joined { id: PlayerID, @@ -307,6 +323,20 @@ pub enum PacketC { uri: Vec<String>, }, + VoteStarted { + initiated_by: PlayerID, + subject: VoteSubject, + message: Message, + }, + VoteUpdated { + total: usize, + agree: usize, + reject: usize, + }, + VoteEnded { + result: bool, + }, + /// For use in replay sessions only ReplayStart, ReplayStop, @@ -316,6 +346,21 @@ pub enum PacketC { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "action")] +pub enum VoteSubject { + StartGame { config: GameConfig }, + EndGame, + RestartGame, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameConfig { + pub map: String, + pub hand_count: Option<usize>, + pub timer: Option<f32>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "menu", content = "data")] pub enum Menu { Score(Score), diff --git a/server/registry/src/lobby.rs b/server/registry/src/lobby.rs index f60d36c9..80a79236 100644 --- a/server/registry/src/lobby.rs +++ b/server/registry/src/lobby.rs @@ -73,7 +73,6 @@ async fn handle_conn(sock: TcpStream, addr: SocketAddr, entries: &[Entry]) -> Re out.push(PacketC::Version { major: VERSION.0, minor: VERSION.1, - supports_bincode: false, }); out.push(PacketC::ServerData(Box::new(Serverdata { bot_algos: vec![], diff --git a/server/src/commands.rs b/server/src/commands.rs index 36c7ccde..f8ead074 100644 --- a/server/src/commands.rs +++ b/server/src/commands.rs @@ -25,11 +25,10 @@ use hurrycurry_bot::algos::ALGO_CONSTRUCTORS; use hurrycurry_data::build_gamedata; use hurrycurry_locale::{TrError, tre, trm}; use hurrycurry_protocol::{ - Character, Hand, ItemLocation, Menu, Message, PacketC, PlayerClass, PlayerID, + Character, GameConfig, Hand, ItemLocation, Menu, Message, PacketC, PacketS, PlayerClass, + PlayerID, VoteSubject, }; -use std::fmt::Write; -#[cfg(feature = "cheats")] -use std::time::Duration; +use std::{fmt::Write, str::FromStr}; #[derive(Parser)] #[clap(multicall = true)] @@ -39,19 +38,15 @@ enum Command { Start { /// Gamedata specification #[arg(default_value = "junior")] - name: String, - - /// Skip announement and pause at game start - #[arg(short = 's', long)] - skip_announce: bool, + map: String, /// Number of hands per player #[arg(short = 'c', long)] hand_count: Option<usize>, /// Duration in seconds - #[cfg(feature = "cheats")] - timer: Option<u64>, + #[arg(short = 't', long)] + timer: Option<f32>, }, /// Shows the best entries of the scoreboard for this map. #[clap(alias = "top", alias = "top5")] @@ -62,6 +57,8 @@ enum Command { #[arg(short, long)] text: bool, }, + #[clap(alias = "v")] + Vote { agree: YesNo }, #[clap(alias = "mapinfo")] Info { /// Name of the map, default: current @@ -147,64 +144,41 @@ impl Server { ) -> Result<(), TrError> { match command { Command::Start { - name, - #[cfg(feature = "cheats")] - timer, - skip_announce, + map, hand_count, + timer, } => { - if !self.game.lobby { - self.broadcast - .send(PacketC::ServerMessage { - message: trm!( - "s.state.game_aborted", - s = self - .game - .players - .get(&player) - .ok_or(tre!("s.error.no_player"))? - .name - .clone() - ), - error: false, - }) - .ok(); - } - let mut data = build_gamedata(&self.config.data_path, &name, true) - .map_err(|e| TrError::Plain(e.to_string()))?; - - if let Some(hand_count) = hand_count { - data.0.hand_count = hand_count; - } - #[cfg(feature = "cheats")] - self.load(data, timer.map(Duration::from_secs)); - #[cfg(not(feature = "cheats"))] - self.load(data, None); - if skip_announce { - self.announce_state = AnnounceState::Done - } + self.packet_in( + replies, + PacketS::InitiateVote { + player, + subject: VoteSubject::StartGame { + config: GameConfig { + hand_count, + map, + timer, + }, + }, + }, + )?; } Command::End => { - self.broadcast - .send(PacketC::ServerMessage { - message: trm!( - "s.state.game_aborted", - s = self - .game - .players - .get(&player) - .ok_or(tre!("s.error.no_player"))? - .name - .clone() - ), - error: false, - }) - .ok(); - self.load( - build_gamedata(&self.config.data_path, &self.config.lobby, true) - .map_err(|e| TrError::Plain(e.to_string()))?, - None, - ); + self.packet_in( + replies, + PacketS::InitiateVote { + player, + subject: VoteSubject::EndGame, + }, + )?; + } + Command::Vote { agree } => { + self.packet_in( + replies, + PacketS::CastVote { + player, + agree: agree.0, + }, + )?; } Command::Reload => { if self.count_chefs() > 1 { @@ -434,3 +408,16 @@ impl Server { Ok(()) } } + +#[derive(Debug, Clone)] +struct YesNo(bool); +impl FromStr for YesNo { + type Err = &'static str; + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { + Ok(match s { + "y" | "yes" | "true" | "1" => Self(true), + "n" | "no" | "false" | "0" => Self(false), + _ => return Err("expected y, yes, true, 1 or their opposites"), + }) + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index f2fdfbe6..cec288ae 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -23,6 +23,7 @@ pub mod network; pub mod scoreboard; pub mod server; pub mod state; +pub mod vote; use hurrycurry_protocol::glam::Vec2; use rand::random; diff --git a/server/src/server.rs b/server/src/server.rs index e56d8b59..87e77fb2 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -19,6 +19,7 @@ use crate::{ ConnectionID, entity::{Entities, EntityContext, construct_entity}, scoreboard::ScoreboardStore, + vote::VoteState, }; use anyhow::{Context, Result, anyhow}; use hurrycurry_data::{PrivateGamedata, build_gamedata, map_list}; @@ -30,7 +31,7 @@ use hurrycurry_locale::{ }; use hurrycurry_protocol::{ Character, Gamedata, Hand, ItemLocation, Menu, MessageTimeout, PacketC, PacketS, PlayerClass, - PlayerID, Score, Serverdata, glam::Vec2, movement::MovementBase, + PlayerID, Score, Serverdata, VoteSubject, glam::Vec2, movement::MovementBase, }; use log::{info, warn}; use std::{ @@ -73,6 +74,7 @@ pub struct Server { pub announce_state: AnnounceState, pub game: Game, + pub vote_state: VoteState, pub data: Box<Serverdata>, pub priv_gamedata: Arc<PrivateGamedata>, pub entities: Entities, @@ -99,6 +101,7 @@ impl Server { name: config.name.clone(), }), config, + vote_state: VoteState::default(), game: Game::default(), tick_perf: (Duration::ZERO, 0), broadcast: tx, @@ -257,7 +260,7 @@ impl Server { } // Need to process loopback packets for entity despawn while let Some(p) = self.packet_loopback.pop_front() { - if let Err(e) = self.packet_in(None, p, &mut vec![]) { + if let Err(e) = self.packet_in(&mut vec![], p) { warn!("Internal entity destructor packet errored: {e}"); } } @@ -298,9 +301,8 @@ impl Server { pub fn packet_in( &mut self, - conn: Option<ConnectionID>, - packet: PacketS, replies: &mut Vec<PacketC>, + packet: PacketS, ) -> Result<(), TrError> { match packet { PacketS::Join { @@ -330,25 +332,19 @@ impl Server { &self.priv_gamedata, position, ); - if let Some(conn) = conn { - info!("{player} joined (owned by {conn})"); - } else { - info!("Server adds {player}"); - } + info!("{player} joined"); + self.vote_state.join(player); replies.push(PacketC::Joined { id: player }) } PacketS::Leave { player } => { - if let Some(conn) = conn { - info!("{player} left (owned by {conn})"); - } else { - info!("Server removes {player}"); - } + info!("{player} left"); if !self.game.remove_player(player) { return Err(tre!("s.error.no_player")); } self.game.players_spatial_index.remove_entry(player); self.player_inactivity_timers.remove(&player); self.last_movement_update.remove(&player); + self.vote_state.leave(player); } PacketS::Effect { player, name } => { self.packet_out.push_back(PacketC::Effect2 { @@ -464,7 +460,7 @@ impl Server { return Ok(()); }; if let Some(message) = &message { - let body = message.display_message(&GLOBAL_LOCALE, &self.game.data, &COLORED); + let body = message.display(&GLOBAL_LOCALE, &self.game.data, &COLORED); if !player_data.name.is_empty() { info!("[{player} {:?}] {body}", player_data.name); } else { @@ -514,6 +510,8 @@ impl Server { PacketS::ReplayTick { .. } => return Err(tre!("s.error.packet_not_supported")), PacketS::Idle { .. } | PacketS::Ready | PacketS::Keepalive => (), PacketS::Debug(d) => self.packet_out.push_back(PacketC::Debug(d)), + p @ PacketS::CastVote { .. } => self.vote_state.packet_in(p)?, + p @ PacketS::InitiateVote { .. } => self.vote_state.packet_in(p)?, } Ok(()) } @@ -593,13 +591,12 @@ impl Server { } for (player, hand) in players_auto_release.drain(..) { let _ = self.packet_in( - None, + &mut vec![], PacketS::Interact { target: None, player, hand, }, - &mut vec![], ); } @@ -638,12 +635,21 @@ impl Server { } }); + self.vote_state.tick(dt); + if let Some(subject) = self.vote_state.ratified.take() { + match subject { + VoteSubject::StartGame { config } => load_map = Some(config.map), + VoteSubject::EndGame => load_map = Some("lobby".to_string()), + VoteSubject::RestartGame => load_map = Some(self.game.data.current_map.clone()), + } + } + if let Some(map) = load_map { return Some((map, None)); } while let Some(p) = self.packet_loopback.pop_front() { - if let Err(e) = self.packet_in(None, p, &mut vec![]) { + if let Err(e) = self.packet_in(&mut vec![], p) { warn!("Internal packet errored: {e}"); } } diff --git a/server/src/state.rs b/server/src/state.rs index aa46f47c..f26275e3 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -22,7 +22,7 @@ use crate::{ use anyhow::Result; use hurrycurry_data::build_gamedata; use hurrycurry_locale::{TrError, tre, trm}; -use hurrycurry_protocol::{KEEPALIVE_INTERVAL, Menu, Message, PacketC, PacketS, PlayerID, VERSION}; +use hurrycurry_protocol::{KEEPALIVE_INTERVAL, Menu, Message, PacketC, PacketS, PlayerID, version}; use log::{debug, info, trace, warn}; use std::{ collections::HashSet, @@ -98,6 +98,7 @@ impl Server { } self.packet_out.extend(self.game.events.drain(..)); + self.packet_out.extend(self.vote_state.packet_out.drain(..)); while let Some(p) = self.packet_out.pop_front() { if matches!(p, PacketC::UpdateMap { .. } | PacketC::Movement { .. }) { trace!("-> {p:?}"); @@ -120,15 +121,9 @@ impl Server { let mut init = self.game.prime_client(); let (replies_tx, replies_rx) = mpsc::channel(1024); let broadcast_rx = self.broadcast.subscribe(); - init.insert( - 0, - PacketC::Version { - major: VERSION.0, - minor: VERSION.1, - supports_bincode: true, - }, - ); + init.insert(0, version()); init.insert(1, PacketC::ServerData(Box::new(*self.data.clone()))); + self.vote_state.prime_client(&mut init); self.connections.insert( id, ConnectionData { @@ -225,7 +220,8 @@ impl Server { } _ => (), } - self.packet_in(Some(conn), packet, &mut replies)?; + + self.packet_in(&mut replies, packet)?; for p in &replies { if let PacketC::Joined { id } = p { @@ -312,6 +308,8 @@ fn get_packet_player(packet: &PacketS) -> Option<PlayerID> { | PacketS::Interact { player, .. } | PacketS::Communicate { player, .. } | PacketS::ReplaceHand { player, .. } + | PacketS::InitiateVote { player, .. } + | PacketS::CastVote { player, .. } | PacketS::Effect { player, .. } => Some(*player), PacketS::Join { .. } | PacketS::Idle { .. } diff --git a/server/src/vote.rs b/server/src/vote.rs new file mode 100644 index 00000000..04c554eb --- /dev/null +++ b/server/src/vote.rs @@ -0,0 +1,183 @@ +/* + Hurry Curry! - a game about cooking + Copyright (C) 2025 Hurry Curry! Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, version 3 of the License only. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +*/ + +use hurrycurry_locale::{ + GLOBAL_LOCALE, TrError, + message::{COLORED, MessageDisplayExt}, + tre, trm, +}; +use hurrycurry_protocol::{Message, PacketC, PacketS, PlayerID, VoteSubject}; +use log::info; +use std::collections::{HashMap, HashSet, VecDeque}; + +#[derive(Default)] +pub struct VoteState { + initiate_cooldown: HashMap<PlayerID, f32>, + cast_cooldown: HashMap<PlayerID, f32>, + voting_players: HashSet<PlayerID>, + active: Option<ActiveVote>, + pub ratified: Option<VoteSubject>, + pub packet_out: VecDeque<PacketC>, +} +pub struct ActiveVote { + timeout: f32, + initiated_by: PlayerID, + ballots: HashMap<PlayerID, bool>, + subject: VoteSubject, +} + +impl VoteState { + pub fn join(&mut self, player: PlayerID) { + self.cast_cooldown.insert(player, 30.); + self.initiate_cooldown.insert(player, 40.); + self.update(); + } + pub fn leave(&mut self, player: PlayerID) { + self.cast_cooldown.remove(&player); + self.initiate_cooldown.remove(&player); + self.voting_players.remove(&player); + self.update(); + } + pub fn prime_client(&self, init: &mut Vec<PacketC>) { + if let Some(active) = &self.active { + init.push(PacketC::VoteStarted { + initiated_by: active.initiated_by, + subject: active.subject.clone(), + message: subject_to_message(&active.subject), + }); + } + } + pub fn packet_in(&mut self, p: PacketS) -> Result<(), TrError> { + match p { + PacketS::InitiateVote { player, subject } => { + if self.active.is_some() { + return Err(tre!("s.vote.error.active")); + } + if let Some(t) = self.initiate_cooldown.get(&player) { + return Err(tre!( + "s.vote.error.initiate_cooldown", + s = format!("{t:.0}") + )); + } + info!( + "{player} initiates vote for {}", + subject_to_message(&subject).display_nodata(&GLOBAL_LOCALE, &COLORED) + ); + self.initiate_cooldown.insert(player, 40.); + self.active = Some(ActiveVote { + timeout: 30., + initiated_by: player, + ballots: [(player, true)].into_iter().collect(), // assuming initiating player agrees with their own vote + subject: subject.clone(), + }); + self.packet_out.push_back(PacketC::VoteStarted { + initiated_by: player, + message: subject_to_message(&subject), + subject, + }); + self.update(); + } + PacketS::CastVote { player, agree } => { + let Some(active) = &mut self.active else { + return Err(tre!("s.vote.error.not_active")); + }; + if let Some(t) = self.cast_cooldown.get(&player) { + return Err(tre!("s.vote.error.cast_cooldown", s = format!("{t:.0}"))); + } + if active.initiated_by == player && !agree { + self.active = None; + self.packet_out + .push_back(PacketC::VoteEnded { result: false }); + return Ok(()); + } + active.ballots.insert(player, agree); + self.update(); + } + _ => (), + } + Ok(()) + } + + pub fn tick(&mut self, dt: f32) { + self.initiate_cooldown.retain(|_, c| { + *c -= dt; + *c > 0. + }); + self.cast_cooldown.retain(|p, c| { + *c -= dt; + if *c <= 0. { + self.voting_players.insert(*p); + } + *c > 0. + }); + if let Some(v) = &mut self.active { + v.timeout -= dt; + if v.timeout <= 0. { + self.update(); + } + } + } + + pub fn update(&mut self) { + // skip cooldowns if single player + if self.voting_players.len() + self.cast_cooldown.len() == 1 { + self.voting_players.extend(self.cast_cooldown.keys()); + self.cast_cooldown.clear(); + self.initiate_cooldown.clear(); + } + if let Some(a) = &self.active { + let total = self.voting_players.len(); + let mut agree = 0; + let mut reject = 0; + for (_, v) in &a.ballots { + if *v { + agree += 1; + } else { + reject += 1; + } + } + if agree * 2 > total || reject * 2 > total || a.timeout <= 0. { + let result = agree > reject; + if result { + info!("Vote passed"); + self.ratified = Some(a.subject.clone()); + } else { + info!("Vote rejected"); + } + self.active = None; + self.packet_out.push_back(PacketC::VoteEnded { result }); + } else { + self.packet_out.push_back(PacketC::VoteUpdated { + total, + agree, + reject, + }); + } + } + } +} + +fn subject_to_message(s: &VoteSubject) -> Message { + match s { + VoteSubject::StartGame { config } => { + trm!("s.vote.subject.start_game", s = config.map.clone()) + } + VoteSubject::EndGame => trm!("s.vote.subject.end_game"), + VoteSubject::RestartGame => trm!("s.vote.subject.restart_game"), + } +} |