Port Height & Records Computer from Cosmatic Drift (#2236)

* Port character records from CD

* Make species' base scales respected by CD heights

* Hide the height editor in the humanoid profile editor

---------

Co-authored-by: Janet Blackquill <uhhadd@gmail.com>
This commit is contained in:
Lyndomen 2025-03-11 11:34:45 -04:00 committed by GitHub
parent 13c9095009
commit f024f46b0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 7753 additions and 10 deletions

View File

@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cc="clr-namespace:Content.Client.Administration.UI.CustomControls" xmlns:cc="clr-namespace:Content.Client.Administration.UI.CustomControls"
xmlns:at="clr-namespace:Content.Client.Administration.UI.Tabs.AdminTab" xmlns:at="clr-namespace:Content.Client.Administration.UI.Tabs.AdminTab"
xmlns:cdAdmin="clr-namespace:Content.Client._CD.Admin.UI"
Margin="4" Margin="4"
MinSize="50 50"> MinSize="50 50">
<BoxContainer Orientation="Vertical"> <BoxContainer Orientation="Vertical">
@ -16,6 +17,8 @@
<cc:UICommandButton Command="callshuttle" Text="{Loc admin-player-actions-window-shuttle}" WindowType="{x:Type at:AdminShuttleWindow}"/> <cc:UICommandButton Command="callshuttle" Text="{Loc admin-player-actions-window-shuttle}" WindowType="{x:Type at:AdminShuttleWindow}"/>
<cc:CommandButton Command="adminlogs" Text="{Loc admin-player-actions-window-admin-logs}"/> <cc:CommandButton Command="adminlogs" Text="{Loc admin-player-actions-window-admin-logs}"/>
<cc:CommandButton Command="faxui" Text="{Loc admin-player-actions-window-admin-fax}"/> <cc:CommandButton Command="faxui" Text="{Loc admin-player-actions-window-admin-fax}"/>
<!-- CD: records purge button -->
<cc:UICommandButton Command="purgecharacterrecords" Text="{Loc admin-player-actions-window-cd-record-purge}" WindowType="{x:Type cdAdmin:ModifyCharacterRecords}"/>
</GridContainer> </GridContainer>
</BoxContainer> </BoxContainer>
</Control> </Control>

View File

@ -5,6 +5,8 @@ using Content.Shared.Preferences;
using Robust.Client.GameObjects; using Robust.Client.GameObjects;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
using Robust.Shared.Utility; using Robust.Shared.Utility;
using System.Numerics; // CD - Character Records
using Robust.Client.Console; // CD - Character Records
namespace Content.Client.Humanoid; namespace Content.Client.Humanoid;
@ -30,6 +32,13 @@ public sealed class HumanoidAppearanceSystem : SharedHumanoidAppearanceSystem
UpdateLayers(component, sprite); UpdateLayers(component, sprite);
ApplyMarkingSet(component, sprite); ApplyMarkingSet(component, sprite);
// Begin CD - Character Records
var speciesPrototype = _prototypeManager.Index<SpeciesPrototype>(component.Species);
var height = Math.Clamp(MathF.Round(component.Height, 2), speciesPrototype.MinHeight, speciesPrototype.MaxHeight); // should NOT be locked, at all
sprite.Scale = speciesPrototype.BaseScale * new Vector2(speciesPrototype.ScaleHeight ? height : 1f, height); // DV - CD Character Records shouldn't nuke species heights
// End CD - Character Records
sprite[sprite.LayerMapReserveBlank(HumanoidVisualLayers.Eyes)].Color = component.EyeColor; sprite[sprite.LayerMapReserveBlank(HumanoidVisualLayers.Eyes)].Color = component.EyeColor;
} }
@ -199,6 +208,7 @@ public sealed class HumanoidAppearanceSystem : SharedHumanoidAppearanceSystem
humanoid.Species = profile.Species; humanoid.Species = profile.Species;
humanoid.SkinColor = profile.Appearance.SkinColor; humanoid.SkinColor = profile.Appearance.SkinColor;
humanoid.EyeColor = profile.Appearance.EyeColor; humanoid.EyeColor = profile.Appearance.EyeColor;
humanoid.Height = profile.Height; // CD - Character Records
UpdateSprite(humanoid, Comp<SpriteComponent>(uid)); UpdateSprite(humanoid, Comp<SpriteComponent>(uid));
} }

View File

@ -67,6 +67,15 @@
<Control HorizontalExpand="True"/> <Control HorizontalExpand="True"/>
<LineEdit Name="AgeEdit" MinSize="40 0" HorizontalAlignment="Right" /> <LineEdit Name="AgeEdit" MinSize="40 0" HorizontalAlignment="Right" />
</BoxContainer> </BoxContainer>
<!-- Begin CD - Character Records -->
<BoxContainer HorizontalExpand="True" Visible="False"> <!-- DeltaV - we haven't decided on height yet -->
<Label Text="{Loc 'humanoid-profile-editor-height-label'}" />
<Control HorizontalExpand="True" />
<Slider Name="CDHeightSlider" HorizontalAlignment="Right" SetWidth="300" MinValue="0.0" MaxValue="1.0"/>
<LineEdit HorizontalAlignment="Right" Name="CDHeight" MinSize="60 0" Text="1.0" />
<Button Name="CDHeightReset" Text="{Loc 'humanoid-profile-editor-reset-height-button'}" HorizontalAlignment="Right"/>
</BoxContainer>
<!-- End CD - Character Records -->
<!-- Sex --> <!-- Sex -->
<BoxContainer HorizontalExpand="True"> <BoxContainer HorizontalExpand="True">
<Label Text="{Loc 'humanoid-profile-editor-sex-label'}" /> <Label Text="{Loc 'humanoid-profile-editor-sex-label'}" />

View File

@ -33,6 +33,11 @@ using Robust.Shared.Enums;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
using Robust.Shared.Utility; using Robust.Shared.Utility;
using Direction = Robust.Shared.Maths.Direction; using Direction = Robust.Shared.Maths.Direction;
// Begin CD - Character Records
using System.Globalization;
using Content.Client._CD.Records.UI;
using Content.Shared._CD.Records;
// End CD - Character Records
namespace Content.Client.Lobby.UI namespace Content.Client.Lobby.UI
{ {
@ -96,6 +101,12 @@ namespace Content.Client.Lobby.UI
private bool _isDirty; private bool _isDirty;
// Begin CD - Station Records
private float _defaultHeight = 1f;
private readonly RecordEditorGui _recordsTab;
// End CD - Station Records
[ValidatePrototypeId<GuideEntryPrototype>] [ValidatePrototypeId<GuideEntryPrototype>]
private const string DefaultSpeciesGuidebook = "Species"; private const string DefaultSpeciesGuidebook = "Species";
@ -220,6 +231,43 @@ namespace Content.Client.Lobby.UI
OnSkinColorOnValueChanged(); OnSkinColorOnValueChanged();
}; };
// Begin CD - Character Records
#region CDHeight
CDHeight.OnTextChanged += args =>
{
if (Profile is null || !float.TryParse(args.Text, out var newHeight))
return;
var prototype = _prototypeManager.Index<SpeciesPrototype>(Profile.Species);
newHeight = MathF.Round(Math.Clamp(newHeight, prototype.MinHeight, prototype.MaxHeight), 2);
// The percentage between the start and end numbers, aka "inverse lerp"
var sliderPercent = (newHeight - prototype.MinHeight) /
(prototype.MaxHeight - prototype.MinHeight);
CDHeightSlider.Value = sliderPercent;
SetProfileHeight(newHeight);
};
CDHeightReset.OnPressed += _ =>
{
CDHeight.SetText(_defaultHeight.ToString(CultureInfo.InvariantCulture), true);
};
CDHeightSlider.OnValueChanged += _ =>
{
if (Profile is null)
return;
var prototype = _prototypeManager.Index<SpeciesPrototype>(Profile.Species);
var newHeight = MathF.Round(MathHelper.Lerp(prototype.MinHeight, prototype.MaxHeight, CDHeightSlider.Value), 2);
CDHeight.Text = newHeight.ToString(CultureInfo.InvariantCulture);
SetProfileHeight(newHeight);
};
#endregion CDHeight
// End CD - Character Records
#region Skin #region Skin
Skin.OnValueChanged += _ => Skin.OnValueChanged += _ =>
@ -413,6 +461,16 @@ namespace Content.Client.Lobby.UI
#endregion Markings #endregion Markings
// Begin CD - Character Records
#region CosmaticRecords
_recordsTab = new RecordEditorGui(UpdateProfileRecords);
TabContainer.AddChild(_recordsTab);
TabContainer.SetTabTitle(TabContainer.ChildCount - 1, Loc.GetString("humanoid-profile-editor-cd-records-tab"));
#endregion CosmaticRecords
// End CD - Character Records
RefreshFlavorText(); RefreshFlavorText();
#region Dummy #region Dummy
@ -754,6 +812,11 @@ namespace Content.Client.Lobby.UI
UpdateCMarkingsHair(); UpdateCMarkingsHair();
UpdateCMarkingsFacialHair(); UpdateCMarkingsFacialHair();
// Begin CD - Character Records
UpdateHeightControls();
_recordsTab.Update(profile);
// End CD - Character Records
RefreshAntags(); RefreshAntags();
RefreshJobs(); RefreshJobs();
RefreshLoadouts(); RefreshLoadouts();
@ -1050,6 +1113,16 @@ namespace Content.Client.Lobby.UI
UpdateJobPriorities(); UpdateJobPriorities();
} }
// Start CD - Character Records
private void UpdateProfileRecords(PlayerProvidedCharacterRecords records)
{
if (Profile is null)
return;
Profile = Profile.WithCDCharacterRecords(records);
IsDirty = true;
}
// End CD - Character Records
private void OnFlavorTextChange(string content) private void OnFlavorTextChange(string content)
{ {
if (Profile is null) if (Profile is null)
@ -1231,6 +1304,15 @@ namespace Content.Client.Lobby.UI
_entManager.System<MetaDataSystem>().SetEntityName(PreviewDummy, newName); _entManager.System<MetaDataSystem>().SetEntityName(PreviewDummy, newName);
} }
// Begin CD - Character Records
private void SetProfileHeight(float height)
{
Profile = Profile?.WithHeight(height);
SetDirty();
ReloadProfilePreview();
}
// End CD - Character Records
private void SetSpawnPriority(SpawnPriorityPreference newSpawnPriority) private void SetSpawnPriority(SpawnPriorityPreference newSpawnPriority)
{ {
Profile = Profile?.WithSpawnPriorityPreference(newSpawnPriority); Profile = Profile?.WithSpawnPriorityPreference(newSpawnPriority);
@ -1416,6 +1498,26 @@ namespace Content.Client.Lobby.UI
PronounsButton.SelectId((int) Profile.Gender); PronounsButton.SelectId((int) Profile.Gender);
} }
// Begin CD - Character Records
private void UpdateHeightControls()
{
if (Profile == null)
{
return;
}
var species = _species.Find(x => x.ID == Profile.Species);
if (species != null)
_defaultHeight = species.DefaultHeight;
var prototype = _prototypeManager.Index<SpeciesPrototype>(Profile.Species);
var sliderPercent = (Profile.Height - prototype.MinHeight) /
(prototype.MaxHeight - prototype.MinHeight);
CDHeightSlider.Value = sliderPercent;
CDHeight.Text = Profile.Height.ToString(CultureInfo.InvariantCulture);
}
// End CD - Character Records
private void UpdateSpawnPriorityControls() private void UpdateSpawnPriorityControls()
{ {
if (Profile == null) if (Profile == null)
@ -1558,6 +1660,8 @@ namespace Content.Client.Lobby.UI
var name = HumanoidCharacterProfile.GetName(Profile.Species, Profile.Gender); var name = HumanoidCharacterProfile.GetName(Profile.Species, Profile.Gender);
SetName(name); SetName(name);
UpdateNameEdit(); UpdateNameEdit();
_recordsTab.Update(Profile); // CD - Character Records
} }
private async void ExportImage() private async void ExportImage()

View File

@ -0,0 +1,14 @@
<DefaultWindow xmlns="https://spacestation14.io"
xmlns:cc="clr-namespace:Content.Client.Administration.UI.CustomControls"
Title="{Loc cd-actions-admin-modify-records}"
MinSize="300 300">
<BoxContainer Orientation="Vertical">
<LineEdit Name="EntityEdit" PlaceHolder="Entity" HorizontalExpand="True" />
<OptionButton Name="EntityEntryType" HorizontalExpand="True" />
<LineEdit Name="EntityEntryIndex" PlaceHolder="Index"/>
<BoxContainer Orientation="Horizontal" >
<cc:CommandButton Name="PurgeCommand" Text="{Loc cd-actions-admin-modify-reset}"/>
<cc:CommandButton Name="DelCommand" Text="{Loc cd-actions-admin-modify-del-entry}"/>
</BoxContainer>
</BoxContainer>
</DefaultWindow>

View File

@ -0,0 +1,48 @@
using Content.Shared._CD.Records;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
namespace Content.Client._CD.Admin.UI;
[GenerateTypedNameReferences]
public sealed partial class ModifyCharacterRecords : DefaultWindow
{
public ModifyCharacterRecords()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
foreach (var v in Enum.GetValues<CharacterRecordType>())
{
EntityEntryType.AddItem(v.ToString());
}
EntityEntryType.OnItemSelected += args =>
{
EntityEntryType.SelectId(args.Id);
UpdateCommands();
};
EntityEdit.OnTextChanged += _ => UpdateCommands();
EntityEntryIndex.OnTextChanged += _ => UpdateCommands();
}
private void UpdateCommands()
{
if (!int.TryParse(EntityEdit.Text, out var uid))
{
return;
}
if (!int.TryParse(EntityEntryIndex.Text, out var idx))
{
return;
}
var ty = (CharacterRecordType)EntityEntryType.SelectedId;
PurgeCommand.Command = $"purgecharacterrecords {uid}";
DelCommand.Command = $"delrecordentry {uid} {ty.ToString()} {idx}";
}
}

View File

@ -0,0 +1,75 @@
using Content.Shared.CriminalRecords.Components;
using Content.Shared.CriminalRecords;
using Content.Shared.StationRecords;
using Content.Shared._CD.Records;
using JetBrains.Annotations;
namespace Content.Client._CD.Records.UI;
[UsedImplicitly]
public sealed class CharacterRecordConsoleBoundUserInterface(EntityUid owner, Enum key) : BoundUserInterface(owner, key)
{
[ViewVariables] private CharacterRecordViewer? _window;
protected override void UpdateState(BoundUserInterfaceState baseState)
{
base.UpdateState(baseState);
if (baseState is not CharacterRecordConsoleState state)
return;
if (_window?.IsSecurity() ?? false)
{
var comp = EntMan.GetComponent<CriminalRecordsConsoleComponent>(Owner);
_window!.SecurityWantedStatusMaxLength = comp.MaxStringLength;
}
_window?.UpdateState(state);
}
protected override void Open()
{
base.Open();
_window = new();
_window.OnClose += Close;
_window.OnListingItemSelected += meta =>
{
SendMessage(new CharacterRecordConsoleSelectMsg(meta?.CharacterRecordKey));
// If we are a security records console, we also need to inform the criminal records
// system of our state.
if (_window.IsSecurity() && meta?.StationRecordKey != null)
{
SendMessage(new SelectStationRecord(meta.Value.StationRecordKey.Value));
_window.SetSecurityStatusEnabled(true);
}
else
{
// If the user does not have criminal records for some reason, we should not be able
// to set their wanted status
_window.SetSecurityStatusEnabled(false);
}
};
_window.OnFiltersChanged += (ty, txt) =>
{
SendMessage(txt == null
? new CharacterRecordsConsoleFilterMsg(null)
: new CharacterRecordsConsoleFilterMsg(new StationRecordsFilter(ty, txt)));
};
_window.OnSetSecurityStatus += (status, reason) =>
{
SendMessage(new CriminalRecordChangeStatus(status, reason));
};
_window.OpenCentered();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_window?.Close();
}
}

View File

