Core/GameProfile.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Prototype;
public sealed class GameSettingsData
{
public Dictionary<string, float> VoiceVolumes { get; set; } = new();
public float FieldOfView { get; set; } = 100f;
public float Brightness { get; set; } = 1f;
public float SoundVolume { get; set; } = 1f;
public float MouseSensitivity { get; set; } = 1f;
public float MusicVolume { get; set; } = 1f;
public float EffectsVolume { get; set; } = 1f;
public float VoiceVolume { get; set; } = 1f;
public bool MicrophoneEnabled { get; set; } = true;
public bool InvertMouse { get; set; }
public bool ShowCrosshair { get; set; } = true;
public bool ShowDamageNumbers { get; set; } = true;
public bool ShowHitmarker { get; set; } = true;
public float HitmarkerBodyHue { get; set; } = -1f;
public float HitmarkerHeadHue { get; set; } = 38f;
public bool ShowHurtEffect { get; set; } = true;
public float HurtEffectHue { get; set; } = 358f;
public bool ReduceWeaponMotion { get; set; }
public int AudioSettingsVersion { get; set; }
public bool CrosshairDot { get; set; } = true;
public bool CrosshairOutline { get; set; } = true;
public float CrosshairLength { get; set; } = 8f;
public float CrosshairGap { get; set; } = 4f;
public float CrosshairThickness { get; set; } = 2f;
public float CrosshairOpacity { get; set; } = 1f;
public float CrosshairHue { get; set; } = 180f;
public float CrosshairSaturation { get; set; } = 1f;
public float DamageSaturation { get; set; } = 1f;
public float DamageTextSize { get; set; } = 24f;
public float DamageHue { get; set; } = 45f;
public GameSettingsData Copy() => new GameSettingsData
{
CrosshairLength = CrosshairLength,
CrosshairGap = CrosshairGap,
CrosshairThickness = CrosshairThickness,
CrosshairOpacity = CrosshairOpacity,
CrosshairHue = CrosshairHue,
CrosshairSaturation = CrosshairSaturation,
DamageSaturation = DamageSaturation,
DamageTextSize = DamageTextSize,
DamageHue = DamageHue,
ShowHitmarker = ShowHitmarker,
HitmarkerBodyHue = HitmarkerBodyHue,
HitmarkerHeadHue = HitmarkerHeadHue,
ShowHurtEffect = ShowHurtEffect,
HurtEffectHue = HurtEffectHue,
AudioSettingsVersion = AudioSettingsVersion,
CrosshairDot = CrosshairDot,
CrosshairOutline = CrosshairOutline,
VoiceVolumes = new Dictionary<string, float>(VoiceVolumes),
FieldOfView = FieldOfView,
Brightness = Brightness,
SoundVolume = SoundVolume,
MouseSensitivity = MouseSensitivity,
MusicVolume = MusicVolume,
EffectsVolume = EffectsVolume,
VoiceVolume = VoiceVolume,
MicrophoneEnabled = MicrophoneEnabled,
InvertMouse = InvertMouse,
ShowCrosshair = ShowCrosshair,
ShowDamageNumbers = ShowDamageNumbers,
ReduceWeaponMotion = ReduceWeaponMotion
};
}
public sealed class RunRecord
{
public string RunId { get; set; } = "";
public long OwnerSteamId { get; set; }
public long GlobalRank { get; set; }
public string PlayerName { get; set; } = "Runner";
public string Category { get; set; } = "Solo";
public int Score { get; set; }
public int Kills { get; set; }
public int Levels { get; set; }
public float Seconds { get; set; }
public long FinishedAt { get; set; }
}
public static partial class GameProfile
{
private const string SettingsKey = "prototype.settings.v1";
private const string RunsKey = "prototype.runs.v1";
private const string ProgressKey = "prototype.progress.v1";
public static GameSettingsData Settings { get; private set; } = new();
public static ProgressionData Progress { get; private set; } = new();
public static IReadOnlyList<RunRecord> Runs => _runs;
private static readonly List<RunRecord> _runs = new();
private static readonly Dictionary<string, List<RunRecord>> _globalRuns = new();
private static bool _loaded;
public static void Load()
{
if ( _loaded ) return;
_loaded = true;
Settings = Sandbox.Game.Cookies.Get<GameSettingsData>( SettingsKey, null ) ?? new GameSettingsData();
Progress = Sandbox.Game.Cookies.Get<ProgressionData>( ProgressKey, null ) ?? new ProgressionData();
Progress.SkillRanks ??= new Dictionary<string, int>();
Progress.CoinReceipts ??= new();
if (!Progress.CoinsMigrated) { Progress.Coins += Math.Max(0, Progress.Insight) * 60; Progress.CoinsMigrated = true; SaveProgress(); }
if (Progress.SkillTreeVersion < 2)
{
foreach (var pair in Progress.SkillRanks.Where(x => !x.Key.StartsWith("tree_")).ToArray())
{
for (var r = 0; r < pair.Value; r++) Progress.Coins += 100 + r * 110 + r * r * 90;
Progress.SkillRanks.Remove(pair.Key);
}
Progress.SkillTreeVersion = 2;
SaveProgress();
}
Settings.VoiceVolumes ??= new();
if (Settings.AudioSettingsVersion < 1)
{
Settings.SoundVolume = Settings.MusicVolume = Settings.EffectsVolume = Settings.VoiceVolume = 1f;
Settings.AudioSettingsVersion = 1;
SaveSettings();
}
Settings.FieldOfView = Math.Clamp( Settings.FieldOfView, 75f, 120f );
Settings.Brightness = Math.Clamp( Settings.Brightness, .65f, 1.5f );
Settings.SoundVolume = Math.Clamp( Settings.SoundVolume, 0f, 2f );
Settings.MouseSensitivity = Math.Clamp( Settings.MouseSensitivity, .25f, 3f );
ValidateSettings();
_runs.AddRange( Sandbox.Game.Cookies.Get<List<RunRecord>>( RunsKey, null ) ?? new List<RunRecord>() );
LoadPendingRuns();
ApplyAudio();
}
private static void ValidateSettings()
{
static float Safe(float v, float min, float max, float fallback) => float.IsFinite(v) ? Math.Clamp(v, min, max) : fallback;
Settings.CrosshairLength = Safe(Settings.CrosshairLength,0f,24f,8f);
Settings.CrosshairGap = Safe(Settings.CrosshairGap,0f,20f,4f);
Settings.CrosshairThickness = Safe(Settings.CrosshairThickness,1f,6f,2f);
Settings.CrosshairOpacity = Safe(Settings.CrosshairOpacity,0f,1f,1f);
Settings.CrosshairSaturation = Safe(Settings.CrosshairSaturation,0f,1f,1f);
Settings.DamageSaturation = Safe(Settings.DamageSaturation,0f,1f,1f);
Settings.CrosshairHue = Safe(Settings.CrosshairHue,0f,360f,180f);
Settings.DamageTextSize = Safe(Settings.DamageTextSize,14f,56f,24f);
Settings.DamageHue = Safe(Settings.DamageHue,0f,360f,45f);
Settings.HitmarkerBodyHue = Safe(Settings.HitmarkerBodyHue,-1f,360f,-1f);
Settings.HitmarkerHeadHue = Safe(Settings.HitmarkerHeadHue,-1f,360f,38f);
Settings.HurtEffectHue = Safe(Settings.HurtEffectHue,-1f,360f,358f);
Settings.FieldOfView = Safe(Settings.FieldOfView,75f,120f,100f);
Settings.Brightness = Safe(Settings.Brightness,.65f,1.5f,1f);
Settings.MouseSensitivity = Safe(Settings.MouseSensitivity,.25f,3f,1f);
Settings.SoundVolume = Safe(Settings.SoundVolume,0f,2f,1f);
Settings.MusicVolume = Safe(Settings.MusicVolume,0f,2f,1f);
Settings.EffectsVolume = Safe(Settings.EffectsVolume,0f,2f,1f);
Settings.VoiceVolume = Safe(Settings.VoiceVolume,0f,2f,1f);
}
public static void SaveSettings()
{
ValidateSettings();
Sandbox.Game.Cookies.Set( SettingsKey, Settings );
ApplyAudio();
}
public static void ApplySettings(GameSettingsData draft)
{
// Preserve per-player voice adjustments made outside this menu.
var voices = Settings.VoiceVolumes;
Settings = draft.Copy();
Settings.AudioSettingsVersion = 1;
Settings.VoiceVolumes = voices;
SaveSettings();
}
private static float _audioTarget = 1f;
private static void ApplyAudio()
{
// Settings only set the target; avoid an abrupt gain jump during a shot.
_audioTarget = Math.Clamp(Settings.SoundVolume, 0f, 2f);
}
public static void UpdateAudio(float delta)
{
var mixer = Sandbox.Audio.Mixer.Master;
if (mixer is null) return;
var step = Math.Clamp(delta, 0f, .02f) / .08f;
mixer.Volume += Math.Clamp(_audioTarget - mixer.Volume, -step, step);
}
public static void AddRun( RunRecord record, bool publish = true )
{
Load();
if (!string.IsNullOrEmpty(record.RunId) && _runs.Any(r => r.RunId == record.RunId)) return;
var previousBest = _runs.Where( x => string.Equals( x.Category, record.Category, StringComparison.OrdinalIgnoreCase ) ).Select( x => x.Score ).DefaultIfEmpty( -1 ).Max();
if (publish) QueueRun(record, record.Score > previousBest);
_runs.Add( record );
var compact = _runs.OrderByDescending( x => x.Score ).ThenByDescending( x => x.Levels ).ThenBy( x => x.Seconds ).Take( 100 ).ToList();
_runs.Clear(); _runs.AddRange( compact );
Sandbox.Game.Cookies.Set( RunsKey, _runs );
if (!publish) return;
// Coins are saved incrementally from host-confirmed rewards, never again at results.
SaveProgress();
}
public static int SkillRank( string id ) => Progress.SkillRanks.GetValueOrDefault( id );
public static void SelectHero( string id ) { Progress.SelectedHero = HeroCatalog.Get( id ).Id; SaveProgress(); }
public static int SkillCost(SkillSpec skill) => 150 + skill.Tier * 250 + SkillRank(skill.Id) * 200;
public static void BankCoins(string runId, int total)
{
Load();
if (string.IsNullOrEmpty(runId) || total < 0) return;
var previous = Progress.CoinReceipts.GetValueOrDefault(runId);
if (total <= previous) return;
Progress.Coins += total - previous;
Progress.CoinReceipts[runId] = total;
SaveProgress();
}
public static string SkillRequirement(SkillSpec skill) => !string.IsNullOrEmpty(skill.Parent) && SkillRank(skill.Parent) < 2
? $"Requires {HeroCatalog.Skills.First(x => x.Id == skill.Parent).Name} rank 2" : "";
public static bool BuySkill( SkillSpec skill )
{
Load();
if (!HeroCatalog.Skills.Contains(skill) || SkillRequirement(skill).Length > 0) return false;
if (PrototypeGame.Instance is not null && PrototypeGame.Instance.LocalPhase != RunPhase.Lobby) return false;
var rank = SkillRank( skill.Id );
if ( rank >= skill.MaxRank ) return false;
var cost = SkillCost(skill);
if ( Progress.Coins < cost ) return false;
Progress.Coins -= cost; Progress.SkillRanks[skill.Id] = rank + 1; SaveProgress(); return true;
}
private static void SaveProgress() => Sandbox.Game.Cookies.Set( ProgressKey, Progress );
public static IEnumerable<RunRecord> Category( string category ) =>
(_globalRuns.TryGetValue( category, out var global ) && global.Count > 0 ? global : _runs.Where( x => string.Equals( x.Category, category, StringComparison.OrdinalIgnoreCase ) ))
.OrderByDescending( x => x.Score ).ThenByDescending( x => x.Levels ).ThenByDescending( x => x.Kills ).ThenBy( x => x.Seconds );
public static string BoardSource(string category) => _globalRuns.TryGetValue(category, out var rows) && rows.Count > 0 ? "Global rankings" : "Local run history";
public static async Task RefreshGlobal( string category )
{
var key = category.ToLowerInvariant();
try
{
var scoreBoard = Sandbox.Services.Leaderboards.GetFromStat( $"prototype_{key}_score" );
scoreBoard.MaxEntries = 30; scoreBoard.SetAggregationMax(); scoreBoard.SetSortDescending(); scoreBoard.FilterByNone();
await scoreBoard.Refresh();
if ( scoreBoard.Entries is null || scoreBoard.Entries.Length == 0 ) { _globalRuns.Remove(category); return; }
var ids = scoreBoard.Entries.Select( x => x.SteamId ).ToArray();
var kills = await ReadValues( $"prototype_{key}_kills", ids );
var levels = await ReadValues( $"prototype_{key}_levels", ids );
var times = await ReadValues( $"prototype_{key}_time", ids );
_globalRuns[category] = scoreBoard.Entries.Select( entry => new RunRecord
{
OwnerSteamId = entry.SteamId, GlobalRank = entry.Rank,
PlayerName = string.IsNullOrWhiteSpace( entry.DisplayName ) ? entry.SteamId.ToString() : entry.DisplayName,
Category = category, Score = (int)Math.Clamp( entry.Value, 0, int.MaxValue ),
Kills = (int)kills.GetValueOrDefault( entry.SteamId ), Levels = (int)levels.GetValueOrDefault( entry.SteamId ),
Seconds = (float)times.GetValueOrDefault( entry.SteamId ), FinishedAt = entry.Timestamp.ToUnixTimeSeconds()
} ).ToList();
var metadataTasks = scoreBoard.Entries.Select(async entry =>
{
if(string.IsNullOrWhiteSpace(entry.DataUrl)) return;
try
{
using var timeout = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(3));
var json = await Sandbox.Http.RequestStringAsync(entry.DataUrl, cancellationToken:timeout.Token);
using var doc = System.Text.Json.JsonDocument.Parse(json);
var row = _globalRuns[category].First(x => x.OwnerSteamId == entry.SteamId);
if(doc.RootElement.TryGetProperty("team",out var team)) row.PlayerName = team.GetString() ?? row.PlayerName;
if(doc.RootElement.TryGetProperty("run",out var run)) row.RunId = run.GetString() ?? "";
if(doc.RootElement.TryGetProperty("kills",out var k) && k.TryGetInt32(out var kv)) row.Kills=kv;
if(doc.RootElement.TryGetProperty("levels",out var l) && l.TryGetInt32(out var lv)) row.Levels=lv;
if(doc.RootElement.TryGetProperty("seconds",out var t) && t.TryGetSingle(out var tv)) row.Seconds=tv;
}
catch { /* Legacy entries keep their recorded account name. */ }
}).ToArray();
foreach (var metadataTask in metadataTasks) await metadataTask;
}
catch ( Exception exception ) { Log.Warning( $"Leaderboard refresh unavailable: {exception.Message}" ); }
}
private static async Task<Dictionary<long, double>> ReadValues( string stat, long[] ids )
{
var board = Sandbox.Services.Leaderboards.GetFromStat( stat );
board.MaxEntries = 30; board.IncludeSteamIds( ids ); board.SetAggregationLast(); board.FilterByNone();
await board.Refresh();
return (board.Entries ?? Array.Empty<Sandbox.Services.Leaderboards.Board2.Entry>()).ToDictionary( x => x.SteamId, x => x.Value );
}
}