OMRRoundRuleDirector.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// Central authored catalog for modular round rules.
///
/// RoundManager continues to own the actual match state machine. This director
/// only resolves profiles, produces fair/random two-choice modifier offers and
/// provides display metadata to UI. Keeping that responsibility separate avoids
/// growing another modifier switch throughout RoundManager and GameHUD.
/// </summary>
public sealed class OMRRoundRuleDirector : Component
{
[Property, Group( "Rules" )]
public List<OMRRoundRuleProfile> Rules { get; set; } =
new();
private readonly List<RoundModifier> _offerBag =
new();
public void ResetMatchSequence()
{
_offerBag.Clear();
}
public OMRRoundRuleProfile ResolveRule(
RoundModifier modifier
)
{
if ( Rules is null )
return null;
foreach ( OMRRoundRuleProfile rule in Rules )
{
if (
rule is null ||
!rule.IsValid ||
rule.Modifier != modifier
)
{
continue;
}
return rule;
}
return null;
}
public string GetDisplayName(
RoundModifier modifier
)
{
OMRRoundRuleProfile rule =
ResolveRule(
modifier
);
return rule is null
? modifier.ToString().ToUpperInvariant()
: rule.GetDisplayName();
}
public string GetDescription(
RoundModifier modifier
)
{
OMRRoundRuleProfile rule =
ResolveRule(
modifier
);
return rule?.GetDescription() ??
"Special round rules.";
}
/// <summary>
/// Consume two distinct offerable rules from a shuffle bag. Every offerable
/// modifier is visited before a new bag is created, rather than independently
/// rolling the same option forever with pure random selection.
/// </summary>
public bool TryTakeModifierOffers(
out RoundModifier optionA,
out RoundModifier optionB
)
{
optionA = RoundModifier.HighNoon;
optionB = RoundModifier.Standard;
List<RoundModifier> offerable =
GetOfferableModifiers();
if ( offerable.Count <= 0 )
return false;
if ( offerable.Count == 1 )
{
optionA = offerable[0];
optionB = RoundModifier.Standard;
return true;
}
optionA =
TakeNextOffer(
offerable,
null
);
optionB =
TakeNextOffer(
offerable,
optionA
);
if ( optionB == optionA )
{
// optionA is an out parameter, so C# cannot capture it inside a
// lambda. Resolve the fallback explicitly instead.
foreach ( RoundModifier candidate in offerable )
{
if ( candidate == optionA )
continue;
optionB = candidate;
break;
}
}
// Randomize left/right placement independently of the bag order so one
// modifier does not become muscle-memory option A.
if ( Game.Random.Next( 0, 2 ) == 1 )
{
(
optionA,
optionB
) = (
optionB,
optionA
);
}
return true;
}
[Button, Title( "Validate Round Rules" )]
public void ValidateRoundRules()
{
int errors = 0;
int warnings = 0;
if (
Rules is null ||
Rules.Count <= 0
)
{
Log.Error(
"ROUND RULES | FAIL | No OMR round rule profiles are assigned."
);
return;
}
HashSet<RoundModifier> modifiers =
new();
OMRRoundLoadoutDirector loadouts =
Components.Get<OMRRoundLoadoutDirector>();
int offerableCount = 0;
foreach ( OMRRoundRuleProfile rule in Rules )
{
if (
rule is null ||
!rule.IsValid
)
{
errors++;
Log.Error(
"ROUND RULES | Missing/invalid profile reference."
);
continue;
}
if ( !modifiers.Add( rule.Modifier ) )
{
errors++;
Log.Error(
$"ROUND RULES | Duplicate profile for {rule.Modifier}."
);
}
if ( rule.OfferInModifierDecision )
offerableCount++;
if (
rule.WeaponPolicy ==
OMRRoundWeaponPolicy.Fixed &&
OMRWeaponUtility.GetWeapon(
rule.ResolveFixedWeaponPrefab()
) is null
)
{
errors++;
Log.Error(
$"ROUND RULES | {rule.Modifier} uses Fixed weapon policy but has no valid OneMoreRoundWeapon prefab."
);
}
if (
rule.Modifier == RoundModifier.Standard &&
rule.OfferInModifierDecision
)
{
warnings++;
Log.Warning(
"ROUND RULES | Standard is offerable. This is valid, but normally Standard is the no-modifier baseline rather than a ONE MORE ROUND choice."
);
}
if (
rule.WeaponPolicy == OMRRoundWeaponPolicy.FilteredShuffle &&
loadouts is null
)
{
warnings++;
Log.Warning(
$"ROUND RULES | {rule.Modifier} uses FilteredShuffle but OMRRoundLoadoutDirector is missing, so the pool cannot be validated."
);
}
else if (
rule.WeaponPolicy == OMRRoundWeaponPolicy.FilteredShuffle &&
loadouts is not null
)
{
int eligible = 0;
for ( int i = 0; i < loadouts.CompetitiveWeaponCount; i++ )
{
GameObject prefab = loadouts.GetCompetitiveWeapon( i );
if (
prefab is not null &&
rule.AllowsWeapon( prefab )
)
{
eligible++;
}
}
if ( eligible <= 0 )
{
errors++;
Log.Error(
$"ROUND RULES | {rule.Modifier} filtered weapon policy matches no competitive weapons."
);
}
}
if ( rule.GravityScale <= 0f )
{
errors++;
Log.Error(
$"ROUND RULES | {rule.Modifier} has invalid GravityScale {rule.GravityScale:0.###}."
);
}
if (
rule.AdditionalAirJumps < 0 ||
rule.AdditionalAirJumps > 3
)
{
errors++;
Log.Error(
$"ROUND RULES | {rule.Modifier} has invalid AdditionalAirJumps {rule.AdditionalAirJumps}; expected 0..3."
);
}
if ( rule.FightDurationOverride < 0f )
{
errors++;
Log.Error(
$"ROUND RULES | {rule.Modifier} has invalid FightDurationOverride {rule.FightDurationOverride:0.###}. Use 0 for the default timer."
);
}
if ( rule.WeaponModifiers is not null )
{
HashSet<string> modifierIds =
new(
StringComparer.OrdinalIgnoreCase
);
foreach ( OMRWeaponModifierDefinition modifier in rule.WeaponModifiers )
{
if (
modifier is null ||
!modifier.IsValid
)
{
warnings++;
Log.Warning(
$"ROUND RULES | {rule.Modifier} contains an invalid weapon modifier reference."
);
continue;
}
string id =
modifier.ModifierId?.Trim() ??
string.Empty;
if ( string.IsNullOrWhiteSpace( id ) )
{
warnings++;
continue;
}
if ( !modifierIds.Add( id ) )
{
warnings++;
Log.Warning(
$"ROUND RULES | {rule.Modifier} repeats weapon modifier ID '{id}'."
);
}
}
}
}
if ( ResolveRule( RoundModifier.Standard ) is null )
{
warnings++;
Log.Warning(
"ROUND RULES | No Standard profile is assigned; legacy fallback behavior will be used."
);
}
if ( offerableCount < 2 )
{
warnings++;
Log.Warning(
$"ROUND RULES | Only {offerableCount} modifier rule(s) are offerable; two-choice decisions need at least two for full variety."
);
}
string result =
errors == 0
? "PASS"
: "FAIL";
Log.Info(
$"ROUND RULES | {result} | Profiles:{Rules.Count} | Offerable:{offerableCount} | Errors:{errors} | Warnings:{warnings}"
);
}
private List<RoundModifier> GetOfferableModifiers()
{
List<RoundModifier> result =
new();
if ( Rules is null )
return result;
HashSet<RoundModifier> seen =
new();
foreach ( OMRRoundRuleProfile rule in Rules )
{
if (
rule is null ||
!rule.IsValid ||
!rule.OfferInModifierDecision ||
!seen.Add( rule.Modifier )
)
{
continue;
}
result.Add(
rule.Modifier
);
}
return result;
}
private RoundModifier TakeNextOffer(
List<RoundModifier> offerable,
RoundModifier? excluded
)
{
for ( int attempt = 0; attempt < 2; attempt++ )
{
EnsureOfferBag(
offerable
);
for ( int i = _offerBag.Count - 1; i >= 0; i-- )
{
RoundModifier candidate =
_offerBag[i];
if (
excluded.HasValue &&
candidate == excluded.Value
)
{
continue;
}
_offerBag.RemoveAt( i );
return candidate;
}
_offerBag.Clear();
}
return offerable.FirstOrDefault(
x =>
!excluded.HasValue ||
x != excluded.Value
);
}
private void EnsureOfferBag(
List<RoundModifier> offerable
)
{
// If hotload changed the authored rules, discard stale enum values before
// continuing the bag.
_offerBag.RemoveAll(
x => !offerable.Contains( x )
);
if ( _offerBag.Count > 0 )
return;
_offerBag.AddRange(
offerable
);
for ( int i = _offerBag.Count - 1; i > 0; i-- )
{
int swap =
Game.Random.Next(
0,
i + 1
);
(
_offerBag[i],
_offerBag[swap]
) = (
_offerBag[swap],
_offerBag[i]
);
}
}
}