]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
criminal records revival (#22510)
authordeltanedas <39013340+deltanedas@users.noreply.github.com>
Sun, 4 Feb 2024 23:29:35 +0000 (23:29 +0000)
committerGitHub <noreply@github.com>
Sun, 4 Feb 2024 23:29:35 +0000 (19:29 -0400)
34 files changed:
Content.Client/CriminalRecords/CrimeHistoryWindow.xaml [new file with mode: 0644]
Content.Client/CriminalRecords/CrimeHistoryWindow.xaml.cs [new file with mode: 0644]
Content.Client/CriminalRecords/CriminalRecordsConsoleBoundUserInterface.cs [new file with mode: 0644]
Content.Client/CriminalRecords/CriminalRecordsConsoleWindow.xaml [new file with mode: 0644]
Content.Client/CriminalRecords/CriminalRecordsConsoleWindow.xaml.cs [new file with mode: 0644]
Content.Client/StationRecords/GeneralStationRecordConsoleBoundUserInterface.cs
Content.Client/StationRecords/GeneralStationRecordConsoleWindow.xaml.cs
Content.Server/Access/Systems/IdCardConsoleSystem.cs
Content.Server/Administration/Systems/AdminSystem.cs
Content.Server/CriminalRecords/Components/CriminalRecordsConsoleComponent.cs [new file with mode: 0644]
Content.Server/CriminalRecords/Systems/CriminalRecordsConsoleSystem.cs [new file with mode: 0644]
Content.Server/CriminalRecords/Systems/CriminalRecordsSystem.cs [new file with mode: 0644]
Content.Server/Mind/Commands/RenameCommand.cs
Content.Server/StationEvents/Events/ClericalErrorRule.cs
Content.Server/StationRecords/Components/GeneralStationRecordConsoleComponent.cs
Content.Server/StationRecords/StationRecordSet.cs
Content.Server/StationRecords/Systems/GeneralStationRecordConsoleSystem.cs
Content.Server/StationRecords/Systems/StationRecordsSystem.cs
Content.Shared/Access/Components/AccessReaderComponent.cs
Content.Shared/CriminalRecords/CriminalRecord.cs [new file with mode: 0644]
Content.Shared/CriminalRecords/CriminalRecordsUi.cs [new file with mode: 0644]
Content.Shared/Security/SecurityStatus.cs [new file with mode: 0644]
Content.Shared/StationRecords/GeneralRecordsUi.cs [moved from Content.Shared/StationRecords/SharedGeneralStationRecordConsoleSystem.cs with 65% similarity]
Content.Shared/StationRecords/GeneralStationRecord.cs
Content.Shared/StationRecords/GeneralStationRecordsFilter.cs [deleted file]
Content.Shared/StationRecords/StationRecordKey.cs
Content.Shared/StationRecords/StationRecordsFilter.cs [new file with mode: 0644]
Resources/Locale/en-US/criminal-records/criminal-records.ftl [new file with mode: 0644]
Resources/Locale/en-US/guidebook/guides.ftl
Resources/Locale/en-US/station-records/general-station-records.ftl
Resources/Prototypes/Datasets/criminal_records.yml [new file with mode: 0644]
Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml
Resources/Prototypes/Guidebook/security.yml
Resources/ServerInfo/Guidebook/Security/CriminalRecords.xml [new file with mode: 0644]

diff --git a/Content.Client/CriminalRecords/CrimeHistoryWindow.xaml b/Content.Client/CriminalRecords/CrimeHistoryWindow.xaml
new file mode 100644 (file)
index 0000000..358fade
--- /dev/null
@@ -0,0 +1,15 @@
+<controls:FancyWindow xmlns="https://spacestation14.io"
+               xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+               Title="{Loc 'criminal-records-console-crime-history'}"
+               MinSize="660 400">
+    <BoxContainer Orientation="Vertical" HorizontalExpand="True" Margin="5">
+        <BoxContainer Name="Editing" Orientation="Horizontal" HorizontalExpand="True" Align="Center" Margin="5">
+            <Button Name="AddButton" Text="{Loc 'criminal-records-add-history'}"/>
+            <Button Name="DeleteButton" Text="{Loc 'criminal-records-delete-history'}" Disabled="True"/>
+        </BoxContainer>
+        <Label Name="NoHistory" Text="{Loc 'criminal-records-no-history'}" HorizontalExpand="True" HorizontalAlignment="Center"/>
+        <ScrollContainer VerticalExpand="True">
+            <ItemList Name="History"/> <!-- Populated when window opened -->
+        </ScrollContainer>
+    </BoxContainer>
+</controls:FancyWindow>
diff --git a/Content.Client/CriminalRecords/CrimeHistoryWindow.xaml.cs b/Content.Client/CriminalRecords/CrimeHistoryWindow.xaml.cs
new file mode 100644 (file)
index 0000000..bccf501
--- /dev/null
@@ -0,0 +1,105 @@
+using Content.Shared.Administration;
+using Content.Shared.CriminalRecords;
+using Content.Client.UserInterface.Controls;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.CriminalRecords;
+
+/// <summary>
+/// Window opened when Crime History button is pressed
+/// </summary>
+[GenerateTypedNameReferences]
+public sealed partial class CrimeHistoryWindow : FancyWindow
+{
+    public Action<string>? OnAddHistory;
+    public Action<uint>? OnDeleteHistory;
+
+    private uint? _index;
+    private DialogWindow? _dialog;
+
+    public CrimeHistoryWindow()
+    {
+        RobustXamlLoader.Load(this);
+
+        OnClose += () =>
+        {
+            _dialog?.Close();
+            // deselect so when reopening the window it doesnt try to use invalid index
+            _index = null;
+        };
+
+        AddButton.OnPressed += _ =>
+        {
+            if (_dialog != null)
+            {
+                _dialog.MoveToFront();
+                return;
+            }
+
+            var field = "line";
+            var prompt = Loc.GetString("criminal-records-console-reason");
+            var placeholder = Loc.GetString("criminal-records-history-placeholder");
+            var entry = new QuickDialogEntry(field, QuickDialogEntryType.LongText, prompt, placeholder);
+            var entries = new List<QuickDialogEntry> { entry };
+            _dialog = new DialogWindow(Title!, entries);
+
+            _dialog.OnConfirmed += responses =>
+            {
+                var line = responses[field];
+                // TODO: whenever the console is moved to shared unhardcode this
+                if (line.Length < 1 || line.Length > 256)
+                    return;
+
+                OnAddHistory?.Invoke(line);
+                // adding deselects so prevent deleting yeah
+                _index = null;
+                DeleteButton.Disabled = true;
+            };
+
+            // prevent MoveToFront being called on a closed window and double closing
+            _dialog.OnClose += () => { _dialog = null; };
+        };
+        DeleteButton.OnPressed += _ =>
+        {
+            if (_index is not {} index)
+                return;
+
+            OnDeleteHistory?.Invoke(index);
+            // prevent total spam wiping
+            History.ClearSelected();
+            _index = null;
+            DeleteButton.Disabled = true;
+        };
+
+        History.OnItemSelected += args =>
+        {
+            _index = (uint) args.ItemIndex;
+            DeleteButton.Disabled = false;
+        };
+        History.OnItemDeselected += args =>
+        {
+            _index = null;
+            DeleteButton.Disabled = true;
+        };
+    }
+
+    public void UpdateHistory(CriminalRecord record, bool access)
+    {
+        History.Clear();
+        Editing.Visible = access;
+
+        NoHistory.Visible = record.History.Count == 0;
+
+        foreach (var entry in record.History)
+        {
+            var time = entry.AddTime;
+            var line = $"{time.Hours:00}:{time.Minutes:00}:{time.Seconds:00} - {entry.Crime}";
+            History.AddItem(line);
+        }
+
+        // deselect if something goes wrong
+        if (_index is {} index && record.History.Count >= index)
+            _index = null;
+    }
+}
diff --git a/Content.Client/CriminalRecords/CriminalRecordsConsoleBoundUserInterface.cs b/Content.Client/CriminalRecords/CriminalRecordsConsoleBoundUserInterface.cs
new file mode 100644 (file)
index 0000000..f6c9080
--- /dev/null
@@ -0,0 +1,78 @@
+using Content.Shared.Access.Systems;
+using Content.Shared.CriminalRecords;
+using Content.Shared.Security;
+using Content.Shared.StationRecords;
+using Robust.Client.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Client.CriminalRecords;
+
+public sealed class CriminalRecordsConsoleBoundUserInterface : BoundUserInterface
+{
+    [Dependency] private readonly IPrototypeManager _proto = default!;
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly IPlayerManager _playerManager = default!;
+    private readonly AccessReaderSystem _accessReader;
+
+    private CriminalRecordsConsoleWindow? _window;
+    private CrimeHistoryWindow? _historyWindow;
+
+    public CriminalRecordsConsoleBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+    {
+        _accessReader = EntMan.System<AccessReaderSystem>();
+    }
+
+    protected override void Open()
+    {
+        base.Open();
+
+        _window = new(Owner, _playerManager, _proto, _random, _accessReader);
+        _window.OnKeySelected += key =>
+            SendMessage(new SelectStationRecord(key));
+        _window.OnFiltersChanged += (type, filterValue) =>
+            SendMessage(new SetStationRecordFilter(type, filterValue));
+        _window.OnStatusSelected += status =>
+            SendMessage(new CriminalRecordChangeStatus(status, null));
+        _window.OnDialogConfirmed += (_, reason) =>
+            SendMessage(new CriminalRecordChangeStatus(SecurityStatus.Wanted, reason));
+        _window.OnHistoryUpdated += UpdateHistory;
+        _window.OnHistoryClosed += () => _historyWindow?.Close();
+        _window.OnClose += Close;
+
+        _historyWindow = new();
+        _historyWindow.OnAddHistory += line => SendMessage(new CriminalRecordAddHistory(line));
+        _historyWindow.OnDeleteHistory += index => SendMessage(new CriminalRecordDeleteHistory(index));
+
+        _historyWindow.Close(); // leave closed until user opens it
+    }
+
+    /// <summary>
+    /// Updates or opens a new history window.
+    /// </summary>
+    private void UpdateHistory(CriminalRecord record, bool access, bool open)
+    {
+        _historyWindow!.UpdateHistory(record, access);
+
+        if (open)
+            _historyWindow.OpenCentered();
+    }
+
+    protected override void UpdateState(BoundUserInterfaceState state)
+    {
+        base.UpdateState(state);
+
+        if (state is not CriminalRecordsConsoleState cast)
+            return;
+
+        _window?.UpdateState(cast);
+    }
+
+    protected override void Dispose(bool disposing)
+    {
+        base.Dispose(disposing);
+
+        _window?.Close();
+        _historyWindow?.Close();
+    }
+}
diff --git a/Content.Client/CriminalRecords/CriminalRecordsConsoleWindow.xaml b/Content.Client/CriminalRecords/CriminalRecordsConsoleWindow.xaml
new file mode 100644 (file)
index 0000000..77da0ba
--- /dev/null
@@ -0,0 +1,37 @@
+<controls:FancyWindow xmlns="https://spacestation14.io"
+               xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+               Title="{Loc 'criminal-records-console-window-title'}"
+               MinSize="660 400">
+    <BoxContainer Orientation="Vertical">
+        <!-- Record search bar
+             TODO: make this into a control shared with general records -->
+        <BoxContainer Margin="5 5 5 10" HorizontalExpand="true" VerticalAlignment="Center">
+            <OptionButton Name="FilterType" MinWidth="200" Margin="0 0 10 0"/> <!-- Populated in constructor -->
+            <LineEdit Name="FilterText" PlaceHolder="{Loc 'criminal-records-filter-placeholder'}" HorizontalExpand="True"/>
+        </BoxContainer>
+        <BoxContainer Orientation="Horizontal" VerticalExpand="True">
+            <!-- Record listing -->
+            <BoxContainer Orientation="Vertical" Margin="5" MinWidth="250" MaxWidth="250">
+                <Label Name="RecordListingTitle" Text="{Loc 'criminal-records-console-records-list-title'}" HorizontalExpand="True" Align="Center"/>
+                <Label Name="NoRecords" Text="{Loc 'criminal-records-console-no-records'}" HorizontalExpand="True" Align="Center" FontColorOverride="DarkGray"/>
+                <ScrollContainer VerticalExpand="True">
+                    <ItemList Name="RecordListing"/> <!-- Populated when loading state -->
+                </ScrollContainer>
+            </BoxContainer>
+            <Label Name="RecordUnselected" Text="{Loc 'criminal-records-console-select-record-info'}" HorizontalExpand="True" Align="Center" FontColorOverride="DarkGray"/>
+            <!-- Selected record info -->
+            <BoxContainer Name="PersonContainer" Orientation="Vertical" Margin="5" Visible="False">
+                <Label Name="PersonName" StyleClasses="LabelBig"/>
+                <Label Name="PersonPrints"/>
+                <Label Name="PersonDna"/>
+                <PanelContainer StyleClasses="LowDivider" Margin="0 5 0 5" />
+                <BoxContainer Orientation="Horizontal" Margin="5 5 5 5">
+                    <Label Name="StatusLabel" Text="{Loc 'criminal-records-console-status'}" FontColorOverride="DarkGray"/>
+                    <OptionButton Name="StatusOptionButton"/> <!-- Populated in constructor -->
+                </BoxContainer>
+                <RichTextLabel Name="WantedReason" Visible="False"/>
+                <Button Name="HistoryButton" Text="{Loc 'criminal-records-console-crime-history'}"/>
+            </BoxContainer>
+        </BoxContainer>
+    </BoxContainer>
+</controls:FancyWindow>
diff --git a/Content.Client/CriminalRecords/CriminalRecordsConsoleWindow.xaml.cs b/Content.Client/CriminalRecords/CriminalRecordsConsoleWindow.xaml.cs
new file mode 100644 (file)
index 0000000..f5c631e
--- /dev/null
@@ -0,0 +1,263 @@
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Access.Systems;
+using Content.Shared.Administration;
+using Content.Shared.CriminalRecords;
+using Content.Shared.Dataset;
+using Content.Shared.Security;
+using Content.Shared.StationRecords;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Player;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using Robust.Shared.Utility;
+
+namespace Content.Client.CriminalRecords;
+
+// TODO: dedupe shitcode from general records theres a lot
+[GenerateTypedNameReferences]
+public sealed partial class CriminalRecordsConsoleWindow : FancyWindow
+{
+    private readonly IPlayerManager _player;
+    private readonly IPrototypeManager _proto;
+    private readonly IRobustRandom _random;
+    private readonly AccessReaderSystem _accessReader;
+
+    public readonly EntityUid Console;
+
+    [ValidatePrototypeId<DatasetPrototype>]
+    private const string ReasonPlaceholders = "CriminalRecordsWantedReasonPlaceholders";
+
+    public Action<uint?>? OnKeySelected;
+    public Action<StationRecordFilterType, string>? OnFiltersChanged;
+    public Action<SecurityStatus>? OnStatusSelected;
+    public Action<CriminalRecord, bool, bool>? OnHistoryUpdated;
+    public Action? OnHistoryClosed;
+    public Action<SecurityStatus, string>? OnDialogConfirmed;
+
+    private bool _isPopulating;
+    private bool _access;
+    private uint? _selectedKey;
+    private CriminalRecord? _selectedRecord;
+
+    private DialogWindow? _reasonDialog;
+
+    private StationRecordFilterType _currentFilterType;
+
+    public CriminalRecordsConsoleWindow(EntityUid console, IPlayerManager playerManager, IPrototypeManager prototypeManager, IRobustRandom robustRandom, AccessReaderSystem accessReader)
+    {
+        RobustXamlLoader.Load(this);
+
+        Console = console;
+        _player = playerManager;
+        _proto = prototypeManager;
+        _random = robustRandom;
+        _accessReader = accessReader;
+
+        _currentFilterType = StationRecordFilterType.Name;
+
+        OpenCentered();
+
+        foreach (var item in Enum.GetValues<StationRecordFilterType>())
+        {
+            FilterType.AddItem(GetTypeFilterLocals(item), (int)item);
+        }
+
+        foreach (var status in Enum.GetValues<SecurityStatus>())
+        {
+            AddStatusSelect(status);
+        }
+
+        OnClose += () => _reasonDialog?.Close();
+
+        RecordListing.OnItemSelected += args =>
+        {
+            if (_isPopulating || RecordListing[args.ItemIndex].Metadata is not uint cast)
+                return;
+
+            OnKeySelected?.Invoke(cast);
+        };
+
+        RecordListing.OnItemDeselected += _ =>
+        {
+            if (!_isPopulating)
+                OnKeySelected?.Invoke(null);
+        };
+
+        FilterType.OnItemSelected += eventArgs =>
+        {
+            var type = (StationRecordFilterType)eventArgs.Id;
+
+            if (_currentFilterType != type)
+            {
+                _currentFilterType = type;
+                FilterListingOfRecords(FilterText.Text);
+            }
+        };
+
+        FilterText.OnTextEntered += args =>
+        {
+            FilterListingOfRecords(args.Text);
+        };
+
+        StatusOptionButton.OnItemSelected += args =>
+        {
+            SetStatus((SecurityStatus) args.Id);
+        };
+
+        HistoryButton.OnPressed += _ =>
+        {
+            if (_selectedRecord is {} record)
+                OnHistoryUpdated?.Invoke(record, _access, true);
+        };
+    }
+
+    public void UpdateState(CriminalRecordsConsoleState state)
+    {
+        if (state.Filter != null)
+        {
+            if (state.Filter.Type != _currentFilterType)
+            {
+                _currentFilterType = state.Filter.Type;
+            }
+
+            if (state.Filter.Value != FilterText.Text)
+            {
+                FilterText.Text = state.Filter.Value;
+            }
+        }
+
+        _selectedKey = state.SelectedKey;
+
+        FilterType.SelectId((int)_currentFilterType);
+
+        // set up the records listing panel
+        RecordListing.Clear();
+
+        var hasRecords = state.RecordListing != null && state.RecordListing.Count > 0;
+        NoRecords.Visible = !hasRecords;
+        if (hasRecords)
+            PopulateRecordListing(state.RecordListing!);
+
+        // set up the selected person's record
+        var selected = _selectedKey != null;
+
+        PersonContainer.Visible = selected;
+        RecordUnselected.Visible = !selected;
+
+        _access = _player.LocalSession?.AttachedEntity is {} player
+            && _accessReader.IsAllowed(player, Console);
+
+        // hide access-required editing parts when no access
+        var editing = _access && selected;
+        StatusOptionButton.Disabled = !editing;
+
+        if (state is { CriminalRecord: not null, StationRecord: not null })
+        {
+            PopulateRecordContainer(state.StationRecord, state.CriminalRecord);
+            OnHistoryUpdated?.Invoke(state.CriminalRecord, _access, false);
+            _selectedRecord = state.CriminalRecord;
+        }
+        else
+        {
+            _selectedRecord = null;
+            OnHistoryClosed?.Invoke();
+        }
+    }
+
+    private void PopulateRecordListing(Dictionary<uint, string> listing)
+    {
+        _isPopulating = true;
+
+        foreach (var (key, name) in listing)
+        {
+            var item = RecordListing.AddItem(name);
+            item.Metadata = key;
+            item.Selected = key == _selectedKey;
+        }
+        _isPopulating = false;
+
+        RecordListing.SortItemsByText();
+    }
+
+    private void PopulateRecordContainer(GeneralStationRecord stationRecord, CriminalRecord criminalRecord)
+    {
+        var na = Loc.GetString("generic-not-available-shorthand");
+        PersonName.Text = stationRecord.Name;
+        PersonPrints.Text = Loc.GetString("general-station-record-console-record-fingerprint", ("fingerprint", stationRecord.Fingerprint ?? na));
+        PersonDna.Text = Loc.GetString("general-station-record-console-record-dna", ("dna", stationRecord.DNA ?? na));
+
+        StatusOptionButton.SelectId((int) criminalRecord.Status);
+        if (criminalRecord.Reason is {} reason)
+        {
+            var message = FormattedMessage.FromMarkup(Loc.GetString("criminal-records-console-wanted-reason"));
+            message.AddText($": {reason}");
+            WantedReason.SetMessage(message);
+            WantedReason.Visible = true;
+        }
+        else
+        {
+            WantedReason.Visible = false;
+        }
+    }
+
+    private void AddStatusSelect(SecurityStatus status)
+    {
+        var name = Loc.GetString($"criminal-records-status-{status.ToString().ToLower()}");
+        StatusOptionButton.AddItem(name, (int)status);
+    }
+
+    private void FilterListingOfRecords(string text = "")
+    {
+        if (!_isPopulating)
+        {
+            OnFiltersChanged?.Invoke(_currentFilterType, text);
+        }
+    }
+
+    private void SetStatus(SecurityStatus status)
+    {
+        if (status == SecurityStatus.Wanted)
+        {
+            GetWantedReason();
+            return;
+        }
+
+        OnStatusSelected?.Invoke(status);
+    }
+
+    private void GetWantedReason()
+    {
+        if (_reasonDialog != null)
+        {
+            _reasonDialog.MoveToFront();
+            return;
+        }
+
+        var field = "reason";
+        var title = Loc.GetString("criminal-records-status-wanted");
+        var placeholders = _proto.Index<DatasetPrototype>(ReasonPlaceholders);
+        var placeholder = Loc.GetString("criminal-records-console-reason-placeholder", ("placeholder", _random.Pick(placeholders.Values))); // just funny it doesn't actually get used
+        var prompt = Loc.GetString("criminal-records-console-reason");
+        var entry = new QuickDialogEntry(field, QuickDialogEntryType.LongText, prompt, placeholder);
+        var entries = new List<QuickDialogEntry>() { entry };
+        _reasonDialog = new DialogWindow(title, entries);
+
+        _reasonDialog.OnConfirmed += responses =>
+        {
+            var reason = responses[field];
+            // TODO: same as history unhardcode
+            if (reason.Length < 1 || reason.Length > 256)
+                return;
+
+            OnDialogConfirmed?.Invoke(SecurityStatus.Wanted, reason);
+        };
+
+        _reasonDialog.OnClose += () => { _reasonDialog = null; };
+    }
+
+    private string GetTypeFilterLocals(StationRecordFilterType type)
+    {
+        return Loc.GetString($"criminal-records-{type.ToString().ToLower()}-filter");
+    }
+}
index 841ea7e79e16fc6199881b3c43ff320f2f6e200a..3be3d98778db829fe94dd10e06f3236e80d51ecb 100644 (file)
@@ -1,5 +1,4 @@
 using Content.Shared.StationRecords;
-using Robust.Client.GameObjects;
 
 namespace Content.Client.StationRecords;
 
@@ -17,33 +16,21 @@ public sealed class GeneralStationRecordConsoleBoundUserInterface : BoundUserInt
         base.Open();
 
         _window = new();
-        _window.OnKeySelected += OnKeySelected;
-        _window.OnFiltersChanged += OnFiltersChanged;
+        _window.OnKeySelected += key =>
+            SendMessage(new SelectStationRecord(key));
+        _window.OnFiltersChanged += (type, filterValue) =>
+            SendMessage(new SetStationRecordFilter(type, filterValue));
         _window.OnClose += Close;
 
         _window.OpenCentered();
     }
 
