use crate::app::{Campaign, PendingActions, Phase, Screen, Sim, announce, match_setup, net};
use crate::sim::{Direction, PlayerAction, TideEvent};
use bevy::prelude::*;
pub(super) fn window_size() -> Option<(f32, f32)> {
let raw = std::env::var("PINCH_WINDOW").ok()?;
let (w, h) = raw.split_once(['x', 'X'])?;
Some((w.trim().parse().ok()?, h.trim().parse().ok()?))
}
pub(super) fn on_top() -> bool {
std::env::var("PINCH_ON_TOP").is_ok()
}
pub(super) fn window_pinned() -> bool {
std::env::var("PINCH_WINDOW").is_ok() || screenshotting()
}
pub(super) fn auto_host_quota() -> Option<usize> {
std::env::var("PINCH_LOBBY_HOST")
.ok()
.map(|quota| quota.parse().unwrap_or(1))
}
pub(super) fn auto_join() -> bool {
std::env::var("PINCH_LOBBY_JOIN").is_ok()
}
pub(super) fn auto_join_bot() -> bool {
std::env::var("PINCH_LOBBY_JOIN_BOT").is_ok()
}
pub(super) fn auto_watch() -> bool {
std::env::var("PINCH_LOBBY_WATCH").is_ok()
}
pub(super) fn direct_host() -> Option<String> {
std::env::var("PINCH_HOST").ok()
}
pub(super) fn direct_join() -> Option<String> {
std::env::var("PINCH_JOIN").ok()
}
pub(super) fn bot_seats() -> Option<u8> {
std::env::var("PINCH_BOT_SEATS").ok()?.parse().ok()
}
pub(crate) fn launch() -> bool {
std::env::var("PINCH_LAUNCH").is_ok()
}
pub(crate) fn bot_key() -> Option<crate::bots::connstr::Key> {
crate::bots::connstr::Key::parse(&std::env::var("PINCH_BOT_KEY").ok()?)
}
pub(super) fn bots() -> Option<u8> {
std::env::var("PINCH_BOTS").ok()?.parse().ok()
}
pub(super) fn compute_threads() -> Option<usize> {
let n: usize = std::env::var("PINCH_THREADS").ok()?.parse().ok()?;
Some(if n == 0 { usize::MAX } else { n })
}
fn seats() -> Option<u8> {
std::env::var("PINCH_SEATS").ok()?.parse().ok()
}
pub(super) fn coop() -> bool {
std::env::var("PINCH_COOP").is_ok()
}
pub(super) fn sandbox() -> bool {
std::env::var("PINCH_SANDBOX").is_ok()
}
pub(super) fn single_threaded_executor() -> bool {
std::env::var("PINCH_ST_EXEC").is_ok()
}
#[cfg(feature = "shuffle")]
pub(super) fn shuffle_seed() -> Option<u64> {
let seed = std::env::var("PINCH_SHUFFLE").ok()?;
Some(seed.parse().unwrap_or_else(|_| {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
seed.hash(&mut hasher);
hasher.finish()
}))
}
pub(super) fn no_update_check() -> bool {
std::env::var("PINCH_NO_UPDATE").is_ok()
}
pub(super) fn update_demo() -> bool {
std::env::var("PINCH_UPDATE_DEMO").is_ok()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum DevHook {
Lobby,
Online,
Sandbox,
Autoplay {
level: usize,
},
Editor,
Settings,
Controls,
Achievements,
Language,
StageSelect {
beach: bool,
},
Skirmish {
map: match_setup::MapChoice,
series: crate::app::tournament::SeriesLength,
},
MatchSetup,
Replay,
Replays,
}
impl DevHook {
pub(super) fn from_env(var: impl Fn(&str) -> Option<String>) -> Option<DevHook> {
let set = |name: &str| var(name).is_some();
if set("PINCH_LOBBY_HOST")
|| set("PINCH_LOBBY_JOIN")
|| set("PINCH_LOBBY_JOIN_BOT")
|| set("PINCH_LOBBY_WATCH")
{
return Some(DevHook::Lobby);
}
if set("PINCH_HOST") || set("PINCH_JOIN") {
return Some(DevHook::Online);
}
if set("PINCH_SANDBOX") {
return Some(DevHook::Sandbox);
}
if let Some(level) = var("PINCH_AUTOPLAY") {
return Some(DevHook::Autoplay {
level: level.parse::<usize>().unwrap_or(1).max(1) - 1,
});
}
for (name, hook) in [
("PINCH_EDITOR", DevHook::Editor),
("PINCH_SETTINGS", DevHook::Settings),
("PINCH_CONTROLS", DevHook::Controls),
("PINCH_ACHIEVEMENTS", DevHook::Achievements),
("PINCH_LANGUAGE", DevHook::Language),
("PINCH_LIBRARY", DevHook::Replays),
] {
if set(name) {
return Some(hook);
}
}
if let Some(list) = var("PINCH_STAGES") {
return Some(DevHook::StageSelect {
beach: list == "beach",
});
}
if let Some(size) = var("PINCH_SKIRMISH") {
return Some(DevHook::Skirmish {
map: match size.as_str() {
"large" => match_setup::MapChoice::GenLarge,
"xl" => match_setup::MapChoice::GenXl,
"ocean" => match_setup::MapChoice::GenOcean,
"custom" => match_setup::MapChoice::Custom,
_ => match_setup::MapChoice::GenClassic,
},
series: match var("PINCH_SERIES").as_deref() {
None => crate::app::tournament::SeriesLength::Single,
Some("3") => crate::app::tournament::SeriesLength::BestOfThree,
Some(_) => crate::app::tournament::SeriesLength::BestOfFive,
},
});
}
if set("PINCH_MATCH") {
return Some(DevHook::MatchSetup);
}
set("PINCH_REPLAY").then_some(DevHook::Replay)
}
}
pub(super) fn kickoff(
mut campaign: ResMut<Campaign>,
mut online: ResMut<net::Online>,
mut config: ResMut<match_setup::MatchConfig>,
mut tournament: ResMut<crate::app::tournament::Tournament>,
mut playback: ResMut<crate::app::Playback>,
mut next_screen: ResMut<NextState<Screen>>,
) {
let Some(hook) = DevHook::from_env(|name| std::env::var(name).ok()) else {
return;
};
match hook {
DevHook::Lobby => {
config.map = debug_map().unwrap_or(config.map);
config.bots = bots().unwrap_or(config.bots);
next_screen.set(Screen::Lobby);
}
DevHook::Online => {
if let Some(session) = net::session_from_env() {
online.0 = Some(session);
next_screen.set(Screen::Versus);
}
}
DevHook::Sandbox => {
next_screen.set(Screen::Versus);
}
DevHook::Autoplay { level } => {
campaign.index = level.min(campaign.levels.len() - 1);
next_screen.set(Screen::Puzzle);
}
DevHook::Editor => next_screen.set(Screen::Editor),
DevHook::Settings => next_screen.set(Screen::Settings),
DevHook::Controls => next_screen.set(Screen::Controls),
DevHook::Achievements => next_screen.set(Screen::Achievements),
DevHook::Language => next_screen.set(Screen::Language),
DevHook::Replays => next_screen.set(Screen::Replays),
DevHook::StageSelect { beach } => {
if beach {
let levels = crate::sim::challenge_levels();
let builtins = levels.len();
campaign.reset(crate::app::CampaignKind::BeachDay, levels, builtins);
}
next_screen.set(Screen::StageSelect);
}
DevHook::Skirmish { map, series } => {
config.seats = seats().unwrap_or(4).clamp(2, crate::sim::MAX_PLAYERS as u8);
config.bots = config.seats - 1;
config.map = map;
config.series = series;
config.bots = bots().unwrap_or(config.seats - 1).min(config.seats - 1);
if series.is_series() {
*tournament = crate::app::tournament::Tournament::start(series);
}
config.armed = true;
next_screen.set(Screen::Versus);
}
DevHook::MatchSetup => {
config.seats = seats()
.unwrap_or(config.seats)
.clamp(2, crate::sim::MAX_PLAYERS as u8);
config.bots = bots().unwrap_or(config.bots).min(config.seats - 1);
for slot in 0..bot_seats().unwrap_or(0).min(config.bots) {
let seat = usize::from(config.seats - 1 - slot);
config.controllers[seat] = crate::app::SeatController::Bot;
}
next_screen.set(Screen::MatchSetup);
}
DevHook::Replay => {
match std::fs::read_to_string(crate::app::replay_path())
.map_err(|e| e.to_string())
.and_then(|t| crate::sim::Replay::parse(&t))
{
Ok(replay) => {
playback.0 = Some((replay, 0));
next_screen.set(Screen::Versus);
}
Err(e) => warn!("PINCH_REPLAY: no replay to watch: {e}"),
}
}
}
}
fn debug_map() -> Option<match_setup::MapChoice> {
Some(match std::env::var("PINCH_SKIRMISH").ok()?.as_str() {
"large" => match_setup::MapChoice::GenLarge,
"xl" => match_setup::MapChoice::GenXl,
"ocean" => match_setup::MapChoice::GenOcean,
"custom" => match_setup::MapChoice::Custom,
_ => match_setup::MapChoice::GenClassic,
})
}
fn only_off_the_wire(online: &net::Online, var: &str) -> bool {
if online.0.is_some() {
warn!(
"{var}: ignored in an online round, where one peer's board is \
not its own to change; use PINCH_SPECTATOR=<event> instead"
);
return true;
}
false
}
pub(super) fn debug_tide(mut sim: ResMut<Sim>, online: Res<net::Online>, mut hook: Local<OneShot>) {
let Some(which) = hook.due("PINCH_TIDE", sim.0.ticks()) else {
return;
};
if only_off_the_wire(&online, "PINCH_TIDE") {
return;
}
let index = which.parse::<usize>().unwrap_or(0) % TideEvent::ALL.len();
sim.0.force_tide_event(TideEvent::ALL[index], 0);
}
#[derive(Default)]
pub(super) struct MomentHooks {
pause: OneShot,
over: OneShot,
interlude: OneShot,
}
#[derive(Default)]
pub(super) struct OneShot {
setting: Option<(Option<String>, Option<u64>)>,
fired: bool,
}
impl OneShot {
const WAIT: u64 = 4;
fn due(&mut self, var: &str, ticks: u64) -> Option<String> {
self.due_after(var, ticks, Self::WAIT)
}
fn due_after(&mut self, var: &str, ticks: u64, seconds: u64) -> Option<String> {
let (setting, at) = self.setting.get_or_insert_with(|| {
let at = std::env::var(format!("{var}_AT"))
.ok()
.and_then(|s| s.parse().ok());
(std::env::var(var).ok(), at)
});
let setting = setting.clone()?;
let seconds = at.unwrap_or(seconds);
if self.fired || ticks < seconds * u64::from(crate::sim::TICKS_PER_SECOND) {
return None;
}
self.fired = true;
Some(setting)
}
}
pub(super) fn debug_lure(mut sim: ResMut<Sim>, online: Res<net::Online>, mut hook: Local<OneShot>) {
let Some(which) = hook.due("PINCH_LURE", sim.0.ticks()) else {
return;
};
if only_off_the_wire(&online, "PINCH_LURE") {
return;
}
let seat = which.parse::<u8>().unwrap_or(0);
sim.0
.force_lure(seat.min(crate::sim::MAX_PLAYERS as u8 - 1));
}
pub(super) fn debug_spectator(
mut commands: Commands,
sim: Res<Sim>,
settings: Res<crate::app::settings::GameSettings>,
mut card: ResMut<crate::app::spectators::SpectatorCard>,
mut online: ResMut<net::Online>,
mut hook: Local<OneShot>,
) {
let Some(which) = hook.due("PINCH_SPECTATOR", sim.0.ticks()) else {
return;
};
if !crate::app::spectators::is_spectating(&online) {
return;
}
if which == "say" {
let me = settings.names[0].clone();
if let Some(session) = &mut online.0
&& !me.is_empty()
{
let line = "hello from the back row";
session
.transport
.send(crate::transport::NetMsg::chat(&me, line));
session.heard.push((me, line.to_string()));
}
return;
}
if which == "card" {
card.0 = true;
crate::app::spectators::spawn_card(&mut commands, &settings);
return;
}
if let Some(seat) = which
.strip_prefix("pick")
.and_then(|n| n.parse::<u8>().ok())
{
if let Some(session) = &mut online.0 {
let seat = seat.saturating_sub(1);
session.stands.my_pick = Some(seat);
session
.transport
.send(crate::transport::NetMsg::SpectatorPick { seat });
}
return;
}
let at = which.parse::<usize>().unwrap_or(0);
let event = crate::app::spectators::SPECTATOR_EVENTS
[at % crate::app::spectators::SPECTATOR_EVENTS.len()];
if let Some(session) = &mut online.0 {
session
.transport
.send(crate::transport::NetMsg::SpectatorVote {
event: event.index() as u8,
});
}
}
pub(super) fn debug_banner(
sim: Res<Sim>,
mut announcer: ResMut<announce::Announcer>,
mut hook: Local<OneShot>,
) {
let Some(which) = hook.due_after("PINCH_BANNER", sim.0.ticks(), 8) else {
return;
};
announcer.push(match which.as_str() {
"lure" => announce::Announcement::Lure(0),
"surge" => announce::Announcement::Surge,
index => announce::Announcement::Tide(
TideEvent::ALL[index.parse::<usize>().unwrap_or(0) % TideEvent::ALL.len()],
),
});
}
pub(super) fn screenshotting() -> bool {
std::env::var("PINCH_SCREENSHOT").is_ok()
}
pub(super) fn debug_screenshot(
mut commands: Commands,
time: Res<Time>,
mut fired: Local<bool>,
mut config: Local<Option<Option<(String, f32)>>>,
mut exit: MessageWriter<AppExit>,
) {
let config = config.get_or_insert_with(|| {
std::env::var("PINCH_SCREENSHOT").ok().map(|path| {
let at = std::env::var("PINCH_SCREENSHOT_AT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
(path, at)
})
});
let Some((path, at)) = config else {
return;
};
let (path, at) = (path.clone(), *at);
use bevy::render::view::screenshot::{Screenshot, save_to_disk};
if !*fired && time.elapsed_secs() > at {
*fired = true;
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(path));
}
if *fired && time.elapsed_secs() > at + 1.5 {
exit.write(AppExit::Success);
}
}
pub(super) fn frame_times(
time: Res<Time<Real>>,
mut on: Local<Option<bool>>,
mut frames: Local<Vec<f32>>,
mut since: Local<f32>,
) {
if !*on.get_or_insert_with(|| std::env::var("PINCH_FRAMES").is_ok()) {
return;
}
let dt = time.delta_secs() * 1000.0;
if dt > 20.0 {
eprintln!("slow frame at {:.3}s: {dt:.1} ms", time.elapsed_secs());
}
frames.push(dt);
*since += dt;
if *since < 5000.0 {
return;
}
let mut sorted = std::mem::take(&mut *frames);
sorted.sort_by(f32::total_cmp);
let n = sorted.len();
let mean = sorted.iter().sum::<f32>() / n as f32;
eprintln!(
"frames: {n} in {:.1}s, mean {mean:.2} ms, p99 {:.2} ms, worst {:.2} ms",
*since / 1000.0,
sorted[(n * 99 / 100).min(n - 1)],
sorted[n - 1],
);
*since = 0.0;
}
pub(super) fn debug_moments(
sim: Res<Sim>,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut tournament: ResMut<crate::app::tournament::Tournament>,
screen: Res<State<Screen>>,
mut next_screen: ResMut<NextState<Screen>>,
mut next_vphase: ResMut<NextState<crate::app::VersusPhase>>,
mut hooks: Local<MomentHooks>,
) {
let MomentHooks {
pause: pause_hook,
over: over_hook,
interlude: interlude_hook,
} = &mut *hooks;
if *screen.get() != Screen::Versus {
return;
}
if pause_hook
.due_after("PINCH_PAUSE", sim.0.ticks(), 2)
.is_some()
{
keys.press(KeyCode::Escape);
keys.release(KeyCode::Escape);
}
if over_hook
.due_after("PINCH_OVER", sim.0.ticks(), 2)
.is_some()
{
next_vphase.set(crate::app::VersusPhase::Over);
}
if interlude_hook
.due_after("PINCH_INTERLUDE", sim.0.ticks(), 2)
.is_some()
{
use crate::app::tournament::{SeriesLength, Tournament};
let mut wins = [0; crate::sim::MAX_PLAYERS];
wins[0] = 1;
wins[1] = 1;
let length = match tournament.length.is_series() {
true => tournament.length,
false => SeriesLength::BestOfFive,
};
let round = 3.min(length.rounds()) - 1;
*tournament = Tournament::taken_up(length, round, wins);
next_screen.set(Screen::Interlude);
}
}
pub(super) fn debug_autoplay(
mut sim: ResMut<Sim>,
campaign: Res<Campaign>,
mut enabled: Local<Option<bool>>,
mut next_phase: ResMut<NextState<Phase>>,
) {
if !*enabled.get_or_insert_with(|| std::env::var("PINCH_AUTOPLAY").is_ok()) {
return;
}
if std::env::var("PINCH_NOSOLVE").is_err() {
let level = campaign.current();
if let Err((x, y, _)) = level.place_solution(&mut sim.0) {
warn!(
"PINCH_AUTOPLAY: {:?} refused its own solution at ({x},{y})",
level.name
);
}
}
next_phase.set(Phase::Running);
}
pub(super) fn autopilot_level() -> Option<crate::sim::BotLevel> {
use crate::sim::BotLevel;
match std::env::var("PINCH_AUTOPILOT").ok()?.as_str() {
"easy" => Some(BotLevel::Easy),
"1" | "normal" => Some(BotLevel::Normal),
"hard" => Some(BotLevel::Hard),
_ => None,
}
}
pub(super) fn debug_autopilot(
sim: Res<Sim>,
online: Res<net::Online>,
mut pending: ResMut<PendingActions>,
mut level: Local<Option<Option<crate::sim::BotLevel>>>,
) {
let Some(level) = *level.get_or_insert_with(autopilot_level) else {
return;
};
let seat = match online.0.as_ref() {
Some(session) => match session.session.seat() {
Some(seat) => seat,
None => return,
},
None => 0,
};
let Some(slot) = pending.0.get_mut(usize::from(seat)) else {
return;
};
if matches!(slot, PlayerAction::None) {
*slot = crate::sim::bot_action(&sim.0, seat, level);
}
}
pub(super) fn debug_net_probe(
sim: Res<Sim>,
online: Res<net::Online>,
mut pending: ResMut<PendingActions>,
mut fired: Local<bool>,
mut enabled: Local<Option<bool>>,
) {
if *fired
|| !*enabled.get_or_insert_with(|| std::env::var("PINCH_NET_PROBE").is_ok())
|| sim.0.ticks() < 90
{
return;
}
*fired = true;
let seat = online
.0
.as_ref()
.and_then(|session| session.session.seat())
.unwrap_or(0);
pending.0[seat as usize] = PlayerAction::Place {
x: 1,
y: 4,
dir: Direction::Up,
};
info!("net probe: seat {seat} placed (1,4) Up");
}
#[cfg(test)]
mod tests {
use super::*;
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name| {
vars.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_string())
}
}
#[test]
fn no_hooks_means_the_menu() {
assert_eq!(DevHook::from_env(env(&[])), None);
assert_eq!(DevHook::from_env(env(&[("PATH", "/usr/bin")])), None);
}
#[test]
fn each_hook_is_reachable() {
for (var, expected) in [
("PINCH_LOBBY_HOST", DevHook::Lobby),
("PINCH_LOBBY_JOIN", DevHook::Lobby),
("PINCH_LOBBY_JOIN_BOT", DevHook::Lobby),
("PINCH_HOST", DevHook::Online),
("PINCH_JOIN", DevHook::Online),
("PINCH_SANDBOX", DevHook::Sandbox),
("PINCH_EDITOR", DevHook::Editor),
("PINCH_SETTINGS", DevHook::Settings),
("PINCH_CONTROLS", DevHook::Controls),
("PINCH_ACHIEVEMENTS", DevHook::Achievements),
("PINCH_LANGUAGE", DevHook::Language),
("PINCH_LIBRARY", DevHook::Replays),
("PINCH_STAGES", DevHook::StageSelect { beach: false }),
("PINCH_AUTOPLAY", DevHook::Autoplay { level: 0 }),
("PINCH_MATCH", DevHook::MatchSetup),
("PINCH_REPLAY", DevHook::Replay),
] {
assert_eq!(
DevHook::from_env(env(&[(var, "1")])),
Some(expected),
"{var}"
);
}
}
#[test]
fn skirmish_reads_its_size_and_series_flag() {
use match_setup::MapChoice;
let skirmish = |vars: &[(&str, &str)]| DevHook::from_env(env(vars));
use crate::app::tournament::SeriesLength;
assert_eq!(
skirmish(&[("PINCH_SKIRMISH", "large")]),
Some(DevHook::Skirmish {
map: MapChoice::GenLarge,
series: SeriesLength::Single
})
);
assert_eq!(
skirmish(&[("PINCH_SKIRMISH", "xl"), ("PINCH_SERIES", "1")]),
Some(DevHook::Skirmish {
map: MapChoice::GenXl,
series: SeriesLength::BestOfFive
}),
"the historical bare flag still means the long series"
);
assert_eq!(
skirmish(&[("PINCH_SKIRMISH", "xl"), ("PINCH_SERIES", "3")]),
Some(DevHook::Skirmish {
map: MapChoice::GenXl,
series: SeriesLength::BestOfThree
})
);
assert_eq!(
skirmish(&[("PINCH_SKIRMISH", "wibble")]),
Some(DevHook::Skirmish {
map: MapChoice::GenClassic,
series: SeriesLength::Single
})
);
assert_eq!(
DevHook::from_env(env(&[("PINCH_AUTOPLAY", "1")])),
Some(DevHook::Autoplay { level: 0 })
);
assert_eq!(
DevHook::from_env(env(&[("PINCH_AUTOPLAY", "25")])),
Some(DevHook::Autoplay { level: 24 })
);
assert_eq!(
DevHook::from_env(env(&[("PINCH_STAGES", "beach")])),
Some(DevHook::StageSelect { beach: true })
);
assert_eq!(skirmish(&[("PINCH_SERIES", "1")]), None);
}
#[test]
fn the_ladder_has_a_fixed_precedence() {
let all = env(&[
("PINCH_LOBBY_HOST", "1"),
("PINCH_HOST", "47777"),
("PINCH_SANDBOX", "1"),
("PINCH_EDITOR", "1"),
("PINCH_SKIRMISH", "xl"),
("PINCH_REPLAY", "1"),
]);
assert_eq!(DevHook::from_env(all), Some(DevHook::Lobby));
assert_eq!(
DevHook::from_env(env(&[("PINCH_HOST", "47777"), ("PINCH_SANDBOX", "1")])),
Some(DevHook::Online)
);
assert_eq!(
DevHook::from_env(env(&[("PINCH_SANDBOX", "1"), ("PINCH_EDITOR", "1")])),
Some(DevHook::Sandbox)
);
assert_eq!(
DevHook::from_env(env(&[("PINCH_EDITOR", "1"), ("PINCH_SKIRMISH", "xl")])),
Some(DevHook::Editor)
);
assert_eq!(
DevHook::from_env(env(&[("PINCH_SKIRMISH", "xl"), ("PINCH_REPLAY", "1")])),
Some(DevHook::Skirmish {
map: match_setup::MapChoice::GenXl,
series: crate::app::tournament::SeriesLength::Single
})
);
}
}