@ -0,0 +1,117 @@
<controls:FancyWindow xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
xmlns:records="clr-namespace:Content.Client._CD.Records.UI"
MinSize="850 750"
SetSize="850 750" >
<BoxContainer Orientation="Vertical">
<!-- Search bar -->
<BoxContainer Margin="5 5 5 10" HorizontalExpand="true" VerticalAlignment="Center">
<OptionButton Name="RecordFilterType" MinWidth="200" Margin="0 0 10 0" Visible="False"/>
<!-- Yes, we do steal some localizations, should be fine -->
<LineEdit Name="RecordFiltersValue"
PlaceHolder="{Loc 'general-station-record-for-filter-line-placeholder'}" HorizontalExpand="True"/>
<Button Name="RecordFilters" Text="{Loc 'general-station-record-console-search-records'}"/>
<Button Name="RecordFiltersReset" Text="{Loc 'general-station-record-console-reset-filters'}"/>
</BoxContainer>
<BoxContainer VerticalExpand="True">
<!-- Character listing -->
<BoxContainer Orientation="Vertical" Margin="5" MinWidth="250" MaxWidth="250">
<Label Name="CharacterListingStatus" Visible="False" />
<ScrollContainer VerticalExpand="True">
<ItemList Name="CharacterListing" />
</ScrollContainer>
</BoxContainer>
<!-- Record box -->
<BoxContainer Orientation="Vertical" HorizontalExpand="True" Margin="5 5 10 10">
<Label Name="RecordContainerStatus" Visible="False" Text="{Loc 'cd-record-viewer-no-record-selected'}"/>
<BoxContainer Name="RecordContainer" Orientation="Vertical" Visible="False" >
<BoxContainer Orientation="Vertical" HorizontalExpand="True">
<!-- Common -->
<BoxContainer Orientation="Vertical" HorizontalExpand="True">
<Label Name="RecordContainerName" StyleClasses="LabelBig" />
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'cd-character-records-viewer-record-age'}"/>
<Control HorizontalExpand="True" />
<Label Name="RecordContainerAge" Align="Right" />
</BoxContainer>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'cd-character-records-viewer-record-job'}"/>
<Control HorizontalExpand="True" />
<Label Name="RecordContainerJob" Align="Right" />
</BoxContainer>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'cd-character-records-viewer-record-gender'}"/>
<Control HorizontalExpand="True" />
<Label Name="RecordContainerGender" Align="Right" />
</BoxContainer>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'cd-character-records-viewer-record-species'}"/>
<Control HorizontalExpand="True" />
<Label Name="RecordContainerSpecies" Align="Right" />
</BoxContainer>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-height'}"/>
<Control HorizontalExpand="True" />
<Label Name="RecordContainerHeight" Align="Right" />
</BoxContainer>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-weight'}"/>
<Control HorizontalExpand="True" />
<Label Name="RecordContainerWeight" Align="Right"/>
</BoxContainer>
<records:RecordLongItemDisplay Name="RecordContainerContactName" Title="{Loc 'humanoid-profile-editor-cd-records-contact-name'}"/>
</BoxContainer>
<!-- Employment -->
<BoxContainer Name="RecordContainerEmployment" Orientation="Vertical" HorizontalExpand="True" Visible="False" >
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-work-authorization'}"/>
<Control HorizontalExpand="True" />
<Label Name="RecordContainerWorkAuth" Align="Right" />
</BoxContainer>
</BoxContainer>
<!-- Medical -->
<BoxContainer Name="RecordContainerMedical" Orientation="Vertical" HorizontalExpand="True" Visible="False" >
<records:RecordLongItemDisplay Name="RecordContainerAllergies" Title="{Loc 'humanoid-profile-editor-cd-records-allergies'}"/>
<records:RecordLongItemDisplay Name="RecordContainerDrugAllergies" Title="{Loc 'humanoid-profile-editor-cd-records-drug-allergies'}"/>
<records:RecordLongItemDisplay Name="RecordContainerPostmortem" Title="{Loc 'humanoid-profile-editor-cd-records-postmortem'}"/>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'cd-character-records-viewer-record-med-sex'}"/>
<Control HorizontalExpand="True" />
<Label Name="RecordContainerSex" Align="Right" />
</BoxContainer>
</BoxContainer>
<!-- Security -->
<BoxContainer Name="RecordContainerSecurity" Orientation="Vertical" HorizontalExpand="True" Visible="False" >
<records:RecordLongItemDisplay Name="RecordContainerIdentFeatures" Title="{Loc 'humanoid-profile-editor-cd-records-identifying-features'}"/>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'cd-character-records-viewer-record-sec-fingerprint'}"/>
<Control HorizontalExpand="True" />
<Label Name="RecordContainerFingerprint" Align="Right" />
</BoxContainer>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'cd-character-records-viewer-record-sec-dna'}"/>
<Control HorizontalExpand="True" />
<Label Name="RecordContainerDNA" Align="Right" />
</BoxContainer>
<BoxContainer Orientation="Horizontal" Margin="5 5 5 5">
<Label Text="{Loc 'criminal-records-console-status'}" FontColorOverride="DarkGray"/>
<OptionButton Name="StatusOptionButton"/>
<Control MinWidth="5"/>
<Label Name="RecordContainerWantedReason" Visible="False" />
</BoxContainer>
</BoxContainer>
</BoxContainer>
<!-- Entry viewer -->
<BoxContainer Orientation="Vertical" HorizontalExpand="True" Margin="10" SeparationOverride="5">
<ItemList Name="RecordEntryList" HorizontalExpand="True" MinHeight="200" />
<BoxContainer Orientation="Horizontal">
<Button Name="RecordEntryViewButton" Text="{Loc 'cd-character-records-viewer-view-entry'}"/>
<!-- Admin console entry type selector -->
<OptionButton Name="RecordEntryViewType" />
</BoxContainer>
</BoxContainer>
</BoxContainer>
</BoxContainer>
</BoxContainer>
</BoxContainer>
</controls:FancyWindow>

View File

@ -0,0 +1,450 @@
using Content.Client.UserInterface.Controls;
using Content.Shared.Administration;
using Content.Shared.Security;
using Content.Shared.StationRecords;
using Content.Shared._CD.Records;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.XAML;
using System.Linq;
namespace Content.Client._CD.Records.UI;
[GenerateTypedNameReferences]
public sealed partial class CharacterRecordViewer : FancyWindow
{
public struct CharacterListMetadata
{
public uint CharacterRecordKey;
public uint? StationRecordKey;
}
public static readonly Color BackgroundColor = Color.FromHex("#25252a"); // Dark grey
public static readonly Color ContentPanelColor = Color.FromHex("#1a1a1a"); // Darker grey for content areas
public static readonly Color BorderColor = Color.FromHex("#404040"); // Light grey border
public static readonly Color ErrorColor = Color.FromHex("#ff0000"); // Red for validation errors
public event Action<CharacterListMetadata?>? OnListingItemSelected;
public event Action<StationRecordFilterType, string?>? OnFiltersChanged;
private bool _isPopulating;
private StationRecordFilterType _filterType;
private RecordConsoleType? _type;
private readonly RecordEntryViewPopup _entryView = new();
private List<PlayerProvidedCharacterRecords.RecordEntry>? _entries;
private DialogWindow? _wantedReasonDialog;
/// <summary>
/// The key to the record of the currently selected item in the listing.
/// </summary>
private uint? _selectedListingKey;
/// <summary>
/// The key to the record that is currently visible.
/// </summary>
/// <remarks>
/// This may differ from <see cref="_selectedListingKey"/> because this contents has not been updated yet to reflect the new selection.
/// </remarks>
private uint? _openRecordKey;
public event Action<SecurityStatus, string?>? OnSetSecurityStatus;
public uint? SecurityWantedStatusMaxLength;
public CharacterRecordViewer()
{
RobustXamlLoader.Load(this);
// There is no reason why we can't just steal the StationRecordFilter class.
// If wizden adds a new kind of filtering we want to replicate it here.
foreach (var item in Enum.GetValues<StationRecordFilterType>())
{
RecordFilterType.AddItem(GetTypeFilterLocals(item), (int)item);
}
// Again, if wizden changes something about Criminal Records, we want to replicate the
// functionality here.
foreach (var status in Enum.GetValues<SecurityStatus>())
{
var name = Loc.GetString($"criminal-records-status-{status.ToString().ToLower()}");
StatusOptionButton.AddItem(name, (int)status);
}
CharacterListing.OnItemSelected += _ =>
{
if (!CharacterListing.GetSelected().Any())
return;
var selected = CharacterListing.GetSelected().First();
var meta = (CharacterListMetadata)selected.Metadata!;
_selectedListingKey = meta.CharacterRecordKey;
if (!_isPopulating)
OnListingItemSelected?.Invoke(meta);
};
CharacterListing.OnItemDeselected += _ =>
{
// When we populate the records, we clear the contents of the listing.
// This could cause a deselection but we don't want to really deselect because it would
// interrupt what the player is doing.
if (!_isPopulating)
OnListingItemSelected?.Invoke(null);
_selectedListingKey = null;
};
RecordFilters.OnPressed += _ =>
{
OnFiltersChanged?.Invoke(_filterType, RecordFiltersValue.Text);
};
RecordFiltersReset.OnPressed += _ =>
{
OnFiltersChanged?.Invoke(StationRecordFilterType.Name, null);
RecordFiltersValue.Clear();
};
RecordFiltersValue.OnTextEntered += text =>
{
OnFiltersChanged?.Invoke(_filterType, text.Text);
};
RecordFilterType.OnItemSelected += eventArgs =>
{
var type = (StationRecordFilterType)eventArgs.Id;
_filterType = type;
RecordFilterType.SelectId(eventArgs.Id);
};
RecordEntryViewButton.OnPressed += _ =>
{
if (_entries == null || !RecordEntryList.GetSelected().Any())
return;
var idx = RecordEntryList.IndexOf(RecordEntryList.GetSelected().First());
_entryView.SetContents(_entries[idx]);
_entryView.Open();
};
StatusOptionButton.OnItemSelected += args =>
{
var status = (SecurityStatus)args.Id;
// This should reflect SetStatus in CriminalRecordsConsoleWindow.xaml.cs
if (status == SecurityStatus.Wanted || status == SecurityStatus.Suspected)
SetStatusWithReason(status);
else
OnSetSecurityStatus?.Invoke(status, null);
};
OnClose += () => _entryView.Close();
// Admin console entry type selector
RecordEntryViewType.AddItem(Loc.GetString("department-Security"));
RecordEntryViewType.AddItem(Loc.GetString("department-Medical"));
RecordEntryViewType.AddItem(Loc.GetString("humanoid-profile-editor-cd-records-employment"));
RecordEntryViewType.OnItemSelected += args =>
{
if (args.Id == RecordEntryViewType.SelectedId)
return;
RecordEntryViewType.SelectId(args.Id);
// This is a hack to get the server to send us another packet with the new entries
OnFiltersChanged?.Invoke(_filterType, RecordFiltersValue.Text);
};
}
// If we are using wizden's class we might as well use their localization.
private string GetTypeFilterLocals(StationRecordFilterType type)
{
return Loc.GetString($"general-station-record-{type.ToString().ToLower()}-filter");
}
/// <summary>
/// Select the record in the listing for the given key.
/// </summary>
/// <param name="key">The index of the record in the dictionary</param>
private void SelectRecordKey(uint? key)
{
if (_selectedListingKey == key)
return;
_selectedListingKey = key;
_isPopulating = true;
CharacterListing.ClearSelected();
// I wish there was a better way of doing this
if (key != null)
{
foreach (var item in CharacterListing)
{
if (((CharacterListMetadata) item.Metadata!).CharacterRecordKey == key)
{
item.Selected = true;
break;
}
}
}
_isPopulating = false;
}
private bool CharacterListNeedsRepopulating(IReadOnlyDictionary<uint, CharacterRecordConsoleState.CharacterInfo> newKeys)
{
var newCount = newKeys.Count;
if (newCount != CharacterListing.Count)
return true;
// Given that there is the same number of keys in the dictionary as in items in the listing, they are not equal
// if and only if there exists a key in the listing that is not in the dictionary
foreach (var item in CharacterListing)
{
var key = ((CharacterListMetadata)item.Metadata!).CharacterRecordKey;
if (!newKeys.ContainsKey(key))
{
return true;
}
}
return false;
}
public void UpdateState(CharacterRecordConsoleState state)
{
#region Visibility
RecordEntryViewType.Visible = false;
_type = state.ConsoleType;
// Disable listing if we don't have one selected
if (state.CharacterList == null)
{
CharacterListingStatus.Visible = true;
CharacterListing.Visible = false;
CharacterListingStatus.Text = Loc.GetString("cd-record-viewer-empty-state");
RecordContainer.Visible = false;
RecordContainerStatus.Visible = false;
return;
}
CharacterListingStatus.Visible = false;
CharacterListing.Visible = true;
// Enable extended filtering only for admin and security consoles
switch (_type)
{
case RecordConsoleType.Employment:
RecordFilterType.Visible = false;
RecordFilterType.SelectId((int)StationRecordFilterType.Name);
Title = Loc.GetString("cd-character-records-viewer-title-employ");
break;
case RecordConsoleType.Medical:
RecordFilterType.Visible = false;
RecordFilterType.SelectId((int)StationRecordFilterType.Name);
Title = Loc.GetString("cd-character-records-viewer-title-med");
break;
case RecordConsoleType.Security:
RecordFilterType.Visible = true;
Title = Loc.GetString("cd-character-records-viewer-title-sec");
break;
case RecordConsoleType.Admin:
RecordFilterType.Visible = true;
Title = "Admin records console";
RecordEntryViewType.Visible = true;
break;
}
#endregion
#region PopulateListing
if (state.Filter != null)
{
RecordFiltersValue.SetText(state.Filter.Value);
RecordFilterType.SelectId((int) state.Filter.Type);
}
if (CharacterListNeedsRepopulating(state.CharacterList))
{
_isPopulating = true;
CharacterListing.Clear();
// Add the records to the listing in a sorted order. There is probably are faster way of doing this, but
// this is not really a hot code path.
state.CharacterList
// The items in this tuple are as follows: (name of character, CharacterListMetadata)
.Select(r
=> (CharacterName: r.Value.CharacterDisplayName, new CharacterListMetadata() { CharacterRecordKey = r.Key, StationRecordKey = r.Value.StationRecordKey}))
.OrderBy(r => r.Item1)
.ToList()
.ForEach(r => CharacterListing.AddItem(r.Item1, metadata: r.Item2));
_isPopulating = false;
}
SelectRecordKey(state.SelectedIndex);
#endregion
#region FillRecordContainer
// Enable container if we have a record selected
if (state.SelectedRecord == null)
{
RecordContainerStatus.Visible = true;
RecordContainer.Visible = false;
return;
}
RecordContainerStatus.Visible = false;
RecordContainer.Visible = true;
// Do not needlessly reload the record if not needed. This is mainly done to prevent a bug in the admin record viewer.
if (state.SelectedIndex == _openRecordKey)
return;
_openRecordKey = state.SelectedIndex;
var record = state.SelectedRecord!;
var cr = record.PRecords;
// Basic info
RecordContainerName.Text = record.Name;
RecordContainerAge.Text = record.Age.ToString();
RecordContainerJob.Text = record.JobTitle; /* At some point in the future we might want to display the icon */
RecordContainerGender.Text = record.Gender.ToString();
RecordContainerSpecies.Text = record.Species;
RecordContainerHeight.Text = cr.Height + " " + UnitConversion.GetImperialDisplayLength(cr.Height);
RecordContainerWeight.Text = cr.Weight + " " + UnitConversion.GetImperialDisplayMass(cr.Weight);
RecordContainerContactName.SetValue(cr.EmergencyContactName);
RecordContainerEmployment.Visible = false;
RecordContainerMedical.Visible = false;
RecordContainerSecurity.Visible = false;
switch (_type)
{
case RecordConsoleType.Employment:
SetEntries(cr.EmploymentEntries);
UpdateRecordBoxEmployment(record);
break;
case RecordConsoleType.Medical:
SetEntries(cr.MedicalEntries);
UpdateRecordBoxMedical(record);
break;
case RecordConsoleType.Security:
SetEntries(cr.SecurityEntries);
UpdateRecordBoxSecurity(record, state.SelectedSecurityStatus);
break;
case RecordConsoleType.Admin:
UpdateRecordBoxEmployment(record);
UpdateRecordBoxMedical(record);
UpdateRecordBoxSecurity(record, state.SelectedSecurityStatus);
switch ((RecordConsoleType) RecordEntryViewType.SelectedId)
{
case RecordConsoleType.Employment:
SetEntries(cr.EmploymentEntries, true);
break;
case RecordConsoleType.Medical:
SetEntries(cr.MedicalEntries, true);
break;
case RecordConsoleType.Security:
SetEntries(cr.SecurityEntries, true);
break;
}
break;
}
#endregion
}
private void SetEntries(List<PlayerProvidedCharacterRecords.RecordEntry> entries, bool addIndex = false)
{
_entries = entries;
RecordEntryList.Clear();
var i = 0;
foreach (var entry in entries)
{
RecordEntryList.AddItem(addIndex ? $"({i.ToString()}) " + entry.Title : entry.Title);
++i;
}
}
private void UpdateRecordBoxEmployment(FullCharacterRecords record)
{
RecordContainerEmployment.Visible = true;
RecordContainerWorkAuth.Text = record.PRecords.HasWorkAuthorization ? "yes" : "no";
}
private void UpdateRecordBoxMedical(FullCharacterRecords record)
{
RecordContainerMedical.Visible = true;
var cr = record.PRecords;
RecordContainerMedical.Visible = true;
RecordContainerAllergies.SetValue(cr.Allergies);
RecordContainerDrugAllergies.SetValue(cr.DrugAllergies);
RecordContainerPostmortem.SetValue(cr.PostmortemInstructions);
RecordContainerSex.Text = record.Sex.ToString();
}
private void UpdateRecordBoxSecurity(FullCharacterRecords record, (SecurityStatus, string?)? criminal)
{
RecordContainerSecurity.Visible = true;
RecordContainerIdentFeatures.SetValue(record.PRecords.IdentifyingFeatures);
RecordContainerFingerprint.Text = record.Fingerprint ?? Loc.GetString("cd-character-records-viewer-unknown");
RecordContainerDNA.Text = record.DNA ?? Loc.GetString("cd-character-records-viewer-unknown");
RecordContainerWantedReason.Visible = false;
if (criminal != null)
{
var (stat, reason) = criminal.Value;
StatusOptionButton.Select((int)stat);
RecordContainerWantedReason.Text = reason;
RecordContainerWantedReason.Visible = reason != null;
}
}
// This is copied almost verbatim from CriminalRecordsConsoleWindow.xaml.cs
private void SetStatusWithReason(SecurityStatus status)
{
if (_wantedReasonDialog != null)
{
_wantedReasonDialog.MoveToFront();
return;
}
const string field = "reason";
var title = Loc.GetString("criminal-records-status-" + status.ToString().ToLower());
var placeholder = Loc.GetString("cd-character-records-viewer-setwanted-placeholder");
var prompt = Loc.GetString("criminal-records-console-reason");
var entry = new QuickDialogEntry(field, QuickDialogEntryType.LongText, prompt, placeholder);
var entries = new List<QuickDialogEntry>() { entry };
_wantedReasonDialog = new DialogWindow(title, entries);
_wantedReasonDialog.OnConfirmed += responses =>
{
var reason = responses[field];
if (reason.Length < 1 || reason.Length > SecurityWantedStatusMaxLength)
return;
OnSetSecurityStatus?.Invoke(status, reason);
};
_wantedReasonDialog.OnClose += () => { _wantedReasonDialog = null; };
}
public bool IsSecurity()
{
return _type == RecordConsoleType.Security || _type == RecordConsoleType.Admin;
}
public void SetSecurityStatusEnabled(bool setting)
{
for (var i = 0; i < StatusOptionButton.ItemCount; ++i)
{
StatusOptionButton.SetItemDisabled(i, !setting);
}
}
}

