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)
This commit is contained in:
Tayrtahn
2024-09-12 06:31:56 -04:00
committed by Spatison
parent 06a75b09cc
commit cbabbd7955
9 changed files with 425 additions and 70 deletions

View File

@@ -0,0 +1,45 @@
using Content.Shared.Guidebook;
namespace Content.Client.Guidebook;
/// <summary>
/// Client system for storing and retrieving values extracted from entity prototypes
/// for display in the guidebook (<see cref="RichText.ProtodataTag"/>).
/// Requests data from the server on <see cref="Initialize"/>.
/// Can also be pushed new data when the server reloads prototypes.
/// </summary>
public sealed class GuidebookDataSystem : EntitySystem
{
private GuidebookData? _data;
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<UpdateGuidebookDataEvent>(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();
}
/// <summary>
/// Attempts to retrieve a value using the given identifiers.
/// See <see cref="GuidebookData.TryGetValue"/> for more information.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,49 @@
using System.Globalization;
using Robust.Client.UserInterface.RichText;
using Robust.Shared.Utility;
namespace Content.Client.Guidebook.RichText;
/// <summary>
/// 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 <see cref="Shared.Guidebook.GuidebookDataAttribute"/>.
/// </summary>
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<GuidebookDataSystem>();
// 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";
}
}

View File

@@ -0,0 +1,111 @@
using System.Reflection;
using Content.Shared.Guidebook;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Server.Guidebook;
/// <summary>
/// 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.
/// </summary>
public sealed class GuidebookDataSystem : EntitySystem
{
[Dependency] private readonly IPrototypeManager _protoMan = default!;
private readonly Dictionary<string, List<MemberInfo>> _tagged = [];
private GuidebookData _cachedData = new();
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<RequestGuidebookDataEvent>(OnRequestRules);
SubscribeLocalEvent<PrototypesReloadedEventArgs>(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<EntityPrototype>())
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<GuidebookDataAttribute>())
{
// 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<EntityPrototype>();
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)");
}
/// <summary>
/// Clears the cached data, then regathers it.
/// </summary>
private void RebuildDataCache()
{
_cachedData.Clear();
GatherData(ref _cachedData);
}
}

View File

@@ -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.
/// </summary>
[DataField] public bool DoPopup = true;
#region GuidebookData
[GuidebookData]
public float? ShortestDelayOption => DelayOptions?.Min();
[GuidebookData]
public float? LongestDelayOption => DelayOptions?.Max();
#endregion GuidebookData
}
}

View File

@@ -0,0 +1,25 @@
using Robust.Shared.Serialization;
namespace Content.Shared.Guidebook;
/// <summary>
/// Raised by the client on GuidebookDataSystem Initialize to request a
/// full set of guidebook data from the server.
/// </summary>
[Serializable, NetSerializable]
public sealed class RequestGuidebookDataEvent : EntityEventArgs { }
/// <summary>
/// Raised by the server at a specific client in response to <see cref="RequestGuidebookDataEvent"/>.
/// Also raised by the server at ALL clients when prototype data is hot-reloaded.
/// </summary>
[Serializable, NetSerializable]
public sealed class UpdateGuidebookDataEvent : EntityEventArgs
{
public GuidebookData Data;
public UpdateGuidebookDataEvent(GuidebookData data)
{
Data = data;
}
}

View File

@@ -0,0 +1,99 @@
using System.Collections.Frozen;
using Robust.Shared.Serialization;
using Robust.Shared.Utility;
namespace Content.Shared.Guidebook;
/// <summary>
/// Used by GuidebookDataSystem to hold data extracted from prototype values,
/// both for storage and for network transmission.
/// </summary>
[Serializable, NetSerializable]
[DataDefinition]
public sealed partial class GuidebookData
{
/// <summary>
/// Total number of data values stored.
/// </summary>
[DataField]
public int Count { get; private set; }
/// <summary>
/// The data extracted by the system.
/// </summary>
/// <remarks>
/// Structured as PrototypeName, ComponentName, FieldName, Value
/// </remarks>
[DataField]
public Dictionary<string, Dictionary<string, Dictionary<string, object?>>> Data = [];
/// <summary>
/// The data extracted by the system, converted to a FrozenDictionary for faster lookup.
/// </summary>
public FrozenDictionary<string, FrozenDictionary<string, FrozenDictionary<string, object?>>> FrozenData;
/// <summary>
/// Has the data been converted to a FrozenDictionary for faster lookup?
/// This should only be done on clients, as FrozenDictionary isn't serializable.
/// </summary>
public bool IsFrozen;
/// <summary>
/// Adds a new value using the given identifiers.
/// </summary>
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++;
}
/// <summary>
/// Attempts to retrieve a value using the given identifiers.
/// </summary>
/// <returns>true if the value was retrieved, otherwise false</returns>
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;
}
/// <summary>
/// Deletes all data.
/// </summary>
public void Clear()
{
Data.Clear();
Count = 0;
IsFrozen = false;
}
public void Freeze()
{
var protos = new Dictionary<string, FrozenDictionary<string, FrozenDictionary<string, object?>>>();
foreach (var (protoId, protoData) in Data)
{
var comps = new Dictionary<string, FrozenDictionary<string, object?>>();
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;
}
}

View File

