perks/PerkChatInstruction.cs

A Perk class for a game called PerkChatInstruction (Voice of God). It periodically issues random tasks/instructions via local chat (type a message, don't move, touch arena edge, destroy tree, etc.), tracks countdowns, rewards on success, and applies punishments on failure (damage, spawn enemies, status modifiers). It also handles chat input, kills, hits and XP pickups to resolve tasks.

NetworkingFile Access
using System;
using Sandbox;

[Perk( Rarity.Unique, locked: true, alwaysOfferDebug: false )]
public class PerkChatInstruction : Perk
{
	private int _maxHpAdded;

	private float _instrTimer;
	private float _instrDelay;
	private string _reqMessage;
	private bool _isTaskActive;
	private ChatTaskType _taskType;
	private float _countdown;
	private float _countdownTotal;
	float CountdownTimeElapsed => _countdownTotal - _countdown;

	private ArenaSide _targetArenaSide;

	private enum ChatTaskType { SayMessage, DontMove, DontKill, ChopTree, TouchArenaSide, LetBasicZombieHurt, DontCollectXp, }
	private enum ArenaSide { Left, Right, Bottom, Top }

	private bool _isLockedInPlace;
	private float _lockedInPlaceCountdown;
	private const float LOCKED_IN_PLACE_TIME = 10f;

	private bool _areControlsReversed;
	private float _reversedControlsCountdown;
	private const float REVERSED_CONTROLS_TIME = 10f;

	public override float ImportanceMultiplier => 1.2f;

	static PerkChatInstruction()
	{
		Register<PerkChatInstruction>(
			name: "Voice of God",
			imagePath: "textures/icons/vector/chat_instructions.png",
			description: level => $"Obey God's instructions"
		);
	}

	public override void Start()
	{
		base.Start();

		ShouldUpdate = true;
		_isTaskActive = false;

		DisplayCooldownColor = new Color( 0.7f, 0.7f, 0.7f, 2f );
	}

	public override void IncreaseLevel()
	{
		base.IncreaseLevel();

		_instrDelay = Game.Random.Float( 0.5f, 1.2f );
	}

	public override void Refresh()
	{
		base.Refresh();

	}

	public override void Update( float dt )
	{
		base.Update( dt );

		if ( _isTaskActive )
		{
			switch ( _taskType )
			{
				case ChatTaskType.DontMove:
					if ( Player.IsMoving && CountdownTimeElapsed > 2f )
					{
						Fail( ranOutOfTime: false );
					}
					break;
				case ChatTaskType.TouchArenaSide:
					float BUFFER = Player.Radius * 1.2f;
					var pos = Player.Position2D;

					if ( (_targetArenaSide == ArenaSide.Left && pos.x < Manager.Instance.BOUNDS_MIN.x + BUFFER) ||
						(_targetArenaSide == ArenaSide.Right && pos.x > Manager.Instance.BOUNDS_MAX.x - BUFFER) ||
						(_targetArenaSide == ArenaSide.Bottom && pos.y < Manager.Instance.BOUNDS_MIN.x + BUFFER) ||
						(_targetArenaSide == ArenaSide.Top && pos.y > Manager.Instance.BOUNDS_MAX.y - BUFFER) )
					{
						Succeed();
					}
					break;
			}

			if ( _isTaskActive )
			{
				_countdown -= dt;
				if ( _countdown <= 0f )
				{
					CountdownFinished();
				}
			}
		}
		else
		{
			_instrTimer += dt;
			if ( _instrTimer > _instrDelay )
			{
				StartRandomTask();

				_instrTimer = 0f;
				_instrDelay = Game.Random.Float( 8f, 32f );

				// todo: better sfx
				Manager.Instance.PlaySfxUI( "god.question", pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f );

				HighlightColor = new Color( 1f, 1f, 1f );
				HighlightDuration = 1.25f;
				HighlightOpacity = 3f;
				Highlight();
			}
		}

		if ( _isTaskActive )
		{
			DisplayText = MathX.CeilToInt( _countdown ).ToString();
			DisplayCooldown = Utils.Map( _countdown, _countdownTotal, 0f, 1f, 0f );
		}

		if ( _isLockedInPlace )
		{
			_lockedInPlaceCountdown -= dt;
			if ( _lockedInPlaceCountdown < 0f )
			{
				Player.StopModifying( this, PlayerStat.CantMove );
				_isLockedInPlace = false;
			}
		}

		if ( _areControlsReversed )
		{
			_reversedControlsCountdown -= dt;
			if ( _reversedControlsCountdown < 0f )
			{
				Player.StopModifying( this, PlayerStat.ReverseMoveControls );
				_areControlsReversed = false;
			}
		}
	}

	void StartRandomTask()
	{
		_isTaskActive = true;

		// don't give chat typing instructions if game pauses while choosing or if player is using a controller
		if ( (Manager.Instance.IsUnpausedChoosing && !Input.UsingController) && Game.Random.Float(0f, 1f) < 0.125f ) 
		{
			StartSayMessageTask();
		}
		else
		{
			int rand = Game.Random.Int( 0, 7 );

			while ( rand == 2 && Player.Scene.GetAll<Tree>().Count() == 0 )
				rand = Game.Random.Int( 0, 7 );

			switch ( rand )
			{
				case 0: default: StartDontMoveTask(); break;
				case 1: StartDontKillTask(); break;
				case 2: StartChopTreeTask(); break;
				case 3: StartTouchArenaSideTask(); break;
				case 4: StartLetBasicZombieHurtTask(); break;
				case 5: StartDontCollectXpTask(); break;
			}
		}
	}

	void StartSayMessageTask()
	{
		_taskType = ChatTaskType.SayMessage;
		_countdown = _countdownTotal = Game.Random.Float( 9f, 17f );

		bool mathQuestion = Game.Random.Int( 0, 4 ) == 0;

		if ( mathQuestion )
		{
			int a = Game.Random.Int( 2, 16 );
			int b = Game.Random.Int( 2, 16 );

			string mathStr;
			int operation = Game.Random.Int( 0, 2 );
			switch ( operation )
			{
				case 0:
				default:
					_reqMessage = (a + b).ToString();
					mathStr = $"{a} + {b}";
					break;
				case 1:
					_reqMessage = (a - b).ToString();
					mathStr = $"{a} - {b}";
					break;
				case 2:
					_reqMessage = (a * b).ToString();
					mathStr = $"{a} x {b}";
					break;
			}

			AddLocalChatMessage( $"{SelectStr( "", "", "Math time! " )}What is {mathStr}?" );
		}
		else
		{
			_reqMessage = GetRandomReqMessage();
			AddLocalChatMessage( $"{SelectStr( "Say", "Type" )} \"{_reqMessage}\" in chat!{SelectStr( "", " Don't misspell it!" )}" );
		}
	}

	string GetRandomReqMessage()
	{
		int rand = Game.Random.Int( 0, 2 );
		switch ( rand )
		{
			case 0: default: return $"{SelectStr( "", "", "hi ", "hello " )}{SelectStr( ":)", ":-)", ":>)", ":>]", ":-]", ":3" )}";
			case 1: return SelectStr( $"i farted", $"{SelectStr( "i", "i", "u", "you" )} {SelectStr( "suck" )}", $"this game is {SelectStr( "cool", "fun", "dope", "awesome" )}", SelectStr( "sausage", "Sausage", "SAUSAGE", RandomizeCapitalization( "sausage" ) ) );
			case 2: return SelectStr( $"{SelectStr( "i'm", "im" )} not {SelectStr( "crazy", "crazy!" )}", $"{SelectStr( "HAHAHA", "HAHAHAHA" )}" );
		}
	}

	void StartDontMoveTask()
	{
		_taskType = ChatTaskType.DontMove;
		_countdown = _countdownTotal = Game.Random.Float( 6f, 10f );

		AddLocalChatMessage( SelectStr( $"Don't move!", $"Stop moving!", $"Don't you dare move an inch{SelectStr( ".", "!" )}", $"Don't move for {MathX.FloorToInt( _countdown )}s!" ) );
	}

	void StartDontKillTask()
	{
		_taskType = ChatTaskType.DontKill;
		_countdown = _countdownTotal = Game.Random.Float( 8f, 14f );

		AddLocalChatMessage( SelectStr( $"Don't kill anything!", $"Don't kill anything for {MathX.FloorToInt( _countdown )}s!" ) );
	}

	void StartChopTreeTask()
	{
		_taskType = ChatTaskType.ChopTree;
		_countdown = _countdownTotal = Game.Random.Float( 16f, 24f );

		AddLocalChatMessage( SelectStr( $"Destroy a tree{SelectStr( ".", "!" )}", $"Destroy a tree immediately{SelectStr( ".", "!" )}", $"Destroy a tree, now{SelectStr( ".", "!" )}", $"Your quest: destroy a tree{SelectStr( ".", "!" )}" ) );
	}

	void StartTouchArenaSideTask()
	{
		_taskType = ChatTaskType.TouchArenaSide;
		_countdown = _countdownTotal = Game.Random.Float( 18f, 27f );
		_targetArenaSide = (ArenaSide)Game.Random.Int( 0, Enum.GetValues( typeof( ArenaSide ) ).Length - 1 );

		float BIG_BUFFER = Player.Radius * 5f;
		var pos = Player.Position2D;

		if ( _targetArenaSide == ArenaSide.Left && pos.x < Manager.Instance.BOUNDS_MIN.x + BIG_BUFFER )
			_targetArenaSide = ArenaSide.Right;
		else if ( _targetArenaSide == ArenaSide.Right && pos.x > Manager.Instance.BOUNDS_MAX.x - BIG_BUFFER )
			_targetArenaSide = ArenaSide.Left;
		else if ( _targetArenaSide == ArenaSide.Bottom && pos.y < Manager.Instance.BOUNDS_MIN.x + BIG_BUFFER )
			_targetArenaSide = ArenaSide.Top;
		else if ( _targetArenaSide == ArenaSide.Top && pos.y > Manager.Instance.BOUNDS_MAX.y - BIG_BUFFER )
			_targetArenaSide = ArenaSide.Bottom;

		AddLocalChatMessage( $"Touch the {GetStringForSide( _targetArenaSide )} edge of the arena{SelectStr( ".", "!" )}" );
	}

	void StartLetBasicZombieHurtTask()
	{
		_taskType = ChatTaskType.LetBasicZombieHurt;
		_countdown = _countdownTotal = Game.Random.Float( 10f, 15f );

		AddLocalChatMessage( $"Let a basic Zombie {SelectStr( "hit", "punch", "bite" )} you{SelectStr( ".", "!" )}" );
	}

	void StartDontCollectXpTask()
	{
		_taskType = ChatTaskType.DontCollectXp;
		_countdown = _countdownTotal = Game.Random.Float( 10f, 16f );

		AddLocalChatMessage( $"Don't {SelectStr( "get", "collect", "pick up" )} any coins{SelectStr( ".", "!" )}" );
	}

	string GetStringForSide( ArenaSide arenaSide )
	{
		switch ( arenaSide )
		{
			case ArenaSide.Left: default: return "left";
			case ArenaSide.Right: return "right";
			case ArenaSide.Bottom: return "bottom";
			case ArenaSide.Top: return "top";
		}
	}

	public override void OnSayChat( string message )
	{
		if ( _isTaskActive && _taskType == ChatTaskType.SayMessage )
		{
			if ( _reqMessage.Equals( message ) )
			{
				Succeed();
			}
			else
			{
				Fail( ranOutOfTime: false );
			}
		}
	}

	public override void OnKill( Enemy enemy, DamageType damageType, bool countsAsKill )
	{
		base.OnKill( enemy, damageType, countsAsKill );

		if ( !_isTaskActive )
			return;

		if ( _taskType == ChatTaskType.DontKill && countsAsKill && CountdownTimeElapsed > 2f )
		{
			Fail( ranOutOfTime: false );
		}
		else if ( _taskType == ChatTaskType.ChopTree && enemy is Tree )
		{
			Succeed();
		}
	}

	public override void OnHit( float amount, DamageType damageType, bool isSelfInflicted, Vector2 dir, float force, Enemy enemySource, EnemyType enemyType, float previousHealth )
	{
		base.OnHit( amount, damageType, isSelfInflicted, dir, force, enemySource, enemyType, previousHealth );

		if ( !_isTaskActive )
			return;

		if ( _taskType == ChatTaskType.LetBasicZombieHurt && enemySource is Zombie )
		{
			Succeed();
		}
	}

	public override void OnGainXpCoin( float xp )
	{
		base.OnGainXpCoin( xp );

		if ( !_isTaskActive )
			return;

		if ( _taskType == ChatTaskType.DontCollectXp )
		{
			Fail( ranOutOfTime: false );
		}
	}

	void CountdownFinished()
	{
		switch ( _taskType )
		{
			case ChatTaskType.SayMessage: default: Fail( ranOutOfTime: true ); break;
			case ChatTaskType.DontMove: Succeed(); break;
			case ChatTaskType.DontKill: Succeed(); break;
			case ChatTaskType.DontCollectXp: Succeed(); break;
		}
	}

	void Succeed()
	{
		GiveRandomReward();

		// todo: better sfx
		Manager.Instance.PlaySfxUI( "god.correct", pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f );

		DisplayText = "✔️";
		DisplayCooldown = 0f;
		_isTaskActive = false;

		HighlightColor = new Color( 0f, 1f, 0f );
		HighlightDuration = 0.35f;
		HighlightOpacity = 3f;
		Highlight();
	}

	void Fail( bool ranOutOfTime )
	{
		GiveRandomPunishment( ranOutOfTime );

		// todo: better sfx
		Manager.Instance.PlaySfxUI( "god.wrong", pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f );

		DisplayText = "❌";
		DisplayCooldown = 0f;
		_isTaskActive = false;

		HighlightColor = new Color( 1f, 0f, 0f );
		HighlightDuration = 0.35f;
		HighlightOpacity = 3f;
		Highlight();
	}

	// todo: sometimes msg is too long and becomes 2 lines
	void GiveRandomReward()
	{
		string msgStart;
		switch ( _taskType )
		{
			case ChatTaskType.SayMessage:
			default:
				msgStart = SelectStr( $"Correct{SelectStr( ".", "!" )}", $"That's right{SelectStr( ".", "!" )}", $"That's correct{SelectStr( ".", "!" )}", $"Well done{SelectStr( ".", "!" )}", "You did it!" );
				break;
			case ChatTaskType.DontMove:
				msgStart = SelectStr( $"Well done{SelectStr( ".", "!" )}", $"Nice{SelectStr( ".", "!" )}" );
				break;
			case ChatTaskType.DontKill:
				msgStart = SelectStr( $"Well done{SelectStr( ".", "!" )}", $"Nice{SelectStr( ".", "!" )}" );
				break;
			case ChatTaskType.ChopTree:
				msgStart = SelectStr( $"Well done{SelectStr( ".", "!" )}", $"You showed that tree{SelectStr( ".", "!" )}" );
				break;
			case ChatTaskType.TouchArenaSide:
				msgStart = SelectStr( $"Good hustle{SelectStr( ".", "!" )}", $"Nice moves{SelectStr( ".", "!" )}", $"Well done{SelectStr( ".", "!" )}" );
				break;
			case ChatTaskType.LetBasicZombieHurt:
				msgStart = SelectStr( "How'd that feel?", "You took that hit well!", $"I'm proud of you{SelectStr( ".", "!" )}" );
				break;
			case ChatTaskType.DontCollectXp:
				msgStart = SelectStr( $"Well done{SelectStr( ".", "!" )}", $"Nice{SelectStr( ".", "!" )}", $"Good job{SelectStr( ".", "!" )}" );
				break;
		}

		string msgEnd = "";
		int rand = Game.Random.Int( 0, 10 );
		switch ( rand )
		{
			case 0:
			default:
				int MAX_HP = Game.Random.Int( 3, 6 );
				msgEnd = $"{SelectStr( "Gain", "Plus" )} {MAX_HP} max hp{SelectStr( ".", "!" )}";
				AddLocalChatMessage( $"✝️🤗 {msgStart} {msgEnd}" );

				AdjustMaxHp( MAX_HP );
				break;
			case 1:
				msgEnd = $"{SelectStr( "Have", "You get", "Here's" )} some xp{SelectStr( ".", "!" )}";
				AddLocalChatMessage( $"✝️🤗 {msgStart} {msgEnd}" );

				for ( int i = 0; i < 2; i++ )
				{
					Manager.Instance.SpawnCoinRpc( Player.Position2D, value: Game.Random.Int( 2, 4 ), Utils.GetRandomVectorInCone( -Player.FacingDir, coneDegrees: 150f ) );
				}
				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f );
				Player.DodgeDuckRpc( dir: Utils.GetRandomVectorInCone( -Player.FacingDir, coneDegrees: 150f ), time: Game.Random.Float( 0.04f, 0.06f ) );
				break;
			case 2:
				msgEnd = SelectStr( $"{SelectStr( "Have", "You get", "Here's" )} a healthpack{SelectStr( ".", "!" )}", "Need a healthpack?" );
				AddLocalChatMessage( $"✝️🤗 {msgStart} {msgEnd}" );

				Player.GiveItemRpc( "health_pack", Utils.GetRandomVectorInCone( -Player.FacingDir, coneDegrees: 200f ) );
				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f );
				break;
			case 3:
				msgEnd = $"{SelectStr( "Have", "You get", "Here's" )} a random perk:";
				Player.GiveRandomPerkItemWithMessage( sourcePerkType: TypeLibrary.GetType( this.GetType() ), message: $"✝️🤗 {msgStart} {msgEnd}", isReward: true );

				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f );
				break;
			case 4:
				// todo: make this one more rare

				msgEnd = $"{SelectStr( "Have", "You get", "Here's" )} a random Unique perk:";
				Player.GiveRandomPerkItemWithMessage( sourcePerkType: TypeLibrary.GetType( this.GetType() ), message: $"✝️🤗 {msgStart} {msgEnd}", Rarity.Unique, isReward: true );

				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Player.Position2D, pitch: Game.Random.Float( 1.25f, 1.3f ), volume: 1f, maxDist: 300f );
				break;
			case 5:
				msgEnd = $"{SelectStr( "Have", "You get", "Here are" )} some rerolls{SelectStr( ".", "!" )}";
				AddLocalChatMessage( $"✝️🤗 {msgStart} {msgEnd}" );

				for ( int i = 0; i < 3; i++ )
				{
					Manager.Instance.SpawnItemRpc( "reroll_item", Player.Position2D, Utils.GetRandomVectorInCone( -Player.FacingDir, coneDegrees: 150f ) );
				}
				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f );
				Player.DodgeDuckRpc( Utils.GetRandomVectorInCone( -Player.FacingDir, coneDegrees: 150f ), time: Game.Random.Float( 0.04f, 0.06f ) );
				break;
			case 6:
				msgEnd = $"{SelectStr( "Have", "You get", "Here's" )} a banish{SelectStr( ".", "!" )}";
				AddLocalChatMessage( $"✝️🤗 {msgStart} {msgEnd}" );

				Player.GiveItemRpc( "banish_item", Utils.GetRandomVectorInCone( -Player.FacingDir, coneDegrees: 150f ) );
				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f );
				break;
			case 7:
				msgEnd = $"{SelectStr( "Have", "You get", "Here's" )} a magnet{SelectStr( ".", "!" )}";
				AddLocalChatMessage( $"✝️🤗 {msgStart} {msgEnd}" );

				Player.GiveItemRpc( "magnet", Utils.GetRandomVectorInCone( -Player.FacingDir, coneDegrees: 150f ) );
				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f );
				break;
			case 8:
				msgEnd = $"{SelectStr( "Have", "You get", "Here's" )} a chest{SelectStr( ".", "!" )}";
				AddLocalChatMessage( $"✝️🤗 {msgStart} {msgEnd}" );

				{
					Vector2 pos = Manager.Instance.ClampPosToBounds( Player.Position2D + Utils.GetRandomVector() * Game.Random.Float( 30f, 50f ) );
					Manager.Instance.SpawnEnemyRpc( EnemyType.Chest, pos, rotAngle: -90f + Game.Random.Float( -30f, 30f ) );
				}
				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f );
				break;
			case 9:
				msgEnd = SelectStr( $"{SelectStr( "Have", "You get", "Here are" )} some barrels{SelectStr( ".", "!" )}", $"Hope you like barrels{SelectStr( ".", "!" )}" );
				AddLocalChatMessage( $"✝️🤗 {msgStart} {msgEnd}" );

				for ( int i = 0; i < 2; i++ )
				{
					Vector2 pos = Manager.Instance.ClampPosToBounds( Player.Position2D + Utils.GetRandomVector() * Game.Random.Float( 30f, 60f ) );
					Manager.Instance.SpawnEnemyRpc( EnemyType.Barrel, pos );
				}

				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f );
				break;
			case 10:
				msgEnd = $"{SelectStr( "Have", "You get", "Here's" )} some armor{SelectStr( ".", "!" )}";
				AddLocalChatMessage( $"✝️🤗 {msgStart} {msgEnd}" );

				Player.GiveItemRpc( "armor_item", Utils.GetRandomVectorInCone( -Player.FacingDir, coneDegrees: 150f ) );

				Manager.Instance.PlaySfxNearbyRpc( "scuffle", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 300f );
				break;
		}
	}

	void GiveRandomPunishment( bool ranOutOfTime )
	{
		string msgStart;
		switch ( _taskType )
		{
			case ChatTaskType.SayMessage:
			default:
				msgStart = (ranOutOfTime ? SelectStr( $"Too slow{SelectStr( ".", "!" )}", "Took too long!" ) : SelectStr( $"Wrong{SelectStr( ".", "!" )}", $"No{SelectStr( ".", "!" )}", $"NO{SelectStr( ".", "!" )}" ));
				break;
			case ChatTaskType.DontMove:
				msgStart = SelectStr( $"You moved{SelectStr( ".", "!" )}", "I told you not to move!", "Why did you move?" );
				break;
			case ChatTaskType.DontKill:
				msgStart = SelectStr( "You killed something!", $"I told you not to kill anything{SelectStr( ".", "!" )}" );
				break;
			case ChatTaskType.ChopTree:
				msgStart = SelectStr( "You didn't destroy a tree!", $"I told you to destroy a tree{SelectStr( ".", "!" )}", "Why didn't you destroy a tree?", $"You took too long{SelectStr( ".", "!" )}" );
				break;
			case ChatTaskType.TouchArenaSide:
				msgStart = SelectStr( $"You are too slow{SelectStr( ".", "!" )}", "You took too long!", $"Stop making me wait{SelectStr( ".", "!" )}" );
				break;
			case ChatTaskType.LetBasicZombieHurt:
				msgStart = SelectStr( "Scared of a basic Zombie?", $"Coward{SelectStr( ".", "!" )}" );
				break;
			case ChatTaskType.DontCollectXp:
				msgStart = SelectStr( $"You have no patience{SelectStr( ".", "!" )}", $"You're too greedy{SelectStr( ".", "!" )}", $"Covetous sinner{SelectStr( ".", "!" )}" );
				break;
		}

		string msgEnd = "";
		int rand = Game.Random.Int( 0, 9 );

		while ( rand == 2 && Player.Scene.GetAll<Tree>().Count() > 20 )
			rand = Game.Random.Int( 0, 9 );

		switch ( rand )
		{
			case 0:
			default:
				int DMG = Game.Random.Int( 1, 35 );
				msgEnd = $"Take {DMG} damage{SelectStr( ".", "!" )}";
				Player.Damage( DMG, DamageType.Self, Player.Position2D, Utils.GetRandomVector(), upwardAmount: 0f, force: Game.Random.Float(0f, 100f), ragdollForce: 1f, enemySource: null, enemyType: EnemyType.None );
				break;
			case 1:
				msgEnd = $"Get burnt{SelectStr( ".", "!", "..." )}";
				Manager.Instance.SpawnFireRingRpc( Player.Position2D, 150f, 15, damage: 5f, lifetime: Game.Random.Float( 8f, 10f ), startDegrees: 0f, scale: 1f, Color.Red, Color.Yellow, playerSource: null, enemySource: null, enemyType: EnemyType.None );
				Manager.Instance.SpawnFireGroundRpc( Player.Position2D, player: null, enemySource: null, enemyType: EnemyType.None, damage: 5f, lifetime: Game.Random.Float( 8f, 10f ), spreadChance: 0.05f, canStack: false, scale: 1f, colorA: Color.Red, colorB: Color.Yellow );
				Manager.Instance.PlaySfxNearbyRpc( "burn", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1f ), volume: 0.9f, maxDist: 300f );
				break;
			case 2:
				msgEnd = $"{SelectStr( "You're in", "Take a" )} time-out{SelectStr( ".", "!", "..." )}";
				Manager.Instance.SpawnEnemyRingRpc( EnemyType.Tree, Player.Position2D, 80f, 8 );
				break;
			case 3:
				msgEnd = $"Watch your {SelectStr( "step.", "step!" )}";
				Manager.Instance.SpawnLandmineRingRpc( Player.Position2D, Game.Random.Float( 80f, 120f ), Game.Random.Int( 7, 13 ) );
				break;
			case 4:
				msgEnd = $"You must {SelectStr( "be punished.", "be punished!", "feel pain." )}";
				{
					float currAngle = Game.Random.Float( 0f, 360f );
					Manager.Instance.SpawnAcidPuddleRingRpc( Player.Position2D, radius: 90f, num: Game.Random.Int( 5, 6 ), scale: Game.Random.Float( 1.1f, 1.25f ), lifetime: Game.Random.Float( 8f, 12f ), damage: 8f, playerSource: null, enemySource: null, enemyType: EnemyType.None, currAngle );

					currAngle += Game.Random.Float( 90f, 110f );
					Manager.Instance.SpawnAcidPuddleRingRpc( Player.Position2D, radius: Game.Random.Float( 160f, 190f ), num: Game.Random.Int( 6, 7 ), scale: Game.Random.Float( 1.35f, 1.45f ), lifetime: Game.Random.Float( 11f, 13f ), damage: 8f, playerSource: null, enemySource: null, enemyType: EnemyType.None, currAngle );
				}
				break;
			case 5:
				int MAX_HP = Game.Random.Int( 1, 10 );
				Player.Damage( MAX_HP, DamageType.Self, Player.Position2D, Utils.GetRandomVector(), upwardAmount: 0f, force: Game.Random.Float( 0f, 100f ), ragdollForce: 1f, enemySource: null, enemyType: EnemyType.None );
				msgEnd = $"Lose {MAX_HP} max hp!";
				AdjustMaxHp( -MAX_HP );
				break;
			case 6:
				msgEnd = SelectStr( "Have a curse!", "Enjoy a curse!", "Free curse!" );

				// todo: curse punishment

				//var curseType = GetRandomCurseType();
				//int tries = 0;
				//while ( Player.HasUpgrade( curseType ) && tries < 15 )
				//{
				//	curseType = GetRandomCurseType();
				//	tries++;
				//}

				//Player.GiveUpgradePowerup( curseType );
				break;
			case 7:
				msgEnd = $"Now you can't move for {LOCKED_IN_PLACE_TIME}s!";

				// todo: kneel on ground while can't move
				Player.Modify( this, PlayerStat.CantMove, 1f, ModifierType.Add );
				_isLockedInPlace = true;
				_lockedInPlaceCountdown = LOCKED_IN_PLACE_TIME;
				break;
			case 8: // todo: don't pick this one if PlayerStat.ReverseMoveControls is already > 0f
				msgEnd = $"{SelectStr( "Reversed movement", "Confusion" )} for {REVERSED_CONTROLS_TIME}s!";

				Player.Modify( this, PlayerStat.ReverseMoveControls, 1f, ModifierType.Add );
				_areControlsReversed = true;
				_reversedControlsCountdown = REVERSED_CONTROLS_TIME;
				break;
			case 9:
				msgEnd = $"Time to die!";
				Manager.Instance.SpawnMiniboss( Player.Position2D + Utils.GetRandomVector() * Game.Random.Float( 90f, 120f ) );
				break;
		}

		AddLocalChatMessage($"✝️😡 {msgStart} {msgEnd}");
	}

	// todo:

	//TypeDescription GetRandomCurseType()
	//{
	//	int rand = Game.Random.Int( 0, 10 );
	//	switch ( rand )
	//	{
	//		case 0: default: return TypeLibrary.GetType( typeof( CurseBacksideUpgrade ) );
	//		case 1: return TypeLibrary.GetType( typeof( CurseCommonUpgrade ) );
	//		case 2: return TypeLibrary.GetType( typeof( CurseMaxHpUpgrade ) );
	//		case 3: return TypeLibrary.GetType( typeof( CurseRerollUpgrade ) );
	//		case 4: return TypeLibrary.GetType( typeof( CurseTurnSpeedUpgrade ) );
	//		case 5: return TypeLibrary.GetType( typeof( CurseRegenUpgrade ) );
	//		case 6: return TypeLibrary.GetType( typeof( CurseLessChoicesUpgrade ) );
	//		case 7: return TypeLibrary.GetType( typeof( CursePiercingUpgrade ) );
	//		case 8: return TypeLibrary.GetType( typeof( CurseCritChanceUpgrade ) );
	//		case 9: return TypeLibrary.GetType( typeof( CurseCritMultiplierUpgrade ) );
	//		case 10: return TypeLibrary.GetType( typeof( CurseTeleportUpgrade ) );
	//	}
	//}

	void AdjustMaxHp( int amount)
	{
		if ( amount == 0 )
			return;

		_maxHpAdded += amount;
		Player.Modify( this, PlayerStat.MaxHp, _maxHpAdded, ModifierType.Add );
		var color = amount > 0 ? new Color( 0f, 0.8f, 0f ) : new Color( 0.8f, 0.4f, 0.4f );

		Manager.Instance.SpawnFloaterTextRpc( Player.WorldPosition.WithZ( 65f ), $"{(amount > 0 ? "+" : "-")}{amount} MAX HP!", color, size: 1.2f, floaterType: amount > 0 ? FloaterType.PositiveMessage : FloaterType.NegativeMessage );
	}

	public static string RandomizeCapitalization( string input )
	{
		char[] characters = input.ToCharArray();

		for ( int i = 0; i < characters.Length; i++ )
		{
			if ( Char.IsLetter( characters[i] ) )
			{
				characters[i] = Game.Random.Int( 0, 1 ) == 0 ? Char.ToLower( characters[i] ) : Char.ToUpper( characters[i] );
			}
		}

		return new string( characters );
	}

	public static string SelectStr( params string[] strings )
	{
		if ( strings == null || strings.Length == 0 )
			return "";

		return strings[Game.Random.Int( 0, strings.Length - 1 )];
	}

	public override void OnDie()
	{
		base.OnDie();

		Player.StopModifying( this, PlayerStat.CantMove );
		Player.StopModifying( this, PlayerStat.ReverseMoveControls );
	}

	void AddLocalChatMessage( string message )
	{
		Manager.Instance.Chat.AddLocalChatMessage( $"{Perk.GetRichTextToken( GetType() )} {message}", from: "" );
	}
}