RoundManager.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
public enum SessionMode
{
Menu,
Match,
Sandbox
}
public enum RoundPhase
{
Waiting,
Countdown,
Fighting,
RoundEnd,
WinnerDecision,
ModifierDecision,
MatchEnd
}
public enum RoundModifier
{
Standard,
HighNoon,
// Phase 8D appends new values so existing serialized enum ordinals remain
// stable for Standard/HighNoon in older scenes and resources.
LowGravity,
HeadshotOnly,
// Phase 8E1 appends additional modular rules. Keep appending values so
// serialized ordinals from existing scenes/resources remain stable.
LargeCaliber,
DoubleJump,
HipfireOnly,
Blitz
}
public enum HighNoonStage
{
Inactive,
WaitingForDraw,
DrawOpen
}
public sealed partial class RoundManager : Component
{
// ============================================================
// REFERENCES
// ============================================================
[Property]
public GameObject SpawnA { get; set; }
[Property]
public GameObject SpawnB { get; set; }
[Property]
public ArenaController Arena { get; set; }
// ============================================================
// PRE-MATCH LOBBY / WARMUP
// ============================================================
[Property, Group( "Lobby" )]
public float LobbyWarmupRespawnDelay { get; set; } = 0.50f;
// ============================================================
// SANDBOX CONFIG
// ============================================================
/// <summary>
/// Sandbox-only weapon catalog. Competitive rounds and lobby warmup use
/// OMRRoundLoadoutDirector instead, so event-only weapons can remain
/// available for testing without leaking into normal match rotation.
/// </summary>
[Property, Group( "Sandbox" )]
public List<GameObject> SandboxWeapons { get; set; } =
new();
[Button, Title( "Validate Weapons" ), Description( "Validate the shared OMR weapon prefab catalog." )]
public void ValidateWeaponCatalog()
{
int errors = 0;
int warnings = 0;
if (
SandboxWeapons is null ||
SandboxWeapons.Count == 0
)
{
Log.Error(
"WEAPON CATALOG | FAIL | No weapon prefabs are assigned."
);
return;
}
HashSet<string> ids =
new(
StringComparer.OrdinalIgnoreCase
);
Dictionary<OMRWeaponArchetype, int> classCounts =
new();
int validWeapons = 0;
foreach (
GameObject prefab in SandboxWeapons
)
{
if (
prefab is null ||
!prefab.IsValid
)
{
errors++;
Log.Error(
"WEAPON CATALOG | Missing/invalid prefab reference."
);
continue;
}
OneMoreRoundWeapon weapon =
OMRWeaponUtility.GetWeapon(
prefab
);
if ( weapon is null )
{
errors++;
Log.Error(
$"WEAPON CATALOG | '{prefab.Name}' has no OneMoreRoundWeapon component."
);
continue;
}
validWeapons++;
weapon.ValidateWeaponSetupInternal(
ref errors,
ref warnings,
prefab.Name
);
string weaponId =
weapon.WeaponId?.Trim() ??
string.Empty;
if (
!string.IsNullOrWhiteSpace(
weaponId
) &&
!ids.Add(
weaponId
)
)
{
errors++;
Log.Error(
$"WEAPON CATALOG | Duplicate WeaponId '{weaponId}'."
);
}
if (
!classCounts.TryGetValue(
weapon.WeaponArchetype,
out int count
)
)
{
count = 0;
}
classCounts[weapon.WeaponArchetype] =
count + 1;
}
string classes =
string.Join(
", ",
classCounts
.OrderBy(
x => x.Key.ToString()
)
.Select(
x => $"{x.Key}:{x.Value}"
)
);
if ( errors == 0 )
{
Log.Info(
$"WEAPON CATALOG | PASS | Weapons:{validWeapons} | {classes} | Warnings:{warnings}"
);
}
else
{
Log.Error(
$"WEAPON CATALOG | FAIL | Weapons:{validWeapons} | Errors:{errors} | Warnings:{warnings}"
);
}
}
[Property, Group( "Sandbox" )]
public float SandboxRespawnDelay { get; set; } = 0.75f;
[Property, Group( "Sandbox / Spawn Safety" )]
public bool SandboxSafeRespawns { get; set; } = true;
[Property, Group( "Sandbox / Spawn Safety" )]
public float SandboxSpawnDangerDistance { get; set; } = 360f;
[Property, Group( "Sandbox / Spawn Safety" )]
public float SandboxVisibleSpawnPenalty { get; set; } = 620f;
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox" )]
public int SandboxWeaponAIndex { get; set; } = 0;
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox" )]
public int SandboxWeaponBIndex { get; set; } = 0;
// ============================================================
// SANDBOX BOT HARNESS (BOT 0 -> BOT 6 ADAPTIVE)
// ============================================================
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
public bool SandboxBotEnabled { get; set; } = false;
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
public OMRBotDifficulty SandboxBotDifficulty { get; set; } =
OMRBotDifficulty.Normal;
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
public OMRBotBehaviorMode SandboxBotBehavior { get; set; } =
OMRBotBehaviorMode.TacticalCombat;
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
public string SandboxBotName { get; set; } = "ROOK";
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
public bool SandboxBotDrawNavMesh { get; set; } = false;
/// <summary>
/// BOT 6 sandbox-only round-rule selector. This lets the bot and the human
/// tester exercise modifier-aware movement/combat before match bot integration
/// exists. High Noon is intentionally excluded from the selector because its
/// draw/lock flow belongs to the real match state machine.
/// </summary>
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
public RoundModifier SandboxBotTestModifier { get; set; } =
RoundModifier.Standard;
/// <summary>
/// Development-only sandbox option for exercising normal inventory switching,
/// equip/holster presentation and reload cancellation. Match and lobby loadouts
/// are unaffected. Keep this disabled for normal OMR play.
/// </summary>
[Property, Group( "Sandbox / Inventory Testing" )]
public bool EnableSandboxMultiWeaponTesting { get; set; } = false;
/// <summary>
/// Extra weapon prefabs granted after the normal selected sandbox weapon. The
/// selected weapon is forced into slot 0; these are placed into slots 1, 2, ...
/// so Slot1/Slot2/etc. are deterministic during testing.
/// </summary>
[Property, Group( "Sandbox / Inventory Testing" )]
public List<GameObject> SandboxAdditionalWeapons { get; set; } = new();
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox" )]
public int SandboxSpawnALayoutIndex { get; set; } = 0;
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox" )]
public int SandboxCenterLayoutIndex { get; set; } = 0;
[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox" )]
public int SandboxSpawnBLayoutIndex { get; set; } = 0;
// ============================================================
// MATCH CONFIG
// ============================================================
[Property]
public float CountdownDuration { get; set; } = 3.0f;
[Property]
public float RoundEndDuration { get; set; } = 2.0f;
[Property]
public float DecisionDuration { get; set; } = 10.0f;
/// <summary>
/// Maximum duration of normal combat before health decides
/// the round.
/// </summary>
[Property]
public float FightDuration { get; set; } = 60.0f;
/// <summary>
/// Extra time granted only when both players finish normal
/// combat at full health.
///
/// During this phase any weapon hit is lethal.
/// </summary>
[Property]
public float SuddenDeathDuration { get; set; } = 10.0f;
/// <summary>
/// Small tolerance used when comparing synchronized health
/// values at timeout.
/// </summary>
[Property]
public float HealthTieTolerance { get; set; } = 0.01f;
// ============================================================
// HIGH NOON CONFIG
// ============================================================
/// <summary>
/// Random delay after the normal round countdown before DRAW opens.
/// </summary>
[Property, Group( "High Noon" ), Range( 0.1f, 3f )]
public float HighNoonMinDrawDelay { get; set; } = 0.70f;
[Property, Group( "High Noon" ), Range( 0.1f, 3f )]
public float HighNoonMaxDrawDelay { get; set; } = 1.60f;
/// <summary>
/// Time available to fire after DRAW before the duel becomes a draw.
/// </summary>
[Property, Group( "High Noon" ), Range( 1f, 20f )]
public float HighNoonDrawDuration { get; set; } = 10.0f;
/// <summary>
/// Small host adjudication window after the first reported trigger. This lets
/// both synchronized Time.Now reaction timestamps arrive before choosing a
/// winner, rather than treating RPC arrival order as reaction speed.
/// </summary>
[Property, Group( "High Noon" ), Range( 0.02f, 0.5f )]
public float HighNoonAdjudicationWindow { get; set; } = 0.15f;
/// <summary>
/// Trigger times this close are considered simultaneous and resolve as a draw.
/// </summary>
[Property, Group( "High Noon" ), Range( 0f, 0.05f )]
public float HighNoonTieTolerance { get; set; } = 0.015f;
/// <summary>
/// First player to BANK this many points wins the match.
/// </summary>
[Property]
public int TargetScore { get; set; } = 7;
/// <summary>
/// Maximum amount of unbanked points a player may hold.
///
/// Reaching this value automatically banks the pot.
/// </summary>
[Property]
public int MaxPot { get; set; } = 7;
/// <summary>
/// Extra unbanked points awarded when the current pot owner wins after
/// choosing ONE MORE ROUND.
///
/// With InitialPot = 1 and this value at 2, the risk ladder is:
/// 1 -> 3 -> 5 -> 7.
/// </summary>
[Property, Range( 1, 10 )]
public int OneMoreRoundPotGain { get; set; } = 2;
// ============================================================
// SESSION STATE
// ============================================================
[Property, Sync( SyncFlags.FromHost )]
public SessionMode Mode { get; set; } =
SessionMode.Menu;
[Property, Sync( SyncFlags.FromHost )]
public RoundPhase Phase { get; set; } =
RoundPhase.Waiting;
// ============================================================
// ROUND STATE
// ============================================================
[Property, Sync( SyncFlags.FromHost )]
public int RoundNumber { get; set; } = 1;
[Property, Sync( SyncFlags.FromHost )]
public int ScoreA { get; set; } = 0;
[Property, Sync( SyncFlags.FromHost )]
public int ScoreB { get; set; } = 0;
[Sync( SyncFlags.FromHost )]
public float PhaseEndsAt { get; set; }
[Sync( SyncFlags.FromHost )]
public float FightMessageEndsAt { get; set; }
/// <summary>
/// Absolute time when the current combat phase ends.
///
/// During normal combat this is the 60 second round timer.
/// During sudden death it becomes the 10 second one-shot timer.
/// </summary>
[Sync( SyncFlags.FromHost )]
public float FightEndsAt { get; set; }
/// <summary>
/// True only during the special full-health tie breaker.
/// </summary>
[Property, Sync( SyncFlags.FromHost )]
public bool IsSuddenDeath { get; set; }
/// <summary>
/// Network-visible High Noon sub-state. Time.Now is synchronized, so clients
/// can present the same DRAW moment from HighNoonDrawAt.
/// </summary>
[Property, Sync( SyncFlags.FromHost )]
public HighNoonStage HighNoonState { get; set; } =
HighNoonStage.Inactive;
[Sync( SyncFlags.FromHost )]
public float HighNoonDrawAt { get; set; }
[Sync( SyncFlags.FromHost )]
public float HighNoonFirstTriggerAt { get; set; }
[Sync( SyncFlags.FromHost )]
public int HighNoonFirstTriggerTeam { get; set; } = -1;
[Sync( SyncFlags.FromHost )]
public int LastRoundWinner { get; set; } = -1;
// ============================================================
// ONE MORE ROUND STATE
// ============================================================
/// <summary>
/// Amount currently earned but not safely banked.
/// </summary>
[Property, Sync( SyncFlags.FromHost )]
public int CurrentPot { get; set; } = 0;
/// <summary>
/// Team currently holding the unbanked pot.
///
/// -1 = nobody
/// 0 = Player A
/// 1 = Player B
/// </summary>
[Property, Sync( SyncFlags.FromHost )]
public int PotOwnerTeam { get; set; } = -1;
/// <summary>
/// Absolute time when the current decision expires.
/// </summary>
[Sync( SyncFlags.FromHost )]
public float DecisionEndsAt { get; set; }
// ============================================================
// MODIFIER STATE
// ============================================================
/// <summary>
/// Modifier being used by the current combat round.
/// </summary>
[Property, Sync( SyncFlags.FromHost )]
public RoundModifier ActiveModifier { get; set; } =
RoundModifier.Standard;
/// <summary>
/// Two possible next-round conditions revealed to both players before the
/// winner chooses BANK or ONE MORE ROUND. If the winner risks the pot, the
/// loser chooses which one becomes active.
/// </summary>
[Sync( SyncFlags.FromHost )]
public RoundModifier ModifierOptionA { get; set; } =
RoundModifier.HighNoon;
[Sync( SyncFlags.FromHost )]
public RoundModifier ModifierOptionB { get; set; } =
RoundModifier.Standard;
/// <summary>
/// Host-resolved weapon previews for the two revealed OMR conditions.
/// These strings are synchronized so clients never run their own random
/// shuffle merely to render UI.
/// </summary>
[Sync( SyncFlags.FromHost )]
public string ModifierOptionAWeaponPreview { get; set; } =
"RANDOM COMPETITIVE WEAPON";
[Sync( SyncFlags.FromHost )]
public string ModifierOptionBWeaponPreview { get; set; } =
"RANDOM COMPETITIVE WEAPON";
// ============================================================
// MATCH END
// ============================================================
/// <summary>
/// -1 = no match winner
/// 0 = Player A
/// 1 = Player B
/// </summary>
[Property, Sync( SyncFlags.FromHost )]
public int MatchWinnerTeam { get; set; } = -1;
// ============================================================
// LOCAL INTERNAL STATE
// ============================================================
private bool _sandboxPrepared = false;
// GameObject.Id is used instead of Network.OwnerId because host-controlled
// bots legitimately share the host connection's owner id.
private readonly HashSet<Guid> _sandboxInitializedPlayers =
new();
private readonly Dictionary<Guid, float> _sandboxRespawnAt =
new();
private readonly Dictionary<Guid, int> _sandboxLastSpawnSide =
new();
private readonly HashSet<Guid> _lobbyWarmupInitializedPlayers =
new();
private readonly Dictionary<Guid, float> _lobbyWarmupRespawnAt =
new();
private readonly Dictionary<int, float> _highNoonTriggerTimes =
new();
private float _highNoonAdjudicateAt;
// Host-side copies of temporary round weapon modifiers. Held weapons are
// owner-simulated, but the host still owns authoritative ammo/reset state.
// Track exactly which round-rule IDs were applied here so they can be removed
// without disturbing attachment/perk modifiers from other systems.
private bool _modifierOffersPrepared;
private readonly Dictionary<PlayerState, OneMoreRoundWeapon> _hostRoundRuleWeapons =
new();
private readonly Dictionary<PlayerState, HashSet<string>> _hostRoundRuleWeaponModifierIds =
new();
// ============================================================
// LIFECYCLE
// ============================================================
protected override void OnStart()
{
if ( !Networking.IsHost )
return;
EnterMenu();
}
protected override void OnUpdate()
{
if ( !Networking.IsHost )
return;
List<PlayerState> players =
GetPlayers();
switch ( Mode )
{
case SessionMode.Menu:
UpdateMenu();
break;
case SessionMode.Sandbox:
UpdateSandbox(
players
);
break;
case SessionMode.Match:
UpdateMatch(
players
);
break;
}
}
// ============================================================
// MENU
// ============================================================
public void StartMatchMode()
{
if ( !Networking.IsHost )
return;
Mode =
SessionMode.Match;
ResetBotMatchDecisionState();
if ( !MatchBotEnabled )
GetBotManager()?.DestroyAllBots();
Phase =
RoundPhase.Waiting;
ScoreA =
0;
ScoreB =
0;
RoundNumber =
1;
LastRoundWinner =
-1;
CurrentPot =
0;
PotOwnerTeam =
-1;
ActiveModifier =
RoundModifier.Standard;
Arena?.ResetMatchSequence();
GetRoundLoadoutDirector()
?.ResetMatchSequence();
GetRoundRuleDirector()
?.ResetMatchSequence();
_modifierOffersPrepared =
false;
ClearRoundRuleRuntime(
GetPlayers()
);
ModifierOptionA =
RoundModifier.HighNoon;
ModifierOptionB =
RoundModifier.Standard;
ModifierOptionAWeaponPreview =
"RANDOM COMPETITIVE WEAPON";
ModifierOptionBWeaponPreview =
"RANDOM COMPETITIVE WEAPON";
MatchWinnerTeam =
-1;
DecisionEndsAt =
0f;
PhaseEndsAt =
0f;
FightEndsAt =
0f;
FightMessageEndsAt =
0f;
IsSuddenDeath =
false;
ResetHighNoonRuntimeState(
GetPlayers(),
clearOwnerState: true
);
SetSuddenDeathMode(
GetPlayers(),
false
);
_sandboxPrepared =
false;
_lobbyWarmupInitializedPlayers.Clear();
_lobbyWarmupRespawnAt.Clear();
foreach (
PlayerState player in
GetPlayers()
)
{
player.PreMatchState =
PreMatchPlayerState.Lobby;
RestoreStartingLoadout(
player
);
}
foreach (
PlayerState player in
GetPlayers()
)
{
player.SetMenuControls(
false
);
player.SetRoundControls(
false
);
}
EnterWaiting();
GetBotManager()?.SyncNow();
Log.Info(
"SESSION MODE | MATCH"
);
}
public void StartSandboxMode()
{
if ( !Networking.IsHost )
return;
MatchBotEnabled = false;
Mode =
SessionMode.Sandbox;
Phase =
RoundPhase.Fighting;
_sandboxPrepared =
false;
_sandboxInitializedPlayers.Clear();
_sandboxRespawnAt.Clear();
_sandboxLastSpawnSide.Clear();
_lobbyWarmupInitializedPlayers.Clear();
_lobbyWarmupRespawnAt.Clear();
ClearRoundRuleRuntime(
GetPlayers()
);
foreach (
PlayerState player in
GetPlayers()
)
{
player.PreMatchState =
PreMatchPlayerState.Lobby;
}
foreach (
PlayerState player in
GetPlayers()
)
{
player.SetMenuControls(
false
);
player.SetRoundControls(
false
);
}
GetBotManager()?.SyncNow();
Log.Info(
"SESSION MODE | SANDBOX"
);
}
// ============================================================
// PRE-MATCH LOBBY
// ============================================================
public void BeginPreMatchLobby()
{
if ( !Networking.IsHost )
return;
List<PlayerState> players =
GetPlayers();
AssignTeams(
players
);
_lobbyWarmupInitializedPlayers.Clear();
_lobbyWarmupRespawnAt.Clear();
if ( Arena is not null )
{
Arena.PrepareLobby();
}
foreach (
PlayerState player in
players
)
{
if ( !player.IsReady )
continue;
player.PreMatchState =
PreMatchPlayerState.Lobby;
player.WarmupWeaponIndex =
ClampWarmupWeaponIndex(
player.WarmupWeaponIndex
);
PlayerHealth health =
player.Components
.Get<PlayerHealth>();
health?.ResetHealth();
player.SetRoundControls(
false
);
player.SetMenuControls(
true
);
}
Log.Info(
"PRE-MATCH LOBBY | BOTH PLAYERS CONNECTED"
);
}
public void EndPreMatchLobby()
{
if ( !Networking.IsHost )
return;
_lobbyWarmupInitializedPlayers.Clear();
_lobbyWarmupRespawnAt.Clear();
foreach (
PlayerState player in
GetPlayers()
)
{
player.PreMatchState =
PreMatchPlayerState.Lobby;
player.SetRoundControls(
false
);
player.SetMenuControls(
true
);
}
}
public bool ArePreMatchPlayersReady()
{
if ( !Networking.IsHost )
return false;
PlayerState[] players =
GetPlayers()
.Where(
x =>
x.IsReady
)
.Take( 2 )
.ToArray();
if ( players.Length < 2 )
return false;
return players.All(
x =>
x.PreMatchState ==
PreMatchPlayerState.Ready
);
}
public void RequestPreMatchReady()
{
if ( !IsPreMatchLobbyActive() )
return;
if ( Networking.IsHost )
{
TogglePreMatchReady(
GetLocalPlayer()
);
return;
}
RequestPreMatchReadyHost();
}
[Rpc.Host]
private void RequestPreMatchReadyHost()
{
if (
!Networking.IsHost ||
!IsPreMatchLobbyActive()
)
return;
TogglePreMatchReady(
GetPlayerByOwnerId(
Rpc.CallerId
)
);
}
private void TogglePreMatchReady(
PlayerState player
)
{
if (
player is null ||
!player.IsReady
)
return;
bool wasReady =
player.PreMatchState ==
PreMatchPlayerState.Ready;
StopPreMatchWarmup(
player,
keepLobbyState: true
);
player.PreMatchState =
wasReady
? PreMatchPlayerState.Lobby
: PreMatchPlayerState.Ready;
Log.Info(
$"PRE-MATCH | {player.Network.OwnerId} | {player.PreMatchState}"
);
}
public void RequestPreMatchWarmup()
{
if ( !IsPreMatchLobbyActive() )
return;
if ( Networking.IsHost )
{
TogglePreMatchWarmup(
GetLocalPlayer()
);
return;
}
RequestPreMatchWarmupHost();
}
[Rpc.Host]
private void RequestPreMatchWarmupHost()
{
if (
!Networking.IsHost ||
!IsPreMatchLobbyActive()
)
return;
TogglePreMatchWarmup(
GetPlayerByOwnerId(
Rpc.CallerId
)
);
}
private void TogglePreMatchWarmup(
PlayerState player
)
{
if (
player is null ||
!player.IsReady
)
return;
if (
player.PreMatchState ==
PreMatchPlayerState.Warmup
)
{
StopPreMatchWarmup(
player,
keepLobbyState: false
);
return;
}
player.PreMatchState =
PreMatchPlayerState.Warmup;
player.SetMenuControls(
false
);
player.SetRoundControls(
false
);
_lobbyWarmupInitializedPlayers.Remove(
player.Network.OwnerId
);
_lobbyWarmupRespawnAt.Remove(
player.Network.OwnerId
);
}
private void StopPreMatchWarmup(
PlayerState player,
bool keepLobbyState
)
{
if ( player is null )
return;
_lobbyWarmupInitializedPlayers.Remove(
player.Network.OwnerId
);
_lobbyWarmupRespawnAt.Remove(
player.Network.OwnerId
);
player.SetRoundControls(
false
);
player.SetMenuControls(
true
);
if ( !keepLobbyState )
{
player.PreMatchState =
PreMatchPlayerState.Lobby;
}
}
public void RequestCyclePreMatchWeapon(
int direction
)
{
if ( !IsPreMatchLobbyActive() )
return;
direction =
Math.Sign(
direction
);
if ( direction == 0 )
return;
if ( Networking.IsHost )
{
CyclePreMatchWeapon(
GetLocalPlayer(),
direction
);
return;
}
RequestCyclePreMatchWeaponHost(
direction
);
}
[Rpc.Host]
private void RequestCyclePreMatchWeaponHost(
int direction
)
{
if (
!Networking.IsHost ||
!IsPreMatchLobbyActive()
)
return;
CyclePreMatchWeapon(
GetPlayerByOwnerId(
Rpc.CallerId
),
Math.Sign(
direction
)
);
}
private void CyclePreMatchWeapon(
PlayerState player,
int direction
)
{
if ( player is null )
return;
int weaponCount =
GetPreMatchWeaponCount();
if ( weaponCount <= 0 )
return;
if (
player.PreMatchState ==
PreMatchPlayerState.Ready
)
{
player.PreMatchState =
PreMatchPlayerState.Lobby;
}
player.WarmupWeaponIndex =
CycleIndex(
ClampWarmupWeaponIndex(
player.WarmupWeaponIndex
),
direction,
weaponCount
);
if (
player.PreMatchState ==
PreMatchPlayerState.Warmup
)
{
_lobbyWarmupInitializedPlayers.Remove(
player.Network.OwnerId
);
}
}
public string GetPreMatchWeaponName(
PlayerState player
)
{
GameObject prefab =
GetPreMatchWeaponPrefab(
player
);
if ( prefab is null )
return "NO WEAPON";
return OMRWeaponUtility
.GetDisplayName(
prefab
)
.ToUpperInvariant();
}
private bool IsPreMatchLobbyActive()
{
if ( Mode != SessionMode.Menu )
return false;
GameFlowManager flow =
GameObject.Components
.Get<GameFlowManager>();
return
flow is not null &&
flow.OnlineState ==
OnlineFlowState.Lobby;
}
/// <summary>
/// Applies a local pause/menu state to the caller without pausing
/// the network session itself.
///
/// The host handles the authoritative player-control change. When
/// resuming, controls are restored only if the current game phase
/// actually allows that player to move and use their weapon.
/// </summary>
public void RequestPauseState(
bool paused
)
{
if (
Mode != SessionMode.Sandbox &&
Mode != SessionMode.Match
)
return;
if ( Networking.IsHost )
{
PlayerState localPlayer =
GetLocalPlayer();
ApplyPauseStateToPlayer(
localPlayer,
paused
);
return;
}
RequestPauseStateHost(
paused
);
}
[Rpc.Host]
private void RequestPauseStateHost(
bool paused
)
{
if (
!Networking.IsHost ||
(
Mode != SessionMode.Sandbox &&
Mode != SessionMode.Match
)
)
return;
PlayerState caller =
GetPlayerByOwnerId(
Rpc.CallerId
);
ApplyPauseStateToPlayer(
caller,
paused
);
}
private void ApplyPauseStateToPlayer(
PlayerState player,
bool paused
)
{
if ( player is null )
return;
if ( paused )
{
player.SetMenuControls(
true
);
return;
}
player.SetMenuControls(
false
);
player.SetRoundControls(
ShouldPlayerHaveGameplayControls(
player
)
);
}
private bool ShouldPlayerHaveGameplayControls(
PlayerState player
)
{
if (
player is null ||
!player.IsAlive
)
return false;
return Mode switch
{
SessionMode.Sandbox =>
true,
SessionMode.Match =>
Phase ==
RoundPhase.Fighting,
_ =>
false
};
}
/// <summary>
/// Compatibility wrapper retained for any older Sandbox UI path.
/// </summary>
public void RequestSandboxControls(
bool enabled
)
{
if ( Mode != SessionMode.Sandbox )
return;
RequestPauseState(
!enabled
);
}
/// <summary>
/// Host-only convenience command for the sandbox overlay.
/// Re-applies the configured arena and loadouts, then respawns both players.
/// </summary>
public void ResetSandboxPlayersNow()
{
if (
!Networking.IsHost ||
Mode != SessionMode.Sandbox
)
return;
_sandboxPrepared =
false;
_sandboxInitializedPlayers.Clear();
_sandboxRespawnAt.Clear();
_sandboxLastSpawnSide.Clear();
}
/// <summary>
/// Sandbox bot-harness reset. Re-initializes only the bot so navigation,
/// perception and combat experiments can be repeated without disturbing the
/// human tester's position/state.
/// </summary>
public void ResetSandboxBotNow()
{
if (
!Networking.IsHost ||
Mode != SessionMode.Sandbox
)
return;
PlayerState bot =
GetPlayers()
.FirstOrDefault( x => x is not null && x.IsBot );
if ( bot?.GameObject is null )
{
GetBotManager()?.SyncNow();
return;
}
Guid botId =
bot.GameObject.Id;
_sandboxInitializedPlayers.Remove(
botId
);
_sandboxRespawnAt.Remove(
botId
);
}
// ============================================================
// SANDBOX SETUP UI
// ============================================================
public void SetSandboxBotEnabled(
bool enabled
)
{
if ( !Networking.IsHost )
return;
SandboxBotEnabled = enabled;
GetBotManager()?.SyncNow();
}
public void ToggleSandboxBot()
{
SetSandboxBotEnabled(
!SandboxBotEnabled
);
}
public void CycleSandboxBotDifficulty(
int direction
)
{
if ( !Networking.IsHost )
return;
int count =
Enum.GetValues<OMRBotDifficulty>().Length;
SandboxBotDifficulty =
(OMRBotDifficulty)CycleIndex(
(int)SandboxBotDifficulty,
direction,
count
);
GetBotManager()?.SyncNow();
}
public string GetSandboxBotDifficultyName() =>
SandboxBotDifficulty
.ToString()
.ToUpperInvariant();
public void CycleSandboxBotBehavior(
int direction
)
{
if ( !Networking.IsHost )
return;
int count =
Enum.GetValues<OMRBotBehaviorMode>().Length;
SandboxBotBehavior =
(OMRBotBehaviorMode)CycleIndex(
(int)SandboxBotBehavior,
direction,
count
);
GetBotManager()?.SyncNow();
}
public void ToggleSandboxBotDrawNavMesh()
{
if ( !Networking.IsHost )
return;
SandboxBotDrawNavMesh =
!SandboxBotDrawNavMesh;
GetBotManager()?.SyncNow();
}
public void CycleSandboxBotModifier( int direction )
{
if ( !Networking.IsHost )
return;
RoundModifier[] sandboxModifiers =
{
RoundModifier.Standard,
RoundModifier.LowGravity,
RoundModifier.HeadshotOnly,
RoundModifier.LargeCaliber,
RoundModifier.DoubleJump,
RoundModifier.HipfireOnly,
RoundModifier.Blitz
};
int current =
Array.IndexOf(
sandboxModifiers,
SandboxBotTestModifier
);
if ( current < 0 )
current = 0;
SandboxBotTestModifier =
sandboxModifiers[CycleIndex( current, direction, sandboxModifiers.Length )];
// Reset both sides so the physical rule state, weapon modifiers and health
// are deterministic. The bot component itself survives, so its learned
// opponent model is intentionally preserved across rule A/B tests.
ResetSandboxPlayersNow();
}
public string GetSandboxBotModifierName() =>
GetModifierDisplayName( SandboxBotTestModifier ).ToUpperInvariant();
public string GetSandboxBotBehaviorName() =>
SandboxBotBehavior switch
{
OMRBotBehaviorMode.Stationary =>
"STATIONARY / FOUNDATION",
OMRBotBehaviorMode.NavigateToOpponent =>
"NAVIGATE TO PLAYER / BOT 1",
OMRBotBehaviorMode.PerceptionSearch =>
"PERCEPTION + SEARCH / BOT 2",
OMRBotBehaviorMode.Combat =>
"COMBAT / BOT 3",
OMRBotBehaviorMode.CombatMovement =>
"COMBAT + MOVEMENT / BOT 4",
OMRBotBehaviorMode.TacticalCombat =>
"ADAPTIVE TACTICAL / BOT 6",
_ =>
SandboxBotBehavior.ToString().ToUpperInvariant()
};
public void CycleSandboxWeapon(
int teamId,
int direction
)
{
if ( !Networking.IsHost )
return;
int count =
SandboxWeapons?.Count ??
0;
if ( count <= 0 )
return;
if ( teamId == 0 )
{
SandboxWeaponAIndex =
CycleIndex(
SandboxWeaponAIndex,
direction,
count
);
}
else if ( teamId == 1 )
{
SandboxWeaponBIndex =
CycleIndex(
SandboxWeaponBIndex,
direction,
count
);
}
}
public void CycleSandboxSpawnALayout(
int direction
)
{
if (
!Networking.IsHost ||
Arena is null
)
return;
SandboxSpawnALayoutIndex =
CycleIndex(
SandboxSpawnALayoutIndex,
direction,
Arena.SpawnALayoutCount
);
}
public void CycleSandboxCenterLayout(
int direction
)
{
if (
!Networking.IsHost ||
Arena is null
)
return;
SandboxCenterLayoutIndex =
CycleIndex(
SandboxCenterLayoutIndex,
direction,
Arena.CenterLayoutCount
);
}
public void CycleSandboxSpawnBLayout(
int direction
)
{
if (
!Networking.IsHost ||
Arena is null
)
return;
SandboxSpawnBLayoutIndex =
CycleIndex(
SandboxSpawnBLayoutIndex,
direction,
Arena.SpawnBLayoutCount
);
}
public string GetSandboxWeaponName(
int teamId
)
{
GameObject prefab =
GetSandboxWeaponPrefab(
teamId
);
if ( prefab is null )
return "CURRENT LOADOUT";
return OMRWeaponUtility
.GetDisplayName(
prefab
)
.ToUpperInvariant();
}
public string GetSandboxSpawnALayoutName()
{
return Arena?.GetSpawnALayoutName(
SandboxSpawnALayoutIndex
) ?? "NONE";
}
public string GetSandboxCenterLayoutName()
{
return Arena?.GetCenterLayoutName(
SandboxCenterLayoutIndex
) ?? "NONE";
}
public string GetSandboxSpawnBLayoutName()
{
return Arena?.GetSpawnBLayoutName(
SandboxSpawnBLayoutIndex
) ?? "NONE";
}
public void ReturnToMenu()
{
if ( !Networking.IsHost )
return;
EnterMenu();
}
private void EnterMenu()
{
MatchBotEnabled = false;
ResetBotMatchDecisionState();
Mode =
SessionMode.Menu;
Phase =
RoundPhase.Waiting;
_sandboxPrepared =
false;
_sandboxInitializedPlayers.Clear();
_sandboxRespawnAt.Clear();
_sandboxLastSpawnSide.Clear();
GetBotManager()?.DestroyAllBots();
_lobbyWarmupInitializedPlayers.Clear();
_lobbyWarmupRespawnAt.Clear();
LastRoundWinner =
-1;
CurrentPot =
0;
PotOwnerTeam =
-1;
ActiveModifier =
RoundModifier.Standard;
_modifierOffersPrepared =
false;
MatchWinnerTeam =
-1;
DecisionEndsAt =
0f;
FightEndsAt =
0f;
FightMessageEndsAt =
0f;
IsSuddenDeath =
false;
SetSuddenDeathMode(
GetPlayers(),
false
);
ClearRoundRuleRuntime(
GetPlayers()
);
foreach (
PlayerState player in
GetPlayers()
)
{
player.SetRoundControls(
false
);
player.SetMenuControls(
true
);
}
Log.Info(
"SESSION MODE | MENU"
);
}
private void UpdateMenu()
{
if ( !IsPreMatchLobbyActive() )
return;
UpdatePreMatchLobby(
GetPlayers()
);
}
private void UpdatePreMatchLobby(
List<PlayerState> players
)
{
AssignTeams(
players
);
HashSet<Guid> connectedOwners =
players
.Select(
x =>
x.Network.OwnerId
)
.ToHashSet();
_lobbyWarmupInitializedPlayers.RemoveWhere(
ownerId =>
!connectedOwners.Contains(
ownerId
)
);
foreach (
Guid ownerId in
_lobbyWarmupRespawnAt.Keys
.Where(
x =>
!connectedOwners.Contains(
x
)
)
.ToList()
)
{
_lobbyWarmupRespawnAt.Remove(
ownerId
);
}
foreach (
PlayerState player in
players
)
{
if (
!player.IsReady ||
player.PreMatchState !=
PreMatchPlayerState.Warmup
)
continue;
Guid ownerId =
player.Network.OwnerId;
if (
!_lobbyWarmupInitializedPlayers.Contains(
ownerId
)
)
{
InitializePreMatchWarmupPlayer(
player
);
continue;
}
if ( player.IsAlive )
{
_lobbyWarmupRespawnAt.Remove(
ownerId
);
continue;
}
if (
!_lobbyWarmupRespawnAt.TryGetValue(
ownerId,
out float respawnAt
)
)
{
_lobbyWarmupRespawnAt[ownerId] =
Time.Now +
MathF.Max(
LobbyWarmupRespawnDelay,
0f
);
continue;
}
if ( Time.Now < respawnAt )
continue;
ResetPreMatchWarmupPlayer(
player
);
_lobbyWarmupRespawnAt.Remove(
ownerId
);
}
}
private void InitializePreMatchWarmupPlayer(
PlayerState player
)
{
ApplyWeaponLoadout(
player,
GetPreMatchWeaponPrefab(
player
)
);
ResetPreMatchWarmupPlayer(
player
);
_lobbyWarmupInitializedPlayers.Add(
player.Network.OwnerId
);
Log.Info(
$"PRE-MATCH WARMUP | TEAM {GetTeamName( player.TeamId )} | {GetPreMatchWeaponName( player )}"
);
}
private void ResetPreMatchWarmupPlayer(
PlayerState player
)
{
GameObject spawn =
player.TeamId == 1
? SpawnB
: SpawnA;
GameObject lookAtSpawn =
player.TeamId == 1
? SpawnA
: SpawnB;
ResetPlayer(
player,
spawn,
lookAtSpawn
);
player.SetMenuControls(
false
);
player.SetRoundControls(
true
);
}
// ============================================================
// SANDBOX / PRACTICE
// ============================================================
private void UpdateSandbox(
List<PlayerState> players
)
{
AssignTeams(
players
);
if ( !_sandboxPrepared )
{
if ( Arena is not null )
{
Arena.PrepareSandbox(
SandboxSpawnALayoutIndex,
SandboxCenterLayoutIndex,
SandboxSpawnBLayoutIndex
);
}
_sandboxPrepared =
true;
}
HashSet<Guid> connectedPlayerIds =
players
.Where( x => x?.GameObject is not null )
.Select( x => x.GameObject.Id )
.ToHashSet();
_sandboxInitializedPlayers.RemoveWhere(
playerId =>
!connectedPlayerIds.Contains( playerId )
);
foreach (
Guid playerId in
_sandboxLastSpawnSide.Keys
.Where( x => !connectedPlayerIds.Contains( x ) )
.ToList()
)
{
_sandboxLastSpawnSide.Remove( playerId );
}
foreach (
Guid playerId in
_sandboxRespawnAt.Keys
.Where( x => !connectedPlayerIds.Contains( x ) )
.ToList()
)
{
_sandboxRespawnAt.Remove(
playerId
);
}
foreach (
PlayerState player in
players
)
{
if (
!player.IsReady ||
(
player.TeamId != 0 &&
player.TeamId != 1
)
)
{
player.SetRoundControls(
false
);
continue;
}
Guid playerId =
player.GameObject.Id;
if (
!_sandboxInitializedPlayers.Contains(
playerId
)
)
{
InitializeSandboxPlayer(
player
);
continue;
}
if ( player.IsAlive )
{
_sandboxRespawnAt.Remove(
playerId
);
continue;
}
if (
!_sandboxRespawnAt.TryGetValue(
playerId,
out float respawnAt
)
)
{
_sandboxRespawnAt[playerId] =
Time.Now +
MathF.Max(
SandboxRespawnDelay,
0f
);
continue;
}
if ( Time.Now < respawnAt )
continue;
ResetSandboxPlayer(
player
);
_sandboxRespawnAt.Remove(
playerId
);
}
Phase =
RoundPhase.Fighting;
}
private void InitializeSandboxPlayer(
PlayerState player
)
{
ApplySandboxLoadout(
player
);
ResetSandboxPlayer(
player
);
_sandboxInitializedPlayers.Add(
player.GameObject.Id
);
Log.Info(
$"SANDBOX PLAYER READY | {player.DisplayName} | TEAM {GetTeamName( player.TeamId )} | BOT:{player.IsBot} | OBJECT {player.GameObject.Id}"
);
}
private void ResetSandboxPlayer(
PlayerState player
)
{
GameObject spawn =
SelectSafeSandboxSpawn(
player,
out GameObject lookAtSpawn
);
ResetPlayer(
player,
spawn,
lookAtSpawn
);
player.SetMenuControls(
false
);
player.SetRoundControls(
true
);
ApplySandboxRuleRuntime( player );
}
private GameObject SelectSafeSandboxSpawn(
PlayerState player,
out GameObject lookAtSpawn
)
{
GameObject preferred =
player?.TeamId == 0
? SpawnA
: SpawnB;
GameObject alternate =
player?.TeamId == 0
? SpawnB
: SpawnA;
lookAtSpawn = alternate;
if (
!SandboxSafeRespawns ||
player?.GameObject is null ||
SpawnA is null ||
SpawnB is null
)
return preferred ?? alternate;
List<PlayerState> opponents =
GetPlayers()
.Where(
x =>
x?.GameObject is not null &&
x.GameObject.Id != player.GameObject.Id &&
x.IsAlive
)
.ToList();
if ( opponents.Count == 0 )
return preferred ?? alternate;
float scoreA = ScoreSandboxSpawn( player, SpawnA, 0, opponents );
float scoreB = ScoreSandboxSpawn( player, SpawnB, 1, opponents );
int chosenSide = scoreB > scoreA ? 1 : 0;
GameObject chosen = chosenSide == 0 ? SpawnA : SpawnB;
_sandboxLastSpawnSide[player.GameObject.Id] = chosenSide;
// Face the opposite arena anchor, not the opponent's live transform. The
// safe-spawn system may use enemy position/LOS to avoid a spawn camp, but it
// does not hand the respawning player free tracking information.
lookAtSpawn = chosenSide == 0 ? SpawnB : SpawnA;
return chosen;
}
private float ScoreSandboxSpawn(
PlayerState respawningPlayer,
GameObject spawn,
int side,
List<PlayerState> opponents
)
{
if ( spawn is null )
return float.MinValue;
float score = 0f;
float dangerDistance =
MathF.Max( 120f, SandboxSpawnDangerDistance );
foreach ( PlayerState opponent in opponents )
{
float distance =
( opponent.WorldPosition - spawn.WorldPosition )
.WithZ( 0f )
.Length;
// Distance is useful, but direct line of sight is the stronger signal.
score += MathF.Min( distance, 1200f ) * 0.34f;
if ( distance < dangerDistance )
{
float proximity =
1f - MathX.Clamp( distance / dangerDistance, 0f, 1f );
score -= 720f * proximity;
}
if ( IsSandboxSpawnVisibleToOpponent( spawn, opponent, respawningPlayer ) )
score -= MathF.Max( 100f, SandboxVisibleSpawnPenalty );
else
score += 185f;
}
if (
respawningPlayer?.GameObject is not null &&
_sandboxLastSpawnSide.TryGetValue(
respawningPlayer.GameObject.Id,
out int previousSide
) &&
previousSide == side
)
{
// A modest anti-repeat term prevents a spawn camper from learning that
// every death returns the victim to the same anchor.
score -= 130f;
}
// Preserve the conventional team side when safety is otherwise equivalent.
if ( respawningPlayer?.TeamId == side )
score += 18f;
return score;
}
private bool IsSandboxSpawnVisibleToOpponent(
GameObject spawn,
PlayerState opponent,
PlayerState respawningPlayer
)
{
if (
Scene is null ||
spawn is null ||
opponent?.GameObject is null
)
return false;
PlayerController controller =
opponent.Components.Get<PlayerController>();
Vector3 from =
controller?.IsValid == true
? controller.EyePosition
: opponent.WorldPosition + Vector3.Up * 58f;
Vector3 to =
spawn.WorldPosition + Vector3.Up * 48f;
SceneTrace traceBuilder =
Scene.Trace
.Ray( from, to )
.IgnoreGameObjectHierarchy( opponent.GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false );
if ( respawningPlayer?.GameObject is not null )
traceBuilder =
traceBuilder.IgnoreGameObjectHierarchy( respawningPlayer.GameObject );
SceneTraceResult trace = traceBuilder.Run();
return !trace.Hit;
}
private void ApplySandboxRuleRuntime( PlayerState player )
{
if (
!Networking.IsHost ||
Mode != SessionMode.Sandbox ||
player is null
)
return;
OMRRoundRuleProfile rule =
ResolveRoundRule( SandboxBotTestModifier );
bool headshotOnly =
rule?.HeadshotOnly ??
SandboxBotTestModifier == RoundModifier.HeadshotOnly;
player.Components
.Get<PlayerHealth>()?
.SetWeaponHeadshotOnlyMode( headshotOnly );
player.ApplyRoundRule( SandboxBotTestModifier );
ApplyRoundWeaponModifiersHost( player, rule );
}
private void ApplySandboxLoadout(
PlayerState player
)
{
GameObject primaryWeapon =
GetSandboxWeaponPrefab(
player.TeamId
);
if ( !EnableSandboxMultiWeaponTesting )
{
ApplyWeaponLoadout(
player,
primaryWeapon
);
return;
}
ApplySandboxMultiWeaponTestLoadout(
player,
primaryWeapon
);
}
/// <summary>
/// Sandbox-only test loadout used to validate inventory input and presentation
/// lifecycle events. Normal match/warmup loadouts continue to use the existing
/// single-weapon path.
/// </summary>
private void ApplySandboxMultiWeaponTestLoadout(
PlayerState player,
GameObject primaryWeapon
)
{
if (
!Networking.IsHost ||
player is null ||
primaryWeapon is null
)
return;
BaseInventoryComponent inventory =
player.Components
.Get<BaseInventoryComponent>();
if ( inventory is null )
return;
inventory.ForceHolster();
foreach (
BaseInventoryItem item in
inventory.Items.ToList()
)
{
inventory.Remove(
item
);
}
BaseInventoryItem primaryItem =
inventory.Pickup(
primaryWeapon,
0
);
int nextSlot = 1;
if ( SandboxAdditionalWeapons is not null )
{
foreach ( GameObject extraPrefab in SandboxAdditionalWeapons )
{
if (
extraPrefab is null ||
!extraPrefab.IsValid
)
continue;
if ( nextSlot >= inventory.MaxSlots )
{
Log.Warning(
$"SANDBOX INVENTORY TEST | Inventory has {inventory.MaxSlots} slots; cannot add '{extraPrefab.Name}' to slot {nextSlot + 1}."
);
break;
}
inventory.Pickup(
extraPrefab,
nextSlot
);
nextSlot++;
}
}
if ( primaryItem is not null )
{
inventory.Switch(
primaryItem,
true
);
}
Log.Info(
$"SANDBOX INVENTORY TEST | OWNER {player.Network.OwnerId} | Items:{inventory.Items.Count()} | Slots:{inventory.MaxSlots}"
);
}
private void RestoreStartingLoadout(
PlayerState player
)
{
if (
!Networking.IsHost ||
player is null
)
return;
BaseInventoryComponent inventory =
player.Components
.Get<BaseInventoryComponent>();
if ( inventory is null )
return;
inventory.ForceHolster();
foreach (
BaseInventoryItem item in
inventory.Items.ToList()
)
{
inventory.Remove(
item
);
}
inventory.GiveLoadout();
}
private void ApplyWeaponLoadout(
PlayerState player,
GameObject weaponPrefab
)
{
if (
!Networking.IsHost ||
player is null ||
weaponPrefab is null
)
return;
BaseInventoryComponent inventory =
player.Components
.Get<BaseInventoryComponent>();
if ( inventory is null )
return;
inventory.ForceHolster();
foreach (
BaseInventoryItem item in
inventory.Items.ToList()
)
{
inventory.Remove(
item
);
}
BaseInventoryItem newItem =
inventory.Pickup(
weaponPrefab,
-1
);
if ( newItem is not null )
{
inventory.Switch(
newItem,
true
);
}
}
private GameObject GetSandboxWeaponPrefab(
int teamId
)
{
if (
SandboxWeapons is null ||
SandboxWeapons.Count <= 0
)
return null;
int index =
teamId == 1
? SandboxWeaponBIndex
: SandboxWeaponAIndex;
index =
Math.Clamp(
index,
0,
SandboxWeapons.Count - 1
);
return SandboxWeapons[index];
}
private GameObject GetPreMatchWeaponPrefab(
PlayerState player
)
{
if ( player is null )
return null;
OMRRoundLoadoutDirector director =
GetRoundLoadoutDirector();
if (
director is not null &&
director.CompetitiveWeaponCount > 0
)
{
int index =
Math.Clamp(
player.WarmupWeaponIndex,
0,
director.CompetitiveWeaponCount - 1
);
return director.GetCompetitiveWeapon(
index
);
}
// Compatibility fallback for scenes that have not yet received the
// loadout director component. Sandbox remains functional either way.
if (
SandboxWeapons is null ||
SandboxWeapons.Count <= 0
)
return null;
int fallbackIndex =
Math.Clamp(
player.WarmupWeaponIndex,
0,
SandboxWeapons.Count - 1
);
return SandboxWeapons[fallbackIndex];
}
private int ClampWarmupWeaponIndex(
int index
)
{
int count =
GetPreMatchWeaponCount();
if ( count <= 0 )
return 0;
return Math.Clamp(
index,
0,
count - 1
);
}
private int GetPreMatchWeaponCount()
{
OMRRoundLoadoutDirector director =
GetRoundLoadoutDirector();
if (
director is not null &&
director.CompetitiveWeaponCount > 0
)
{
return director.CompetitiveWeaponCount;
}
return SandboxWeapons?.Count ?? 0;
}
private OMRRoundLoadoutDirector GetRoundLoadoutDirector()
{
return GameObject.Components
.Get<OMRRoundLoadoutDirector>();
}
private OMRRoundRuleDirector GetRoundRuleDirector()
{
return GameObject.Components
.Get<OMRRoundRuleDirector>();
}
private OMRBotManager GetBotManager()
{
return GameObject.Components
.Get<OMRBotManager>();
}
private int CycleIndex(
int current,
int direction,
int count
)
{
if ( count <= 0 )
return 0;
int next =
(
current +
direction
) %
count;
if ( next < 0 )
next += count;
return next;
}
// ============================================================
// MATCH UPDATE
// ============================================================
private void UpdateMatch(
List<PlayerState> players
)
{
if ( players.Count < 2 )
{
if (
Phase !=
RoundPhase.Waiting
)
{
EnterWaiting();
}
return;
}
AssignTeams(
players
);
if (
!PlayersAreReady(
players
)
)
{
if (
Phase !=
RoundPhase.Waiting
)
{
EnterWaiting();
}
return;
}
switch ( Phase )
{
case RoundPhase.Waiting:
StartCountdown(
players,
RoundModifier.Standard
);
break;
case RoundPhase.Countdown:
if (
Time.Now >=
PhaseEndsAt
)
{
StartFight(
players
);
}
break;
case RoundPhase.Fighting:
CheckForRoundWinner(
players
);
//
// A kill may have moved us to RoundEnd.
//
if (
Phase !=
RoundPhase.Fighting
)
{
break;
}
if ( UsesHighNoonFlow( ActiveModifier ) )
{
UpdateHighNoon(
players
);
break;
}
if (
FightEndsAt > 0f &&
Time.Now >= FightEndsAt
)
{
ResolveFightTimeout(
players
);
}
break;
case RoundPhase.RoundEnd:
if (
Time.Now >=
PhaseEndsAt
)
{
ResolvePostRound(
players
);
}
break;
case RoundPhase.WinnerDecision:
if ( TryHandleBotWinnerDecision( players ) )
break;
if (
Time.Now >=
DecisionEndsAt
)
{
//
// Safe default:
// if the winner does nothing, BANK.
//
HandleWinnerDecision(
PotOwnerTeam,
true,
players
);
}
break;
case RoundPhase.ModifierDecision:
if ( TryHandleBotModifierDecision( players ) )
break;
if (
Time.Now >=
DecisionEndsAt
)
{
//
// Never allow a disconnected / distracted
// player to lock the match forever.
//
int chooserTeam =
GetOpponentTeam(
PotOwnerTeam
);
HandleModifierDecision(
chooserTeam,
ModifierOptionA,
players
);
}
break;
case RoundPhase.MatchEnd:
//
// Wait for the host to return to menu.
//
break;
}
}
// ============================================================
// PLAYER LOOKUP
// ============================================================
private List<PlayerState> GetPlayers()
{
return Scene
.GetAllComponents<PlayerState>()
.ToList();
}
private PlayerState GetLocalPlayer()
{
return GetPlayers()
.FirstOrDefault(
x =>
x.IsReady &&
x.IsLocalHuman
);
}
private PlayerState GetPlayerByOwnerId(
Guid ownerId
)
{
return GetPlayers()
.FirstOrDefault(
x =>
!x.IsBot &&
x.Network.OwnerId ==
ownerId
);
}
private bool PlayersAreReady(
List<PlayerState> players
)
{
PlayerState playerA =
players.FirstOrDefault(
x =>
x.TeamId == 0
);
PlayerState playerB =
players.FirstOrDefault(
x =>
x.TeamId == 1
);
if (
playerA is null ||
playerB is null
)
return false;
return
playerA.IsReady &&
playerB.IsReady;
}
private void AssignTeams(
List<PlayerState> players
)
{
foreach (
PlayerState player in
players.Where(
x =>
x.TeamId < 0
)
)
{
bool teamAUsed =
players.Any(
x =>
x != player &&
x.TeamId == 0
);
bool teamBUsed =
players.Any(
x =>
x != player &&
x.TeamId == 1
);
if ( !teamAUsed )
{
player.TeamId =
0;
}
else if ( !teamBUsed )
{
player.TeamId =
1;
}
}
}
// ============================================================
// WAITING
// ============================================================
private void EnterWaiting()
{
Phase =
RoundPhase.Waiting;
LastRoundWinner =
-1;
DecisionEndsAt =
0f;
FightEndsAt =
0f;
FightMessageEndsAt =
0f;
IsSuddenDeath =
false;
ResetHighNoonRuntimeState(
GetPlayers(),
clearOwnerState: true
);
SetSuddenDeathMode(
GetPlayers(),
false
);
ClearRoundRuleRuntime(
GetPlayers()
);
foreach (
PlayerState player in
GetPlayers()
)
{
player.SetMenuControls(
false
);
player.SetRoundControls(
false
);
}
Log.Info(
"WAITING FOR READY PLAYERS"
);
}
// ============================================================
// ROUND START
// ============================================================
private void StartNextRound(
List<PlayerState> players,
RoundModifier modifier
)
{
RoundNumber++;
StartCountdown(
players,
modifier
);
}
private void StartCountdown(
List<PlayerState> players,
RoundModifier modifier
)
{
Phase =
RoundPhase.Countdown;
PhaseEndsAt =
Time.Now +
CountdownDuration;
DecisionEndsAt =
0f;
LastRoundWinner =
-1;
FightEndsAt =
0f;
FightMessageEndsAt =
0f;
IsSuddenDeath =
false;
ResetHighNoonRuntimeState(
players,
clearOwnerState: true
);
SetSuddenDeathMode(
players,
false
);
ActiveModifier =
modifier;
OMRRoundRuleProfile activeRule =
ResolveRoundRule(
ActiveModifier
);
if ( Arena is not null )
{
Arena.PrepareForRound(
RoundNumber,
ActiveModifier,
activeRule?.ArenaUsage ??
GetLegacyArenaUsage( ActiveModifier )
);
}
PlayerState playerA =
players.FirstOrDefault(
x =>
x.TeamId == 0
);
PlayerState playerB =
players.FirstOrDefault(
x =>
x.TeamId == 1
);
//
// Disable gameplay input before moving/resetting either player.
// This prevents a held input from leaking into the new round while
// authoritative ammo and local movement state are being rebuilt.
//
foreach (
PlayerState player in
players
)
{
player.SetMenuControls(
false
);
player.SetRoundControls(
false
);
}
GameObject roundWeapon =
ResolveRoundWeapon(
ActiveModifier,
activeRule
);
if ( roundWeapon is not null )
{
ApplyWeaponLoadout(
playerA,
roundWeapon
);
ApplyWeaponLoadout(
playerB,
roundWeapon
);
}
else
{
Log.Warning(
$"ROUND {RoundNumber} | No configured round weapon; preserving current inventories."
);
}
// Apply host-side weapon modifier snapshots before ResetPlayer restores
// clip/reserve ammo, so future magazine/ammo modifiers resolve against the
// same effective stats used by the owning client.
ApplyRoundWeaponModifiersHost(
playerA,
activeRule
);
ApplyRoundWeaponModifiersHost(
playerB,
activeRule
);
ResetPlayer(
playerA,
SpawnA,
SpawnB
);
ResetPlayer(
playerB,
SpawnB,
SpawnA
);
ApplyRoundRuleRuntime(
players,
activeRule
);
if ( UsesHighNoonFlow( ActiveModifier ) )
{
playerA?.
LockHighNoonCountdown(
SpawnB is not null
? SpawnB.WorldPosition
: playerB?.WorldPosition ?? Vector3.Zero
);
playerB?.
LockHighNoonCountdown(
SpawnA is not null
? SpawnA.WorldPosition
: playerA?.WorldPosition ?? Vector3.Zero
);
}
string roundWeaponName =
roundWeapon is null
? "CURRENT LOADOUT"
: OMRWeaponUtility.GetDisplayName(
roundWeapon
);
Log.Info(
$"ROUND {RoundNumber} | COUNTDOWN | MODIFIER {ActiveModifier} | WEAPON {roundWeaponName}"
);
}
private void StartFight(
List<PlayerState> players
)
{
PlayerState playerA =
players.FirstOrDefault(
x =>
x.TeamId == 0
);
PlayerState playerB =
players.FirstOrDefault(
x =>
x.TeamId == 1
);
if (
playerA is null ||
playerB is null
)
{
EnterWaiting();
return;
}
if (
!playerA.IsAlive ||
!playerB.IsAlive
)
{
Log.Warning(
"Fight attempted to start with a dead player. Resetting round."
);
StartCountdown(
players,
ActiveModifier
);
return;
}
if ( UsesHighNoonFlow( ActiveModifier ) )
{
StartHighNoonFight(
players,
playerA,
playerB
);
return;
}
Phase =
RoundPhase.Fighting;
IsSuddenDeath =
false;
SetSuddenDeathMode(
players,
false
);
OMRRoundRuleProfile activeRule =
ResolveRoundRule(
ActiveModifier
);
float resolvedFightDuration =
ResolveFightDuration(
activeRule
);
FightEndsAt =
Time.Now +
resolvedFightDuration;
FightMessageEndsAt =
Time.Now +
1.0f;
foreach (
PlayerState player in
players
)
{
player.SetMenuControls(
false
);
player.SetRoundControls(
true
);
}
Log.Info(
$"ROUND {RoundNumber} | FIGHT! | MODIFIER {ActiveModifier} | TIMER {resolvedFightDuration:0.##}s"
);
}
// ============================================================
// HIGH NOON
// ============================================================
private void StartHighNoonFight(
List<PlayerState> players,
PlayerState playerA,
PlayerState playerB
)
{
Phase =
RoundPhase.Fighting;
IsSuddenDeath =
false;
SetSuddenDeathMode(
players,
false
);
float minimumDelay =
MathF.Max(
MathF.Min(
HighNoonMinDrawDelay,
HighNoonMaxDrawDelay
),
0.05f
);
float maximumDelay =
MathF.Max(
MathF.Max(
HighNoonMinDrawDelay,
HighNoonMaxDrawDelay
),
minimumDelay
);
float drawDelay =
Game.Random.Float(
minimumDelay,
maximumDelay
);
HighNoonDrawAt =
Time.Now +
drawDelay;
HighNoonState =
HighNoonStage.WaitingForDraw;
HighNoonFirstTriggerAt =
0f;
HighNoonFirstTriggerTeam =
-1;
_highNoonTriggerTimes.Clear();
_highNoonAdjudicateAt = 0f;
FightEndsAt =
HighNoonDrawAt +
MathF.Max(
HighNoonDrawDuration,
1f
);
// The center HUD uses this as the DRAW banner lifetime.
FightMessageEndsAt =
HighNoonDrawAt +
1.0f;
Vector3 lookTargetA =
SpawnB is not null
? SpawnB.WorldPosition
: playerB.WorldPosition;
Vector3 lookTargetB =
SpawnA is not null
? SpawnA.WorldPosition
: playerA.WorldPosition;
playerA.BeginHighNoonDrawSequence(
lookTargetA,
HighNoonDrawAt,
RoundNumber
);
playerB.BeginHighNoonDrawSequence(
lookTargetB,
HighNoonDrawAt,
RoundNumber
);
Log.Info(
$"ROUND {RoundNumber} | HIGH NOON | READY | DRAW IN {drawDelay:0.###}s | WINDOW {MathF.Max( HighNoonDrawDuration, 1f ):0.##}s"
);
}
private void UpdateHighNoon(
List<PlayerState> players
)
{
if (
!UsesHighNoonFlow( ActiveModifier ) ||
Phase !=
RoundPhase.Fighting
)
return;
if (
HighNoonState ==
HighNoonStage.WaitingForDraw &&
Time.Now >=
HighNoonDrawAt
)
{
HighNoonState =
HighNoonStage.DrawOpen;
Log.Info(
$"ROUND {RoundNumber} | HIGH NOON | DRAW!"
);
}
if (
_highNoonAdjudicateAt > 0f &&
Time.Now >=
_highNoonAdjudicateAt
)
{
ResolveHighNoonTriggerRace(
players
);
return;
}
if (
FightEndsAt > 0f &&
Time.Now >=
FightEndsAt
)
{
Log.Info(
$"ROUND {RoundNumber} | HIGH NOON EXPIRED | DRAW"
);
EndRound(
null,
players
);
}
}
/// <summary>
/// Receives the synchronized Time.Now captured on the owning client at the
/// exact point its revolver PrimaryAttack committed.
///
/// The host deliberately compares those timestamps, not RPC arrival order.
/// </summary>
public void HandleHighNoonTrigger(
PlayerState player,
int roundNumber,
float firedAt
)
{
if (
!Networking.IsHost ||
player is null ||
Mode !=
SessionMode.Match ||
Phase !=
RoundPhase.Fighting ||
!UsesHighNoonFlow( ActiveModifier ) ||
roundNumber !=
RoundNumber
)
return;
if (
player.TeamId != 0 &&
player.TeamId != 1
)
return;
// Time.Now is synchronized, but leave a small tolerance for update/RPC
// ordering around the exact DRAW boundary.
if (
firedAt <
HighNoonDrawAt -
0.03f
)
{
Log.Warning(
$"HIGH NOON trigger rejected | Player {GetTeamName( player.TeamId )} | Before DRAW by {HighNoonDrawAt - firedAt:0.###}s"
);
return;
}
if (
firedAt >
Time.Now +
0.10f ||
(
FightEndsAt > 0f &&
firedAt >
FightEndsAt +
0.05f
)
)
{
Log.Warning(
$"HIGH NOON trigger rejected | Player {GetTeamName( player.TeamId )} | Invalid timestamp {firedAt:0.###}"
);
return;
}
if (
_highNoonTriggerTimes.ContainsKey(
player.TeamId
)
)
return;
float acceptedAt =
MathF.Max(
firedAt,
HighNoonDrawAt
);
_highNoonTriggerTimes[
player.TeamId
] = acceptedAt;
if (
HighNoonFirstTriggerTeam < 0 ||
acceptedAt <
HighNoonFirstTriggerAt
)
{
HighNoonFirstTriggerAt =
acceptedAt;
HighNoonFirstTriggerTeam =
player.TeamId;
}
float reaction =
acceptedAt -
HighNoonDrawAt;
Log.Info(
$"ROUND {RoundNumber} | HIGH NOON TRIGGER | PLAYER {GetTeamName( player.TeamId )} | REACTION {reaction * 1000f:0.#}ms"
);
if (
_highNoonTriggerTimes.Count >= 2
)
{
ResolveHighNoonTriggerRace(
GetPlayers()
);
return;
}
_highNoonAdjudicateAt =
Time.Now +
MathF.Max(
HighNoonAdjudicationWindow,
0.02f
);
}
private void ResolveHighNoonTriggerRace(
List<PlayerState> players
)
{
if (
Phase !=
RoundPhase.Fighting ||
!UsesHighNoonFlow( ActiveModifier ) ||
_highNoonTriggerTimes.Count == 0
)
return;
_highNoonAdjudicateAt = 0f;
bool hasA =
_highNoonTriggerTimes.TryGetValue(
0,
out float triggerA
);
bool hasB =
_highNoonTriggerTimes.TryGetValue(
1,
out float triggerB
);
if (
hasA &&
hasB &&
MathF.Abs(
triggerA -
triggerB
) <=
MathF.Max(
HighNoonTieTolerance,
0f
)
)
{
Log.Info(
$"ROUND {RoundNumber} | HIGH NOON | SIMULTANEOUS DRAW | A {(triggerA - HighNoonDrawAt) * 1000f:0.#}ms | B {(triggerB - HighNoonDrawAt) * 1000f:0.#}ms"
);
EndRound(
null,
players
);
return;
}
int winnerTeam;
if ( hasA && hasB )
{
winnerTeam =
triggerA < triggerB
? 0
: 1;
}
else
{
winnerTeam =
hasA
? 0
: 1;
}
int loserTeam =
GetOpponentTeam(
winnerTeam
);
PlayerState winner =
players.FirstOrDefault(
x =>
x.TeamId ==
winnerTeam
);
PlayerState loser =
players.FirstOrDefault(
x =>
x.TeamId ==
loserTeam
);
if (
winner is null ||
loser is null
)
{
EndRound(
null,
players
);
return;
}
loser.Components
.Get<PlayerHealth>()?
.EliminateForRoundRule();
Log.Info(
$"ROUND {RoundNumber} | HIGH NOON WINNER | PLAYER {GetTeamName( winnerTeam )} | REACTION {_highNoonTriggerTimes[winnerTeam] - HighNoonDrawAt:0.###}s"
);
EndRound(
winner,
players
);
}
private void ResetHighNoonRuntimeState(
IEnumerable<PlayerState> players,
bool clearOwnerState
)
{
HighNoonState =
HighNoonStage.Inactive;
HighNoonDrawAt =
0f;
HighNoonFirstTriggerAt =
0f;
HighNoonFirstTriggerTeam =
-1;
_highNoonTriggerTimes.Clear();
_highNoonAdjudicateAt = 0f;
if (
!clearOwnerState ||
players is null
)
return;
foreach (
PlayerState player in
players
)
{
player?.
ClearHighNoonRoundState();
}
}
// ============================================================
// FIGHT TIMEOUT / SUDDEN DEATH
// ============================================================
private void ResolveFightTimeout(
List<PlayerState> players
)
{
PlayerState playerA =
players.FirstOrDefault(
x =>
x.TeamId == 0
);
PlayerState playerB =
players.FirstOrDefault(
x =>
x.TeamId == 1
);
if (
playerA is null ||
playerB is null
)
return;
//
// Sudden death has no health tiebreak after its timer.
// If nobody lands the lethal shot, the round is a draw.
//
if ( IsSuddenDeath )
{
Log.Info(
$"ROUND {RoundNumber} | SUDDEN DEATH EXPIRED | DRAW"
);
EndRound(
null,
players
);
return;
}
PlayerHealth healthA =
playerA.Components
.Get<PlayerHealth>();
PlayerHealth healthB =
playerB.Components
.Get<PlayerHealth>();
if (
healthA is null ||
healthB is null
)
{
Log.Warning(
"Round timeout could not compare health because a PlayerHealth component is missing."
);
EndRound(
null,
players
);
return;
}
float tolerance =
MathF.Max(
HealthTieTolerance,
0f
);
float difference =
healthA.Health -
healthB.Health;
if (
difference >
tolerance
)
{
Log.Info(
$"ROUND {RoundNumber} | TIME | PLAYER A WINS ON HEALTH | {healthA.Health:0.#} - {healthB.Health:0.#}"
);
EndRound(
playerA,
players
);
return;
}
if (
difference <
-tolerance
)
{
Log.Info(
$"ROUND {RoundNumber} | TIME | PLAYER B WINS ON HEALTH | {healthA.Health:0.#} - {healthB.Health:0.#}"
);
EndRound(
playerB,
players
);
return;
}
bool playerAFull =
MathF.Abs(
healthA.Health -
healthA.MaxHealth
) <=
tolerance;
bool playerBFull =
MathF.Abs(
healthB.Health -
healthB.MaxHealth
) <=
tolerance;
//
// Only a completely untouched 100%-100% timeout gets
// the extra one-shot phase.
//
if (
playerAFull &&
playerBFull
)
{
StartSuddenDeath(
players
);
return;
}
//
// Equal health after both players have taken damage is
// already a draw. No modifier is chosen and no player
// earns/keeps an unbanked pot from this round.
//
Log.Info(
$"ROUND {RoundNumber} | TIME | EQUAL HEALTH {healthA.Health:0.#} - {healthB.Health:0.#} | DRAW"
);
EndRound(
null,
players
);
}
private void StartSuddenDeath(
List<PlayerState> players
)
{
IsSuddenDeath =
true;
SetSuddenDeathMode(
players,
true
);
FightEndsAt =
Time.Now +
MathF.Max(
SuddenDeathDuration,
1f
);
FightMessageEndsAt =
Time.Now +
1.5f;
Log.Info(
$"ROUND {RoundNumber} | SUDDEN DEATH | ONE SHOT | TIMER {MathF.Max( SuddenDeathDuration, 1f ):0.##}s"
);
}
private void SetSuddenDeathMode(
IEnumerable<PlayerState> players,
bool enabled
)
{
if ( !Networking.IsHost )
return;
foreach (
PlayerState player in
players
)
{
PlayerHealth health =
player.Components
.Get<PlayerHealth>();
health?.
SetWeaponOneShotMode(
enabled
);
}
}
// ============================================================
// ROUND END
// ============================================================
private void CheckForRoundWinner(
List<PlayerState> players
)
{
PlayerState playerA =
players.FirstOrDefault(
x =>
x.TeamId == 0
);
PlayerState playerB =
players.FirstOrDefault(
x =>
x.TeamId == 1
);
if (
playerA is null ||
playerB is null
)
return;
if (
playerA.IsAlive &&
playerB.IsAlive
)
return;
if (
!playerA.IsAlive &&
!playerB.IsAlive
)
{
EndRound(
null,
players
);
return;
}
PlayerState winner =
playerA.IsAlive
? playerA
: playerB;
EndRound(
winner,
players
);
}
private void EndRound(
PlayerState winner,
List<PlayerState> players
)
{
Phase =
RoundPhase.RoundEnd;
PhaseEndsAt =
Time.Now +
RoundEndDuration;
DecisionEndsAt =
0f;
FightEndsAt =
0f;
FightMessageEndsAt =
0f;
IsSuddenDeath =
false;
ResetHighNoonRuntimeState(
players,
clearOwnerState: true
);
SetSuddenDeathMode(
players,
false
);
ClearRoundRuleRuntime(
players
);
foreach (
PlayerState player in
players
)
{
player.SetRoundControls(
false
);
}
//
// DRAW
//
if ( winner is null )
{
LastRoundWinner =
2;
//
// A draw awards nobody the round and does not destroy an
// existing wager. The current pot owner keeps the exact stake.
//
// If this draw happened during an OMR modifier round, that
// modifier is replayed on the next round so the wager remains
// unresolved instead of silently becoming safer.
//
Log.Info(
$"ROUND {RoundNumber}: DRAW | BANKED {ScoreA} - {ScoreB} | POT PRESERVED {CurrentPot} | OWNER {GetTeamName( PotOwnerTeam )}"
);
return;
}
LastRoundWinner =
winner.TeamId;
//
// The current pot owner defended their streak.
//
if (
CurrentPot > 0 &&
PotOwnerTeam ==
winner.TeamId
)
{
CurrentPot =
Math.Min(
CurrentPot +
Math.Max(
OneMoreRoundPotGain,
1
),
Math.Max(
MaxPot,
1
)
);
}
else
{
//
// Either there was no pot yet, or the previous
// holder lost it.
//
// The old unbanked pot simply disappears and the
// round winner begins a new streak at 1.
//
PotOwnerTeam =
winner.TeamId;
CurrentPot =
1;
}
Log.Info(
$"ROUND {RoundNumber}: PLAYER {GetTeamName( winner.TeamId )} WINS | POT OWNER {GetTeamName( PotOwnerTeam )} | POT {CurrentPot}"
);
}
private void ResolvePostRound(
List<PlayerState> players
)
{
//
// A draw leaves the wager unresolved. Preserve the current pot and replay
// the same modifier instead of burning earned-but-unbanked points.
//
if (
LastRoundWinner ==
2
)
{
StartNextRound(
players,
ActiveModifier
);
return;
}
//
// Max risk reached.
//
// Force banking rather than offering a gamble with no higher payout.
//
if (
CurrentPot >=
Math.Max(
MaxPot,
1
)
)
{
BankCurrentPot(
players
);
return;
}
//
// If banking already wins the match there is no strategically meaningful
// reason to ask for another input. Secure the win immediately.
//
if (
GetBankedScore(
PotOwnerTeam
) +
CurrentPot >=
Math.Max(
TargetScore,
1
)
)
{
BankCurrentPot(
players
);
return;
}
//
// Reveal the two possible next-round conditions before asking the winner
// whether to risk the current pot. This makes the wager informed rather
// than blind while preserving the loser's power to choose the condition.
//
PrepareModifierOffers();
EnterWinnerDecision();
}
// ============================================================
// WINNER DECISION
// ============================================================
private void EnterWinnerDecision()
{
if (
PotOwnerTeam < 0 ||
CurrentPot <= 0
)
return;
Phase =
RoundPhase.WinnerDecision;
DecisionEndsAt =
Time.Now +
DecisionDuration;
Log.Info(
$"ONE MORE ROUND | PLAYER {GetTeamName( PotOwnerTeam )} DECIDES | POT {CurrentPot} -> {GetNextRiskPot()} | OFFERS {GetModifierDisplayName( ModifierOptionA )} / {GetModifierDisplayName( ModifierOptionB )}"
);
}
public void RequestBank()
{
if ( Networking.IsHost )
{
PlayerState localPlayer =
GetLocalPlayer();
if ( localPlayer is null )
return;
HandleWinnerDecision(
localPlayer.TeamId,
true,
GetPlayers()
);
return;
}
RequestWinnerDecisionHost(
true
);
}
public void RequestOneMoreRound()
{
if ( Networking.IsHost )
{
PlayerState localPlayer =
GetLocalPlayer();
if ( localPlayer is null )
return;
HandleWinnerDecision(
localPlayer.TeamId,
false,
GetPlayers()
);
return;
}
RequestWinnerDecisionHost(
false
);
}
[Rpc.Host]
private void RequestWinnerDecisionHost(
bool bank
)
{
if ( !Networking.IsHost )
return;
PlayerState caller =
GetPlayerByOwnerId(
Rpc.CallerId
);
if ( caller is null )
{
Log.Warning(
"Winner decision rejected: caller has no PlayerState."
);
return;
}
HandleWinnerDecision(
caller.TeamId,
bank,
GetPlayers()
);
}
private void HandleWinnerDecision(
int teamId,
bool bank,
List<PlayerState> players
)
{
if (
Mode !=
SessionMode.Match ||
Phase !=
RoundPhase.WinnerDecision
)
return;
if (
teamId !=
PotOwnerTeam
)
{
Log.Warning(
$"Winner decision rejected: Player {GetTeamName( teamId )} does not own the pot."
);
return;
}
if (
CurrentPot <= 0
)
return;
Log.Info(
$"OMR DECISION | PLAYER {GetTeamName( teamId )} | CHOICE {(bank ? "BANK" : "ONE MORE ROUND")} | BANKED {GetBankedScore( teamId )}/{Math.Max( TargetScore, 1 )} | POT {CurrentPot} | NEXT {GetNextRiskPot()} | OFFERS {GetModifierDisplayName( ModifierOptionA )} [{ModifierOptionAWeaponPreview}] / {GetModifierDisplayName( ModifierOptionB )} [{ModifierOptionBWeaponPreview}]"
);
if ( bank )
{
BankCurrentPot(
players
);
return;
}
BeginModifierDecision();
}
// ============================================================
// BANK
// ============================================================
private void BankCurrentPot(
List<PlayerState> players
)
{
if (
PotOwnerTeam < 0 ||
CurrentPot <= 0
)
return;
int bankingTeam =
PotOwnerTeam;
int amount =
CurrentPot;
if ( bankingTeam == 0 )
{
ScoreA +=
amount;
}
else if ( bankingTeam == 1 )
{
ScoreB +=
amount;
}
Log.Info(
$"BANK | PLAYER {GetTeamName( bankingTeam )} +{amount} | BANKED {ScoreA} - {ScoreB}"
);
CurrentPot =
0;
PotOwnerTeam =
-1;
ActiveModifier =
RoundModifier.Standard;
DecisionEndsAt =
0f;
ModifierOptionA =
RoundModifier.HighNoon;
ModifierOptionB =
RoundModifier.Standard;
ModifierOptionAWeaponPreview =
"RANDOM COMPETITIVE WEAPON";
ModifierOptionBWeaponPreview =
"RANDOM COMPETITIVE WEAPON";
_modifierOffersPrepared =
false;
int score =
bankingTeam == 0
? ScoreA
: ScoreB;
if (
score >=
Math.Max(
TargetScore,
1
)
)
{
EndMatch(
bankingTeam,
players
);
return;
}
StartNextRound(
players,
RoundModifier.Standard
);
}
// ============================================================
// MODIFIER DECISION
// ============================================================
private void BeginModifierDecision()
{
if ( !_modifierOffersPrepared )
{
PrepareModifierOffers();
}
Phase =
RoundPhase.ModifierDecision;
DecisionEndsAt =
Time.Now +
DecisionDuration;
int chooserTeam =
GetOpponentTeam(
PotOwnerTeam
);
Log.Info(
$"ONE MORE ROUND ACCEPTED | PLAYER {GetTeamName( chooserTeam )} CHOOSES MODIFIER | OPTIONS {GetModifierDisplayName( ModifierOptionA )} / {GetModifierDisplayName( ModifierOptionB )}"
);
}
private void PrepareModifierOffers()
{
OMRRoundRuleDirector ruleDirector =
GetRoundRuleDirector();
RoundModifier offerA =
RoundModifier.HighNoon;
RoundModifier offerB =
RoundModifier.Standard;
bool resolvedOffers =
ruleDirector is not null &&
ruleDirector.TryTakeModifierOffers(
out offerA,
out offerB
);
if ( resolvedOffers )
{
ModifierOptionA = offerA;
ModifierOptionB = offerB;
}
else
{
// Safe compatibility fallback for scenes that have not received the
// modular rule director yet.
if ( RoundNumber % 2 == 0 )
{
ModifierOptionA = RoundModifier.HighNoon;
ModifierOptionB = RoundModifier.Standard;
}
else
{
ModifierOptionA = RoundModifier.Standard;
ModifierOptionB = RoundModifier.HighNoon;
}
}
ModifierOptionAWeaponPreview =
ResolveModifierWeaponPreviewHost(
ModifierOptionA
);
ModifierOptionBWeaponPreview =
ResolveModifierWeaponPreviewHost(
ModifierOptionB
);
_modifierOffersPrepared =
true;
}
public void RequestModifier(
RoundModifier modifier
)
{
if ( Networking.IsHost )
{
PlayerState localPlayer =
GetLocalPlayer();
if ( localPlayer is null )
return;
HandleModifierDecision(
localPlayer.TeamId,
modifier,
GetPlayers()
);
return;
}
RequestModifierHost(
modifier
);
}
[Rpc.Host]
private void RequestModifierHost(
RoundModifier modifier
)
{
if ( !Networking.IsHost )
return;
PlayerState caller =
GetPlayerByOwnerId(
Rpc.CallerId
);
if ( caller is null )
{
Log.Warning(
"Modifier decision rejected: caller has no PlayerState."
);
return;
}
HandleModifierDecision(
caller.TeamId,
modifier,
GetPlayers()
);
}
private void HandleModifierDecision(
int teamId,
RoundModifier modifier,
List<PlayerState> players
)
{
if (
Mode !=
SessionMode.Match ||
Phase !=
RoundPhase.ModifierDecision
)
return;
int expectedTeam =
GetOpponentTeam(
PotOwnerTeam
);
if (
teamId !=
expectedTeam
)
{
Log.Warning(
$"Modifier decision rejected: Player {GetTeamName( teamId )} is not the chooser."
);
return;
}
bool validChoice =
modifier ==
ModifierOptionA ||
modifier ==
ModifierOptionB;
if ( !validChoice )
{
Log.Warning(
$"Modifier decision rejected: {modifier} was not offered."
);
return;
}
DecisionEndsAt =
0f;
ActiveModifier =
modifier;
_modifierOffersPrepared =
false;
Log.Info(
$"MODIFIER SELECTED | PLAYER {GetTeamName( teamId )} | {modifier}"
);
StartNextRound(
players,
modifier
);
}
// ============================================================
// MATCH END
// ============================================================
private void EndMatch(
int winnerTeam,
List<PlayerState> players
)
{
Phase =
RoundPhase.MatchEnd;
MatchWinnerTeam =
winnerTeam;
PhaseEndsAt =
0f;
DecisionEndsAt =
0f;
FightEndsAt =
0f;
FightMessageEndsAt =
0f;
IsSuddenDeath =
false;
ResetHighNoonRuntimeState(
players,
clearOwnerState: true
);
SetSuddenDeathMode(
players,
false
);
ClearRoundRuleRuntime(
players
);
foreach (
PlayerState player in
players
)
{
player.SetRoundControls(
false
);
}
Log.Info(
$"MATCH OVER | PLAYER {GetTeamName( winnerTeam )} WINS | FINAL {ScoreA} - {ScoreB}"
);
}
// ============================================================
// HELPERS
// ============================================================
private int GetOpponentTeam(
int teamId
)
{
return teamId switch
{
0 => 1,
1 => 0,
_ => -1
};
}
private string GetTeamName(
int teamId
)
{
return teamId switch
{
0 => "A",
1 => "B",
_ => "?"
};
}
private OMRRoundRuleProfile ResolveRoundRule(
RoundModifier modifier
)
{
return GetRoundRuleDirector()?.
ResolveRule( modifier );
}
private float ResolveFightDuration(
OMRRoundRuleProfile rule
)
{
float authoredOverride =
rule?.FightDurationOverride ?? 0f;
if ( authoredOverride > 0f )
{
return MathF.Max(
authoredOverride,
1f
);
}
return MathF.Max(
FightDuration,
1f
);
}
private ArenaLayoutUsage GetLegacyArenaUsage(
RoundModifier modifier
)
{
return modifier == RoundModifier.HighNoon
? ArenaLayoutUsage.HighNoon
: ArenaLayoutUsage.Standard;
}
private bool UsesHighNoonFlow(
RoundModifier modifier
)
{
OMRRoundRuleProfile rule =
ResolveRoundRule(
modifier
);
if (
rule is not null &&
rule.IsValid
)
{
return rule.SpecialFlow ==
OMRRoundSpecialFlow.HighNoon;
}
return modifier ==
RoundModifier.HighNoon;
}
public bool ActiveUsesHighNoonFlow =>
UsesHighNoonFlow( ActiveModifier );
public int GetBankedScore(
int teamId
)
{
return teamId switch
{
0 => ScoreA,
1 => ScoreB,
_ => 0
};
}
public int GetBankScorePreview()
{
if (
PotOwnerTeam < 0 ||
CurrentPot <= 0
)
return 0;
return Math.Min(
GetBankedScore(
PotOwnerTeam
) +
CurrentPot,
Math.Max(
TargetScore,
1
)
);
}
public int GetNextRiskPot()
{
if ( CurrentPot <= 0 )
return 1;
return Math.Min(
CurrentPot +
Math.Max(
OneMoreRoundPotGain,
1
),
Math.Max(
MaxPot,
1
)
);
}
public string GetModifierWeaponPreview(
RoundModifier modifier
)
{
if ( modifier == ModifierOptionA )
return ModifierOptionAWeaponPreview;
if ( modifier == ModifierOptionB )
return ModifierOptionBWeaponPreview;
return "RANDOM COMPETITIVE WEAPON";
}
private string ResolveModifierWeaponPreviewHost(
RoundModifier modifier
)
{
OMRRoundRuleProfile rule =
ResolveRoundRule(
modifier
);
OMRRoundLoadoutDirector loadouts =
GetRoundLoadoutDirector();
GameObject previewWeapon =
loadouts?.
PreviewRoundWeapon(
rule
);
if (
previewWeapon is not null &&
previewWeapon.IsValid
)
{
return OMRWeaponUtility
.GetDisplayName(
previewWeapon
)
.ToUpperInvariant();
}
if (
rule is not null &&
rule.IsValid &&
rule.WeaponPolicy ==
OMRRoundWeaponPolicy.FilteredShuffle &&
rule.AllowedWeaponIds is not null
)
{
string[] ids =
rule.AllowedWeaponIds
.Where(
x => !string.IsNullOrWhiteSpace( x )
)
.Select(
x => x.Trim().ToUpperInvariant()
)
.ToArray();
if ( ids.Length > 0 )
return string.Join(
" / ",
ids
);
}
return modifier == RoundModifier.HighNoon
? "REVOLVER"
: "RANDOM COMPETITIVE WEAPON";
}
public string GetModifierDisplayName(
RoundModifier modifier
)
{
OMRRoundRuleDirector director =
GetRoundRuleDirector();
if ( director is not null )
return director.GetDisplayName( modifier );
return modifier switch
{
RoundModifier.HighNoon => "HIGH NOON",
RoundModifier.LowGravity => "LOW GRAVITY",
RoundModifier.HeadshotOnly => "HEADSHOT ONLY",
RoundModifier.LargeCaliber => "LARGE CALIBER",
RoundModifier.DoubleJump => "DOUBLE JUMP",
RoundModifier.HipfireOnly => "HIPFIRE ONLY",
RoundModifier.Blitz => "BLITZ",
_ => "STANDARD"
};
}
public string GetModifierDescription(
RoundModifier modifier
)
{
OMRRoundRuleDirector director =
GetRoundRuleDirector();
if ( director is not null )
return director.GetDescription( modifier );
return modifier switch
{
RoundModifier.HighNoon => "Open arena. Draw on the signal.",
RoundModifier.LowGravity => "Reduced gravity with a competitive weapon.",
RoundModifier.HeadshotOnly => "Only headshots deal weapon damage.",
RoundModifier.LargeCaliber => "Smaller magazine. Higher damage and recoil.",
RoundModifier.DoubleJump => "Gain one additional jump while airborne.",
RoundModifier.HipfireOnly => "ADS disabled. Fight from the hip.",
RoundModifier.Blitz => "A fast 30-second round.",
_ => "Standard arena rules."
};
}
private GameObject ResolveRoundWeapon(
RoundModifier modifier,
OMRRoundRuleProfile rule
)
{
OMRRoundLoadoutDirector director =
GetRoundLoadoutDirector();
if ( director is null )
{
Log.Warning(
"ROUND LOADOUTS | OMRRoundLoadoutDirector is missing; match loadout rotation is disabled."
);
return null;
}
if (
rule is not null &&
rule.IsValid
)
{
return director.ResolveRoundWeapon(
RoundNumber,
rule
);
}
return director.ResolveRoundWeapon(
RoundNumber,
modifier
);
}
private void ApplyRoundRuleRuntime(
IEnumerable<PlayerState> players,
OMRRoundRuleProfile rule
)
{
if ( !Networking.IsHost )
return;
bool headshotOnly =
rule?.HeadshotOnly ?? false;
RoundModifier modifier =
rule?.Modifier ??
ActiveModifier;
foreach ( PlayerState player in players )
{
if ( player is null )
continue;
player.Components
.Get<PlayerHealth>()?
.SetWeaponHeadshotOnlyMode(
headshotOnly
);
player.ApplyRoundRule(
modifier
);
}
if ( rule is not null )
{
Log.Info(
$"ROUND {RoundNumber} | RULE {rule.GetDisplayName()} | Gravity:{MathX.Clamp( rule.GravityScale, 0.05f, 3f ):0.##} | AirJumps:{Math.Clamp( rule.AdditionalAirJumps, 0, 3 )} | AllowAim:{rule.AllowAim} | FightOverride:{MathF.Max( rule.FightDurationOverride, 0f ):0.##} | HeadshotOnly:{headshotOnly} | WeaponPolicy:{rule.WeaponPolicy}"
);
}
}
private void ClearRoundRuleRuntime(
IEnumerable<PlayerState> players
)
{
if (
!Networking.IsHost ||
players is null
)
return;
foreach ( PlayerState player in players )
{
if ( player is null )
continue;
ClearRoundWeaponModifiersHost(
player
);
player.Components
.Get<PlayerHealth>()?
.SetWeaponHeadshotOnlyMode(
false
);
player.ClearRoundRuleState();
}
}
private void ApplyRoundWeaponModifiersHost(
PlayerState player,
OMRRoundRuleProfile rule
)
{
if (
!Networking.IsHost ||
player is null
)
return;
// A logical round may reuse the same weapon prefab with a different rule.
// Remove only our previous round-rule IDs before applying the new set.
ClearRoundWeaponModifiersHost(
player
);
if (
rule is null ||
!rule.IsValid ||
rule.WeaponModifiers is null ||
rule.WeaponModifiers.Count <= 0
)
return;
BaseInventoryComponent inventory =
player.Components
.Get<BaseInventoryComponent>();
OneMoreRoundWeapon weapon =
inventory?.ActiveItem as
OneMoreRoundWeapon;
if ( weapon is null )
{
Log.Warning(
$"ROUND {RoundNumber} | RULE {rule.Modifier} | Host could not resolve active weapon for modifier application."
);
return;
}
HashSet<string> appliedIds =
new(
StringComparer.OrdinalIgnoreCase
);
foreach ( OMRWeaponModifierDefinition modifier in rule.WeaponModifiers )
{
if (
modifier is null ||
!modifier.IsValid
)
continue;
string id =
modifier.ModifierId?.Trim() ??
string.Empty;
if (
string.IsNullOrWhiteSpace( id ) ||
!appliedIds.Add( id )
)
continue;
weapon.SetStatModifier( modifier );
}
if ( appliedIds.Count <= 0 )
return;
_hostRoundRuleWeapons[player] = weapon;
_hostRoundRuleWeaponModifierIds[player] = appliedIds;
}
private void ClearRoundWeaponModifiersHost(
PlayerState player
)
{
if ( player is null )
return;
if (
_hostRoundRuleWeapons.TryGetValue(
player,
out OneMoreRoundWeapon weapon
) &&
weapon is not null &&
weapon.IsValid &&
_hostRoundRuleWeaponModifierIds.TryGetValue(
player,
out HashSet<string> ids
) &&
ids is not null
)
{
foreach ( string id in ids )
{
weapon.RemoveStatModifier( id );
}
}
_hostRoundRuleWeapons.Remove( player );
_hostRoundRuleWeaponModifierIds.Remove( player );
}
// ============================================================
// PLAYER RESET
// ============================================================
private void ResetPlayer(
PlayerState player,
GameObject spawn,
GameObject lookAtSpawn
)
{
if (
player is null ||
spawn is null
)
return;
PlayerHealth health =
player.Components
.Get<PlayerHealth>();
if ( health is not null )
{
health.ResetHealth();
}
ResetWeaponsForRound(
player
);
player.IsAlive =
true;
Vector3 lookTarget =
lookAtSpawn is not null
? lookAtSpawn.WorldPosition
: spawn.WorldPosition +
spawn.WorldRotation.Forward *
100f;
player.MoveToRoundSpawn(
spawn.WorldPosition,
lookTarget
);
}
/// <summary>
/// Restores authoritative weapon state at the beginning of a round.
///
/// Magazine counts and reserve ammo belong to the host, so this is
/// deliberately kept in RoundManager rather than the owner-side spawn RPC.
/// Presentation state is rebuilt by PlayerState on the owning client.
/// </summary>
private void ResetWeaponsForRound(
PlayerState player
)
{
if ( !Networking.IsHost )
return;
BaseInventoryComponent inventory =
player.Components
.Get<BaseInventoryComponent>();
if ( inventory is null )
return;
foreach (
BaseCombatWeapon weapon in
inventory.Items
.OfType<BaseCombatWeapon>()
)
{
//
// A reload from the previous round must not be allowed to
// complete after the new round has already restored ammunition.
//
if ( weapon.IsReloading )
{
weapon.CancelReload();
}
if (
weapon.UsesPrimaryClip &&
weapon.PrimaryClipSize >= 0
)
{
weapon.Clip1 =
weapon.PrimaryClipSize;
}
if (
weapon.UsesSecondaryClip &&
weapon.SecondaryClipSize >= 0
)
{
int secondaryFill =
weapon.SecondaryDefaultClip < 0
? weapon.SecondaryClipSize
: Math.Min(
weapon.SecondaryDefaultClip,
weapon.SecondaryClipSize
);
weapon.Clip2 =
Math.Max(
secondaryFill,
0
);
}
//
// Null ammo type means the weapon intentionally has an
// unlimited reserve; there is no inventory pool to restore.
//
if ( weapon.PrimaryAmmoType is not null )
{
inventory.SetAmmo(
weapon.PrimaryAmmoType,
Math.Max(
weapon.StartingAmmo,
0
)
);
}
}
}
}