Adventure mode run system for the game. Manages states for flushing a fish into toilet pipes, selecting a map and team, resolving rooms (look, loot, hazard, fight, choice), handling ambushes, rewards, injuries, faucet return and saving/loading progress.
namespace NoChillquarium;
public enum LotRunPhase
{
Closed,
/// <summary>Toilet flush cutscene — Adventure Mode cold open.</summary>
Flush,
/// <summary>Pick a map location.</summary>
Map,
/// <summary>Pick fish team for the run.</summary>
Team,
/// <summary>Standing in a room — act or continue.</summary>
Room,
/// <summary>After a fight resolve, before next room.</summary>
RoomResult,
/// <summary>Died on adventure — spit back out the kitchen faucet.</summary>
Faucet,
/// <summary>Run complete or wiped (post-faucet / normal maps).</summary>
Done
}
/// <summary>
/// Adventure Mode: flush a fish → toilet-pipe crawl → more lot dungeons
/// (jr high, alley, neighbor pool, gas, storm drain, bingo basement).
/// </summary>
public static class LotRunSystem
{
public const string ToiletLocationId = "toilet_pipes";
// One-beat cutscenes — click once and crawl (don't force a novella).
static readonly string[] FlushLines =
{
"Handle pulled. They're in the pipes under the lot. Follow or they don't come back easy."
};
/// <summary>Wipe only — die on adventure → always faucet home.</summary>
static readonly string[] FaucetLines =
{
"Kitchen faucet knocks. Hurt, angry, alive. Grandma doesn't look up from the sink."
};
/// <summary>Random ambush enemies when Look/Loot/Hazard turns into a skirmish.</summary>
static readonly string[] AmbushNames =
{
"Drain Runt", "Lot Skater", "Pipe Bully", "Silt Gang", "Alley Fry",
"Wet Ratfish", "Gutter Spec", "Trap Gremlin", "Scale Flake", "Mop Eel"
};
static readonly Random _rng = new();
static readonly HashSet<string> _cleared = new( StringComparer.OrdinalIgnoreCase );
static readonly List<string> _selectedIds = new();
static readonly List<string> _log = new();
static LotRunPhase _phase = LotRunPhase.Closed;
static LotLocationDef _location;
static int _roomIndex;
static int _version;
static string _roomResultTitle = "";
static string _roomResultBody = "";
static string _doneTitle = "";
static string _doneBody = "";
static double _runEarnings;
static bool _runWon;
static string _lastEnemyName = "";
/// <summary>Player has flushed once — Adventure map is open.</summary>
static bool _hasFlushed;
static int _flushLine;
static string _flushFishId = "";
static string _flushFishName = "";
static int _faucetLine;
/// <summary>True while a flushed fish is away on a crawl (must faucet-return on wipe/bail).</summary>
static bool _fishAwayOnAdventure;
public static int Version => _version;
public static LotRunPhase Phase => _phase;
public static bool IsOpen => _phase != LotRunPhase.Closed;
public static bool IsFlushing => _phase == LotRunPhase.Flush;
public static bool IsFaucetReturn => _phase == LotRunPhase.Faucet;
public static LotLocationDef Location => _location;
public static int RoomIndex => _roomIndex;
public static int RoomCount => _location?.Rooms?.Count ?? 0;
public static LotRoomDef CurrentRoom =>
_location?.Rooms is null || _roomIndex < 0 || _roomIndex >= _location.Rooms.Count
? null
: _location.Rooms[_roomIndex];
public static IReadOnlyList<string> SelectedIds => _selectedIds;
public static int SelectedCount => _selectedIds.Count;
public static IReadOnlyList<string> Log => _log;
public static string RoomResultTitle => _roomResultTitle;
public static string RoomResultBody => _roomResultBody;
public static string DoneTitle => _doneTitle;
public static string DoneBody => _doneBody;
public static double RunEarnings => _runEarnings;
public static bool RunWon => _runWon;
public static string LastEnemyName => _lastEnemyName;
/// <summary>Selected team combat power (END/SPD/AGI) — train raises this.</summary>
public static float SelectedTeamPower => TeamPower();
/// <summary>Ð bagged this crawl (shown in room header so risk/reward is always visible).</summary>
public static string RunEarningsLabel =>
_runEarnings > 0.01
? "bag Ð" + Economy.FormatDoge( _runEarnings )
: "bag Ð0";
public static bool HasFlushed => _hasFlushed;
public static int FlushLineIndex => _flushLine;
public static int FlushLineCount => FlushLines.Length;
public static string FlushFishName =>
string.IsNullOrEmpty( _flushFishName ) ? "your fish" : _flushFishName;
public static string FlushVisibleLine
{
get
{
if ( _flushLine < 0 || _flushLine >= FlushLines.Length )
return "";
if ( _flushLine == 0 && !string.IsNullOrEmpty( _flushFishName ) )
return _flushFishName + " swirls down. Pipes under the lot — follow or they don't come back easy.";
return FlushLines[_flushLine];
}
}
public static int FaucetLineIndex => _faucetLine;
public static int FaucetLineCount => FaucetLines.Length;
public static string FaucetVisibleLine
{
get
{
if ( _faucetLine < 0 || _faucetLine >= FaucetLines.Length )
return "";
if ( _faucetLine == 0 && !string.IsNullOrEmpty( _flushFishName ) )
return "Faucet knocks. " + _flushFishName + " comes through — hurt, angry, alive.";
return FaucetLines[_faucetLine];
}
}
/// <summary>Player-facing room kind label (short, not UI enum noise).</summary>
public static string RoomKindLabel
{
get
{
var room = CurrentRoom;
if ( room is null )
return "";
return room.Kind switch
{
LotRoomKind.Look => "SCOUT",
LotRoomKind.Fight => "FIGHT",
LotRoomKind.Loot => "LOOT",
LotRoomKind.Hazard => "HAZARD",
LotRoomKind.Choice => "CHOICE",
LotRoomKind.Boss => "BOSS",
_ => ""
};
}
}
public static string RoomKindCss
{
get
{
var room = CurrentRoom;
if ( room is null )
return "";
return room.Kind switch
{
LotRoomKind.Look => "kind-look",
LotRoomKind.Fight => "kind-fight",
LotRoomKind.Loot => "kind-loot",
LotRoomKind.Hazard => "kind-hazard",
LotRoomKind.Choice => "kind-choice",
LotRoomKind.Boss => "kind-boss",
_ => ""
};
}
}
public static bool FishAwayOnAdventure => _fishAwayOnAdventure;
public static string ProgressLabel
{
get
{
if ( _location is null || RoomCount <= 0 )
return "";
var n = Math.Clamp( _roomIndex + 1, 1, RoomCount );
return n + "/" + RoomCount;
}
}
public static bool IsCleared( string locationId ) =>
!string.IsNullOrEmpty( locationId ) && _cleared.Contains( locationId );
/// <summary>
/// Flush / Tools → Adventure after the ladder: care → fusion intro → fight intro → adventure intro.
/// Last gift in the lot cast sequence.
/// </summary>
public static bool Unlocked
{
get
{
if ( !TankSim.IsActive || !GrandmaGifts.HasTank )
return false;
if ( TankSim.TotalFishCount < 1 )
return false;
// Already mid-adventure (flushed before) — stay open.
if ( _hasFlushed )
return true;
// Soft unlock: adventure intro gift (or long save seed).
return GrandmaGifts.HasAdventureIntro;
}
}
public static string UnlockHint
{
get
{
if ( !GrandmaGifts.HasTank )
return "Need the tank first";
if ( TankSim.TotalFishCount < 1 )
return "Need a fish to flush";
if ( !StorySystem.HasFedOnce )
return "Feed once first";
var fusionDone = GrandmaGifts.HasReceived( "janitor_fusion" ) || StorySystem.HasCombinedOnce;
var fightDone = GrandmaGifts.HasReceived( "lot_bully_fight" )
|| BattleSystem.Wins + BattleSystem.Losses > 0;
if ( !fusionDone )
return "Combine first · then Adventure";
if ( !fightDone )
return "Fight once · then Adventure";
if ( !GrandmaGifts.HasAdventureIntro )
return "Adventure intro coming…";
return "";
}
}
/// <summary>Full map after first toilet adventure; before that only flush path.</summary>
public static bool MapUnlocked => _hasFlushed;
public static bool IsLocationUnlocked( LotLocationDef loc )
{
if ( loc is null || !Unlocked )
return false;
// Toilet pipes: map only after you've flushed once (re-runs).
if ( loc.FlushOrigin )
return _hasFlushed;
if ( !_hasFlushed )
return false;
if ( loc.RequireTrained && !StorySystem.HasTrainedOnce )
return false;
if ( Economy.PlaytimeSeconds < loc.UnlockPlaytime )
return false;
var fights = BattleSystem.Wins + BattleSystem.Losses;
if ( fights < loc.UnlockFights )
return false;
return true;
}
public static string LocationLockHint( LotLocationDef loc )
{
if ( loc is null )
return "";
if ( !Unlocked )
return UnlockHint;
if ( loc.FlushOrigin && !_hasFlushed )
return "Flush a fish to start Adventure";
if ( !_hasFlushed )
return "Flush a fish first (fish menu → Flush)";
if ( loc.RequireTrained && !StorySystem.HasTrainedOnce )
return "Train once first";
if ( Economy.PlaytimeSeconds < loc.UnlockPlaytime )
{
var left = loc.UnlockPlaytime - Economy.PlaytimeSeconds;
var m = Math.Max( 1, (int)MathF.Ceiling( left / 60f ) );
return "Play ~" + m + "m more";
}
var fights = BattleSystem.Wins + BattleSystem.Losses;
if ( fights < loc.UnlockFights )
return "Fight " + (loc.UnlockFights - fights) + " more bout(s)";
return "";
}
/// <summary>Map card risk/reward line (always plain C# — no Ð@ in Razor).</summary>
public static string LocationRewardSummary( LotLocationDef loc )
{
if ( loc is null )
return "";
var rooms = loc.Rooms?.Count ?? 0;
var fee = loc.EntryFee <= 0.01
? "free entry"
: "Ð" + Economy.FormatDoge( loc.EntryFee ) + " entry";
var clear = loc.ClearBonus > 0
? " · clear ~Ð" + Economy.FormatDoge( loc.ClearBonus )
: "";
var first = !IsCleared( loc.Id ) && loc.ClearBonus > 0
? " · first-clear bonus"
: "";
return fee + " · " + rooms + " rooms" + clear + first + " · team " + loc.TeamSize;
}
/// <summary>
/// Room stakes line: expected loot / fight odds / hazard risk.
/// Keeps Adventure from feeling like blind button mashing.
/// </summary>
public static string RoomStakesLabel
{
get
{
var room = CurrentRoom;
if ( room is null )
return "";
var power = TeamPower();
return room.Kind switch
{
LotRoomKind.Look => "Scout · sometimes ambush · train = safer",
LotRoomKind.Loot => LootStakes( room ),
LotRoomKind.Hazard => HazardStakes( room ),
LotRoomKind.Fight or LotRoomKind.Boss => FightStakes( room, power ),
LotRoomKind.Choice => ChoiceStakes( room ),
_ => ""
};
}
}
static string LootStakes( LotRoomDef room )
{
var lo = room.LootMin;
var hi = Math.Max( lo, room.LootMax );
return "Loot ~Ð" + Economy.FormatDoge( lo ) + "–" + Economy.FormatDoge( hi )
+ " · SPD/AGI scouts more";
}
static string HazardStakes( LotRoomDef room )
{
var chance = (int)MathF.Round( ScaleInjuryChance( room.InjuryChance ) * 100f );
return "Injury risk ~" + chance + "% · END/AGI shrugs pipes";
}
static string FightStakes( LotRoomDef room, float power )
{
var them = EstimateRoomThreat( room );
var ratio = power / MathF.Max( 0.5f, them );
var lo = room.FightRewardMin;
var hi = Math.Max( lo, room.FightRewardMax );
var purse = "purse ~Ð" + Economy.FormatDoge( lo ) + "–" + Economy.FormatDoge( hi );
var odds = ratio >= 1.15f ? "FAVOR"
: ratio >= 0.85f ? "EVEN"
: ratio >= 0.55f ? "UNDERDOG"
: "LONG SHOT";
var boss = room.Kind == LotRoomKind.Boss ? " · BOSS" : "";
return odds + boss + " · you " + power.ToString( "0.#" )
+ " vs ~" + them.ToString( "0.#" ) + " · " + purse;
}
static string ChoiceStakes( LotRoomDef room )
{
var a = room.ChoiceALoot > 0 ? "A ~Ð" + Economy.FormatDoge( room.ChoiceALoot ) : "A safe";
var b = room.ChoiceBLoot > 0 ? "B ~Ð" + Economy.FormatDoge( room.ChoiceBLoot ) : "B risky";
if ( room.ChoiceBInjuryChance > 0.05f )
b += " · injury";
return a + " · " + b;
}
/// <summary>Mid threat estimate (same scale as TeamPower / CombatPower).</summary>
public static float EstimateRoomThreat( LotRoomDef room )
{
if ( room is null )
return 8f;
var lo = room.AiStatMin > 0.1f ? room.AiStatMin : 2f;
var hi = room.AiStatMax > lo ? room.AiStatMax : lo + 1.4f;
var mid = (lo + hi) * 0.5f;
var pow = mid * 3.45f;
var n = Math.Max( 1, _selectedIds.Count );
pow *= 1f + (n - 1) * 0.28f;
if ( room.Kind == LotRoomKind.Boss )
pow *= 1.08f;
return MathF.Max( 1.5f, pow );
}
public static void ResetProgress()
{
_cleared.Clear();
_hasFlushed = false;
Clear();
}
public static void Clear()
{
// Safety: never leave a fish stuck in the pipes across sessions.
ReturnAllAdventureFish( quiet: true, applyInjury: false );
_phase = LotRunPhase.Closed;
_location = null;
_roomIndex = 0;
_selectedIds.Clear();
_log.Clear();
_roomResultTitle = "";
_roomResultBody = "";
_doneTitle = "";
_doneBody = "";
_runEarnings = 0;
_runWon = false;
_lastEnemyName = "";
_flushLine = 0;
_flushFishId = "";
_flushFishName = "";
_faucetLine = 0;
_fishAwayOnAdventure = false;
_version++;
}
public static void Load( SaveData data )
{
_cleared.Clear();
// Clear without double-return during load.
_phase = LotRunPhase.Closed;
_location = null;
_selectedIds.Clear();
_log.Clear();
_fishAwayOnAdventure = false;
_flushFishId = "";
_flushFishName = "";
_faucetLine = 0;
_hasFlushed = data?.AdventureFlushed == true;
if ( data?.LotRunsCleared is not null )
{
foreach ( var id in data.LotRunsCleared )
{
if ( !string.IsNullOrWhiteSpace( id ) )
_cleared.Add( id.Trim() );
}
}
if ( _cleared.Count > 0 )
_hasFlushed = true;
// Any fish flagged from a crashed session — home without injury spam.
ReturnAllAdventureFish( quiet: true, applyInjury: false );
_version++;
}
public static void WriteToSave( SaveData data )
{
if ( data is null )
return;
data.LotRunsCleared = _cleared.ToList();
data.AdventureFlushed = _hasFlushed;
}
// ---- Open / flush / close ----
/// <summary>Fish menu → Flush: Adventure Mode cold open.</summary>
public static bool TryFlushFish( string fishId )
{
if ( !TankSim.IsActive || !Unlocked )
{
TankSim.ShowBanner( string.IsNullOrEmpty( UnlockHint ) ? "Can't flush right now." : UnlockHint );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
var fish = TankSim.FindFish( fishId );
if ( fish is null || fish.Species is null )
{
TankSim.ShowBanner( "Pick a real fish." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( fish.IsFightInjured )
{
TankSim.ShowBanner( "Hurt fish — wait out cooldown before flushing." );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( GrandmaGifts.IsBusy || StorySystem.CardOpen )
return false;
if ( BattleSystem.IsOpen )
BattleSystem.Close();
if ( TrainingSystem.TrainOpen )
TrainingSystem.CancelTrain();
if ( TrainingSystem.InjectOpen )
TrainingSystem.CancelInject();
if ( ExplodeSystem.ModeActive )
ExplodeSystem.ModeActive = false;
_flushFishId = fish.InstanceId;
_flushFishName = fish.DisplayName;
_flushLine = 0;
_faucetLine = 0;
_phase = LotRunPhase.Flush;
_location = null;
_selectedIds.Clear();
_log.Clear();
_runEarnings = 0;
_runWon = false;
_fishAwayOnAdventure = false;
Chaos.Add( 2f );
Karma.Add( -0.5f ); // she noticed
GameAudio.PlayUi( GameAudio.Notice );
TankSim.ShowBanner( _flushFishName + " is in the pipes." );
_version++;
return true;
}
public static void AdvanceFlush()
{
if ( _phase != LotRunPhase.Flush )
return;
if ( _flushLine < FlushLines.Length - 1 )
{
_flushLine++;
GameAudio.PlayUi( GameAudio.Pop );
_version++;
return;
}
// End cutscene → toilet pipe run with that fish.
BeginToiletAdventure();
}
/// <summary>Wipe path: click through faucet return until fish is home.</summary>
public static void AdvanceFaucet()
{
if ( _phase != LotRunPhase.Faucet )
return;
if ( _faucetLine < FaucetLines.Length - 1 )
{
_faucetLine++;
GameAudio.PlayUi( GameAudio.Pop );
_version++;
return;
}
CompleteFaucetReturn();
}
static void BeginToiletAdventure()
{
var loc = LotLocationDef.Find( ToiletLocationId );
if ( loc is null )
{
Clear();
return;
}
var fish = TankSim.FindFish( _flushFishId );
if ( fish is null )
{
TankSim.ShowBanner( "Fish vanished before the swirl. Spooky." );
Clear();
return;
}
_hasFlushed = true;
_location = loc;
_selectedIds.Clear();
_selectedIds.Add( fish.InstanceId );
// Leave the glass — adventure until win (continue mode) or die (faucet).
fish.OnAdventure = true;
_fishAwayOnAdventure = true;
TankSim.BumpFrame();
_roomIndex = 0;
_runEarnings = 0;
_runWon = false;
_log.Clear();
_log.Add( FlushFishName + " · toilet pipes." );
_phase = LotRunPhase.Room;
GameAudio.PlayUi( GameAudio.Confirm );
TankSim.ShowBanner( "Adventure Mode · Toilet Pipes." );
SaveGame.TrySave( quiet: true );
_version++;
}
/// <summary>Tools → Adventure: map if flushed before; else tell player to flush.</summary>
public static bool TryOpen()
{
if ( !TankSim.IsActive )
return false;
if ( !Unlocked )
{
TankSim.ShowBanner( UnlockHint );
GameAudio.PlayUi( GameAudio.Error );
return false;
}
if ( GrandmaGifts.IsBusy || StorySystem.CardOpen )
return false;
if ( BattleSystem.IsOpen )
BattleSystem.Close();
if ( TrainingSystem.TrainOpen )
TrainingSystem.CancelTrain();
if ( TrainingSystem.InjectOpen )
TrainingSystem.CancelInject();
if ( ExplodeSystem.ModeActive )
ExplodeSystem.ModeActive = false;
if ( !_hasFlushed )
{
TankSim.ShowBanner( "Adventure starts dirty · click a fish → Flush" );
GameAudio.PlayUi( GameAudio.Notice );
return false;
}
_phase = LotRunPhase.Map;
_location = null;
_roomIndex = 0;
_selectedIds.Clear();
_log.Clear();
_runEarnings = 0;
_runWon = false;
GameAudio.PlayUi( GameAudio.Notice );
_version++;
return true;
}
public static void Close()
{
if ( _phase == LotRunPhase.Closed )
return;
// Mid-crawl with fish in the pipes — they still come home via faucet.
if ( _fishAwayOnAdventure
&& _phase is LotRunPhase.Room or LotRunPhase.RoomResult )
{
TankSim.ShowBanner( "Bailed. Faucet's gonna spit them out anyway." );
StartFaucetReturn(
"BAILED",
"You left them in the pipes. The faucet still brings them home." );
GameAudio.PlayUi( GameAudio.Close );
return;
}
if ( _phase is LotRunPhase.Team && _location is not null && _location.EntryFee <= 0 )
{
// no fee
}
else if ( _phase is LotRunPhase.Team or LotRunPhase.Room or LotRunPhase.RoomResult )
{
if ( _location is not null && _location.EntryFee > 0 )
TankSim.ShowBanner( "Bailed the run. Entry fee stays spent." );
}
if ( _phase == LotRunPhase.Flush )
TankSim.ShowBanner( "Flush cancelled. Fish stays in the tank. Coward." );
if ( _phase == LotRunPhase.Faucet )
{
// Force-complete faucet so fish isn't stuck away.
CompleteFaucetReturn();
return;
}
Clear();
GameAudio.PlayUi( GameAudio.Close );
}
public static void SelectLocation( LotLocationDef loc )
{
if ( _phase != LotRunPhase.Map || loc is null )
return;
if ( !IsLocationUnlocked( loc ) )
{
TankSim.ShowBanner( LocationLockHint( loc ) );
GameAudio.PlayUi( GameAudio.Error );
_version++;
return;
}
if ( Economy.Balance + 0.001 < loc.EntryFee )
{
TankSim.ShowBanner( "Need Ð" + Economy.FormatDoge( loc.EntryFee ) + " entry." );
GameAudio.PlayUi( GameAudio.Error );
_version++;
return;
}
_location = loc;
_selectedIds.Clear();
AutoPickTeam( loc.TeamSize );
_phase = LotRunPhase.Team;
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
public static void ToggleFish( string instanceId )
{
if ( _phase != LotRunPhase.Team || _location is null || string.IsNullOrEmpty( instanceId ) )
return;
var fish = TankSim.FindFish( instanceId );
if ( !CanBring( fish ) )
{
GameAudio.PlayUi( GameAudio.Error );
return;
}
if ( _selectedIds.Contains( instanceId ) )
{
_selectedIds.Remove( instanceId );
GameAudio.PlayUi( GameAudio.Pop );
_version++;
return;
}
if ( _selectedIds.Count >= _location.TeamSize )
{
// Replace weakest.
_selectedIds.RemoveAt( 0 );
}
_selectedIds.Add( instanceId );
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
public static bool CanBring( FishActor fish ) =>
fish is not null
&& fish.Species is not null
&& !fish.IsFightInjured
&& !fish.OnAdventure;
public static bool CanStartRun()
{
if ( _phase != LotRunPhase.Team || _location is null )
return false;
if ( _selectedIds.Count < _location.TeamSize )
return false;
if ( Economy.Balance + 0.001 < _location.EntryFee )
return false;
foreach ( var id in _selectedIds )
{
if ( !CanBring( TankSim.FindFish( id ) ) )
return false;
}
return true;
}
public static void StartRun()
{
if ( !CanStartRun() )
{
TankSim.ShowBanner( "Pick " + (_location?.TeamSize ?? 1) + " healthy fish · pay entry." );
GameAudio.PlayUi( GameAudio.Error );
_version++;
return;
}
if ( !Economy.TrySpend( _location.EntryFee ) )
{
GameAudio.PlayUi( GameAudio.Error );
_version++;
return;
}
_roomIndex = 0;
_runEarnings = 0;
_runWon = false;
_log.Clear();
_log.Add( "Entered " + _location.Name + " (−Ð" + Economy.FormatDoge( _location.EntryFee ) + ")." );
_phase = LotRunPhase.Room;
Chaos.Add( 1f );
GameAudio.PlayUi( GameAudio.Confirm );
_version++;
}
public static void BackToMap()
{
if ( _phase != LotRunPhase.Team )
return;
_location = null;
_selectedIds.Clear();
_phase = LotRunPhase.Map;
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
// ---- Room actions ----
public static void ActPrimary()
{
if ( _phase != LotRunPhase.Room )
return;
var room = CurrentRoom;
if ( room is null )
return;
switch ( room.Kind )
{
case LotRoomKind.Look:
// Sometimes the "scout" is an ambush — train or limp.
if ( TryAmbush( room, chance: 0.34f, afterWin: AmbushAfter.Continue ) )
return;
AdvanceRoom( "Moved on.", quiet: true );
break;
case LotRoomKind.Loot:
// Fight for the pocket sometimes.
if ( TryAmbush( room, chance: 0.22f, afterWin: AmbushAfter.Loot ) )
return;
DoLoot( room );
break;
case LotRoomKind.Hazard:
// Living hazard: fight instead of pure scrape roll.
if ( TryAmbush( room, chance: 0.18f, afterWin: AmbushAfter.HazardSafe ) )
return;
DoHazard( room );
break;
case LotRoomKind.Fight:
case LotRoomKind.Boss:
DoFight( room, isBoss: room.Kind == LotRoomKind.Boss );
break;
case LotRoomKind.Choice:
// Need A/B — primary defaults to A
DoChoice( room, pickB: false );
break;
}
}
public static void ActChoiceA()
{
if ( _phase != LotRunPhase.Room || CurrentRoom?.Kind != LotRoomKind.Choice )
return;
DoChoice( CurrentRoom, pickB: false );
}
public static void ActChoiceB()
{
if ( _phase != LotRunPhase.Room || CurrentRoom?.Kind != LotRoomKind.Choice )
return;
DoChoice( CurrentRoom, pickB: true );
}
public static void ContinueFromRoomResult()
{
if ( _phase != LotRunPhase.RoomResult )
return;
if ( _runWon || AliveTeamCount() <= 0 )
{
// Done already staged
return;
}
_roomIndex++;
if ( _location is null || _roomIndex >= _location.Rooms.Count )
{
FinishWin();
return;
}
_phase = LotRunPhase.Room;
_roomResultTitle = "";
_roomResultBody = "";
_version++;
}
public static void FinishToMap()
{
if ( _phase != LotRunPhase.Done )
return;
_phase = LotRunPhase.Map;
_location = null;
_selectedIds.Clear();
_roomIndex = 0;
_log.Clear();
_doneTitle = "";
_doneBody = "";
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
// ---- Resolve helpers ----
enum AmbushAfter
{
Continue,
Loot,
HazardSafe
}
/// <summary>
/// Sometimes a non-fight room is a skirmish. Stats decide win / pay / injury.
/// Returns true if an ambush was resolved (caller should not also resolve the room).
/// </summary>
static bool TryAmbush( LotRoomDef room, float chance, AmbushAfter afterWin )
{
if ( room is null || chance <= 0f )
return false;
if ( _rng.NextDouble() >= chance )
return false;
// Scale ambush AI from map room index + any fight stats on this room.
var baseMin = room.AiStatMin > 0.1f ? room.AiStatMin : 1.5f;
var baseMax = room.AiStatMax > baseMin ? room.AiStatMax : baseMin + 1.4f;
// Soft rooms (look/loot) fight slightly weaker pests.
if ( room.Kind is LotRoomKind.Look or LotRoomKind.Loot )
{
baseMin = MathF.Max( 1.2f, baseMin * 0.85f );
baseMax = MathF.Max( baseMin + 0.6f, baseMax * 0.9f );
}
var synth = new LotRoomDef
{
Title = "Ambush",
Body = "Something mean in the dark.",
Kind = LotRoomKind.Fight,
Button = "Fight",
AiStatMin = baseMin,
AiStatMax = baseMax,
EnemyNames = room.EnemyNames is { Length: > 0 } ? room.EnemyNames : AmbushNames,
EnemyColor = room.EnemyColor ?? "#607080",
FightRewardMin = Math.Max( 4, room.FightRewardMin > 0 ? room.FightRewardMin * 0.55 : 6 ),
FightRewardMax = Math.Max( 8, room.FightRewardMax > 0 ? room.FightRewardMax * 0.65 : 14 )
};
var won = ResolveFightCore( synth, isBoss: false, out var pay, out var margin );
if ( won )
{
// Ambush win: pay, then maybe the room reward too.
if ( afterWin == AmbushAfter.Loot )
{
var loot = ScaleLoot( Roll( room.LootMin, room.LootMax ) );
if ( loot > 0 )
{
Economy.Add( loot );
_runEarnings += loot;
}
ShowRoomResult(
"Ambush loot",
"Beat " + _lastEnemyName + ". +Ð" + Economy.FormatDoge( pay )
+ (loot > 0 ? " · pocket +Ð" + Economy.FormatDoge( loot ) : "")
+ StatMarginNote( margin ) + "." );
GameAudio.PlayDogeBark();
}
else if ( afterWin == AmbushAfter.HazardSafe )
{
ShowRoomResult(
"Fought through",
"Beat " + _lastEnemyName + ". +Ð" + Economy.FormatDoge( pay )
+ " · no scrape" + StatMarginNote( margin ) + "." );
GameAudio.PlayUi( GameAudio.Confirm );
}
else
{
ShowRoomResult(
"Ambush",
"Beat " + _lastEnemyName + ". +Ð" + Economy.FormatDoge( pay )
+ StatMarginNote( margin ) + "." );
GameAudio.PlayUi( GameAudio.Confirm );
}
QueueAdvanceAfterResult();
}
// Loss path already queued result / wipe inside ResolveFightCore.
return true;
}
static void DoLoot( LotRoomDef room )
{
var raw = Roll( room.LootMin, room.LootMax );
var amt = ScaleLoot( raw );
if ( amt > 0 )
{
Economy.Add( amt );
_runEarnings += amt;
}
var scout = TeamScoutNorm();
var tip = scout >= 0.55f ? " · quick fins" : scout < 0.35f ? " · slow hands" : "";
_log.Add( "Loot +Ð" + Economy.FormatDoge( amt ) );
ShowRoomResult( "Loot", "+" + "Ð" + Economy.FormatDoge( amt ) + tip + ". " + (room.Body ?? "") );
GameAudio.PlayDogeBark();
Karma.Add( 0.3f );
QueueAdvanceAfterResult();
}
static void DoHazard( LotRoomDef room )
{
Chaos.Add( room.ChaosOnHazard );
// Tough fish (END/AGI) shrug pipes; soft fish get scraped.
var chance = ScaleInjuryChance( room.InjuryChance );
var hurt = false;
if ( _rng.NextDouble() < chance )
hurt = InjureRandomTeamMember();
var tough = TeamToughnessNorm();
var body = hurt
? "Ouch. One fish is hurt (fight cooldown)."
: tough >= 0.55f
? "Trained body. Scraped through clean."
: "Scraped through. Barely.";
_log.Add( hurt ? "Hazard · injury" : "Hazard · ok" );
ShowRoomResult( room.Title ?? "Hazard", body );
GameAudio.PlayUi( hurt ? GameAudio.Error : GameAudio.Pop );
if ( AliveTeamCount() <= 0 )
{
FinishWipe( "Team wiped on a hazard. Lot laughs." );
return;
}
QueueAdvanceAfterResult();
}
static void DoChoice( LotRoomDef room, bool pickB )
{
var result = pickB ? room.ChoiceBResult : room.ChoiceAResult;
var loot = pickB ? room.ChoiceBLoot : room.ChoiceALoot;
var chaos = pickB ? room.ChoiceBChaos : room.ChoiceAChaos;
var injChance = pickB ? room.ChoiceBInjuryChance : 0f;
if ( loot > 0 )
{
loot = ScaleLoot( loot );
Economy.Add( loot );
_runEarnings += loot;
}
if ( chaos > 0 )
Chaos.Add( chaos );
var hurt = injChance > 0
&& _rng.NextDouble() < ScaleInjuryChance( injChance )
&& InjureRandomTeamMember();
var body = (result ?? "Done.") + (loot > 0 ? " +Ð" + Economy.FormatDoge( loot ) + "." : "");
if ( hurt )
body += " Fish hurt.";
_log.Add( (pickB ? "Choice B" : "Choice A") + (loot > 0 ? " +Ð" + Economy.FormatDoge( loot ) : "") );
ShowRoomResult( room.Title ?? "Choice", body );
if ( loot > 0 )
GameAudio.PlayCoin();
else
GameAudio.PlayUi( GameAudio.Pop );
if ( AliveTeamCount() <= 0 )
{
FinishWipe( "Bad choice. Team down." );
return;
}
QueueAdvanceAfterResult();
}
static void DoFight( LotRoomDef room, bool isBoss )
{
var won = ResolveFightCore( room, isBoss, out var pay, out var margin );
if ( won )
{
ShowRoomResult(
isBoss ? "Boss down" : "Win",
"Beat " + _lastEnemyName + ". +Ð" + Economy.FormatDoge( pay )
+ StatMarginNote( margin ) + "." );
GameAudio.PlayUi( GameAudio.Confirm );
if ( isBoss )
_runWon = true;
QueueAdvanceAfterResult();
}
// Loss already handled in ResolveFightCore.
}
/// <summary>
/// Shared fight resolve. AI stats map into CombatPower-scale so training matters.
/// Returns win; pays on win; injures on loss. margin = player/ai power ratio.
/// </summary>
static bool ResolveFightCore( LotRoomDef room, bool isBoss, out double pay, out float margin )
{
pay = 0;
var playerPow = TeamPower();
var aiPow = RollAiPower( room );
margin = playerPow / MathF.Max( 0.5f, aiPow );
// Swing + ratio → real chance to lose if soft.
var swing = 0.82f + (float)_rng.NextDouble() * 0.36f;
var won = playerPow * swing >= aiPow;
_lastEnemyName = PickName( room.EnemyNames );
if ( won )
{
var raw = Roll( room.FightRewardMin, room.FightRewardMax );
// Trained / strong teams extract more Ð; weak squeak by with less.
var rewardMul = Math.Clamp( 0.55f + margin * 0.4f, 0.55f, 1.7f );
pay = Math.Round( raw * rewardMul, 1 );
if ( pay > 0 )
{
Economy.Add( pay );
_runEarnings += pay;
}
Karma.Add( isBoss ? 2f : 0.8f );
Chaos.Add( isBoss ? 2f : 1f );
_log.Add( "Win vs " + _lastEnemyName + " +Ð" + Economy.FormatDoge( pay ) );
return true;
}
// Loss: always injure one; very outmatched may injure a second if team >1.
InjureRandomTeamMember();
if ( margin < 0.55f && AliveTeamCount() > 1 && _rng.NextDouble() < 0.4 )
InjureRandomTeamMember();
Karma.Add( 0.4f );
Chaos.Add( isBoss ? 2.5f : 2f );
_log.Add( "Loss vs " + _lastEnemyName );
ShowRoomResult(
"Loss",
_lastEnemyName + " slapped your team. Fish hurt"
+ (margin < 0.7f ? " · train harder next time" : "") + "." );
GameAudio.PlayUi( GameAudio.Error );
if ( AliveTeamCount() <= 0 || isBoss )
{
FinishWipe( isBoss
? "Boss ended the run. " + _lastEnemyName + " owns this block."
: "Team wiped. Crawl home." );
return false;
}
QueueAdvanceAfterResult();
return false;
}
/// <summary>
/// Map AiStat (≈1–5 rating) into CombatPower space.
/// Untrained fish ~10 power; AiStat 3 → ~10; trained 10s ~34 crush early pests.
/// </summary>
static float RollAiPower( LotRoomDef room )
{
var lo = room?.AiStatMin ?? 2f;
var hi = room?.AiStatMax ?? 4f;
if ( hi < lo )
(hi, lo) = (lo, hi);
var rating = lo + (float)_rng.NextDouble() * MathF.Max( 0.1f, hi - lo );
// ~3.45× rating ≈ same formula weight as END+SPD+AGI at that level.
var pow = rating * 3.45f;
// Multi-fish teams face harder packs.
var n = Math.Max( 1, _selectedIds.Count );
pow *= 1f + (n - 1) * 0.28f;
// Boss rooms already have higher AiStat; tiny extra grit.
if ( room?.Kind == LotRoomKind.Boss )
pow *= 1.08f;
return MathF.Max( 1.5f, pow );
}
/// <summary>0–1 mean END/AGI of live team — hazard resistance.</summary>
static float TeamToughnessNorm()
{
var sum = 0f;
var n = 0;
foreach ( var id in _selectedIds )
{
var f = TankSim.FindFish( id );
if ( f is null || f.IsFightInjured )
continue;
sum += (f.EnduranceNorm + f.AgilityNorm) * 0.5f;
n++;
}
return n <= 0 ? 0.25f : Math.Clamp( sum / n, 0f, 1f );
}
/// <summary>0–1 mean SPD/AGI — loot scouting.</summary>
static float TeamScoutNorm()
{
var sum = 0f;
var n = 0;
foreach ( var id in _selectedIds )
{
var f = TankSim.FindFish( id );
if ( f is null || f.IsFightInjured )
continue;
sum += (f.SpeedNorm + f.AgilityNorm) * 0.5f;
n++;
}
return n <= 0 ? 0.25f : Math.Clamp( sum / n, 0f, 1f );
}
static float ScaleInjuryChance( float baseChance )
{
if ( baseChance <= 0f )
return 0f;
// Soft fish: up to ~1.15× injury. Jacked/trained: down to ~0.35×.
var tough = TeamToughnessNorm();
var mul = 1.15f - tough * 0.8f;
if ( TeamHasJacked() )
mul *= 0.75f;
return Math.Clamp( baseChance * mul, 0.04f, 0.92f );
}
static double ScaleLoot( double raw )
{
if ( raw <= 0 )
return 0;
// Soft: ~0.7×. Scouted: up to ~1.35×.
var scout = TeamScoutNorm();
var mul = 0.7 + scout * 0.65;
return Math.Round( raw * mul, 1 );
}
static bool TeamHasJacked()
{
foreach ( var id in _selectedIds )
{
var f = TankSim.FindFish( id );
if ( f is not null && !f.IsFightInjured && f.Jacked )
return true;
}
return false;
}
static string StatMarginNote( float margin )
{
if ( margin >= 1.6f )
return " · overpower";
if ( margin >= 1.15f )
return " · clean";
if ( margin >= 0.9f )
return " · close";
return " · lucky";
}
static void QueueAdvanceAfterResult()
{
_phase = LotRunPhase.RoomResult;
_version++;
}
static void AdvanceRoom( string note, bool quiet )
{
_log.Add( note );
_roomIndex++;
if ( _location is null || _roomIndex >= _location.Rooms.Count )
{
FinishWin();
return;
}
_phase = LotRunPhase.Room;
if ( !quiet )
GameAudio.PlayUi( GameAudio.Pop );
_version++;
}
/// <summary>After RoomResult, if boss won or last room.</summary>
public static void ContinueAfterResult()
{
if ( _phase != LotRunPhase.RoomResult )
return;
if ( AliveTeamCount() <= 0 )
{
FinishWipe( "Nobody left swimming." );
return;
}
var room = CurrentRoom;
var wasBoss = room?.Kind == LotRoomKind.Boss;
if ( _runWon || wasBoss )
{
// Only finish win if we actually beat the boss (flag set on boss win).
if ( _runWon )
{
FinishWin();
return;
}
// Boss loss already went to FinishWipe.
}
_roomIndex++;
if ( _location is null || _roomIndex >= _location.Rooms.Count )
{
FinishWin();
return;
}
_phase = LotRunPhase.Room;
_roomResultTitle = "";
_roomResultBody = "";
_version++;
}
static void FinishWin()
{
_runWon = true;
var bonus = _location?.ClearBonus ?? 0;
var first = _location is not null && !_cleared.Contains( _location.Id );
// First clear of a map pays better — risk of unknown rooms → real reward.
if ( first && bonus > 0 )
bonus = Math.Round( bonus * 1.35, 1 );
else if ( first && bonus <= 0 )
bonus = 12; // survival tip even if map forgot a ClearBonus
// Healthy trained survivors extract a little more on clear.
if ( bonus > 0 )
{
var clearMul = 0.9 + TeamToughnessNorm() * 0.25 + TeamScoutNorm() * 0.1;
bonus = Math.Round( bonus * clearMul, 1 );
Economy.Add( bonus );
_runEarnings += bonus;
}
var flushOrigin = _location?.FlushOrigin == true || _fishAwayOnAdventure;
if ( _location is not null )
{
_cleared.Add( _location.Id );
if ( first )
{
Karma.Add( 3.5f );
if ( !string.IsNullOrEmpty( _location.ClearBanner ) )
TankSim.ShowBanner( _location.ClearBanner + " +Ð" + Economy.FormatDoge( bonus ) );
else
TankSim.ShowBanner( _location.Name + " first clear! +Ð" + Economy.FormatDoge( bonus ) );
}
else
{
Karma.Add( 1f );
TankSim.ShowBanner( _location.Name + " cleared again. +Ð" + Economy.FormatDoge( bonus ) );
}
}
// Survive → fish comes home quietly and Adventure Mode continues (map).
if ( _fishAwayOnAdventure )
{
ReturnAllAdventureFish( quiet: false, applyInjury: false );
_flushFishId = "";
// Survived the flush path — no injury tax, extra tip for bringing them home.
var homeTip = first ? 8.0 : 3.0;
Economy.Add( homeTip );
_runEarnings += homeTip;
TankSim.ShowBanner( FlushFishName + " home. +Ð" + Economy.FormatDoge( homeTip ) );
}
_doneTitle = flushOrigin ? "PIPES CLEAR" : "MAP CLEAR";
var next = NextOpenMapHint();
_doneBody = "Bagged Ð" + Economy.FormatDoge( _runEarnings )
+ (bonus > 0 ? " · clear Ð" + Economy.FormatDoge( bonus ) : "")
+ (flushOrigin ? " · map board open" : "")
+ (string.IsNullOrEmpty( next ) ? "" : " · next: " + next)
+ ".";
_phase = LotRunPhase.Done;
GameAudio.PlayDogeBark();
SaveGame.TrySave( quiet: true );
_version++;
}
/// <summary>Soft "what next" after a clear — unlocked uncleared map, else first open map name.</summary>
static string NextOpenMapHint()
{
LotLocationDef firstOpen = null;
foreach ( var loc in LotLocationDef.Catalog )
{
if ( loc is null || !IsLocationUnlocked( loc ) )
continue;
if ( firstOpen is null )
firstOpen = loc;
if ( !IsCleared( loc.Id ) )
return loc.Name;
}
return firstOpen?.Name ?? "";
}
static void FinishWipe( string body )
{
_runWon = false;
// Died / wiped on adventure with fish away → always faucet return (they come home).
if ( _fishAwayOnAdventure )
{
StartFaucetReturn(
"DOWN",
( body ?? "Team lost." )
+ " Kept Ð" + Economy.FormatDoge( _runEarnings )
+ ". Faucet return next." );
return;
}
_doneTitle = "RUN OVER";
_doneBody = ( body ?? "" ) + " Kept Ð" + Economy.FormatDoge( _runEarnings ) + ".";
_phase = LotRunPhase.Done;
GameAudio.PlayUi( GameAudio.Error );
_version++;
}
static void StartFaucetReturn( string title, string body )
{
_doneTitle = title ?? "FAUCET";
_doneBody = body ?? "";
_faucetLine = 0;
_phase = LotRunPhase.Faucet;
GameAudio.PlayUi( GameAudio.Error );
_version++;
}
static void CompleteFaucetReturn()
{
// Died on adventure → home via faucet, benched but alive. Map stays open.
ReturnAllAdventureFish( quiet: false, applyInjury: true );
_doneTitle = string.IsNullOrEmpty( _doneTitle ) ? "HOME" : _doneTitle;
if ( string.IsNullOrEmpty( _doneBody ) )
_doneBody = FlushFishName + " is back in the tank via the kitchen faucet.";
else
_doneBody = _doneBody + " They're home. Adventure Mode continues when ready.";
_phase = LotRunPhase.Done;
TankSim.ShowBanner( FlushFishName + " returned via faucet." );
GameAudio.PlayUi( GameAudio.Confirm );
SaveGame.TrySave( quiet: true );
_version++;
}
static void ReturnAllAdventureFish( bool quiet, bool applyInjury )
{
foreach ( var f in TankSim.AllFish() )
{
if ( f is null || !f.OnAdventure )
continue;
f.OnAdventure = false;
// Wipe path: benched. Survive path: no extra injury.
if ( applyInjury && !f.IsFightInjured )
f.ApplyFightLossInjury();
}
_fishAwayOnAdventure = false;
TankSim.BumpFrame();
if ( !quiet )
_version++;
}
static void ShowRoomResult( string title, string body )
{
_roomResultTitle = title ?? "";
_roomResultBody = body ?? "";
}
static void AutoPickTeam( int size )
{
_selectedIds.Clear();
var picks = TankSim.Fish
.Where( CanBring )
.OrderByDescending( BattleSystem.CombatPower )
.Take( Math.Max( 1, size ) )
.Select( f => f.InstanceId );
foreach ( var id in picks )
_selectedIds.Add( id );
}
static float TeamPower()
{
var p = 0f;
foreach ( var id in _selectedIds )
{
var f = TankSim.FindFish( id );
if ( f is null || f.IsFightInjured )
continue;
// Adventure fish still fight even while hidden from the glass.
p += BattleSystem.CombatPower( f );
}
return MathF.Max( p, 0.01f );
}
static int AliveTeamCount()
{
var n = 0;
foreach ( var id in _selectedIds )
{
var f = TankSim.FindFish( id );
// On adventure: "dead" = all injured / missing.
if ( f is not null && !f.IsFightInjured )
n++;
}
return n;
}
static bool InjureRandomTeamMember()
{
var live = new List<FishActor>();
foreach ( var id in _selectedIds )
{
var f = TankSim.FindFish( id );
if ( f is not null && !f.IsFightInjured )
live.Add( f );
}
if ( live.Count == 0 )
return false;
var pick = live[_rng.Next( live.Count )];
pick.ApplyFightLossInjury();
return true;
}
static double Roll( double min, double max )
{
if ( max < min )
(max, min) = (min, max);
if ( max <= 0 )
return 0;
return min + _rng.NextDouble() * (max - min);
}
static string PickName( string[] names )
{
if ( names is null || names.Length == 0 )
return "Local Pest";
return names[_rng.Next( names.Length )];
}
}