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]
+ - Делай с ним что хочешь — бесполезен. Отличный кандидат для тестов.
-Теперь ты знаешь всё, чтобы не закончить смену в виде кровавого облака.
+ Теперь ты знаешь всё, чтобы не закончить смену в виде кровавого облака.