aboutsummaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2026-03-13 03:20:46 +0100
committermetamuffin <metamuffin@disroot.org>2026-03-13 03:20:50 +0100
commit5726d34f31d0129d314dc447340b0feb56534060 (patch)
tree4001955680a353ff763afd6d8f5525420c005acf /server
parent1a55a5d74bb3e57eb6e77286b86a5be559d43589 (diff)
downloadhurrycurry-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.rs2
-rw-r--r--server/editor/src/main.rs1
-rw-r--r--server/game-core/src/network/sync.rs16
-rw-r--r--server/game-core/src/network/tokio.rs18
-rw-r--r--server/locale/src/message.rs33
-rw-r--r--server/protocol/src/lib.rs47
-rw-r--r--server/registry/src/lobby.rs1
-rw-r--r--server/src/commands.rs117
-rw-r--r--server/src/lib.rs1
-rw-r--r--server/src/server.rs42
-rw-r--r--server/src/state.rs18
-rw-r--r--server/src/vote.rs183
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"),
+ }
+}