-    private void OnKeySelected((NetEntity, uint)? key)
-    {
-        SendMessage(new SelectGeneralStationRecord(key));
-    }
-
-    private void OnFiltersChanged(
-        GeneralStationRecordFilterType type, string filterValue)
-    {
-        GeneralStationRecordsFilterMsg msg = new(type, filterValue);
-        SendMessage(msg);
-    }
-
     protected override void UpdateState(BoundUserInterfaceState state)
     {
         base.UpdateState(state);
 
         if (state is not GeneralStationRecordConsoleState cast)
-        {
             return;
-        }
 
         _window?.UpdateState(cast);
     }
index c71b115c7a9ddd2b785d0262bb6771a0c549618d..fbdd6c2f0b526c3903137f61b873056b35fbfd1f 100644 (file)
@@ -1,4 +1,3 @@
-using System.Linq;
 using Content.Shared.StationRecords;
 using Robust.Client.AutoGenerated;
 using Robust.Client.UserInterface;
@@ -11,31 +10,29 @@ namespace Content.Client.StationRecords;
 [GenerateTypedNameReferences]
 public sealed partial class GeneralStationRecordConsoleWindow : DefaultWindow
 {
-    public Action<(NetEntity, uint)?>? OnKeySelected;
+    public Action<uint?>? OnKeySelected;
 
-    public Action<GeneralStationRecordFilterType, string>? OnFiltersChanged;
+    public Action<StationRecordFilterType, string>? OnFiltersChanged;
 
     private bool _isPopulating;
 
-    private GeneralStationRecordFilterType _currentFilterType;
+    private StationRecordFilterType _currentFilterType;
 
     public GeneralStationRecordConsoleWindow()
     {
         RobustXamlLoader.Load(this);
 
-        _currentFilterType = GeneralStationRecordFilterType.Name;
+        _currentFilterType = StationRecordFilterType.Name;
 
-        foreach (var item in Enum.GetValues<GeneralStationRecordFilterType>())
+        foreach (var item in Enum.GetValues<StationRecordFilterType>())
         {
             StationRecordsFilterType.AddItem(GetTypeFilterLocals(item), (int)item);
         }
 
         RecordListing.OnItemSelected += args =>
         {
-            if (_isPopulating || RecordListing[args.ItemIndex].Metadata is not ValueTuple<NetEntity, uint> cast)
-            {
+            if (_isPopulating || RecordListing[args.ItemIndex].Metadata is not uint cast)
                 return;
-            }
 
             OnKeySelected?.Invoke(cast);
         };
@@ -48,7 +45,7 @@ public sealed partial class GeneralStationRecordConsoleWindow : DefaultWindow
 
         StationRecordsFilterType.OnItemSelected += eventArgs =>
         {
-            var type = (GeneralStationRecordFilterType)eventArgs.Id;
+            var type = (StationRecordFilterType) eventArgs.Id;
 
             if (_currentFilterType != type)
             {
@@ -123,7 +120,7 @@ public sealed partial class GeneralStationRecordConsoleWindow : DefaultWindow
             RecordContainer.RemoveAllChildren();
         }
     }
-    private void PopulateRecordListing(Dictionary<(NetEntity, uint), string> listing, (NetEntity, uint)? selected)
+    private void PopulateRecordListing(Dictionary<uint, string> listing, uint? selected)
     {
         RecordListing.Clear();
         RecordListing.ClearSelected();
@@ -134,10 +131,7 @@ public sealed partial class GeneralStationRecordConsoleWindow : DefaultWindow
         {
             var item = RecordListing.AddItem(name);
             item.Metadata = key;
-            if (selected != null && key.Item1 == selected.Value.Item1 && key.Item2 == selected.Value.Item2)
-            {
-                item.Selected = true;
-            }
+            item.Selected = key == selected;
         }
         _isPopulating = false;
 
@@ -197,7 +191,7 @@ public sealed partial class GeneralStationRecordConsoleWindow : DefaultWindow
         }
     }
 
-    private string GetTypeFilterLocals(GeneralStationRecordFilterType type)
+    private string GetTypeFilterLocals(StationRecordFilterType type)
     {
         return Loc.GetString($"general-station-record-{type.ToString().ToLower()}-filter");
     }
index 10bf65d0c6102b580bf423af7db81f8816a5c57e..b3b2baf28e78c984879d51fc9c4ad35c69d5c67f 100644 (file)
@@ -1,4 +1,3 @@
-using Content.Server.Station.Systems;
 using Content.Server.StationRecords.Systems;
 using Content.Shared.Access.Components;
 using Content.Shared.Access.Systems;
@@ -21,7 +20,6 @@ public sealed class IdCardConsoleSystem : SharedIdCardConsoleSystem
 {
     [Dependency] private readonly IPrototypeManager _prototype = default!;
     [Dependency] private readonly StationRecordsSystem _record = default!;
-    [Dependency] private readonly StationSystem _station = default!;
     [Dependency] private readonly UserInterfaceSystem _userInterface = default!;
     [Dependency] private readonly AccessReaderSystem _accessReader = default!;
     [Dependency] private readonly AccessSystem _access = default!;
@@ -85,10 +83,9 @@ public sealed class IdCardConsoleSystem : SharedIdCardConsoleSystem
             var targetAccessComponent = EntityManager.GetComponent<AccessComponent>(targetId);
 
             var jobProto = string.Empty;
-            if (_station.GetOwningStation(uid) is { } station
-                && EntityManager.TryGetComponent<StationRecordKeyStorageComponent>(targetId, out var keyStorage)
-                && keyStorage.Key != null
-                && _record.TryGetRecord<GeneralStationRecord>(station, keyStorage.Key.Value, out var record))
+            if (TryComp<StationRecordKeyStorageComponent>(targetId, out var keyStorage)
+                && keyStorage.Key is {} key
+                && _record.TryGetRecord<GeneralStationRecord>(key, out var record))
             {
                 jobProto = record.JobPrototype;
             }
@@ -103,7 +100,7 @@ public sealed class IdCardConsoleSystem : SharedIdCardConsoleSystem
                 possibleAccess,
                 jobProto,
                 privilegedIdName,
-                EntityManager.GetComponent<MetaDataComponent>(targetId).EntityName);
+                Name(targetId));
         }
 
         _userInterface.TrySetUiState(uid, IdCardConsoleUiKey.Key, newState);
