use crate::app::editor::EditorState;
use crate::app::i18n::{Tr, fill};
use crate::app::lobby::LobbyState;
use crate::app::net::Online;
use crate::app::settings::GameSettings;
use crate::app::teams::TeamMode;
use crate::app::{Bots, Campaign, CampaignKind, Phase, Playback, Screen, Seats, Sim, VersusPhase};
use crate::sim::{Goal, LURE_TICKS, TICKS_PER_SECOND, TideEvent};
use bevy::prelude::*;
use crate::app::palette::{CLOCK_CALM, CLOCK_RED, CLOCK_RED_BRIGHT};
pub(crate) fn clock_into(out: &mut String, ticks: u64) {
use std::fmt::Write;
let secs = ticks / u64::from(TICKS_PER_SECOND);
out.clear();
let _ = write!(out, "{}:{:02}", secs / 60, secs % 60);
}
#[cfg(test)]
pub(crate) fn clock_text(ticks: u64) -> String {
let mut out = String::new();
clock_into(&mut out, ticks);
out
}
pub(crate) fn urgency_band(round: Option<u32>, band: u32) -> u64 {
match round {
Some(round) if round <= band => u64::from(round) / 3,
_ => u64::from(band),
}
}
pub(crate) fn clock_color(ticks: u64, round: Option<u32>, elapsed: f32, blink: bool) -> Color {
const BLINK_TICKS: u64 = 10 * TICKS_PER_SECOND as u64;
let red = urgency_band(round, crate::sim::SURGE_TICKS);
if ticks <= BLINK_TICKS.min(red / 3) {
if !blink || ((elapsed * 2.0) as u32).is_multiple_of(2) {
CLOCK_RED
} else {
CLOCK_RED_BRIGHT
}
} else if ticks <= red {
CLOCK_RED
} else {
CLOCK_CALM
}
}
pub(super) struct HudText {
pub title: String,
pub status: String,
pub prompt: String,
}
impl HudText {
fn new(
title: impl Into<String>,
status: impl Into<String>,
prompt: impl Into<String>,
) -> HudText {
HudText {
title: title.into(),
status: status.into(),
prompt: prompt.into(),
}
}
}
pub(super) fn lobby_text(tr: &Tr, lobby: &LobbyState) -> HudText {
let title = tr.title_lobby.to_string();
let status = lobby.feedback.clone();
use crate::app::lobby::Standing;
let prompt = match lobby.standing() {
Standing::Choosing { watching: true } => tr.lobby_watch_armed.to_string(),
Standing::Joining(joined) if joined.queued.is_some() => match joined.watching {
true => tr.lobby_queued_watch_prompt.to_string(),
false => tr.lobby_queued_prompt.to_string(),
},
Standing::Joining(joined) if joined.watching => tr.lobby_watching_prompt.to_string(),
Standing::Joining(_) => tr.lobby_aboard_prompt.to_string(),
Standing::Hosting(_) => tr.lobby_broadcasting.to_string(),
Standing::Choosing { watching: false } if lobby.hosts.is_empty() => {
tr.lobby_none_yet.to_string()
}
Standing::Choosing { watching: false } => {
match lobby.hosts.len() {
1 => tr.lobby_join_list_one.to_string(),
n => fill(tr.lobby_join_list, &[("n", &n.to_string())]),
}
}
};
HudText::new(title, status, prompt)
}
pub(super) fn editor_text(tr: &Tr, editor: &EditorState, sim: &Sim) -> HudText {
let testing = editor.is_testing();
let kind = match editor.kind {
crate::sim::LevelKind::Puzzle => tr.ed_kind_puzzle,
crate::sim::LevelKind::Arena => tr.ed_kind_arena,
};
let title = if testing {
tr.title_playtest.to_string()
} else {
format!(
"{} [{kind}] - {}{}",
tr.title_editor,
editor.name,
if editor.is_naming() { "_" } else { "" }
)
};
let counted = match editor.kind {
crate::sim::LevelKind::Puzzle => fill(tr.ed_posts, &[("n", &editor.posts.to_string())]),
crate::sim::LevelKind::Arena => {
fill(tr.ed_seats, &[("n", &sim.0.castle_seats().to_string())])
}
};
let status = fill(&counted, &[("msg", &editor.feedback)]);
let prompt = if testing {
tr.ed_playtest_prompt.to_string()
} else {
tr.ed_prompt.to_string()
};
HudText::new(title, status, prompt)
}
pub(super) fn puzzle_text(
tr: &Tr,
lang: crate::app::i18n::Lang,
campaign: &Campaign,
coop: bool,
sim: &Sim,
phase: &State<Phase>,
custom_keys: bool,
) -> HudText {
let level = campaign.current();
let campaign_title = match campaign.kind {
CampaignKind::TidePool => tr.title_tide_pool,
CampaignKind::BeachDay => tr.title_beach_day,
};
let (place, of, custom) = campaign.place();
let section = match custom {
true => tr.stage_custom,
false => campaign_title,
};
let title = format!("{section} {place}/{of} - {}", lang.level_name(&level.name));
let used = sim.0.signpost_count(0);
let saved = fill(
tr.saved_count,
&[
("a", &sim.0.crabs_banked().to_string()),
(
"b",
&sim.0.crabs_spawned().max(level.crab_count()).to_string(),
),
],
);
let goal = match level.goal {
Goal::AllCrabs => saved,
Goal::Bank(n) => fill(
tr.goal_bank,
&[
("a", &sim.0.crabs_banked().to_string()),
("b", &n.to_string()),
("t", ""),
],
),
Goal::Survive => fill(tr.goal_survive, &[("t", "")]),
Goal::Golden => fill(tr.goal_golden, &[("t", "")]),
};
let status = match level.posts {
0 => goal,
posts => format!(
"{} | {goal}",
fill(
tr.signposts_count,
&[("a", &used.to_string()), ("b", &posts.to_string())]
)
),
};
let prompt = match phase.get() {
Phase::Setup if level.posts == 0 => tr.prompt_setup_no_posts.to_string(),
Phase::Setup if used >= level.posts as usize => tr.prompt_setup_full.to_string(),
Phase::Setup if custom_keys => tr.prompt_setup_custom.to_string(),
Phase::Setup if coop => tr.prompt_setup_coop.to_string(),
Phase::Setup => tr.prompt_setup.to_string(),
Phase::Running => tr.prompt_running.to_string(),
Phase::Won if campaign.is_last() => tr.last_level.to_string(),
Phase::Won => tr.prompt_won.to_string(),
Phase::Lost => tr.prompt_lost.to_string(),
};
HudText::new(title, status, prompt)
}
fn enter_door(tr: &Tr, online: &Online, series_on: bool) -> &'static str {
use crate::app::play_input::AfterRound;
match crate::app::play_input::after_round(online, series_on) {
AfterRound::NextRound => tr.tour_next,
AfterRound::Lobby => tr.prompt_enter_lobby,
AfterRound::Menu => tr.prompt_enter_menu,
}
}
pub(super) fn versus_text(r: &Readout) -> HudText {
let Readout {
tr,
sim,
seats,
settings,
names,
playback,
online,
bots,
vphase,
tournament,
speed,
..
} = *r;
let scores = sim.0.scores();
let team_mode = crate::app::teams::in_play(settings, online, seats.0);
let mut mode = if playback.0.is_some() {
tr.title_replay.to_string()
} else if let Some(session) = &online.0 {
match session.session.seat() {
Some(seat) => fill(tr.title_online, &[("p", &names.label(tr, seat))]),
None => tr.title_watching.to_string(),
}
} else if bots.0.iter().any(Option::is_some) {
let count = bots.0.iter().filter(|b| b.is_some()).count();
fill(
tr.title_vs_ai,
&[("n", &count.to_string()), ("p", &names.label(tr, 0))],
)
} else {
tr.title_turf_war.to_string()
};
if team_mode != TeamMode::Solo {
let totals = crate::app::teams::team_scores(scores, seats.0, team_mode)
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(" vs ");
mode = fill(tr.team_banner, &[("mode", &mode), ("s", &totals)]);
}
let mut status = if playback.0.is_some() && speed > 1 {
fill(tr.replay_speed, &[("n", &speed.to_string())])
} else if sim.0.in_surge() && !sim.0.round_over() {
tr.the_gulls.to_string()
} else {
String::new()
};
if let Some((event, at)) = sim.0.last_event()
&& sim.0.ticks().saturating_sub(at) < u64::from(3 * TICKS_PER_SECOND)
{
status = fill(tr.tide_event, &[("e", event_name(tr, event))]);
}
if let Some((owner, remaining)) = sim.0.lure() {
let seat = names.label(tr, owner);
status = if remaining > LURE_TICKS - 2 * TICKS_PER_SECOND {
fill(tr.lure_started, &[("p", &seat)])
} else {
fill(
tr.lure_banner,
&[
("s", &remaining.div_ceil(TICKS_PER_SECOND).to_string()),
("p", &seat),
],
)
};
}
if let Some(session) = &online.0 {
if let Some(frame) = session.desync_at() {
status = fill(tr.desync, &[("f", &frame.to_string())]);
} else if !session.transport.connected() {
status = tr.waiting_peer.to_string();
} else if let Some(seat) = session.waiting_on() {
status = fill(tr.waiting_for, &[("p", &names.label(tr, seat))]);
}
}
if status.is_empty() && r.crowd.watching > 0 {
status = fill(tr.spectator_count, &[("n", &r.crowd.watching.to_string())]);
}
let prompt = match vphase.get() {
VersusPhase::Running if playback.0.is_some() => tr.prompt_replay_transport.to_string(),
VersusPhase::Running if online.0.as_ref().is_some_and(|s| s.session.watching()) => {
match r.spectator_typing {
Some(line) => format!("> {line}_"),
None => {
let call = match (r.crowd.open, r.crowd.wait) {
(0, 0) => tr.spectator_call_hint.to_string(),
(0, wait) => fill(tr.spectator_wait, &[("n", &wait.to_string())]),
(open, _) => fill(tr.spectator_vote_open, &[("n", &open.to_string())]),
};
let session = online.0.as_ref();
let open = session.map_or(0, |s| s.stands.picks.open);
let call = match (open, session.and_then(|s| s.stands.my_pick)) {
(0, _) => call,
(n, None) => fill(tr.spectator_pick_hint, &[("n", &n.to_string())]),
(n, Some(seat)) => fill(
tr.spectator_picked,
&[("p", &names.label(tr, seat)), ("n", &n.to_string())],
),
};
format!("{call} | {} | {}", tr.lobby_chat_hint, tr.prompt_esc_menu)
}
}
}
VersusPhase::Over if online.0.as_ref().is_some_and(|s| s.session.watching()) => {
match r.spectator_typing {
Some(line) => format!("> {line}_"),
None => format!(
"{} | {}",
tr.lobby_chat_hint,
enter_door(tr, online, tournament.is_running())
),
}
}
VersusPhase::Running if online.0.is_some() || bots.0.iter().any(Option::is_some) => {
tr.prompt_versus_short.to_string()
}
VersusPhase::Running if settings.custom_binds() => tr.prompt_versus_custom.to_string(),
VersusPhase::Running => tr.prompt_versus_local.to_string(),
VersusPhase::Over => enter_door(tr, online, tournament.is_running()).to_string(),
};
HudText::new(mode, status, prompt)
}
pub(crate) fn event_name(tr: &Tr, event: TideEvent) -> &'static str {
tr.events[event.index()]
}
pub(super) struct Readout<'a> {
pub tr: &'static Tr,
pub lang: crate::app::i18n::Lang,
pub sim: &'a Sim,
pub campaign: &'a Campaign,
pub coop: bool,
pub phase: &'a State<Phase>,
pub vphase: &'a State<VersusPhase>,
pub editor: &'a EditorState,
pub online: &'a Online,
pub playback: &'a Playback,
pub lobby: &'a LobbyState,
pub tournament: &'a crate::app::tournament::Tournament,
pub seats: &'a Seats,
pub settings: &'a GameSettings,
pub keycaps: &'a crate::app::keycaps::KeyCaps,
pub names: &'a crate::app::SeatNames,
pub bots: &'a Bots,
pub library: &'a crate::app::replays::Library,
pub notice: &'a crate::app::RoundNotice,
pub match_menu: &'a crate::app::match_setup::MatchMenu,
pub paused: bool,
pub spectator_typing: Option<&'a str>,
pub crowd: crate::app::spectators::Crowd,
pub speed: u8,
}
pub(super) fn screen_text(screen: Screen, r: &Readout) -> HudText {
let mut said = screen_text_for(screen, r);
if matches!(screen, Screen::Versus | Screen::Puzzle) {
if r.paused {
said.prompt = r.tr.prompt_paused.to_string();
}
said.prompt = format!("{} | {}", said.prompt, r.tr.prompt_mute);
}
said.prompt = r.keycaps.legend(&said.prompt);
said
}
pub(super) fn screen_text_for(screen: Screen, r: &Readout) -> HudText {
match screen {
Screen::Menu => HudText::new(
String::new(),
r.notice.0.clone(),
r.tr.menu_prompt.to_string(),
),
Screen::Settings => HudText::new(
r.tr.title_settings.to_string(),
String::new(),
r.tr.prompt_settings.to_string(),
),
Screen::Controls => HudText::new(
r.tr.title_controls.to_string(),
String::new(),
r.tr.prompt_controls.to_string(),
),
Screen::MatchSetup => HudText::new(
r.tr.title_match_setup.to_string(),
String::new(),
if matches!(
crate::app::match_setup::Row::ALL[r.match_menu.selected],
crate::app::match_setup::Row::Name(_)
) {
r.tr.prompt_match_name.to_string()
} else {
r.tr.prompt_match_setup.to_string()
},
),
Screen::Achievements => HudText::new(
r.tr.title_achievements.to_string(),
String::new(),
r.tr.prompt_achievements.to_string(),
),
Screen::Replays => HudText::new(
r.tr.title_replays.to_string(),
r.library.feedback.clone(),
r.tr.prompt_replays.to_string(),
),
Screen::StageSelect => HudText::new(
format!(
"{} - {}",
match r.campaign.kind {
CampaignKind::TidePool => r.tr.title_tide_pool,
CampaignKind::BeachDay => r.tr.title_beach_day,
},
r.tr.title_stages
),
match r.coop {
true => r.tr.stage_coop_on.to_string(),
false => String::new(),
},
match r.campaign.kind {
CampaignKind::TidePool => r.tr.prompt_stages_tide.to_string(),
CampaignKind::BeachDay => r.tr.prompt_stages.to_string(),
},
),
Screen::Interlude => HudText::new(
r.tr.title_turf_war.to_string(),
String::new(),
String::new(),
),
Screen::Lobby => lobby_text(r.tr, r.lobby),
Screen::Editor => editor_text(r.tr, r.editor, r.sim),
Screen::Puzzle => puzzle_text(
r.tr,
r.lang,
r.campaign,
r.coop,
r.sim,
r.phase,
match r.coop {
true => r.settings.custom_binds(),
false => !r.settings.stock_legend(),
},
),
Screen::Language => HudText::new(
r.tr.title_pick_language.to_string(),
String::new(),
r.tr.prompt_pick_language.to_string(),
),
Screen::NewVersion => HudText::new(
r.tr.title_new_version.to_string(),
String::new(),
r.tr.prompt_new_version.to_string(),
),
Screen::Versus => versus_text(r),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::i18n::{EN, Lang};
use crate::sim::{Board, Level, campaign_levels};
#[test]
fn every_screen_names_itself_and_its_keys() {
use crate::app::{Bots, Campaign, CampaignKind, Playback, Seats};
use crate::sim::{Board, campaign_levels};
let levels = campaign_levels();
let builtins = levels.len();
let campaign = Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
};
let settings = GameSettings::default();
let keycaps = crate::app::keycaps::KeyCaps::default();
let readout = Readout {
tr: &EN,
lang: Lang::En,
sim: &Sim(Board::new(9, 7, 1)),
campaign: &campaign,
coop: false,
phase: &State::new(Phase::Setup),
vphase: &State::new(VersusPhase::Running),
editor: &EditorState::default(),
online: &Online::default(),
playback: &Playback::default(),
lobby: &LobbyState::default(),
tournament: &crate::app::tournament::Tournament::default(),
seats: &Seats(2),
settings: &settings,
keycaps: &keycaps,
names: &crate::app::SeatNames::default(),
bots: &Bots::default(),
library: &crate::app::replays::Library::default(),
notice: &crate::app::RoundNotice::default(),
match_menu: &crate::app::match_setup::MatchMenu::default(),
paused: false,
spectator_typing: None,
crowd: Default::default(),
speed: 1,
};
for screen in Screen::ALL {
let said = screen_text(screen, &readout);
let (title, prompt) = (&said.title, &said.prompt);
if screen != Screen::Menu {
assert!(!title.is_empty(), "{screen:?} has no header");
}
if screen != Screen::Interlude {
assert!(!prompt.is_empty(), "{screen:?} offers no keys");
}
}
}
#[test]
fn no_prompt_names_the_mute_key_twice() {
use crate::app::{Bots, Campaign, CampaignKind, Playback, Seats};
use crate::sim::{Replay, campaign_levels};
let levels = campaign_levels();
let builtins = levels.len();
let watching = Playback(Some((
Replay::new(levels.first().expect("a campaign level").clone()),
0,
)));
for lang in crate::app::i18n::ALL_LANGS {
let settings = GameSettings {
language: lang,
..GameSettings::default()
};
let tr = settings.tr();
let readout = Readout {
tr,
lang,
sim: &Sim(Board::new(9, 7, 1)),
campaign: &Campaign {
kind: CampaignKind::TidePool,
levels: campaign_levels(),
index: 0,
builtins,
},
coop: false,
phase: &State::new(Phase::Running),
vphase: &State::new(VersusPhase::Running),
editor: &EditorState::default(),
online: &Online::default(),
playback: &watching,
lobby: &LobbyState::default(),
tournament: &crate::app::tournament::Tournament::default(),
seats: &Seats::default(),
settings: &settings,
keycaps: &crate::app::keycaps::KeyCaps::default(),
names: &crate::app::SeatNames::default(),
bots: &Bots::default(),
library: &crate::app::replays::Library::default(),
notice: &crate::app::RoundNotice::default(),
match_menu: &crate::app::match_setup::MatchMenu::default(),
paused: false,
spectator_typing: None,
crowd: Default::default(),
speed: 1,
};
let prompt = screen_text(Screen::Versus, &readout).prompt;
let mute = tr.prompt_mute;
assert_eq!(
prompt.matches(mute).count(),
1,
"{lang:?} names the mute key more than once: {prompt}"
);
}
}
#[test]
fn the_editor_header_follows_the_playtest_toggle() {
let mut editor = EditorState::default();
editor.posts = 3;
editor.name = "Gull Alley".into();
editor.feedback = "solvable: 2".into();
let mut board = Board::new(9, 7, 1);
board.set_tile(0, 0, crate::sim::TileKind::Castle(0));
board.set_tile(8, 6, crate::sim::TileKind::Castle(1));
let sim = Sim(board);
let HudText {
title,
status,
prompt,
} = editor_text(&EN, &editor, &sim);
assert!(title.starts_with(EN.title_editor), "{title}");
assert!(title.contains("Gull Alley"), "the name is on the header");
assert!(title.contains(EN.ed_kind_puzzle), "{title}");
assert!(status.contains('3') && status.contains("solvable: 2"));
assert_eq!(prompt, EN.ed_prompt);
editor.kind = crate::sim::LevelKind::Arena;
let beach = editor_text(&EN, &editor, &sim);
assert!(beach.title.contains(EN.ed_kind_arena), "{}", beach.title);
assert_eq!(beach.status, "seats: 2 | solvable: 2", "{}", beach.status);
editor.kind = crate::sim::LevelKind::Puzzle;
editor.mode = crate::app::editor::Mode::Naming;
let typing = editor_text(&EN, &editor, &sim).title;
assert!(typing.ends_with('_'), "{typing}");
editor.mode = crate::app::editor::Mode::Painting;
editor.mode = crate::app::editor::Mode::Testing(Box::new(Board::new(4, 4, 0)));
let HudText { title, prompt, .. } = editor_text(&EN, &editor, &sim);
assert_eq!(title, EN.title_playtest);
assert_eq!(prompt, EN.ed_playtest_prompt);
}
#[test]
fn the_lobby_prompt_tracks_the_connection_state() {
let mut lobby = LobbyState::default();
assert_eq!(lobby_text(&EN, &lobby).prompt, EN.lobby_none_yet);
for at in 1..=3u8 {
lobby.hosts.push(crate::app::lobby::HostEntry {
addr: format!("10.0.0.{at}:47777").parse().expect("addr"),
id: u64::from(at),
name: String::new(),
host: String::new(),
taken: 1,
seats: 6,
running: false,
age: 0.0,
});
}
let prompt = lobby_text(&EN, &lobby).prompt;
assert!(prompt.contains('3'), "how many are out there: {prompt}");
lobby.feedback = "hosting on port 47777".into();
assert_eq!(lobby_text(&EN, &lobby).status, "hosting on port 47777");
}
#[test]
fn each_way_of_being_at_a_beach_gets_its_own_prompt() {
use crate::app::lobby::{Joined, Standing};
use crate::transport::UdpTransport;
let joined = |watching, queued| {
let mut lobby = LobbyState::default();
let mut aboard = Joined::returned(UdpTransport::host(0).expect("socket"), watching, 0);
aboard.queued = queued;
lobby.standing = Standing::Joining(aboard);
lobby_text(&EN, &lobby).prompt
};
assert_eq!(joined(false, None), EN.lobby_aboard_prompt);
assert_eq!(joined(true, None), EN.lobby_watching_prompt);
assert_eq!(joined(false, Some(0)), EN.lobby_queued_prompt);
assert_eq!(joined(true, Some(2)), EN.lobby_queued_watch_prompt);
let all = [
EN.lobby_aboard_prompt,
EN.lobby_watching_prompt,
EN.lobby_queued_prompt,
EN.lobby_queued_watch_prompt,
];
for (at, line) in all.iter().enumerate() {
assert!(
!all[at + 1..].contains(line),
"four standings want four lines: {line}"
);
}
}
#[test]
fn a_spectators_prompt_says_what_the_tide_key_will_do() {
use crate::app::spectators::Crowd;
use crate::app::{Bots, Campaign, CampaignKind, Playback, Seats};
let levels = campaign_levels();
let builtins = levels.len();
let campaign = Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
};
let settings = GameSettings::default();
let watching = Online(Some(crate::app::net::OnlineSession::new(
crate::transport::UdpTransport::host(0).expect("socket"),
crate::sim::Lockstep::observer(vec![0, 1], crate::sim::DEFAULT_DELAY),
2,
crate::transport::MatchTerms::default(),
)));
let said = |crowd: Crowd| {
versus_text(&Readout {
tr: &EN,
lang: Lang::En,
sim: &Sim(Board::new(9, 7, 1)),
campaign: &campaign,
coop: false,
phase: &State::new(Phase::Setup),
vphase: &State::new(VersusPhase::Running),
editor: &EditorState::default(),
online: &watching,
playback: &Playback::default(),
lobby: &LobbyState::default(),
tournament: &crate::app::tournament::Tournament::default(),
seats: &Seats(2),
settings: &settings,
keycaps: &crate::app::keycaps::KeyCaps::default(),
names: &crate::app::SeatNames::default(),
bots: &Bots::default(),
library: &crate::app::replays::Library::default(),
notice: &crate::app::RoundNotice::default(),
match_menu: &crate::app::match_setup::MatchMenu::default(),
paused: false,
spectator_typing: None,
crowd,
speed: 1,
})
.prompt
};
let idle = said(Crowd::default());
assert!(
idle.starts_with(EN.spectator_call_hint),
"the offer: {idle}"
);
let open = said(Crowd {
open: 2,
..Default::default()
});
assert!(
open.starts_with(&fill(EN.spectator_vote_open, &[("n", "2")])),
"a vote to join: {open}"
);
let wait = said(Crowd {
wait: 9,
..Default::default()
});
assert!(
wait.starts_with(&fill(EN.spectator_wait, &[("n", "9")])),
"a wait to sit out: {wait}"
);
let both = said(Crowd {
open: 2,
wait: 9,
watching: 3,
});
assert!(
both.starts_with(&fill(EN.spectator_vote_open, &[("n", "2")])),
"the open one: {both}"
);
for state in [idle, open, wait, both] {
assert!(state.contains(EN.lobby_chat_hint), "and T is always there");
}
}
#[test]
fn a_spectator_is_told_the_key_that_actually_lets_it_out() {
use crate::app::{Bots, Campaign, CampaignKind, Playback, Seats};
let levels = campaign_levels();
let builtins = levels.len();
let campaign = Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
};
let settings = GameSettings::default();
let mut session = crate::app::net::OnlineSession::new(
crate::transport::UdpTransport::host(0).expect("socket"),
crate::sim::Lockstep::observer(vec![0, 1], crate::sim::DEFAULT_DELAY),
2,
crate::transport::MatchTerms::default(),
);
session.home.from_lobby = true;
let watching = Online(Some(session));
let said = |phase| {
versus_text(&Readout {
tr: &EN,
lang: Lang::En,
sim: &Sim(Board::new(9, 7, 1)),
campaign: &campaign,
coop: false,
phase: &State::new(Phase::Setup),
vphase: &State::new(phase),
editor: &EditorState::default(),
online: &watching,
playback: &Playback::default(),
lobby: &LobbyState::default(),
tournament: &crate::app::tournament::Tournament::default(),
seats: &Seats(2),
settings: &settings,
keycaps: &crate::app::keycaps::KeyCaps::default(),
names: &crate::app::SeatNames::default(),
bots: &Bots::default(),
library: &crate::app::replays::Library::default(),
notice: &crate::app::RoundNotice::default(),
match_menu: &crate::app::match_setup::MatchMenu::default(),
paused: false,
spectator_typing: None,
crowd: Default::default(),
speed: 1,
})
.prompt
};
let running = said(VersusPhase::Running);
assert!(
running.contains(EN.prompt_esc_menu),
"Escape is the way out of a round you are watching: {running}"
);
assert!(
!running.contains(EN.prompt_enter_menu),
"and Enter is not, so it must not be offered: {running}"
);
let over = said(VersusPhase::Over);
assert!(
over.contains(EN.prompt_enter_lobby),
"and the card names the door the key opens: {over}"
);
assert_eq!(
crate::app::play_input::after_round(&watching, false),
crate::app::play_input::AfterRound::Lobby,
"which is the lobby, as the key itself reads it"
);
}
#[test]
fn the_table_is_told_it_has_an_audience_when_nothing_louder_is_saying_anything() {
use crate::app::{Bots, Campaign, CampaignKind, Playback, Seats};
let levels = campaign_levels();
let builtins = levels.len();
let campaign = Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
};
let settings = GameSettings::default();
let status = |watching: u8, surging: bool| {
let mut board = Board::new(9, 7, 1);
if surging {
board.set_round_length(Some(crate::sim::SURGE_TICKS));
}
versus_text(&Readout {
tr: &EN,
lang: Lang::En,
sim: &Sim(board),
campaign: &campaign,
coop: false,
phase: &State::new(Phase::Setup),
vphase: &State::new(VersusPhase::Running),
editor: &EditorState::default(),
online: &Online::default(),
playback: &Playback::default(),
lobby: &LobbyState::default(),
tournament: &crate::app::tournament::Tournament::default(),
seats: &Seats(2),
settings: &settings,
keycaps: &crate::app::keycaps::KeyCaps::default(),
names: &crate::app::SeatNames::default(),
bots: &Bots::default(),
library: &crate::app::replays::Library::default(),
notice: &crate::app::RoundNotice::default(),
match_menu: &crate::app::match_setup::MatchMenu::default(),
paused: false,
spectator_typing: None,
crowd: crate::app::spectators::Crowd {
watching,
..Default::default()
},
speed: 1,
})
.status
};
assert_eq!(status(0, false), "", "nobody watching, nothing said");
assert_eq!(
status(3, false),
fill(EN.spectator_count, &[("n", "3")]),
"three at the back, and room to say so"
);
assert_eq!(
status(3, true),
EN.the_gulls,
"and the flock coming in always has the slot instead"
);
}
#[test]
fn the_prompt_names_the_pause_cards_keys_while_it_is_up() {
use crate::app::{Bots, Campaign, CampaignKind, Playback, Seats};
let levels = campaign_levels();
let builtins = levels.len();
let campaign = Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
};
let settings = GameSettings::default();
let said = |screen, paused| {
screen_text(
screen,
&Readout {
tr: &EN,
lang: Lang::En,
sim: &Sim(Board::new(9, 7, 1)),
campaign: &campaign,
coop: false,
phase: &State::new(Phase::Setup),
vphase: &State::new(VersusPhase::Running),
editor: &EditorState::default(),
online: &Online::default(),
playback: &Playback::default(),
lobby: &LobbyState::default(),
tournament: &crate::app::tournament::Tournament::default(),
seats: &Seats(2),
settings: &settings,
keycaps: &crate::app::keycaps::KeyCaps::default(),
names: &crate::app::SeatNames::default(),
bots: &Bots::default(),
library: &crate::app::replays::Library::default(),
notice: &crate::app::RoundNotice::default(),
match_menu: &crate::app::match_setup::MatchMenu::default(),
paused,
spectator_typing: None,
crowd: Default::default(),
speed: 1,
},
)
.prompt
};
for screen in [Screen::Puzzle, Screen::Versus] {
let playing = said(screen, false);
let paused = said(screen, true);
assert_ne!(playing, paused, "{screen:?}");
assert!(paused.starts_with(EN.prompt_paused), "{screen:?}: {paused}");
assert!(paused.ends_with(EN.prompt_mute), "{screen:?}: {paused}");
}
}
#[test]
fn the_prompt_carries_a_local_series_on_rather_than_offering_the_menu() {
use crate::app::tournament::{SeriesLength, Tournament};
use crate::app::{Bots, Campaign, CampaignKind, Playback, Seats};
let levels = campaign_levels();
let builtins = levels.len();
let campaign = Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
};
let settings = GameSettings::default();
let said = |tournament: &Tournament| {
versus_text(&Readout {
tr: &EN,
lang: Lang::En,
sim: &Sim(Board::new(9, 7, 1)),
campaign: &campaign,
coop: false,
phase: &State::new(Phase::Setup),
vphase: &State::new(VersusPhase::Over),
editor: &EditorState::default(),
online: &Online::default(),
playback: &Playback::default(),
lobby: &LobbyState::default(),
tournament,
seats: &Seats(2),
settings: &settings,
keycaps: &crate::app::keycaps::KeyCaps::default(),
names: &crate::app::SeatNames::default(),
bots: &Bots::default(),
library: &crate::app::replays::Library::default(),
notice: &crate::app::RoundNotice::default(),
match_menu: &crate::app::match_setup::MatchMenu::default(),
paused: false,
spectator_typing: None,
crowd: Default::default(),
speed: 1,
})
.prompt
};
assert_eq!(
said(&Tournament::start(SeriesLength::BestOfThree)),
EN.tour_next
);
assert_eq!(said(&Tournament::default()), EN.prompt_enter_menu);
let mut decided = Tournament::start(SeriesLength::BestOfThree);
decided.wins[0] = 2;
decided.state = crate::app::tournament::SeriesState::Decided;
assert_eq!(said(&decided), EN.prompt_enter_menu);
}
#[test]
fn driftwood_is_a_shelf_behind_the_campaign_not_more_of_it() {
let shipped = campaign_levels();
let builtins = shipped.len();
let mut levels = shipped.clone();
levels.extend([shipped[0].clone(), shipped[1].clone()]);
let mut campaign = Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
};
let said = |campaign: &Campaign, phase| {
let sim = Sim(campaign.current().board());
puzzle_text(
&EN,
Lang::En,
campaign,
false,
&sim,
&State::new(phase),
false,
)
};
let title = said(&campaign, Phase::Setup).title;
assert!(title.contains(&format!("1/{builtins}")), "{title}");
assert!(!title.contains(EN.stage_custom), "{title}");
campaign.index = builtins - 1;
assert_eq!(said(&campaign, Phase::Won).prompt, EN.last_level);
campaign.index = builtins;
let HudText { title, prompt, .. } = said(&campaign, Phase::Won);
assert!(title.contains(EN.stage_custom), "{title}");
assert!(title.contains("1/2"), "{title}");
assert!(!title.contains(EN.title_tide_pool), "{title}");
assert_eq!(prompt, EN.prompt_won);
campaign.index = builtins + 1;
let HudText { title, prompt, .. } = said(&campaign, Phase::Won);
assert!(title.contains("2/2"), "{title}");
assert_eq!(prompt, EN.last_level);
}
#[test]
fn the_puzzle_prompt_reacts_to_a_spent_inventory() {
let levels = campaign_levels();
let builtins = levels.len();
let campaign = Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
};
let level: &Level = campaign.current();
let posts = level.posts;
let mut sim = Sim(level.board());
let phase = State::new(Phase::Setup);
let HudText { title, prompt, .. } =
puzzle_text(&EN, Lang::En, &campaign, false, &sim, &phase, false);
assert!(title.starts_with(EN.title_tide_pool), "{title}");
assert!(
title.contains("1/"),
"the level's place in the list: {title}"
);
assert_eq!(
prompt,
if posts == 0 {
EN.prompt_setup_no_posts
} else {
EN.prompt_setup
}
);
if posts > 0 {
let mut placed = 0;
'fill: for y in 0..sim.0.height() {
for x in 0..sim.0.width() {
if placed == posts {
break 'fill;
}
if sim.0.place_signpost(0, x, y, crate::sim::Direction::Up) {
placed += 1;
}
}
}
let prompt = puzzle_text(&EN, Lang::En, &campaign, false, &sim, &phase, false).prompt;
assert_eq!(prompt, EN.prompt_setup_full);
}
let prompt = puzzle_text(
&EN,
Lang::En,
&campaign,
false,
&Sim(level.board()),
&phase,
true,
)
.prompt;
assert_eq!(prompt, EN.prompt_setup_custom);
let coop = |custom| {
puzzle_text(
&EN,
Lang::En,
&campaign,
true,
&Sim(level.board()),
&phase,
custom,
)
.prompt
};
if posts > 0 {
assert_eq!(coop(false), EN.prompt_setup_coop);
}
assert_eq!(coop(true), EN.prompt_setup_custom);
let one_hand = GameSettings {
commit: crate::app::settings::CommitScheme::Ijkl,
..GameSettings::default()
};
assert!(!one_hand.stock_legend());
assert!(GameSettings::default().stock_legend());
}
#[test]
fn every_tide_event_has_a_distinct_name() {
let names: Vec<&str> = TideEvent::ALL
.iter()
.map(|&event| event_name(&EN, event))
.collect();
for name in &names {
assert!(!name.is_empty());
}
let mut unique = names.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(
unique.len(),
names.len(),
"duplicate event names: {names:?}"
);
}
#[test]
fn clock_formats_minutes_and_seconds() {
let tps = u64::from(TICKS_PER_SECOND);
assert_eq!(clock_text(0), "0:00");
assert_eq!(clock_text(29 * tps), "0:29");
assert_eq!(clock_text(90 * tps), "1:30");
assert_eq!(clock_text(600 * tps), "10:00");
}
#[test]
fn the_clock_reddens_then_blinks() {
let tps = u64::from(TICKS_PER_SECOND);
let long = Some(3 * 60 * TICKS_PER_SECOND);
for round in [None, long] {
assert_eq!(clock_color(60 * tps, round, 0.0, true), CLOCK_CALM);
assert_eq!(clock_color(20 * tps, round, 0.0, true), CLOCK_RED);
assert_eq!(clock_color(5 * tps, round, 0.0, true), CLOCK_RED);
assert_eq!(clock_color(5 * tps, round, 0.5, true), CLOCK_RED_BRIGHT);
assert_eq!(clock_color(5 * tps, round, 0.5, false), CLOCK_RED);
}
}
#[test]
fn a_short_round_is_not_one_long_emergency() {
let dry_feet = Some(240);
assert_eq!(clock_color(200, dry_feet, 0.0, true), CLOCK_CALM);
assert_eq!(clock_color(100, dry_feet, 0.0, true), CLOCK_CALM);
assert_eq!(clock_color(60, dry_feet, 0.0, true), CLOCK_RED);
assert_eq!(clock_color(20, dry_feet, 0.5, true), CLOCK_RED_BRIGHT);
for round in [240u32, 300, 900, 1800, 3600] {
let red = urgency_band(Some(round), crate::sim::SURGE_TICKS);
let blink = (0..=round)
.rev()
.map(u64::from)
.find(|&t| clock_color(t, Some(round), 0.5, true) == CLOCK_RED_BRIGHT);
assert!(
blink.is_some_and(|blink| blink < red),
"round {round}: reddens at {red}, blinks at {blink:?}"
);
}
}
#[test]
fn the_urgency_band_shrinks_only_for_short_rounds() {
assert_eq!(urgency_band(None, 900), 900);
assert_eq!(urgency_band(Some(3600), 900), 900);
assert_eq!(urgency_band(Some(901), 900), 900);
assert_eq!(urgency_band(Some(900), 900), 300);
assert_eq!(urgency_band(Some(240), 900), 80);
}
}