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 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_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 bots() -> Option<u8> {
std::env::var("PINCH_BOTS").ok()?.parse().ok()
}
fn seats() -> Option<u8> {
std::env::var("PINCH_SEATS").ok()?.parse().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()
}
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,
StageSelect {
beach: bool,
},
Skirmish {
map: match_setup::MapChoice,
series: bool,
},
MatchSetup,
Replay,
Replays,
Resume,
}
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_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_LIBRARY", DevHook::Replays),
("PINCH_RESUME", DevHook::Resume),
] {
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: set("PINCH_SERIES"),
});
}
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 resuming: ResMut<crate::app::Resuming>,
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::Replays => next_screen.set(Screen::Replays),
DevHook::Resume => match crate::app::suspend::pick_up() {
Ok(round) => {
resuming.0 = Some(round);
next_screen.set(Screen::Versus);
}
Err(e) => warn!("PINCH_RESUME: nothing to pick up: {e}"),
},
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 {
*tournament = crate::app::tournament::Tournament::start();
}
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);
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,
})
}
pub(super) fn debug_tide(mut sim: ResMut<Sim>, mut hook: Local<OneShot>) {
let Some(which) = hook.due("PINCH_TIDE", sim.0.ticks()) else {
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 OneShot {
setting: Option<Option<String>>,
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 = self
.setting
.get_or_insert_with(|| std::env::var(var).ok())
.clone()?;
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>, mut hook: Local<OneShot>) {
let Some(which) = hook.due("PINCH_LURE", sim.0.ticks()) else {
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_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 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 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;
}
let level = campaign.current();
for &(x, y, dir) in &level.solution {
let _ = sim.0.place_signpost(0, x, y, dir);
}
next_phase.set(Phase::Running);
}
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_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_LIBRARY", DevHook::Replays),
("PINCH_RESUME", DevHook::Resume),
("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));
assert_eq!(
skirmish(&[("PINCH_SKIRMISH", "large")]),
Some(DevHook::Skirmish {
map: MapChoice::GenLarge,
series: false
})
);
assert_eq!(
skirmish(&[("PINCH_SKIRMISH", "xl"), ("PINCH_SERIES", "1")]),
Some(DevHook::Skirmish {
map: MapChoice::GenXl,
series: true
})
);
assert_eq!(
skirmish(&[("PINCH_SKIRMISH", "wibble")]),
Some(DevHook::Skirmish {
map: MapChoice::GenClassic,
series: false
})
);
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: false
})
);
}
}