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; using Robust.Shared.Audio.Systems; using Robust.Shared.Player; 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 _accessSystem = 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 _idCardSystem = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly SharedTransformSystem _transform = 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 companyIndex = message.CompanyIndex; var amount = (int)message.Amount; var station = ent.Comp.Station; var loader = GetEntity(args.LoaderUid); var xform = Transform(loader); // Ensure station and stock market components are valid if (station == null || !TryComp(station, out var stockMarket)) return; // Validate company index if (companyIndex < 0 || companyIndex >= stockMarket.Companies.Count) return; if (!TryComp(ent.Owner, out var access)) return; // Attempt to retrieve ID card from loader IdCardComponent? idCard = null; if (_idCardSystem.TryGetIdCard(loader, out var pdaId)) idCard = pdaId; // Play deny sound and exit if access is not allowed if (idCard == null || !_accessSystem.IsAllowed(pdaId.Owner, ent.Owner, access)) { _audio.PlayEntity( stockMarket.DenySound, Filter.Empty().AddInRange(_transform.GetMapCoordinates(loader, xform), 0.05f), loader, true, AudioParams.Default.WithMaxDistance(0.05f) ); 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(loader)} attempting to buy {amount} stocks of {company.LocalizedDisplayName}"); success = TryBuyStocks(station.Value, stockMarket, companyIndex, amount); break; case StockTradingUiAction.Sell: _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(loader)} attempting to sell {amount} stocks of {company.LocalizedDisplayName}"); success = TrySellStocks(station.Value, stockMarket, companyIndex, amount); break; default: throw new ArgumentOutOfRangeException(); } // Play confirmation sound if the transaction was successful _audio.PlayEntity(success ? stockMarket.ConfirmSound : stockMarket.DenySound, loader, args.Actor); } finally { // Raise the event to update the UI regardless of outcome var ev = new StockMarketUpdatedEvent(station.Value); RaiseLocalEvent(ev); } } private bool TryBuyStocks( EntityUid station, StationStockMarketComponent stockMarket, int companyIndex, int amount) { 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); // See if we can afford it if (bank.Balance < totalValue) return false; if (!stockMarket.StockOwnership.TryGetValue(companyIndex, out var currentOwned)) currentOwned = 0; // Update the bank account _cargo.UpdateBankAccount(station, bank, -totalValue); stockMarket.StockOwnership[companyIndex] = currentOwned + amount; // Log the transaction _adminLogger.Add(LogType.Action, LogImpact.Medium, $"[StockMarket] Bought {amount} stocks of {company.LocalizedDisplayName} at {company.CurrentPrice:F2} credits each (Total: {totalValue})"); return true; } private bool TrySellStocks( EntityUid station, StationStockMarketComponent stockMarket, int companyIndex, int amount) { 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; if (!stockMarket.StockOwnership.TryGetValue(companyIndex, out var currentOwned) || currentOwned < amount) return false; var company = stockMarket.Companies[companyIndex]; var totalValue = (int)Math.Round(company.CurrentPrice * amount); // Update stock ownership var newAmount = currentOwned - amount; if (newAmount > 0) stockMarket.StockOwnership[companyIndex] = newAmount; else stockMarket.StockOwnership.Remove(companyIndex); // Update the bank account _cargo.UpdateBankAccount(station, bank, totalValue); // Log the transaction _adminLogger.Add(LogType.Action, LogImpact.Medium, $"[StockMarket] Sold {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(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 var ev = new StockMarketUpdatedEvent(station); RaiseLocalEvent(ev); // 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(company); company.CurrentPrice = MathF.Max(newPrice, company.BasePrice * 0.1f); stockMarket.Companies[companyIndex] = company; var ev = new StockMarketUpdatedEvent(station); RaiseLocalEvent(ev); 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 StockCompanyStruct { LocalizedDisplayName = displayName, // Assume there's no Loc for it BasePrice = basePrice, CurrentPrice = basePrice, PriceHistory = [], }; stockMarket.Companies.Add(company); UpdatePriceHistory(company); var ev = new StockMarketUpdatedEvent(station); RaiseLocalEvent(ev); return true; } /// /// Attempts to add a new company to the station using the StockCompanyStruct /// /// False if the company already exists, true otherwise public bool TryAddCompany(EntityUid station, StationStockMarketComponent stockMarket, StockCompanyStruct company) { // Add the new company to the dictionary stockMarket.Companies.Add(company); // Make sure it has a price history UpdatePriceHistory(company); var ev = new StockMarketUpdatedEvent(station); RaiseLocalEvent(ev); return true; } private static void UpdatePriceHistory(StockCompanyStruct 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); } } public sealed class StockMarketUpdatedEvent(EntityUid station) : EntityEventArgs { public EntityUid Station = station; }