@@ -184,7 +181,7 @@ public sealed class IdCardConsoleSystem : SharedIdCardConsoleSystem
         if (!Resolve(uid, ref component))
             return true;
 
-        if (!EntityManager.TryGetComponent<AccessReaderComponent>(uid, out var reader))
+        if (!TryComp<AccessReaderComponent>(uid, out var reader))
             return true;
 
         var privilegedId = component.PrivilegedIdSlot.Item;
@@ -193,10 +190,9 @@ public sealed class IdCardConsoleSystem : SharedIdCardConsoleSystem
 
     private void UpdateStationRecord(EntityUid uid, EntityUid targetId, string newFullName, string newJobTitle, JobPrototype? newJobProto)
     {
-        if (_station.GetOwningStation(uid) is not { } station
-            || !EntityManager.TryGetComponent<StationRecordKeyStorageComponent>(targetId, out var keyStorage)
+        if (!TryComp<StationRecordKeyStorageComponent>(targetId, out var keyStorage)
             || keyStorage.Key is not { } key
-            || !_record.TryGetRecord<GeneralStationRecord>(station, key, out var record))
+            || !_record.TryGetRecord<GeneralStationRecord>(key, out var record))
         {
             return;
         }
@@ -210,6 +206,6 @@ public sealed class IdCardConsoleSystem : SharedIdCardConsoleSystem
             record.JobIcon = newJobProto.Icon;
         }
 
-        _record.Synchronize(station);
+        _record.Synchronize(key);
     }
 }
index abaa99ece572d5eefc5e2d4c4ee4fda20c838c2a..2d9e3393f3184f069b7d2cde9ece9b83464576bb 100644 (file)
@@ -349,7 +349,7 @@ namespace Content.Server.Administration.Systems
                     if (TryComp(item, out PdaComponent? pda) &&
                         TryComp(pda.ContainedId, out StationRecordKeyStorageComponent? keyStorage) &&
                         keyStorage.Key is { } key &&
-                        _stationRecords.TryGetRecord(key.OriginStation, key, out GeneralStationRecord? record))
+                        _stationRecords.TryGetRecord(key, out GeneralStationRecord? record))
                     {
                         if (TryComp(entity, out DnaComponent? dna) &&
                             dna.DNA != record.DNA)
@@ -363,7 +363,7 @@ namespace Content.Server.Administration.Systems
                             continue;
                         }
 
-                        _stationRecords.RemoveRecord(key.OriginStation, key);
+                        _stationRecords.RemoveRecord(key);
                         Del(item);
                     }
                 }
