criminal records revival (#22510)

This commit is contained in:
deltanedas
2024-02-04 23:29:35 +00:00
committed by GitHub
parent c856dd7506
commit 683591ab04
34 changed files with 1564 additions and 339 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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");
}
}

View 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);
}

View 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");
}

View 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);
}
}

View 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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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;
}

View 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)
{

View 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);
}
private void UpdateUserInterface<T>(EntityUid uid, GeneralStationRecordConsoleComponent component, T ev)
{
UpdateUserInterface(uid, component);
}
private void OnKeySelected(EntityUid uid, GeneralStationRecordConsoleComponent component,
SelectGeneralStationRecord msg)
{
component.ActiveKey = msg.SelectedKey;
UpdateUserInterface(uid, component);
}
private void OnFiltersChanged(EntityUid uid,
GeneralStationRecordConsoleComponent component, GeneralStationRecordsFilterMsg msg)
{
if (component.Filter == null ||
component.Filter.Type != msg.Type || component.Filter.Value != msg.Value)
Subs.BuiEvents<GeneralStationRecordConsoleComponent>(GeneralStationRecordConsoleKey.Key, subs =>
{
component.Filter = new GeneralStationRecordsFilter(msg.Type, msg.Value);
UpdateUserInterface(uid, component);
subs.Event<BoundUIOpenedEvent>(UpdateUserInterface);
subs.Event<SelectStationRecord>(OnKeySelected);
subs.Event<SetStationRecordFilter>(OnFiltersChanged);
});
}
private void UpdateUserInterface<T>(Entity<GeneralStationRecordConsoleComponent> ent, ref T args)
{
UpdateUserInterface(ent);
}
// 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)
{
ent.Comp.ActiveKey = msg.SelectedKey;
UpdateUserInterface(ent);
}
private void OnFiltersChanged(Entity<GeneralStationRecordConsoleComponent> 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 UpdateUserInterface(EntityUid uid,
GeneralStationRecordConsoleComponent? console = null)
private void UpdateUserInterface(Entity<GeneralStationRecordConsoleComponent> ent)
{
if (!Resolve(uid, ref console))
var (uid, console) = ent;
var owningStation = _station.GetOwningStation(uid);
if (!TryComp<StationRecordsComponent>(owningStation, out var stationRecords))
{
_ui.TrySetUiState(uid, GeneralStationRecordConsoleKey.Key, new GeneralStationRecordConsoleState());
return;
}
var owningStation = _stationSystem.GetOwningStation(uid);
var listing = _stationRecords.BuildListing((owningStation.Value, stationRecords), console.Filter);
if (!TryComp<StationRecordsComponent>(owningStation, out var stationRecordsComponent))
switch (listing.Count)
{
GeneralStationRecordConsoleState state = new(null, null, null, null);
SetStateForInterface(uid, state);
case 0:
_ui.TrySetUiState(uid, GeneralStationRecordConsoleKey.Key, new GeneralStationRecordConsoleState());
return;
case 1:
console.ActiveKey = listing.Keys.First();
break;
}
if (console.ActiveKey is not { } id)
return;
}
var consoleRecords =
_stationRecordsSystem.GetRecordsOfType<GeneralStationRecord>(owningStation.Value, stationRecordsComponent);
var key = new StationRecordKey(id, owningStation.Value);
_stationRecords.TryGetRecord<GeneralStationRecord>(key, out var record, stationRecords);
var listing = new Dictionary<(NetEntity, uint), string>();
foreach (var pair in consoleRecords)
{
if (console.Filter != null && IsSkippedRecord(console.Filter, pair.Item2))
{
continue;
}
listing.Add(_stationRecordsSystem.Convert(pair.Item1), pair.Item2.Name);
}
if (listing.Count == 0)
{
GeneralStationRecordConsoleState state = new(null, null, null, console.Filter);
SetStateForInterface(uid, state);
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);
}
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);
}
}

View 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(jobId, out JobPrototype? jobPrototype))
{
if (!_prototypeManager.TryIndex<JobPrototype>(jobId, out var jobPrototype))
throw new ArgumentException($"Invalid job prototype ID: {jobId}");
// 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)
{
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())
return;
if (idUid != null)
{
var keyStorageEntity = idUid;
if (TryComp(idUid, out PdaComponent? pdaComponent) && pdaComponent.ContainedId != null)
{
keyStorageEntity = pdaComponent.IdSlot.Item;
}
if (keyStorageEntity != null)
{
_keyStorageSystem.AssignKey(keyStorageEntity.Value, key);
}
Log.Warning($"Failed to add general record entry for {name}");
return;
}
RaiseLocalEvent(new AfterGeneralRecordCreatedEvent(station, key, record, profile));
SetIdKey(idUid, key);
RaiseLocalEvent(new AfterGeneralRecordCreatedEvent(key, record, profile));
}
/// <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)
{
keyStorageEntity = id;
}
_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;
}
}

View 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]

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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
}

View 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;
}

View 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;
}

View File

@@ -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,
}

View 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")]

View File

@@ -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,
}

View File

@@ -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>

View 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

View 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
general-station-record-console-reset-filters = Reset

View File

@@ -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

View File

@@ -285,8 +285,15 @@
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"]
@@ -303,6 +310,11 @@
color: "#1f8c28"
- type: Computer
board: CriminalRecordsComputerCircuitboard
- type: AccessReader
access: [["Security"]]
- type: GuideHelp
guides:
- CriminalRecords
- type: entity
parent: BaseComputer

View 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"

View File

@@ -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>