View File

@ -0,0 +1,14 @@
<Control xmlns="https://spacestation14.io">
<BoxContainer Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True">
<ItemList Name="EntrySelector" HorizontalExpand="True" VerticalExpand="True" MinSize="300 200" />
<BoxContainer Orientation="Horizontal">
<Button Name="AddButton" StyleClasses="OpenBoth" Text="{Loc 'humanoid-profile-editor-cd-records-add-entry'}" />
<Button Name="EditButton" StyleClasses="OpenBoth" Text="{Loc 'humanoid-profile-editor-cd-records-edit-entry'}" />
<Button Name="ViewButton" StyleClasses="OpenBoth" Text="{Loc 'humanoid-profile-editor-cd-records-view-entry'}" />
<Button Name="RemoveButton" StyleClasses="OpenLeft" Text="{Loc 'humanoid-profile-editor-cd-records-remove-entry'}" />
<Control HorizontalExpand="True" />
<Button Name="UpButton" HorizontalAlignment="Right" StyleClasses="OpenRight" Text="{Loc 'humanoid-profile-editor-cd-records-up'}" />
<Button Name="DownButton" HorizontalAlignment="Right" StyleClasses="OpenBoth" Text="{Loc 'humanoid-profile-editor-cd-records-down'}" />
</BoxContainer>
</BoxContainer>
</Control>

View File

@ -0,0 +1,135 @@
using Content.Shared._CD.Records;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.XAML;
using Robust.Client.UserInterface;
using System.Linq;
namespace Content.Client._CD.Records.UI;
/// <summary>
/// The box that contains the list of entities in the record editor. We create one for each record type
/// </summary>
[GenerateTypedNameReferences]
public sealed partial class RecordEditorEntrySelector : Control
{
private List<PlayerProvidedCharacterRecords.RecordEntry> _entries = new();
public event Action<RecordEditorEntryUpdateArgs>? OnUpdateEntries;
private readonly RecordEntryEditPopup _editPopup = new();
private readonly RecordEntryViewPopup _entryViewPopup = new();
private int _editIdx;
public RecordEditorEntrySelector()
{
RobustXamlLoader.Load(this);
AddButton.OnPressed += _ =>
{
_editIdx = _entries.Count;
_editPopup.SetContents(new PlayerProvidedCharacterRecords.RecordEntry("", "", ""));
_editPopup.Open();
};
EditButton.OnPressed += _ =>
{
if (!EntrySelector.GetSelected().Any())
return;
_editIdx = EntrySelector.IndexOf(EntrySelector.GetSelected().First());
_editPopup.SetContents(_entries[_editIdx]);
_editPopup.Open();
};
ViewButton.OnPressed += _ =>
{
if (!EntrySelector.GetSelected().Any())
return;
var idx = EntrySelector.IndexOf(EntrySelector.GetSelected().First());
_entryViewPopup.SetContents(_entries[idx]);
_entryViewPopup.Open();
};
RemoveButton.OnPressed += _ =>
{
if (!EntrySelector.GetSelected().Any())
return;
// Remove the entry, being careful to set the index correctly
var idx = EntrySelector.IndexOf(EntrySelector.GetSelected().First());
EntrySelector.RemoveAt(idx);
_entries.RemoveAt(idx);
if (idx == _editIdx)
_editPopup.Close();
_editIdx--;
OnUpdateEntries?.Invoke(new RecordEditorEntryUpdateArgs(_entries));
};
UpButton.OnPressed += _ =>
{
if (!EntrySelector.GetSelected().Any())
return;
var idx = EntrySelector.IndexOf(EntrySelector.GetSelected().First());
if (idx < 1)
return;
(_entries[idx], _entries[idx - 1]) = (_entries[idx - 1], _entries[idx]);
(EntrySelector[idx], EntrySelector[idx - 1]) = (EntrySelector[idx - 1], EntrySelector[idx]);
OnUpdateEntries?.Invoke(new RecordEditorEntryUpdateArgs(_entries));
};
DownButton.OnPressed += _ =>
{
if (!EntrySelector.GetSelected().Any())
return;
var idx = EntrySelector.IndexOf(EntrySelector.GetSelected().First());
if (idx >= EntrySelector.Count - 1)
return;
(_entries[idx], _entries[idx + 1]) = (_entries[idx + 1], _entries[idx]);
(EntrySelector[idx], EntrySelector[idx + 1]) = (EntrySelector[idx + 1], EntrySelector[idx]);
OnUpdateEntries?.Invoke(new RecordEditorEntryUpdateArgs(_entries));
};
_editPopup.SaveButton.OnPressed += _ =>
{
if (_editIdx >= _entries.Count)
{
var rec = _editPopup.GetContents();
_entries.Add(rec);
EntrySelector.AddItem(rec.Title);
}
else
{
_entries[_editIdx] = _editPopup.GetContents();
EntrySelector[_editIdx].Text = _entries[_editIdx].Title;
}
OnUpdateEntries?.Invoke(new RecordEditorEntryUpdateArgs(_entries));
};
OnVisibilityChanged += _ =>
{
_editPopup.Close();
};
}
public void UpdateContents(List<PlayerProvidedCharacterRecords.RecordEntry> entries)
{
_entries = entries;
RefreshSelector();
}
private void RefreshSelector()
{
EntrySelector.Clear();
foreach (var entry in _entries)
{
EntrySelector.AddItem(entry.Title);
}
}
public sealed class RecordEditorEntryUpdateArgs
{
public List<PlayerProvidedCharacterRecords.RecordEntry> Entries { get; private set; }
public RecordEditorEntryUpdateArgs(List<PlayerProvidedCharacterRecords.RecordEntry> entries)
{
Entries = entries;
}
}
}

View File

@ -0,0 +1,68 @@
<Control xmlns="https://spacestation14.io"
xmlns:cdrecords="clr-namespace:Content.Client._CD.Records.UI">
<BoxContainer Orientation="Vertical">
<ScrollContainer VerticalExpand="True">
<BoxContainer Orientation="Vertical" HorizontalExpand="True" Margin="10">
<!-- Height, Weight -->
<GridContainer Columns="2">
<BoxContainer HorizontalExpand="True" SeparationOverride="2">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-height'}" />
<Control HorizontalExpand="True" MinSize="5 0" />
<LineEdit Name="HeightEdit" HorizontalAlignment="Right" MinSize="60 0" />
<Label Name="HeightImperialLabel" MinWidth="60" />
</BoxContainer>
<BoxContainer HorizontalExpand="True" SeparationOverride="2">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-weight'}" />
<Control HorizontalExpand="True" MinSize="5 0" />
<LineEdit Name="WeightEdit" HorizontalAlignment="Right" MinSize="60 0" />
<Label Name="WeightImperialLabel" MinWidth="70"/>
</BoxContainer>
</GridContainer>
<!-- Emergency Contact -->
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-contact-name'}" />
<Control HorizontalExpand="True" />
<LineEdit Name="ContactNameEdit" HorizontalAlignment="Right" MinSize="350 0"/>
</BoxContainer>
<Control MinSize="0 20"/>
<!-- Employment stuff -->
<BoxContainer HorizontalExpand="True">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-work-authorization'}" />
<Control HorizontalExpand="True" MinSize="5 0" />
<CheckBox Name="WorkAuthCheckBox" HorizontalAlignment="Right"/>
</BoxContainer>
<Control MinSize="0 20"/>
<!-- Security stuff -->
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-identifying-features'}" />
<Control HorizontalExpand="True" />
<LineEdit Name="IdentifyingFeaturesEdit" HorizontalAlignment="Right" MinSize="350 0"/>
</BoxContainer>
<Control MinSize="0 20"/>
<!-- Medical stuff -->
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-allergies'}" />
<Control HorizontalExpand="True" />
<LineEdit Name="AllergiesEdit" HorizontalAlignment="Right" MinSize="350 0"/>
</BoxContainer>
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-drug-allergies'}" />
<Control HorizontalExpand="True" />
<LineEdit Name="DrugAllergiesEdit" HorizontalAlignment="Right" MinSize="350 0"/>
</BoxContainer>
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'humanoid-profile-editor-cd-records-postmortem'}" />
<Control HorizontalExpand="True" />
<LineEdit Name="PostmortemEdit" HorizontalAlignment="Right" MinSize="350 0"/>
</BoxContainer>
<Control MinSize="0 20"/>
<!-- Entry editor -->
<TabContainer Name="EntryEditorTabs" VerticalExpand="True" HorizontalExpand="True" Margin="10">
<cdrecords:RecordEditorEntrySelector Name="EmploymentEntrySelector"/>
<cdrecords:RecordEditorEntrySelector Name="MedicalEntrySelector"/>
<cdrecords:RecordEditorEntrySelector Name="SecurityEntrySelector"/>
</TabContainer>
</BoxContainer>
</ScrollContainer>
</BoxContainer>
</Control>

View File

@ -0,0 +1,155 @@
using Content.Shared.Preferences;
using Content.Shared._CD.Records;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.XAML;
using Robust.Client.UserInterface;
namespace Content.Client._CD.Records.UI;
/// <summary>
/// The record editor tab that gets "injected" into the character editor.
/// </summary>
[GenerateTypedNameReferences]
public sealed partial class RecordEditorGui : Control
{
/// <summary>
/// Delegate that tells the editor to save records when the save button is pressed
/// </summary>
private readonly Action<PlayerProvidedCharacterRecords> _updateProfileRecords;
private PlayerProvidedCharacterRecords _records = default!;
public RecordEditorGui(Action<PlayerProvidedCharacterRecords> updateProfileRecords)
{
RobustXamlLoader.Load(this);
_updateProfileRecords = updateProfileRecords;
#region General
HeightEdit.OnTextChanged += args =>
{
if (!int.TryParse(args.Text, out var newHeight))
return;
UpdateImperialHeight(newHeight);
UpdateRecords(_records.WithHeight(newHeight));
};
WeightEdit.OnTextChanged += args =>
{
if (!int.TryParse(args.Text, out var newWeight))
return;
UpdateImperialWeight(newWeight);
UpdateRecords(_records.WithWeight(newWeight));
};
ContactNameEdit.OnTextChanged += args =>
{
UpdateRecords(_records.WithContactName(args.Text));
};
#endregion
#region Employment
WorkAuthCheckBox.OnToggled += args =>
{
UpdateRecords(_records.WithWorkAuth(args.Pressed));
};
#endregion
#region Security
IdentifyingFeaturesEdit.OnTextChanged += args =>
{
UpdateRecords(_records.WithIdentifyingFeatures(args.Text));
};
#endregion
#region Medical
AllergiesEdit.OnTextChanged += args =>
{
UpdateRecords(_records.WithAllergies(args.Text));
};
DrugAllergiesEdit.OnTextChanged += args =>
{
UpdateRecords(_records.WithDrugAllergies(args.Text));
};
PostmortemEdit.OnTextChanged += args =>
{
UpdateRecords(_records.WithPostmortemInstructions(args.Text));
};
#endregion
#region Entries
EntryEditorTabs.SetTabTitle(0, Loc.GetString("humanoid-profile-editor-cd-records-employment"));
EntryEditorTabs.SetTabTitle(1, Loc.GetString("department-Medical"));
EntryEditorTabs.SetTabTitle(2, Loc.GetString("department-Security"));
EmploymentEntrySelector.OnUpdateEntries += args =>
{
UpdateRecords(_records.WithEmploymentEntries(args.Entries));
};
MedicalEntrySelector.OnUpdateEntries += args =>
{
UpdateRecords(_records.WithMedicalEntries(args.Entries));
};
SecurityEntrySelector.OnUpdateEntries += args =>
{
UpdateRecords(_records.WithSecurityEntries(args.Entries));
};
#endregion
}
public void Update(HumanoidCharacterProfile? profile)
{
_records = profile?.CDCharacterRecords ?? PlayerProvidedCharacterRecords.DefaultRecords();
EmploymentEntrySelector.UpdateContents(_records.EmploymentEntries);
MedicalEntrySelector.UpdateContents(_records.MedicalEntries);
SecurityEntrySelector.UpdateContents(_records.SecurityEntries);
UpdateWidgets();
}
private void UpdateRecords(PlayerProvidedCharacterRecords records)
{
records.EnsureValid();
_records = records;
_updateProfileRecords(_records);
UpdateWidgets();
}
private void UpdateWidgets()
{
HeightEdit.SetText(_records.Height.ToString());
UpdateImperialHeight(_records.Height);
WeightEdit.SetText(_records.Weight.ToString());
UpdateImperialWeight(_records.Weight);
ContactNameEdit.SetText(_records.EmergencyContactName);
WorkAuthCheckBox.Pressed = _records.HasWorkAuthorization;
IdentifyingFeaturesEdit.SetText(_records.IdentifyingFeatures);
AllergiesEdit.SetText(_records.Allergies);
DrugAllergiesEdit.SetText(_records.DrugAllergies);
PostmortemEdit.SetText(_records.PostmortemInstructions);
}
private void UpdateImperialHeight(int newHeight)
{
HeightImperialLabel.Text = UnitConversion.GetImperialDisplayLength(newHeight);
}
private void UpdateImperialWeight(int newWeight)
{
WeightImperialLabel.Text = UnitConversion.GetImperialDisplayMass(newWeight);
}
}

View File

@ -0,0 +1,36 @@
<controls:FancyWindow xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
MinSize="500 700"
SetSize="600 700"
Title="{Loc 'cd-records-entry-edit-popup-title'}">
<BoxContainer Margin="5 2 5 5" Orientation="Vertical" HorizontalExpand="True" SeparationOverride="2">
<!-- Header section -->
<PanelContainer StyleClasses="AngleRect">
<BoxContainer Orientation="Vertical" HorizontalExpand="True" Margin="4" SeparationOverride="8">
<LineEdit Name="TitleEdit" HorizontalExpand="True" PlaceHolder="{Loc 'cd-records-entry-edit-popup-title-placeholder'}"/>
<LineEdit Name="InvolvedEdit" HorizontalExpand="True" PlaceHolder="{Loc 'cd-records-entry-edit-popup-involved-placeholder'}"/>
</BoxContainer>
</PanelContainer>
<!-- Content section with styled background -->
<PanelContainer Name="ContentPanel" VerticalExpand="True">
<TextEdit Name="DescriptionEdit"
HorizontalExpand="True"
VerticalExpand="True"
Margin="8 4 8 8"/>
</PanelContainer>
<!-- Save button and validation section -->
<BoxContainer Orientation="Vertical" HorizontalExpand="True" SeparationOverride="4">
<PanelContainer StyleClasses="AngleRect">
<Button Access="Public"
Name="SaveButton"
Text="{Loc 'cd-records-entry-edit-popup-save'}"
Margin="4"/>
</PanelContainer>
<Label Name="ValidationLabel"
HorizontalExpand="True"
HorizontalAlignment="Center"/>
</BoxContainer>
</BoxContainer>
</controls:FancyWindow>

View File