diff --git a/Content.Server/CriminalRecords/Components/CriminalRecordsConsoleComponent.cs b/Content.Server/CriminalRecords/Components/CriminalRecordsConsoleComponent.cs
new file mode 100644 (file)
index 0000000..de9ada8
--- /dev/null
@@ -0,0 +1,45 @@
+using Content.Server.CriminalRecords.Systems;
+using Content.Shared.Radio;
+using Content.Shared.StationRecords;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.CriminalRecords.Components;
+
+/// <summary>
+/// A component for Criminal Record Console storing an active station record key and a currently applied filter
+/// </summary>
+[RegisterComponent]
+[Access(typeof(CriminalRecordsConsoleSystem))]
+public sealed partial class CriminalRecordsConsoleComponent : Component
+{
+    /// <summary>
+    /// Currently active station record key.
+    /// There is no station parameter as the console uses the current station.
+    /// </summary>
+    /// <remarks>
+    /// TODO: in the future this should be clientside instead of something players can fight over.
+    /// Client selects a record and tells the server the key it wants records for.
+    /// Server then sends a state with just the records, not the listing or filter, and the client updates just that.
+    /// I don't know if it's possible to have multiple bui states right now.
+    /// </remarks>
+    [DataField]
+    public uint? ActiveKey;
+
+    /// <summary>
+    /// Currently applied filter.
+    /// </summary>
+    [DataField]
+    public StationRecordsFilter? Filter;
+
+    /// <summary>
+    /// Channel to send messages to when someone's status gets changed.
+    /// </summary>
+    [DataField]
+    public ProtoId<RadioChannelPrototype> SecurityChannel = "Security";
+
+    /// <summary>
+    /// Max length of arrest and crime history strings.
+    /// </summary>
+    [DataField]
+    public uint MaxStringLength = 256;
+}
diff --git a/Content.Server/CriminalRecords/Systems/CriminalRecordsConsoleSystem.cs b/Content.Server/CriminalRecords/Systems/CriminalRecordsConsoleSystem.cs
new file mode 100644 (file)
index 0000000..67ac1bf
--- /dev/null
@@ -0,0 +1,224 @@
+using Content.Server.CriminalRecords.Components;
+using Content.Server.Popups;
+using Content.Server.Radio.EntitySystems;
+using Content.Server.Station.Systems;
+using Content.Server.StationRecords;
+using Content.Server.StationRecords.Systems;
+using Content.Shared.Access.Systems;
+using Content.Shared.CriminalRecords;
+using Content.Shared.Security;
+using Content.Shared.StationRecords;
+using Robust.Server.GameObjects;
+using Robust.Shared.Player;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Content.Server.CriminalRecords.Systems;
+
+public sealed class CriminalRecordsConsoleSystem : EntitySystem
+{
+    [Dependency] private readonly AccessReaderSystem _access = default!;
+    [Dependency] private readonly CriminalRecordsSystem _criminalRecords = default!;
+    [Dependency] private readonly PopupSystem _popup = default!;
+    [Dependency] private readonly RadioSystem _radio = default!;
+    [Dependency] private readonly SharedIdCardSystem _idCard = default!;
+    [Dependency] private readonly StationRecordsSystem _stationRecords = default!;
+    [Dependency] private readonly StationSystem _station = default!;
+    [Dependency] private readonly UserInterfaceSystem _ui = default!;
+
+    public override void Initialize()
+    {
+        SubscribeLocalEvent<CriminalRecordsConsoleComponent, RecordModifiedEvent>(UpdateUserInterface);
+        SubscribeLocalEvent<CriminalRecordsConsoleComponent, AfterGeneralRecordCreatedEvent>(UpdateUserInterface);
+
+        Subs.BuiEvents<CriminalRecordsConsoleComponent>(CriminalRecordsConsoleKey.Key, subs =>
+        {
+            subs.Event<BoundUIOpenedEvent>(UpdateUserInterface);
+            subs.Event<SelectStationRecord>(OnKeySelected);
+            subs.Event<SetStationRecordFilter>(OnFiltersChanged);
+            subs.Event<CriminalRecordChangeStatus>(OnChangeStatus);
+            subs.Event<CriminalRecordAddHistory>(OnAddHistory);
+            subs.Event<CriminalRecordDeleteHistory>(OnDeleteHistory);
+        });
+    }
+
+    private void UpdateUserInterface<T>(Entity<CriminalRecordsConsoleComponent> ent, ref T args)
+    {
+        // TODO: this is probably wasteful, maybe better to send a message to modify the exact state?
+        UpdateUserInterface(ent);
+    }
+
+    private void OnKeySelected(Entity<CriminalRecordsConsoleComponent> ent, ref SelectStationRecord msg)
+    {
+        // no concern of sus client since record retrieval will fail if invalid id is given
+        ent.Comp.ActiveKey = msg.SelectedKey;
+        UpdateUserInterface(ent);
+    }
+
+    private void OnFiltersChanged(Entity<CriminalRecordsConsoleComponent> ent, ref SetStationRecordFilter msg)
+    {
+        if (ent.Comp.Filter == null ||
+            ent.Comp.Filter.Type != msg.Type || ent.Comp.Filter.Value != msg.Value)
+        {
+            ent.Comp.Filter = new StationRecordsFilter(msg.Type, msg.Value);
+            UpdateUserInterface(ent);
+        }
+    }
+
+    private void OnChangeStatus(Entity<CriminalRecordsConsoleComponent> ent, ref CriminalRecordChangeStatus msg)
+    {
+        // prevent malf client violating wanted/reason nullability
+        if ((msg.Status == SecurityStatus.Wanted) != (msg.Reason != null))
+            return;
+
+        if (!CheckSelected(ent, msg.Session, out var mob, out var key))
+            return;
+
+        if (!_stationRecords.TryGetRecord<CriminalRecord>(key.Value, out var record) || record.Status == msg.Status)
+            return;
+
+        // validate the reason
+        string? reason = null;
+        if (msg.Reason != null)
+        {
+            reason = msg.Reason.Trim();
+            if (reason.Length < 1 || reason.Length > ent.Comp.MaxStringLength)
+                return;
+        }
+
+        // when arresting someone add it to history automatically
+        // fallback exists if the player was not set to wanted beforehand
+        if (msg.Status == SecurityStatus.Detained)
+        {
+            var oldReason = record.Reason ?? Loc.GetString("criminal-records-console-unspecified-reason");
+            var history = Loc.GetString("criminal-records-console-auto-history", ("reason", oldReason));
+            _criminalRecords.TryAddHistory(key.Value, history);
+        }
+
+        var oldStatus = record.Status;
+
+        // will probably never fail given the checks above
+        _criminalRecords.TryChangeStatus(key.Value, msg.Status, msg.Reason);
+
+        var name = RecordName(key.Value);
+        var officer = Loc.GetString("criminal-records-console-unknown-officer");
+        if (_idCard.TryFindIdCard(mob.Value, out var id) && id.Comp.FullName is {} fullName)
+            officer = fullName;
+
+        // figure out which radio message to send depending on transition
+        var statusString = (oldStatus, msg.Status) switch
+        {
+            // going from wanted or detained on the spot
+            (_, SecurityStatus.Detained) => "detained",
+            // prisoner did their time
+            (SecurityStatus.Detained, SecurityStatus.None) => "released",
+            // going from wanted to none, must have been a mistake
+            (_, SecurityStatus.None) => "not-wanted",
+            // going from none or detained, AOS or prisonbreak / lazy secoff never set them to released and they reoffended
+            (_, SecurityStatus.Wanted) => "wanted",
+            // this is impossible
+            _ => "not-wanted"
+        };
+        var message = Loc.GetString($"criminal-records-console-{statusString}", ("name", name), ("officer", officer),
+            reason != null ? ("reason", reason) : default!);
+        _radio.SendRadioMessage(ent, message, ent.Comp.SecurityChannel, ent);
+
+        UpdateUserInterface(ent);
+    }
+
+    private void OnAddHistory(Entity<CriminalRecordsConsoleComponent> ent, ref CriminalRecordAddHistory msg)
+    {
+        if (!CheckSelected(ent, msg.Session, out _, out var key))
+            return;
+
+        var line = msg.Line.Trim();
+        if (line.Length < 1 || line.Length > ent.Comp.MaxStringLength)
+            return;
+
+        if (!_criminalRecords.TryAddHistory(key.Value, line))
+            return;
+
+        // no radio message since its not crucial to officers patrolling
+
+        UpdateUserInterface(ent);
+    }
+
+    private void OnDeleteHistory(Entity<CriminalRecordsConsoleComponent> ent, ref CriminalRecordDeleteHistory msg)
+    {
+        if (!CheckSelected(ent, msg.Session, out _, out var key))
+            return;
+
+        if (!_criminalRecords.TryDeleteHistory(key.Value, msg.Index))
+            return;
+
+        // a bit sus but not crucial to officers patrolling
+
+        UpdateUserInterface(ent);
+    }
+
+    private void UpdateUserInterface(Entity<CriminalRecordsConsoleComponent> ent)
+    {
+        var (uid, console) = ent;
+        var owningStation = _station.GetOwningStation(uid);
+
+        if (!TryComp<StationRecordsComponent>(owningStation, out var stationRecords))
+        {
+            _ui.TrySetUiState(uid, CriminalRecordsConsoleKey.Key, new CriminalRecordsConsoleState());
+            return;
+        }
+
+        var listing = _stationRecords.BuildListing((owningStation.Value, stationRecords), console.Filter);
+
+        var state = new CriminalRecordsConsoleState(listing, console.Filter);
+        if (console.ActiveKey is {} id)
+        {
+            // get records to display when a crewmember is selected
+            var key = new StationRecordKey(id, owningStation.Value);
+            _stationRecords.TryGetRecord(key, out state.StationRecord, stationRecords);
+            _stationRecords.TryGetRecord(key, out state.CriminalRecord, stationRecords);
+            state.SelectedKey = id;
+        }
+
+        _ui.TrySetUiState(uid, CriminalRecordsConsoleKey.Key, state);
+    }
+
+    /// <summary>
+    /// Boilerplate that most actions use, if they require that a record be selected.
+    /// Obviously shouldn't be used for selecting records.
+    /// </summary>
+    private bool CheckSelected(Entity<CriminalRecordsConsoleComponent> ent, ICommonSession session,
+        [NotNullWhen(true)] out EntityUid? mob, [NotNullWhen(true)] out StationRecordKey? key)
+    {
+        key = null;
+        mob = null;
+        if (session.AttachedEntity is not {} user)
+            return false;
+
+        if (!_access.IsAllowed(user, ent))
+        {
+            _popup.PopupEntity(Loc.GetString("criminal-records-permission-denied"), ent, session);
+            return false;
+        }
+
+        if (ent.Comp.ActiveKey is not {} id)
+            return false;
+
+        // checking the console's station since the user might be off-grid using on-grid console
+        if (_station.GetOwningStation(ent) is not {} station)
+            return false;
+
+        key = new StationRecordKey(id, station);
+        mob = user;
+        return true;
+    }
+
+    /// <summary>
+    /// Gets the name from a record, or empty string if this somehow fails.
+    /// </summary>
+    private string RecordName(StationRecordKey key)
+    {
+        if (!_stationRecords.TryGetRecord<GeneralStationRecord>(key, out var record))
+            return "";
+
+        return record.Name;
+    }
+}
diff --git a/Content.Server/CriminalRecords/Systems/CriminalRecordsSystem.cs b/Content.Server/CriminalRecords/Systems/CriminalRecordsSystem.cs
new file mode 100644 (file)
index 0000000..efec184
--- /dev/null
@@ -0,0 +1,93 @@
+using System.Diagnostics.CodeAnalysis;
+using Content.Server.StationRecords.Systems;
+using Content.Shared.CriminalRecords;
+using Content.Shared.Security;
+using Content.Shared.StationRecords;
+using Robust.Shared.Timing;
+
+namespace Content.Server.CriminalRecords.Systems;
+
+/// <summary>
+///     Criminal records
+///
+///     Criminal Records inherit Station Records' core and add role-playing tools for Security:
+///         - Ability to track a person's status (Detained/Wanted/None)
+///         - See security officers' actions in Criminal Records in the radio
+///         - See reasons for any action with no need to ask the officer personally
+/// </summary>
+public sealed class CriminalRecordsSystem : EntitySystem
+{
+    [Dependency] private readonly IGameTiming _timing = default!;
+    [Dependency] private readonly StationRecordsSystem _stationRecords = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<AfterGeneralRecordCreatedEvent>(OnGeneralRecordCreated);
+    }
+
+    private void OnGeneralRecordCreated(AfterGeneralRecordCreatedEvent ev)
+    {
+        _stationRecords.AddRecordEntry(ev.Key, new CriminalRecord());
+        _stationRecords.Synchronize(ev.Key);
+    }
+
+    /// <summary>
+    /// Tries to change the status of the record found by the StationRecordKey.
+    /// Reason should only be passed if status is Wanted.
+    /// </summary>
+    /// <returns>True if the status is changed, false if not</returns>
+    public bool TryChangeStatus(StationRecordKey key, SecurityStatus status, string? reason)
+    {
+        // don't do anything if its the same status
+        if (!_stationRecords.TryGetRecord<CriminalRecord>(key, out var record)
+            || status == record.Status)
+            return false;
+
+        record.Status = status;
+        record.Reason = reason;
+
+        _stationRecords.Synchronize(key);
+
+        return true;
+    }
+
+    /// <summary>
+    /// Tries to add a history entry to a criminal record.
+    /// </summary>
+    /// <returns>True if adding succeeded, false if not</returns>
+    public bool TryAddHistory(StationRecordKey key, CrimeHistory entry)
+    {
+        if (!_stationRecords.TryGetRecord<CriminalRecord>(key, out var record))
+            return false;
+
+        record.History.Add(entry);
+        return true;
+    }
+
+    /// <summary>
+    /// Creates and tries to add a history entry using the current time.
+    /// </summary>
+    public bool TryAddHistory(StationRecordKey key, string line)
+    {
+        var entry = new CrimeHistory(_timing.CurTime, line);
+        return TryAddHistory(key, entry);
+    }
+
+    /// <summary>
+    /// Tries to delete a sepcific line of history from a criminal record, by index.
+    /// </summary>
+    /// <returns>True if the line was removed, false if not</returns>
+    public bool TryDeleteHistory(StationRecordKey key, uint index)
+    {
+        if (!_stationRecords.TryGetRecord<CriminalRecord>(key, out var record))
+            return false;
+
+        if (index >= record.History.Count)
+            return false;
+
+        record.History.RemoveAt((int) index);
+        return true;
+    }
+}
index bb7d89ddf59c2cbf1e367165eb2e5c160915bfc0..834453fb198503f7e964d9b30e5adb695164d145 100644 (file)
@@ -68,18 +68,14 @@ public sealed class RenameCommand : IConsoleCommand
                 // This is done here because ID cards are linked to station records
                 if (_entManager.TrySystem<StationRecordsSystem>(out var recordsSystem)
                     && _entManager.TryGetComponent(idCard, out StationRecordKeyStorageComponent? keyStorage)
-                    && keyStorage.Key != null)
+                    && keyStorage.Key is {} key)
                 {
-                    var origin = keyStorage.Key.Value.OriginStation;
-
-                    if (recordsSystem.TryGetRecord<GeneralStationRecord>(origin,
-                            keyStorage.Key.Value,
-                            out var generalRecord))
+                    if (recordsSystem.TryGetRecord<GeneralStationRecord>(key, out var generalRecord))
                     {
                         generalRecord.Name = name;
                     }
 
-                    recordsSystem.Synchronize(origin);
+                    recordsSystem.Synchronize(key);
                 }
             }
         }
index c1b4cd9334329d562a917bf51ab5e86735301d9f..dd4473952cb4dd196e974f77fc3885c127468eba 100644 (file)
@@ -29,15 +29,16 @@ public sealed class ClericalErrorRule : StationEventSystem<ClericalErrorRuleComp
         var min = (int) Math.Max(1, Math.Round(component.MinToRemove * recordCount));
         var max = (int) Math.Max(min, Math.Round(component.MaxToRemove * recordCount));
         var toRemove = RobustRandom.Next(min, max);
-        var keys = new List<StationRecordKey>();
+        var keys = new List<uint>();
         for (var i = 0; i < toRemove; i++)
         {
             keys.Add(RobustRandom.Pick(stationRecords.Records.Keys));
         }
 
-        foreach (var key in keys)
+        foreach (var id in keys)
         {
-            _stationRecords.RemoveRecord(chosenStation.Value, key, stationRecords);
+            var key = new StationRecordKey(id, chosenStation.Value);
+            _stationRecords.RemoveRecord(key, stationRecords);
         }
     }
 }
index e5b7f7a260b4e830f737bb4d3d244faf5ea6c0c8..9076bee436fe6ee29f7e9d43a328b833af5fc9cf 100644 (file)
@@ -1,10 +1,21 @@
+using Content.Server.StationRecords.Systems;
 using Content.Shared.StationRecords;
 
-namespace Content.Server.StationRecords;
+namespace Content.Server.StationRecords.Components;
 
-[RegisterComponent]
+[RegisterComponent, Access(typeof(GeneralStationRecordConsoleSystem))]
 public sealed partial class GeneralStationRecordConsoleComponent : Component
 {
-    public (NetEntity, uint)? ActiveKey { get; set; }
-    public GeneralStationRecordsFilter? Filter { get; set; }
+    /// <summary>
+    /// Selected crewmember record id.
+    /// Station always uses the station that owns the console.
+    /// </summary>
+    [DataField]
+    public uint? ActiveKey;
+
+    /// <summary>
+    /// Qualities to filter a search by.
+    /// </summary>
+    [DataField]
+    public StationRecordsFilter? Filter;
 }
index 2f6b220a783215757dc14adb6d758505993bb635..b5a4501cea7d9c540afd87a7199e9492637e27a7 100644 (file)
@@ -6,9 +6,10 @@ using Robust.Shared.Utility;
 namespace Content.Server.StationRecords;
 
 /// <summary>
-///     Set of station records. StationRecordsComponent stores these.
-///     Keyed by StationRecordKey, which should be obtained from
+///     Set of station records for a single station. StationRecordsComponent stores these.
+///     Keyed by the record id, which should be obtained from
 ///     an entity that stores a reference to it.
+///     A StationRecordKey has both the station entity (use to get the record set) and id (use for this).
 /// </summary>
 [DataDefinition]
 public sealed partial class StationRecordSet
@@ -16,22 +17,31 @@ public sealed partial class StationRecordSet
     [DataField("currentRecordId")]
     private uint _currentRecordId;
 
-    // TODO add custom type serializer so that keys don't have to be written twice.
-    [DataField("keys")]
-    public HashSet<StationRecordKey> Keys = new();
+    /// <summary>
+    /// Every key id that has a record(s) stored.
+    /// Presumably this is faster than iterating the dictionary to check if any tables have a key.
+    /// </summary>
+    [DataField]
+    public HashSet<uint> Keys = new();
 
-    [DataField("recentlyAccessed")]
-    private HashSet<StationRecordKey> _recentlyAccessed = new();
+    /// <summary>
+    /// Recently accessed key ids which are used to synchronize them efficiently.
+    /// </summary>
+    [DataField]
+    private HashSet<uint> _recentlyAccessed = new();
 
