use crate::app::net::Online;
use crate::sim::TideEvent;
use crate::transport::NetMsg;
use bevy::prelude::*;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct Crowd {
pub watching: u8,
pub open: u8,
pub wait: u8,
}
#[derive(Resource, Default)]
pub struct SpectatorChat(pub Option<String>);
impl SpectatorChat {
pub fn open(&self) -> bool {
self.0.is_some()
}
}
pub const SPECTATOR_EVENTS: [TideEvent; 7] = [
TideEvent::CrabMania,
TideEvent::GullMania,
TideEvent::SpeedUp,
TideEvent::SlowDown,
TideEvent::FreshSand,
TideEvent::CastleSwap,
TideEvent::RightClaws,
];
const WINDOW: f32 = 3.0;
const COOLDOWN: f32 = 25.0;
#[derive(Default)]
pub struct SpectatorVotes {
tally: [u16; SPECTATOR_EVENTS.len()],
open_for: f32,
cooldown: f32,
}
impl SpectatorVotes {
pub fn cast(&mut self, event: u8) {
let Some(at) = SPECTATOR_EVENTS
.iter()
.position(|e| e.index() == usize::from(event))
else {
return; };
if self.cooldown > 0.0 {
return;
}
if self.open_for <= 0.0 {
self.tally = [0; SPECTATOR_EVENTS.len()];
self.open_for = WINDOW;
}
self.tally[at] = self.tally[at].saturating_add(1);
}
pub fn settle(&mut self, seconds: f32) -> Option<TideEvent> {
self.cooldown = (self.cooldown - seconds).max(0.0);
if self.open_for <= 0.0 {
return None;
}
self.open_for -= seconds;
if self.open_for > 0.0 {
return None;
}
self.open_for = 0.0;
self.cooldown = COOLDOWN;
let (at, votes) = self
.tally
.iter()
.enumerate()
.max_by_key(|(at, votes)| (**votes, std::cmp::Reverse(*at)))?;
(*votes > 0).then(|| SPECTATOR_EVENTS[at])
}
pub fn forget_open(&mut self) {
self.open_for = 0.0;
self.tally = [0; SPECTATOR_EVENTS.len()];
}
pub fn forget_all(&mut self) {
*self = SpectatorVotes::default();
}
pub fn open(&self) -> Option<f32> {
(self.open_for > 0.0).then_some(self.open_for)
}
pub fn waiting(&self) -> Option<f32> {
(self.cooldown > 0.0).then_some(self.cooldown)
}
}
pub fn is_spectating(online: &Online) -> bool {
online
.0
.as_ref()
.is_some_and(|session| session.session.watching())
}
pub fn spectator_chat_input(
keys: Res<ButtonInput<KeyCode>>,
caps: Res<crate::app::keycaps::KeyCaps>,
mut typed: MessageReader<bevy::input::keyboard::KeyboardInput>,
settings: Res<crate::app::settings::GameSettings>,
mut chat: ResMut<SpectatorChat>,
mut online: ResMut<Online>,
) {
if !is_spectating(&online) {
chat.0 = None;
typed.clear();
return;
}
let Some(line) = &mut chat.0 else {
if caps.just_pressed(&keys, 'T') {
chat.0 = Some(String::new());
typed.clear();
}
return;
};
let said = crate::app::lobby::type_a_line(&mut typed, line);
if keys.just_pressed(KeyCode::Escape) {
chat.0 = None;
return;
}
let Some(said) = said else {
return;
};
chat.0 = None;
if said.trim().is_empty() {
return;
}
let me = settings.names[0].clone();
if me.is_empty() {
return;
}
if let Some(session) = &mut online.0 {
session.transport.send(NetMsg::chat(&me, &said));
session.heard.push((me, said));
}
}
pub fn settle_spectator_vote(
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
phase: Res<State<crate::app::VersusPhase>>,
mut online: ResMut<Online>,
) {
let Some(session) = &mut online.0 else {
return;
};
if !session.is_host() {
return;
}
if *phase.get() != crate::app::VersusPhase::Running {
session.forget_open_vote();
return;
}
let crowd = Crowd {
watching: session.watchers_in_round().min(u8::MAX as usize) as u8,
open: session
.spectators
.open()
.map_or(0, |left| left.ceil() as u8),
wait: session
.spectators
.waiting()
.map_or(0, |left| left.ceil() as u8),
};
let again = crate::app::lobby::once_a_second(&mut session.crowd_said, time.delta_secs());
if crowd != session.crowd || again {
session.crowd = crowd;
session.transport.send(NetMsg::SpectatorTally {
watching: crowd.watching,
open: crowd.open,
wait: crowd.wait,
});
}
let Some(event) = session.spectators.settle(time.delta_secs()) else {
return;
};
session.pending_call = Some(event);
let tr = settings.tr();
let line = crate::app::i18n::fill(tr.spectator_called, &[("e", tr.events[event.index()])]);
session.transport.send(NetMsg::chat("", &line));
session.heard.push((String::new(), line));
}
#[derive(Component)]
pub struct SpectatorCardUi;
#[derive(Resource, Default)]
pub struct SpectatorCard(pub bool);
#[allow(clippy::too_many_arguments)]
pub fn spectator_vote_input(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
caps: Res<crate::app::keycaps::KeyCaps>,
settings: Res<crate::app::settings::GameSettings>,
chat: Res<SpectatorChat>,
phase: Res<State<crate::app::VersusPhase>>,
mut card: ResMut<SpectatorCard>,
mut online: ResMut<Online>,
ui: Query<Entity, With<SpectatorCardUi>>,
) {
let shut = |commands: &mut Commands, card: &mut SpectatorCard| {
card.0 = false;
for entity in &ui {
commands.entity(entity).despawn();
}
};
let playing = *phase.get() == crate::app::VersusPhase::Running;
if !is_spectating(&online) || chat.open() || !playing {
if card.0 {
shut(&mut commands, &mut card);
}
return;
}
if !card.0 {
if caps.just_pressed(&keys, 'E') {
card.0 = true;
spawn_card(&mut commands, &settings);
}
return;
}
if keys.just_pressed(KeyCode::Escape) || caps.just_pressed(&keys, 'E') {
shut(&mut commands, &mut card);
return;
}
const PICKS: [KeyCode; 7] = [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
KeyCode::Digit6,
KeyCode::Digit7,
];
for (at, key) in PICKS.into_iter().enumerate() {
if !keys.just_pressed(key) {
continue;
}
if let Some(session) = &mut online.0 {
session.transport.send(NetMsg::SpectatorVote {
event: SPECTATOR_EVENTS[at].index() as u8,
});
}
shut(&mut commands, &mut card);
return;
}
}
pub(super) fn spawn_card(commands: &mut Commands, settings: &crate::app::settings::GameSettings) {
use crate::app::{menu_ui, palette};
let tr = settings.tr();
commands
.spawn((
SpectatorCardUi,
GlobalZIndex(menu_ui::layer::CARD),
menu_ui::centred_overlay(),
))
.with_children(|wrap| {
wrap.spawn(menu_ui::screen_card()).with_children(|card| {
card.spawn((
Text::new(tr.spectator_call_title),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::HEADING),
..default()
},
TextColor(palette::GOLD),
));
for (at, event) in SPECTATOR_EVENTS.into_iter().enumerate() {
card.spawn((
Text::new(format!("{} {}", at + 1, tr.events[event.index()])),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::ROW),
..default()
},
TextColor(palette::PARCHMENT),
));
}
});
});
}
pub fn forget_spectating(
mut commands: Commands,
mut chat: ResMut<SpectatorChat>,
mut card: ResMut<SpectatorCard>,
mut online: ResMut<Online>,
ui: Query<Entity, With<SpectatorCardUi>>,
) {
chat.0 = None;
card.0 = false;
for entity in &ui {
commands.entity(entity).despawn();
}
if let Some(session) = &mut online.0 {
session.pending_call = None;
session.forget_spectator_votes();
session.crowd = Crowd::default();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::net::OnlineSession;
use crate::sim::{DEFAULT_DELAY, Lockstep};
use crate::transport::{MatchTerms, UdpTransport};
use bevy::ecs::system::RunSystemOnce;
use bevy::input::ButtonState;
use bevy::input::keyboard::{Key, KeyboardInput, NativeKey};
fn beach(local: Option<u8>) -> App {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<crate::app::Screen>();
app.init_state::<crate::app::VersusPhase>();
app.insert_resource(State::new(crate::app::Screen::Versus));
app.insert_resource(State::new(crate::app::VersusPhase::Running));
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<crate::app::keycaps::KeyCaps>();
app.init_resource::<Time>();
app.add_message::<KeyboardInput>();
let mut settings = crate::app::settings::GameSettings::default();
settings.set_name(0, "Wanda");
app.insert_resource(settings);
app.init_resource::<SpectatorChat>();
app.init_resource::<SpectatorCard>();
app.insert_resource(Online(Some(session(local))));
app.add_systems(
Update,
(
spectator_chat_input,
spectator_vote_input,
settle_spectator_vote,
)
.chain(),
);
app
}
fn tap(app: &mut App, key: KeyCode) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(key);
app.world_mut().write_message(KeyboardInput {
key_code: key,
logical_key: Key::Unidentified(NativeKey::Unidentified),
state: ButtonState::Pressed,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
});
app.update();
}
fn type_char(app: &mut App, ch: &str) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::KeyB,
logical_key: Key::Character(ch.into()),
state: ButtonState::Pressed,
text: Some(ch.into()),
repeat: false,
window: Entity::PLACEHOLDER,
});
app.update();
}
fn saying(app: &App) -> Option<String> {
app.world().resource::<SpectatorChat>().0.clone()
}
fn card_open(app: &App) -> bool {
app.world().resource::<SpectatorCard>().0
}
fn heard(app: &App) -> Vec<(String, String)> {
app.world()
.resource::<Online>()
.0
.as_ref()
.expect("a session")
.heard
.clone()
}
fn session(local: Option<u8>) -> OnlineSession {
let step = match local {
Some(seat) => Lockstep::new(seat, vec![0, 1], DEFAULT_DELAY),
None => Lockstep::observer(vec![0, 1], DEFAULT_DELAY),
};
OnlineSession::new(
UdpTransport::host(0).expect("socket"),
step,
2,
MatchTerms::default(),
)
}
#[test]
fn spectators_settle_on_one_event_and_then_wait() {
let mut votes = SpectatorVotes::default();
assert!(votes.open().is_none(), "nothing open to begin with");
assert_eq!(votes.settle(1.0), None, "and nothing to settle");
let fresh = TideEvent::FreshSand.index() as u8;
let swap = TideEvent::CastleSwap.index() as u8;
votes.cast(fresh);
assert!(votes.open().is_some(), "the first pick opened it");
votes.cast(swap);
votes.cast(swap);
assert_eq!(votes.settle(1.0), None, "still open");
assert_eq!(votes.settle(WINDOW), Some(TideEvent::CastleSwap));
assert!(votes.waiting().is_some());
votes.cast(fresh);
assert!(
votes.open().is_none(),
"a vote during the wait opens nothing"
);
assert_eq!(votes.settle(COOLDOWN), None);
assert!(votes.waiting().is_none(), "the wait is over");
votes.cast(fresh);
assert!(votes.open().is_some(), "and they may call again");
}
#[test]
fn a_tied_vote_breaks_the_same_way_every_time() {
let pick = |order: [TideEvent; 2]| {
let mut votes = SpectatorVotes::default();
for event in order {
votes.cast(event.index() as u8);
}
votes.settle(WINDOW)
};
let early = SPECTATOR_EVENTS[1];
let late = SPECTATOR_EVENTS[5];
assert_eq!(pick([early, late]), Some(early));
assert_eq!(pick([late, early]), Some(early), "whichever arrived first");
}
#[test]
fn a_vote_still_open_at_the_wave_is_dropped_rather_than_held() {
let mut votes = SpectatorVotes::default();
votes.cast(TideEvent::FreshSand.index() as u8);
assert!(votes.open().is_some());
votes.forget_open();
assert!(votes.open().is_none(), "nothing left to settle");
assert_eq!(votes.settle(WINDOW), None, "and nothing settles");
assert!(
votes.waiting().is_none(),
"a vote that never happened costs no wait"
);
let mut votes = SpectatorVotes::default();
votes.cast(TideEvent::FreshSand.index() as u8);
assert!(votes.settle(WINDOW).is_some());
votes.forget_open();
assert!(votes.waiting().is_some(), "still waiting");
}
#[test]
fn spectators_cannot_call_an_event_that_favours_a_seat() {
for event in [TideEvent::Monopoly, TideEvent::GullAttack] {
assert!(!SPECTATOR_EVENTS.contains(&event), "{event:?}");
let mut votes = SpectatorVotes::default();
votes.cast(event.index() as u8);
assert!(votes.open().is_none(), "{event:?} is not on the list");
}
assert_eq!(
SPECTATOR_EVENTS.len() + 2,
TideEvent::ALL.len(),
"and the rest are"
);
}
#[test]
fn only_a_seatless_peer_is_a_spectator() {
assert!(
!is_spectating(&Online::default()),
"nobody is online at all"
);
assert!(
!is_spectating(&Online(Some(session(Some(0))))),
"a player holds a seat"
);
assert!(
is_spectating(&Online(Some(session(None)))),
"a watcher holds none"
);
}
#[test]
fn t_opens_a_line_and_enter_says_it() {
let mut app = beach(None);
assert_eq!(saying(&app), None, "no line to begin with");
tap(&mut app, KeyCode::KeyT);
assert_eq!(saying(&app), Some(String::new()), "T opened one");
type_char(&mut app, "h");
type_char(&mut app, "i");
assert_eq!(saying(&app), Some("hi".to_string()));
tap(&mut app, KeyCode::Enter);
assert_eq!(saying(&app), None, "saying it closes the line");
assert_eq!(
heard(&app),
vec![("Wanda".to_string(), "hi".to_string())],
"and the sender sees it too"
);
}
#[test]
fn escape_drops_a_half_typed_line_without_saying_it() {
let mut app = beach(None);
tap(&mut app, KeyCode::KeyT);
type_char(&mut app, "o");
type_char(&mut app, "i");
assert_eq!(saying(&app), Some("oi".to_string()));
tap(&mut app, KeyCode::Escape);
assert_eq!(saying(&app), None, "the line is gone");
assert!(heard(&app).is_empty(), "and nobody heard it");
}
#[test]
fn a_seated_player_keeps_its_letters() {
let mut app = beach(Some(0));
tap(&mut app, KeyCode::KeyT);
assert_eq!(saying(&app), None, "no line opened");
type_char(&mut app, "x");
assert_eq!(saying(&app), None, "and nowhere for a letter to go");
assert!(heard(&app).is_empty());
}
#[test]
fn an_empty_line_and_a_nameless_one_both_say_nothing() {
let mut app = beach(None);
tap(&mut app, KeyCode::KeyT);
tap(&mut app, KeyCode::Enter);
assert_eq!(saying(&app), None, "the line closed");
assert!(heard(&app).is_empty(), "with nothing said");
app.world_mut()
.resource_mut::<crate::app::settings::GameSettings>()
.set_name(0, "");
tap(&mut app, KeyCode::KeyT);
type_char(&mut app, "?");
tap(&mut app, KeyCode::Enter);
assert_eq!(saying(&app), None, "the line closed either way");
assert!(heard(&app).is_empty(), "and nobody speaks as the beach");
}
#[test]
fn losing_the_session_takes_a_half_typed_line_with_it() {
let mut app = beach(None);
tap(&mut app, KeyCode::KeyT);
type_char(&mut app, "w");
assert_eq!(saying(&app), Some("w".to_string()));
app.world_mut().resource_mut::<Online>().0 = None;
app.update();
assert_eq!(saying(&app), None, "gone with the session");
}
#[test]
fn e_opens_the_list_and_a_number_votes() {
let mut app = beach(None);
assert!(!card_open(&app), "shut to begin with");
tap(&mut app, KeyCode::KeyE);
assert!(card_open(&app), "E opened it");
tap(&mut app, KeyCode::Digit3);
assert!(!card_open(&app), "a pick shuts it behind itself");
}
#[test]
fn escape_shuts_the_list_without_voting() {
let mut app = beach(None);
tap(&mut app, KeyCode::KeyE);
assert!(card_open(&app));
tap(&mut app, KeyCode::Escape);
assert!(!card_open(&app), "shut again");
}
#[test]
fn a_seated_player_cannot_open_the_list() {
let mut app = beach(Some(0));
tap(&mut app, KeyCode::KeyE);
assert!(!card_open(&app), "no list for a player");
}
#[test]
fn a_line_being_typed_shuts_the_list() {
let mut app = beach(None);
tap(&mut app, KeyCode::KeyE);
assert!(card_open(&app));
tap(&mut app, KeyCode::KeyT);
assert_eq!(saying(&app), Some(String::new()), "the line opened");
assert!(!card_open(&app), "and the list stood down");
type_char(&mut app, "5");
assert_eq!(saying(&app), Some("5".to_string()), "the digit was typed");
}
#[test]
fn the_list_is_not_offered_once_the_tide_is_in() {
let mut app = beach(None);
tap(&mut app, KeyCode::KeyE);
assert!(card_open(&app));
app.insert_resource(State::new(crate::app::VersusPhase::Over));
app.update();
assert!(!card_open(&app), "the list went with the round");
tap(&mut app, KeyCode::KeyE);
assert!(!card_open(&app), "and does not come back");
}
#[test]
fn only_the_host_settles_a_vote() {
for (seat, expected) in [(1, None), (0, Some(TideEvent::FreshSand))] {
let mut app = beach(Some(seat));
{
let mut online = app.world_mut().resource_mut::<Online>();
let session = online.0.as_mut().expect("a session");
session.spectators.cast(TideEvent::FreshSand.index() as u8);
}
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_secs_f32(WINDOW + 0.1));
app.update();
let call = app
.world()
.resource::<Online>()
.0
.as_ref()
.expect("a session")
.pending_call;
assert_eq!(call, expected, "seat {seat}");
}
}
#[test]
fn the_crowds_numbers_are_told_apart_by_name() {
let crowd = Crowd {
watching: 3,
open: 2,
wait: 0,
};
assert_eq!(crowd.watching, 3, "three at the back");
assert_eq!(crowd.open, 2, "two seconds to join them");
assert_eq!(crowd.wait, 0, "and nothing owed");
assert_eq!(
Crowd::default(),
Crowd {
watching: 0,
open: 0,
wait: 0
}
);
}
#[test]
fn the_wait_ends_with_the_round_rather_than_freezing_into_the_next() {
let mut votes = SpectatorVotes::default();
votes.cast(TideEvent::FreshSand.index() as u8);
assert_eq!(votes.settle(WINDOW), Some(TideEvent::FreshSand));
assert!(votes.waiting().is_some(), "a wait was earned");
votes.forget_open();
assert!(votes.waiting().is_some(), "still waiting mid-round");
votes.forget_all();
assert!(votes.waiting().is_none(), "and the round takes it away");
votes.cast(TideEvent::FreshSand.index() as u8);
assert!(votes.open().is_some(), "the next round starts them fresh");
}
#[test]
fn leaving_the_round_puts_everything_away() {
let mut app = beach(None);
tap(&mut app, KeyCode::KeyE);
assert!(card_open(&app), "a list to leave open");
assert!(
app.world_mut()
.query::<&SpectatorCardUi>()
.iter(app.world())
.next()
.is_some(),
"and something on screen to leave behind"
);
{
let mut chat = app.world_mut().resource_mut::<SpectatorChat>();
chat.0 = Some("half a thought".into());
}
{
let mut online = app.world_mut().resource_mut::<Online>();
let session = online.0.as_mut().expect("a session");
session.pending_call = Some(TideEvent::CastleSwap);
session.spectators.cast(TideEvent::FreshSand.index() as u8);
}
let _ = app.world_mut().run_system_once(forget_spectating);
assert!(!card_open(&app), "the list is shut");
assert_eq!(saying(&app), None, "the line is dropped");
assert_eq!(
app.world_mut()
.query::<&SpectatorCardUi>()
.iter(app.world())
.count(),
0,
"and nothing of it is left on screen"
);
let online = app.world().resource::<Online>();
let session = online.0.as_ref().expect("a session");
assert_eq!(session.pending_call, None, "no call waiting on a frame");
assert!(session.spectators.open().is_none(), "no vote still open");
}
#[test]
fn the_crowd_is_told_its_size_again_without_being_asked() {
let mut app = beach(Some(0));
let port = {
let online = app.world().resource::<Online>();
let session = online.0.as_ref().expect("a session");
session.transport.local_addr().expect("addr").port()
};
let mut ear = UdpTransport::join(("127.0.0.1", port)).expect("join");
ear.send(NetMsg::watch("Dee"));
let mut tallies = 0;
for _ in 0..3 {
std::thread::sleep(std::time::Duration::from_millis(30));
{
let mut online = app.world_mut().resource_mut::<Online>();
let session = online.0.as_mut().expect("a session");
let _ = session.transport.recv_all();
}
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_secs_f32(1.1));
app.update();
std::thread::sleep(std::time::Duration::from_millis(30));
tallies += ear
.recv_all()
.into_iter()
.filter(|(msg, _)| matches!(msg, NetMsg::SpectatorTally { .. }))
.count();
}
assert!(
tallies >= 2,
"the crowd hears where it stands more than once, got {tallies}"
);
}
}