@ -0,0 +1,145 @@
using Content.Client.UserInterface.Controls;
using Content.Shared._CD.Records;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Utility;
using System.Linq;
namespace Content.Client._CD.Records.UI;
[GenerateTypedNameReferences]
public sealed partial class RecordEntryEditPopup : FancyWindow
{
private bool _isValid;
private record struct ValidationRule(
Func<bool> IsInvalid,
string LocalizationKey,
(string, object)[]? Parameters = null);
public RecordEntryEditPopup()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
ValidationLabel.FontColorOverride = CharacterRecordViewer.ErrorColor;
ValidationLabel.Visible = false;
// Style the content panel
var styleBox = new StyleBoxFlat
{
BackgroundColor = CharacterRecordViewer.ContentPanelColor,
BorderColor = CharacterRecordViewer.BorderColor,
BorderThickness = new Thickness(1),
};
ContentPanel.PanelOverride = styleBox;
SetupEventHandlers();
}
private void SetupEventHandlers()
{
TitleEdit.OnTextChanged += args =>
{
if (args.Text.Length > PlayerProvidedCharacterRecords.TextMedLen)
{
TitleEdit.Text = args.Text[..PlayerProvidedCharacterRecords.TextMedLen];
}
ValidateFields();
};
InvolvedEdit.OnTextChanged += args =>
{
if (args.Text.Length > PlayerProvidedCharacterRecords.TextMedLen)
{
InvolvedEdit.Text = args.Text[..PlayerProvidedCharacterRecords.TextMedLen];
}
ValidateFields();
};
DescriptionEdit.OnTextChanged += _ => ValidateFields();
SaveButton.OnPressed += _ =>
{
if (!_isValid)
return;
Close();
};
DescriptionEdit.Placeholder =
new Rope.Leaf(Loc.GetString("cd-records-entry-edit-popup-description-placeholder"));
TitleEdit.PlaceHolder = Loc.GetString("cd-records-entry-edit-popup-title-placeholder");
InvolvedEdit.PlaceHolder = Loc.GetString("cd-records-entry-edit-popup-involved-placeholder");
}
private void ValidateFields()
{
var descriptionText = Rope.Collapse(DescriptionEdit.TextRope);
var descriptionLength = descriptionText.Length;
// Validation rules in priority order
// Overcomplicated into oblivion just because I didn't like how the else if statements looked
var rules = new[]
{
new ValidationRule(
() => string.IsNullOrWhiteSpace(TitleEdit.Text),
"cd-records-entry-edit-popup-title-required"),
new ValidationRule(
() => string.IsNullOrWhiteSpace(InvolvedEdit.Text),
"cd-records-entry-edit-popup-involved-required"),
new ValidationRule(
() => string.IsNullOrWhiteSpace(descriptionText),
"cd-records-entry-edit-popup-description-required"),
new ValidationRule(
() => descriptionLength > PlayerProvidedCharacterRecords.TextVeryLargeLen,
"cd-records-entry-edit-popup-description-too-long",
new[]
{
("current", (object)descriptionLength),
("max", PlayerProvidedCharacterRecords.TextVeryLargeLen)
}),
};
// Find first failing validation rule
var failedRule = rules.FirstOrDefault(rule => rule.IsInvalid());
if (failedRule.IsInvalid != null)
{
ValidationLabel.Text = failedRule.Parameters != null
? Loc.GetString(failedRule.LocalizationKey, failedRule.Parameters)
: Loc.GetString(failedRule.LocalizationKey);
ValidationLabel.Visible = true;
_isValid = false;
}
else
{
ValidationLabel.Visible = false;
_isValid = true;
}
SaveButton.Disabled = !_isValid;
}
public PlayerProvidedCharacterRecords.RecordEntry GetContents()
{
var desc = Rope.Collapse(DescriptionEdit.TextRope).Trim();
return new PlayerProvidedCharacterRecords.RecordEntry(
TitleEdit.Text.Trim(),
InvolvedEdit.Text.Trim(),
desc);
}
public void SetContents(PlayerProvidedCharacterRecords.RecordEntry entry)
{
TitleEdit.Text = entry.Title;
InvolvedEdit.Text = entry.Involved;
DescriptionEdit.TextRope = new Rope.Leaf(entry.Description);
ValidateFields();
}
}

View File

@ -0,0 +1,38 @@
<controls:FancyWindow xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
MinSize="500 700"
SetSize="600 700"
Title="{Loc 'cd-records-entry-view-popup-title'}">
<BoxContainer Margin="5 2 5 5" Orientation="Vertical" HorizontalExpand="True" SeparationOverride="2">
<!-- Header section -->
<PanelContainer StyleClasses="AngleRect">
<BoxContainer Orientation="Vertical" HorizontalExpand="True" Margin="4">
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'cd-character-records-entry-view-title'}" StyleClasses="LabelBig"/>
<Control MinWidth="5"/>
<RichTextLabel HorizontalAlignment="Right" Name="EntryTitle" HorizontalExpand="True" StyleClasses="LabelBig" />
</BoxContainer>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'cd-character-records-entry-view-involved'}" StyleClasses="LabelBig"/>
<Control MinWidth="5"/>
<RichTextLabel HorizontalAlignment="Right" Name="EntryInvolved" HorizontalExpand="True" StyleClasses="LabelBig"/>
</BoxContainer>
</BoxContainer>
</PanelContainer>
<!-- Content section with styled background -->
<PanelContainer Name="ContentPanel" VerticalExpand="True">
<ScrollContainer HorizontalExpand="True"
VerticalExpand="True"
HScrollEnabled="False">
<BoxContainer Orientation="Vertical"
HorizontalExpand="True"
Margin="8 4 8 8">
<RichTextLabel Name="EntryDesc"
HorizontalExpand="True"
Margin="4"/>
</BoxContainer>
</ScrollContainer>
</PanelContainer>
</BoxContainer>
</controls:FancyWindow>

View File

@ -0,0 +1,46 @@
using Content.Client.UserInterface.Controls;
using Content.Shared._CD.Records;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.RichText;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Utility;
namespace Content.Client._CD.Records.UI;
[GenerateTypedNameReferences]
public sealed partial class RecordEntryViewPopup : FancyWindow
{
// Font tags bad
private static readonly Type[] AllowedTags =
[
typeof(BoldItalicTag),
typeof(BoldTag),
typeof(BulletTag),
typeof(ColorTag),
typeof(HeadingTag),
typeof(ItalicTag),
];
public RecordEntryViewPopup()
{
RobustXamlLoader.Load(this);
// Create a styled box for the content panel
var styleBox = new StyleBoxFlat
{
BackgroundColor = CharacterRecordViewer.ContentPanelColor,
BorderColor = CharacterRecordViewer.BorderColor,
BorderThickness = new Thickness(1),
};
ContentPanel.PanelOverride = styleBox;
}
public void SetContents(PlayerProvidedCharacterRecords.RecordEntry entry)
{
EntryTitle.SetMessage(entry.Title);
EntryInvolved.SetMessage(entry.Involved);
EntryDesc.SetMessage(FormattedMessage.FromMarkupPermissive(entry.Description.Trim()), AllowedTags);
}
}

View File

@ -0,0 +1,65 @@
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
namespace Content.Client._CD.Records.UI;
/// <summary>
/// Widget that displays the record on one line if it is short enough, and on two lines if it is
/// too long. This should only be used if you know the length may be long enough to break things when
/// using a normal Label.
/// </summary>
public sealed class RecordLongItemDisplay : BoxContainer
{
private const int MaxShortLength = 32;
public string? Title
{
get => _titleLabel.Text;
set => _titleLabel.Text = value;
}
// Row containing the title and short value
private readonly BoxContainer _firstRow = new()
{
Orientation = LayoutOrientation.Horizontal,
HorizontalExpand = true
};
// Row containing the long value
private readonly BoxContainer _secondRow = new()
{
Orientation = LayoutOrientation.Horizontal,
HorizontalExpand = true,
Visible = false,
};
private readonly Label _titleLabel = new();
private readonly Label _shortContents = new() { Visible = true, Align = Label.AlignMode.Right };
private readonly RichTextLabel _longContents = new() { HorizontalExpand = true };
public RecordLongItemDisplay()
{
Orientation = LayoutOrientation.Vertical;
_firstRow.AddChild(_titleLabel);
_firstRow.AddChild(new Control() { HorizontalExpand = true });
_firstRow.AddChild(_shortContents);
AddChild(_firstRow);
_secondRow.AddChild(new Control() { HorizontalExpand = true, SizeFlagsStretchRatio = 0.15f});
_secondRow.AddChild(_longContents);
AddChild(_secondRow);
}
public void SetValue(string s)
{
if (s.Length > MaxShortLength)
{
_longContents.SetMessage(s);
_secondRow.Visible = true;
_shortContents.Visible = false;
}
else
{
_shortContents.Text = s;
_shortContents.Visible = true;
_secondRow.Visible = false;
}
}
}

View File

@ -0,0 +1,16 @@
namespace Content.Client._CD.Records.UI;
public static class UnitConversion
{
public static string GetImperialDisplayLength(int lengthCm)
{
var heightIn = (int) Math.Round(lengthCm * 0.3937007874 /* cm to in*/);
return $"({heightIn / 12}'{heightIn % 12}'')";
}
public static string GetImperialDisplayMass(int massKg)
{
var weightLbs = (int) Math.Round(massKg * 2.2046226218 /* kg to lbs */);
return $"({weightLbs} lbs)";
}
}

View File

@ -0,0 +1,56 @@
// File to store as much CD related database things outside of Model.cs
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
namespace Content.Server.Database;
public static class CDModel
{
/// <summary>
/// Stores CD Character data separately from the main Profile. This is done to work around a bug
/// in EFCore migrations.
/// <p />
/// There is no way of forcing a dependent table to exist in EFCore (according to MS).
/// You must always account for the possibility of this table not existing.
/// </summary>
public class CDProfile
{
public int Id { get; set; }
public int ProfileId { get; set; }
public Profile Profile { get; set; } = null!;
public float Height { get; set; } = 1f;
[Column("character_records", TypeName = "jsonb")]
public JsonDocument? CharacterRecords { get; set; }
public List<CharacterRecordEntry> CharacterRecordEntries { get; set; } = new();
}
public enum DbRecordEntryType : byte
{
Medical = 0, Security = 1, Employment = 2
}
[Table("cd_character_record_entries"), Index(nameof(Id))]
public sealed class CharacterRecordEntry
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public string Involved { get; set; } = null!;
public string Description { get; set; } = null!;
public DbRecordEntryType Type { get; set; }
public int CDProfileId { get; set; }
public CDProfile CDProfile { get; set; } = null!;
}
}

View File

