From cbabbd7955db49c8f03bf5df6dd2bb09f68d22f3 Mon Sep 17 00:00:00 2001 From: Tayrtahn Date: Thu, 12 Sep 2024 06:31:56 -0400 Subject: [PATCH] Add guidebook protodata tag: embed prototype values in guidebook text (#27570) * First clumsy implementation of guidebook protodata * Support for Properties, format strings * Better null * Rename file * Documentation and some cleanup * Handle errors better * Added note about client-side components * A couple of examples * DataFields * Use attributes like a sane person * Outdated doc * string.Empty * No IComponent? * No casting * Use EntityManager.ComponentFactory * Use FrozenDictionary * Cache tagged component fields * Iterate components and check if they're tagged (cherry picked from commit 320135347ffc01f078a89b10322d7952e3af92e4) --- .../Guidebook/GuidebookDataSystem.cs | 45 +++++++ .../Guidebook/Richtext/ProtodataTag.cs | 49 +++++++ .../Guidebook/GuidebookDataSystem.cs | 111 ++++++++++++++++ .../Components/OnUseTimerTriggerComponent.cs | 12 ++ Content.Shared/Guidebook/Events.cs | 25 ++++ Content.Shared/Guidebook/GuidebookData.cs | 99 ++++++++++++++ .../Guidebook/GuidebookDataAttribute.cs | 12 ++ .../Power/Generator/FuelGeneratorComponent.cs | 18 +-- .../ServerInfo/Guidebook/Security/Defusal.xml | 124 +++++++++--------- 9 files changed, 425 insertions(+), 70 deletions(-) create mode 100644 Content.Client/Guidebook/GuidebookDataSystem.cs create mode 100644 Content.Client/Guidebook/Richtext/ProtodataTag.cs create mode 100644 Content.Server/Guidebook/GuidebookDataSystem.cs create mode 100644 Content.Shared/Guidebook/Events.cs create mode 100644 Content.Shared/Guidebook/GuidebookData.cs create mode 100644 Content.Shared/Guidebook/GuidebookDataAttribute.cs diff --git a/Content.Client/Guidebook/GuidebookDataSystem.cs b/Content.Client/Guidebook/GuidebookDataSystem.cs new file mode 100644 index 0000000000..f47ad6ef1b --- /dev/null +++ b/Content.Client/Guidebook/GuidebookDataSystem.cs @@ -0,0 +1,45 @@ +using Content.Shared.Guidebook; + +namespace Content.Client.Guidebook; + +/// +/// Client system for storing and retrieving values extracted from entity prototypes +/// for display in the guidebook (). +/// Requests data from the server on . +/// Can also be pushed new data when the server reloads prototypes. +/// +public sealed class GuidebookDataSystem : EntitySystem +{ + private GuidebookData? _data; + + public override void Initialize() + { + base.Initialize(); + + SubscribeNetworkEvent(OnServerUpdated); + + // Request data from the server + RaiseNetworkEvent(new RequestGuidebookDataEvent()); + } + + private void OnServerUpdated(UpdateGuidebookDataEvent args) + { + // Got new data from the server, either in response to our request, or because prototypes reloaded on the server + _data = args.Data; + _data.Freeze(); + } + + /// + /// Attempts to retrieve a value using the given identifiers. + /// See for more information. + /// + public bool TryGetValue(string prototype, string component, string field, out object? value) + { + if (_data == null) + { + value = null; + return false; + } + return _data.TryGetValue(prototype, component, field, out value); + } +} diff --git a/Content.Client/Guidebook/Richtext/ProtodataTag.cs b/Content.Client/Guidebook/Richtext/ProtodataTag.cs new file mode 100644 index 0000000000..a725fd4e4b --- /dev/null +++ b/Content.Client/Guidebook/Richtext/ProtodataTag.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using Robust.Client.UserInterface.RichText; +using Robust.Shared.Utility; + +namespace Content.Client.Guidebook.RichText; + +/// +/// RichText tag that can display values extracted from entity prototypes. +/// In order to be accessed by this tag, the desired field/property must +/// be tagged with . +/// +public sealed class ProtodataTag : IMarkupTag +{ + [Dependency] private readonly ILogManager _logMan = default!; + [Dependency] private readonly IEntityManager _entMan = default!; + + public string Name => "protodata"; + private ISawmill Log => _log ??= _logMan.GetSawmill("protodata_tag"); + private ISawmill? _log; + + public string TextBefore(MarkupNode node) + { + // Do nothing with an empty tag + if (!node.Value.TryGetString(out var prototype)) + return string.Empty; + + if (!node.Attributes.TryGetValue("comp", out var component)) + return string.Empty; + if (!node.Attributes.TryGetValue("member", out var member)) + return string.Empty; + node.Attributes.TryGetValue("format", out var format); + + var guidebookData = _entMan.System(); + + // Try to get the value + if (!guidebookData.TryGetValue(prototype, component.StringValue!, member.StringValue!, out var value)) + { + Log.Error($"Failed to find protodata for {component}.{member} in {prototype}"); + return "???"; + } + + // If we have a format string and a formattable value, format it as requested + if (!string.IsNullOrEmpty(format.StringValue) && value is IFormattable formattable) + return formattable.ToString(format.StringValue, CultureInfo.CurrentCulture); + + // No format string given, so just use default ToString + return value?.ToString() ?? "NULL"; + } +} diff --git a/Content.Server/Guidebook/GuidebookDataSystem.cs b/Content.Server/Guidebook/GuidebookDataSystem.cs new file mode 100644 index 0000000000..86a6344156 --- /dev/null +++ b/Content.Server/Guidebook/GuidebookDataSystem.cs @@ -0,0 +1,111 @@ +using System.Reflection; +using Content.Shared.Guidebook; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Content.Server.Guidebook; + +/// +/// Server system for identifying component fields/properties to extract values from entity prototypes. +/// Extracted data is sent to clients when they connect or when prototypes are reloaded. +/// +public sealed class GuidebookDataSystem : EntitySystem +{ + [Dependency] private readonly IPrototypeManager _protoMan = default!; + + private readonly Dictionary> _tagged = []; + private GuidebookData _cachedData = new(); + + public override void Initialize() + { + base.Initialize(); + + SubscribeNetworkEvent(OnRequestRules); + SubscribeLocalEvent(OnPrototypesReloaded); + + // Build initial cache + GatherData(ref _cachedData); + } + + private void OnRequestRules(RequestGuidebookDataEvent ev, EntitySessionEventArgs args) + { + // Send cached data to requesting client + var sendEv = new UpdateGuidebookDataEvent(_cachedData); + RaiseNetworkEvent(sendEv, args.SenderSession); + } + + private void OnPrototypesReloaded(PrototypesReloadedEventArgs args) + { + // We only care about entity prototypes + if (!args.WasModified()) + return; + + // The entity prototypes changed! Clear our cache and regather data + RebuildDataCache(); + + // Send new data to all clients + var ev = new UpdateGuidebookDataEvent(_cachedData); + RaiseNetworkEvent(ev); + } + + private void GatherData(ref GuidebookData cache) + { + // Just for debug metrics + var memberCount = 0; + var prototypeCount = 0; + + if (_tagged.Count == 0) + { + // Scan component registrations to find members tagged for extraction + foreach (var registration in EntityManager.ComponentFactory.GetAllRegistrations()) + { + foreach (var member in registration.Type.GetMembers()) + { + if (member.HasCustomAttribute()) + { + // Note this component-member pair for later + _tagged.GetOrNew(registration.Name).Add(member); + memberCount++; + } + } + } + } + + // Scan entity prototypes for the component-member pairs we noted + var entityPrototypes = _protoMan.EnumeratePrototypes(); + foreach (var prototype in entityPrototypes) + { + foreach (var (component, entry) in prototype.Components) + { + if (!_tagged.TryGetValue(component, out var members)) + continue; + + prototypeCount++; + + foreach (var member in members) + { + // It's dumb that we can't just do member.GetValue, but we can't, so + var value = member switch + { + FieldInfo field => field.GetValue(entry.Component), + PropertyInfo property => property.GetValue(entry.Component), + _ => throw new NotImplementedException("Unsupported member type") + }; + // Add it into the data cache + cache.AddData(prototype.ID, component, member.Name, value); + } + } + } + + Log.Debug($"Collected {cache.Count} Guidebook Protodata value(s) - {prototypeCount} matched prototype(s), {_tagged.Count} component(s), {memberCount} member(s)"); + } + + /// + /// Clears the cached data, then regathers it. + /// + private void RebuildDataCache() + { + _cachedData.Clear(); + GatherData(ref _cachedData); + } +} diff --git a/Content.Shared/Explosion/Components/OnUseTimerTriggerComponent.cs b/Content.Shared/Explosion/Components/OnUseTimerTriggerComponent.cs index c4e6e787a4..983b8a31ee 100644 --- a/Content.Shared/Explosion/Components/OnUseTimerTriggerComponent.cs +++ b/Content.Shared/Explosion/Components/OnUseTimerTriggerComponent.cs @@ -1,3 +1,5 @@ +using System.Linq; +using Content.Shared.Guidebook; using Robust.Shared.Audio; using Robust.Shared.GameStates; @@ -50,5 +52,15 @@ namespace Content.Shared.Explosion.Components /// Whether or not to show the user a popup when starting the timer. /// [DataField] public bool DoPopup = true; + + #region GuidebookData + + [GuidebookData] + public float? ShortestDelayOption => DelayOptions?.Min(); + + [GuidebookData] + public float? LongestDelayOption => DelayOptions?.Max(); + + #endregion GuidebookData } } diff --git a/Content.Shared/Guidebook/Events.cs b/Content.Shared/Guidebook/Events.cs new file mode 100644 index 0000000000..e43bf4392c --- /dev/null +++ b/Content.Shared/Guidebook/Events.cs @@ -0,0 +1,25 @@ +using Robust.Shared.Serialization; + +namespace Content.Shared.Guidebook; + +/// +/// Raised by the client on GuidebookDataSystem Initialize to request a +/// full set of guidebook data from the server. +/// +[Serializable, NetSerializable] +public sealed class RequestGuidebookDataEvent : EntityEventArgs { } + +/// +/// Raised by the server at a specific client in response to . +/// Also raised by the server at ALL clients when prototype data is hot-reloaded. +/// +[Serializable, NetSerializable] +public sealed class UpdateGuidebookDataEvent : EntityEventArgs +{ + public GuidebookData Data; + + public UpdateGuidebookDataEvent(GuidebookData data) + { + Data = data; + } +} diff --git a/Content.Shared/Guidebook/GuidebookData.cs b/Content.Shared/Guidebook/GuidebookData.cs new file mode 100644 index 0000000000..703940ed1e --- /dev/null +++ b/Content.Shared/Guidebook/GuidebookData.cs @@ -0,0 +1,99 @@ +using System.Collections.Frozen; +using Robust.Shared.Serialization; +using Robust.Shared.Utility; + +namespace Content.Shared.Guidebook; + +/// +/// Used by GuidebookDataSystem to hold data extracted from prototype values, +/// both for storage and for network transmission. +/// +[Serializable, NetSerializable] +[DataDefinition] +public sealed partial class GuidebookData +{ + /// + /// Total number of data values stored. + /// + [DataField] + public int Count { get; private set; } + + /// + /// The data extracted by the system. + /// + /// + /// Structured as PrototypeName, ComponentName, FieldName, Value + /// + [DataField] + public Dictionary>> Data = []; + + /// + /// The data extracted by the system, converted to a FrozenDictionary for faster lookup. + /// + public FrozenDictionary>> FrozenData; + + /// + /// Has the data been converted to a FrozenDictionary for faster lookup? + /// This should only be done on clients, as FrozenDictionary isn't serializable. + /// + public bool IsFrozen; + + /// + /// Adds a new value using the given identifiers. + /// + public void AddData(string prototype, string component, string field, object? value) + { + if (IsFrozen) + throw new InvalidOperationException("Attempted to add data to GuidebookData while it is frozen!"); + Data.GetOrNew(prototype).GetOrNew(component).Add(field, value); + Count++; + } + + /// + /// Attempts to retrieve a value using the given identifiers. + /// + /// true if the value was retrieved, otherwise false + public bool TryGetValue(string prototype, string component, string field, out object? value) + { + if (!IsFrozen) + throw new InvalidOperationException("Freeze the GuidebookData before calling TryGetValue!"); + + // Look in frozen dictionary + if (FrozenData.TryGetValue(prototype, out var p) + && p.TryGetValue(component, out var c) + && c.TryGetValue(field, out value)) + { + return true; + } + + value = null; + return false; + } + + /// + /// Deletes all data. + /// + public void Clear() + { + Data.Clear(); + Count = 0; + IsFrozen = false; + } + + public void Freeze() + { + var protos = new Dictionary>>(); + foreach (var (protoId, protoData) in Data) + { + var comps = new Dictionary>(); + foreach (var (compId, compData) in protoData) + { + comps.Add(compId, FrozenDictionary.ToFrozenDictionary(compData)); + } + protos.Add(protoId, FrozenDictionary.ToFrozenDictionary(comps)); + } + FrozenData = FrozenDictionary.ToFrozenDictionary(protos); + Data.Clear(); + IsFrozen = true; + } +} diff --git a/Content.Shared/Guidebook/GuidebookDataAttribute.cs b/Content.Shared/Guidebook/GuidebookDataAttribute.cs new file mode 100644 index 0000000000..2b83892b88 --- /dev/null +++ b/Content.Shared/Guidebook/GuidebookDataAttribute.cs @@ -0,0 +1,12 @@ +namespace Content.Shared.Guidebook; + +/// +/// Indicates that GuidebookDataSystem should include this field/property when +/// scanning entity prototypes for values to extract. +/// +/// +/// Note that this will not work for client-only components, because the data extraction +/// is done on the server (it uses reflection, which is blocked by the sandbox on clients). +/// +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +public sealed class GuidebookDataAttribute : Attribute { } diff --git a/Content.Shared/Power/Generator/FuelGeneratorComponent.cs b/Content.Shared/Power/Generator/FuelGeneratorComponent.cs index cdf97fb085..1cdb22a109 100644 --- a/Content.Shared/Power/Generator/FuelGeneratorComponent.cs +++ b/Content.Shared/Power/Generator/FuelGeneratorComponent.cs @@ -1,4 +1,5 @@ -using Robust.Shared.GameStates; +using Content.Shared.Guidebook; +using Robust.Shared.GameStates; namespace Content.Shared.Power.Generator; @@ -17,19 +18,20 @@ public sealed partial class FuelGeneratorComponent : Component /// /// Is the generator currently running? /// - [DataField("on"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField] + [DataField, AutoNetworkedField] public bool On; /// /// The generator's target power. /// - [DataField("targetPower"), ViewVariables(VVAccess.ReadWrite)] + [DataField] public float TargetPower = 15_000.0f; /// /// The maximum target power. /// - [DataField("maxTargetPower"), ViewVariables(VVAccess.ReadWrite)] + [DataField] + [GuidebookData] public float MaxTargetPower = 30_000.0f; /// @@ -38,24 +40,24 @@ public sealed partial class FuelGeneratorComponent : Component /// /// Setting this to any value above 0 means that the generator can't idle without consuming some amount of fuel. /// - [DataField("minTargetPower"), ViewVariables(VVAccess.ReadWrite)] + [DataField] public float MinTargetPower = 1_000; /// /// The "optimal" power at which the generator is considered to be at 100% efficiency. /// - [DataField("optimalPower"), ViewVariables(VVAccess.ReadWrite)] + [DataField] public float OptimalPower = 15_000.0f; /// /// The rate at which one unit of fuel should be consumed. /// - [DataField("optimalBurnRate"), ViewVariables(VVAccess.ReadWrite)] + [DataField] public float OptimalBurnRate = 1 / 60.0f; // Once every 60 seconds. /// /// A constant used to calculate fuel efficiency in relation to target power output and optimal power output /// - [DataField("fuelEfficiencyConstant")] + [DataField] public float FuelEfficiencyConstant = 1.3f; } diff --git a/Resources/ServerInfo/Guidebook/Security/Defusal.xml b/Resources/ServerInfo/Guidebook/Security/Defusal.xml index 2c142a934b..56670853c0 100644 --- a/Resources/ServerInfo/Guidebook/Security/Defusal.xml +++ b/Resources/ServerInfo/Guidebook/Security/Defusal.xml @@ -1,90 +1,90 @@  -# Обезвреживание Крупных Бомб + # Обезвреживание Крупных Бомб -Ну что, ты нашёл большую бомбу, и она уже пикает? Эти штуки детонируют не сразу, но оставляют после себя здоровенную дыру в корпусе станции. Просто читай дальше — и, возможно, не взорвёшься. + Ну что, ты нашёл большую бомбу, и она уже пикает? Эти штуки детонируют не сразу, но оставляют после себя здоровенную дыру в корпусе станции. Просто читай дальше — и, возможно, не взорвёшься. -## Снаряжение + ## Снаряжение -Для обезвреживания тебе понадобятся два основных инструмента, а мультитул поможет безопасно опознавать провода: - - - - - + Для обезвреживания тебе понадобятся два основных инструмента, а мультитул поможет безопасно опознавать провода: + + + + + -Для защиты от превращения в фарш пригодится [color=yellow]костюм сапёра[/color] или любое другое защитное снаряжение: - - - - - - + Для защиты от превращения в фарш пригодится [color=yellow]костюм сапёра[/color] или любое другое защитное снаряжение: + + + + + + -## Типы Бомб + ## Типы Бомб -Чаще всего тебе встретятся два вида: + Чаще всего тебе встретятся два вида: - - - - + + + + -Учебная бомба наносит лишь лёгкие повреждения и вряд ли убьёт. А вот синдикатовская устроит шоу с фейерверками, если не облачён в скафандр. + Учебная бомба наносит лишь лёгкие повреждения и вряд ли убьёт. А вот синдикатовская устроит шоу с фейерверками, если не облачён в скафандр. -## Активация + ## Активация -Чтобы активировать бомбу, [color=yellow]кликни правой кнопкой[/color] и выбери [color=yellow]Начать отсчёт[/color], либо [color=yellow]альт-кликни[/color] по ней. После этого она начнёт пикать. + Чтобы активировать бомбу, [color=yellow]кликни правой кнопкой[/color] и выбери [color=yellow]Начать отсчёт[/color], либо [color=yellow]альт-кликни[/color] по ней. После этого она начнёт пикать. -## Время + ## Время -Бомба тикает от 90 до 300 секунд. Время можно узнать, осмотрев бомбу, если только не перерезан провод Proceed. Как только отсчёт дойдёт до нуля — бабах. + Бомба тикает от [protodata="SyndicateBomb" comp="OnUseTimerTrigger" member="ShortestDelayOption"/] до [protodata="SyndicateBomb" comp="OnUseTimerTrigger" member="LongestDelayOption"/] секунд. Время можно узнать, осмотрев бомбу, если только не перерезан провод Proceed. Как только отсчёт дойдёт до нуля — бабах. -## Болты + ## Болты -После активации бомба сама прикручивается к полу. Найди провод BOLT и перережь его, чтобы открутить её. После этого можно выкинуть бомбу в космос. + После активации бомба сама прикручивается к полу. Найди провод BOLT и перережь его, чтобы открутить её. После этого можно выкинуть бомбу в космос. -## Провода + ## Провода -Открывай крышку отвёрткой и готовься к разгадыванию проводной головоломки. В стандартной синдикатовской бомбе около [color=yellow]10 проводов[/color]: -- 3 — бесполезные (dummy), -- [color=red]3 — вызовут взрыв[/color], -- остальные выполняют полезные функции. + Открывай крышку отвёрткой и готовься к разгадыванию проводной головоломки. В стандартной синдикатовской бомбе около [color=yellow]10 проводов[/color]: + - 3 — бесполезные (dummy), + - [color=red]3 — вызовут взрыв[/color], + - остальные выполняют полезные функции. -Ты можешь: -- [color=yellow]Пульсировать[/color] мультитулом — безопасно, полезно. -- [color=red]Резать[/color] кусачками — может взорваться. Осторожно! -- [color=green]Соединять[/color] обратно — иногда полезно. + Ты можешь: + - [color=yellow]Пульсировать[/color] мультитулом — безопасно, полезно. + - [color=red]Резать[/color] кусачками — может взорваться. Осторожно! + - [color=green]Соединять[/color] обратно — иногда полезно. -## Типы Проводов + ## Типы Проводов -[color=#a4885c]Провод Активации (LIVE)[/color] -- [color=yellow]Пульс[/color]: Отложит взрыв на 30 сек. -- [color=red]Резка[/color]: Обезвредит бомбу, если та активна. Если нет — активирует таймер. -- [color=green]Соединение[/color]: Бесполезно. + [color=#a4885c]Провод Активации (LIVE)[/color] + - [color=yellow]Пульс[/color]: Отложит взрыв на 30 сек. + - [color=red]Резка[/color]: Обезвредит бомбу, если та активна. Если нет — активирует таймер. + - [color=green]Соединение[/color]: Бесполезно. -[color=#a4885c]Провод Отсчёта (PRCD)[/color] -- [color=yellow]Пульс[/color]: Ускорит таймер на 15 сек. -- [color=red]Резка[/color]: Скрывает таймер на осмотре. -- [color=green]Соединение[/color]: Бесполезно. + [color=#a4885c]Провод Отсчёта (PRCD)[/color] + - [color=yellow]Пульс[/color]: Ускорит таймер на 15 сек. + - [color=red]Резка[/color]: Скрывает таймер на осмотре. + - [color=green]Соединение[/color]: Бесполезно. -[color=#a4885c]Провод Задержки (DLAY)[/color] -- [color=yellow]Пульс[/color]: Замедляет бомбу на 30 сек. -- [color=red]Резка[/color]: Ничего. -- [color=green]Соединение[/color]: Ничего. + [color=#a4885c]Провод Задержки (DLAY)[/color] + - [color=yellow]Пульс[/color]: Замедляет бомбу на 30 сек. + - [color=red]Резка[/color]: Ничего. + - [color=green]Соединение[/color]: Ничего. -[color=#a4885c]Провод БУМ (BOOM)[/color] -- [color=yellow]Пульс[/color]: [color=red]БАБАХ, если бомба активна![/color] -- [color=red]Резка[/color]: [color=red]БАБАХ, если активна![/color] Иначе — отключает бомбу. -- [color=green]Соединение[/color]: Повторно активирует бомбу. + [color=#a4885c]Провод БУМ (BOOM)[/color] + - [color=yellow]Пульс[/color]: [color=red]БАБАХ, если бомба активна![/color] + - [color=red]Резка[/color]: [color=red]БАБАХ, если активна![/color] Иначе — отключает бомбу. + - [color=green]Соединение[/color]: Повторно активирует бомбу. -[color=#a4885c]Провод Болтов (BOLT)[/color] -- [color=yellow]Пульс[/color]: Визуально прокручивает болты. -- [color=red]Резка[/color]: Открепляет бомбу от пола. -- [color=green]Соединение[/color]: Прикручивает снова. + [color=#a4885c]Провод Болтов (BOLT)[/color] + - [color=yellow]Пульс[/color]: Визуально прокручивает болты. + - [color=red]Резка[/color]: Открепляет бомбу от пола. + - [color=green]Соединение[/color]: Прикручивает снова. -[color=#a4885c]Фальшивый Провод (Dummy)[/color] -- Делай с ним что хочешь — бесполезен. Отличный кандидат для тестов. + [color=#a4885c]Фальшивый Провод (Dummy)[/color] + - Делай с ним что хочешь — бесполезен. Отличный кандидат для тестов. -Теперь ты знаешь всё, чтобы не закончить смену в виде кровавого облака. + Теперь ты знаешь всё, чтобы не закончить смену в виде кровавого облака.