diff --git a/Content.Client/DeltaV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml b/Content.Client/DeltaV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml new file mode 100644 index 00000000000..058bde07e9c --- /dev/null +++ b/Content.Client/DeltaV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/Content.Client/DeltaV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs b/Content.Client/DeltaV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs new file mode 100644 index 00000000000..f5798f44c42 --- /dev/null +++ b/Content.Client/DeltaV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs @@ -0,0 +1,75 @@ +using System.Linq; +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client.DeltaV.CartridgeLoader.Cartridges; + +[GenerateTypedNameReferences] +public sealed partial class PriceHistoryTable : BoxContainer +{ + public PriceHistoryTable() + { + RobustXamlLoader.Load(this); + + // Create the stylebox here so we can use the colors from StockTradingUi + var styleBox = new StyleBoxFlat + { + BackgroundColor = StockTradingUiFragment.PriceBackgroundColor, + ContentMarginLeftOverride = 6, + ContentMarginRightOverride = 6, + ContentMarginTopOverride = 4, + ContentMarginBottomOverride = 4, + BorderColor = StockTradingUiFragment.BorderColor, + BorderThickness = new Thickness(1), + }; + + HistoryPanel.PanelOverride = styleBox; + } + + public void Update(List priceHistory) + { + PriceGrid.RemoveAllChildren(); + + // Take last 5 prices + var lastFivePrices = priceHistory.TakeLast(5).ToList(); + + for (var i = 0; i < lastFivePrices.Count; i++) + { + var price = lastFivePrices[i]; + var previousPrice = i > 0 ? lastFivePrices[i - 1] : price; + var priceChange = ((price - previousPrice) / previousPrice) * 100; + + var entryContainer = new BoxContainer + { + Orientation = LayoutOrientation.Vertical, + MinWidth = 80, + HorizontalAlignment = HAlignment.Center, + }; + + var priceLabel = new Label + { + Text = $"${price:F2}", + HorizontalAlignment = HAlignment.Center, + }; + + var changeLabel = new Label + { + Text = $"{(priceChange >= 0 ? "+" : "")}{priceChange:F2}%", + HorizontalAlignment = HAlignment.Center, + StyleClasses = { "LabelSubText" }, + Modulate = priceChange switch + { + > 0 => StockTradingUiFragment.PositiveColor, + < 0 => StockTradingUiFragment.NegativeColor, + _ => StockTradingUiFragment.NeutralColor, + } + }; + + entryContainer.AddChild(priceLabel); + entryContainer.AddChild(changeLabel); + PriceGrid.AddChild(entryContainer); + } + } +} diff --git a/Content.Client/DeltaV/CartridgeLoader/Cartridges/StockTradingUi.cs b/Content.Client/DeltaV/CartridgeLoader/Cartridges/StockTradingUi.cs new file mode 100644 index 00000000000..45704ee2349 --- /dev/null +++ b/Content.Client/DeltaV/CartridgeLoader/Cartridges/StockTradingUi.cs @@ -0,0 +1,45 @@ +using Robust.Client.UserInterface; +using Content.Client.UserInterface.Fragments; +using Content.Shared.CartridgeLoader; +using Content.Shared.CartridgeLoader.Cartridges; + +namespace Content.Client.DeltaV.CartridgeLoader.Cartridges; + +public sealed partial class StockTradingUi : UIFragment +{ + private StockTradingUiFragment? _fragment; + + public override Control GetUIFragmentRoot() + { + return _fragment!; + } + + public override void Setup(BoundUserInterface userInterface, EntityUid? fragmentOwner) + { + _fragment = new StockTradingUiFragment(); + + _fragment.OnBuyButtonPressed += (company, amount) => + { + SendStockTradingUiMessage(StockTradingUiAction.Buy, company, amount, userInterface); + }; + _fragment.OnSellButtonPressed += (company, amount) => + { + SendStockTradingUiMessage(StockTradingUiAction.Sell, company, amount, userInterface); + }; + } + + public override void UpdateState(BoundUserInterfaceState state) + { + if (state is StockTradingUiState cast) + { + _fragment?.UpdateState(cast); + } + } + + private static void SendStockTradingUiMessage(StockTradingUiAction action, int company, float amount, BoundUserInterface userInterface) + { + var newsMessage = new StockTradingUiMessageEvent(action, company, amount); + var message = new CartridgeUiMessage(newsMessage); + userInterface.SendMessage(message); + } +} diff --git a/Content.Client/DeltaV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml b/Content.Client/DeltaV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml new file mode 100644 index 00000000000..00b45584cc4 --- /dev/null +++ b/Content.Client/DeltaV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Content.Client/DeltaV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs b/Content.Client/DeltaV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs new file mode 100644 index 00000000000..b44e8f44c70 --- /dev/null +++ b/Content.Client/DeltaV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs @@ -0,0 +1,269 @@ +using System.Linq; +using Content.Client.Administration.UI.CustomControls; +using Content.Shared.CartridgeLoader.Cartridges; +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client.DeltaV.CartridgeLoader.Cartridges; + +[GenerateTypedNameReferences] +public sealed partial class StockTradingUiFragment : BoxContainer +{ + private readonly Dictionary _companyEntries = new(); + + // Event handlers for the parent UI + public event Action? OnBuyButtonPressed; + public event Action? OnSellButtonPressed; + + // Define colors + public static readonly Color PositiveColor = Color.FromHex("#00ff00"); // Green + public static readonly Color NegativeColor = Color.FromHex("#ff0000"); // Red + public static readonly Color NeutralColor = Color.FromHex("#ffffff"); // White + public static readonly Color BackgroundColor = Color.FromHex("#25252a"); // Dark grey + public static readonly Color PriceBackgroundColor = Color.FromHex("#1a1a1a"); // Darker grey + public static readonly Color BorderColor = Color.FromHex("#404040"); // Light grey + + public StockTradingUiFragment() + { + RobustXamlLoader.Load(this); + } + + public void UpdateState(StockTradingUiState state) + { + NoEntries.Visible = state.Entries.Count == 0; + Balance.Text = Loc.GetString("stock-trading-balance", ("balance", state.Balance)); + + // Clear all existing entries + foreach (var entry in _companyEntries.Values) + { + entry.Container.RemoveAllChildren(); + } + _companyEntries.Clear(); + Entries.RemoveAllChildren(); + + // Add new entries + for (var i = 0; i < state.Entries.Count; i++) + { + var company = state.Entries[i]; + var entry = new CompanyEntry(i, company.LocalizedDisplayName, OnBuyButtonPressed, OnSellButtonPressed); + _companyEntries[i] = entry; + Entries.AddChild(entry.Container); + + var ownedStocks = state.OwnedStocks.GetValueOrDefault(i, 0); + entry.Update(company, ownedStocks); + } + } + + private sealed class CompanyEntry + { + public readonly BoxContainer Container; + private readonly Label _nameLabel; + private readonly Label _priceLabel; + private readonly Label _changeLabel; + private readonly Button _sellButton; + private readonly Button _buyButton; + private readonly Label _sharesLabel; + private readonly LineEdit _amountEdit; + private readonly PriceHistoryTable _priceHistory; + + public CompanyEntry(int companyIndex, + string displayName, + Action? onBuyPressed, + Action? onSellPressed) + { + Container = new BoxContainer + { + Orientation = LayoutOrientation.Vertical, + HorizontalExpand = true, + Margin = new Thickness(0, 0, 0, 2), + }; + + // Company info panel + var companyPanel = new PanelContainer(); + + var mainContent = new BoxContainer + { + Orientation = LayoutOrientation.Vertical, + HorizontalExpand = true, + Margin = new Thickness(8), + }; + + // Top row with company name and price info + var topRow = new BoxContainer + { + Orientation = LayoutOrientation.Horizontal, + HorizontalExpand = true, + }; + + _nameLabel = new Label + { + HorizontalExpand = true, + Text = displayName, + }; + + // Create a panel for price and change + var pricePanel = new PanelContainer + { + HorizontalAlignment = HAlignment.Right, + }; + + // Style the price panel + var priceStyleBox = new StyleBoxFlat + { + BackgroundColor = BackgroundColor, + ContentMarginLeftOverride = 8, + ContentMarginRightOverride = 8, + ContentMarginTopOverride = 4, + ContentMarginBottomOverride = 4, + BorderColor = BorderColor, + BorderThickness = new Thickness(1), + }; + + pricePanel.PanelOverride = priceStyleBox; + + // Container for price and change labels + var priceContainer = new BoxContainer + { + Orientation = LayoutOrientation.Horizontal, + }; + + _priceLabel = new Label(); + + _changeLabel = new Label + { + HorizontalAlignment = HAlignment.Right, + Modulate = NeutralColor, + Margin = new Thickness(15, 0, 0, 0), + }; + + priceContainer.AddChild(_priceLabel); + priceContainer.AddChild(_changeLabel); + pricePanel.AddChild(priceContainer); + + topRow.AddChild(_nameLabel); + topRow.AddChild(pricePanel); + + // Add the top row + mainContent.AddChild(topRow); + + // Add the price history table between top and bottom rows + _priceHistory = new PriceHistoryTable(); + mainContent.AddChild(_priceHistory); + + // Trading controls (bottom row) + var bottomRow = new BoxContainer + { + Orientation = LayoutOrientation.Horizontal, + HorizontalExpand = true, + Margin = new Thickness(0, 5, 0, 0), + }; + + _sharesLabel = new Label + { + Text = Loc.GetString("stock-trading-owned-shares"), + MinWidth = 100, + }; + + _amountEdit = new LineEdit + { + PlaceHolder = Loc.GetString("stock-trading-amount-placeholder"), + HorizontalExpand = true, + MinWidth = 80, + }; + + var buttonContainer = new BoxContainer + { + Orientation = LayoutOrientation.Horizontal, + HorizontalAlignment = HAlignment.Right, + MinWidth = 140, + }; + + _buyButton = new Button + { + Text = Loc.GetString("stock-trading-buy-button"), + MinWidth = 65, + Margin = new Thickness(3, 0, 3, 0), + }; + + _sellButton = new Button + { + Text = Loc.GetString("stock-trading-sell-button"), + MinWidth = 65, + }; + + buttonContainer.AddChild(_buyButton); + buttonContainer.AddChild(_sellButton); + + bottomRow.AddChild(_sharesLabel); + bottomRow.AddChild(_amountEdit); + bottomRow.AddChild(buttonContainer); + + // Add the bottom row last + mainContent.AddChild(bottomRow); + + companyPanel.AddChild(mainContent); + Container.AddChild(companyPanel); + + // Add horizontal separator after the panel + var separator = new HSeparator + { + Margin = new Thickness(5, 3, 5, 5), + }; + Container.AddChild(separator); + + // Button click events + _buyButton.OnPressed += _ => + { + if (float.TryParse(_amountEdit.Text, out var amount) && amount > 0) + onBuyPressed?.Invoke(companyIndex, amount); + }; + + _sellButton.OnPressed += _ => + { + if (float.TryParse(_amountEdit.Text, out var amount) && amount > 0) + onSellPressed?.Invoke(companyIndex, amount); + }; + + // There has to be a better way of doing this + _amountEdit.OnTextChanged += args => + { + var newText = string.Concat(args.Text.Where(char.IsDigit)); + if (newText != args.Text) + _amountEdit.Text = newText; + }; + } + + public void Update(StockCompanyStruct company, int ownedStocks) + { + _nameLabel.Text = company.LocalizedDisplayName; + _priceLabel.Text = $"${company.CurrentPrice:F2}"; + _sharesLabel.Text = Loc.GetString("stock-trading-owned-shares", ("shares", ownedStocks)); + + var priceChange = 0f; + if (company.PriceHistory is { Count: > 0 }) + { + var previousPrice = company.PriceHistory[^1]; + priceChange = (company.CurrentPrice - previousPrice) / previousPrice * 100; + } + + _changeLabel.Text = $"{(priceChange >= 0 ? "+" : "")}{priceChange:F2}%"; + + // Update color based on price change + _changeLabel.Modulate = priceChange switch + { + > 0 => PositiveColor, + < 0 => NegativeColor, + _ => NeutralColor, + }; + + // Update the price history table if not null + if (company.PriceHistory != null) + _priceHistory.Update(company.PriceHistory); + + // Disable sell button if no shares owned + _sellButton.Disabled = ownedStocks <= 0; + } + } +} diff --git a/Content.Server/Cargo/Components/StationStockMarketComponent.cs b/Content.Server/Cargo/Components/StationStockMarketComponent.cs new file mode 100644 index 00000000000..4ea9bd43133 --- /dev/null +++ b/Content.Server/Cargo/Components/StationStockMarketComponent.cs @@ -0,0 +1,71 @@ +using System.Numerics; +using Content.Server.DeltaV.Cargo.Systems; +using Content.Server.DeltaV.CartridgeLoader.Cartridges; +using Content.Shared.CartridgeLoader.Cartridges; +using Robust.Shared.Audio; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; +using Robust.Shared.Timing; + +namespace Content.Server.DeltaV.Cargo.Components; + +[RegisterComponent, AutoGenerateComponentPause] +[Access(typeof(StockMarketSystem), typeof(StockTradingCartridgeSystem))] +public sealed partial class StationStockMarketComponent : Component +{ + /// + /// The list of companies you can invest in + /// + [DataField] + public List Companies = []; + + /// + /// The list of shares owned by the station + /// + [DataField] + public Dictionary StockOwnership = new(); + + /// + /// The interval at which the stock market updates + /// + [DataField] + public TimeSpan UpdateInterval = TimeSpan.FromSeconds(600); // 10 minutes + + /// + /// The timespan of next update. + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))] + [AutoPausedField] + public TimeSpan NextUpdate = TimeSpan.Zero; + + /// + /// The sound to play after selling or buying stocks + /// + [DataField] + public SoundSpecifier ConfirmSound = new SoundPathSpecifier("/Audio/Effects/Cargo/ping.ogg"); + + /// + /// The sound to play if the don't have access to buy or sell stocks + /// + [DataField] + public SoundSpecifier DenySound = new SoundPathSpecifier("/Audio/Effects/Cargo/buzz_sigh.ogg"); + + // These work well as presets but can be changed in the yaml + [DataField] + public List MarketChanges = + [ + new() { Chance = 0.86f, Range = new Vector2(-0.05f, 0.05f) }, // Minor + new() { Chance = 0.10f, Range = new Vector2(-0.3f, 0.2f) }, // Moderate + new() { Chance = 0.03f, Range = new Vector2(-0.5f, 1.5f) }, // Major + new() { Chance = 0.01f, Range = new Vector2(-0.9f, 4.0f) }, // Catastrophic + ]; +} + +[DataDefinition] +public sealed partial class MarketChange +{ + [DataField(required: true)] + public float Chance; + + [DataField(required: true)] + public Vector2 Range; +} diff --git a/Content.Server/Cargo/StocksCommands.cs b/Content.Server/Cargo/StocksCommands.cs new file mode 100644 index 00000000000..dfe1776f666 --- /dev/null +++ b/Content.Server/Cargo/StocksCommands.cs @@ -0,0 +1,135 @@ +using Content.Server.Administration; +using Content.Server.DeltaV.Cargo.Components; +using Content.Server.DeltaV.Cargo.Systems; +using Content.Shared.Administration; +using Content.Shared.CartridgeLoader.Cartridges; +using Robust.Shared.Console; + +namespace Content.Server.DeltaV.Cargo; + +[AdminCommand(AdminFlags.Fun)] +public sealed class ChangeStocksPriceCommand : IConsoleCommand +{ + public string Command => "changestocksprice"; + public string Description => Loc.GetString("cmd-changestocksprice-desc"); + public string Help => Loc.GetString("cmd-changestocksprice-help", ("command", Command)); + + [Dependency] private readonly IEntityManager _entityManager = default!; + [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length < 2) + { + shell.WriteLine(Loc.GetString("shell-wrong-arguments-number")); + return; + } + + if (!int.TryParse(args[0], out var companyIndex)) + { + shell.WriteError(Loc.GetString("shell-argument-must-be-number")); + return; + } + + if (!float.TryParse(args[1], out var newPrice)) + { + shell.WriteError(Loc.GetString("shell-argument-must-be-number")); + return; + } + + EntityUid? targetStation = null; + if (args.Length > 2) + { + if (!EntityUid.TryParse(args[2], out var station)) + { + shell.WriteError(Loc.GetString("shell-entity-uid-must-be-number")); + return; + } + targetStation = station; + } + + var stockMarket = _entitySystemManager.GetEntitySystem(); + var query = _entityManager.EntityQueryEnumerator(); + + while (query.MoveNext(out var uid, out var comp)) + { + // Skip if we're looking for a specific station and this isn't it + if (targetStation != null && uid != targetStation) + continue; + + if (stockMarket.TryChangeStocksPrice(uid, comp, newPrice, companyIndex)) + { + shell.WriteLine(Loc.GetString("shell-command-success")); + return; + } + + shell.WriteLine(Loc.GetString("cmd-changestocksprice-invalid-company")); + return; + } + + shell.WriteLine(targetStation != null + ? Loc.GetString("cmd-changestocksprice-invalid-station") + : Loc.GetString("cmd-changestocksprice-no-stations")); + } +} + +[AdminCommand(AdminFlags.Fun)] +public sealed class AddStocksCompanyCommand : IConsoleCommand +{ + public string Command => "addstockscompany"; + public string Description => Loc.GetString("cmd-addstockscompany-desc"); + public string Help => Loc.GetString("cmd-addstockscompany-help", ("command", Command)); + + [Dependency] private readonly IEntityManager _entityManager = default!; + [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length < 2) + { + shell.WriteLine(Loc.GetString("shell-wrong-arguments-number")); + return; + } + + if (!float.TryParse(args[1], out var basePrice)) + { + shell.WriteError(Loc.GetString("shell-argument-must-be-number")); + return; + } + + EntityUid? targetStation = null; + if (args.Length > 2) + { + if (!EntityUid.TryParse(args[2], out var station)) + { + shell.WriteError(Loc.GetString("shell-entity-uid-must-be-number")); + return; + } + targetStation = station; + } + + var displayName = args[0]; + var stockMarket = _entitySystemManager.GetEntitySystem(); + var query = _entityManager.EntityQueryEnumerator(); + + while (query.MoveNext(out var uid, out var comp)) + { + // Skip if we're looking for a specific station and this isn't it + if (targetStation != null && uid != targetStation) + continue; + + if (stockMarket.TryAddCompany(uid, comp, basePrice, displayName)) + { + shell.WriteLine(Loc.GetString("shell-command-success")); + return; + } + + shell.WriteLine(Loc.GetString("cmd-addstockscompany-failure")); + return; + } + + shell.WriteLine(targetStation != null + ? Loc.GetString("cmd-addstockscompany-invalid-station") + : Loc.GetString("cmd-addstockscompany-no-stations")); + } +} diff --git a/Content.Server/Cargo/Systems/StockMarketSystem.cs b/Content.Server/Cargo/Systems/StockMarketSystem.cs new file mode 100644 index 00000000000..5ff5cd4ff7f --- /dev/null +++ b/Content.Server/Cargo/Systems/StockMarketSystem.cs @@ -0,0 +1,385 @@ +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 + if (success) + { + _audio.PlayEntity( + stockMarket.ConfirmSound, + Filter.Empty().AddInRange(_transform.GetMapCoordinates(loader, xform), 0.05f), + loader, + true, + AudioParams.Default.WithMaxDistance(0.05f) + ); + } + } + 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; +} diff --git a/Content.Server/DeltaV/CartridgeLoader/Cartridges/StockTradingCartridgeComponent.cs b/Content.Server/DeltaV/CartridgeLoader/Cartridges/StockTradingCartridgeComponent.cs new file mode 100644 index 00000000000..7ab11f64d4a --- /dev/null +++ b/Content.Server/DeltaV/CartridgeLoader/Cartridges/StockTradingCartridgeComponent.cs @@ -0,0 +1,11 @@ +namespace Content.Server.DeltaV.CartridgeLoader.Cartridges; + +[RegisterComponent, Access(typeof(StockTradingCartridgeSystem))] +public sealed partial class StockTradingCartridgeComponent : Component +{ + /// + /// Station entity to keep track of + /// + [DataField] + public EntityUid? Station; +} diff --git a/Content.Server/DeltaV/CartridgeLoader/Cartridges/StockTradingCartridgeSystem.cs b/Content.Server/DeltaV/CartridgeLoader/Cartridges/StockTradingCartridgeSystem.cs new file mode 100644 index 00000000000..cd68c5adb43 --- /dev/null +++ b/Content.Server/DeltaV/CartridgeLoader/Cartridges/StockTradingCartridgeSystem.cs @@ -0,0 +1,101 @@ +using System.Linq; +using Content.Server.Cargo.Components; +using Content.Server.DeltaV.Cargo.Components; +using Content.Server.DeltaV.Cargo.Systems; +using Content.Server.Station.Systems; +using Content.Server.CartridgeLoader; +using Content.Shared.Cargo.Components; +using Content.Shared.CartridgeLoader; +using Content.Shared.CartridgeLoader.Cartridges; + +namespace Content.Server.DeltaV.CartridgeLoader.Cartridges; + +public sealed class StockTradingCartridgeSystem : EntitySystem +{ + [Dependency] private readonly CartridgeLoaderSystem _cartridgeLoader = default!; + [Dependency] private readonly StationSystem _station = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnUiReady); + SubscribeLocalEvent(OnStockMarketUpdated); + SubscribeLocalEvent(OnMapInit); + SubscribeLocalEvent(OnBalanceUpdated); + } + + private void OnBalanceUpdated(Entity ent, ref BankBalanceUpdatedEvent args) + { + UpdateAllCartridges(args.Station); + } + + private void OnUiReady(Entity ent, ref CartridgeUiReadyEvent args) + { + UpdateUI(ent, args.Loader); + } + + private void OnStockMarketUpdated(StockMarketUpdatedEvent args) + { + UpdateAllCartridges(args.Station); + } + + private void OnMapInit(Entity ent, ref MapInitEvent args) + { + // Initialize price history for each company + for (var i = 0; i < ent.Comp.Companies.Count; i++) + { + var company = ent.Comp.Companies[i]; + + // Create initial price history using base price + company.PriceHistory = new List(); + for (var j = 0; j < 5; j++) + { + company.PriceHistory.Add(company.BasePrice); + } + + ent.Comp.Companies[i] = company; + } + + if (_station.GetOwningStation(ent.Owner) is { } station) + UpdateAllCartridges(station); + } + + private void UpdateAllCartridges(EntityUid station) + { + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var comp, out var cartridge)) + { + if (cartridge.LoaderUid is not { } loader || comp.Station != station) + continue; + UpdateUI((uid, comp), loader); + } + } + + private void UpdateUI(Entity ent, EntityUid loader) + { + if (_station.GetOwningStation(loader) is { } station) + ent.Comp.Station = station; + + if (!TryComp(ent.Comp.Station, out var stockMarket) || + !TryComp(ent.Comp.Station, out var bankAccount)) + return; + + // Convert company data to UI state format + var entries = stockMarket.Companies.Select(company => new StockCompanyStruct( + displayName: company.LocalizedDisplayName, + currentPrice: company.CurrentPrice, + basePrice: company.BasePrice, + priceHistory: company.PriceHistory)) + .ToList(); + + // Send the UI state with balance and owned stocks + var state = new StockTradingUiState( + entries: entries, + ownedStocks: stockMarket.StockOwnership, + balance: bankAccount.Balance + ); + + _cartridgeLoader.UpdateCartridgeUiState(loader, state); + } +} diff --git a/Content.Shared/DeltaV/CartridgeLoader/Cartridges/StockTradingUiMessageEvent.cs b/Content.Shared/DeltaV/CartridgeLoader/Cartridges/StockTradingUiMessageEvent.cs new file mode 100644 index 00000000000..a80f8c6b8a8 --- /dev/null +++ b/Content.Shared/DeltaV/CartridgeLoader/Cartridges/StockTradingUiMessageEvent.cs @@ -0,0 +1,19 @@ +using Robust.Shared.Serialization; + +namespace Content.Shared.CartridgeLoader.Cartridges; + +[Serializable, NetSerializable] +public sealed class StockTradingUiMessageEvent(StockTradingUiAction action, int companyIndex, float amount) + : CartridgeMessageEvent +{ + public readonly StockTradingUiAction Action = action; + public readonly int CompanyIndex = companyIndex; + public readonly float Amount = amount; +} + +[Serializable, NetSerializable] +public enum StockTradingUiAction +{ + Buy, + Sell, +} diff --git a/Content.Shared/DeltaV/CartridgeLoader/Cartridges/StockTradingUiState.cs b/Content.Shared/DeltaV/CartridgeLoader/Cartridges/StockTradingUiState.cs new file mode 100644 index 00000000000..aea4ba5aa1d --- /dev/null +++ b/Content.Shared/DeltaV/CartridgeLoader/Cartridges/StockTradingUiState.cs @@ -0,0 +1,66 @@ +using Robust.Shared.Serialization; + +namespace Content.Shared.CartridgeLoader.Cartridges; + +[Serializable, NetSerializable] +public sealed class StockTradingUiState( + List entries, + Dictionary ownedStocks, + float balance) + : BoundUserInterfaceState +{ + public readonly List Entries = entries; + public readonly Dictionary OwnedStocks = ownedStocks; + public readonly float Balance = balance; +} + +// No structure, zero fucks given +[DataDefinition, Serializable] +public partial struct StockCompanyStruct +{ + /// + /// The displayed name of the company shown in the UI. + /// + [DataField(required: true)] + public LocId? DisplayName; + + // Used for runtime-added companies that don't have a localization entry + private string? _displayName; + + /// + /// Gets or sets the display name, using either the localized or direct string value + /// + [Access(Other = AccessPermissions.ReadWriteExecute)] + public string LocalizedDisplayName + { + get => _displayName ?? Loc.GetString(DisplayName ?? string.Empty); + set => _displayName = value; + } + + /// + /// The current price of the company's stock + /// + [DataField(required: true)] + public float CurrentPrice; + + /// + /// The base price of the company's stock + /// + [DataField(required: true)] + public float BasePrice; + + /// + /// The price history of the company's stock + /// + [DataField] + public List? PriceHistory; + + public StockCompanyStruct(string displayName, float currentPrice, float basePrice, List? priceHistory) + { + DisplayName = displayName; + _displayName = null; + CurrentPrice = currentPrice; + BasePrice = basePrice; + PriceHistory = priceHistory ?? []; + } +} diff --git a/Resources/Locale/en-US/deltav/cargo/stocks-comapnies.ftl b/Resources/Locale/en-US/deltav/cargo/stocks-comapnies.ftl new file mode 100644 index 00000000000..69ef7330a56 --- /dev/null +++ b/Resources/Locale/en-US/deltav/cargo/stocks-comapnies.ftl @@ -0,0 +1,6 @@ +# Company names used for stocks trading +stock-trading-company-nanotrasen = Nanotrasen [NT] +stock-trading-company-gorlex = Gorlex [GRX] +stock-trading-company-interdyne = Interdyne Pharmaceuticals [INTP] +stock-trading-company-fishinc = Fish Inc. [FIN] +stock-trading-company-donk = Donk Co. [DONK] diff --git a/Resources/Locale/en-US/deltav/cargo/stocks-commands.ftl b/Resources/Locale/en-US/deltav/cargo/stocks-commands.ftl new file mode 100644 index 00000000000..8e0fe014999 --- /dev/null +++ b/Resources/Locale/en-US/deltav/cargo/stocks-commands.ftl @@ -0,0 +1,13 @@ +# changestockprice command +cmd-changestocksprice-desc = Changes a company's stock price to the specified number. +cmd-changestocksprice-help = changestockprice [Station UID] +cmd-changestocksprice-invalid-company = Failed to execute command! Invalid company index or the new price exceeds the allowed limit. +cmd-changestocksprice-invalid-station = No stock market found for specified station +cmd-changestocksprice-no-stations = No stations with stock markets found + +# addstockscompany command +cmd-addstockscompany-desc = Adds a new company to the stocks market. +cmd-addstockscompany-help = addstockscompany [Station UID] +cmd-addstockscompany-failure = Failed to add company to the stock market. +cmd-addstockscompany-invalid-station = No stock market found for specified station +cmd-addstockscompany-no-stations = No stations with stock markets found diff --git a/Resources/Locale/en-US/deltav/cartridge-loader/cartridges.ftl b/Resources/Locale/en-US/deltav/cartridge-loader/cartridges.ftl index ede1a36b8ee..ec6fe1e11ae 100644 --- a/Resources/Locale/en-US/deltav/cartridge-loader/cartridges.ftl +++ b/Resources/Locale/en-US/deltav/cartridge-loader/cartridges.ftl @@ -1,3 +1,6 @@ +## CrimeAssist + +# General crime-assist-program-name = Crime Assist crime-assist-yes-button = Yes crime-assist-no-button = No @@ -6,6 +9,14 @@ crime-assist-crimetype-misdemeanour = Misdemeanour crime-assist-crimetype-felony = Felony crime-assist-crimetype-capital = Capital crime-assist-crime-innocent = No crime was committed +crime-assist-mainmenu = Welcome to Crime Assist! +crime-assist-sophont-explanation = A sophont is described as any entity with the capacity to display the following attributes: + • [bold]Sapience[/bold]: the entity possesses basic logic and problem-solving skills, or at a minimum some level of significant intelligence. + • [bold]Sentience[/bold]: the entity has the capacity to process an emotion or lack thereof, or at a minimum the ability to recognise its own pain. + • [bold]Self-awareness[/bold]: the entity is capable of altering its behaviour in a reasonable fashion as a result of stimuli, or at a minimum is capable of recognising its own sapience and sentience. + Any sophont is considered a legal person, regardless of origin or prior cognitive status. Much like any other intelligent organic, a sophont may press charges against crew and be tried for crimes. + +# Crimes crime-assist-crime-animalcruelty = Code 101: Animal Cruelty crime-assist-crime-theft = Code 102: Theft crime-assist-crime-trespass = Code 110: Trespass @@ -32,7 +43,8 @@ crime-assist-crime-decorporealisation = Code 305: Decorporealisation crime-assist-crime-kidnapping = Code 309: Kidnapping crime-assist-crime-sedition = Code 311: Sedition crime-assist-crime-sexualharassment = Code 314: Sexual Harassment -crime-assist-mainmenu = Welcome to Crime Assist! + +# Questions crime-assist-question-isitterrorism = Did the suspect hold hostages, cause many deaths or major destruction to force compliance from the crew? crime-assist-question-wassomeoneattacked = Was an entity attacked? crime-assist-question-wasitsophont = Was the victim in question a sophont? @@ -59,6 +71,8 @@ crime-assist-question-happenincourt = Was the suspect a nuisance in court? crime-assist-question-duringactiveinvestigation = Was the suspect a nuisance during an active investigation, and hindered the investigation as a result? crime-assist-question-tocommandstaff = Did the suspect overthrow or compromise a lawfully established Chain of Command, or attempt to do so? crime-assist-question-wasitcommanditself = Was a command staff or department head abusing authority over another sophont? + +# Crime details crime-assist-crimedetail-innocent = Crime could not be determined. Use your best judgement to resolve the situation. crime-assist-crimedetail-animalcruelty = To inflict unnecessary suffering on a sapient being with malicious intent. crime-assist-crimedetail-theft = To unlawfully take property or items without consent. @@ -86,6 +100,8 @@ crime-assist-crimedetail-decorporealisation = To unlawfully, maliciously, and pe crime-assist-crimedetail-kidnapping = To unlawfully confine or restrict the free movement of a sophont against their will. crime-assist-crimedetail-sedition = To act to overthrow a lawfully established Chain of Command or governing body without lawful or legitimate cause. crime-assist-crimedetail-sexualharassment = To sexually harass, attempt to coerce into sexual relations, or effect unwanted sexual contact with an unwilling sophont. + +# Punishments crime-assist-crimepunishment-innocent = No punishment may be necessary crime-assist-crimepunishment-animalcruelty = Punishment: 3 minutes crime-assist-crimepunishment-theft = Punishment: 2 minutes @@ -113,12 +129,10 @@ crime-assist-crimepunishment-decorporealisation = Punishment: Capital crime-assist-crimepunishment-kidnapping = Punishment: Capital crime-assist-crimepunishment-sedition = Punishment: Capital crime-assist-crimepunishment-sexualharassment = Punishment: Capital -crime-assist-sophont-explanation = A sophont is described as any entity with the capacity to display the following attributes: - • [bold]Sapience[/bold]: the entity possesses basic logic and problem-solving skills, or at a minimum some level of significant intelligence. - • [bold]Sentience[/bold]: the entity has the capacity to process an emotion or lack thereof, or at a minimum the ability to recognise its own pain. - • [bold]Self-awareness[/bold]: the entity is capable of altering its behaviour in a reasonable fashion as a result of stimuli, or at a minimum is capable of recognising its own sapience and sentience. - Any sophont is considered a legal person, regardless of origin or prior cognitive status. Much like any other intelligent organic, a sophont may press charges against crew and be tried for crimes. +## MailMetrics + +# General mail-metrics-program-name = MailMetrics mail-metrics-header = Income from Mail Deliveries mail-metrics-opened = Earnings (Opened) @@ -131,3 +145,16 @@ mail-metrics-money-header = Spesos mail-metrics-total = Total mail-metrics-progress = {$opened} out of {$total} packages opened! mail-metrics-progress-percent = Success rate: {$successRate}% + +## StockTrading + +# General +stock-trading-program-name = StockTrading +stock-trading-title = Intergalactic Stock Market +stock-trading-balance = Balance: {$balance} credits +stock-trading-no-entries = No entries +stock-trading-owned-shares = Owned: {$shares} +stock-trading-buy-button = Buy +stock-trading-sell-button = Sell +stock-trading-amount-placeholder = Amount +stock-trading-price-history = Price History diff --git a/Resources/Prototypes/DeltaV/Entities/Objects/Devices/cartridges.yml b/Resources/Prototypes/DeltaV/Entities/Objects/Devices/cartridges.yml index 81e11d9d084..94b9210e60c 100644 --- a/Resources/Prototypes/DeltaV/Entities/Objects/Devices/cartridges.yml +++ b/Resources/Prototypes/DeltaV/Entities/Objects/Devices/cartridges.yml @@ -59,3 +59,27 @@ icon: sprite: Objects/Specific/Mail/mail.rsi state: icon + +- type: entity + parent: BaseItem + id: StockTradingCartridge + name: StockTrading cartridge + description: A cartridge that tracks the intergalactic stock market. + components: + - type: Sprite + sprite: DeltaV/Objects/Devices/cartridge.rsi + state: cart-stonk + - type: Icon + sprite: DeltaV/Objects/Devices/cartridge.rsi + state: cart-mail + - type: UIFragment + ui: !type:StockTradingUi + - type: StockTradingCartridge + - type: Cartridge + programName: stock-trading-program-name + icon: + sprite: DeltaV/Misc/program_icons.rsi + state: stock_trading + - type: BankClient + - type: AccessReader # This is so that we can restrict who can buy stocks + access: [["Orders"]] diff --git a/Resources/Prototypes/DeltaV/Entities/Stations/base.yml b/Resources/Prototypes/DeltaV/Entities/Stations/base.yml new file mode 100644 index 00000000000..fe31706b262 --- /dev/null +++ b/Resources/Prototypes/DeltaV/Entities/Stations/base.yml @@ -0,0 +1,21 @@ +- type: entity + id: BaseStationStockMarket + abstract: true + components: + - type: StationStockMarket + companies: + - displayName: stock-trading-company-nanotrasen + basePrice: 100 + currentPrice: 100 + - displayName: stock-trading-company-gorlex + basePrice: 75 + currentPrice: 75 + - displayName: stock-trading-company-interdyne + basePrice: 300 + currentPrice: 300 + - displayName: stock-trading-company-fishinc + basePrice: 25 + currentPrice: 25 + - displayName: stock-trading-company-donk + basePrice: 90 + currentPrice: 90 diff --git a/Resources/Prototypes/Entities/Objects/Devices/pda.yml b/Resources/Prototypes/Entities/Objects/Devices/pda.yml index 2166f554470..f4abe6ba213 100644 --- a/Resources/Prototypes/Entities/Objects/Devices/pda.yml +++ b/Resources/Prototypes/Entities/Objects/Devices/pda.yml @@ -375,6 +375,7 @@ - NotekeeperCartridge - NewsReaderCartridge - MailMetricsCartridge + - StockTradingCartridge # DeltaV - StockTrading - type: entity name: LO special PDA unit # DeltaV - Logistics Department replacing Cargo @@ -395,7 +396,8 @@ - CrewManifestCartridge - NotekeeperCartridge - NewsReaderCartridge - - MailMetricsCartridge + - MailMetricsCartridge # DeltaV - MailMetrics courier tracker + - StockTradingCartridge # DeltaV - StockTrading - type: entity parent: BasePDA @@ -410,6 +412,12 @@ borderColor: "#e39751" - type: Icon state: pda-cargo + - type: CartridgeLoader # DeltaV + preinstalled: + - CrewManifestCartridge + - NotekeeperCartridge + - NewsReaderCartridge + - StockTradingCartridge # DeltaV - StockTrading - type: entity parent: BasePDA @@ -954,6 +962,7 @@ - NotekeeperCartridge - NewsReaderCartridge - LogProbeCartridge + - StockTradingCartridge # DeltaV - StockTrading - type: entity parent: CentcomPDA @@ -1171,6 +1180,12 @@ borderColor: "#3f3f74" - type: Icon state: pda-reporter + - type: CartridgeLoader # DeltaV + preinstalled: + - CrewManifestCartridge + - NotekeeperCartridge + - NewsReaderCartridge + - StockTradingCartridge # DeltaV - StockTrading - type: entity parent: BasePDA diff --git a/Resources/Prototypes/Entities/Stations/nanotrasen.yml b/Resources/Prototypes/Entities/Stations/nanotrasen.yml index 9f4adce96ae..d9a41967e42 100644 --- a/Resources/Prototypes/Entities/Stations/nanotrasen.yml +++ b/Resources/Prototypes/Entities/Stations/nanotrasen.yml @@ -27,6 +27,7 @@ - BaseStationNanotrasen - BaseRandomStation - BaseStationMail # Nyano component, required for station mail to function + - BaseStationStockMarket # DeltaV categories: [ HideSpawnMenu ] components: - type: Transform diff --git a/Resources/Textures/DeltaV/Misc/program_icons.rsi/meta.json b/Resources/Textures/DeltaV/Misc/program_icons.rsi/meta.json new file mode 100644 index 00000000000..1a7d2a16194 --- /dev/null +++ b/Resources/Textures/DeltaV/Misc/program_icons.rsi/meta.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "license": "CC0-1.0", + "copyright": "stock_trading made by Malice", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "stock_trading" + } + ] +} diff --git a/Resources/Textures/DeltaV/Misc/program_icons.rsi/stock_trading.png b/Resources/Textures/DeltaV/Misc/program_icons.rsi/stock_trading.png new file mode 100644 index 00000000000..251b46a3f83 Binary files /dev/null and b/Resources/Textures/DeltaV/Misc/program_icons.rsi/stock_trading.png differ diff --git a/Resources/Textures/DeltaV/Objects/Devices/cartridge.rsi/cart-stonk.png b/Resources/Textures/DeltaV/Objects/Devices/cartridge.rsi/cart-stonk.png new file mode 100644 index 00000000000..ddfed6e915c Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Devices/cartridge.rsi/cart-stonk.png differ diff --git a/Resources/Textures/DeltaV/Objects/Devices/cartridge.rsi/meta.json b/Resources/Textures/DeltaV/Objects/Devices/cartridge.rsi/meta.json index 4a4ba3352f8..e7d607aa4a8 100644 --- a/Resources/Textures/DeltaV/Objects/Devices/cartridge.rsi/meta.json +++ b/Resources/Textures/DeltaV/Objects/Devices/cartridge.rsi/meta.json @@ -12,6 +12,9 @@ }, { "name": "cart-mail" + }, + { + "name": "cart-stonk" } ] }