@ -0,0 +1,86 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Content.Server.Database.Migrations.Postgres
{
/// <inheritdoc />
public partial class CosmaticDriftCharacterRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "cdprofile",
columns: table => new
{
cdprofile_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
profile_id = table.Column<int>(type: "integer", nullable: false),
height = table.Column<float>(type: "real", nullable: false),
character_records = table.Column<JsonDocument>(type: "jsonb", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_cdprofile", x => x.cdprofile_id);
table.ForeignKey(
name: "FK_cdprofile_profile_profile_id",
column: x => x.profile_id,
principalTable: "profile",
principalColumn: "profile_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "cd_character_record_entries",
columns: table => new
{
cd_character_record_entries_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
title = table.Column<string>(type: "text", nullable: false),
involved = table.Column<string>(type: "text", nullable: false),
description = table.Column<string>(type: "text", nullable: false),
type = table.Column<byte>(type: "smallint", nullable: false),
cdprofile_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_cd_character_record_entries", x => x.cd_character_record_entries_id);
table.ForeignKey(
name: "FK_cd_character_record_entries_cdprofile_cdprofile_id",
column: x => x.cdprofile_id,
principalTable: "cdprofile",
principalColumn: "cdprofile_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_cd_character_record_entries_cd_character_record_entries_id",
table: "cd_character_record_entries",
column: "cd_character_record_entries_id");
migrationBuilder.CreateIndex(
name: "IX_cd_character_record_entries_cdprofile_id",
table: "cd_character_record_entries",
column: "cdprofile_id");
migrationBuilder.CreateIndex(
name: "IX_cdprofile_profile_id",
table: "cdprofile",
column: "profile_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "cd_character_record_entries");
migrationBuilder.DropTable(
name: "cdprofile");
}
}
}

View File

@ -578,6 +578,79 @@ namespace Content.Server.Database.Migrations.Postgres
b.ToTable("blacklist", (string)null); b.ToTable("blacklist", (string)null);
}); });
modelBuilder.Entity("Content.Server.Database.CDModel+CDProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("cdprofile_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<JsonDocument>("CharacterRecords")
.HasColumnType("jsonb")
.HasColumnName("character_records");
b.Property<float>("Height")
.HasColumnType("real")
.HasColumnName("height");
b.Property<int>("ProfileId")
.HasColumnType("integer")
.HasColumnName("profile_id");
b.HasKey("Id")
.HasName("PK_cdprofile");
b.HasIndex("ProfileId")
.IsUnique();
b.ToTable("cdprofile", (string)null);
});
modelBuilder.Entity("Content.Server.Database.CDModel+CharacterRecordEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("cd_character_record_entries_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CDProfileId")
.HasColumnType("integer")
.HasColumnName("cdprofile_id");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<string>("Involved")
.IsRequired()
.HasColumnType("text")
.HasColumnName("involved");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<byte>("Type")
.HasColumnType("smallint")
.HasColumnName("type");
b.HasKey("Id")
.HasName("PK_cd_character_record_entries");
b.HasIndex("CDProfileId");
b.HasIndex("Id")
.HasDatabaseName("IX_cd_character_record_entries_cd_character_record_entries_id");
b.ToTable("cd_character_record_entries", (string)null);
});
modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => modelBuilder.Entity("Content.Server.Database.ConnectionLog", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -1656,6 +1729,30 @@ namespace Content.Server.Database.Migrations.Postgres
b.Navigation("Profile"); b.Navigation("Profile");
}); });
modelBuilder.Entity("Content.Server.Database.CDModel+CDProfile", b =>
{
b.HasOne("Content.Server.Database.Profile", "Profile")
.WithOne("CDProfile")
.HasForeignKey("Content.Server.Database.CDModel+CDProfile", "ProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_cdprofile_profile_profile_id");
b.Navigation("Profile");
});
modelBuilder.Entity("Content.Server.Database.CDModel+CharacterRecordEntry", b =>
{
b.HasOne("Content.Server.Database.CDModel+CDProfile", "CDProfile")
.WithMany("CharacterRecordEntries")
.HasForeignKey("CDProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_cd_character_record_entries_cdprofile_cdprofile_id");
b.Navigation("CDProfile");
});
modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => modelBuilder.Entity("Content.Server.Database.ConnectionLog", b =>
{ {
b.HasOne("Content.Server.Database.Server", "Server") b.HasOne("Content.Server.Database.Server", "Server")
@ -2015,6 +2112,11 @@ namespace Content.Server.Database.Migrations.Postgres
b.Navigation("Flags"); b.Navigation("Flags");
}); });
modelBuilder.Entity("Content.Server.Database.CDModel+CDProfile", b =>
{
b.Navigation("CharacterRecordEntries");
});
modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => modelBuilder.Entity("Content.Server.Database.ConnectionLog", b =>
{ {
b.Navigation("BanHits"); b.Navigation("BanHits");
@ -2068,6 +2170,8 @@ namespace Content.Server.Database.Migrations.Postgres
{ {
b.Navigation("Antags"); b.Navigation("Antags");
b.Navigation("CDProfile");
b.Navigation("Jobs"); b.Navigation("Jobs");
b.Navigation("Loadouts"); b.Navigation("Loadouts");

View File

@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Content.Server.Database.Migrations.Sqlite
{
/// <inheritdoc />
public partial class CosmaticDriftCharacterRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "cdprofile",
columns: table => new
{
cdprofile_id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
profile_id = table.Column<int>(type: "INTEGER", nullable: false),
height = table.Column<float>(type: "REAL", nullable: false),
character_records = table.Column<byte[]>(type: "jsonb", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_cdprofile", x => x.cdprofile_id);
table.ForeignKey(
name: "FK_cdprofile_profile_profile_id",
column: x => x.profile_id,
principalTable: "profile",
principalColumn: "profile_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "cd_character_record_entries",
columns: table => new
{
cd_character_record_entries_id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
title = table.Column<string>(type: "TEXT", nullable: false),
involved = table.Column<string>(type: "TEXT", nullable: false),
description = table.Column<string>(type: "TEXT", nullable: false),
type = table.Column<byte>(type: "INTEGER", nullable: false),
cdprofile_id = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_cd_character_record_entries", x => x.cd_character_record_entries_id);
table.ForeignKey(
name: "FK_cd_character_record_entries_cdprofile_cdprofile_id",
column: x => x.cdprofile_id,
principalTable: "cdprofile",
principalColumn: "cdprofile_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_cd_character_record_entries_cd_character_record_entries_id",
table: "cd_character_record_entries",
column: "cd_character_record_entries_id");
migrationBuilder.CreateIndex(
name: "IX_cd_character_record_entries_cdprofile_id",
table: "cd_character_record_entries",
column: "cdprofile_id");
migrationBuilder.CreateIndex(
name: "IX_cdprofile_profile_id",
table: "cdprofile",
column: "profile_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "cd_character_record_entries");
migrationBuilder.DropTable(
name: "cdprofile");
}
}
}

View File

@ -547,6 +547,76 @@ namespace Content.Server.Database.Migrations.Sqlite
b.ToTable("blacklist", (string)null); b.ToTable("blacklist", (string)null);
}); });
modelBuilder.Entity("Content.Server.Database.CDModel+CDProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("cdprofile_id");
b.Property<byte[]>("CharacterRecords")
.HasColumnType("jsonb")
.HasColumnName("character_records");
b.Property<float>("Height")
.HasColumnType("REAL")
.HasColumnName("height");
b.Property<int>("ProfileId")
.HasColumnType("INTEGER")
.HasColumnName("profile_id");
b.HasKey("Id")
.HasName("PK_cdprofile");
b.HasIndex("ProfileId")
.IsUnique();
b.ToTable("cdprofile", (string)null);
});
modelBuilder.Entity("Content.Server.Database.CDModel+CharacterRecordEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("cd_character_record_entries_id");
b.Property<int>("CDProfileId")
.HasColumnType("INTEGER")
.HasColumnName("cdprofile_id");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("Involved")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("involved");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.Property<byte>("Type")
.HasColumnType("INTEGER")
.HasColumnName("type");
b.HasKey("Id")
.HasName("PK_cd_character_record_entries");
b.HasIndex("CDProfileId")
.HasDatabaseName("IX_cd_character_record_entries_cdprofile_id");
b.HasIndex("Id")
.HasDatabaseName("IX_cd_character_record_entries_cd_character_record_entries_id");
b.ToTable("cd_character_record_entries", (string)null);
});
modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => modelBuilder.Entity("Content.Server.Database.ConnectionLog", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -1580,6 +1650,30 @@ namespace Content.Server.Database.Migrations.Sqlite
b.Navigation("Profile"); b.Navigation("Profile");
}); });
modelBuilder.Entity("Content.Server.Database.CDModel+CDProfile", b =>
{
b.HasOne("Content.Server.Database.Profile", "Profile")
.WithOne("CDProfile")
.HasForeignKey("Content.Server.Database.CDModel+CDProfile", "ProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_cdprofile_profile_profile_id");
b.Navigation("Profile");
});
modelBuilder.Entity("Content.Server.Database.CDModel+CharacterRecordEntry", b =>
{
b.HasOne("Content.Server.Database.CDModel+CDProfile", "CDProfile")
.WithMany("CharacterRecordEntries")
.HasForeignKey("CDProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_cd_character_record_entries_cdprofile_cdprofile_id");
b.Navigation("CDProfile");
});
modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => modelBuilder.Entity("Content.Server.Database.ConnectionLog", b =>
{ {
b.HasOne("Content.Server.Database.Server", "Server") b.HasOne("Content.Server.Database.Server", "Server")
@ -1939,6 +2033,11 @@ namespace Content.Server.Database.Migrations.Sqlite
b.Navigation("Flags"); b.Navigation("Flags");
}); });
modelBuilder.Entity("Content.Server.Database.CDModel+CDProfile", b =>
{
b.Navigation("CharacterRecordEntries");
});
modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => modelBuilder.Entity("Content.Server.Database.ConnectionLog", b =>
{ {
b.Navigation("BanHits"); b.Navigation("BanHits");
@ -1992,6 +2091,8 @@ namespace Content.Server.Database.Migrations.Sqlite
{ {
b.Navigation("Antags"); b.Navigation("Antags");
b.Navigation("CDProfile");
b.Navigation("Jobs"); b.Navigation("Jobs");
b.Navigation("Loadouts"); b.Navigation("Loadouts");

View File

@ -57,6 +57,20 @@ namespace Content.Server.Database
.HasIndex(p => new {p.Slot, PrefsId = p.PreferenceId}) .HasIndex(p => new {p.Slot, PrefsId = p.PreferenceId})
.IsUnique(); .IsUnique();
// Begin CD - CD Character Data
modelBuilder.Entity<CDModel.CDProfile>()
.HasOne(p => p.Profile)
.WithOne(p => p.CDProfile)
.HasForeignKey<CDModel.CDProfile>(p => p.ProfileId)
.IsRequired();
modelBuilder.Entity<CDModel.CharacterRecordEntry>()
.HasOne(e => e.CDProfile)
.WithMany(e => e.CharacterRecordEntries)
.HasForeignKey(e => e.CDProfileId)
.IsRequired();
// End CD - CD Character Data
modelBuilder.Entity<Antag>() modelBuilder.Entity<Antag>()
.HasIndex(p => new {HumanoidProfileId = p.ProfileId, p.AntagName}) .HasIndex(p => new {HumanoidProfileId = p.ProfileId, p.AntagName})
.IsUnique(); .IsUnique();
@ -423,6 +437,8 @@ namespace Content.Server.Database
public int PreferenceId { get; set; } public int PreferenceId { get; set; }
public Preference Preference { get; set; } = null!; public Preference Preference { get; set; } = null!;
public CDModel.CDProfile? CDProfile { get; set; } // CD - Character Records
} }
public class Job public class Job

View File

@ -82,6 +82,12 @@ namespace Content.Server.Database
.Property(log => log.Markings) .Property(log => log.Markings)
.HasConversion(jsonByteArrayConverter); .HasConversion(jsonByteArrayConverter);
// Begin CD - Character Records
modelBuilder.Entity<CDModel.CDProfile>()
.Property(log => log.CharacterRecords)
.HasConversion(jsonByteArrayConverter);
// End CD - Character Records
// EF core can make this automatically unique on sqlite but not psql. // EF core can make this automatically unique on sqlite but not psql.
modelBuilder.Entity<IPIntelCache>() modelBuilder.Entity<IPIntelCache>()
.HasIndex(p => p.Address) .HasIndex(p => p.Address)

View File

@ -21,6 +21,8 @@ using Robust.Shared.Enums;
using Robust.Shared.Network; using Robust.Shared.Network;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
using Robust.Shared.Utility; using Robust.Shared.Utility;
using Content.Server._CD.Records; // CD - Character Records
using Content.Shared._CD.Records; // CD - Character Records
namespace Content.Server.Database namespace Content.Server.Database
{ {
@ -48,6 +50,11 @@ namespace Content.Server.Database
.Include(p => p.Profiles).ThenInclude(h => h.Jobs) .Include(p => p.Profiles).ThenInclude(h => h.Jobs)
.Include(p => p.Profiles).ThenInclude(h => h.Antags) .Include(p => p.Profiles).ThenInclude(h => h.Antags)
.Include(p => p.Profiles).ThenInclude(h => h.Traits) .Include(p => p.Profiles).ThenInclude(h => h.Traits)
// Begin CD - Character Records
.Include(p => p.Profiles)
.ThenInclude(h => h.CDProfile)
.ThenInclude(cd => cd != null ? cd.CharacterRecordEntries : null)
// End CD - Character Records
.Include(p => p.Profiles) .Include(p => p.Profiles)
.ThenInclude(h => h.Loadouts) .ThenInclude(h => h.Loadouts)
.ThenInclude(l => l.Groups) .ThenInclude(l => l.Groups)
@ -95,6 +102,8 @@ namespace Content.Server.Database
} }
var oldProfile = db.DbContext.Profile var oldProfile = db.DbContext.Profile
.Include(p => p.CDProfile) // CD - Character Records
.ThenInclude(cd => cd != null ? cd.CharacterRecordEntries : null)
.Include(p => p.Preference) .Include(p => p.Preference)
.Where(p => p.Preference.UserId == userId.UserId) .Where(p => p.Preference.UserId == userId.UserId)
.Include(p => p.Jobs) .Include(p => p.Jobs)
@ -216,6 +225,11 @@ namespace Content.Server.Database
} }
} }
// Begin CD - Chracter Records
var cdRecords = profile.CDProfile?.CharacterRecords != null
? RecordsSerialization.Deserialize(profile.CDProfile.CharacterRecords, profile.CDProfile.CharacterRecordEntries)
: PlayerProvidedCharacterRecords.DefaultRecords();
// End CD - Character Records
var loadouts = new Dictionary<string, RoleLoadout>(); var loadouts = new Dictionary<string, RoleLoadout>();
foreach (var role in profile.Loadouts) foreach (var role in profile.Loadouts)
@ -262,7 +276,9 @@ namespace Content.Server.Database
(PreferenceUnavailableMode) profile.PreferenceUnavailable, (PreferenceUnavailableMode) profile.PreferenceUnavailable,
antags.ToHashSet(), antags.ToHashSet(),
traits.ToHashSet(), traits.ToHashSet(),
loadouts loadouts,
profile.CDProfile?.Height ?? 1.0f, // CD - Character Records
cdRecords // CD - Character Records
); );
} }
@ -313,6 +329,18 @@ namespace Content.Server.Database
.Select(t => new Trait {TraitName = t}) .Select(t => new Trait {TraitName = t})
); );
// Begin CD - Character Records
profile.CDProfile ??= new CDModel.CDProfile();
profile.CDProfile.Height = humanoid.Height;
// There are JsonIgnore annotations to ensure that entries are not stored as JSON.
profile.CDProfile.CharacterRecords = JsonSerializer.SerializeToDocument(humanoid.CDCharacterRecords ?? PlayerProvidedCharacterRecords.DefaultRecords());
if (humanoid.CDCharacterRecords != null)
{
profile.CDProfile.CharacterRecordEntries.Clear();
profile.CDProfile.CharacterRecordEntries.AddRange(RecordsSerialization.GetEntries(humanoid.CDCharacterRecords));
}
// End CD - Character Records
profile.Loadouts.Clear(); profile.Loadouts.Clear();
foreach (var (role, loadouts) in humanoid.Loadouts) foreach (var (role, loadouts) in humanoid.Loadouts)

View File

@ -0,0 +1,17 @@
namespace Content.Server._CD.Records;
/// <summary>
/// Stores the key to the entities character records.
/// </summary>
[RegisterComponent]
[Access(typeof(CharacterRecordsSystem))]
public sealed partial class CharacterRecordKeyStorageComponent : Component
{
[ViewVariables(VVAccess.ReadOnly)]
public CharacterRecordKey Key;
public CharacterRecordKeyStorageComponent(CharacterRecordKey key)
{
Key = key;
}
}

View File

@ -0,0 +1,31 @@
using Content.Shared._CD.Records;
namespace Content.Server._CD.Records;
/// <summary>
/// The component on the station that stores records after the round starts.
/// </summary>
[RegisterComponent]
[Access(typeof(CharacterRecordsSystem))]
public sealed partial class CharacterRecordsComponent : Component
{
[ViewVariables(VVAccess.ReadOnly)]
public Dictionary<uint, FullCharacterRecords> Records = new();
[ViewVariables(VVAccess.ReadOnly)]
private uint _nextKey = 1;
/// <summary>
/// Creates a key has never been used previously
/// </summary>
public uint CreateNewKey()
{
return _nextKey++;
}
}
public sealed record CharacterRecordKey
{
public EntityUid Station { get; init; }
public uint Index { get; init; }
}

View File

@ -0,0 +1,194 @@
using Content.Server.Forensics;
using Content.Server.GameTicking;
using Content.Server.StationRecords.Systems;
using Content.Server.StationRecords;
using Content.Shared.Inventory;
using Content.Shared.PDA;
using Content.Shared.Roles;
using Content.Shared.StationRecords;
using Content.Shared._CD.Records;
using Content.Shared.Forensics.Components;
using Content.Shared.GameTicking;
using Robust.Shared.Prototypes;
namespace Content.Server._CD.Records;
public sealed class CharacterRecordsSystem : EntitySystem
{
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly StationRecordsSystem _records = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<PlayerSpawnCompleteEvent>(OnPlayerSpawn, after: [typeof(StationRecordsSystem)]);
}
private void OnPlayerSpawn(PlayerSpawnCompleteEvent args)
{
if (!HasComp<StationRecordsComponent>(args.Station))
{
Log.Error("Tried to add CharacterRecords on a station without StationRecords");
return;
}
if (!HasComp<CharacterRecordsComponent>(args.Station))
AddComp<CharacterRecordsComponent>(args.Station);
if (string.IsNullOrEmpty(args.JobId))
{
Log.Error($"Null JobId in CharacterRecordsSystem::OnPlayerSpawn for character {args.Profile.Name} played by {args.Player.Name}");
return;
}
if (HasComp<SkipLoadingCharacterRecordsComponent>(args.Mob))
return;
var profile = args.Profile;
if (profile.CDCharacterRecords == null)
{
Log.Error($"Null records in CharacterRecordsSystem::OnPlayerSpawn for character {args.Profile.Name} played by {args.Player.Name}.");
return;
}
var player = args.Mob;
if (!_prototype.TryIndex(args.JobId, out JobPrototype? jobPrototype))
{
throw new ArgumentException($"Invalid job prototype ID: {args.JobId}");
}
TryComp<FingerprintComponent>(player, out var fingerprintComponent);
TryComp<DnaComponent>(player, out var dnaComponent);
var jobTitle = jobPrototype.LocalizedName;
var stationRecordsKey = FindStationRecordsKey(player);
// Grab the title from the station records if they exist to support our job title system
if (stationRecordsKey != null && _records.TryGetRecord<GeneralStationRecord>(stationRecordsKey.Value, out var stationRecords))
{
jobTitle = stationRecords.JobTitle;
}
var records = new FullCharacterRecords(
pRecords: new PlayerProvidedCharacterRecords(profile.CDCharacterRecords),
stationRecordsKey: stationRecordsKey?.Id,
name: profile.Name,
age: profile.Age,
species: profile.Species,
jobTitle: jobTitle,
jobIcon: jobPrototype.Icon,
gender: profile.Gender,
sex: profile.Sex,
fingerprint: fingerprintComponent?.Fingerprint,
dna: dnaComponent?.DNA,
owner: player);
AddRecord(args.Station, args.Mob, records);
}
private StationRecordKey? FindStationRecordsKey(EntityUid uid)
{
if (!_inventory.TryGetSlotEntity(uid, "id", out var idUid))
return null;
var keyStorageEntity = idUid;
if (TryComp<PdaComponent>(idUid, out var pda) && pda.ContainedId is {} id)
{
keyStorageEntity = id;
}
if (!TryComp<StationRecordKeyStorageComponent>(keyStorageEntity, out var storage))
{
return null;
}
return storage.Key;
}
private void AddRecord(EntityUid station, EntityUid player, FullCharacterRecords records, CharacterRecordsComponent? recordsDb = null)
{
if (!Resolve(station, ref recordsDb))
return;
var key = recordsDb.CreateNewKey();
recordsDb.Records.Add(key, records);
var playerKey = new CharacterRecordKey { Station = station, Index = key };
AddComp(player, new CharacterRecordKeyStorageComponent(playerKey));
RaiseLocalEvent(station, new CharacterRecordsModifiedEvent());
}
public void DelEntry(
EntityUid station,
EntityUid player,
CharacterRecordType ty,
int idx,
CharacterRecordsComponent? recordsDb = null,
CharacterRecordKeyStorageComponent? key = null)
{
if (!Resolve(station, ref recordsDb) || !Resolve(player, ref key))
return;
if (!recordsDb.Records.TryGetValue(key.Key.Index, out var value))
return;
var cr = value.PRecords;
switch (ty)
{
case CharacterRecordType.Employment:
cr.EmploymentEntries.RemoveAt(idx);
break;
case CharacterRecordType.Medical:
cr.MedicalEntries.RemoveAt(idx);
break;
case CharacterRecordType.Security:
cr.SecurityEntries.RemoveAt(idx);
break;
}
RaiseLocalEvent(station, new CharacterRecordsModifiedEvent());
}
public void ResetRecord(
EntityUid station,
EntityUid player,
CharacterRecordsComponent? recordsDb = null,
CharacterRecordKeyStorageComponent? key = null)
{
if (!Resolve(station, ref recordsDb) || !Resolve(player, ref key))
return;
if (!recordsDb.Records.TryGetValue(key.Key.Index, out var value))
return;
var records = PlayerProvidedCharacterRecords.DefaultRecords();
if (TryComp(player, out MetaDataComponent? meta))
value.Name = meta.EntityName;
value.PRecords = records;
RaiseLocalEvent(station, new CharacterRecordsModifiedEvent());
}
public void DeleteAllRecords(EntityUid player, CharacterRecordKeyStorageComponent? key = null)
{
if (!Resolve(player, ref key))
return;
var station = key.Key.Station;
CharacterRecordsComponent? records = null;
if (!Resolve(station, ref records))
return;
records.Records.Remove(key.Key.Index);
}
public IDictionary<uint, FullCharacterRecords> QueryRecords(EntityUid station, CharacterRecordsComponent? recordsDb = null)
{
return !Resolve(station, ref recordsDb)
? new Dictionary<uint, FullCharacterRecords>()
: recordsDb.Records;
}
}
public sealed class CharacterRecordsModifiedEvent : EntityEventArgs;

