Console command helpers for the DesertPump game. Exposes various ConCmds to inspect game state, manipulate pumps, upgrades, UI layout and panels, and run diagnostic scans (layout, coverage, effects, tree timers, etc.).
namespace DesertPump;
/// <summary>
/// Console commands, so you can check the numbers and jump around the pump ladder
/// without grinding for it. Type them into the editor console while the game runs.
/// </summary>
public static class PumpCommands
{
static DesertPumpGame Game => DesertPumpGame.Current;
static bool Missing()
{
if ( Game is not null ) return false;
Log.Warning( "Desert Pump isn't running - press play first." );
return true;
}
/// <summary>Print the current state of the run.</summary>
[ConCmd( "pump_status" )]
public static void Status()
{
if ( Missing() ) return;
var game = Game;
Log.Info( $"{game.CurrentPump.Position} - {game.CurrentPump.Name} ({game.Tier.Name}) [pump {game.PumpLevel + 1}/{PumpModel.All.Length}]" );
Log.Info( $"Coins {game.Coins:0.##} | Water {game.Water:0.##}/{game.CurrentPump.Tank:0.##} | Tank worth {game.SaleValue:0.##}" );
Log.Info( $"Per click {game.CurrentPump.PerClick} | Per second {game.CurrentPump.PerSecond} | Per litre {game.CurrentPump.PricePerLitre}" );
Log.Info( $"Total pumps {game.TotalPumps} | Total earned {game.TotalEarned:0.##} | Litres sold {game.TotalLitresSold:0.##}" );
Log.Info( $"Tank {game.Water:0.##}/{game.TankCapacity:0.##}L | {game.LitresPerClick:0.##}L per click | {game.PricePerLitre:0.##} per litre" );
Log.Info( $"Pressure {game.Pressure:0.00} ({game.PressureBonusText}) | {game.SurgeCount} surge(s) this session" );
foreach ( var track in UpgradeTrack.All )
{
var cost = game.CostOf( track.Kind );
var costText = double.IsInfinity( cost ) ? "maxed" : Numbers.Short( cost );
Log.Info( $" {track.Name,-16} Lv {game.LevelOf( track.Kind ),2}/{track.MaxLevel} x{game.MultiplierOf( track.Kind ):0.00} next {costText}" );
}
var music = game.Scene.GetAllComponents<PumpMusic>().FirstOrDefault();
Log.Info( $"Muted {game.Muted} | Music {(music is null ? "no component" : music.IsPlaying ? $"playing at {music.Volume:0.00}" : "stopped")}" );
if ( game.NextPump is not null )
{
Log.Info( $"Next: {game.NextPump.Name} for {game.NextPump.Cost} ({(game.CanUpgrade ? "affordable" : "too expensive")})" );
}
}
/// <summary>Pump by hand, the same way clicking does.</summary>
[ConCmd( "pump_pump" )]
public static void Pump( int count = 1 )
{
if ( Missing() ) return;
double total = 0;
for ( var i = 0; i < count; i++ )
{
total += Game.Pump();
}
Log.Info( $"Pumped {count}x for {total:0.##}L - tank now {Game.Water:0.##}L" );
}
/// <summary>Show the pressure gauge, or force it to a 0-1 value.</summary>
[ConCmd( "pump_pressure" )]
public static void Pressure( float value = -1f )
{
if ( Missing() ) return;
if ( value >= 0f )
{
Game.SetPressure( value );
}
Log.Info( $"Pressure {Game.Pressure:0.00} (stage {Game.PressureStage}) - clicks worth x{Game.PressureBonus:0.00}" );
Log.Info( $"Surges this session: {Game.SurgeCount} at {DesertPumpGame.SurgeChance * 100f:0.#}% for x{DesertPumpGame.SurgeMultiplier:0.#}" );
}
/// <summary>
/// Pump until a surge comes up, so the burst can be looked at without waiting on a
/// 1-in-50 roll. Gives up rather than looping forever if the tank can't take more.
/// </summary>
[ConCmd( "pump_surge" )]
public static void Surge( int maxTries = 400 )
{
if ( Missing() ) return;
var before = Game.SurgeCount;
for ( var i = 0; i < maxTries; i++ )
{
Game.Pump();
if ( Game.SurgeCount > before )
{
Log.Info( $"Surge after {i + 1} pump(s) - tank now {Game.Water:0.##}L" );
return;
}
if ( Game.TankFull )
{
Log.Warning( $"Tank filled after {i + 1} pump(s) before a surge came up - sell and try again." );
return;
}
}
Log.Warning( $"No surge in {maxTries} pumps. Unlucky, or SurgeChance is zero." );
}
/// <summary>Sell the tank.</summary>
[ConCmd( "pump_sell" )]
public static void Sell()
{
if ( Missing() ) return;
var earned = Game.SellAll();
if ( earned <= 0 )
{
Log.Info( "Nothing in the tank to sell." );
return;
}
Log.Info( $"Sold for {earned:0.##} - coins now {Game.Coins:0.##}" );
}
/// <summary>Buy the next pump if it's affordable.</summary>
[ConCmd( "pump_buy" )]
public static void Buy()
{
if ( Missing() ) return;
if ( Game.BuyNextPump() )
{
Log.Info( $"Bought {Game.CurrentPump.Name} - coins now {Game.Coins:0.##}" );
return;
}
Log.Info( "Couldn't buy - not enough coins, or you own everything." );
}
/// <summary>Buy a shop upgrade: storage, power or value.</summary>
[ConCmd( "pump_upgrade" )]
public static void Upgrade( string which, int count = 1 )
{
if ( Missing() ) return;
if ( !Enum.TryParse<UpgradeKind>( which, true, out var kind ) )
{
Log.Warning( "Use one of: storage, power, value" );
return;
}
var bought = 0;
for ( var i = 0; i < count && Game.BuyUpgrade( kind ); i++ )
{
bought++;
}
var track = UpgradeTrack.Get( kind );
Log.Info( $"{track.Name} +{bought} -> Lv {Game.LevelOf( kind )} (x{Game.MultiplierOf( kind ):0.00}), coins {Game.Coins:0.##}" );
}
/// <summary>Show the tree, and optionally pour the tank into it.</summary>
[ConCmd( "pump_tree" )]
public static void Tree( string action = "status" )
{
if ( Missing() ) return;
if ( action.Equals( "water", StringComparison.OrdinalIgnoreCase ) )
{
var poured = Game.WaterTree();
Log.Info( $"Poured {Numbers.Short( poured )}L into the tree." );
}
var state = Game.TreeIsGrowing
? $"growing, {Game.TreeTimeLeft} left"
: $"{Numbers.Short( Game.TreeWater )}/{Numbers.Short( Game.TreeWaterNeeded )}L toward the next stage";
Log.Info( $"Tree height {Game.TreeHeight} - {state}" );
}
/// <summary>Wind the tree's growth timer forward, for testing.</summary>
[ConCmd( "pump_tree_skip" )]
public static void TreeSkip( double seconds = 900 )
{
if ( Missing() ) return;
Game.SkipTreeTime( seconds );
Log.Info( $"Skipped {seconds}s - tree height now {Game.TreeHeight}" );
}
/// <summary>Jump straight to a pump by its 1-108 number, to eyeball a tier's numbers.</summary>
[ConCmd( "pump_goto" )]
public static void Goto( int pumpNumber )
{
if ( Missing() ) return;
Game.JumpToPump( pumpNumber - 1 );
var pump = Game.CurrentPump;
Log.Info( $"{pump.Position} - {pump.Name} ({pump.Tier.Name})" );
Log.Info( $" cost {Numbers.Short( pump.Cost )} | {Numbers.Short( pump.PerClick )}L per click | " +
$"{Numbers.Short( pump.PerSecond )}L/s | tank {Numbers.Short( pump.Tank )}L | " +
$"{Numbers.Short( pump.PricePerLitre )} per litre" );
}
/// <summary>Drop coins in your pocket to test the upgrade curve.</summary>
[ConCmd( "pump_coins" )]
public static void Coins( double amount )
{
if ( Missing() ) return;
Game.AddCoins( amount );
Log.Info( $"Coins now {Game.Coins:0.##}" );
}
/// <summary>Dump the HUD's computed pointer-events, to check the UI is clickable.</summary>
[ConCmd( "pump_ui" )]
public static void Ui()
{
if ( Missing() ) return;
var hud = Game.Scene.GetAllComponents<GameHud>().FirstOrDefault();
if ( hud?.Panel is null )
{
Log.Warning( "No GameHud panel found." );
return;
}
// Anything reporting None here is dead to the mouse, along with everything
// inside it - that is what makes the whole UI unclickable. The rect comes along
// because the effects layer is only correct if it sits exactly on the pump, and
// that is a measurement rather than something to eyeball in a screenshot.
var watched = new[] { "hud", "topbar", "stage", "pumpwrap", "pumparea", "fx", "pressure",
"nameplate", "actions", "collection", "shoppanel", "settingspanel" };
Log.Info( $"root pe={hud.Panel.ComputedStyle?.PointerEvents}" );
foreach ( var panel in hud.Panel.Descendants )
{
foreach ( var name in watched )
{
if ( panel.HasClass( name ) )
{
var style = panel.ComputedStyle;
var rect = panel.Box.Rect;
Log.Info( $" .{name,-14} pe={style?.PointerEvents} z={style?.ZIndex?.ToString() ?? "auto"} " +
$"centre=({rect.Center.x:0},{rect.Center.y:0}) size=({rect.Width:0}x{rect.Height:0})" );
}
}
}
}
/// <summary>
/// Check the open UI for overlapping elements. Reads the real laid-out rectangles
/// rather than eyeballing a screenshot, so "nothing overlaps" is a measurement.
/// </summary>
[ConCmd( "pump_layout" )]
public static void Layout()
{
if ( Missing() ) return;
var hud = Game.Scene.GetAllComponents<GameHud>().FirstOrDefault();
if ( hud?.Panel is null )
{
Log.Warning( "No GameHud panel found." );
return;
}
var problems = 0;
// Rows in a list must stack, never sit on top of one another.
foreach ( var list in hud.Panel.Descendants.Where( x => x.HasClass( "tracks" ) || x.HasClass( "rows" ) ) )
{
var rows = list.Children.ToList();
Log.Info( $"list .{(list.HasClass( "tracks" ) ? "tracks" : "rows")}: {rows.Count} rows" );
for ( var i = 1; i < rows.Count; i++ )
{
var above = rows[i - 1].Box.Rect;
var below = rows[i].Box.Rect;
if ( below.Top < above.Bottom - 0.5f )
{
problems++;
Log.Warning( $" OVERLAP rows {i - 1}/{i}: bottom {above.Bottom:0.#} > top {below.Top:0.#}" );
}
}
}
// Inside a row, the text block must not run under the button.
foreach ( var row in hud.Panel.Descendants.Where( x => x.HasClass( "track" ) ) )
{
var info = row.Children.FirstOrDefault( x => x.HasClass( "info" ) );
var buy = row.Children.FirstOrDefault( x => x.HasClass( "buy" ) );
if ( info is null || buy is null ) continue;
if ( info.Box.Rect.Right > buy.Box.Rect.Left + 0.5f )
{
problems++;
Log.Warning( $" OVERLAP text/button: text ends {info.Box.Rect.Right:0.#}, button starts {buy.Box.Rect.Left:0.#}" );
}
}
Log.Info( problems == 0 ? "Layout clean - no overlaps." : $"{problems} overlap(s) found." );
}
/// <summary>
/// Sweep the whole live panel tree for the visual faults you would otherwise have to
/// spot by eye: anything hanging off the edge of the screen, anything laid out to
/// nothing, and anything sitting on top of a control the player has to be able to hit.
/// </summary>
[ConCmd( "pump_scan" )]
public static void Scan()
{
if ( Missing() ) return;
var hud = Game.Scene.GetAllComponents<GameHud>().FirstOrDefault();
if ( hud?.Panel is null )
{
Log.Warning( "No GameHud panel found." );
return;
}
var panels = hud.Panel.Descendants.ToList();
// The component's own root panel lays out to zero width, so it is useless as a
// bounds reference - .hud is the thing that actually fills the screen.
var root = panels.FirstOrDefault( x => x.HasClass( "hud" ) );
if ( root is null )
{
Log.Warning( "No .hud panel found." );
return;
}
var screen = root.Box.Rect;
var problems = 0;
// Things the player must be able to click. Anything opaque drawn over one of
// these is a dead button, which is the worst bug this game can have.
var mustBeHittable = new[] { "pumparea", "bigbutton", "iconbutton", "treebtn", "gusher", "daily" };
// Descendants come back in tree order, which is also paint order - a panel can
// only steal a click from one that appears before it. Without this the check
// reports every button as covered by the .stage it happens to sit over.
var order = new Dictionary<Sandbox.UI.Panel, int>();
for ( var i = 0; i < panels.Count; i++ ) order[panels[i]] = i;
// While a modal is up, everything behind it is blocked on purpose. Scan inside
// the modal instead of reporting the whole HUD as broken.
var modal = panels.FirstOrDefault( x =>
x.HasClass( "shoppanel" ) || x.HasClass( "settingspanel" ) || x.HasClass( "treepanel" ) );
if ( modal is not null )
{
Log.Info( $" (modal open: {Describe( modal )} - only its own controls are checked)" );
}
foreach ( var panel in panels )
{
var rect = panel.Box.Rect;
var style = panel.ComputedStyle;
var name = Describe( panel );
// Invisible things are allowed to be anywhere.
if ( style?.Opacity is 0f ) continue;
if ( rect.Width <= 0f || rect.Height <= 0f ) continue;
// Off the edge. The fx anchor is deliberately zero-sized and its children
// fly outward by design, so particles are not a fault - and neither is a
// row sitting below the fold inside a scrolling list, which is what the
// shop's worker and background tabs are made of.
var off = rect.Left < screen.Left - 1f || rect.Top < screen.Top - 1f ||
rect.Right > screen.Right + 1f || rect.Bottom > screen.Bottom + 1f;
if ( off && !IsEffect( panel ) && !IsClipped( panel ) )
{
problems++;
Log.Warning( $" OFFSCREEN {name} rect=({rect.Left:0},{rect.Top:0})-({rect.Right:0},{rect.Bottom:0}) screen=({screen.Width:0}x{screen.Height:0})" );
}
}
// Now the coverage test, which is the one that actually matters.
var targets = panels.Where( p => mustBeHittable.Any( p.HasClass ) );
if ( modal is not null )
{
targets = targets.Where( p => modal.IsAncestor( p ) );
}
foreach ( var target in targets )
{
var t = target.Box.Rect;
if ( t.Width <= 0f || t.Height <= 0f ) continue;
foreach ( var other in panels )
{
if ( other == target || other.IsAncestor( target ) || target.IsAncestor( other ) )
continue;
// Only something painted later can take the click.
if ( order[other] < order[target] ) continue;
var style = other.ComputedStyle;
if ( style?.PointerEvents is not Sandbox.UI.PointerEvents.All ) continue;
if ( style?.Opacity is 0f ) continue;
var o = other.Box.Rect;
if ( o.Width <= 0f || o.Height <= 0f ) continue;
// Does it swallow the middle of the control?
var c = t.Center;
if ( c.x >= o.Left && c.x <= o.Right && c.y >= o.Top && c.y <= o.Bottom )
{
problems++;
Log.Warning( $" COVERED {Describe( target )} centre is under {Describe( other )}" );
}
}
}
// Live effect panels. These must fall back to zero once nothing is animating -
// a leftover here is a number or a droplet frozen on screen, which happens if
// the HUD stops rebuilding before the last one is cleared.
var fx = panels.Count( x => x.HasClass( "particle" ) || x.HasClass( "floater" ) );
Log.Info( $" fx panels live: {fx}" );
Log.Info( problems == 0
? $"Scan clean - {panels.Count} panels, none offscreen, no control covered."
: $"{problems} problem(s) across {panels.Count} panels." );
}
/// <summary>
/// True when some ancestor clips this panel, so hanging past the screen edge is the
/// scroll container doing its job rather than a layout fault. A scroll container that
/// is *itself* off the screen is still reported - that one really would be lost.
/// </summary>
static bool IsClipped( Sandbox.UI.Panel panel )
{
for ( var p = panel.Parent; p is not null; p = p.Parent )
{
var overflow = p.ComputedStyle?.Overflow;
if ( overflow is Sandbox.UI.OverflowMode.Scroll or Sandbox.UI.OverflowMode.Hidden )
return true;
}
return false;
}
/// <summary>Particles and floaters leave the screen on purpose.</summary>
static bool IsEffect( Sandbox.UI.Panel panel ) =>
panel.HasClass( "particle" ) || panel.HasClass( "floater" ) || panel.HasClass( "fx" ) ||
panel.HasClass( "clickring" ) || panel.HasClass( "surgering" ) || panel.HasClass( "celebrate-ring" ) ||
panel.HasClass( "mote" ) || panel.HasClass( "cloud" ) || panel.HasClass( "sun" ) ||
panel.HasClass( "sun-rays" ) || panel.HasClass( "dune" ) || panel.HasClass( "floor" ) ||
panel.HasClass( "shimmer" ) || panel.HasClass( "scenery" ) || panel.HasClass( "stars" ) ||
panel.HasClass( "tank-wave" ) || panel.HasClass( "shine" ) || panel.HasClass( "heatring" ) ||
panel.HasClass( "glow" ) || panel.HasClass( "coinflash" ) || panel.HasClass( "gusher-ring" );
/// <summary>A readable name for a panel - its classes, or its element type.</summary>
static string Describe( Sandbox.UI.Panel panel )
{
var classes = string.Join( ".", panel.Class );
return string.IsNullOrEmpty( classes ) ? panel.ElementName : "." + classes;
}
/// <summary>Open a UI panel: shop, workers, bg, settings, tree, or none.</summary>
[ConCmd( "pump_panel" )]
public static void Panel( string which = "none" )
{
if ( Missing() ) return;
var hud = Game.Scene.GetAllComponents<GameHud>().FirstOrDefault();
if ( hud is null )
{
Log.Warning( "No GameHud in the scene." );
return;
}
hud.ShowPanel( which );
var shop = hud.Panel?.Descendants.OfType<ShopPanel>().FirstOrDefault();
shop?.ShowTab( which switch { "bg" => 1, "workers" => 2, _ => 0 } );
Log.Info( $"Panel -> {which}" );
}
/// <summary>Wipe the save and start again from the hand pump.</summary>
[ConCmd( "pump_reset" )]
public static void Reset()
{
if ( Missing() ) return;
Game.ResetProgress();
Log.Info( "Progress wiped." );
}
}