using Content.Server.Access.Systems; using Content.Server.Administration.Logs; using Content.Server.Cargo.Components; using Content.Server.Cargo.Systems; using Content.Server.DeltaV.Cargo.Components; using Content.Server.DeltaV.CartridgeLoader.Cartridges; using Content.Shared.Access.Components; using Content.Shared.Access.Systems; using Content.Shared.CartridgeLoader; using Content.Shared.CartridgeLoader.Cartridges; using Content.Shared.Database; using Robust.Shared.Audio.Systems; using Robust.Shared.Random; using Robust.Shared.Timing; namespace Content.Server.DeltaV.Cargo.Systems; /// /// This handles the stock market updates /// public sealed class StockMarketSystem : EntitySystem { [Dependency] private readonly AccessReaderSystem _access = default!; [Dependency] private readonly CargoSystem _cargo = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly ILogManager _log = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly IdCardSystem _idCard = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; private ISawmill _sawmill = default!; private const float MaxPrice = 262144; // 1/64 of max safe integer public override void Initialize() { base.Initialize(); _sawmill = _log.GetSawmill("admin.stock_market"); SubscribeLocalEvent(OnStockTradingMessage); } public override void Update(float frameTime) { var curTime = _timing.CurTime; var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var component)) { if (curTime < component.NextUpdate) continue; component.NextUpdate = curTime + component.UpdateInterval; UpdateStockPrices(uid, component); } } private void OnStockTradingMessage(Entity ent, ref CartridgeMessageEvent args) { if (args is not StockTradingUiMessageEvent message) return; var user = args.Actor; var companyIndex = message.CompanyIndex; var amount = message.Amount; var loader = GetEntity(args.LoaderUid); // Ensure station and stock market components are valid if (ent.Comp.Station is not {} station || !TryComp(station, out var stockMarket)) return; // Validate company index if (companyIndex < 0 || companyIndex >= stockMarket.Companies.Count) return; if (!TryComp(ent, out var access)) return; // Attempt to retrieve ID card from loader, // play deny sound and exit if access is not allowed if (!_idCard.TryGetIdCard(loader, out var idCard) || !_access.IsAllowed(idCard, ent.Owner, access)) { _audio.PlayEntity(stockMarket.DenySound, loader, user); return; } try { var company = stockMarket.Companies[companyIndex]; // Attempt to buy or sell stocks based on the action bool success; switch (message.Action) { case StockTradingUiAction.Buy: _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(user):user} attempting to buy {amount} stocks of {company.LocalizedDisplayName}"); success = TryChangeStocks(station, stockMarket, companyIndex, amount, user); break; case StockTradingUiAction.Sell: _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(user):user} attempting to sell {amount} stocks of {company.LocalizedDisplayName}"); success = TryChangeStocks(station, stockMarket, companyIndex, -amount, user); break; default: throw new ArgumentOutOfRangeException(); } // Play confirmation sound if the transaction was successful _audio.PlayEntity(success ? stockMarket.ConfirmSound : stockMarket.DenySound, loader, user); } finally { // Raise the event to update the UI regardless of outcome UpdateStockMarket(station); } } private void UpdateStockMarket(EntityUid station) { var ev = new StockMarketUpdatedEvent(station); RaiseLocalEvent(ref ev); } private bool TryChangeStocks( EntityUid station, StationStockMarketComponent stockMarket, int companyIndex, int amount, EntityUid user) { if (amount == 0 || companyIndex < 0 || companyIndex >= stockMarket.Companies.Count) return false; // Check if the station has a bank account if (!TryComp(station, out var bank)) return false; var company = stockMarket.Companies[companyIndex]; var totalValue = (int)Math.Round(company.CurrentPrice * amount); if (!stockMarket.StockOwnership.TryGetValue(companyIndex, out var currentOwned)) currentOwned = 0; if (amount > 0) { // Buying: see if we can afford it if (bank.Balance < totalValue) return false; } else { // Selling: see if we have enough stocks to sell var selling = -amount; if (currentOwned < selling) return false; } var newAmount = currentOwned + amount; if (newAmount > 0) stockMarket.StockOwnership[companyIndex] = newAmount; else stockMarket.StockOwnership.Remove(companyIndex); // Update the bank account (take away for buying and give for selling) _cargo.UpdateBankAccount(station, bank, -totalValue); // Log the transaction var verb = amount > 0 ? "bought" : "sold"; _adminLogger.Add(LogType.Action, LogImpact.Medium, $"[StockMarket] {ToPrettyString(user):user} {verb} {Math.Abs(amount)} stocks of {company.LocalizedDisplayName} at {company.CurrentPrice:F2} credits each (Total: {totalValue})"); return true; } private void UpdateStockPrices(EntityUid station, StationStockMarketComponent stockMarket) { for (var i = 0; i < stockMarket.Companies.Count; i++) { var company = stockMarket.Companies[i]; var changeType = DetermineMarketChange(stockMarket.MarketChanges); var multiplier = CalculatePriceMultiplier(changeType); UpdatePriceHistory(ref company); // Update price with multiplier var oldPrice = company.CurrentPrice; company.CurrentPrice *= (1 + multiplier); // Ensure price doesn't go below minimum threshold company.CurrentPrice = MathF.Max(company.CurrentPrice, company.BasePrice * 0.1f); // Ensure price doesn't go above maximum threshold company.CurrentPrice = MathF.Min(company.CurrentPrice, MaxPrice); stockMarket.Companies[i] = company; // Calculate the percentage change var percentChange = (company.CurrentPrice - oldPrice) / oldPrice * 100; // Raise the event UpdateStockMarket(station); // Log it _adminLogger.Add(LogType.Action, LogImpact.Medium, $"[StockMarket] Company '{company.LocalizedDisplayName}' price updated by {percentChange:+0.00;-0.00}% from {oldPrice:0.00} to {company.CurrentPrice:0.00}"); } } /// /// Attempts to change the price for a specific company /// /// True if the operation was successful, false otherwise public bool TryChangeStocksPrice(EntityUid station, StationStockMarketComponent stockMarket, float newPrice, int companyIndex) { // Check if it exceeds the max price if (newPrice > MaxPrice) { _sawmill.Error($"New price cannot be greater than {MaxPrice}."); return false; } if (companyIndex < 0 || companyIndex >= stockMarket.Companies.Count) return false; var company = stockMarket.Companies[companyIndex]; UpdatePriceHistory(ref company); company.CurrentPrice = MathF.Max(newPrice, company.BasePrice * 0.1f); stockMarket.Companies[companyIndex] = company; UpdateStockMarket(station); return true; } /// /// Attempts to add a new company to the station /// /// False if the company already exists, true otherwise public bool TryAddCompany(EntityUid station, StationStockMarketComponent stockMarket, float basePrice, string displayName) { // Create a new company struct with the specified parameters var company = new StockCompany { LocalizedDisplayName = displayName, // Assume there's no Loc for it BasePrice = basePrice, CurrentPrice = basePrice, PriceHistory = [], }; UpdatePriceHistory(ref company); stockMarket.Companies.Add(company); UpdateStockMarket(station); return true; } /// /// Attempts to add a new company to the station using the StockCompany /// /// False if the company already exists, true otherwise public bool TryAddCompany(Entity station, StockCompany company) { // Make sure it has a price history UpdatePriceHistory(ref company); // Add the new company to the dictionary station.Comp.Companies.Add(company); UpdateStockMarket(station); return true; } private static void UpdatePriceHistory(ref StockCompany company) { // Create if null company.PriceHistory ??= []; // Make sure it has at least 5 entries while (company.PriceHistory.Count < 5) { company.PriceHistory.Add(company.BasePrice); } // Store previous price in history company.PriceHistory.Add(company.CurrentPrice); if (company.PriceHistory.Count > 5) // Keep last 5 prices company.PriceHistory.RemoveAt(1); // Always keep the base price } private MarketChange DetermineMarketChange(List marketChanges) { var roll = _random.NextFloat(); var cumulative = 0f; foreach (var change in marketChanges) { cumulative += change.Chance; if (roll <= cumulative) return change; } return marketChanges[0]; // Default to first (usually minor) change if we somehow exceed 100% } private float CalculatePriceMultiplier(MarketChange change) { // Using Box-Muller transform for normal distribution var u1 = _random.NextFloat(); var u2 = _random.NextFloat(); var randStdNormal = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2); // Scale and shift the result to our desired range var range = change.Range.Y - change.Range.X; var mean = (change.Range.Y + change.Range.X) / 2; var stdDev = range / 6.0f; // 99.7% of values within range var result = (float)(mean + (stdDev * randStdNormal)); return Math.Clamp(result, change.Range.X, change.Range.Y); } } /// /// Broadcast whenever a stock market is updated. /// [ByRefEvent] public record struct StockMarketUpdatedEvent(EntityUid Station);