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"
}
]
}