View File

@ -0,0 +1,55 @@
using Content.Server.Administration;
using Content.Server.Station.Systems;
using Content.Shared._CD.Records;
using Content.Shared.Administration;
using Robust.Shared.Console;
namespace Content.Server._CD.Records.Commands;
[AdminCommand(AdminFlags.Ban)]
public sealed class DelRecordEntryCommand : IConsoleCommand
{
[Dependency] private readonly IEntityManager _entManager = default!;
public string Command => "delrecordentry";
public string Description =>
"Resets the records of the given entity to the default values. This is not saved to the database and only lasts until the round is over";
public string Help => $"{Command} <entity> <recordType> <index>";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length < 3)
{
shell.WriteLine($"Not enough arguments.\n{Help}");
return;
}
if (!NetEntity.TryParse(args[0], out var uidNet) || !_entManager.TryGetEntity(uidNet, out var uid))
{
shell.WriteLine($"Invalid entity id.");
return;
}
if (!Enum.TryParse<CharacterRecordType>(args[1], out var ty))
{
shell.WriteLine($"Invalid entry type.");
return;
}
if (!int.TryParse(args[2], out var idx))
{
shell.WriteLine($"Invalid index.");
return;
}
var characterRecordsSystem = _entManager.System<CharacterRecordsSystem>();
var stationSystem = _entManager.System<StationSystem>();
foreach (var s in stationSystem.GetStations())
{
characterRecordsSystem.DelEntry(s, uid.Value, ty, idx);
}
}
}

View File

@ -0,0 +1,42 @@
using Content.Server.Administration;
using Content.Server.Station.Systems;
using Content.Shared.Administration;
using Robust.Shared.Console;
namespace Content.Server._CD.Records.Commands;
[AdminCommand(AdminFlags.Ban)]
public sealed class PurgeCharacterRecordsCommand : IConsoleCommand
{
[Dependency] private readonly IEntityManager _entManager = default!;
public string Command => "purgecharacterrecords";
public string Description =>
"Resets the records of the given entity to the default values. This is not saved to the database and only lasts until the round is over";
public string Help => $"{Command} <entity>";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length < 1)
{
shell.WriteLine($"Not enough arguments.\n{Help}");
return;
}
if (!NetEntity.TryParse(args[0], out var uidNet) || !_entManager.TryGetEntity(uidNet, out var uid))
{
shell.WriteLine($"Invalid entity id.");
return;
}
var characterRecordsSystem = _entManager.System<CharacterRecordsSystem>();
var stationSystem = _entManager.System<StationSystem>();
foreach (var s in stationSystem.GetStations())
{
characterRecordsSystem.ResetRecord(s, uid.Value);
}
}
}

View File

@ -0,0 +1,17 @@
using Content.Shared._CD.Records;
using Content.Shared.StationRecords;
namespace Content.Server._CD.Records.Consoles;
[RegisterComponent]
public sealed partial class CharacterRecordConsoleComponent : Component
{
[ViewVariables(VVAccess.ReadOnly)]
public uint? SelectedIndex { get; set; }
[ViewVariables(VVAccess.ReadOnly)]
public StationRecordsFilter? Filter;
[DataField(required: true), ViewVariables(VVAccess.ReadOnly)]
public RecordConsoleType ConsoleType;
}

View File

@ -0,0 +1,152 @@
using Content.Server.Station.Systems;
using Content.Server.StationRecords.Systems;
using Content.Server.StationRecords;
using Content.Shared.CriminalRecords;
using Content.Shared.Security;
using Content.Shared.StationRecords;
using Content.Shared._CD.Records;
using Robust.Server.GameObjects;
namespace Content.Server._CD.Records.Consoles;
public sealed class CharacterRecordConsoleSystem : EntitySystem
{
[Dependency] private readonly CharacterRecordsSystem _characterRecords = default!;
[Dependency] private readonly IEntityManager _entity = default!;
[Dependency] private readonly StationRecordsSystem _records = default!;
[Dependency] private readonly StationSystem _station = default!;
[Dependency] private readonly UserInterfaceSystem _ui = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<CharacterRecordConsoleComponent, CharacterRecordsModifiedEvent>((uid, component, _) =>
UpdateUi(uid, component));
Subs.BuiEvents<CharacterRecordConsoleComponent>(CharacterRecordConsoleKey.Key,
subr =>
{
subr.Event<BoundUIOpenedEvent>((uid, component, _) => UpdateUi(uid, component));
subr.Event<CharacterRecordConsoleSelectMsg>(OnKeySelect);
subr.Event<CharacterRecordsConsoleFilterMsg>(OnFilterApplied);
});
}
private void OnFilterApplied(Entity<CharacterRecordConsoleComponent> ent, ref CharacterRecordsConsoleFilterMsg msg)
{
ent.Comp.Filter = msg.Filter;
UpdateUi(ent);
}
private void OnKeySelect(Entity<CharacterRecordConsoleComponent> ent, ref CharacterRecordConsoleSelectMsg msg)
{
ent.Comp.SelectedIndex = msg.CharacterRecordKey;
UpdateUi(ent);
}
private void UpdateUi(EntityUid entity, CharacterRecordConsoleComponent? console = null)
{
if (!Resolve(entity, ref console))
return;
var station = _station.GetOwningStation(entity);
if (!HasComp<StationRecordsComponent>(station) ||
!HasComp<CharacterRecordsComponent>(station))
{
SendState(entity, new CharacterRecordConsoleState { ConsoleType = console.ConsoleType });
return;
}
var characterRecords = _characterRecords.QueryRecords(station.Value);
// Get the name and station records key display from the list of records
var names = new Dictionary<uint, CharacterRecordConsoleState.CharacterInfo>();
foreach (var (i, r) in characterRecords)
{
var netEnt = _entity.GetNetEntity(r.Owner!.Value);
// Admins get additional info to make it easier to run commands
var nameJob = console.ConsoleType != RecordConsoleType.Admin
? $"{r.Name} ({r.JobTitle})"
: $"{r.Name} ({netEnt}, {r.JobTitle}";
// Apply any filter the user has set
if (console.Filter != null)
{
if (IsSkippedRecord(console.Filter, r, nameJob))
continue;
}
if (names.ContainsKey(i))
{
Log.Error(
$"We somehow have duplicate character record keys, NetEntity: {i}, Entity: {entity}, Character Name: {r.Name}");
}
names[i] = new CharacterRecordConsoleState.CharacterInfo
{ CharacterDisplayName = nameJob, StationRecordKey = r.StationRecordsKey };
}
var record =
console.SelectedIndex == null || !characterRecords.TryGetValue(console.SelectedIndex!.Value, out var value)
? null
: value;
(SecurityStatus, string?)? securityStatus = null;
// If we need the character's security status, gather it from the criminal records
if ((console.ConsoleType == RecordConsoleType.Admin ||
console.ConsoleType == RecordConsoleType.Security)
&& record?.StationRecordsKey != null)
{
var key = new StationRecordKey(record.StationRecordsKey.Value, station.Value);
if (_records.TryGetRecord<CriminalRecord>(key, out var entry))
securityStatus = (entry.Status, entry.Reason);
}
SendState(entity,
new CharacterRecordConsoleState
{
ConsoleType = console.ConsoleType,
CharacterList = names,
SelectedIndex = console.SelectedIndex,
SelectedRecord = record,
Filter = console.Filter,
SelectedSecurityStatus = securityStatus,
});
}
private void SendState(EntityUid entity, CharacterRecordConsoleState state)
{
_ui.SetUiState(entity, CharacterRecordConsoleKey.Key, state);
}
/// <summary>
/// Almost exactly the same as <see cref="StationRecordsSystem.IsSkipped"/>
/// </summary>
private static bool IsSkippedRecord(StationRecordsFilter filter,
FullCharacterRecords record,
string nameJob)
{
var isFilter = filter.Value.Length > 0;
if (!isFilter)
return false;
var filterLowerCaseValue = filter.Value.ToLower();
return filter.Type switch
{
StationRecordFilterType.Name =>
!nameJob.Contains(filterLowerCaseValue, StringComparison.CurrentCultureIgnoreCase),
StationRecordFilterType.Prints => record.Fingerprint != null
&& IsFilterWithSomeCodeValue(record.Fingerprint, filterLowerCaseValue),
StationRecordFilterType.DNA => record.DNA != null
&& IsFilterWithSomeCodeValue(record.DNA, filterLowerCaseValue),
_ => throw new ArgumentOutOfRangeException(nameof(filter), "Invalid Character Record filter type"),
};
}
private static bool IsFilterWithSomeCodeValue(string value, string filter)
{
return !value.StartsWith(filter, StringComparison.CurrentCultureIgnoreCase);
}
}

View File

@ -0,0 +1,93 @@
using Content.Server.Database;
using Content.Shared._CD.Records;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
namespace Content.Server._CD.Records;
public static class RecordsSerialization
{
private static int DeserializeInt(JsonElement e, string key, int def)
{
if (e.TryGetProperty(key, out var prop) && prop.TryGetInt32(out var v))
{
return v;
}
return def;
}
private static bool DeserializeBool(JsonElement e, string key, bool def)
{
if (!e.TryGetProperty(key, out var v))
return def;
return v.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => def,
};
}
[return: NotNullIfNotNull(nameof(def))]
private static string? DeserializeString(JsonElement e, string key, string? def)
{
if (!e.TryGetProperty(key, out var v))
return def;
if (v.ValueKind == JsonValueKind.String)
return v.GetString() ?? def;
return def;
}
private static List<PlayerProvidedCharacterRecords.RecordEntry> DeserializeEntries(List<CDModel.CharacterRecordEntry> entries, CDModel.DbRecordEntryType ty)
{
return entries.Where(e => e.Type == ty)
.OrderBy(e => e.Id) // attempt at fixing the record order changing bug.
.Select(e => new PlayerProvidedCharacterRecords.RecordEntry(e.Title, e.Involved, e.Description))
.ToList();
}
/// <summary>
/// We need to manually deserialize CharacterRecords because the easy JSON deserializer does not
/// do exactly what we want. More specifically, we need to more robustly handle missing and extra fields
/// <br />
/// <br />
/// Missing fields are filled in with their default value, extra fields are simply ignored
/// </summary>
public static PlayerProvidedCharacterRecords Deserialize(JsonDocument json, List<CDModel.CharacterRecordEntry> entries)
{
var e = json.RootElement;
var def = PlayerProvidedCharacterRecords.DefaultRecords();
return new PlayerProvidedCharacterRecords(
height: DeserializeInt(e, nameof(def.Height), def.Height),
weight: DeserializeInt(e, nameof(def.Weight), def.Weight),
emergencyContactName: DeserializeString(e, nameof(def.EmergencyContactName), def.EmergencyContactName),
hasWorkAuthorization: DeserializeBool(e, nameof(def.HasWorkAuthorization), def.HasWorkAuthorization),
identifyingFeatures: DeserializeString(e, nameof(def.IdentifyingFeatures), def.IdentifyingFeatures),
allergies: DeserializeString(e, nameof(def.Allergies), def.Allergies),
drugAllergies: DeserializeString(e, nameof(def.DrugAllergies), def.DrugAllergies),
postmortemInstructions: DeserializeString(e, nameof(def.PostmortemInstructions), def.PostmortemInstructions),
medicalEntries: DeserializeEntries(entries, CDModel.DbRecordEntryType.Medical),
securityEntries: DeserializeEntries(entries, CDModel.DbRecordEntryType.Security),
employmentEntries: DeserializeEntries(entries, CDModel.DbRecordEntryType.Employment));
}
private static CDModel.CharacterRecordEntry ConvertEntry(PlayerProvidedCharacterRecords.RecordEntry entry, CDModel.DbRecordEntryType type)
{
entry.EnsureValid();
return new CDModel.CharacterRecordEntry()
{ Title = entry.Title, Involved = entry.Involved, Description = entry.Description, Type = type };
}
public static List<CDModel.CharacterRecordEntry> GetEntries(PlayerProvidedCharacterRecords records)
{
return records.MedicalEntries.Select(medical => ConvertEntry(medical, CDModel.DbRecordEntryType.Medical))
.Concat(records.SecurityEntries.Select(security => ConvertEntry(security, CDModel.DbRecordEntryType.Security)))
.Concat(records.EmploymentEntries.Select(employment => ConvertEntry(employment, CDModel.DbRecordEntryType.Employment)))
.ToList();
}
}

View File

@ -0,0 +1,4 @@
namespace Content.Server._CD.Records;
[RegisterComponent]
public sealed partial class SkipLoadingCharacterRecordsComponent : Component;

View File

@ -85,6 +85,14 @@ public sealed partial class HumanoidAppearanceComponent : Component
[ViewVariables(VVAccess.ReadOnly)] [ViewVariables(VVAccess.ReadOnly)]
public Color? CachedFacialHairColor; public Color? CachedFacialHairColor;
// CD - Character Records
/// <summary>
/// The height of this humanoid.
/// </summary>
[DataField, AutoNetworkedField]
public float Height = 1f;
// CD - Character Records
/// <summary> /// <summary>
/// Which layers of this humanoid that should be hidden on equipping a corresponding item.. /// Which layers of this humanoid that should be hidden on equipping a corresponding item..
/// </summary> /// </summary>

View File

@ -120,6 +120,52 @@ public sealed partial class SpeciesPrototype : IPrototype
/// </summary> /// </summary>
[DataField] [DataField]
public int MaxAge = 120; public int MaxAge = 120;
// Begin DV - CD Character Records shouldn't nuke species heights
/// <summary>
/// The base height scale for this species
/// </summary>
[DataField("baseScale")]
public System.Numerics.Vector2 BaseScale = new(1f, 1f);
// End DV - CD Character Records shouldn't nuke species heights
// Begin CD - Character Records
/// <summary>
/// The minimum height for this species
/// </summary>
[DataField("minHeight")]
public float MinHeight = 0.85f; // DeltaV - less trolling with the heights
/// <summary>
/// The maximum height for this species
/// </summary>
[DataField("maxHeight")]
public float MaxHeight = 1.2f; // DeltaV - less trolling with the heights
/// <summary>
/// The default height for this species
/// </summary>
[DataField("defaultHeight")]
public float DefaultHeight = 1f;
/// <summary>
/// The default width for this species
/// </summary>
[DataField("defaultWidth")]
public float DefaultWidth = 1f;
/// <summary>
/// Whether to scale horizontally or not
/// </summary>
[DataField("scaleWidth")]
public bool ScaleWidth = true;
/// <summary>
/// Whether to scale vertically or not
/// </summary>
[DataField("scaleHeight")]
public bool ScaleHeight = true;
// End CD - Character Records
} }
public enum SpeciesNaming : byte public enum SpeciesNaming : byte

View File

@ -154,6 +154,8 @@ public abstract class SharedHumanoidAppearanceSystem : EntitySystem
targetHumanoid.CustomBaseLayers = new(sourceHumanoid.CustomBaseLayers); targetHumanoid.CustomBaseLayers = new(sourceHumanoid.CustomBaseLayers);
targetHumanoid.MarkingSet = new(sourceHumanoid.MarkingSet); targetHumanoid.MarkingSet = new(sourceHumanoid.MarkingSet);
targetHumanoid.Height = sourceHumanoid.Height; // CD - Character Records
targetHumanoid.Gender = sourceHumanoid.Gender; targetHumanoid.Gender = sourceHumanoid.Gender;
if (TryComp<GrammarComponent>(target, out var grammar)) if (TryComp<GrammarComponent>(target, out var grammar))
grammar.Gender = sourceHumanoid.Gender; grammar.Gender = sourceHumanoid.Gender;
@ -417,6 +419,7 @@ public abstract class SharedHumanoidAppearanceSystem : EntitySystem
} }
humanoid.Age = profile.Age; humanoid.Age = profile.Age;
humanoid.Height = profile.Height; // CD - Character Records
humanoid.LastProfileLoaded = profile; // DeltaV - let paradox anomaly be cloned humanoid.LastProfileLoaded = profile; // DeltaV - let paradox anomaly be cloned

View File

