perks/PerkChatInstruction.cs

A Perk implementation named PerkChatInstruction (Voice of God) that intermittently issues in-game tasks via chat (e.g. say a message, don't move, touch arena edge). It tracks timers, validates player actions (chat, kills, hits, XP pickup, movement, position) and grants rewards or punishments using game APIs (spawning items/enemies, modifying player stats, playing SFX, showing messages).

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: "" );
	}
}