OMRRoundLoadoutDirector.cs
using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// Host-authoritative weapon selection for competitive OMR rounds.
///
/// Responsibilities are deliberately narrow:
/// - CompetitiveWeapons: normal round + pre-match warmup catalog.
/// - HighNoonWeapon: legacy/fallback event-only override for High Noon.
/// - Sandbox remains owned by RoundManager and can expose every weapon.
/// - CompetitiveShuffle uses one shared shuffle bag so every configured weapon
/// appears before the bag is reshuffled, with immediate repeats avoided.
/// - FilteredShuffle builds an independent bag per modular round rule while
/// still selecting from the exact same competitive prefab catalog.
/// - Fixed profiles can force a curated weapon without mutating the pool.
///
/// The director only chooses a prefab. RoundManager still owns inventory,
/// round reset, spawning, ammo restoration and actual modifier gameplay.
/// </summary>
public sealed class OMRRoundLoadoutDirector : Component
{
[Property, Group( "Competitive" )]
public List<GameObject> CompetitiveWeapons { get; set; } = new();
[Property, Group( "Competitive" ), Range( 1, 5 )]
public int RoundsPerLoadout { get; set; } = 1;
/// <summary>
/// Legacy/fallback High Noon reference. Phase 8D prefers the FixedWeapon in
/// the High Noon OMRRoundRuleProfile, but this stays authored so older scenes
/// and a missing rule profile still fail safely.
/// </summary>
[Property, Group( "Events" )]
public GameObject HighNoonWeapon { get; set; }
private readonly List<int> _shuffleBag = new();
private int _currentCompetitiveIndex = -1;
private int _standardRoundsOnCurrent = 0;
private int _lastCompetitiveIndex = -1;
private readonly Dictionary<RoundModifier, List<int>> _filteredShuffleBags =
new();
private readonly Dictionary<RoundModifier, int> _filteredCurrentIndex =
new();
private readonly Dictionary<RoundModifier, int> _filteredRoundsOnCurrent =
new();
private readonly Dictionary<RoundModifier, int> _filteredLastIndex =
new();
private int _cachedRoundNumber = int.MinValue;
private RoundModifier _cachedModifier = RoundModifier.Standard;
private GameObject _cachedRoundWeapon;
public int CompetitiveWeaponCount =>
CompetitiveWeapons?.Count ?? 0;
public GameObject CurrentCompetitiveWeapon =>
GetCompetitiveWeapon( _currentCompetitiveIndex );
public string CurrentCompetitiveWeaponName =>
CurrentCompetitiveWeapon is null
? "NONE"
: OMRWeaponUtility.GetDisplayName( CurrentCompetitiveWeapon );
/// <summary>
/// Clear transient bag/cached-round state at the start of a new match.
/// Authored weapon lists remain untouched.
/// </summary>
public void ResetMatchSequence()
{
_shuffleBag.Clear();
_currentCompetitiveIndex = -1;
_standardRoundsOnCurrent = 0;
_lastCompetitiveIndex = -1;
_filteredShuffleBags.Clear();
_filteredCurrentIndex.Clear();
_filteredRoundsOnCurrent.Clear();
_filteredLastIndex.Clear();
_cachedRoundNumber = int.MinValue;
_cachedModifier = RoundModifier.Standard;
_cachedRoundWeapon = null;
}
/// <summary>
/// Phase 8D entry point. The profile decides whether this round consumes the
/// shared competitive shuffle, an authored filtered pool, or a fixed weapon.
/// Re-entering countdown for the same logical round remains idempotent.
/// </summary>
public GameObject ResolveRoundWeapon(
int roundNumber,
OMRRoundRuleProfile rule
)
{
RoundModifier modifier =
rule?.Modifier ??
RoundModifier.Standard;
if (
_cachedRoundNumber == roundNumber &&
_cachedModifier == modifier &&
_cachedRoundWeapon is not null &&
_cachedRoundWeapon.IsValid
)
{
return _cachedRoundWeapon;
}
GameObject resolved;
if (
rule is null ||
!rule.IsValid
)
{
resolved =
ResolveLegacyModifierWeapon(
modifier
);
}
else
{
resolved =
rule.WeaponPolicy switch
{
OMRRoundWeaponPolicy.Fixed =>
ResolveFixedWeapon( rule ),
OMRRoundWeaponPolicy.FilteredShuffle =>
ResolveFilteredWeapon( rule ),
_ =>
ResolveStandardWeapon()
};
}
_cachedRoundNumber = roundNumber;
_cachedModifier = modifier;
_cachedRoundWeapon = resolved;
return resolved;
}
/// <summary>
/// Compatibility overload for older callers/scenes. Modular RoundManager
/// resolves a profile first and uses the overload above.
/// </summary>
public GameObject ResolveRoundWeapon(
int roundNumber,
RoundModifier modifier
)
{
if (
_cachedRoundNumber == roundNumber &&
_cachedModifier == modifier &&
_cachedRoundWeapon is not null &&
_cachedRoundWeapon.IsValid
)
{
return _cachedRoundWeapon;
}
GameObject resolved =
ResolveLegacyModifierWeapon(
modifier
);
_cachedRoundNumber = roundNumber;
_cachedModifier = modifier;
_cachedRoundWeapon = resolved;
return resolved;
}
/// <summary>
/// Resolve the weapon that would be used by a rule without consuming its
/// shuffle bag. The host uses this to reveal the actual next loadout before
/// the BANK / ONE MORE ROUND decision. Initializing a shuffle bag is allowed;
/// the selected round later consumes the exact entry that was previewed.
/// </summary>
public GameObject PreviewRoundWeapon(
OMRRoundRuleProfile rule
)
{
if (
rule is null ||
!rule.IsValid
)
{
return PreviewStandardWeapon();
}
return rule.WeaponPolicy switch
{
OMRRoundWeaponPolicy.Fixed =>
rule.ResolveFixedWeaponPrefab(),
OMRRoundWeaponPolicy.FilteredShuffle =>
PreviewFilteredWeapon( rule ),
_ =>
PreviewStandardWeapon()
};
}
private GameObject PreviewStandardWeapon()
{
int holdRounds =
Math.Max(
RoundsPerLoadout,
1
);
if (
IsCompetitiveIndexValid( _currentCompetitiveIndex ) &&
_standardRoundsOnCurrent < holdRounds
)
{
return GetCompetitiveWeapon(
_currentCompetitiveIndex
);
}
EnsureShuffleBag();
for ( int i = _shuffleBag.Count - 1; i >= 0; i-- )
{
int index =
_shuffleBag[i];
if ( IsCompetitiveIndexValid( index ) )
return GetCompetitiveWeapon( index );
}
return null;
}
private GameObject PreviewFilteredWeapon(
OMRRoundRuleProfile rule
)
{
RoundModifier key =
rule.Modifier;
int holdRounds =
Math.Max(
RoundsPerLoadout,
1
);
int currentIndex =
_filteredCurrentIndex.TryGetValue(
key,
out int storedCurrent
)
? storedCurrent
: -1;
int roundsOnCurrent =
_filteredRoundsOnCurrent.TryGetValue(
key,
out int storedRounds
)
? storedRounds
: 0;
if (
IsFilteredIndexValid(
currentIndex,
rule
) &&
roundsOnCurrent < holdRounds
)
{
return GetCompetitiveWeapon(
currentIndex
);
}
EnsureFilteredShuffleBag(
rule
);
if (
_filteredShuffleBags.TryGetValue(
key,
out List<int> bag
) &&
bag is not null
)
{
for ( int i = bag.Count - 1; i >= 0; i-- )
{
int index =
bag[i];
if (
IsFilteredIndexValid(
index,
rule
)
)
{
return GetCompetitiveWeapon(
index
);
}
}
}
return PreviewStandardWeapon();
}
public GameObject GetCompetitiveWeapon(
int index
)
{
if (
CompetitiveWeapons is null ||
CompetitiveWeapons.Count <= 0 ||
index < 0 ||
index >= CompetitiveWeapons.Count
)
{
return null;
}
GameObject prefab = CompetitiveWeapons[index];
return
prefab is not null &&
prefab.IsValid
? prefab
: null;
}
[Button, Title( "Validate Round Loadouts" ), Description( "Validate competitive and event-only weapon assignments." )]
public void ValidateRoundLoadouts()
{
int errors = 0;
int warnings = 0;
if (
CompetitiveWeapons is null ||
CompetitiveWeapons.Count <= 0
)
{
Log.Error(
"ROUND LOADOUTS | FAIL | Competitive weapon pool is empty."
);
return;
}
HashSet<string> ids =
new(
StringComparer.OrdinalIgnoreCase
);
for (
int i = 0;
i < CompetitiveWeapons.Count;
i++
)
{
GameObject prefab =
CompetitiveWeapons[i];
if (
prefab is null ||
!prefab.IsValid
)
{
errors++;
Log.Error(
$"ROUND LOADOUTS | Competitive slot {i} has no valid prefab."
);
continue;
}
OneMoreRoundWeapon weapon =
OMRWeaponUtility.GetWeapon(
prefab
);
if ( weapon is null )
{
errors++;
Log.Error(
$"ROUND LOADOUTS | '{prefab.Name}' has no OneMoreRoundWeapon component."
);
continue;
}
string id =
OMRWeaponUtility.GetWeaponId(
prefab
);
if ( string.IsNullOrWhiteSpace( id ) )
{
warnings++;
Log.Warning(
$"ROUND LOADOUTS | '{prefab.Name}' has no stable WeaponId."
);
}
else if ( !ids.Add( id ) )
{
warnings++;
Log.Warning(
$"ROUND LOADOUTS | Duplicate competitive WeaponId '{id}'."
);
}
}
if (
HighNoonWeapon is null ||
!HighNoonWeapon.IsValid
)
{
warnings++;
Log.Warning(
"ROUND LOADOUTS | Legacy High Noon fallback weapon is not assigned. A valid fixed High Noon rule profile can still supply it."
);
}
else
{
string highNoonId =
OMRWeaponUtility.GetWeaponId(
HighNoonWeapon
);
if ( ids.Contains( highNoonId ) )
{
warnings++;
Log.Warning(
$"ROUND LOADOUTS | High Noon weapon '{highNoonId}' is also in the competitive pool."
);
}
}
string result =
errors == 0
? "PASS"
: "FAIL";
Log.Info(
$"ROUND LOADOUTS | {result} | Competitive:{CompetitiveWeapons.Count} | RoundsPerLoadout:{Math.Max( RoundsPerLoadout, 1 )} | Errors:{errors} | Warnings:{warnings}"
);
}
private GameObject ResolveLegacyModifierWeapon(
RoundModifier modifier
)
{
return
modifier == RoundModifier.HighNoon
? ResolveHighNoonWeapon()
: ResolveStandardWeapon();
}
private GameObject ResolveFixedWeapon(
OMRRoundRuleProfile rule
)
{
GameObject fixedWeapon =
rule.ResolveFixedWeaponPrefab();
if (
fixedWeapon is not null &&
fixedWeapon.IsValid &&
OMRWeaponUtility.GetWeapon( fixedWeapon ) is not null
)
{
return fixedWeapon;
}
Log.Warning(
$"ROUND LOADOUTS | {rule.Modifier} requests a fixed weapon but its prefab is invalid. Falling back safely."
);
if ( rule.Modifier == RoundModifier.HighNoon )
return ResolveHighNoonWeapon();
return ResolveStandardWeapon();
}
private GameObject ResolveHighNoonWeapon()
{
if (
HighNoonWeapon is not null &&
HighNoonWeapon.IsValid
)
{
return HighNoonWeapon;
}
Log.Warning(
"ROUND LOADOUTS | High Noon weapon is missing; falling back to the current competitive weapon."
);
return
CurrentCompetitiveWeapon ??
ResolveStandardWeapon();
}
private GameObject ResolveStandardWeapon()
{
int holdRounds =
Math.Max(
RoundsPerLoadout,
1
);
if (
IsCompetitiveIndexValid( _currentCompetitiveIndex ) &&
_standardRoundsOnCurrent < holdRounds
)
{
_standardRoundsOnCurrent++;
return GetCompetitiveWeapon( _currentCompetitiveIndex );
}
int nextIndex =
TakeNextCompetitiveIndex();
if ( nextIndex < 0 )
{
Log.Error(
"ROUND LOADOUTS | No valid competitive weapon could be resolved."
);
return null;
}
_currentCompetitiveIndex = nextIndex;
_lastCompetitiveIndex = nextIndex;
_standardRoundsOnCurrent = 1;
return GetCompetitiveWeapon( nextIndex );
}
private GameObject ResolveFilteredWeapon(
OMRRoundRuleProfile rule
)
{
RoundModifier key =
rule.Modifier;
int holdRounds =
Math.Max(
RoundsPerLoadout,
1
);
int currentIndex =
_filteredCurrentIndex.TryGetValue(
key,
out int storedCurrent
)
? storedCurrent
: -1;
int roundsOnCurrent =
_filteredRoundsOnCurrent.TryGetValue(
key,
out int storedRounds
)
? storedRounds
: 0;
if (
IsFilteredIndexValid(
currentIndex,
rule
) &&
roundsOnCurrent < holdRounds
)
{
_filteredRoundsOnCurrent[key] =
roundsOnCurrent + 1;
return GetCompetitiveWeapon(
currentIndex
);
}
int nextIndex =
TakeNextFilteredIndex(
rule
);
if ( nextIndex < 0 )
{
Log.Warning(
$"ROUND LOADOUTS | {rule.Modifier} filtered pool resolved no valid weapon; falling back to the shared competitive shuffle."
);
return ResolveStandardWeapon();
}
_filteredCurrentIndex[key] =
nextIndex;
_filteredLastIndex[key] =
nextIndex;
_filteredRoundsOnCurrent[key] =
1;
return GetCompetitiveWeapon(
nextIndex
);
}
private int TakeNextCompetitiveIndex()
{
EnsureShuffleBag();
while ( _shuffleBag.Count > 0 )
{
int last =
_shuffleBag.Count - 1;
int index =
_shuffleBag[last];
_shuffleBag.RemoveAt(
last
);
if ( IsCompetitiveIndexValid( index ) )
return index;
}
return -1;
}
private void EnsureShuffleBag()
{
if ( _shuffleBag.Count > 0 )
return;
if (
CompetitiveWeapons is null ||
CompetitiveWeapons.Count <= 0
)
return;
for (
int i = 0;
i < CompetitiveWeapons.Count;
i++
)
{
if ( IsCompetitiveIndexValid( i ) )
{
_shuffleBag.Add( i );
}
}
ShuffleIndices(
_shuffleBag
);
AvoidImmediateBagRepeat(
_shuffleBag,
_lastCompetitiveIndex
);
}
private int TakeNextFilteredIndex(
OMRRoundRuleProfile rule
)
{
RoundModifier key =
rule.Modifier;
EnsureFilteredShuffleBag(
rule
);
if (
!_filteredShuffleBags.TryGetValue(
key,
out List<int> bag
) ||
bag is null
)
{
return -1;
}
while ( bag.Count > 0 )
{
int last =
bag.Count - 1;
int index =
bag[last];
bag.RemoveAt(
last
);
if (
IsFilteredIndexValid(
index,
rule
)
)
{
return index;
}
}
return -1;
}
private void EnsureFilteredShuffleBag(
OMRRoundRuleProfile rule
)
{
RoundModifier key =
rule.Modifier;
if (
_filteredShuffleBags.TryGetValue(
key,
out List<int> existing
) &&
existing is not null &&
existing.Count > 0
)
{
// Remove entries that became invalid due to a hotloaded rule/profile.
existing.RemoveAll(
x =>
!IsFilteredIndexValid(
x,
rule
)
);
if ( existing.Count > 0 )
return;
}
List<int> bag =
new();
if ( CompetitiveWeapons is not null )
{
for (
int i = 0;
i < CompetitiveWeapons.Count;
i++
)
{
if (
IsFilteredIndexValid(
i,
rule
)
)
{
bag.Add( i );
}
}
}
ShuffleIndices(
bag
);
int lastIndex =
_filteredLastIndex.TryGetValue(
key,
out int storedLast
)
? storedLast
: -1;
AvoidImmediateBagRepeat(
bag,
lastIndex
);
_filteredShuffleBags[key] =
bag;
}
private static void ShuffleIndices(
List<int> bag
)
{
if ( bag is null )
return;
for (
int i = bag.Count - 1;
i > 0;
i--
)
{
int swapIndex =
Game.Random.Next(
0,
i + 1
);
(
bag[i],
bag[swapIndex]
) = (
bag[swapIndex],
bag[i]
);
}
}
/// <summary>
/// Bags are consumed from the end. Keep the previous index away from that
/// slot so a reshuffle never creates an immediate duplicate when alternatives
/// exist.
/// </summary>
private static void AvoidImmediateBagRepeat(
List<int> bag,
int previousIndex
)
{
if (
bag is null ||
bag.Count <= 1 ||
previousIndex < 0
)
return;
int nextBagIndex =
bag.Count - 1;
if ( bag[nextBagIndex] != previousIndex )
return;
for (
int i = 0;
i < nextBagIndex;
i++
)
{
if ( bag[i] == previousIndex )
continue;
(
bag[i],
bag[nextBagIndex]
) = (
bag[nextBagIndex],
bag[i]
);
break;
}
}
private bool IsCompetitiveIndexValid(
int index
)
{
GameObject prefab =
GetCompetitiveWeapon(
index
);
return
prefab is not null &&
OMRWeaponUtility.GetWeapon( prefab ) is not null;
}
private bool IsFilteredIndexValid(
int index,
OMRRoundRuleProfile rule
)
{
if (
rule is null ||
!rule.IsValid ||
!IsCompetitiveIndexValid( index )
)
{
return false;
}
return rule.AllowsWeapon(
GetCompetitiveWeapon( index )
);
}
}