use crate::app::{Campaign, PendingActions, Phase, Screen, Sim, announce, match_setup, net};
use crate::sim::{Direction, PlayerAction, PlayerId, 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 lobby_partners() -> Option<(u8, usize)> {
let value = std::env::var("PINCH_LOBBY_PARTNERS").ok()?;
let (seat, rivals) = value.split_once('@')?;
Some((seat.parse().ok()?, rivals.parse().ok()?))
}
pub(super) fn lobby_pick() -> bool {
std::env::var("PINCH_LOBBY_PICK").is_ok()
}
pub(super) fn lobby_pick_go() -> bool {
std::env::var("PINCH_LOBBY_PICK").is_ok_and(|value| value == "go")
}
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 })
}
pub(super) fn all_plugins() -> bool {
std::env::var("PINCH_ALL_PLUGINS").is_ok()
}
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 multi_threaded_executor() -> bool {
std::env::var("PINCH_MT_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(crate) fn pad_prompts() -> bool {
std::env::var("PINCH_PAD_PROMPTS").is_ok()
}
pub(crate) fn quit_card() -> bool {
std::env::var("PINCH_QUIT_CARD").is_ok()
}
pub(crate) fn virtual_pads() -> usize {
std::env::var("PINCH_PADS")
.ok()
.and_then(|n| n.parse().ok())
.unwrap_or(0)
.min(crate::sim::MAX_PLAYERS)
}
pub fn editor_level() -> Option<std::path::PathBuf> {
std::env::var_os("PINCH_EDITOR_LEVEL").map(std::path::PathBuf::from)
}
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,
Skirmish {
map: match_setup::MapChoice,
series: crate::app::tournament::SeriesLength,
},
MatchSetup {
step: match_setup::Step,
},
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")
|| set("PINCH_LOBBY_BROWSE")
{
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_EDITOR_LEVEL", 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),
] {
if set(name) {
return Some(hook);
}
}
if let Some(size) = var("PINCH_SKIRMISH") {
return Some(DevHook::Skirmish {
map: skirmish_map("PINCH_SKIRMISH", &size),
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 let Some(step) = var("PINCH_MATCH") {
return Some(DevHook::MatchSetup {
step: match step.as_str() {
"2" => match_setup::Step::Map,
"3" => match_setup::Step::Players,
_ => match_setup::Step::Game,
},
});
}
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>>,
mut setup: ResMut<match_setup::MatchMenu>,
) {
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.lobby_bots = bots().unwrap_or(config.lobby_bots);
match std::env::var("PINCH_SERIES").as_deref() {
Ok("3") => config.series = crate::app::tournament::SeriesLength::BestOfThree,
Ok(_) => config.series = crate::app::tournament::SeriesLength::BestOfFive,
Err(_) => {}
}
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 => next_screen.set(Screen::StageSelect),
DevHook::Skirmish { map, series } => {
let seats = seats().unwrap_or(4);
let table = match_setup::MatchConfig::with_ai(seats, bots().unwrap_or(seats - 1));
config.seats = table.seats;
config.controllers = table.controllers;
config.map = map;
config.series = series;
if series.is_series() {
*tournament = crate::app::tournament::Tournament::start(series);
}
config.armed = true;
next_screen.set(Screen::Versus);
}
DevHook::MatchSetup { step } => {
let seats = seats().unwrap_or(config.seats);
let ai = bots().unwrap_or(0);
let table = match_setup::MatchConfig::with_ai(seats, ai);
config.seats = table.seats;
config.controllers = table.controllers;
for slot in 0..bot_seats().unwrap_or(0).min(table.ai_seats()) {
let seat = usize::from(config.seats - 1 - slot);
config.controllers[seat] = crate::app::SeatController::Bot;
}
match std::env::var("PINCH_MATCH_MAP").ok().as_deref() {
Some("random") => config.random = true,
Some(name) => {
config.map = skirmish_map("PINCH_MATCH_MAP", name);
config.random = false;
match_setup::pick_the_map_in_force(&mut config);
}
None => {}
}
setup.step = step;
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(skirmish_map(
"PINCH_SKIRMISH",
&std::env::var("PINCH_SKIRMISH").ok()?,
))
}
fn skirmish_map(var: &str, value: &str) -> match_setup::MapChoice {
match value {
"classic" => match_setup::MapChoice::GenClassic,
"arena" => match_setup::MapChoice::Classic,
name => match_setup::MapChoice::from_token(name).unwrap_or_else(|| {
warn!("{var}: no map called {name:?}, playing the classic generated beach");
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 event = TideEvent::ALL[which.parse::<usize>().unwrap_or(0) % TideEvent::ALL.len()];
match std::env::var("PINCH_TIDE_BY")
.ok()
.and_then(|seat| seat.parse::<PlayerId>().ok())
{
Some(seat) => sim.0.force_spun_tide_event(event, seat),
None => sim.0.force_tide_event(event, 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" {
if let Some(session) = &mut online.0
&& let me = crate::app::lobby::speaking_as(session.bot.as_deref(), &settings.names[0])
.to_string()
&& !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.winner.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 {
event: TideEvent::ALL[index.parse::<usize>().unwrap_or(0) % TideEvent::ALL.len()],
by: None,
keeper: None,
},
});
}
pub(super) fn screenshotting() -> bool {
std::env::var("PINCH_SCREENSHOT").is_ok()
}
pub(super) fn debug_screenshot(
mut commands: Commands,
time: Res<Time>,
sim: Res<Sim>,
mut taken: Local<u32>,
mut done_at: Local<Option<f32>>,
mut config: Local<Option<Option<ShotPlan>>>,
mut exit: MessageWriter<AppExit>,
) {
let config = config.get_or_insert_with(ShotPlan::from_env);
let Some(plan) = config else {
return;
};
use bevy::render::view::screenshot::{Screenshot, save_to_disk};
if let Some(done) = *done_at {
if time.elapsed_secs() > done + 1.5 {
exit.write(AppExit::Success);
}
return;
}
let due = match plan.tick {
Some(tick) => sim.0.ticks() >= tick + u64::from(*taken * plan.every),
None => time.elapsed_secs() > plan.at,
};
if !due {
return;
}
let path = match plan.burst {
1 => plan.path.clone(),
_ => {
let stem = plan.path.strip_suffix(".png").unwrap_or(&plan.path);
format!("{stem}_{:02}.png", *taken)
}
};
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(path));
*taken += 1;
if *taken >= plan.burst {
*done_at = Some(time.elapsed_secs());
}
}
pub(super) fn slow_motion(mut time: ResMut<Time<Virtual>>) {
if let Some(speed) = std::env::var("PINCH_SLOWMO")
.ok()
.and_then(|s| s.parse::<f32>().ok())
.filter(|s| *s > 0.0)
{
time.set_relative_speed(speed);
}
}
pub(super) struct ShotPlan {
path: String,
at: f32,
tick: Option<u64>,
burst: u32,
every: u32,
}
impl ShotPlan {
fn from_env() -> Option<ShotPlan> {
fn num<T: std::str::FromStr>(key: &str) -> Option<T> {
std::env::var(key).ok()?.parse().ok()
}
Some(ShotPlan {
path: std::env::var("PINCH_SCREENSHOT").ok()?,
at: num("PINCH_SCREENSHOT_AT").unwrap_or(2.0),
tick: num("PINCH_SCREENSHOT_TICK"),
burst: num("PINCH_SCREENSHOT_BURST").unwrap_or(1).max(1),
every: num("PINCH_SCREENSHOT_EVERY").unwrap_or(3).max(1),
})
}
}
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_library_cursor(mut library: ResMut<crate::app::replays::Library>) {
let Some(round) = std::env::var("PINCH_LIBRARY")
.ok()
.and_then(|n| n.parse::<usize>().ok())
else {
return;
};
library.selected = round.saturating_sub(1);
library.settle();
}
pub(super) fn debug_lobby_cursor(mut state: ResMut<crate::app::lobby::LobbyState>) {
let Some(beach) = std::env::var("PINCH_LOBBY_BROWSE")
.ok()
.and_then(|n| n.parse::<usize>().ok())
.and_then(|n| n.checked_sub(1))
else {
return;
};
if beach < state.hosts.len() && state.selected_index() != Some(beach) {
state.point_at(Some(beach));
}
}
pub(super) fn debug_delete(
mut keys: ResMut<ButtonInput<KeyCode>>,
caps: Res<crate::app::keycaps::KeyCaps>,
time: Res<Time>,
mut opened: Local<Option<f32>>,
mut fired: Local<bool>,
) {
if *fired || std::env::var("PINCH_DELETE").is_err() {
return;
}
let now = time.elapsed_secs();
if now - *opened.get_or_insert(now) < 1.0 {
return;
}
*fired = true;
if let Some(key) = caps.key_for(crate::app::replays::DELETE_CAP) {
keys.press(key);
keys.release(key);
}
}
pub(super) fn debug_editor_press(
mut keys: ResMut<ButtonInput<KeyCode>>,
time: Res<Time>,
mut opened: Local<Option<f32>>,
mut pressed: Local<usize>,
) {
let Ok(list) = std::env::var("PINCH_EDITOR_PRESS") else {
return;
};
let now = time.elapsed_secs();
let since = now - *opened.get_or_insert(now);
let Some(name) = list.split(',').nth(*pressed) else {
return;
};
if since < (*pressed + 1) as f32 {
return;
}
*pressed += 1;
keys.release(KeyCode::ShiftLeft);
let (shift, name) = match name.trim().strip_prefix("Shift+") {
Some(name) => (true, name),
None => (false, name.trim()),
};
match key_named(name) {
Some(key) => {
if shift {
keys.press(KeyCode::ShiftLeft);
}
keys.press(key);
keys.release(key);
}
None => warn!("PINCH_EDITOR_PRESS: no key called {name:?}"),
}
}
fn key_named(name: &str) -> Option<KeyCode> {
const NAMED: [KeyCode; 15] = [
KeyCode::F1,
KeyCode::F2,
KeyCode::F3,
KeyCode::F4,
KeyCode::F5,
KeyCode::F6,
KeyCode::F7,
KeyCode::F8,
KeyCode::F9,
KeyCode::F10,
KeyCode::F11,
KeyCode::F12,
KeyCode::Escape,
KeyCode::Enter,
KeyCode::Tab,
];
NAMED
.into_iter()
.find(|&key| crate::app::binds::key_name(key) == name)
.or_else(|| crate::app::binds::any_key_from_name(name))
}
pub(super) fn debug_keep(
mut keys: ResMut<ButtonInput<KeyCode>>,
caps: Res<crate::app::keycaps::KeyCaps>,
time: Res<Time>,
mut opened: Local<Option<f32>>,
mut fired: Local<bool>,
) {
if *fired || std::env::var("PINCH_KEEP").is_err() {
return;
}
let now = time.elapsed_secs();
if now - *opened.get_or_insert(now) < 1.0 {
return;
}
*fired = true;
if let Some(key) = caps.key_for(crate::app::replays::KEEP_CAP) {
keys.press(key);
keys.release(key);
}
}
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, recorder, mut keep): (
ResMut<NextState<crate::app::VersusPhase>>,
Res<crate::app::Recorder>,
ResMut<crate::app::replays::RoundToKeep>,
),
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);
offer_as_finished(&recorder, &mut keep);
}
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);
offer_as_finished(&recorder, &mut keep);
next_screen.set(Screen::Interlude);
}
}
fn offer_as_finished(recorder: &crate::app::Recorder, keep: &mut crate::app::replays::RoundToKeep) {
if let Some(replay) = &recorder.0 {
*keep = crate::app::replays::RoundToKeep::Offered(Box::new(crate::app::replays::Unkept {
text: replay.to_text(),
stamp: crate::app::clock::now_secs(),
winner: None,
}));
}
}
pub(super) fn debug_autoplay(
mut sim: ResMut<Sim>,
campaign: Res<Campaign>,
mut enabled: Local<Option<bool>>,
mut next_phase: ResMut<NextState<Phase>>,
) {
let stay = std::env::var("PINCH_STAY").ok();
let placed = stay.as_deref() == Some("placed");
let wanted = || std::env::var("PINCH_AUTOPLAY").is_ok() && (stay.is_none() || placed);
if !*enabled.get_or_insert_with(wanted) {
return;
}
if placed {
*enabled = Some(false);
if let Err((x, y, _, _)) = campaign.current().place_solution(&mut sim.0) {
warn!("PINCH_STAY=placed: refused at ({x},{y})");
}
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>,
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
mut pending: ResMut<PendingActions>,
mut cursors: Query<&mut crate::app::cursor::Cursor>,
mut pilot: Local<Option<Autopilot>>,
) {
let walk = pilot.get_or_insert_with(|| Autopilot {
level: autopilot_level(),
walks: std::env::var("PINCH_AUTOPILOT_WALK").is_ok(),
heading: None,
wait: 0.0,
});
let Some(level) = walk.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) {
return;
}
if !walk.walks {
*slot = crate::sim::bot_action(&sim.0, seat, level);
return;
}
let Some(mut cursor) = cursors.iter_mut().find(|cursor| cursor.player == seat) else {
return;
};
let wanted = match walk.heading {
Some(action) => action,
None => crate::sim::bot_action(&sim.0, seat, level),
};
let (x, y) = match wanted {
PlayerAction::Place { x, y, .. } | PlayerAction::Remove { x, y } => (x, y),
PlayerAction::None | PlayerAction::CallEvent(_) => {
*slot = wanted;
return;
}
};
if (cursor.x, cursor.y) == (x, y) {
*slot = wanted;
walk.heading = None;
return;
}
walk.heading = Some(wanted);
walk.wait -= time.delta_secs();
if walk.wait > 0.0 {
return;
}
walk.wait = settings.repeat_interval;
let (dx, dy) = (
i16::from(x) - i16::from(cursor.x),
i16::from(y) - i16::from(cursor.y),
);
if dx.abs() >= dy.abs() {
cursor.x = cursor.x.saturating_add_signed(dx.signum() as i8);
} else {
cursor.y = cursor.y.saturating_add_signed(dy.signum() as i8);
}
}
pub(super) struct Autopilot {
level: Option<crate::sim::BotLevel>,
walks: bool,
heading: Option<PlayerAction>,
wait: f32,
}
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::*;
#[test]
fn the_old_kelp_keep_name_still_picks_the_gates() {
use match_setup::MapChoice;
assert_eq!(skirmish_map("T", "kelp-keep"), MapChoice::TheGates);
assert_eq!(skirmish_map("T", "gates"), MapChoice::TheGates);
assert_eq!(skirmish_map("T", "no-such-beach"), MapChoice::GenClassic);
}
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_LOBBY_BROWSE", DevHook::Lobby),
("PINCH_HOST", DevHook::Online),
("PINCH_JOIN", DevHook::Online),
("PINCH_SANDBOX", DevHook::Sandbox),
("PINCH_EDITOR", DevHook::Editor),
("PINCH_EDITOR_LEVEL", 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),
("PINCH_AUTOPLAY", DevHook::Autoplay { level: 0 }),
(
"PINCH_MATCH",
DevHook::MatchSetup {
step: match_setup::Step::Game,
},
),
("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
})
);
for (token, map) in [
("classic", MapChoice::GenClassic),
("arena", MapChoice::Classic),
] {
assert_eq!(
skirmish(&[("PINCH_SKIRMISH", token)]),
Some(DevHook::Skirmish {
map,
series: SeriesLength::Single
}),
"{token}"
);
}
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!(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
})
);
}
}
pub(super) fn present_mode() -> Option<bevy::window::PresentMode> {
use bevy::window::PresentMode;
Some(match std::env::var("PINCH_PRESENT").ok()?.as_str() {
"immediate" => PresentMode::Immediate,
"mailbox" => PresentMode::Mailbox,
"novsync" => PresentMode::AutoNoVsync,
_ => PresentMode::AutoVsync,
})
}
#[derive(Resource, Clone, Debug, Default)]
pub(super) struct Profile {
warmup: f32,
span: f32,
hide: Vec<String>,
gfx: Vec<String>,
cpu_only: bool,
}
impl Profile {
pub(super) fn from_env() -> Option<Profile> {
let span = std::env::var("PINCH_PROFILE").ok()?.parse().ok()?;
let list = |key: &str| -> Vec<String> {
std::env::var(key)
.unwrap_or_default()
.split(',')
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
};
Some(Profile {
warmup: std::env::var("PINCH_PROFILE_WARMUP")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8.0),
span,
hide: list("PINCH_HIDE"),
gfx: list("PINCH_GFX"),
cpu_only: std::env::var("PINCH_PROFILE_CPU").is_ok(),
})
}
fn hides(&self, what: &str) -> bool {
self.hide.iter().any(|h| h == what)
}
fn takes_off(&self, what: &str) -> bool {
self.gfx.iter().any(|g| g == what)
}
}
pub(super) fn msaa_override() -> Option<bool> {
static SAID: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
*SAID.get_or_init(|| {
let gfx = std::env::var("PINCH_GFX").ok()?;
let mut said = None;
for g in gfx.split(',') {
match g {
"msaa0" => said = Some(false),
"msaa4" => said = Some(true),
_ => {}
}
}
said
})
}
pub(super) fn install_profiling(app: &mut App) {
let Some(profile) = Profile::from_env() else {
return;
};
if !profile.cpu_only {
app.add_plugins(bevy::render::diagnostic::RenderDiagnosticsPlugin);
}
app.add_plugins(bevy::diagnostic::FrameTimeDiagnosticsPlugin::default())
.insert_resource(profile)
.init_resource::<Churn>()
.add_systems(
Last,
(profile_camera, profile_hide, profile_churn, profile_sample).chain(),
);
}
#[derive(Resource, Default)]
struct Churn {
frames: u32,
sprites: u64,
sprites_changed: u64,
materials_added: u64,
texts_changed: u64,
nodes_changed: u64,
by_image: std::collections::HashMap<AssetId<Image>, u64>,
}
type SpriteMaterial = SpriteMeshMaterial;
fn profile_churn(
mut churn: ResMut<Churn>,
profile: Res<Profile>,
time: Res<Time<Real>>,
sprites: Query<Ref<Sprite>>,
texts: Query<(), Changed<Text>>,
nodes: Query<(), Changed<Node>>,
mut materials: MessageReader<AssetEvent<SpriteMaterial>>,
) {
let added = materials
.read()
.filter(|e| matches!(e, AssetEvent::Added { .. }))
.count() as u64;
if time.elapsed_secs() < profile.warmup {
return;
}
churn.frames += 1;
churn.sprites += sprites.iter().count() as u64;
for sprite in sprites.iter().filter(Ref::is_changed) {
churn.sprites_changed += 1;
*churn.by_image.entry(sprite.image.id()).or_default() += 1;
}
churn.materials_added += added;
churn.texts_changed += texts.iter().count() as u64;
churn.nodes_changed += nodes.iter().count() as u64;
}
#[derive(bevy::ecs::system::SystemParam)]
struct ChurnLine<'w> {
churn: Res<'w, Churn>,
assets: Res<'w, AssetServer>,
}
impl ChurnLine<'_> {
fn line(&self) -> String {
let churn = &*self.churn;
let n = f64::from(churn.frames.max(1));
let mut top: Vec<_> = churn.by_image.iter().collect();
top.sort_by(|a, b| b.1.cmp(a.1));
let top: Vec<String> = top
.into_iter()
.take(8)
.map(|(id, count)| {
let name = self
.assets
.get_path(*id)
.map_or_else(|| format!("{id:?}"), |p| p.to_string());
format!("{name} {:.1}", *count as f64 / n)
})
.collect();
format!(
"sprites {:.0}, sprites changed {:.1}, sprite materials added {:.1}, texts changed {:.1}, nodes changed {:.1} (per frame); changed by image: {}",
churn.sprites as f64 / n,
churn.sprites_changed as f64 / n,
churn.materials_added as f64 / n,
churn.texts_changed as f64 / n,
churn.nodes_changed as f64 / n,
top.join(", "),
)
}
}
fn profile_camera(
mut commands: Commands,
profile: Res<Profile>,
cameras: Query<Entity, With<Camera2d>>,
box_shadows: Query<Entity, With<BoxShadow>>,
) {
for camera in &cameras {
let mut entity = commands.entity(camera);
if profile.takes_off("nohdr") {
entity.remove::<bevy::camera::Hdr>();
}
if profile.takes_off("nograde") {
entity.insert(bevy::render::view::ColorGrading::default());
}
if let Some(n) = profile
.gfx
.iter()
.find_map(|g| g.strip_prefix("shadowsamples")?.parse().ok())
{
entity.insert(BoxShadowSamples(n));
}
}
if profile.hides("boxshadows") {
for card in &box_shadows {
commands.entity(card).remove::<BoxShadow>();
}
}
}
type SandQuad = MeshMaterial2d<crate::app::materials::SandMaterial>;
type WaterQuad = MeshMaterial2d<crate::app::materials::WaterMaterial>;
type WaterOnly = (Without<Sprite>, Without<SandQuad>);
type FadeQuad = MeshMaterial2d<crate::app::materials::FadeMaterial>;
type FadeOnly = (
With<FadeQuad>,
Without<Sprite>,
Without<SandQuad>,
Without<WaterQuad>,
Without<Node>,
);
type UiRoot = (
With<Node>,
Without<ChildOf>,
Without<Sprite>,
Without<SandQuad>,
Without<WaterQuad>,
);
fn profile_hide(
profile: Res<Profile>,
art: Option<Res<crate::app::art::Art>>,
mut sand: Query<(&mut Visibility, &SandQuad), Without<Sprite>>,
mut water: Query<(&mut Visibility, &WaterQuad), WaterOnly>,
mut sprites: Query<(&mut Visibility, &Sprite, Has<crate::app::effects::Particle>)>,
mut roots: Query<&mut Visibility, UiRoot>,
mut fades: Query<&mut Visibility, FadeOnly>,
) {
let Some(art) = art else {
return;
};
if profile.hide.is_empty() {
return;
}
let shaded = &art.shaded;
for (mut visibility, material) in &mut sand {
let board = material.0 == shaded.board_sand;
if (board && profile.hides("board")) || (!board && profile.hides("sand")) {
visibility.set_if_neq(Visibility::Hidden);
}
}
for (mut visibility, material) in &mut water {
let sea = material.0 == shaded.sea || material.0 == shaded.ocean;
if (sea && profile.hides("sea")) || (!sea && profile.hides("pools")) {
visibility.set_if_neq(Visibility::Hidden);
}
}
let shadows = [
&art.shadow,
&art.gull_shadow,
&art.gull_shadow_b,
&art.cloud,
];
for (mut visibility, sprite, particle) in &mut sprites {
let shadow = shadows.contains(&&sprite.image);
if profile.hides("sprites")
|| (particle && profile.hides("particles"))
|| (shadow && profile.hides("shadows"))
{
visibility.set_if_neq(Visibility::Hidden);
}
}
if profile.hides("sprites") || profile.hides("particles") {
for mut visibility in &mut fades {
visibility.set_if_neq(Visibility::Hidden);
}
}
if profile.hides("ui") {
for mut visibility in &mut roots {
visibility.set_if_neq(Visibility::Hidden);
}
}
}
#[derive(Default)]
struct ProfileSamples {
started: Option<(f32, f64)>,
threads: std::collections::BTreeMap<u32, (String, f64)>,
seen: std::collections::HashMap<String, std::time::Instant>,
values: std::collections::BTreeMap<String, Vec<f64>>,
frames: u32,
}
fn process_cpu_secs() -> f64 {
let Ok(stat) = std::fs::read_to_string("/proc/self/stat") else {
return 0.0;
};
let rest = stat.rsplit_once(')').map_or("", |(_, r)| r);
let fields: Vec<&str> = rest.split_whitespace().collect();
let ticks: f64 = fields.get(11..13).map_or(0.0, |f| {
f.iter().filter_map(|v| v.parse::<f64>().ok()).sum()
});
ticks / 100.0
}
fn thread_cpu_secs() -> std::collections::BTreeMap<u32, (String, f64)> {
let mut out = std::collections::BTreeMap::new();
let Ok(tasks) = std::fs::read_dir("/proc/self/task") else {
return out;
};
for task in tasks.flatten() {
let Some(tid) = task.file_name().to_str().and_then(|t| t.parse().ok()) else {
continue;
};
let Ok(stat) = std::fs::read_to_string(task.path().join("stat")) else {
continue;
};
let name = stat
.split_once('(')
.and_then(|(_, r)| r.rsplit_once(')'))
.map_or(String::new(), |(n, _)| n.to_owned());
let rest = stat.rsplit_once(')').map_or("", |(_, r)| r);
let fields: Vec<&str> = rest.split_whitespace().collect();
let ticks: f64 = fields.get(11..13).map_or(0.0, |f| {
f.iter().filter_map(|v| v.parse::<f64>().ok()).sum()
});
out.insert(tid, (name, ticks / 100.0));
}
out
}
fn profile_sample(
profile: Res<Profile>,
time: Res<Time<Real>>,
store: Res<bevy::diagnostic::DiagnosticsStore>,
windows: Query<&Window>,
mut samples: Local<ProfileSamples>,
mut exit: MessageWriter<AppExit>,
churn: ChurnLine,
) {
let now = time.elapsed_secs();
if now < profile.warmup {
return;
}
if samples.started.is_none() {
samples.threads = thread_cpu_secs();
}
let (start, cpu_start) = *samples
.started
.get_or_insert_with(|| (now, process_cpu_secs()));
let first = samples.frames == 0;
samples.frames += 1;
for diagnostic in store.iter() {
let path = diagnostic.path().as_str().to_owned();
let last = samples.seen.get(&path).copied();
let mut per_frame: Vec<(std::time::Instant, f64)> = Vec::new();
for m in diagnostic.measurements() {
if last.is_some_and(|l| m.time <= l) {
continue;
}
match per_frame.last_mut() {
Some((t, v)) if *t == m.time => *v += m.value,
_ => per_frame.push((m.time, m.value)),
}
}
if let Some((t, _)) = per_frame.last() {
samples.seen.insert(path.clone(), *t);
}
if !first {
samples
.values
.entry(path)
.or_default()
.extend(per_frame.into_iter().map(|(_, v)| v));
}
}
if now - start < profile.span {
return;
}
let cpu = process_cpu_secs() - cpu_start;
let frames = samples.frames.saturating_sub(1).max(1);
let size = windows.iter().next().map_or(String::new(), |w| {
format!("{}x{}", w.physical_width(), w.physical_height())
});
let label = std::env::var("PINCH_PROFILE_LABEL").unwrap_or_default();
let mut lines = vec![format!(
"# {label} {size} frames {frames} in {:.1}s, cpu {:.2} ms/frame (all threads)",
now - start,
cpu * 1000.0 / f64::from(frames),
)];
let mut by_name: std::collections::BTreeMap<String, (f64, u32)> = Default::default();
for (tid, (name, secs)) in thread_cpu_secs() {
let before = samples.threads.get(&tid).map_or(0.0, |(_, s)| *s);
let entry = by_name.entry(name).or_default();
entry.0 += secs - before;
entry.1 += 1;
}
let wall = f64::from(now - start).max(1e-6);
let mut named: Vec<_> = by_name.into_iter().filter(|(_, (s, _))| *s > 0.0).collect();
named.sort_by(|a, b| b.1.0.total_cmp(&a.1.0));
for (name, (secs, count)) in named {
lines.push(format!(
"{label}\t{size}\tthread/{name} x{count}\t{:.3}\t{:.2}\t0",
secs * 1000.0 / f64::from(frames),
secs / wall,
));
}
lines.push(format!("# {label} churn: {}", churn.line()));
lines.push(format!(
"{label}\t{size}\tcpu_ms_per_frame\t{:.3}\t{:.2}\t{frames}",
cpu * 1000.0 / f64::from(frames),
cpu / wall,
));
let stat = |v: &[f64]| -> (f64, f64) {
let mut sorted = v.to_vec();
sorted.sort_by(f64::total_cmp);
let n = sorted.len();
let mean = sorted.iter().sum::<f64>() / n as f64;
(mean, sorted[(n * 95 / 100).min(n - 1)])
};
for (path, values) in &samples.values {
let keep = path.ends_with("elapsed_gpu")
|| path.ends_with("elapsed_cpu")
|| path.starts_with("fps")
|| path.starts_with("frame_time");
if !keep || values.is_empty() {
continue;
}
let (mean, p95) = stat(values);
lines.push(format!(
"{label}\t{size}\t{path}\t{mean:.3}\t{p95:.3}\t{}",
values.len()
));
}
let text = lines.join("\n");
eprintln!("{text}");
if let Ok(out) = std::env::var("PINCH_PROFILE_OUT") {
use std::io::Write;
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(out)
{
let _ = writeln!(file, "{text}");
}
}
exit.write(AppExit::Success);
}