OneMoreRoundWeapon.Definition.cs
using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// Definition + resolved-stat pipeline for OneMoreRoundWeapon.
///
/// Existing prefabs remain valid when Definition is null. A canonical
/// OMRWeaponDefinition becomes the immutable authored baseline when assigned.
/// Runtime modifiers are always applied to a cloned OMRWeaponStats snapshot and
/// never mutate the source asset.
/// </summary>
public partial class OneMoreRoundWeapon
{
[Property, Group( "Weapon Definition" ), Title( "Definition" )]
[Description( "Optional canonical weapon asset. When assigned, its authored gameplay/presentation values become the base stats before BaseCombatWeapon starts. Leave empty for legacy prefab values during migration." )]
public OMRWeaponDefinition Definition { get; set; }
[Property, Group( "Weapon Definition" ), Title( "Log Definition Apply" )]
public bool LogDefinitionApply { get; set; } = false;
public bool UsesWeaponDefinition =>
Definition is not null &&
Definition.IsValid;
/// <summary>
/// Immutable-by-convention baseline captured from the canonical definition,
/// or from legacy prefab-authored values while migration is still in progress.
/// Never modify this instance directly after initialization.
/// </summary>
private OMRWeaponStats _baseStats;
public OMRWeaponStats BaseStats => _baseStats?.Clone();
/// <summary>
/// Current effective stats after the deterministic runtime modifier stack.
/// Callers receive a copy so external systems cannot mutate the weapon's
/// authoritative local snapshot behind the resolver's back.
/// </summary>
private OMRWeaponStats _resolvedStats;
public OMRWeaponStats ResolvedStats => _resolvedStats?.Clone();
public int StatRevision { get; private set; }
private readonly List<IOMRWeaponStatModifier> _activeStatModifiers = new();
private bool _weaponDefinitionInitialized;
public IReadOnlyList<IOMRWeaponStatModifier> ActiveStatModifiers => _activeStatModifiers;
/// <summary>
/// Fired locally whenever the effective stat snapshot has been rebuilt and
/// applied to the engine-facing weapon component.
/// </summary>
public event Action<OneMoreRoundWeapon> ResolvedStatsChanged;
/// <summary>
/// Initializes the authored baseline and applies the first resolved snapshot.
/// This must execute before BaseCombatWeapon.OnStart so magazine/model setup
/// sees canonical/resolved values instead of stale legacy prefab values.
/// </summary>
private void ApplyWeaponDefinitionIfAssigned()
{
if ( _weaponDefinitionInitialized )
return;
ApplyDefinitionPresentationIfAssigned();
_baseStats = UsesWeaponDefinition
? OMRWeaponStats.FromDefinition( Definition )
: OMRWeaponStats.Capture( this );
if ( _baseStats is null )
{
Log.Error( $"[OMR WEAPON STATS] Failed to establish base stats for '{GameObject?.Name ?? "Weapon"}'." );
return;
}
_baseStats.Normalize();
SortStatModifiers();
RebuildResolvedStats( initializing: true );
_weaponDefinitionInitialized = true;
if ( LogDefinitionApply )
{
string source = UsesWeaponDefinition
? Definition.ResourceName
: "LEGACY PREFAB VALUES";
Log.Info(
$"[OMR WEAPON STATS] Initialized '{_resolvedStats.WeaponId}' from {source} | Damage:{_resolvedStats.Damage:0.##} | Delay:{_resolvedStats.PrimaryDelay:0.###} | Clip:{_resolvedStats.ClipMaxSize} | Modifiers:{_activeStatModifiers.Count}"
);
}
}
/// <summary>
/// Rebuilds the effective stat copy from the untouched base snapshot, applies
/// every modifier in deterministic order, normalizes impossible values and
/// copies the result into BaseCombatWeapon/OMR runtime fields.
/// </summary>
public void RebuildResolvedStats()
{
RebuildResolvedStats( initializing: false );
}
private void RebuildResolvedStats( bool initializing )
{
if ( _baseStats is null )
{
// This can happen if an external source registers a modifier before this
// component's OnStart. The modifier stays queued and initialization will
// resolve it once the authored baseline exists.
return;
}
OMRWeaponStats resolved = _baseStats.Clone();
foreach ( IOMRWeaponStatModifier modifier in _activeStatModifiers )
{
if ( modifier is null )
continue;
try
{
modifier.Apply( resolved );
}
catch ( Exception exception )
{
Log.Error(
$"[OMR WEAPON STATS] Modifier '{modifier.ModifierId}' failed on '{resolved.WeaponId}': {exception.Message}"
);
}
}
resolved.Normalize();
resolved.ApplyTo( this, clampLoadedAmmoToMagazine: !initializing );
_resolvedStats = resolved;
StatRevision++;
ResolvedStatsChanged?.Invoke( this );
}
/// <summary>
/// Adds or replaces one modifier using ModifierId as its stable key. This is
/// the primary API OMR modes, future attachments and other games should call.
/// </summary>
public bool SetStatModifier( IOMRWeaponStatModifier modifier )
{
if ( modifier is null )
return false;
string modifierId = modifier.ModifierId?.Trim() ?? string.Empty;
if ( string.IsNullOrWhiteSpace( modifierId ) )
{
Log.Warning( $"[OMR WEAPON STATS] Rejected modifier with an empty ID on '{GameObject?.Name ?? "Weapon"}'." );
return false;
}
for ( int i = _activeStatModifiers.Count - 1; i >= 0; i-- )
{
IOMRWeaponStatModifier existing = _activeStatModifiers[i];
if ( existing is null )
{
_activeStatModifiers.RemoveAt( i );
continue;
}
if ( string.Equals( existing.ModifierId?.Trim(), modifierId, StringComparison.OrdinalIgnoreCase ) )
{
_activeStatModifiers.RemoveAt( i );
}
}
_activeStatModifiers.Add( modifier );
SortStatModifiers();
RebuildResolvedStats();
return true;
}
public bool RemoveStatModifier( string modifierId )
{
if ( string.IsNullOrWhiteSpace( modifierId ) )
return false;
string normalizedId = modifierId.Trim();
bool removed = false;
for ( int i = _activeStatModifiers.Count - 1; i >= 0; i-- )
{
IOMRWeaponStatModifier existing = _activeStatModifiers[i];
if ( existing is null )
{
_activeStatModifiers.RemoveAt( i );
removed = true;
continue;
}
if ( string.Equals( existing.ModifierId?.Trim(), normalizedId, StringComparison.OrdinalIgnoreCase ) )
{
_activeStatModifiers.RemoveAt( i );
removed = true;
}
}
if ( removed )
{
SortStatModifiers();
RebuildResolvedStats();
}
return removed;
}
public void ClearStatModifiers()
{
if ( _activeStatModifiers.Count == 0 )
return;
_activeStatModifiers.Clear();
RebuildResolvedStats();
}
public bool HasStatModifier( string modifierId )
{
if ( string.IsNullOrWhiteSpace( modifierId ) )
return false;
string normalizedId = modifierId.Trim();
foreach ( IOMRWeaponStatModifier modifier in _activeStatModifiers )
{
if ( modifier is null )
continue;
if ( string.Equals( modifier.ModifierId?.Trim(), normalizedId, StringComparison.OrdinalIgnoreCase ) )
return true;
}
return false;
}
private void SortStatModifiers()
{
_activeStatModifiers.Sort( CompareStatModifiers );
}
private static int CompareStatModifiers( IOMRWeaponStatModifier a, IOMRWeaponStatModifier b )
{
if ( ReferenceEquals( a, b ) )
return 0;
if ( a is null )
return 1;
if ( b is null )
return -1;
int priorityComparison = a.Priority.CompareTo( b.Priority );
if ( priorityComparison != 0 )
return priorityComparison;
return string.Compare(
a.ModifierId ?? string.Empty,
b.ModifierId ?? string.Empty,
StringComparison.OrdinalIgnoreCase
);
}
/// <summary>
/// Presentation resources remain authored by the canonical definition, but
/// are intentionally outside the numeric modifier pipeline. A damage buff
/// should not unexpectedly replace a viewmodel because humans deserve at
/// least that much predictability.
/// </summary>
private void ApplyDefinitionPresentationIfAssigned()
{
if ( !UsesWeaponDefinition )
return;
OMRWeaponDefinition definition = Definition;
// Capability and presentation resources are canonical when a definition is
// assigned. Do not silently retain stale prefab values when the definition
// intentionally leaves a field empty; null in the .omrwep must mean null at
// runtime. Legacy prefab-authored presentation remains supported only when no
// Definition is assigned.
OpticProfile = definition.OpticProfile;
CycleProfile = definition.CycleProfile;
AllowAim = definition.AllowAim;
AllowReload = definition.AllowReload;
// Reload behavior is canonical definition data too. These values must be
// present before BaseCombatWeapon.OnStart initializes its reload state.
IncrementalReloading = definition.IncrementalReloading;
ReloadStartTime = MathF.Max( definition.ReloadStartTime, 0f );
FirstShellReloadTime = MathF.Max( definition.FirstShellReloadTime, 0f );
ViewModelPrefab = ResolveDefinitionPrefab(
definition.ViewModelPrefab,
"ViewModel"
);
WorldModelPrefab = ResolveDefinitionPrefab(
definition.WorldModelPrefab,
"WorldModel"
);
AttackSound = definition.AttackSound;
DryFireSound = definition.DryFireSound;
ReloadSoundCues = definition.ReloadSoundCues is null
? new List<OMRReloadSoundCue>()
: new List<OMRReloadSoundCue>( definition.ReloadSoundCues );
IncrementalReloadInsertSound = definition.IncrementalReloadInsertSound;
CycleSoundCues = definition.CycleSoundCues is null
? new List<OMRCycleSoundCue>()
: new List<OMRCycleSoundCue>( definition.CycleSoundCues );
}
/// <summary>
/// Resolve an authored PrefabFile resource to the cached prefab-scene root that
/// BaseCombatWeapon expects for ViewModelPrefab / WorldModelPrefab. Storing the
/// asset itself in OMRWeaponDefinition avoids serializing scene GameObject
/// references inside a GameResource, which can resolve too late or to null.
/// </summary>
private GameObject ResolveDefinitionPrefab( PrefabFile prefab, string role )
{
if ( prefab is null || !prefab.IsValid )
return null;
string path = prefab.ResourcePath;
GameObject template = string.IsNullOrWhiteSpace( path )
? null
: GameObject.GetPrefab( path );
// Keep the PrefabScene route as a compatibility fallback. Modern s&box
// recommends GameObject.GetPrefab for runtime prefab access, but both
// routes resolve to the loaded template expected by BaseCombatWeapon.
if ( template is null || !template.IsValid )
template = SceneUtility.GetPrefabScene( prefab );
if ( template is null || !template.IsValid )
{
Log.Warning(
$"[OMR WEAPON DEFINITION] Failed to resolve {role} prefab '{path}' for '{GameObject?.Name ?? "Weapon"}'."
);
return null;
}
return template;
}
/// <summary>
/// Kept for existing diagnostic call sites during the migration. Unlike the
/// previous implementation this rebuilds the full modifier pipeline rather
/// than merely scraping current component values.
/// </summary>
private void RefreshResolvedStatsSnapshot()
{
RebuildResolvedStats();
}
}