@ -15,6 +15,7 @@ using Robust.Shared.Prototypes;
using Robust.Shared.Random; using Robust.Shared.Random;
using Robust.Shared.Serialization; using Robust.Shared.Serialization;
using Robust.Shared.Utility; using Robust.Shared.Utility;
using Content.Shared._CD.Records; // CD - Character Records
namespace Content.Shared.Preferences namespace Content.Shared.Preferences
{ {
@ -135,6 +136,14 @@ namespace Content.Shared.Preferences
public PreferenceUnavailableMode PreferenceUnavailable { get; private set; } = public PreferenceUnavailableMode PreferenceUnavailable { get; private set; } =
PreferenceUnavailableMode.SpawnAsOverflow; PreferenceUnavailableMode.SpawnAsOverflow;
// Begin CD - Character records
[DataField("cosmaticDriftCharacterHeight")]
public float Height = 1f;
[DataField("cosmaticDriftCharacterRecords")]
public PlayerProvidedCharacterRecords? CDCharacterRecords;
// End CD - Character records
public HumanoidCharacterProfile( public HumanoidCharacterProfile(
string name, string name,
string flavortext, string flavortext,
@ -148,7 +157,12 @@ namespace Content.Shared.Preferences
PreferenceUnavailableMode preferenceUnavailable, PreferenceUnavailableMode preferenceUnavailable,
HashSet<ProtoId<AntagPrototype>> antagPreferences, HashSet<ProtoId<AntagPrototype>> antagPreferences,
HashSet<ProtoId<TraitPrototype>> traitPreferences, HashSet<ProtoId<TraitPrototype>> traitPreferences,
Dictionary<string, RoleLoadout> loadouts) Dictionary<string, RoleLoadout> loadouts,
// Begin CD - Character Records
float height,
PlayerProvidedCharacterRecords? cdCharacterRecords
// End CD - Character Records
)
{ {
Name = name; Name = name;
FlavorText = flavortext; FlavorText = flavortext;
@ -163,6 +177,10 @@ namespace Content.Shared.Preferences
_antagPreferences = antagPreferences; _antagPreferences = antagPreferences;
_traitPreferences = traitPreferences; _traitPreferences = traitPreferences;
_loadouts = loadouts; _loadouts = loadouts;
// Begin CD - Character Records
Height = height;
CDCharacterRecords = cdCharacterRecords;
// End CD - Character Records
var hasHighPrority = false; var hasHighPrority = false;
foreach (var (key, value) in _jobPriorities) foreach (var (key, value) in _jobPriorities)
@ -193,7 +211,9 @@ namespace Content.Shared.Preferences
other.PreferenceUnavailable, other.PreferenceUnavailable,
new HashSet<ProtoId<AntagPrototype>>(other.AntagPreferences), new HashSet<ProtoId<AntagPrototype>>(other.AntagPreferences),
new HashSet<ProtoId<TraitPrototype>>(other.TraitPreferences), new HashSet<ProtoId<TraitPrototype>>(other.TraitPreferences),
new Dictionary<string, RoleLoadout>(other.Loadouts)) new Dictionary<string, RoleLoadout>(other.Loadouts),
other.Height, // CD - Character Records
other.CDCharacterRecords) // CD - Character Records
{ {
} }
@ -241,10 +261,12 @@ namespace Content.Shared.Preferences
var sex = Sex.Unsexed; var sex = Sex.Unsexed;
var age = 18; var age = 18;
var height = 1f; // CD - Character Records
if (prototypeManager.TryIndex<SpeciesPrototype>(species, out var speciesPrototype)) if (prototypeManager.TryIndex<SpeciesPrototype>(species, out var speciesPrototype))
{ {
sex = random.Pick(speciesPrototype.Sexes); sex = random.Pick(speciesPrototype.Sexes);
age = random.Next(speciesPrototype.MinAge, speciesPrototype.OldAge); // people don't look and keep making 119 year old characters with zero rp, cap it at middle aged age = random.Next(speciesPrototype.MinAge, speciesPrototype.OldAge); // people don't look and keep making 119 year old characters with zero rp, cap it at middle aged
height = MathF.Round(random.NextFloat(speciesPrototype.MinHeight, speciesPrototype.MaxHeight), 2); // CD - Character Records
} }
var gender = Gender.Epicene; var gender = Gender.Epicene;
@ -269,6 +291,7 @@ namespace Content.Shared.Preferences
Gender = gender, Gender = gender,
Species = species, Species = species,
Appearance = HumanoidCharacterAppearance.Random(species, sex), Appearance = HumanoidCharacterAppearance.Random(species, sex),
Height = height,
}; };
} }
@ -313,6 +336,18 @@ namespace Content.Shared.Preferences
return new(this) { SpawnPriority = spawnPriority }; return new(this) { SpawnPriority = spawnPriority };
} }
// Begin CD - Character Records
public HumanoidCharacterProfile WithHeight(float height)
{
return new(this) { Height = height };
}
public HumanoidCharacterProfile WithCDCharacterRecords(PlayerProvidedCharacterRecords records)
{
return new HumanoidCharacterProfile(this) { CDCharacterRecords = records };
}
// End CD - Character Records
public HumanoidCharacterProfile WithJobPriorities(IEnumerable<KeyValuePair<ProtoId<JobPrototype>, JobPriority>> jobPriorities) public HumanoidCharacterProfile WithJobPriorities(IEnumerable<KeyValuePair<ProtoId<JobPrototype>, JobPriority>> jobPriorities)
{ {
var dictionary = new Dictionary<ProtoId<JobPrototype>, JobPriority>(jobPriorities); var dictionary = new Dictionary<ProtoId<JobPrototype>, JobPriority>(jobPriorities);
@ -479,6 +514,9 @@ namespace Content.Shared.Preferences
if (!_traitPreferences.SequenceEqual(other._traitPreferences)) return false; if (!_traitPreferences.SequenceEqual(other._traitPreferences)) return false;
if (!Loadouts.SequenceEqual(other.Loadouts)) return false; if (!Loadouts.SequenceEqual(other.Loadouts)) return false;
if (FlavorText != other.FlavorText) return false; if (FlavorText != other.FlavorText) return false;
if (Height != other.Height) return false; // CD
if (CDCharacterRecords != null && other.CDCharacterRecords != null && // CD
!CDCharacterRecords.MemberwiseEquals(other.CDCharacterRecords)) return false; // CD
return Appearance.MemberwiseEquals(other.Appearance); return Appearance.MemberwiseEquals(other.Appearance);
} }
@ -558,6 +596,12 @@ namespace Content.Shared.Preferences
flavortext = FormattedMessage.RemoveMarkupOrThrow(FlavorText); flavortext = FormattedMessage.RemoveMarkupOrThrow(FlavorText);
} }
// Begin CD - Character Records
var height = Height;
if (speciesPrototype != null)
height = Math.Clamp(MathF.Round(Height, 2), speciesPrototype.MinHeight, speciesPrototype.MaxHeight);
// End CD - Character Records
var appearance = HumanoidCharacterAppearance.EnsureValid(Appearance, Species, Sex); var appearance = HumanoidCharacterAppearance.EnsureValid(Appearance, Species, Sex);
var prefsUnavailableMode = PreferenceUnavailable switch var prefsUnavailableMode = PreferenceUnavailable switch
@ -627,6 +671,18 @@ namespace Content.Shared.Preferences
_traitPreferences.Clear(); _traitPreferences.Clear();
_traitPreferences.UnionWith(GetValidTraits(traits, prototypeManager)); _traitPreferences.UnionWith(GetValidTraits(traits, prototypeManager));
// Begin CD - Character Records
Height = height;
if (CDCharacterRecords == null)
{
CDCharacterRecords = PlayerProvidedCharacterRecords.DefaultRecords();
}
else
{
CDCharacterRecords!.EnsureValid();
}
// End CD - Character Records
// Checks prototypes exist for all loadouts and dump / set to default if not. // Checks prototypes exist for all loadouts and dump / set to default if not.
var toRemove = new ValueList<string>(); var toRemove = new ValueList<string>();
@ -722,6 +778,7 @@ namespace Content.Shared.Preferences
hashCode.Add(Appearance); hashCode.Add(Appearance);
hashCode.Add((int)SpawnPriority); hashCode.Add((int)SpawnPriority);
hashCode.Add((int)PreferenceUnavailable); hashCode.Add((int)PreferenceUnavailable);
hashCode.Add(Height); // CD - Character Records
return hashCode.ToHashCode(); return hashCode.ToHashCode();
} }

View File

@ -0,0 +1,94 @@
using Content.Shared.Humanoid;
using Robust.Shared.Enums;
using Robust.Shared.Serialization;
namespace Content.Shared._CD.Records;
/// <summary>
/// Contains the full records information, not just stuff that is in the database.
/// </summary>
[Serializable, NetSerializable]
public sealed class FullCharacterRecords(
PlayerProvidedCharacterRecords pRecords,
uint? stationRecordsKey,
string name,
int age,
string jobTitle,
string jobIcon,
string species,
Gender gender,
Sex sex,
string? fingerprint,
string? dna,
EntityUid? owner = null)
{
[ViewVariables]
public PlayerProvidedCharacterRecords PRecords = pRecords;
/// <summary>
/// Key for the equivalent entry in the station records
///
/// Sadly, this has to be a uint because StationRecordsKey is not serializable
/// </summary>
[ViewVariables]
public uint? StationRecordsKey = stationRecordsKey;
/// <summary>
/// Name tied to this record.
/// </summary>
[ViewVariables]
public string Name = name;
/// <summary>
/// Age of the person that this record represents.
/// </summary>
[ViewVariables]
public int Age = age;
/// <summary>
/// Job title tied to this record.
/// </summary>
[ViewVariables]
public string JobTitle = jobTitle;
/// <summary>
/// Job icon tied to this record.
/// </summary>
[ViewVariables]
public string JobIcon = jobIcon;
/// <summary>
/// Species tied to this record.
/// </summary>
[ViewVariables]
public string Species = species;
/// <summary>
/// Gender identity tied to this record.
/// </summary>
[ViewVariables]
public Gender Gender = gender;
/// <summary>
/// Sex identity tied to this record.
/// </summary>
[ViewVariables]
public Sex Sex = sex;
[ViewVariables]
public string? Fingerprint = fingerprint;
/// <summary>
/// DNA of the person.
/// </summary>
[ViewVariables]
// ReSharper disable once InconsistentNaming
public string? DNA = dna;
/// <summary>
/// The entity that owns this record. Should always nonnull inside CharacterRecordsComponent. This field should not be accessed client side.
/// </summary>
[ViewVariables]
[NonSerialized]
public EntityUid? Owner = owner;
}

View File

@ -0,0 +1,268 @@
using System.Linq;
using System.Text.Json.Serialization;
using Robust.Shared.Serialization;
namespace Content.Shared._CD.Records;
/// <summary>
/// Contains Cosmatic Drift records that can be changed in the character editor. This is stored on the character's profile.
/// </summary>
[DataDefinition]
[Serializable, NetSerializable]
public sealed partial class PlayerProvidedCharacterRecords
{
public const int TextMedLen = 64;
public const int TextVeryLargeLen = 4096;
/* Basic info */
// Additional data is fetched from the Profile
// All
[DataField]
public int Height { get; private set; }
public const int MaxHeight = 800;
[DataField]
public int Weight { get; private set; }
public const int MaxWeight = 300;
[DataField]
public string EmergencyContactName { get; private set; }
// Employment
[DataField]
public bool HasWorkAuthorization { get; private set; }
// Security
[DataField]
public string IdentifyingFeatures { get; private set; }
// Medical
[DataField]
public string Allergies { get; private set; }
[DataField]
public string DrugAllergies { get; private set; }
[DataField]
public string PostmortemInstructions { get; private set; }
// history, prescriptions, etc. would be a record below
// "incidents"
[DataField, JsonIgnore]
public List<RecordEntry> MedicalEntries { get; private set; }
[DataField, JsonIgnore]
public List<RecordEntry> SecurityEntries { get; private set; }
[DataField, JsonIgnore]
public List<RecordEntry> EmploymentEntries { get; private set; }
[DataDefinition]
[Serializable, NetSerializable]
public sealed partial class RecordEntry
{
[DataField]
public string Title { get; private set; }
// players involved, can be left blank (or with a generic "CentCom" etc.) for backstory related issues
[DataField]
public string Involved { get; private set; }
// Longer description of events.
[DataField]
public string Description { get; private set; }
public RecordEntry(string title, string involved, string desc)
{
Title = title;
Involved = involved;
Description = desc;
}
public RecordEntry(RecordEntry other)
: this(other.Title, other.Involved, other.Description)
{
}
public bool MemberwiseEquals(RecordEntry other)
{
return Title == other.Title && Involved == other.Involved && Description == other.Description;
}
public void EnsureValid()
{
Title = ClampString(Title, TextMedLen);
Involved = ClampString(Involved, TextMedLen);
Description = ClampString(Description, TextVeryLargeLen);
}
}
public PlayerProvidedCharacterRecords(
bool hasWorkAuthorization,
int height, int weight,
string emergencyContactName,
string identifyingFeatures,
string allergies, string drugAllergies,
string postmortemInstructions,
List<RecordEntry> medicalEntries, List<RecordEntry> securityEntries, List<RecordEntry> employmentEntries)
{
HasWorkAuthorization = hasWorkAuthorization;
Height = height;
Weight = weight;
EmergencyContactName = emergencyContactName;
IdentifyingFeatures = identifyingFeatures;
Allergies = allergies;
DrugAllergies = drugAllergies;
PostmortemInstructions = postmortemInstructions;
MedicalEntries = medicalEntries;
SecurityEntries = securityEntries;
EmploymentEntries = employmentEntries;
}
public PlayerProvidedCharacterRecords(PlayerProvidedCharacterRecords other)
{
Height = other.Height;
Weight = other.Weight;
EmergencyContactName = other.EmergencyContactName;
HasWorkAuthorization = other.HasWorkAuthorization;
IdentifyingFeatures = other.IdentifyingFeatures;
Allergies = other.Allergies;
DrugAllergies = other.DrugAllergies;
PostmortemInstructions = other.PostmortemInstructions;
MedicalEntries = other.MedicalEntries.Select(x => new RecordEntry(x)).ToList();
SecurityEntries = other.SecurityEntries.Select(x => new RecordEntry(x)).ToList();
EmploymentEntries = other.EmploymentEntries.Select(x => new RecordEntry(x)).ToList();
}
public static PlayerProvidedCharacterRecords DefaultRecords()
{
return new PlayerProvidedCharacterRecords(
hasWorkAuthorization: true,
height: 170, weight: 70,
emergencyContactName: "",
identifyingFeatures: "",
allergies: "None",
drugAllergies: "None",
postmortemInstructions: "Return home",
medicalEntries: new List<RecordEntry>(),
securityEntries: new List<RecordEntry>(),
employmentEntries: new List<RecordEntry>()
);
}
public bool MemberwiseEquals(PlayerProvidedCharacterRecords other)
{
// This is ugly but is only used for integration tests.
var test = Height == other.Height
&& Weight == other.Weight
&& EmergencyContactName == other.EmergencyContactName
&& HasWorkAuthorization == other.HasWorkAuthorization
&& IdentifyingFeatures == other.IdentifyingFeatures
&& Allergies == other.Allergies
&& DrugAllergies == other.DrugAllergies
&& PostmortemInstructions == other.PostmortemInstructions;
if (!test)
return false;
if (MedicalEntries.Count != other.MedicalEntries.Count)
return false;
if (SecurityEntries.Count != other.SecurityEntries.Count)
return false;
if (EmploymentEntries.Count != other.EmploymentEntries.Count)
return false;
if (MedicalEntries.Where((t, i) => !t.MemberwiseEquals(other.MedicalEntries[i])).Any())
{
return false;
}
if (SecurityEntries.Where((t, i) => !t.MemberwiseEquals(other.SecurityEntries[i])).Any())
{
return false;
}
if (EmploymentEntries.Where((t, i) => !t.MemberwiseEquals(other.EmploymentEntries[i])).Any())
{
return false;
}
return true;
}
private static string ClampString(string str, int maxLen)
{
if (str.Length > maxLen)
{
return str[..maxLen];
}
return str;
}
private static void EnsureValidEntries(List<RecordEntry> entries)
{
foreach (var entry in entries)
{
entry.EnsureValid();
}
}
/// <summary>
/// Clamp invalid entries to valid values
/// </summary>
public void EnsureValid()
{
Height = Math.Clamp(Height, 0, MaxHeight);
Weight = Math.Clamp(Weight, 0, MaxWeight);
EmergencyContactName =
ClampString(EmergencyContactName, TextMedLen);
IdentifyingFeatures = ClampString(IdentifyingFeatures, TextMedLen);
Allergies = ClampString(Allergies, TextMedLen);
DrugAllergies = ClampString(DrugAllergies, TextMedLen);
PostmortemInstructions = ClampString(PostmortemInstructions, TextMedLen);
EnsureValidEntries(EmploymentEntries);
EnsureValidEntries(MedicalEntries);
EnsureValidEntries(SecurityEntries);
}
public PlayerProvidedCharacterRecords WithHeight(int height)
{
return new(this) { Height = height };
}
public PlayerProvidedCharacterRecords WithWeight(int weight)
{
return new(this) { Weight = weight };
}
public PlayerProvidedCharacterRecords WithWorkAuth(bool auth)
{
return new(this) { HasWorkAuthorization = auth };
}
public PlayerProvidedCharacterRecords WithContactName(string name)
{
return new(this) { EmergencyContactName = name};
}
public PlayerProvidedCharacterRecords WithIdentifyingFeatures(string feat)
{
return new(this) { IdentifyingFeatures = feat};
}
public PlayerProvidedCharacterRecords WithAllergies(string s)
{
return new(this) { Allergies = s };
}
public PlayerProvidedCharacterRecords WithDrugAllergies(string s)
{
return new(this) { DrugAllergies = s };
}
public PlayerProvidedCharacterRecords WithPostmortemInstructions(string s)
{
return new(this) { PostmortemInstructions = s};
}
public PlayerProvidedCharacterRecords WithEmploymentEntries(List<RecordEntry> entries)
{
return new(this) { EmploymentEntries = entries};
}
public PlayerProvidedCharacterRecords WithMedicalEntries(List<RecordEntry> entries)
{
return new(this) { MedicalEntries = entries};
}
public PlayerProvidedCharacterRecords WithSecurityEntries(List<RecordEntry> entries)
{
return new(this) { SecurityEntries = entries};
}
}
public enum CharacterRecordType : byte
{
Employment, Medical, Security
}

