use crate::app::net::Online;
use crate::sim::TideEvent;
use crate::transport::NetMsg;
use bevy::prelude::*;
mod chat;
mod picks;
mod vote;
pub use chat::*;
pub use picks::*;
pub use vote::*;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct Crowd {
pub watching: u8,
pub open: u8,
pub wait: u8,
}
#[derive(Default)]
pub struct Stands {
pub votes: SpectatorVotes,
pub crowd: Crowd,
pub crowd_said: f32,
pub pending_call: Option<TideEvent>,
pub picks: Picks,
pub picks_said: bool,
pub my_pick: Option<u8>,
pub calls: Calls,
pub last_call: Option<(u8, bool)>,
}
impl Stands {
pub fn new_round(&mut self) {
self.picks = Picks::default();
self.picks_said = false;
self.my_pick = None;
self.last_call = None;
}
pub fn leave_round(&mut self) {
self.pending_call = None;
self.votes.forget_all();
self.crowd = Crowd::default();
}
}
pub trait CardFlag: Resource + Component<Mutability = bevy::ecs::component::Mutable> {
fn is_up(&self) -> bool;
fn set_up(&mut self, up: bool);
}
impl CardFlag for PickCard {
fn is_up(&self) -> bool {
self.0
}
fn set_up(&mut self, up: bool) {
self.0 = up;
}
}
impl CardFlag for SpectatorCard {
fn is_up(&self) -> bool {
self.0
}
fn set_up(&mut self, up: bool) {
self.0 = up;
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct ListCard<'w, 's, Ui: Component, Flag: CardFlag> {
commands: Commands<'w, 's>,
flag: ResMut<'w, Flag>,
ui: Query<'w, 's, Entity, With<Ui>>,
}
impl<'w, 's, Ui: Component, Flag: CardFlag> ListCard<'w, 's, Ui, Flag> {
pub fn is_up(&self) -> bool {
self.flag.is_up()
}
pub fn raise(&mut self) -> &mut Commands<'w, 's> {
self.flag.set_up(true);
&mut self.commands
}
pub fn shut(&mut self) {
self.flag.set_up(false);
for entity in &self.ui {
self.commands.entity(entity).despawn();
}
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Watching<'w, Other: CardFlag> {
chat: Res<'w, SpectatorChat>,
phase: Res<'w, State<crate::app::VersusPhase>>,
other: Res<'w, Other>,
}
impl<Other: CardFlag> Watching<'_, Other> {
pub fn free(&self) -> bool {
!self.chat.open() && *self.phase.get() == crate::app::VersusPhase::Running
}
pub fn other_up(&self) -> bool {
self.other.is_up()
}
}
pub fn is_spectating(online: &Online) -> bool {
online
.0
.as_ref()
.is_some_and(|session| session.session.watching())
}
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.stands.votes.forget_open();
return;
}
let crowd = Crowd {
watching: session.watchers_in_round().min(u8::MAX as usize) as u8,
open: session
.stands
.votes
.open()
.map_or(0, |left| left.ceil() as u8),
wait: session
.stands
.votes
.waiting()
.map_or(0, |left| left.ceil() as u8),
};
let again = crate::app::lobby::once_a_second(&mut session.stands.crowd_said, time.delta_secs());
if crowd != session.stands.crowd || again {
session.stands.crowd = crowd;
session.transport.send(NetMsg::SpectatorTally {
watching: crowd.watching,
open: crowd.open,
wait: crowd.wait,
});
}
say_the_calls(session, settings.tr(), again);
let Some(event) = session.stands.votes.settle(time.delta_secs()) else {
return;
};
session.stands.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));
}
pub(super) fn spawn_list_card(
commands: &mut Commands,
marker: impl Component,
title: &str,
rows: impl IntoIterator<Item = (String, Color)>,
) {
use crate::app::{menu_ui, palette};
let rows: Vec<(String, Color)> = rows.into_iter().collect();
commands
.spawn((
marker,
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(title.to_string()),
menu_ui::display_font(menu_ui::type_scale::HEADING),
TextColor(palette::GOLD),
));
for (line, color) in rows {
card.spawn((
Text::new(line),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::ROW),
..default()
},
TextColor(color),
));
}
});
});
}
type EitherCard = Or<(With<SpectatorCardUi>, With<PickCardUi>)>;
pub fn forget_spectating(
mut commands: Commands,
mut chat: ResMut<SpectatorChat>,
(mut card, mut pick): (ResMut<SpectatorCard>, ResMut<PickCard>),
mut online: ResMut<Online>,
ui: Query<Entity, EitherCard>,
) {
chat.0 = None;
card.0 = false;
pick.0 = false;
for entity in &ui {
commands.entity(entity).despawn();
}
if let Some(session) = &mut online.0 {
session.stands.leave_round();
}
}
#[cfg(test)]
mod tests {
use super::vote::{COOLDOWN, WINDOW};
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.init_resource::<PickCard>();
app.insert_resource(Online(Some(session(local))));
app.add_systems(
Update,
(
spectator_chat_input,
spectator_vote_input,
spectator_pick_input,
settle_spectator_vote,
)
.chain(),
);
app
}
fn calls_open(app: &mut App, open: u8) {
if let Some(session) = &mut app.world_mut().resource_mut::<Online>().0 {
session.stands.picks.open = open;
}
}
fn my_pick(app: &App) -> Option<u8> {
app.world()
.resource::<Online>()
.0
.as_ref()
.and_then(|s| s.stands.my_pick)
}
#[test]
fn p_opens_the_seats_and_a_number_calls_one() {
let mut app = beach(None);
calls_open(&mut app, 20);
tap(&mut app, KeyCode::KeyP);
assert!(app.world().resource::<PickCard>().0, "P opened it");
tap(&mut app, KeyCode::Digit2);
assert!(!app.world().resource::<PickCard>().0, "a call shuts it");
assert_eq!(my_pick(&app), Some(1));
tap(&mut app, KeyCode::KeyP);
tap(&mut app, KeyCode::Digit1);
assert_eq!(my_pick(&app), Some(0), "a change of mind");
tap(&mut app, KeyCode::KeyP);
tap(&mut app, KeyCode::Digit5);
assert_eq!(my_pick(&app), Some(0));
}
#[test]
fn no_calls_once_they_close_or_from_a_player() {
let mut app = beach(None);
calls_open(&mut app, 0);
tap(&mut app, KeyCode::KeyP);
assert!(!app.world().resource::<PickCard>().0);
let mut app = beach(Some(0));
calls_open(&mut app, 20);
tap(&mut app, KeyCode::KeyP);
assert!(!app.world().resource::<PickCard>().0, "a seated player");
}
#[test]
fn the_crowds_calls_read_most_called_first() {
let picks = Picks {
counts: [1, 0, 2, 1, 0, 0],
open: 0,
};
assert_eq!(
picks.line(|seat| format!("P{}", seat + 1)),
"P3 ×2 · P1 ×1 · P4 ×1"
);
assert!(!Picks::default().any());
}
#[test]
fn a_call_is_scored_against_the_winner() {
for (pick, right) in [(1, true), (0, false)] {
let mut app = App::new();
let mut board = crate::sim::classic_arena(false, 2);
board.set_score(0, 3);
board.set_score(1, 9);
app.insert_resource(crate::app::Sim(board));
app.insert_resource(crate::app::settings::GameSettings::default());
let mut watcher = session(None);
watcher.stands.my_pick = Some(pick);
app.insert_resource(Online(Some(watcher)));
app.add_systems(Update, score_the_call);
app.update();
let session = app
.world()
.resource::<Online>()
.0
.as_ref()
.expect("session");
assert_eq!(session.stands.last_call, Some((pick, right)));
assert_eq!(
(session.stands.calls.right, session.stands.calls.made),
(u32::from(right), 1)
);
}
}
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 the_key_that_closes_a_line_is_not_read_again() {
for closer in [KeyCode::Enter, KeyCode::Escape] {
let mut app = beach(None);
tap(&mut app, KeyCode::KeyT);
type_char(&mut app, "g");
tap(&mut app, closer);
assert_eq!(saying(&app), None);
let keys = app.world().resource::<ButtonInput<KeyCode>>();
assert!(!keys.just_pressed(closer), "{closer:?} was spent");
}
}
#[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
.stands
.votes
.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")
.stands
.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.stands.pending_call = Some(TideEvent::CastleSwap);
session
.stands
.votes
.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.stands.pending_call, None,
"no call waiting on a frame"
);
assert!(session.stands.votes.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}"
);
}
}