-    [DataField("tables")] // TODO ensure all of this data is serializable.
-    private Dictionary<Type, Dictionary<StationRecordKey, object>> _tables = new();
+    /// <summary>
+    /// Dictionary between a record's type and then each record indexed by id.
+    /// </summary>
+    [DataField]
+    private Dictionary<Type, Dictionary<uint, object>> _tables = new();
 
     /// <summary>
     ///     Gets all records of a specific type stored in the record set.
     /// </summary>
     /// <typeparam name="T">The type of record to fetch.</typeparam>
     /// <returns>An enumerable object that contains a pair of both a station key, and the record associated with it.</returns>
-    public IEnumerable<(StationRecordKey, T)> GetRecordsOfType<T>()
+    public IEnumerable<(uint, T)> GetRecordsOfType<T>()
     {
         if (!_tables.ContainsKey(typeof(T)))
         {
@@ -52,43 +62,44 @@ public sealed partial class StationRecordSet
     }
 
     /// <summary>
-    ///     Add an entry into a record.
+    /// Create a new record with an entry.
+    /// Returns an id that can only be used to access the record for this station.
     /// </summary>
     /// <param name="entry">Entry to add.</param>
     /// <typeparam name="T">Type of the entry that's being added.</typeparam>
-    public StationRecordKey AddRecordEntry<T>(EntityUid station, T entry)
+    public uint? AddRecordEntry<T>(T entry)
     {
         if (entry == null)
-            return StationRecordKey.Invalid;
+            return null;
 
-        var key = new StationRecordKey(_currentRecordId++, station);
+        var key = _currentRecordId++;
         AddRecordEntry(key, entry);
         return key;
     }
 
     /// <summary>
-    ///     Add an entry into a record.
+    ///     Add an entry into an existing record.
     /// </summary>
-    /// <param name="key">Key for the record.</param>
+    /// <param name="key">Key id for the record.</param>
     /// <param name="entry">Entry to add.</param>
     /// <typeparam name="T">Type of the entry that's being added.</typeparam>
-    public void AddRecordEntry<T>(StationRecordKey key, T entry)
+    public void AddRecordEntry<T>(uint key, T entry)
     {
         if (entry == null)
             return;
 
-        if (Keys.Add(key))
-            _tables.GetOrNew(typeof(T))[key] = entry;
+        Keys.Add(key);
+        _tables.GetOrNew(typeof(T))[key] = entry;
     }
 
     /// <summary>
     ///     Try to get an record entry by type, from this record key.
     /// </summary>
-    /// <param name="key">The StationRecordKey to get the entries from.</param>
+    /// <param name="key">The record id to get the entries from.</param>
     /// <param name="entry">The entry that is retrieved from the record set.</param>
     /// <typeparam name="T">The type of entry to search for.</typeparam>
     /// <returns>True if the record exists and was retrieved, false otherwise.</returns>
-    public bool TryGetRecordEntry<T>(StationRecordKey key, [NotNullWhen(true)] out T? entry)
+    public bool TryGetRecordEntry<T>(uint key, [NotNullWhen(true)] out T? entry)
     {
         entry = default;
 
@@ -108,10 +119,10 @@ public sealed partial class StationRecordSet
     /// <summary>
     ///     Checks if the record associated with this key has an entry of a certain type.
     /// </summary>
-    /// <param name="key">The record key.</param>
+    /// <param name="key">The record key id.</param>
     /// <typeparam name="T">Type to check.</typeparam>
     /// <returns>True if the entry exists, false otherwise.</returns>
-    public bool HasRecordEntry<T>(StationRecordKey key)
+    public bool HasRecordEntry<T>(uint key)
     {
         return Keys.Contains(key)
                && _tables.TryGetValue(typeof(T), out var table)
@@ -122,7 +133,7 @@ public sealed partial class StationRecordSet
     ///     Get the recently accessed keys from this record set.
     /// </summary>
     /// <returns>All recently accessed keys from this record set.</returns>
-    public IEnumerable<StationRecordKey> GetRecentlyAccessed()
+    public IEnumerable<uint> GetRecentlyAccessed()
     {
         return _recentlyAccessed.ToArray();
     }
@@ -135,17 +146,23 @@ public sealed partial class StationRecordSet
         _recentlyAccessed.Clear();
     }
 
+    /// <summary>
+    /// Removes a recently accessed key from the set.
+    /// </summary>
+    public void RemoveFromRecentlyAccessed(uint key)
+    {
+        _recentlyAccessed.Remove(key);
+    }
+
     /// <summary>
     ///     Removes all record entries related to this key from this set.
     /// </summary>
     /// <param name="key">The key to remove.</param>
     /// <returns>True if successful, false otherwise.</returns>
-    public bool RemoveAllRecords(StationRecordKey key)
+    public bool RemoveAllRecords(uint key)
     {
         if (!Keys.Remove(key))
-        {
             return false;
-        }
 
         foreach (var table in _tables.Values)
         {
index f69caaa9a7e357519289ae9ad39effee5d490b83..721eff6f2cfd12a8b51da970356d0fbb812ab3f6 100644 (file)
@@ -1,5 +1,6 @@
 using System.Linq;
 using Content.Server.Station.Systems;
+using Content.Server.StationRecords.Components;
 using Content.Shared.StationRecords;
 using Robust.Server.GameObjects;
 
@@ -7,126 +8,78 @@ namespace Content.Server.StationRecords.Systems;
 
 public sealed class GeneralStationRecordConsoleSystem : EntitySystem
 {
-    [Dependency] private readonly UserInterfaceSystem _userInterface = default!;
-    [Dependency] private readonly StationSystem _stationSystem = default!;
-    [Dependency] private readonly StationRecordsSystem _stationRecordsSystem = default!;
+    [Dependency] private readonly UserInterfaceSystem _ui = default!;
+    [Dependency] private readonly StationSystem _station = default!;
+    [Dependency] private readonly StationRecordsSystem _stationRecords = default!;
 
     public override void Initialize()
     {
-        SubscribeLocalEvent<GeneralStationRecordConsoleComponent, BoundUIOpenedEvent>(UpdateUserInterface);
-        SubscribeLocalEvent<GeneralStationRecordConsoleComponent, SelectGeneralStationRecord>(OnKeySelected);
-        SubscribeLocalEvent<GeneralStationRecordConsoleComponent, GeneralStationRecordsFilterMsg>(OnFiltersChanged);
         SubscribeLocalEvent<GeneralStationRecordConsoleComponent, RecordModifiedEvent>(UpdateUserInterface);
         SubscribeLocalEvent<GeneralStationRecordConsoleComponent, AfterGeneralRecordCreatedEvent>(UpdateUserInterface);
         SubscribeLocalEvent<GeneralStationRecordConsoleComponent, RecordRemovedEvent>(UpdateUserInterface);
+
+        Subs.BuiEvents<GeneralStationRecordConsoleComponent>(GeneralStationRecordConsoleKey.Key, subs =>
+        {
+            subs.Event<BoundUIOpenedEvent>(UpdateUserInterface);
+            subs.Event<SelectStationRecord>(OnKeySelected);
+            subs.Event<SetStationRecordFilter>(OnFiltersChanged);
+        });
     }
 
-    private void UpdateUserInterface<T>(EntityUid uid, GeneralStationRecordConsoleComponent component, T ev)
+    private void UpdateUserInterface<T>(Entity<GeneralStationRecordConsoleComponent> ent, ref T args)
     {
-        UpdateUserInterface(uid, component);
+        UpdateUserInterface(ent);
     }
 
-    private void OnKeySelected(EntityUid uid, GeneralStationRecordConsoleComponent component,
-        SelectGeneralStationRecord msg)
+    // TODO: instead of copy paste shitcode for each record console, have a shared records console comp they all use
+    // then have this somehow play nicely with creating ui state
+    // if that gets done put it in StationRecordsSystem console helpers section :)
+    private void OnKeySelected(Entity<GeneralStationRecordConsoleComponent> ent, ref SelectStationRecord msg)
     {
-        component.ActiveKey = msg.SelectedKey;
-        UpdateUserInterface(uid, component);
+        ent.Comp.ActiveKey = msg.SelectedKey;
+        UpdateUserInterface(ent);
     }
 
-    private void OnFiltersChanged(EntityUid uid,
-        GeneralStationRecordConsoleComponent component, GeneralStationRecordsFilterMsg msg)
+    private void OnFiltersChanged(Entity<GeneralStationRecordConsoleComponent> ent, ref SetStationRecordFilter msg)
     {
-        if (component.Filter == null ||
-            component.Filter.Type != msg.Type || component.Filter.Value != msg.Value)
+        if (ent.Comp.Filter == null ||
+            ent.Comp.Filter.Type != msg.Type || ent.Comp.Filter.Value != msg.Value)
         {
-            component.Filter = new GeneralStationRecordsFilter(msg.Type, msg.Value);
-            UpdateUserInterface(uid, component);
+            ent.Comp.Filter = new StationRecordsFilter(msg.Type, msg.Value);
+            UpdateUserInterface(ent);
         }
     }
 
-    private void UpdateUserInterface(EntityUid uid,
-        GeneralStationRecordConsoleComponent? console = null)
+    private void UpdateUserInterface(Entity<GeneralStationRecordConsoleComponent> ent)
     {
-        if (!Resolve(uid, ref console))
-        {
-            return;
-        }
-
-        var owningStation = _stationSystem.GetOwningStation(uid);
+        var (uid, console) = ent;
+        var owningStation = _station.GetOwningStation(uid);
 
-        if (!TryComp<StationRecordsComponent>(owningStation, out var stationRecordsComponent))
+        if (!TryComp<StationRecordsComponent>(owningStation, out var stationRecords))
         {
-            GeneralStationRecordConsoleState state = new(null, null, null, null);
-            SetStateForInterface(uid, state);
+            _ui.TrySetUiState(uid, GeneralStationRecordConsoleKey.Key, new GeneralStationRecordConsoleState());
             return;
         }
 
-        var consoleRecords =
-            _stationRecordsSystem.GetRecordsOfType<GeneralStationRecord>(owningStation.Value, stationRecordsComponent);
-
-        var listing = new Dictionary<(NetEntity, uint), string>();
+        var listing = _stationRecords.BuildListing((owningStation.Value, stationRecords), console.Filter);
 
-        foreach (var pair in consoleRecords)
+        switch (listing.Count)
         {
-            if (console.Filter != null && IsSkippedRecord(console.Filter, pair.Item2))
-            {
-                continue;
-            }
-
-            listing.Add(_stationRecordsSystem.Convert(pair.Item1), pair.Item2.Name);
+            case 0:
+                _ui.TrySetUiState(uid, GeneralStationRecordConsoleKey.Key, new GeneralStationRecordConsoleState());
+                return;
+            case 1:
+                console.ActiveKey = listing.Keys.First();
+                break;
         }
 
-        if (listing.Count == 0)
-        {
-            GeneralStationRecordConsoleState state = new(null, null, null, console.Filter);
-            SetStateForInterface(uid, state);
+        if (console.ActiveKey is not { } id)
             return;
-        }
-        else if (listing.Count == 1)
-        {
-            console.ActiveKey = listing.Keys.First();
-        }
 
-        GeneralStationRecord? record = null;
-        if (console.ActiveKey != null)
-        {
-            _stationRecordsSystem.TryGetRecord(owningStation.Value, _stationRecordsSystem.Convert(console.ActiveKey.Value), out record,
-                stationRecordsComponent);
-        }
+        var key = new StationRecordKey(id, owningStation.Value);
+        _stationRecords.TryGetRecord<GeneralStationRecord>(key, out var record, stationRecords);
 
-        GeneralStationRecordConsoleState newState = new(console.ActiveKey, record, listing, console.Filter);
-        SetStateForInterface(uid, newState);
-    }
-
-    private void SetStateForInterface(EntityUid uid, GeneralStationRecordConsoleState newState)
-    {
-        _userInterface.TrySetUiState(uid, GeneralStationRecordConsoleKey.Key, newState);
-    }
-
-    private bool IsSkippedRecord(GeneralStationRecordsFilter filter,
-        GeneralStationRecord someRecord)
-    {
-        bool isFilter = filter.Value.Length > 0;
-        string filterLowerCaseValue = "";
-
-        if (!isFilter)
-            return false;
-
-        filterLowerCaseValue = filter.Value.ToLower();
-
-        return filter.Type switch
-        {
-            GeneralStationRecordFilterType.Name =>
-                !someRecord.Name.ToLower().Contains(filterLowerCaseValue),
-            GeneralStationRecordFilterType.Prints => someRecord.Fingerprint != null
-                && IsFilterWithSomeCodeValue(someRecord.Fingerprint, filterLowerCaseValue),
-            GeneralStationRecordFilterType.DNA => someRecord.DNA != null
-                && IsFilterWithSomeCodeValue(someRecord.DNA, filterLowerCaseValue),
-        };
-    }
-
-    private bool IsFilterWithSomeCodeValue(string value, string filter)
-    {
-        return !value.ToLower().StartsWith(filter);
+        GeneralStationRecordConsoleState newState = new(id, record, listing, console.Filter);
+        _ui.TrySetUiState(uid, GeneralStationRecordConsoleKey.Key, newState);
     }
 }
index fd5094d5330a1045d5ab9fa7a0b5c4827235d6d3..09a00e5967cd4c51ad939cb8c66ecffd0d5e0d77 100644 (file)
@@ -32,8 +32,8 @@ namespace Content.Server.StationRecords.Systems;
 /// </summary>
 public sealed class StationRecordsSystem : SharedStationRecordsSystem
 {
-    [Dependency] private readonly InventorySystem _inventorySystem = default!;
-    [Dependency] private readonly StationRecordKeyStorageSystem _keyStorageSystem = default!;
+    [Dependency] private readonly InventorySystem _inventory = default!;
+    [Dependency] private readonly StationRecordKeyStorageSystem _keyStorage = default!;
     [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
 
     public override void Initialize()
@@ -45,26 +45,22 @@ public sealed class StationRecordsSystem : SharedStationRecordsSystem
 
     private void OnPlayerSpawn(PlayerSpawnCompleteEvent args)
     {
-        if (!HasComp<StationRecordsComponent>(args.Station))
+        if (!TryComp<StationRecordsComponent>(args.Station, out var stationRecords))
             return;
 
-        CreateGeneralRecord(args.Station, args.Mob, args.Profile, args.JobId);
+        CreateGeneralRecord(args.Station, args.Mob, args.Profile, args.JobId, stationRecords);
     }
 
     private void CreateGeneralRecord(EntityUid station, EntityUid player, HumanoidCharacterProfile profile,
-        string? jobId, StationRecordsComponent? records = null)
+        string? jobId, StationRecordsComponent records)
     {
-        if (!Resolve(station, ref records)
-            || string.IsNullOrEmpty(jobId)
+        // TODO make PlayerSpawnCompleteEvent.JobId a ProtoId
+        if (string.IsNullOrEmpty(jobId)
             || !_prototypeManager.HasIndex<JobPrototype>(jobId))
-        {
             return;
-        }
 
-        if (!_inventorySystem.TryGetSlotEntity(player, "id", out var idUid))
-        {
+        if (!_inventory.TryGetSlotEntity(player, "id", out var idUid))
             return;
-        }
 
         TryComp<FingerprintComponent>(player, out var fingerprintComponent);
         TryComp<DnaComponent>(player, out var dnaComponent);
@@ -100,17 +96,28 @@ public sealed class StationRecordsSystem : SharedStationRecordsSystem
     ///     Optional - other systems should anticipate this.
     /// </param>
     /// <param name="records">Station records component.</param>
-    public void CreateGeneralRecord(EntityUid station, EntityUid? idUid, string name, int age, string species, Gender gender, string jobId, string? mobFingerprint, string? dna, HumanoidCharacterProfile? profile = null,
-        StationRecordsComponent? records = null)
+    public void CreateGeneralRecord(
+        EntityUid station,
+        EntityUid? idUid,
+        string name,
+        int age,
+        string species,
+        Gender gender,
+        string jobId,
+        string? mobFingerprint,
+        string? dna,
+        HumanoidCharacterProfile profile,
+        StationRecordsComponent records)
     {
-        if (!Resolve(station, ref records))
-        {
-            return;
-        }
+        if (!_prototypeManager.TryIndex<JobPrototype>(jobId, out var jobPrototype))
+            throw new ArgumentException($"Invalid job prototype ID: {jobId}");
 
-        if (!_prototypeManager.TryIndex(jobId, out JobPrototype? jobPrototype))
+        // when adding a record that already exists use the old one
+        // this happens when respawning as the same character
+        if (GetRecordByName(station, name, records) is {} id)
         {
-            throw new ArgumentException($"Invalid job prototype ID: {jobId}");
+            SetIdKey(idUid, new StationRecordKey(id, station));
+            return;
         }
 
         var record = new GeneralStationRecord()
@@ -129,40 +136,47 @@ public sealed class StationRecordsSystem : SharedStationRecordsSystem
 
         var key = AddRecordEntry(station, record);
         if (!key.IsValid())
+        {
+            Log.Warning($"Failed to add general record entry for {name}");
             return;
+        }
+
+        SetIdKey(idUid, key);
+
+        RaiseLocalEvent(new AfterGeneralRecordCreatedEvent(key, record, profile));
+    }
 
-        if (idUid != null)
+    /// <summary>
+    /// Set the station records key for an id/pda.
+    /// </summary>
+    public void SetIdKey(EntityUid? uid, StationRecordKey key)
+    {
+        if (uid is not {} idUid)
+            return;
+
+        var keyStorageEntity = idUid;
+        if (TryComp<PdaComponent>(idUid, out var pda) && pda.ContainedId is {} id)
         {
-            var keyStorageEntity = idUid;
-            if (TryComp(idUid, out PdaComponent? pdaComponent) && pdaComponent.ContainedId != null)
-            {
-                keyStorageEntity = pdaComponent.IdSlot.Item;
-            }
-
-            if (keyStorageEntity != null)
-            {
-                _keyStorageSystem.AssignKey(keyStorageEntity.Value, key);
-            }
+            keyStorageEntity = id;
         }
 
-        RaiseLocalEvent(new AfterGeneralRecordCreatedEvent(station, key, record, profile));
+        _keyStorage.AssignKey(keyStorageEntity, key);
     }
 
     /// <summary>
     ///     Removes a record from this station.
     /// </summary>
-    /// <param name="station">Station to remove the record from.</param>
-    /// <param name="key">The key to remove.</param>
+    /// <param name="key">The station and key to remove.</param>
     /// <param name="records">Station records component.</param>
     /// <returns>True if the record was removed, false otherwise.</returns>
-    public bool RemoveRecord(EntityUid station, StationRecordKey key, StationRecordsComponent? records = null)
+    public bool RemoveRecord(StationRecordKey key, StationRecordsComponent? records = null)
     {
-        if (!Resolve(station, ref records))
+        if (!Resolve(key.OriginStation, ref records))
             return false;
 
-        if (records.Records.RemoveAllRecords(key))
+        if (records.Records.RemoveAllRecords(key.Id))
         {
-            RaiseLocalEvent(new RecordRemovedEvent(station, key));
+            RaiseLocalEvent(new RecordRemovedEvent(key));
             return true;
         }
 
@@ -174,20 +188,39 @@ public sealed class StationRecordsSystem : SharedStationRecordsSystem
     ///     from the provided station record key. Will always return
     ///     null if the key does not match the station.
     /// </summary>
-    /// <param name="station">Station to get the record from.</param>
-    /// <param name="key">Key to try and index from the record set.</param>
+    /// <param name="key">Station and key to try and index from the record set.</param>
     /// <param name="entry">The resulting entry.</param>
     /// <param name="records">Station record component.</param>
     /// <typeparam name="T">Type to get from the record set.</typeparam>
     /// <returns>True if the record was obtained, false otherwise.</returns>
-    public bool TryGetRecord<T>(EntityUid station, StationRecordKey key, [NotNullWhen(true)] out T? entry, StationRecordsComponent? records = null)
+    public bool TryGetRecord<T>(StationRecordKey key, [NotNullWhen(true)] out T? entry, StationRecordsComponent? records = null)
     {
         entry = default;
 
-        if (!Resolve(station, ref records))
+        if (!Resolve(key.OriginStation, ref records))
             return false;
 
-        return records.Records.TryGetRecordEntry(key, out entry);
+        return records.Records.TryGetRecordEntry(key.Id, out entry);
+    }
+
+    /// <summary>
+    /// Returns an id if a record with the same name exists.
+    /// </summary>
+    /// <remarks>
+    /// Linear search so O(n) time complexity.
+    /// </remarks>
+    public uint? GetRecordByName(EntityUid station, string name, StationRecordsComponent? records = null)
+    {
+        if (!Resolve(station, ref records))
+            return null;
+
+        foreach (var (id, record) in GetRecordsOfType<GeneralStationRecord>(station, records))
+        {
+            if (record.Name == name)
+                return id;
+        }
+
+        return null;
     }
 
     /// <summary>
@@ -197,30 +230,47 @@ public sealed class StationRecordsSystem : SharedStationRecordsSystem
     /// <param name="records">Station records component.</param>
     /// <typeparam name="T">Type of record to fetch</typeparam>
     /// <returns>Enumerable of pairs with a station record key, and the entry in question of type T.</returns>
-    public IEnumerable<(StationRecordKey, T)> GetRecordsOfType<T>(EntityUid station, StationRecordsComponent? records = null)
+    public IEnumerable<(uint, T)> GetRecordsOfType<T>(EntityUid station, StationRecordsComponent? records = null)
     {
         if (!Resolve(station, ref records))
-        {
-            return Array.Empty<(StationRecordKey, T)>();
-        }
+            return Array.Empty<(uint, T)>();
 
         return records.Records.GetRecordsOfType<T>();
     }
 
     /// <summary>
-    ///     Adds a record entry to a station's record set.
+    ///     Adds a new record entry to a station's record set.
     /// </summary>
     /// <param name="station">The station to add the record to.</param>
     /// <param name="record">The record to add.</param>
     /// <param name="records">Station records component.</param>
     /// <typeparam name="T">The type of record to add.</typeparam>
-    public StationRecordKey AddRecordEntry<T>(EntityUid station, T record,
-        StationRecordsComponent? records = null)
+    public StationRecordKey AddRecordEntry<T>(EntityUid station, T record, StationRecordsComponent? records = null)
     {
         if (!Resolve(station, ref records))
             return StationRecordKey.Invalid;
 
-        return records.Records.AddRecordEntry(station, record);
+        var id = records.Records.AddRecordEntry(record);
+        if (id == null)
+            return StationRecordKey.Invalid;
+
+        return new StationRecordKey(id.Value, station);
+    }
+
+    /// <summary>
+    /// Adds a record to an existing entry.
+    /// </summary>
+    /// <param name="key">The station and id of the existing entry.</param>
+    /// <param name="record">The record to add.</param>
+    /// <param name="records">Station records component.</param>
+    /// <typeparam name="T">The type of record to add.</typeparam>
+    public void AddRecordEntry<T>(StationRecordKey key, T record,
+        StationRecordsComponent? records = null)
+    {
+        if (!Resolve(key.OriginStation, ref records))
+            return;
+
+        records.Records.AddRecordEntry(key.Id, record);
     }
 
     /// <summary>
@@ -231,17 +281,99 @@ public sealed class StationRecordsSystem : SharedStationRecordsSystem
     public void Synchronize(EntityUid station, StationRecordsComponent? records = null)
     {
         if (!Resolve(station, ref records))
-        {
             return;
-        }
 
         foreach (var key in records.Records.GetRecentlyAccessed())
         {
-            RaiseLocalEvent(new RecordModifiedEvent(station, key));
+            RaiseLocalEvent(new RecordModifiedEvent(new StationRecordKey(key, station)));
         }
 
         records.Records.ClearRecentlyAccessed();
     }
+
+    /// <summary>
+    /// Synchronizes a single record's entries for a station.
+    /// </summary>
+    /// <param name="key">The station and id of the record</param>
+    /// <param name="records">Station records component.</param>
+    public void Synchronize(StationRecordKey key, StationRecordsComponent? records = null)
+    {
+        if (!Resolve(key.OriginStation, ref records))
+            return;
+
+        RaiseLocalEvent(new RecordModifiedEvent(key));
+
+        records.Records.RemoveFromRecentlyAccessed(key.Id);
+    }
+
+    #region Console system helpers
+
+    /// <summary>
+    /// Checks if a record should be skipped given a filter.
+    /// Takes general record since even if you are using this for e.g. criminal records,
+    /// you don't want to duplicate basic info like name and dna.
+    /// Station records lets you do this nicely with multiple types having their own data.
+    /// </summary>
+    public bool IsSkipped(StationRecordsFilter? filter, GeneralStationRecord someRecord)
+    {
+        // if nothing is being filtered, show everything
+        if (filter == null)
+            return false;
+        if (filter.Value.Length == 0)
+            return false;
+
+        var filterLowerCaseValue = filter.Value.ToLower();
+
+        return filter.Type switch
+        {
+            StationRecordFilterType.Name =>
+                !someRecord.Name.ToLower().Contains(filterLowerCaseValue),
+            StationRecordFilterType.Prints => someRecord.Fingerprint != null
+                && IsFilterWithSomeCodeValue(someRecord.Fingerprint, filterLowerCaseValue),
+            StationRecordFilterType.DNA => someRecord.DNA != null
+                && IsFilterWithSomeCodeValue(someRecord.DNA, filterLowerCaseValue),
+        };
+    }
+
+    private bool IsFilterWithSomeCodeValue(string value, string filter)
+    {
+        return !value.ToLower().StartsWith(filter);
+    }
+
+    /// <summary>
+    /// Build a record listing of id to name for a station and filter.
+    /// </summary>
+    public Dictionary<uint, string> BuildListing(Entity<StationRecordsComponent> station, StationRecordsFilter? filter)
+    {
+        var listing = new Dictionary<uint, string>();
+
+        var records = GetRecordsOfType<GeneralStationRecord>(station, station.Comp);
+        foreach (var pair in records)
+        {
+            if (IsSkipped(filter, pair.Item2))
+                continue;
+
+            listing.Add(pair.Item1, pair.Item2.Name);
+        }
+
+        return listing;
+    }
+
+    #endregion
+}
+
+/// <summary>
+/// Base event for station record events
+/// </summary>
+public abstract class StationRecordEvent : EntityEventArgs
+{
+    public readonly StationRecordKey Key;
+    public EntityUid Station => Key.OriginStation;
+
+    protected StationRecordEvent(StationRecordKey key)
+    {
+        Key = key;
+    }
 }
 
 /// <summary>
@@ -250,23 +382,19 @@ public sealed class StationRecordsSystem : SharedStationRecordsSystem
 ///     listening to this event, as it contains the character's record key.
 ///     Also stores the general record reference, to save some time.
 /// </summary>
-public sealed class AfterGeneralRecordCreatedEvent : EntityEventArgs
+public sealed class AfterGeneralRecordCreatedEvent : StationRecordEvent
 {
-    public readonly EntityUid Station;
-    public StationRecordKey Key { get; }
-    public GeneralStationRecord Record { get; }
+    public readonly GeneralStationRecord Record;
     /// <summary>
     /// Profile for the related player. This is so that other systems can get further information
     ///     about the player character.
     ///     Optional - other systems should anticipate this.
     /// </summary>
-    public HumanoidCharacterProfile? Profile { get; }
+    public readonly HumanoidCharacterProfile Profile;
 
-    public AfterGeneralRecordCreatedEvent(EntityUid station, StationRecordKey key, GeneralStationRecord record,
-        HumanoidCharacterProfile? profile)
+    public AfterGeneralRecordCreatedEvent(StationRecordKey key, GeneralStationRecord record,
+        HumanoidCharacterProfile profile) : base(key)
     {
-        Station = station;
-        Key = key;
         Record = record;
         Profile = profile;
     }
@@ -278,15 +406,10 @@ public sealed class AfterGeneralRecordCreatedEvent : EntityEventArgs
 ///     that store record keys can then remove the key from their internal
 ///     fields.
 /// </summary>
-public sealed class RecordRemovedEvent : EntityEventArgs
+public sealed class RecordRemovedEvent : StationRecordEvent
 {
-    public readonly EntityUid Station;
-    public StationRecordKey Key { get; }
-
-    public RecordRemovedEvent(EntityUid station, StationRecordKey key)
+    public RecordRemovedEvent(StationRecordKey key) : base(key)
     {
-        Station = station;
-        Key = key;
     }
 }
 
@@ -295,14 +418,9 @@ public sealed class RecordRemovedEvent : EntityEventArgs
 ///     inform other systems that records stored in this key
 ///     may have changed.
 /// </summary>
-public sealed class RecordModifiedEvent : EntityEventArgs
+public sealed class RecordModifiedEvent : StationRecordEvent
 {
-    public readonly EntityUid Station;
-    public StationRecordKey Key { get; }
-
-    public RecordModifiedEvent(EntityUid station, StationRecordKey key)
+    public RecordModifiedEvent(StationRecordKey key) : base(key)
     {
-        Station = station;
-        Key = key;
     }
 }
index 3f6c9e1c052f1f0437eb5db80c0ec31c45457462..b157797922362a22e3b59b025b2579a7a2f058e6 100644 (file)
@@ -34,7 +34,7 @@ public sealed partial class AccessReaderComponent : Component
     public List<HashSet<string>> AccessLists = new();
 
     /// <summary>
-    /// A list of <see cref="StationRecordKey"/>s that grant access. Only a single matching key is required tp gaim
+    /// A list of <see cref="StationRecordKey"/>s that grant access. Only a single matching key is required to gain
     /// access.
     /// </summary>
     [DataField]
diff --git a/Content.Shared/CriminalRecords/CriminalRecord.cs b/Content.Shared/CriminalRecords/CriminalRecord.cs
new file mode 100644 (file)
index 0000000..0fe23d4
--- /dev/null
@@ -0,0 +1,38 @@
+using Content.Shared.Security;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.CriminalRecords;
+
+/// <summary>
+/// Criminal record for a crewmember.
+/// Can be viewed and edited in a criminal records console by security.
+/// </summary>
+[Serializable, NetSerializable, DataRecord]
+public sealed record CriminalRecord
+{
+    /// <summary>
+    /// Status of the person (None, Wanted, Detained).
+    /// </summary>
+    [DataField]
+    public SecurityStatus Status = SecurityStatus.None;
+
+    /// <summary>
+    /// When Status is Wanted, the reason for it.
+    /// Should never be set otherwise.
+    /// </summary>
+    [DataField]
+    public string? Reason;
+
+    /// <summary>
+    /// Criminal history of the person.
+    /// This should have charges and time served added after someone is detained.
+    /// </summary>
+    [DataField]
+    public List<CrimeHistory> History = new();
+}
+
+/// <summary>
+/// A line of criminal activity and the time it was added at.
+/// </summary>
+[Serializable, NetSerializable]
+public record struct CrimeHistory(TimeSpan AddTime, string Crime);
diff --git a/Content.Shared/CriminalRecords/CriminalRecordsUi.cs b/Content.Shared/CriminalRecords/CriminalRecordsUi.cs
new file mode 100644 (file)
index 0000000..287de36
--- /dev/null
@@ -0,0 +1,102 @@
+using Content.Shared.Security;
+using Content.Shared.StationRecords;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.CriminalRecords;
+
+[Serializable, NetSerializable]
+public enum CriminalRecordsConsoleKey : byte
+{
+    Key
+}
+
+/// <summary>
+///     Criminal records console state. There are a few states:
+///     - SelectedKey null, Record null, RecordListing null
+///         - The station record database could not be accessed.
+///     - SelectedKey null, Record null, RecordListing non-null
+///         - Records are populated in the database, or at least the station has
+///           the correct component.
+///     - SelectedKey non-null, Record null, RecordListing non-null
+///         - The selected key does not have a record tied to it.
+///     - SelectedKey non-null, Record non-null, RecordListing non-null
+///         - The selected key has a record tied to it, and the record has been sent.
+///
+///     - there is added new filters and so added new states
+///         -SelectedKey null, Record null, RecordListing null, filters non-null
+///            the station may have data, but they all did not pass through the filters
+///
+///     Other states are erroneous.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class CriminalRecordsConsoleState : BoundUserInterfaceState
+{
+    /// <summary>
+    /// Currently selected crewmember record key.
+    /// </summary>
+    public uint? SelectedKey = null;
+
+    public CriminalRecord? CriminalRecord = null;
+    public GeneralStationRecord? StationRecord = null;
+    public readonly Dictionary<uint, string>? RecordListing;
+    public readonly StationRecordsFilter? Filter;
+
+    public CriminalRecordsConsoleState(Dictionary<uint, string>? recordListing, StationRecordsFilter? newFilter)
+    {
+        RecordListing = recordListing;
+        Filter = newFilter;
+    }
+
+    /// <summary>
+    /// Default state for opening the console
+    /// </summary>
+    public CriminalRecordsConsoleState() : this(null, null)
+    {
+    }
+
+    public bool IsEmpty() => SelectedKey == null && StationRecord == null && CriminalRecord == null && RecordListing == null;
+}
+
+/// <summary>
+/// Used to change status, respecting the wanted/reason nullability rules in <see cref="CriminalRecord"/>.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class CriminalRecordChangeStatus : BoundUserInterfaceMessage
+{
+    public readonly SecurityStatus Status;
+    public readonly string? Reason;
+
+    public CriminalRecordChangeStatus(SecurityStatus status, string? reason)
+    {
+        Status = status;
+        Reason = reason;
+    }
+}
+
+/// <summary>
+/// Used to add a single line to the record's crime history.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class CriminalRecordAddHistory : BoundUserInterfaceMessage
+{
+    public readonly string Line;
+
+    public CriminalRecordAddHistory(string line)
+    {
+        Line = line;
+    }
+}
+
+/// <summary>
+/// Used to delete a single line from the crime history, by index.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class CriminalRecordDeleteHistory : BoundUserInterfaceMessage
+{
+    public readonly uint Index;
+
+    public CriminalRecordDeleteHistory(uint index)
+    {
+        Index = index;
+    }
+}
diff --git a/Content.Shared/Security/SecurityStatus.cs b/Content.Shared/Security/SecurityStatus.cs
new file mode 100644 (file)
index 0000000..95250a8
--- /dev/null
@@ -0,0 +1,15 @@
+namespace Content.Shared.Security;
+
+/// <summary>
+/// Status used in Criminal Records.
+///
+/// None - the default value
+/// Wanted - the person is being wanted by security
+/// Detained - the person is detained by security
+/// </summary>
+public enum SecurityStatus : byte
+{
+    None,
+    Wanted,
+    Detained
+}
similarity index 65%
rename from Content.Shared/StationRecords/SharedGeneralStationRecordConsoleSystem.cs
rename to Content.Shared/StationRecords/GeneralRecordsUi.cs
index 27288a7a1f95f20628e2709efbda42c6dcbb0eaf..860454efde51b52c4bf7fc8f7ff46dbe7f01d06f 100644 (file)
@@ -30,14 +30,16 @@ public enum GeneralStationRecordConsoleKey : byte
 public sealed class GeneralStationRecordConsoleState : BoundUserInterfaceState
 {
     /// <summary>
-    ///     Current selected key.
+    /// Current selected key.
+    /// Station is always the station that owns the console.
     /// </summary>
-    public (NetEntity, uint)? SelectedKey { get; }
-    public GeneralStationRecord? Record { get; }
-    public Dictionary<(NetEntity, uint), string>? RecordListing { get; }
-    public GeneralStationRecordsFilter? Filter { get; }
-    public GeneralStationRecordConsoleState((NetEntity, uint)? key, GeneralStationRecord? record,
-        Dictionary<(NetEntity, uint), string>? recordListing, GeneralStationRecordsFilter? newFilter)
+    public readonly uint? SelectedKey;
+    public readonly GeneralStationRecord? Record;
+    public readonly Dictionary<uint, string>? RecordListing;
+    public readonly StationRecordsFilter? Filter;
+
+    public GeneralStationRecordConsoleState(uint? key, GeneralStationRecord? record,
+        Dictionary<uint, string>? recordListing, StationRecordsFilter? newFilter)
     {
         SelectedKey = key;
         Record = record;
@@ -45,16 +47,24 @@ public sealed class GeneralStationRecordConsoleState : BoundUserInterfaceState
         Filter = newFilter;
     }
 
+    public GeneralStationRecordConsoleState() : this(null, null, null, null)
+    {
+    }
+
     public bool IsEmpty() => SelectedKey == null
         && Record == null && RecordListing == null;
 }
 
+/// <summary>
+/// Select a specific crewmember's record, or deselect.
+/// Used by any kind of records console including general and criminal.
+/// </summary>
 [Serializable, NetSerializable]
-public sealed class SelectGeneralStationRecord : BoundUserInterfaceMessage
+public sealed class SelectStationRecord : BoundUserInterfaceMessage
 {
-    public (NetEntity, uint)? SelectedKey { get; }
+    public readonly uint? SelectedKey;
 
-    public SelectGeneralStationRecord((NetEntity, uint)? selectedKey)
+    public SelectStationRecord(uint? selectedKey)
     {
         SelectedKey = selectedKey;
     }
index de4cda8f251ccd01a184346d5155fc444134364b..2ca34a4ffbde616c641a06f3dcd252f033635388 100644 (file)
@@ -7,46 +7,46 @@ namespace Content.Shared.StationRecords;
 ///     General station record. Indicates the crewmember's name and job.
 /// </summary>
 [Serializable, NetSerializable]
-public sealed class GeneralStationRecord
+public sealed record GeneralStationRecord
 {
     /// <summary>
     ///     Name tied to this station record.
     /// </summary>
-    [ViewVariables]
+    [DataField]
     public string Name = string.Empty;
 
     /// <summary>
     ///     Age of the person that this station record represents.
     /// </summary>
-    [ViewVariables]
+    [DataField]
     public int Age;
 
     /// <summary>
     ///     Job title tied to this station record.
     /// </summary>
-    [ViewVariables]
+    [DataField]
     public string JobTitle = string.Empty;
 
     /// <summary>
     ///     Job icon tied to this station record.
     /// </summary>
-    [ViewVariables]
+    [DataField]
     public string JobIcon = string.Empty;
 
-    [ViewVariables]
+    [DataField]
     public string JobPrototype = string.Empty;
 
     /// <summary>
     ///     Species tied to this station record.
     /// </summary>
-    [ViewVariables]
+    [DataField]
     public string Species = string.Empty;
 
     /// <summary>
     ///     Gender identity tied to this station record.
     /// </summary>
     /// <remarks>Sex should be placed in a medical record, not a general record.</remarks>
-    [ViewVariables]
+    [DataField]
     public Gender Gender = Gender.Epicene;
 
     /// <summary>
@@ -54,18 +54,18 @@ public sealed class GeneralStationRecord
     ///     This is taken from the 'weight' of a job prototype,
     ///     usually.
     /// </summary>
-    [ViewVariables]
+    [DataField]
     public int DisplayPriority;
 
     /// <summary>
     ///     Fingerprint of the person.
     /// </summary>
-    [ViewVariables]
+    [DataField]
     public string? Fingerprint;
 
     /// <summary>
     ///     DNA of the person.
     /// </summary>
-    [ViewVariables]
+    [DataField]
     public string? DNA;
 }
diff --git a/Content.Shared/StationRecords/GeneralStationRecordsFilter.cs b/Content.Shared/StationRecords/GeneralStationRecordsFilter.cs
deleted file mode 100644 (file)
index f032242..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-using Robust.Shared.Serialization;
-
-namespace Content.Shared.StationRecords;
-
-[Serializable, NetSerializable]
-public sealed class GeneralStationRecordsFilter
-{
-    public GeneralStationRecordFilterType Type { get; set; }
-        = GeneralStationRecordFilterType.Name;
-    public string Value { get; set; } = "";
-    public GeneralStationRecordsFilter(GeneralStationRecordFilterType filterType, string newValue = "")
-    {
-        Type = filterType;
-        Value = newValue;
-    }
-}
-
-[Serializable, NetSerializable]
-public sealed class GeneralStationRecordsFilterMsg : BoundUserInterfaceMessage
-{
-    public string Value { get; }
-    public GeneralStationRecordFilterType Type { get; }
-
-    public GeneralStationRecordsFilterMsg(GeneralStationRecordFilterType filterType,
-        string filterValue)
-    {
-        Type = filterType;
-        Value = filterValue;
-    }
-}
-
-[Serializable, NetSerializable]
-public enum GeneralStationRecordFilterType : byte
-{
-    Name,
-    Prints,
-    DNA,
-}
index 937c3aa3ef1e4c35299d56393f91d4d652040f71..3693c0f57d9a7832fedeb893adccaba3d8bb5960 100644 (file)
@@ -1,10 +1,14 @@
 namespace Content.Shared.StationRecords;
 
-// Station record keys. These should be stored somewhere,
-// preferably within an ID card.
+/// <summary>
+/// Station record keys. These should be stored somewhere,
+/// preferably within an ID card.
+/// This refers to both the id and station. This is suitable for an access reader field etc,
+/// but when you already know the station just store the id itself.
+/// </summary>
 public readonly struct StationRecordKey : IEquatable<StationRecordKey>
 {
-    [DataField("id")]
+    [DataField]
     public readonly uint Id;
 
     [DataField("station")]
diff --git a/Content.Shared/StationRecords/StationRecordsFilter.cs b/Content.Shared/StationRecords/StationRecordsFilter.cs
new file mode 100644 (file)
index 0000000..10b94dd
--- /dev/null
@@ -0,0 +1,44 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.StationRecords;
+
+[Serializable, NetSerializable]
+public sealed class StationRecordsFilter
+{
+    public StationRecordFilterType Type = StationRecordFilterType.Name;
+    public string Value  = "";
+
+    public StationRecordsFilter(StationRecordFilterType filterType, string newValue = "")
+    {
+        Type = filterType;
+        Value = newValue;
+    }
+}
+
+/// <summary>
+/// Message for updating the filter on any kind of records console.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class SetStationRecordFilter : BoundUserInterfaceMessage
+{
+    public readonly string Value;
+    public readonly StationRecordFilterType Type;
+
+    public SetStationRecordFilter(StationRecordFilterType filterType,
+        string filterValue)
+    {
+        Type = filterType;
+        Value = filterValue;
+    }
+}
+
+/// <summary>
+/// Different strings that results can be filtered by.
+/// </summary>
+[Serializable, NetSerializable]
+public enum StationRecordFilterType : byte
+{
+    Name,
+    Prints,
+    DNA,
+}
diff --git a/Resources/Locale/en-US/criminal-records/criminal-records.ftl b/Resources/Locale/en-US/criminal-records/criminal-records.ftl
new file mode 100644 (file)
index 0000000..49cd599
--- /dev/null
@@ -0,0 +1,44 @@
+criminal-records-console-window-title = Criminal Records Computer
+criminal-records-console-records-list-title = Crewmembers
+criminal-records-console-select-record-info = Select a record.
+criminal-records-console-no-records = No records found!
+criminal-records-console-no-record-found = No record was found for the selected person.
+
+## Status
+
+criminal-records-console-status = Status
+criminal-records-status-none = None
+criminal-records-status-wanted = Wanted
+criminal-records-status-detained = Detained
+
+criminal-records-console-wanted-reason = [color=gray]Wanted Reason[/color]
+criminal-records-console-reason = Reason
+criminal-records-console-reason-placeholder = For example: {$placeholder}
+
+## Crime History
+
+criminal-records-console-crime-history = Crime History
+criminal-records-history-placeholder = Write the crime here
+criminal-records-no-history = This crewmember's record is spotless.
+criminal-records-add-history = Add
+criminal-records-delete-history = Delete
+
+criminal-records-permission-denied = Permission denied
+
+## Security channel notifications
+
+criminal-records-console-wanted = {$name} is wanted by {$officer} for: {$reason}.
+criminal-records-console-detained = {$name} has been detained by {$officer}.
+criminal-records-console-released = {$name} has been released by {$officer}.
+criminal-records-console-not-wanted = {$name} is no longer wanted.
+
+## Filters
+
+criminal-records-filter-placeholder = Input text and press "Enter"
+criminal-records-name-filter = Name
+criminal-records-prints-filter = Fingerprints
+criminal-records-dna-filter = DNA
+
+## Arrest auto history lines
+criminal-records-console-auto-history = ARRESTED: {$reason}
+criminal-records-console-unspecified-reason = <unspecified reason>
index 93f613ecbd2668e98841744bda6907929c6204b3..5571e58e4fd8dfcff5476940463ad07f0caeea10 100644 (file)
@@ -48,6 +48,7 @@ guide-entry-cyborgs = Cyborgs
 guide-entry-security = Security
 guide-entry-forensics = Forensics
 guide-entry-defusal = Large Bomb Defusal
+guide-entry-criminal-records = Criminal Records
 
 guide-entry-antagonists = Antagonists
 guide-entry-nuclear-operatives = Nuclear Operatives
index 89775a449e2911b111bfbdc1f92796a440fb45e8..3ee1f834fb3f5b30728741ae29181ee0219f4567 100644 (file)
@@ -1,4 +1,4 @@
-general-station-record-console-window-title = Station Records Computer
+general-station-record-console-window-title = Station Records Computer
 general-station-record-console-select-record-info = Select a record on the left.
 general-station-record-console-empty-state = No records found!
 general-station-record-console-no-record-found = No record was found for the selected person.
@@ -11,8 +11,5 @@ general-station-record-console-record-fingerprint = Fingerprint: {$fingerprint}
 general-station-record-console-record-dna = DNA: {$dna}
 
 general-station-record-for-filter-line-placeholder = Input text and press "Enter"
-general-station-record-name-filter = Name of person
-general-station-record-prints-filter = Fingerprints
-general-station-record-dna-filter = DNA
 general-station-record-console-search-records = Search
-general-station-record-console-reset-filters = Reset
\ No newline at end of file
+general-station-record-console-reset-filters = Reset
diff --git a/Resources/Prototypes/Datasets/criminal_records.yml b/Resources/Prototypes/Datasets/criminal_records.yml
new file mode 100644 (file)
index 0000000..ee28309
--- /dev/null
@@ -0,0 +1,18 @@
+# "funny" placeholders of extremely minor/non-crimes for wanted reason dialog
+- type: dataset
+  id: CriminalRecordsWantedReasonPlaceholders
+  values:
+  - Ate their own shoes
+  - Being a clown
+  - Being a mime
+  - Breathed the wrong way
+  - Broke into evac
+  - Did literally nothing
+  - Didn't say hello to me
+  - Drank one too many
+  - Lied on common radio
+  - Looked at me funny
+  - Slipped the HoS
+  - Stole the clown's mask
+  - Told an unfunny joke
+  - Wore a gasmask
index 5aa1680aaeacf3febdc827a9ae1157cd30cd98ac..c9cbb34d63e95094a602ce8ddefdcc4ff80c5e49 100644 (file)
   parent: BaseComputer
   id: ComputerCriminalRecords
   name: criminal records computer
-  description: This can be used to check criminal records.
+  description: This can be used to check criminal records. Only security can modify them.
   components:
+  - type: CriminalRecordsConsole
+  - type: UserInterface
+    interfaces:
+    - key: enum.CriminalRecordsConsoleKey.Key
+      type: CriminalRecordsConsoleBoundUserInterface
+  - type: ActivatableUI
+    key: enum.CriminalRecordsConsoleKey.Key
   - type: Sprite
     layers:
     - map: ["computerLayerBody"]
     color: "#1f8c28"
   - type: Computer
     board: CriminalRecordsComputerCircuitboard
+  - type: AccessReader
+    access: [["Security"]]
+  - type: GuideHelp
+    guides:
+    - CriminalRecords
 
 - type: entity
   parent: BaseComputer
index 8e734b4d137a0dfe6dc39f8f8080d5376ea00a32..f5e347082836ef209c53b85c41b2cbea8661dfd0 100644 (file)
@@ -3,8 +3,9 @@
   name: guide-entry-security
   text: "/ServerInfo/Guidebook/Security/Security.xml"
   children:
-    - Forensics
-    - Defusal
+  - Forensics
+  - Defusal
+  - CriminalRecords
 
 - type: guideEntry
   id: Forensics
@@ -15,3 +16,8 @@
   id: Defusal
   name: guide-entry-defusal
   text: "/ServerInfo/Guidebook/Security/Defusal.xml"
+
+- type: guideEntry
+  id: CriminalRecords
+  name: guide-entry-criminal-records
+  text: "/ServerInfo/Guidebook/Security/CriminalRecords.xml"
diff --git a/Resources/ServerInfo/Guidebook/Security/CriminalRecords.xml b/Resources/ServerInfo/Guidebook/Security/CriminalRecords.xml
new file mode 100644 (file)
index 0000000..c7b7ad2
--- /dev/null
@@ -0,0 +1,39 @@
+<Document>
+  # Criminal Records
+  The criminal records console is accessible in every station's security department, it serves the purpose of tracking and managing the criminal history and status of anybody part of the crew manifest.
+
+  <Box>
+  <GuideEntityEmbed Entity="ComputerCriminalRecords"/>
+  </Box>
+
+  Anyone can open the console's UI, but only those with Security access can modify anything.
+
+  The UI is composed by the following elements:
+  - A search bar that has a filter next to it that lets you filter the crewmembers by their names, fingerprints or DNA.
+
+  - A list of all the crewmembers in the manifest, selecting one of the entries will make the criminal records of a crewmember appear. The list is filtered by the search bar so make sure it's empty if you want an overall overview!
+
+  - The criminal records themselves
+
+  In the record section you can:
+    - See security-related information about a crewmember like their name, fingerprints and DNA.
+
+    - Change the security status between [color=gray]None[/color], [color=yellow]Wanted[/color] and [color=red]Detained[/color]. When setting it to Wanted you will be asked to write a reason.
+
+    - If they are wanted, you can see the reason given below the status dropdown.
+
+    - Once someone has been arrested, update their status on the console so everyone knows they no longer need to be captured.
+
+    - After they've done their time, release them and update their status to None so nobody thinks they are an escaped convict.
+
+    - Open the Crime History window to check or modify it.
+
+  The Crime History window lists someone's crimes and can be modified in multiple ways:
+    - Automatically, just by setting someone's status to arrested. The reason will be added to "ARRESTED:" so it's easy to see the automated entries.
+
+    - Adding a new line by clicking "Add" and writing something in the input box. When adding a record, remember to mention their crime and sentence, the console will automatically insert the shift's time so you don't need to!
+
+    - Select a line of unwanted history and click "Delete" to remove it. Excellent for keeping records clean from the clown's stolen ID antics.
+
+  Now you can be the desk jockey you've always wanted to be.
+</Document>