perks/PerkSeeOtherChoices.cs

A Perk class (PerkSeeOtherChoices) that tracks which perk choices the player has seen and alters the available perk selection so the player does not see the same choices until all others have been shown. It updates UI display state, logs debug info, and adjusts the valid-perk list used by PerkManager to filter or weight choices to guarantee unseen perks appear first.

File Access
using Sandbox;
using System;

[Perk( Rarity.Unique, locked: true, minUnlocksReq: 9, alwaysOfferDebug: false )]
public class PerkSeeOtherChoices : Perk
{
	// Tracks which perks have appeared as choices since the last full reset.
	// Identity ints match TypeDescription.Identity used throughout PerkManager.
	private HashSet<int> _seenPerkIdentities = new();

	// Stored during FilterValid (which runs inside GetRandomPerks) so OnPerkChoicesAssigned
	// can log accurate counts against the actually-eligible pool rather than ValidPerks.
	private int _lastEligibleTotal;
	private int _lastEligibleSeen;

	// Set in FilterValid when a reset occurs so OnPerkChoicesAssigned can play the animation.
	private bool _justReset;

	public bool ShouldLog { get; set; } = false;

	static PerkSeeOtherChoices()
	{
		Register<PerkSeeOtherChoices>(
			name: "Full Circuit",
			imagePath: "textures/icons/vector/see_other_choices.png",
			description: level => "[-]+1[/-] curse\nYou won't see the same perk choices\nagain until you see every other perk"
		);
	}

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

		DisplayCooldownColor = new Color( 0.3f, 0.85f, 1f, 0.45f );
		HighlightColor = new Color( 0.4f, 1f, 0.9f );
		HighlightDuration = 0.6f;
		HighlightOpacity = 5f;

		Player.GiveRandomPerkItemWithMessage( sourcePerkType: TypeLibrary.GetType( this.GetType() ), curseSelection: CurseSelection.OnlyCurses, forceToCollect: true );
	}

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

	// Called by Player.RefreshPerkChoices (full refresh) and by BanishExistingPerkChoice /
	// RerollSinglePerkChoice (single-slot replacement) after CurrentPerkChoices is updated.
	// Marks every currently-visible choice as seen so it won't appear again until a reset.
	// Using a HashSet means re-marking an already-seen perk is a no-op.
	public override void OnPerkChoicesAssigned()
	{
		base.OnPerkChoicesAssigned();

		foreach ( var choice in Player.CurrentPerkChoices )
			_seenPerkIdentities.Add( choice.Identity );

		// Update the fill overlay: seen ratio 0→1 as the rotation fills up.
		DisplayCooldown = _lastEligibleTotal > 0
			? MathF.Min( 1f, (float)_seenPerkIdentities.Count / _lastEligibleTotal )
			: 0f;

		if ( _justReset )
		{
			_justReset = false;
			Highlight();
			IconScale = 1.3f;
			IconAngleOffset = Game.Random.Float( 10f, 20f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);
		}

		if ( ShouldLog )
		{
			int remaining = _lastEligibleTotal - _lastEligibleSeen;
			var choiceNames = string.Join( ", ", Player.CurrentPerkChoices.Select( c => Perk.GetName( c.TargetType ) ) );
			Log.Info( $"[FreshPicks] Choices assigned: [{choiceNames}] | Seen: {_lastEligibleSeen}/{_lastEligibleTotal} eligible | Remaining unseen: {remaining}" );
		}
	}

	public override void Remove( bool restart = false )
	{
		base.Remove( restart );

		if ( restart )
			_seenPerkIdentities.Clear();
	}

	// ── Filtering ────────────────────────────────────────────────────────────────
	// Called from PerkManager.GetRandomPerks, before weighted selection, whenever
	// isChoice is true. Mutates the 'valid' list in place so the caller's selection
	// loop naturally produces only unseen (or reset-fresh) perks.
	//
	// Three cases:
	//   A) unseenCount >= numPerks  →  remove all seen entries; selection is unseen-only.
	//   B) unseenCount == 0         →  every perk has been seen; reset and let normal
	//                                  selection run over the full pool.
	//   C) 0 < unseenCount < numPerks → not enough unseen to fill all slots. Reset the
	//                                  seen list, but give the formerly-unseen perks an
	//                                  enormous weight (1e15) so they are guaranteed to
	//                                  be selected before any formerly-seen perks fill
	//                                  the remaining slots. This ensures the player always
	//                                  sees the full requested number of choices.
	public static void AdjustForSeenPerks( Player player, List<(TypeDescription Type, float Weight)> valid, int numPerks )
	{
		var perkType = TypeLibrary.GetType( typeof( PerkSeeOtherChoices ) );
		if ( player.GetPerk( perkType ) is not PerkSeeOtherChoices perk )
			return;

		perk.FilterValid( valid, numPerks );
	}

	private void FilterValid( List<(TypeDescription Type, float Weight)> valid, int numPerks )
	{
		// Count how many entries in the current valid pool have not yet been seen.
		// 'valid' is already filtered by GetRandomPerks (locked, disabled, unmet requirements
		// etc. are excluded), so these counts reflect only actually-offerable perks.
		int unseenCount = 0;
		foreach ( var item in valid )
			if ( !_seenPerkIdentities.Contains( item.Type.Identity ) )
				unseenCount++;

		// Snapshot for logging in OnPerkChoicesAssigned (called after valid is mutated).
		_lastEligibleTotal = valid.Count;
		_lastEligibleSeen = valid.Count - unseenCount;

		// Case B: nothing new left — reset so the full pool is available again.
		if ( unseenCount == 0 )
		{
			if ( ShouldLog ) Log.Info( $"[FreshPicks] RESET — all {valid.Count} eligible perks seen. Starting a new rotation." );
			_seenPerkIdentities.Clear();
			_justReset = true;
			return;
		}

		// Case A: enough unseen perks to fill all slots — strip the seen ones out entirely.
		if ( unseenCount >= numPerks )
		{
			if ( ShouldLog ) Log.Info( $"[FreshPicks] Filtering choices — {unseenCount} unseen available, need {numPerks}. Removing {valid.Count - unseenCount} seen perks from pool." );
			valid.RemoveAll( x => _seenPerkIdentities.Contains( x.Type.Identity ) );
			return;
		}

		// Case C: some unseen perks exist but fewer than numPerks.
		// Snapshot the formerly-unseen identities before we wipe the seen list.
		var unseenIdentities = new HashSet<int>();
		foreach ( var item in valid )
			if ( !_seenPerkIdentities.Contains( item.Type.Identity ) )
				unseenIdentities.Add( item.Type.Identity );

		if ( ShouldLog ) Log.Info( $"[FreshPicks] PARTIAL RESET — only {unseenCount} unseen perk(s) left but need {numPerks} choices. Resetting and boosting unseen weights to guarantee they appear." );

		// Reset so formerly-seen perks re-enter the pool for the remaining slots.
		_seenPerkIdentities.Clear();
		_justReset = true;

		// Boost unseen weights to 1e15 — vastly larger than any normal weight —
		// so the weighted-random loop in GetRandomPerks always picks them first,
		// guaranteeing they appear before any formerly-seen perks fill remaining slots.
		for ( int i = 0; i < valid.Count; i++ )
		{
			if ( unseenIdentities.Contains( valid[i].Type.Identity ) )
				valid[i] = (valid[i].Type, 1e15f);
		}
	}
}