View File

@ -0,0 +1,80 @@
using Content.Shared.Security;
using Content.Shared.StationRecords;
using Robust.Shared.Serialization;
namespace Content.Shared._CD.Records;
[Serializable, NetSerializable]
public enum CharacterRecordConsoleKey : byte
{
Key
}
[Serializable, NetSerializable]
public enum RecordConsoleType : byte
{
Security,
Medical,
Employment,
/// <summary>
/// Admin console has the functionality of all other types and has some additional admin related functionality
/// </summary>
Admin
}
[Serializable, NetSerializable]
public sealed class CharacterRecordConsoleState : BoundUserInterfaceState
{
[Serializable, NetSerializable]
public struct CharacterInfo
{
public string CharacterDisplayName;
public uint? StationRecordKey;
}
public RecordConsoleType ConsoleType { get; set; }
/// <summary>
/// Character selected in the console
/// </summary>
public uint? SelectedIndex { get; set; } = null;
/// <summary>
/// List of names+station record keys to display in the listing
/// </summary>
public Dictionary<uint, CharacterInfo>? CharacterList { get; set; }
/// <summary>
/// The contents of the selected record
/// </summary>
public FullCharacterRecords? SelectedRecord { get; set; } = null;
public StationRecordsFilter? Filter { get; set; } = null;
/// <summary>
/// Security status of the selected record
/// </summary>
public (SecurityStatus, string?)? SelectedSecurityStatus = null;
}
[Serializable, NetSerializable]
public sealed class CharacterRecordsConsoleFilterMsg : BoundUserInterfaceMessage
{
public readonly StationRecordsFilter? Filter;
public CharacterRecordsConsoleFilterMsg(StationRecordsFilter? filter)
{
Filter = filter;
}
}
[Serializable, NetSerializable]
public sealed class CharacterRecordConsoleSelectMsg : BoundUserInterfaceMessage
{
public readonly uint? CharacterRecordKey;
public CharacterRecordConsoleSelectMsg(uint? characterRecordKey)
{
CharacterRecordKey = characterRecordKey;
}
}

View File

@ -0,0 +1,11 @@
admin-player-actions-window-cd-record-purge = Modify Character Records
cd-actions-admin-modify-records = Modify Character Records
cd-actions-admin-modify-reset = Reset Records
cd-actions-admin-modify-del-entry = Delete Entry
cd-eventpreferencepanel-title = Event Preference View
cd-eventpreferencepanel-player = Player: {$player}
cd-eventpreferencepanel-character = Character: {$characterName}
cmd-eventpreferences-help = Usage: eventpreferences <name>
cmd-eventpreferences-desc = Displays a player's current character's event preferences (i.e. antags)

View File

@ -0,0 +1,16 @@
guide-entry-cd-records = Character Records
guide-entry-rules-cd = Cosmatic Drift Rules
guide-entry-cd = Cosmatic Drift Content
guide-entry-cd-newplayer = Welcome to Cosmatic Drift!
guide-entry-rules-ic = In Character Policy
guide-entry-rules-sop-core = Standard Operating Procedure
guide-entry-rules-sop-restricted = List of Restricted Items
guide-entry-rules-sop-command = Command
guide-entry-rules-sop-sec = Security
guide-entry-rules-sop-engi = Engineering
guide-entry-rules-sop-med = Medical
guide-entry-rules-sop-sci = Science
guide-entry-rules-sop-cargo = Cargo
guide-entry-rules-sop-service = Service

View File

@ -0,0 +1,2 @@
humanoid-profile-editor-height-label = Height:
humanoid-profile-editor-reset-height-button = Reset

View File

@ -0,0 +1,5 @@
ent-CriminalRecordsComputerCircuitboard = security records computer board
.desc = A computer printed circuit board for a security records computer.
ent-StationRecordsComputerCircuitboard = employment records computer board
.desc = A computer printed circuit board for a employment records computer.

View File

@ -0,0 +1,40 @@
# Records editor
humanoid-profile-editor-cd-records-tab = Records
# General
humanoid-profile-editor-cd-records-height = Height (cm):
humanoid-profile-editor-cd-records-weight = Weight (kg):
humanoid-profile-editor-cd-records-contact-name = Emergency Contact Names(s):
# Employment
humanoid-profile-editor-cd-records-employment = Employment
humanoid-profile-editor-cd-records-work-authorization = Work Authorization:
# Security
humanoid-profile-editor-cd-records-identifying-features = Identifying Features:
# Medical
humanoid-profile-editor-cd-records-allergies = Allergies:
humanoid-profile-editor-cd-records-drug-allergies = Drug Allergies:
humanoid-profile-editor-cd-records-postmortem = Postmortem Instructions:
# Entries
humanoid-profile-editor-cd-records-add-entry = Add Entry
humanoid-profile-editor-cd-records-edit-entry = Edit Entry
humanoid-profile-editor-cd-records-view-entry = View Entry
humanoid-profile-editor-cd-records-remove-entry = Remove Entry
humanoid-profile-editor-cd-records-up = Up
humanoid-profile-editor-cd-records-down = Down
cd-records-entry-edit-popup-title = View/Edit Entry
cd-records-entry-edit-popup-save = Save
cd-records-entry-default-title = Untitled Entry
cd-records-entry-edit-popup-title-placeholder = Entry Title
cd-records-entry-edit-popup-involved-placeholder = Author(s)
cd-records-entry-edit-popup-description-placeholder = Description
cd-records-entry-edit-popup-title-required = Title is required
cd-records-entry-edit-popup-involved-required = Author(s) is required
cd-records-entry-edit-popup-description-required = Description is required
cd-records-entry-edit-popup-description-too-long = Description is too long! ({$current}/{$max} characters)

View File

@ -0,0 +1,26 @@
cd-character-records-viewer-title-employ = Employment Records
cd-character-records-viewer-title-sec = Security Records
cd-character-records-viewer-title-med = Medical Records
cd-record-viewer-empty-state = Cannot fetch records.
cd-record-viewer-no-record-selected = Please select record.
cd-character-records-viewer-record-age = Age:
cd-character-records-viewer-record-job = Job:
cd-character-records-viewer-record-gender = Gender:
cd-character-records-viewer-record-species = Species:
cd-character-records-viewer-record-sec-fingerprint = Fingerprint:
cd-character-records-viewer-record-sec-dna = DNA:
cd-character-records-viewer-unknown = ERROR UNKNOWN
cd-character-records-viewer-record-med-sex = Sex:
cd-character-records-viewer-view-entry = View
cd-records-entry-view-popup-title = View Entry
cd-character-records-entry-view-title = Title:
cd-character-records-entry-view-involved = Author(s):
cd-character-records-viewer-setwanted-placeholder = Reason

View File

@ -52,6 +52,10 @@
type: CargoOrderConsoleBoundUserInterface type: CargoOrderConsoleBoundUserInterface
enum.CrewMonitoringUIKey.Key: enum.CrewMonitoringUIKey.Key:
type: CrewMonitoringBoundUserInterface type: CrewMonitoringBoundUserInterface
# Begin CD - Character Records
enum.CharacterRecordConsoleKey.Key:
type: CharacterRecordConsoleBoundUserInterface
# End CD - Character Records
enum.GeneralStationRecordConsoleKey.Key: enum.GeneralStationRecordConsoleKey.Key:
# who the fuck named this bruh # who the fuck named this bruh
type: GeneralStationRecordConsoleBoundUserInterface type: GeneralStationRecordConsoleBoundUserInterface
@ -69,6 +73,10 @@
toggleAction: ActionAGhostShowCrewMonitoring toggleAction: ActionAGhostShowCrewMonitoring
enum.GeneralStationRecordConsoleKey.Key: enum.GeneralStationRecordConsoleKey.Key:
toggleAction: ActionAGhostShowStationRecords toggleAction: ActionAGhostShowStationRecords
# Begin CD - Character Records
enum.CharacterRecordConsoleKey.Key:
toggleAction: ActionAGhostShowCharacterRecords
# End CD - Character Records
- type: SolarControlConsole # look ma i AM the computer! - type: SolarControlConsole # look ma i AM the computer!
- type: CommunicationsConsole - type: CommunicationsConsole
title: comms-console-announcement-title-centcom title: comms-console-announcement-title-centcom
@ -83,6 +91,11 @@
- type: CrewMonitoringConsole - type: CrewMonitoringConsole
- type: GeneralStationRecordConsole - type: GeneralStationRecordConsole
canDeleteEntries: true canDeleteEntries: true
# Begin CD - Character Records
- type: CharacterRecordConsole
consoleType: Admin
- type: CriminalRecordsConsole
# End CD - Character Records
- type: DeviceNetwork - type: DeviceNetwork
deviceNetId: Wireless deviceNetId: Wireless
receiveFrequencyId: CrewMonitor receiveFrequencyId: CrewMonitor
@ -174,3 +187,18 @@
keywords: [ "AI", "console", "interface" ] keywords: [ "AI", "console", "interface" ]
priority: -7 priority: -7
event: !type:ToggleIntrinsicUIEvent { key: enum.GeneralStationRecordConsoleKey.Key } event: !type:ToggleIntrinsicUIEvent { key: enum.GeneralStationRecordConsoleKey.Key }
# Begin CD - Character Records
- type: entity
id: ActionAGhostShowCharacterRecords
name: Character Records Interface
description: View all of the character records
categories: [ HideSpawnMenu ]
components:
- type: InstantAction
icon: { sprite: Structures/Machines/parts.rsi, state: box_0 }
iconOn: Structures/Machines/parts.rsi/box_2.png
keywords: [ "AI", "console", "interface" ]
priority: -10
event: !type:ToggleIntrinsicUIEvent { key: enum.CharacterRecordConsoleKey.Key }
# Edn CD - Character Records

View File

@ -360,6 +360,16 @@
color: "#1f8c28" color: "#1f8c28"
- type: Computer - type: Computer
board: MedicalRecordsComputerCircuitboard board: MedicalRecordsComputerCircuitboard
# Begin CD - Character Records
- type: CharacterRecordConsole
consoleType: Medical
- type: UserInterface
interfaces:
enum.CharacterRecordConsoleKey.Key:
type: CharacterRecordConsoleBoundUserInterface
- type: ActivatableUI
key: enum.CharacterRecordConsoleKey.Key
# End CD - Character Records
- type: entity - type: entity
parent: BaseComputerAiAccess parent: BaseComputerAiAccess
@ -370,12 +380,18 @@
- type: CriminalRecordsConsole - type: CriminalRecordsConsole
- type: UserInterface - type: UserInterface
interfaces: interfaces:
enum.CriminalRecordsConsoleKey.Key: # Begin CD - Character Records
type: CriminalRecordsConsoleBoundUserInterface enum.CharacterRecordConsoleKey.Key:
type: CharacterRecordConsoleBoundUserInterface
# End CD - Character Records
enum.WiresUiKey.Key: enum.WiresUiKey.Key:
type: WiresBoundUserInterface type: WiresBoundUserInterface
# Begin CD - Character Records
- type: CharacterRecordConsole
consoleType: Security
- type: ActivatableUI - type: ActivatableUI
key: enum.CriminalRecordsConsoleKey.Key key: enum.CharacterRecordConsoleKey.Key
# End CD - Character Records
- type: Sprite - type: Sprite
layers: layers:
- map: ["computerLayerBody"] - map: ["computerLayerBody"]
@ -409,12 +425,18 @@
- type: GeneralStationRecordConsole - type: GeneralStationRecordConsole
- type: UserInterface - type: UserInterface
interfaces: interfaces:
enum.GeneralStationRecordConsoleKey.Key: # Begin CD - Character Records
type: GeneralStationRecordConsoleBoundUserInterface enum.CharacterRecordConsoleKey.Key:
type: CharacterRecordConsoleBoundUserInterface
# End CD - Character Records
enum.WiresUiKey.Key: enum.WiresUiKey.Key:
type: WiresBoundUserInterface type: WiresBoundUserInterface
# Begin CD - Character Records
- type: CharacterRecordConsole
consoleType: Employment
- type: ActivatableUI - type: ActivatableUI
key: enum.GeneralStationRecordConsoleKey.Key key: enum.CharacterRecordConsoleKey.Key
# End CD - Character Records
- type: PointLight - type: PointLight
radius: 1.5 radius: 1.5
energy: 1.6 energy: 1.6

View File

@ -25,7 +25,8 @@
text: "/ServerInfo/Guidebook/NewPlayer/CharacterCreation.xml" text: "/ServerInfo/Guidebook/NewPlayer/CharacterCreation.xml"
children: children:
- YourFirstCharacter - YourFirstCharacter
- Species - Species
- Records # DeltaV - CD records page goes here
- type: guideEntry - type: guideEntry
id: YourFirstCharacter id: YourFirstCharacter

View File

@ -11,6 +11,7 @@
femaleFirstNames: NamesOniFemale femaleFirstNames: NamesOniFemale
lastNames: NamesOniLocation lastNames: NamesOniLocation
naming: LastNoFirst naming: LastNoFirst
baseScale: "1.2, 1.2"
- type: markingPoints - type: markingPoints
id: MobOniMarkingLimits id: MobOniMarkingLimits

View File

@ -7,6 +7,7 @@
markingLimits: MobFelinidMarkingLimits markingLimits: MobFelinidMarkingLimits
dollPrototype: MobFelinidDummy dollPrototype: MobFelinidDummy
skinColoration: Hues # DeltaV skinColoration: Hues # DeltaV
baseScale: "0.8, 0.8"
- type: markingPoints - type: markingPoints
id: MobFelinidMarkingLimits id: MobFelinidMarkingLimits

View File

@ -0,0 +1,4 @@
- type: guideEntry
id: Records
name: guide-entry-cd-records
text: "/ServerInfo/Guidebook/_CD/Records.xml"

View File

@ -7,6 +7,7 @@
markingLimits: MobHarpyMarkingLimits markingLimits: MobHarpyMarkingLimits
dollPrototype: MobHarpyDummy dollPrototype: MobHarpyDummy
skinColoration: HumanToned skinColoration: HumanToned
baseScale: "0.9, 0.9"
- type: speciesBaseSprites - type: speciesBaseSprites
id: MobHarpySprites id: MobHarpySprites

View File

@ -12,6 +12,7 @@
femaleFirstNames: NamesRodentiaFemale femaleFirstNames: NamesRodentiaFemale
lastNames: NamesRodentiaLast lastNames: NamesRodentiaLast
naming: LastFirst naming: LastFirst
baseScale: "0.8, 0.8"
- type: speciesBaseSprites - type: speciesBaseSprites
id: MobRodentiaSprites id: MobRodentiaSprites

View File

@ -15,6 +15,7 @@
- Male - Male
- Female - Female
- Unsexed - Unsexed
baseScale: "1, 1.05" # DeltaV - make sure Thaven are still scaled w/ CD records
- type: speciesBaseSprites - type: speciesBaseSprites
id: MobThavenSprites id: MobThavenSprites

View File

@ -0,0 +1,40 @@
<Document>
## Character Records
You can create records for you characters in the records tab of the character
creator. Just simply fill in the values and save. Entries should typically be
written from the perspective of a CentCom official.
## Employment Entries
Employment Entries should include your characters education, previous jobs, and
anything that you think NanoTrasen would log about your characters past that is
not security related.
## Medical Entries
Medical Entries should include anything a doctor should know before treating
your character, major surgeries, prescriptions, and any psychological evaluations.
## Security Entries
Security Entries should include any major arrests/incidents your character has
had in their past or while working with NanoTrasen. Do not log minor incidents.
## Accessing Records
You can access the records of your crewmates in game from their respective consoles.
<Box>
<GuideEntityEmbed Entity="ComputerMedicalRecords" Caption="Medical"/>
<GuideEntityEmbed Entity="ComputerCriminalRecords" Caption="Security"/>
<GuideEntityEmbed Entity="ComputerStationRecords" Caption="Station"/>
</Box>
It is currently not possible to create new records in game. This is something
that will be added in the future. If there is something that occurs in the round
that you think is relevant for a record entry, it is on you to write it at the
moment. Keep in mind that records should typically be written from the
perspective of some CentCom official.
</Document>