From 79ad7e18a2b859269dc9d9ad1dfecd3a14689368 Mon Sep 17 00:00:00 2001
From: Epic Toast <44736041+EpicToastTM@users.noreply.github.com>
Date: Sat, 11 Jan 2025 14:52:24 -0800
Subject: [PATCH 1/7] Add the stock market cartridge
---
.../Cartridges/PriceHistoryTable.xaml | 25 ++
.../Cartridges/PriceHistoryTable.xaml.cs | 75 ++++
.../Cartridges/StockTradingUi.cs | 45 +++
.../Cartridges/StockTradingUiFragment.xaml | 44 +++
.../Cartridges/StockTradingUiFragment.xaml.cs | 269 ++++++++++++++
.../Components/StationStockMarketComponent.cs | 64 ++++
Content.Server/_DV/Cargo/StocksCommands.cs | 135 +++++++
.../_DV/Cargo/Systems/StockMarketSystem.cs | 351 ++++++++++++++++++
.../StockTradingCartridgeComponent.cs | 11 +
.../Cartridges/StockTradingCartridgeSystem.cs | 93 +++++
.../Cartridges/StockTradingUiMessageEvent.cs | 19 +
.../Cartridges/StockTradingUiState.cs | 66 ++++
.../en-US/_DV/cargo/stocks-comapnies.ftl | 10 +
.../en-US/_DV/cargo/stocks-commands.ftl | 13 +
.../en-US/_DV/cartridge-loader/cartridges.ftl | 15 +
.../Catalog/Fills/Lockers/heads.yml | 1 +
.../Entities/Objects/Devices/pda.yml | 23 ++
.../Entities/Stations/nanotrasen.yml | 1 +
.../Entities/Objects/Devices/cartridges.yml | 24 ++
.../Prototypes/_DV/Entities/Stations/base.yml | 30 ++
.../Entities/Objects/Devices/pda.yml | 8 +
.../_DV/Misc/program_icons.rsi/meta.json | 7 +-
.../Misc/program_icons.rsi/stock_trading.png | Bin 0 -> 1012 bytes
.../Devices/cartridge.rsi/cart-stonk.png | Bin 0 -> 367 bytes
.../Objects/Devices/cartridge.rsi/meta.json | 3 +
25 files changed, 1330 insertions(+), 2 deletions(-)
create mode 100644 Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml
create mode 100644 Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs
create mode 100644 Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUi.cs
create mode 100644 Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml
create mode 100644 Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs
create mode 100644 Content.Server/_DV/Cargo/Components/StationStockMarketComponent.cs
create mode 100644 Content.Server/_DV/Cargo/StocksCommands.cs
create mode 100644 Content.Server/_DV/Cargo/Systems/StockMarketSystem.cs
create mode 100644 Content.Server/_DV/CartridgeLoader/Cartridges/StockTradingCartridgeComponent.cs
create mode 100644 Content.Server/_DV/CartridgeLoader/Cartridges/StockTradingCartridgeSystem.cs
create mode 100644 Content.Shared/_DV/CartridgeLoader/Cartridges/StockTradingUiMessageEvent.cs
create mode 100644 Content.Shared/_DV/CartridgeLoader/Cartridges/StockTradingUiState.cs
create mode 100644 Resources/Locale/en-US/_DV/cargo/stocks-comapnies.ftl
create mode 100644 Resources/Locale/en-US/_DV/cargo/stocks-commands.ftl
create mode 100644 Resources/Prototypes/_DV/Entities/Stations/base.yml
create mode 100644 Resources/Textures/_DV/Misc/program_icons.rsi/stock_trading.png
create mode 100644 Resources/Textures/_DV/Objects/Devices/cartridge.rsi/cart-stonk.png
diff --git a/Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml b/Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml
new file mode 100644
index 00000000000000..21b30497d71294
--- /dev/null
+++ b/Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs b/Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs
new file mode 100644
index 00000000000000..f04d01c297bdd1
--- /dev/null
+++ b/Content.Client/_DV/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._DV.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/_DV/CartridgeLoader/Cartridges/StockTradingUi.cs b/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUi.cs
new file mode 100644
index 00000000000000..a182311a70329f
--- /dev/null
+++ b/Content.Client/_DV/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._DV.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, int amount, BoundUserInterface userInterface)
+ {
+ var newsMessage = new StockTradingUiMessageEvent(action, company, amount);
+ var message = new CartridgeUiMessage(newsMessage);
+ userInterface.SendMessage(message);
+ }
+}
diff --git a/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml b/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml
new file mode 100644
index 00000000000000..66647897d52728
--- /dev/null
+++ b/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs b/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs
new file mode 100644
index 00000000000000..34f71058c2bdcd
--- /dev/null
+++ b/Content.Client/_DV/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._DV.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 (int.TryParse(_amountEdit.Text, out var amount) && amount > 0)
+ onBuyPressed?.Invoke(companyIndex, amount);
+ };
+
+ _sellButton.OnPressed += _ =>
+ {
+ if (int.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(StockCompany 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/_DV/Cargo/Components/StationStockMarketComponent.cs b/Content.Server/_DV/Cargo/Components/StationStockMarketComponent.cs
new file mode 100644
index 00000000000000..9eb34bb88e949f
--- /dev/null
+++ b/Content.Server/_DV/Cargo/Components/StationStockMarketComponent.cs
@@ -0,0 +1,64 @@
+using System.Numerics;
+using Content.Server._DV.Cargo.Systems;
+using Content.Server._DV.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._DV.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(300); // 5 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(0.86f, new Vector2(-0.05f, 0.05f)), // Minor
+ new(0.10f, new Vector2(-0.3f, 0.2f)), // Moderate
+ new(0.03f, new Vector2(-0.5f, 1.5f)), // Major
+ new(0.01f, new Vector2(-0.9f, 4.0f)), // Catastrophic
+ ];
+}
+
+[DataRecord]
+public record struct MarketChange(float Chance, Vector2 Range);
diff --git a/Content.Server/_DV/Cargo/StocksCommands.cs b/Content.Server/_DV/Cargo/StocksCommands.cs
new file mode 100644
index 00000000000000..59693dd0316166
--- /dev/null
+++ b/Content.Server/_DV/Cargo/StocksCommands.cs
@@ -0,0 +1,135 @@
+using Content.Server.Administration;
+using Content.Server._DV.Cargo.Components;
+using Content.Server._DV.Cargo.Systems;
+using Content.Shared.Administration;
+using Content.Shared.CartridgeLoader.Cartridges;
+using Robust.Shared.Console;
+
+namespace Content.Server._DV.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/_DV/Cargo/Systems/StockMarketSystem.cs b/Content.Server/_DV/Cargo/Systems/StockMarketSystem.cs
new file mode 100644
index 00000000000000..ccd539e3880222
--- /dev/null
+++ b/Content.Server/_DV/Cargo/Systems/StockMarketSystem.cs
@@ -0,0 +1,351 @@
+using Content.Server.Access.Systems;
+using Content.Server.Administration.Logs;
+using Content.Server.Cargo.Components;
+using Content.Server.Cargo.Systems;
+using Content.Server._DV.Cargo.Components;
+using Content.Server._DV.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 Content.Shared.Popups;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server._DV.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!;
+ [Dependency] private readonly SharedPopupSystem _popup = 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, user, loader);
+ _popup.PopupEntity(Loc.GetString("stock-trading-access-denied"), user, 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, user, loader);
+ if (!success)
+ {
+ _popup.PopupEntity(Loc.GetString("stock-trading-transaction-failed"), user, 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);
diff --git a/Content.Server/_DV/CartridgeLoader/Cartridges/StockTradingCartridgeComponent.cs b/Content.Server/_DV/CartridgeLoader/Cartridges/StockTradingCartridgeComponent.cs
new file mode 100644
index 00000000000000..d9b84aeee197ab
--- /dev/null
+++ b/Content.Server/_DV/CartridgeLoader/Cartridges/StockTradingCartridgeComponent.cs
@@ -0,0 +1,11 @@
+namespace Content.Server._DV.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/_DV/CartridgeLoader/Cartridges/StockTradingCartridgeSystem.cs b/Content.Server/_DV/CartridgeLoader/Cartridges/StockTradingCartridgeSystem.cs
new file mode 100644
index 00000000000000..e8677ea01b788c
--- /dev/null
+++ b/Content.Server/_DV/CartridgeLoader/Cartridges/StockTradingCartridgeSystem.cs
@@ -0,0 +1,93 @@
+using System.Linq;
+using Content.Server.Cargo.Components;
+using Content.Server._DV.Cargo.Components;
+using Content.Server._DV.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._DV.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(ref 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;
+
+ // Send the UI state with balance and owned stocks
+ var state = new StockTradingUiState(
+ entries: stockMarket.Companies,
+ ownedStocks: stockMarket.StockOwnership,
+ balance: bankAccount.Balance
+ );
+
+ _cartridgeLoader.UpdateCartridgeUiState(loader, state);
+ }
+}
diff --git a/Content.Shared/_DV/CartridgeLoader/Cartridges/StockTradingUiMessageEvent.cs b/Content.Shared/_DV/CartridgeLoader/Cartridges/StockTradingUiMessageEvent.cs
new file mode 100644
index 00000000000000..5981f03b28ea59
--- /dev/null
+++ b/Content.Shared/_DV/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, int amount)
+ : CartridgeMessageEvent
+{
+ public readonly StockTradingUiAction Action = action;
+ public readonly int CompanyIndex = companyIndex;
+ public readonly int Amount = amount;
+}
+
+[Serializable, NetSerializable]
+public enum StockTradingUiAction
+{
+ Buy,
+ Sell,
+}
diff --git a/Content.Shared/_DV/CartridgeLoader/Cartridges/StockTradingUiState.cs b/Content.Shared/_DV/CartridgeLoader/Cartridges/StockTradingUiState.cs
new file mode 100644
index 00000000000000..42adb6feaa8d0f
--- /dev/null
+++ b/Content.Shared/_DV/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 StockCompany
+{
+ ///
+ /// 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 StockCompany(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/_DV/cargo/stocks-comapnies.ftl b/Resources/Locale/en-US/_DV/cargo/stocks-comapnies.ftl
new file mode 100644
index 00000000000000..8afe9120ffd3be
--- /dev/null
+++ b/Resources/Locale/en-US/_DV/cargo/stocks-comapnies.ftl
@@ -0,0 +1,10 @@
+# Company names used for stocks trading
+stock-trading-company-nanotrasen = Nanotrasen [NT]
+stock-trading-company-gorlex = Gorlex Marauders [GRX]
+stock-trading-company-interdyne = Interdyne Pharmaceutics [INTP]
+stock-trading-company-donk = Donk Co. [DONK]
+# Imp specials
+stock-trading-company-cybersun = CyberSun Industries [CSI]
+stock-trading-company-rtvs = Radio TV Solutions [RTVS]
+stock-trading-company-scargo = S-Cargo [SCG]
+stock-trading-company-dahir = Dahir Insaat [DIS]
\ No newline at end of file
diff --git a/Resources/Locale/en-US/_DV/cargo/stocks-commands.ftl b/Resources/Locale/en-US/_DV/cargo/stocks-commands.ftl
new file mode 100644
index 00000000000000..8e0fe014999e5f
--- /dev/null
+++ b/Resources/Locale/en-US/_DV/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/_DV/cartridge-loader/cartridges.ftl b/Resources/Locale/en-US/_DV/cartridge-loader/cartridges.ftl
index 9db691d763b805..7095aecb12be81 100644
--- a/Resources/Locale/en-US/_DV/cartridge-loader/cartridges.ftl
+++ b/Resources/Locale/en-US/_DV/cartridge-loader/cartridges.ftl
@@ -36,3 +36,18 @@ log-probe-card-number = Card: {$number}
log-probe-recipients = {$count} Recipients
log-probe-recipient-list = Known Recipients:
log-probe-message-format = {$sender} → {$recipient}: {$content}
+
+## 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
+stock-trading-access-denied = Access denied
+stock-trading-transaction-failed = Transaction failed
\ No newline at end of file
diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml b/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml
index f5076ba5f3abeb..8ea1d8e73a8e24 100644
--- a/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml
+++ b/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml
@@ -20,6 +20,7 @@
- id: RubberStampDenied
- id: RubberStampQm
- id: AstroNavCartridge
+ - id: StockTradingCartridge # Delta-V
- type: entity
id: LockerQuarterMasterFilled
diff --git a/Resources/Prototypes/Entities/Objects/Devices/pda.yml b/Resources/Prototypes/Entities/Objects/Devices/pda.yml
index b75c84b47bf612..bc92cf71743c5f 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/pda.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/pda.yml
@@ -440,6 +440,13 @@
accentVColor: "#a23e3e"
- type: Icon
state: pda-qm
+ - type: CartridgeLoader # DeltaV
+ preinstalled:
+ - CrewManifestCartridge
+ - NotekeeperCartridge
+ - NewsReaderCartridge
+ - StockTradingCartridge # DeltaV
+ - NanoChatCartridge # DeltaV
- type: entity
parent: BasePDA
@@ -458,6 +465,13 @@
borderColor: "#e39751"
- type: Icon
state: pda-cargo
+ - type: CartridgeLoader # DeltaV
+ preinstalled:
+ - CrewManifestCartridge
+ - NotekeeperCartridge
+ - NewsReaderCartridge
+ - StockTradingCartridge # DeltaV
+ - NanoChatCartridge # DeltaV
- type: entity
parent: BasePDA
@@ -906,6 +920,7 @@
- LogProbeCartridge
- AstroNavCartridge
- NanoChatCartridge # DV
+ - StockTradingCartridge
- type: entity
parent: CentcomPDA
@@ -931,6 +946,7 @@
- MedTekCartridge
- AstroNavCartridge
- NanoChatCartridge # DV
+ - StockTradingCartridge
- type: Tag # Ignore Chameleon tags
tags:
- DoorBumpOpener
@@ -1187,6 +1203,13 @@
borderColor: "#3f3f74"
- type: Icon
state: pda-reporter
+ - type: CartridgeLoader # DeltaV
+ preinstalled:
+ - CrewManifestCartridge
+ - NotekeeperCartridge
+ - NewsReaderCartridge
+ - StockTradingCartridge # DeltaV
+ - NanoChatCartridge # DeltaV
- type: entity
parent: BasePDA
diff --git a/Resources/Prototypes/Entities/Stations/nanotrasen.yml b/Resources/Prototypes/Entities/Stations/nanotrasen.yml
index 613bb1b1f4f165..7f64715f3ce384 100644
--- a/Resources/Prototypes/Entities/Stations/nanotrasen.yml
+++ b/Resources/Prototypes/Entities/Stations/nanotrasen.yml
@@ -25,6 +25,7 @@
- BaseStationSiliconLawCrewsimov
- BaseStationAllEventsEligible
- BaseStationNanotrasen
+ - BaseStationStockMarket # DeltaV
categories: [ HideSpawnMenu ]
components:
- type: Transform
diff --git a/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml b/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
index eb9dc667db3ac1..7050de11fa40ef 100644
--- a/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
+++ b/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
@@ -18,3 +18,27 @@
- type: ActiveRadio
channels:
- Common
+
+- type: entity
+ parent: BaseItem
+ id: StockTradingCartridge
+ name: StockTrading cartridge
+ description: A cartridge that tracks the intergalactic stock market.
+ components:
+ - type: Sprite
+ sprite: _DV/Objects/Devices/cartridge.rsi
+ state: cart-stonk
+ - type: Icon
+ sprite: _DV/Objects/Devices/cartridge.rsi
+ state: cart-mail
+ - type: UIFragment
+ ui: !type:StockTradingUi
+ - type: StockTradingCartridge
+ - type: Cartridge
+ programName: stock-trading-program-name
+ icon:
+ sprite: _DV/Misc/program_icons.rsi
+ state: stock_trading
+ - type: BankClient
+ - type: AccessReader # This is so that we can restrict who can buy stocks
+ access: [["Cargo"]]
\ No newline at end of file
diff --git a/Resources/Prototypes/_DV/Entities/Stations/base.yml b/Resources/Prototypes/_DV/Entities/Stations/base.yml
new file mode 100644
index 00000000000000..74d29deb6845af
--- /dev/null
+++ b/Resources/Prototypes/_DV/Entities/Stations/base.yml
@@ -0,0 +1,30 @@
+- 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-donk
+ basePrice: 90
+ currentPrice: 90
+ - displayName: stock-trading-company-cybersun
+ basePrice: 175
+ currentPrice: 175
+ - displayName: stock-trading-company-rtvs
+ basePrice: 200
+ currentPrice: 200
+ - displayName: stock-trading-company-scargo
+ basePrice: 150
+ currentPrice: 150
+ - displayName: stock-trading-company-dahir
+ basePrice: 50
+ currentPrice: 50
\ No newline at end of file
diff --git a/Resources/Prototypes/_Impstation/Entities/Objects/Devices/pda.yml b/Resources/Prototypes/_Impstation/Entities/Objects/Devices/pda.yml
index 0790436f201f04..62a77083f5aec3 100644
--- a/Resources/Prototypes/_Impstation/Entities/Objects/Devices/pda.yml
+++ b/Resources/Prototypes/_Impstation/Entities/Objects/Devices/pda.yml
@@ -55,6 +55,14 @@
accentVColor: "#FF9500"
- type: Icon
state: pda-seniorcargo
+ - type: CartridgeLoader
+ uiKey: enum.PdaUiKey.Key
+ preinstalled:
+ - CrewManifestCartridge
+ - NotekeeperCartridge
+ - NewsReaderCartridge
+ - StockTradingCartridge # DeltaV
+ - NanoChatCartridge # DeltaV
- type: entity
parent: BasePDA
diff --git a/Resources/Textures/_DV/Misc/program_icons.rsi/meta.json b/Resources/Textures/_DV/Misc/program_icons.rsi/meta.json
index 935cb557bb13d8..1f5f472528d265 100644
--- a/Resources/Textures/_DV/Misc/program_icons.rsi/meta.json
+++ b/Resources/Textures/_DV/Misc/program_icons.rsi/meta.json
@@ -1,14 +1,17 @@
{
"version": 1,
"license": "CC0-1.0",
- "copyright": "nanochat made by kushbreth (discord)",
+ "copyright": "stock_trading made by Malice, nanochat made by kushbreth (discord)",
"size": {
"x": 32,
"y": 32
},
"states": [
+ {
+ "name": "stock_trading"
+ },
{
"name": "nanochat"
}
]
-}
+}
\ No newline at end of file
diff --git a/Resources/Textures/_DV/Misc/program_icons.rsi/stock_trading.png b/Resources/Textures/_DV/Misc/program_icons.rsi/stock_trading.png
new file mode 100644
index 0000000000000000000000000000000000000000..251b46a3f83cbe27088978ca4e58413d8ed9fe19
GIT binary patch
literal 1012
zcmVH=4?=*
zlOrL1eF_1!1=1Y=z}*FYhD8b_UXuVOkGCsu?;U-egrrF@97tCXgBWXbLdpjlyk-W+
z02rP=nq|6j1&8{V1Aab^>G;Kv4e;#9Ga%sWR_90>t5^cp0RUhM2scW%1qQjJblceyjNd_L=3x&b
zYoy>S5ATq_xm}6e%MmrVVI^9O004#tqF$75!Bxf?kRvyAzn=4(*I6FBW&A!n^ToKQ
zAG(fi8-RPY#eVnv(HTHVv>`QYo*T|yhm{*~M&|nAQsZri+b-GrQhVMBgog*@Mn}6i
z2xBmbPM5yZV8vhI>_Mdd!Yn~VmT6485^FFoUu`*nsjq5=9?(9JSx*g~`HVk!|
z=Spt<7C-!iUy&n>Z0*R;0V|EPbp)1zTh&y@
z%DQZ*+>LiZs%_p=$8P_*k=A5eP%7<{X%EQFB=t8v&<-B@L7dxKGo6+LI$7Oo|BPAp
zKnOrX<~iMJnRBx?rsMn^=miIW
z1+~G<18#If2EgE3(cfaSgzq+b%Z}0TF0000q!<_+7))m{Fa|R+rOk9uV_*niFzK9WFU!E-$zV2T
zrh~>z2gUW17#K_^+}OgPtgLKcU?3qe7#J@dcmhO>_
z%)r2R7=#&*=dVZs3RZc#IEGmGzrApit3g4;HSls@YUXyw|Np~{r?Bl(+PmG6C3ca9
z(^B~*b_^di^zSufJX!lJ>U^<4(AEo5$6ri&;=s?$lPvQhQlLMo-P2krmg!tl*%qCU
z9Z3vlEW{gMT~gK+{C~n-Ct~}ZQ%v>m+#lXw@ayW*x(TZq1j-CLJQeGAJyQm{f-g0~
dGtJkRK?}&{0Adih6g(M3dAj
Date: Sat, 11 Jan 2025 15:06:20 -0800
Subject: [PATCH 2/7] Remove StockTrading cartridge from reporter PDAs to avoid
confusion with access
---
Resources/Prototypes/Entities/Objects/Devices/pda.yml | 7 -------
1 file changed, 7 deletions(-)
diff --git a/Resources/Prototypes/Entities/Objects/Devices/pda.yml b/Resources/Prototypes/Entities/Objects/Devices/pda.yml
index bc92cf71743c5f..34a70b449b81ee 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/pda.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/pda.yml
@@ -1203,13 +1203,6 @@
borderColor: "#3f3f74"
- type: Icon
state: pda-reporter
- - type: CartridgeLoader # DeltaV
- preinstalled:
- - CrewManifestCartridge
- - NotekeeperCartridge
- - NewsReaderCartridge
- - StockTradingCartridge # DeltaV
- - NanoChatCartridge # DeltaV
- type: entity
parent: BasePDA
From bcfd711b4f6c970b9b4ff84151db1859bb48bbfc Mon Sep 17 00:00:00 2001
From: Epic Toast <44736041+EpicToastTM@users.noreply.github.com>
Date: Sat, 11 Jan 2025 15:21:20 -0800
Subject: [PATCH 3/7] Remove icon component to match the NanoChat cartridge
format
---
.../Prototypes/_DV/Entities/Objects/Devices/cartridges.yml | 3 ---
1 file changed, 3 deletions(-)
diff --git a/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml b/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
index 7050de11fa40ef..ae7e3f7fdd972e 100644
--- a/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
+++ b/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
@@ -28,9 +28,6 @@
- type: Sprite
sprite: _DV/Objects/Devices/cartridge.rsi
state: cart-stonk
- - type: Icon
- sprite: _DV/Objects/Devices/cartridge.rsi
- state: cart-mail
- type: UIFragment
ui: !type:StockTradingUi
- type: StockTradingCartridge
From 5189b0be40dfef98ada1bbda5a22150f35711e8c Mon Sep 17 00:00:00 2001
From: Epic Toast <44736041+EpicToastTM@users.noreply.github.com>
Date: Sat, 11 Jan 2025 15:22:58 -0800
Subject: [PATCH 4/7] Update DV attributions
---
Resources/Prototypes/Entities/Objects/Devices/pda.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Resources/Prototypes/Entities/Objects/Devices/pda.yml b/Resources/Prototypes/Entities/Objects/Devices/pda.yml
index 34a70b449b81ee..1f15f89ace8a2e 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/pda.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/pda.yml
@@ -920,7 +920,7 @@
- LogProbeCartridge
- AstroNavCartridge
- NanoChatCartridge # DV
- - StockTradingCartridge
+ - StockTradingCartridge # DV
- type: entity
parent: CentcomPDA
@@ -946,7 +946,7 @@
- MedTekCartridge
- AstroNavCartridge
- NanoChatCartridge # DV
- - StockTradingCartridge
+ - StockTradingCartridge # DV
- type: Tag # Ignore Chameleon tags
tags:
- DoorBumpOpener
From 4209e6b75f5ec62feecfe659e309738c67e44590 Mon Sep 17 00:00:00 2001
From: Epic Toast <44736041+EpicToastTM@users.noreply.github.com>
Date: Sat, 11 Jan 2025 15:43:56 -0800
Subject: [PATCH 5/7] Make the StockTrading cartridge a thief objective
---
.../objectives/conditions/steal-target-groups.ftl | 1 +
Resources/Prototypes/Objectives/objectiveGroups.yml | 1 +
.../_DV/Entities/Objects/Devices/cartridges.yml | 4 +++-
.../_Impstation/Objectives/stealTargetGroups.yml | 7 +++++++
Resources/Prototypes/_Impstation/Objectives/thief.yml | 9 +++++++++
5 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/Resources/Locale/en-US/_Impstation/objectives/conditions/steal-target-groups.ftl b/Resources/Locale/en-US/_Impstation/objectives/conditions/steal-target-groups.ftl
index d2a7bc985479c7..8a90d092a1820b 100644
--- a/Resources/Locale/en-US/_Impstation/objectives/conditions/steal-target-groups.ftl
+++ b/Resources/Locale/en-US/_Impstation/objectives/conditions/steal-target-groups.ftl
@@ -9,6 +9,7 @@ steal-target-groups-seedextractor = seed extractor
steal-target-groups-medtekcartridge = MedTek cartridge
steal-target-groups-logprobecartridge = LogProbe cartridge
steal-target-groups-astronavcartridge = AstroNav cartridge
+steal-target-groups-stocktradingcartridge = StockTrading cartridge
steal-target-groups-servicetechfab = service techfab machine board
steal-target-groups-shipyardcomputercircuitboard = shipyard computer board
diff --git a/Resources/Prototypes/Objectives/objectiveGroups.yml b/Resources/Prototypes/Objectives/objectiveGroups.yml
index 478858a1989d80..8de7d5b777b32c 100644
--- a/Resources/Prototypes/Objectives/objectiveGroups.yml
+++ b/Resources/Prototypes/Objectives/objectiveGroups.yml
@@ -99,6 +99,7 @@
MedTekCartridgeStealObjective: 1
LogProbeCartridgeStealObjective: 1
AstroNavCartridgeStealObjective: 1
+ StockTradingCartridgeStealObjective: 1
ServiceTechFabStealObjective: 1
ShiningSpringStealObjective: 1
ShipyardComputerCircuitboardStealObjective: 1
diff --git a/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml b/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
index ae7e3f7fdd972e..eb2d1c9df2875a 100644
--- a/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
+++ b/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
@@ -38,4 +38,6 @@
state: stock_trading
- type: BankClient
- type: AccessReader # This is so that we can restrict who can buy stocks
- access: [["Cargo"]]
\ No newline at end of file
+ access: [["Cargo"]]
+ - type: StealTarget
+ stealGroup: StockTradingCartridge
\ No newline at end of file
diff --git a/Resources/Prototypes/_Impstation/Objectives/stealTargetGroups.yml b/Resources/Prototypes/_Impstation/Objectives/stealTargetGroups.yml
index 593cfc8d630886..15417bdd4679b9 100644
--- a/Resources/Prototypes/_Impstation/Objectives/stealTargetGroups.yml
+++ b/Resources/Prototypes/_Impstation/Objectives/stealTargetGroups.yml
@@ -277,3 +277,10 @@
sprite:
sprite: Mobs/Species/Skeleton/parts.rsi
state: skull_icon
+
+- type: stealTargetGroup
+ id: StockTradingCartridge
+ name: steal-target-groups-stocktradingcartridge
+ sprite:
+ sprite: _DV/Objects/Devices/cartridge.rsi
+ state: cart-stonk
\ No newline at end of file
diff --git a/Resources/Prototypes/_Impstation/Objectives/thief.yml b/Resources/Prototypes/_Impstation/Objectives/thief.yml
index d11216de15ae44..57e396a6e01fa9 100644
--- a/Resources/Prototypes/_Impstation/Objectives/thief.yml
+++ b/Resources/Prototypes/_Impstation/Objectives/thief.yml
@@ -463,3 +463,12 @@
descriptionText: objective-condition-thief-evil-skull-description
- type: Objective
difficulty: 0.6 #a little harder than the bible, it glows red and there's only on, and it's an artifact
+
+- type: entity
+ parent: BaseThiefStealObjective
+ id: StockTradingCartridgeStealObjective
+ components:
+ - type: StealCondition
+ stealGroup: StockTradingCartridge
+ - type: Objective
+ difficulty: 1
\ No newline at end of file
From 7cf44aa1972e42cdaffc19368f1c1e5e299a34a6 Mon Sep 17 00:00:00 2001
From: Epic Toast <44736041+EpicToastTM@users.noreply.github.com>
Date: Sat, 11 Jan 2025 15:47:27 -0800
Subject: [PATCH 6/7] =?UTF-8?q?Change=20the=20speso=20symbol=20to=20=C2=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs | 2 +-
.../CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs b/Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs
index f04d01c297bdd1..f8216c03ee3585 100644
--- a/Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs
+++ b/Content.Client/_DV/CartridgeLoader/Cartridges/PriceHistoryTable.xaml.cs
@@ -50,7 +50,7 @@ public void Update(List priceHistory)
var priceLabel = new Label
{
- Text = $"${price:F2}",
+ Text = $"§{price:F2}",
HorizontalAlignment = HAlignment.Center,
};
diff --git a/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs b/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs
index 34f71058c2bdcd..c5e5ed53b05204 100644
--- a/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs
+++ b/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs
@@ -238,7 +238,7 @@ public CompanyEntry(int companyIndex,
public void Update(StockCompany company, int ownedStocks)
{
_nameLabel.Text = company.LocalizedDisplayName;
- _priceLabel.Text = $"${company.CurrentPrice:F2}";
+ _priceLabel.Text = $"§{company.CurrentPrice:F2}";
_sharesLabel.Text = Loc.GetString("stock-trading-owned-shares", ("shares", ownedStocks));
var priceChange = 0f;
From 3c1d5d8b9142e55f6b422f4f29ba4ecea3adbec3 Mon Sep 17 00:00:00 2001
From: Epic Toast <44736041+EpicToastTM@users.noreply.github.com>
Date: Tue, 14 Jan 2025 14:09:15 -0800
Subject: [PATCH 7/7] Update default value & contraband status
---
.../CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs | 1 +
.../Prototypes/_DV/Entities/Objects/Devices/cartridges.yml | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs b/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs
index c5e5ed53b05204..2d88bc6b0ead0d 100644
--- a/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs
+++ b/Content.Client/_DV/CartridgeLoader/Cartridges/StockTradingUiFragment.xaml.cs
@@ -168,6 +168,7 @@ public CompanyEntry(int companyIndex,
_amountEdit = new LineEdit
{
+ Text = "1",
PlaceHolder = Loc.GetString("stock-trading-amount-placeholder"),
HorizontalExpand = true,
MinWidth = 80,
diff --git a/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml b/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
index eb2d1c9df2875a..62d31c8d862c05 100644
--- a/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
+++ b/Resources/Prototypes/_DV/Entities/Objects/Devices/cartridges.yml
@@ -20,7 +20,7 @@
- Common
- type: entity
- parent: BaseItem
+ parent: [BaseItem, BaseCargoContraband]
id: StockTradingCartridge
name: StockTrading cartridge
description: A cartridge that tracks the intergalactic stock market.