WorldBuilder/ShopManager.cs
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using Clover.Components;
using Clover.Data;
using Clover.Items;
using Clover.Npc;
using Clover.Persistence;
using Clover.Player;
using Clover.Ui;
using Sandbox.Diagnostics;

namespace Clover.WorldBuilder;

[Category( "Clover/World" )]
public class ShopManager : Component
{
	[Property] public string StoreId { get; set; }

	[Property] public List<ShopDisplay> Displays { get; set; }

	[Property] public ShopClerk ShopClerk { get; set; }

	[Property] public List<CatalogueData> Catalogues { get; set; }

	[Property] public Dialogue BuyItemDialogue { get; set; }

	private CatalogueData PickRandomCatalogue()
	{
		var availableCatalogues = Catalogues.FindAll( c => c.IsCurrentlyInsideAvailablePeriod );
		if ( availableCatalogues.Count == 0 )
			return null;

		return Random.Shared.FromList( availableCatalogues );
	}

	private bool HasItem( string itemId )
	{
		return Items.Exists( i => i.ItemId == itemId );
	}

	private void GenerateItems()
	{
		Log.Info( "Generating shop items." );
		foreach ( var display in Displays )
		{
			if ( !display.IsValid() )
			{
				Log.Error( $"Shop: Display {display} is not valid." );
				continue;
			}

			Log.Info( $"Generating items for display {display}." );

			display.ShopManager = this;

			var catalogue = PickRandomCatalogue();
			if ( catalogue == null )
			{
				Log.Error( $"Shop: No available catalogues found for display {display}." );
				continue;
			}

			var nonAddedItems = catalogue.Items
				.Where( i => !HasItem( i.GetIdentifier() ) && i.GetMaxBounds() <= display.Size ).ToList();

			if ( !nonAddedItems.Any() )
			{
				Log.Error( $"Shop: No items found for display {display}." );
				continue;
			}

			Log.Info( $"Shop: Found {nonAddedItems.Count} items for display {display}." );

			var item = Random.Shared.FromList( nonAddedItems );

			if ( item == null )
			{
				Log.Error( $"Shop: Invalid item found for display {display}. This should not happen." );
				continue;
			}

			// WORKAROUND FOR WRONGLY CASTED ITEMDATA
			// TODO: Remove this when fixed
			// https://github.com/Facepunch/sbox-issues/issues/6630
			if ( string.IsNullOrEmpty( item.GetIdentifier() ) )
			{
				item = ResourceLibrary.Get<ItemData>( item.ResourcePath );
			}

			Items.Add( new ShopItem
			{
				ItemId = item.GetIdentifier(),
				Price = item.BaseBuyPrice,
				Stock = 1,
				Display = Displays.IndexOf( display )
			} );

			Log.Info( $"Shop: Added item {item} to display {display}." );
		}
	}

	private string GetStatePath() => $"{RealmManager.CurrentRealm.Path}/shops/{StoreId}.json";

	public void LoadState()
	{
		if ( !FileSystem.Data.FileExists( GetStatePath() ) )
		{
			Log.Info( "Shop: No shop state found, generating new items." );
			State = new ShopManagerState { Items = new List<ShopItem>(), LastGenerated = DateTime.MinValue };
			GenerateItems();
			SaveState();
			LoadItems();
			return;
		}

		State = JsonSerializer.Deserialize<ShopManagerState>( FileSystem.Data.ReadAllText( GetStatePath() ),
			GameManager.JsonOptions );

		if ( State.LastGenerated.Date != DateTime.Today )
		{
			Log.Info( $"Shop: Regenerating shop items (last: {State.LastGenerated}, today: {DateTime.Today})." );
			State.Items.Clear();
			State.LastGenerated = DateTime.Today;
			GenerateItems();
			SaveState();
		}

		LoadItems();
	}

	private void LoadItems()
	{
		foreach ( var display in Displays )
		{
			display.ShopManager = this;
			display.UpdateItem();
		}
	}

	public void SaveState()
	{
		Log.Info( "Shop: Saving shop state." );
		FileSystem.Data.CreateDirectory( $"{RealmManager.CurrentRealm.Path}/shops" );
		FileSystem.Data.WriteAllText( GetStatePath(), JsonSerializer.Serialize( State, GameManager.JsonOptions ) );
	}

	protected override void OnStart()
	{
		if ( IsProxy ) return;
		LoadState();
	}

	public class ShopItem
	{
		public string ItemId { get; set; }
		public int Price { get; set; }
		public int Stock { get; set; }
		public int Display { get; set; }
		[JsonIgnore] public ItemData ItemData => ItemData.Get( ItemId );
	}

	public class ShopManagerState
	{
		public List<ShopItem> Items { get; set; }
		public DateTime LastGenerated { get; set; }
	}

	public List<ShopItem> Items => State.Items;

	public ShopManagerState State { get; set; }

	public void DispatchBuyItem( PlayerCharacter player, ShopItem item )
	{
		if ( !Items.Contains( item ) )
		{
			player.Notify( Notifications.NotificationType.Error, "This item is not available in this shop." );
			return;
		}

		if ( item.Stock <= 0 )
		{
			player.Notify( Notifications.NotificationType.Error, "This item is out of stock." );
			return;
		}

		Assert.NotNull( ShopClerk, "ShopClerk is not set." );

		CameraMan.Instance?.AddTarget( ShopClerk.GameObject );

		var window = DialogueManager.Instance.DialogueWindow;

		window.SetData( "ItemName", item.ItemData.Name );
		window.SetData( "ItemPrice", item.Price );
		window.SetData( "PlayerClovers", player.CloverBalanceController.GetBalance() );

		window.SetTarget( 0, ShopClerk.GameObject );

		window.SetAction( "BuyItem", () =>
		{
			if ( !BuyItem( player, item ) )
				return;

			window.Enabled = false;
		} );

		ShopClerk.LoadDialogue( BuyItemDialogue );

		ShopClerk.SetState( BaseNpc.NpcState.Interacting );
		ShopClerk.LookAt( player.GameObject );

		window.OnDialogueEnd += () =>
		{
			ShopClerk.SetState( BaseNpc.NpcState.Idle );
			CameraMan.Instance.RemoveTarget( ShopClerk.GameObject );
		};
	}

	public bool BuyItem( PlayerCharacter player, ShopItem item )
	{
		if ( player.CloverBalanceController.GetBalance() < item.Price )
		{
			player.Notify( Notifications.NotificationType.Error, "You don't have enough clovers to buy this item." );
			return false;
		}

		player.CloverBalanceController.DeductClover( item.Price );

		item.Stock--;

		if ( item.Stock <= 0 )
		{
			Items.Remove( item );
		}

		SaveState();

		player.Inventory.PickUpItem( PersistentItem.Create( item.ItemId ) );

		player.Notify( Notifications.NotificationType.Success,
			$"You bought {item.ItemData.Name} for {item.Price} clovers." );

		return true;
	}
}