Player/Player.Persist.cs

Player persistence logic. Defines PlayerSave and ItemSave structs, loads/saves player data to the engine data filesystem as JSON, applies loaded saves on the host, and sends owner-client saves to the host via an RPC.

File AccessNetworkingHttp Calls
using System;
using System.Collections.Generic;
using Sandbox;
using BrickJam.Upgrading;

namespace BrickJam;

public sealed partial class Player
{
	public static bool DataChanged { get; set; }

	private const float SaveInterval = 15f;
	private TimeUntil nextSave = SaveInterval;

	public struct ItemSave
	{
		public string PrefabPath { get; set; }
		public LootRarity Rarity { get; set; }
		public int Count { get; set; }

		public static ItemSave FromEntry( ItemEntry entry, int count ) => new()
		{
			PrefabPath = entry.Prefab?.ResourceName,
			Rarity = entry.Rarity,
			Count = count
		};
	}

	public struct PlayerSave
	{
		public int Money { get; set; }
		public List<ItemSave> Inventory { get; set; }
		public List<string> Upgrades { get; set; }
		public bool SeenTips { get; set; }

		private static string FileName => $"{Connection.Local.SteamId}.player";

		public static PlayerSave? LoadStored()
		{
			if ( !FileSystem.Data.FileExists( FileName ) )
				return null;

			try { return Json.Deserialize<PlayerSave>( FileSystem.Data.ReadAllText( FileName ) ); }
			catch { return null; }
		}

		public void SaveStored() => FileSystem.Data.WriteJson( FileName, this );

		public static PlayerSave FromPlayer( Player player )
		{
			var save = new PlayerSave
			{
				Inventory = new List<ItemSave>(),
				Upgrades = new List<string>(),
				Money = player.Money,
				SeenTips = player.SeenTips
			};

			if ( player.Inventory is not null )
			{
				foreach ( var (entry, count) in player.Inventory.Entries )
					save.Inventory.Add( ItemSave.FromEntry( entry, count ) );
			}

			foreach ( var upgrade in player.Upgrades )
				save.Upgrades.Add( upgrade );

			return save;
		}
	}

	/// <summary>Host-side: apply a save to this player.</summary>
	public void LoadSaveData( PlayerSave save )
	{
		if ( !Networking.IsHost )
			return;

		Inventory?.Clear();
		Upgrades.Clear();

		if ( Inventory is not null )
		{
			foreach ( var item in save.Inventory )
			{
				var prefab = LootPrefab.Get( item.PrefabPath );
				if ( prefab is null )
					continue;

				Inventory.Add( new ItemEntry { Prefab = prefab, Rarity = item.Rarity }, item.Count );
			}
		}

		foreach ( var upgrade in save.Upgrades )
		{
			if ( Upgrade.Exists( upgrade ) && !Upgrades.Contains( upgrade ) )
				Upgrades.Add( upgrade );
		}

		SeenTips = save.SeenTips;
		SetMoney( save.Money );
	}

	/// <summary>Owner client: persist the local player's data to disk.</summary>
	public static void StoreSave()
	{
		if ( !DataChanged || Local is null )
			return;

		PlayerSave.FromPlayer( Local ).SaveStored();
		DataChanged = false;
	}

	/// <summary>Owner client: load the stored save (if any) and push it to the host.</summary>
	private void SendStoredSaveToServer()
	{
		var save = PlayerSave.LoadStored();
		if ( save is null )
			return;

		// File Explorer: we just successfully loaded our progress file (owner client).
		AchievementTracker.OnSaveLoaded();

		ReceiveSaveFromClient( Json.Serialize( save.Value ) );
	}

	[Rpc.Host]
	private void ReceiveSaveFromClient( string json )
	{
		try { LoadSaveData( Json.Deserialize<PlayerSave>( json ) ); }
		catch ( Exception e ) { Log.Warning( $"Invalid player save data: {e.Message}" ); }
	}

	private void TickSave()
	{
		if ( IsProxy )
			return;

		if ( nextSave )
		{
			DataChanged = true;
			StoreSave();
			nextSave = SaveInterval;
		}
	}
}