MissionBoard.cs
using Sandbox;
using SWB.Player;
using SWB.Shared;
using System;
using System.Collections.Generic;
using System.Linq;
// One job on the board. Adding a mission later is just adding an entry
// here and pointing it at that mission's scene - no code change.
public sealed class MissionEntry
{
[Property] public string DisplayName { get; set; } = "Untitled Job";
// Short pitch shown under the name. Flavour, or a difficulty hint.
[Property] public string Description { get; set; }
// The scene this job loads. Each mission scene brings its own
// MissionSpawner / MissionTimer / EscapeZone setup with it.
[Property] public SceneFile Scene { get; set; }
// Read out of the mission scene's own EscapeZone rather than typed in
// here, so the board can't advertise a figure the map doesn't actually
// pay. Only read when the board list is rebuilt (on open), so parsing
// the scene JSON each time is cheaper than keeping a cache correct.
public int BaseReward => SceneRewardReader.ReadEscapeReward( Scene );
/// <summary>What this job pays a player who gets out, difficulty included.</summary>
public int AdvertisedPayout => (int)(BaseReward * PayoutMultiplier);
// Difficulty lives on the entry rather than being picked separately,
// so the same scene can be listed more than once at different
// settings - "Gas Station (Standard)" and "Gas Station (Heavy)" are
// just two entries pointing at one map. The crew already votes on an
// entry, so agreeing on difficulty comes free with that.
[Property] public string DifficultyName { get; set; } = "Standard";
/// <summary>Scales what escaping pays out. The reason to take a harder job.</summary>
[Property] public float PayoutMultiplier { get; set; } = 1f;
/// <summary>Below 1 means cops arrive faster. 0.5 is twice the rate.</summary>
[Property] public float SpawnIntervalScale { get; set; } = 1f;
/// <summary>
/// Multiplies how many cops can be alive at once. 1 is the map's own
/// setting, 2 is double. 0 means no cops at all, which is handy for
/// testing the loop without a fight.
/// </summary>
[Property] public float MaxCopsMultiplier { get; set; } = 1f;
/// <summary>Scales cop weapon damage on top of CopBotAI's own DamageScale.</summary>
[Property] public float CopDamageScale { get; set; } = 1f;
}
// Carries the chosen job's difficulty from the hideout into the mission
// scene. A static because scene loads destroy everything in the old scene,
// exactly like PlayerLoadout's save cache.
//
// Set on every client by MissionBoard.Launch, which runs through a
// broadcast - so each machine has the same values before the map loads.
public static class ActiveMission
{
public static string DifficultyName { get; private set; } = "Standard";
public static float PayoutMultiplier { get; private set; } = 1f;
public static float SpawnIntervalScale { get; private set; } = 1f;
public static float MaxCopsMultiplier { get; private set; } = 1f;
public static float CopDamageScale { get; private set; } = 1f;
public static void SetFrom( MissionEntry entry )
{
if ( entry is null )
{
Reset();
return;
}
DifficultyName = entry.DifficultyName;
// Clamped because a zero or negative multiplier anywhere here
// produces nonsense - free money, or a spawn interval of zero that
// floods the map in a single frame.
PayoutMultiplier = MathF.Max( entry.PayoutMultiplier, 0f );
SpawnIntervalScale = MathF.Max( entry.SpawnIntervalScale, 0.05f );
// Floored at 0 rather than 1: zero is a legitimate "no cops"
// setting for testing the loop, and a negative cap would make
// EffectiveMaxCops negative and stop spawning entirely in a way
// that looks like a bug.
MaxCopsMultiplier = MathF.Max( entry.MaxCopsMultiplier, 0f );
CopDamageScale = MathF.Max( entry.CopDamageScale, 0f );
}
public static void Reset()
{
DifficultyName = "Standard";
PayoutMultiplier = 1f;
SpawnIntervalScale = 1f;
MaxCopsMultiplier = 1f;
CopDamageScale = 1f;
}
}
// The job board in the Hideout. Put this on a GameObject with a trigger
// Collider; walk in and hold Use to open it, hold Use again to close.
// Same interaction shape as the Vendor.
public sealed class MissionBoard : Component, Component.ITriggerListener, IInteractable
{
[Property]
public List<MissionEntry> Missions { get; set; } = new();
[Property]
public float HoldDuration { get; set; } = 0.4f;
bool _playerInRange = false;
float _holdProgress = 0f;
bool _wasHolding = false;
public bool IsOpen { get; private set; }
// Ready-up state, so a job can't start while half the crew is still
// shopping. Everyone has to pick the SAME job before it launches.
//
// Host-authoritative: clients send their pick to the host, the host is
// the only one that decides when the crew is go and calls Launch.
// _readyByConnection only ever holds meaningful data on the host.
readonly Dictionary<Guid, int> _readyByConnection = new();
// Mirrored to every client for display. The local player's own choice
// is tracked separately below, since the host doesn't tell each client
// which specific pick was theirs.
public int ReadyCount { get; private set; }
public int CrewSize { get; private set; } = 1;
public int LocalReadyIndex { get; private set; } = -1;
// The job the crew is currently voting on, -1 for none. Drives the
// prompt shown to players who haven't opened the board, so they don't
// have to walk over to it to find out what's been picked.
public int PendingMissionIndex { get; private set; } = -1;
public string ProposedBy { get; private set; }
public MissionEntry PendingMission =>
PendingMissionIndex >= 0 && PendingMissionIndex < Missions.Count
? Missions[PendingMissionIndex]
: null;
public bool LocalPlayerIsReady => LocalReadyIndex >= 0;
// Drives the on-screen prompt - see IInteractable.
public bool PlayerInRange => _playerInRange;
public float HoldFraction => HoldDuration <= 0f ? 0f : _holdProgress / HoldDuration;
public string PromptText => "Hold E to view jobs";
public bool SuppressPrompt => IsOpen;
public void OnTriggerEnter( Collider other )
{
if ( other.GameObject.Root.Tags.Has( "player" ) )
_playerInRange = true;
}
public void OnTriggerExit( Collider other )
{
if ( !other.GameObject.Root.Tags.Has( "player" ) )
return;
_playerInRange = false;
_holdProgress = 0f;
IsOpen = false;
}
// DIAGNOSTIC - one line per job, once per hideout load. Remove once the
// bank job is confirmed loading from the board.
//
// Says exactly what the board is holding for each job's scene: which
// file it resolved to, how many root objects that copy has, and what
// payout it reads out of it. The bank job was advertising $0 and not
// loading, while the scene file on disk (and its compiled copy) were
// both correct - so the question is what the board actually has in
// memory, and this answers it without guessing. A bank line showing a
// handful of objects and no EscapeZone means the board is holding an
// old copy of the scene from before it was built out.
protected override void OnStart()
{
foreach ( var mission in Missions )
{
var scene = mission?.Scene;
if ( scene is null )
{
Log.Warning( $"[board] '{mission?.DisplayName}': NO SCENE - the reference didn't resolve to anything." );
continue;
}
var roots = scene.GameObjects?.Length ?? -1;
Log.Info( $"[board] '{mission.DisplayName}' -> '{scene.ResourcePath}' " +
$"(scene id {scene.Id}, {roots} root objects, valid={scene.IsValid}, error={scene.IsError}) " +
$"reads payout ${mission.BaseReward}" );
}
}
// DIAGNOSTIC - remove with the [board] lines above.
//
// Coming back from the bank shows nothing but the skybox and the
// objective markers. The markers prove the hideout scene and the local
// player exist; nothing else rendering means whatever camera is drawing
// the frame isn't looking at the room. This says which camera that is
// and where everything actually is, instead of guessing.
//
// Logged twice: once shortly after arrival, and again after SWB's
// one-second camera hand-off (PlayerBase.OverlayUIWorkaround) has had
// time to finish, so a camera that's wrong only at first can be told
// apart from one that stays wrong.
TimeSince _sinceArrival = 0;
int _cameraReportsDone = 0;
void ReportCameras()
{
var due = _cameraReportsDone == 0 ? 0.5f : 3f;
if ( _cameraReportsDone >= 2 || _sinceArrival < due )
return;
_cameraReportsDone++;
var local = PlayerBase.Local;
var players = Scene.GetAllComponents<PlayerBase>().Count();
Log.Info( $"[cams {_cameraReportsDone}] t={_sinceArrival.Relative:0.0}s players={players} " +
$"local={(local.IsValid() ? $"'{local.GameObject.Name}' at {local.WorldPosition} alive={local.IsAlive} scene={(local.Scene == Scene ? "this" : "OTHER")}" : "NONE")}" );
foreach ( var cam in Scene.GetAllComponents<CameraComponent>() )
{
Log.Info( $"[cams {_cameraReportsDone}] '{cam.GameObject.Name}' (root '{cam.GameObject.Root.Name}') " +
$"enabled={cam.Enabled} active={cam.Active} main={cam.IsMainCamera} priority={cam.Priority} at {cam.WorldPosition}" );
}
}
protected override void OnUpdate()
{
ReportCameras();
// Checked before the in-range guard on purpose: the whole point of
// the ready-up prompt is that you can do it from wherever you are in
// the hideout, without walking back to the board.
HandleReadyUpKey();
if ( !_playerInRange )
return;
var holding = Input.Down( InputButtonHelper.Use );
if ( holding )
{
_holdProgress += Time.Delta;
// Toggle once on the hold completing, then wait for release -
// otherwise holding Use would flap the board open and shut
// every HoldDuration seconds.
if ( _holdProgress >= HoldDuration && !_wasHolding )
{
_wasHolding = true;
IsOpen = !IsOpen;
}
}
else
{
_holdProgress = 0f;
_wasHolding = false;
}
}
public void Close()
{
IsOpen = false;
}
// The ReadyUp key readies you for whatever the crew has already
// proposed. Does nothing until somebody picks a job, since there'd be
// nothing to agree to.
void HandleReadyUpKey()
{
if ( PendingMissionIndex < 0 )
return;
if ( !Input.Pressed( "ReadyUp" ) )
return;
ToggleReady( PendingMissionIndex );
}
// Called by MissionBoardMenu when a job is clicked. Toggles this
// player's readiness for it - clicking the job you're already ready
// for backs you out, clicking a different one switches your vote.
public void ToggleReady( int missionIndex )
{
LocalReadyIndex = LocalReadyIndex == missionIndex ? -1 : missionIndex;
SubmitReady( Connection.Local.Id, LocalReadyIndex );
}
// Runs on the host no matter who called it, so one machine owns the
// decision and there's no chance of two clients both launching.
[Rpc.Host]
public void SubmitReady( Guid connectionId, int missionIndex )
{
if ( missionIndex < 0 )
_readyByConnection.Remove( connectionId );
else
_readyByConnection[connectionId] = missionIndex;
EvaluateCrew();
}
// Host only. Drops anyone who has disconnected, tells everyone where
// the crew stands, and launches once they all agree.
void EvaluateCrew()
{
if ( !Networking.IsHost )
return;
var connections = Connection.All;
var connected = connections.Select( c => c.Id ).ToHashSet();
// Someone readied up and then quit - otherwise the crew could
// never be complete and the job would never start.
foreach ( var stale in _readyByConnection.Keys.Where( k => !connected.Contains( k ) ).ToList() )
_readyByConnection.Remove( stale );
var crewSize = Math.Max( connections.Count, 1 );
// The job everyone has to agree on is whichever the most people
// have picked; ties resolve arbitrarily, which is fine because a
// tie can't be unanimous anyway.
var leading = _readyByConnection
.GroupBy( kv => kv.Value )
.OrderByDescending( g => g.Count() )
.FirstOrDefault();
var readyCount = leading?.Count() ?? 0;
var missionIndex = leading?.Key ?? -1;
// Whoever's voting for the leading job gets the credit in the
// prompt, so it reads as a person suggesting something rather than
// a job appearing out of nowhere.
var proposer = connections
.FirstOrDefault( c => _readyByConnection.TryGetValue( c.Id, out var v ) && v == missionIndex );
BroadcastCrewState( readyCount, crewSize, missionIndex, proposer?.DisplayName ?? "Someone" );
if ( readyCount >= crewSize && missionIndex >= 0 )
LaunchBroadcast( missionIndex );
}
[Rpc.Broadcast]
void BroadcastCrewState( int readyCount, int crewSize, int missionIndex, string proposedBy )
{
ReadyCount = readyCount;
CrewSize = crewSize;
PendingMissionIndex = missionIndex;
ProposedBy = proposedBy;
}
// Broadcast so every client changes scene, not just the host. Whether
// that actually carries connected clients across is UNTESTED - see the
// note in Launch.
[Rpc.Broadcast]
void LaunchBroadcast( int missionIndex )
{
if ( missionIndex < 0 || missionIndex >= Missions.Count )
return;
Launch( Missions[missionIndex] );
}
// Everything the mission needs lives in its own scene, so launching is
// just a scene change - the player's cash and loadout survive it
// because PlayerLoadout keeps them in a static store, not on the
// GameObject.
public void Launch( MissionEntry mission )
{
if ( mission?.Scene is null )
{
Log.Warning( $"MissionBoard: '{mission?.DisplayName}' has no Scene assigned." );
return;
}
IsOpen = false;
_readyByConnection.Clear();
LocalReadyIndex = -1;
PendingMissionIndex = -1;
// Has to happen before the scene change, and on every client -
// Launch is called from a broadcast, so each machine records the
// same difficulty before its map loads.
ActiveMission.SetFrom( mission );
var options = new SceneLoadOptions();
options.SetScene( mission.Scene );
options.ShowLoadingScreen = true;
// NOTE: options.DeleteEverything is deliberately NOT set.
//
// It was tried, on the theory that network-spawned objects were
// surviving the change. It fixed nothing measurable and made
// Game.ChangeScene itself throw a NullReferenceException, which
// is far worse than the problem: a scene change that throws
// half-way leaves exactly the partially-torn-down state we were
// trying to avoid. Don't re-add it without a reproducing case.
// Let go of the outgoing scene before asking the engine to drop
// it. These caches hold a Scene reference and GameObjects inside
// it, and anything still referencing them keeps the whole old
// scene graph reachable - which is what made a second job behave
// differently from the first.
Crew.Reset();
PoliceSpawns.Reset();
Log.Info( $"Launching job: {mission.DisplayName}" );
Game.ChangeScene( options );
}
}