@@ -0,0 +1,12 @@
namespace Content.Shared.Guidebook;
/// <summary>
/// Indicates that GuidebookDataSystem should include this field/property when
/// scanning entity prototypes for values to extract.
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public sealed class GuidebookDataAttribute : Attribute { }

View File

@@ -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
/// <summary>
/// Is the generator currently running?
/// </summary>
[DataField("on"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField]
[DataField, AutoNetworkedField]
public bool On;
/// <summary>
/// The generator's target power.
/// </summary>
[DataField("targetPower"), ViewVariables(VVAccess.ReadWrite)]
[DataField]
public float TargetPower = 15_000.0f;
/// <summary>
/// The maximum target power.
/// </summary>
[DataField("maxTargetPower"), ViewVariables(VVAccess.ReadWrite)]
[DataField]
[GuidebookData]
public float MaxTargetPower = 30_000.0f;
/// <summary>
@@ -38,24 +40,24 @@ public sealed partial class FuelGeneratorComponent : Component
/// <remarks>
/// Setting this to any value above 0 means that the generator can't idle without consuming some amount of fuel.
/// </remarks>
[DataField("minTargetPower"), ViewVariables(VVAccess.ReadWrite)]
[DataField]
public float MinTargetPower = 1_000;
/// <summary>
/// The "optimal" power at which the generator is considered to be at 100% efficiency.
/// </summary>
[DataField("optimalPower"), ViewVariables(VVAccess.ReadWrite)]
[DataField]
public float OptimalPower = 15_000.0f;
/// <summary>
/// The rate at which one unit of fuel should be consumed.
/// </summary>
[DataField("optimalBurnRate"), ViewVariables(VVAccess.ReadWrite)]
[DataField]
public float OptimalBurnRate = 1 / 60.0f; // Once every 60 seconds.
/// <summary>
/// A constant used to calculate fuel efficiency in relation to target power output and optimal power output
/// </summary>
[DataField("fuelEfficiencyConstant")]
[DataField]
public float FuelEfficiencyConstant = 1.3f;
}

View File

@@ -1,90 +1,90 @@
<Document>
# Обезвреживание Крупных Бомб
# Обезвреживание Крупных Бомб
Ну что, ты нашёл большую бомбу, и она уже пикает? Эти штуки детонируют не сразу, но оставляют после себя здоровенную дыру в корпусе станции. Просто читай дальше — и, возможно, не взорвёшься.
Ну что, ты нашёл большую бомбу, и она уже пикает? Эти штуки детонируют не сразу, но оставляют после себя здоровенную дыру в корпусе станции. Просто читай дальше — и, возможно, не взорвёшься.
## Снаряжение
## Снаряжение
Для обезвреживания тебе понадобятся два основных инструмента, а мультитул поможет безопасно опознавать провода:
<Box>
<GuideEntityEmbed Entity="Wirecutter"/>
<GuideEntityEmbed Entity="Screwdriver"/>
<GuideEntityEmbed Entity="Multitool"/>
</Box>
Для обезвреживания тебе понадобятся два основных инструмента, а мультитул поможет безопасно опознавать провода:
<Box>
<GuideEntityEmbed Entity="Wirecutter"/>
<GuideEntityEmbed Entity="Screwdriver"/>
<GuideEntityEmbed Entity="Multitool"/>
</Box>
Для защиты от превращения в фарш пригодится [color=yellow]костюм сапёра[/color] или любое другое защитное снаряжение:
<Box>
<GuideEntityEmbed Entity="ClothingHeadHelmetBombSuit"/>
<GuideEntityEmbed Entity="ClothingOuterSuitBomb"/>
<GuideEntityEmbed Entity="ClothingOuterHardsuitRd"/>
<GuideEntityEmbed Entity="ClothingOuterHardsuitAtmos"/>
</Box>
Для защиты от превращения в фарш пригодится [color=yellow]костюм сапёра[/color] или любое другое защитное снаряжение:
<Box>
<GuideEntityEmbed Entity="ClothingHeadHelmetBombSuit"/>
<GuideEntityEmbed Entity="ClothingOuterSuitBomb"/>
<GuideEntityEmbed Entity="ClothingOuterHardsuitRd"/>
<GuideEntityEmbed Entity="ClothingOuterHardsuitAtmos"/>
</Box>
## Типы Бомб
## Типы Бомб
Чаще всего тебе встретятся два вида:
Чаще всего тебе встретятся два вида:
<Box>
<GuideEntityEmbed Entity="SyndicateBomb"/>
<GuideEntityEmbed Entity="TrainingBomb"/>
</Box>
<Box>
<GuideEntityEmbed Entity="SyndicateBomb"/>
<GuideEntityEmbed Entity="TrainingBomb"/>
</Box>
Учебная бомба наносит лишь лёгкие повреждения и вряд ли убьёт. А вот синдикатовская устроит шоу с фейерверками, если не облачён в скафандр.
Учебная бомба наносит лишь лёгкие повреждения и вряд ли убьёт. А вот синдикатовская устроит шоу с фейерверками, если не облачён в скафандр.
## Активация
## Активация
Чтобы активировать бомбу, [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]
- Делай с ним что хочешь — бесполезен. Отличный кандидат для тестов.
Теперь ты знаешь всё, чтобы не закончить смену в виде кровавого облака.
Теперь ты знаешь всё, чтобы не закончить смену в виде кровавого облака.
</Document>