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
This commit is contained in:
Tayrtahn
2024-09-12 06:31:56 -04:00
committed by GitHub
parent c8f2ddc729
commit 320135347f
10 changed files with 366 additions and 11 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

@@ -16,7 +16,7 @@
<GuideEntityEmbed Entity="WeldingFuelTank" />
</Box>
The J.R.P.A.C.M.A.N. can be found across the station in maintenance shafts, and is ideal for crew to set up themselves whenever there are power issues.
The J.R.P.A.C.M.A.N. can be found across the station in maintenance shafts, and is ideal for crew to set up themselves whenever there are power issues. Its output of up to [color=orange][protodata="PortableGeneratorJrPacman" comp="FuelGenerator" member="MaxTargetPower" format="N0"/] W[/color] is enough to power a few important devices.
Setup is incredibly easy: wrench it down above an [color=green]LV[/color] power cable, give it some welding fuel, and start it up.
Welding fuel should be plentiful to find around the station. In a pinch, you can even transfer some from the big tanks with a soda can or water bottle. Just remember to empty the soda can first, I don't think it likes soda as fuel.
@@ -35,7 +35,7 @@
The (S.U.P.E.R.)P.A.C.M.A.N. is intended for usage by engineering for advanced power scenarios. Bootstrapping larger engines, powering departments, and so on.
The S.U.P.E.R.P.A.C.M.A.N. boasts a larger power output and longer runtime at maximum output, but scales down to lower outputs less efficiently.
The S.U.P.E.R.P.A.C.M.A.N. boasts a larger power output (up to [color=orange][protodata="PortableGeneratorSuperPacman" comp="FuelGenerator" member="MaxTargetPower" format="N0"/] W[/color]) and longer runtime at maximum output, but scales down to lower outputs less efficiently.
They connect directly to [color=yellow]MV[/color] or [color=orange]HV[/color] power cables, and are able to switch between them for flexibility.

View File

@@ -29,7 +29,7 @@
To arm a bomb, you can either [color=yellow]right click[/color] and click [color=yellow]Begin countdown[/click], or [color=yellow]alt-click[/color] the bomb. It will begin beeping.
## Time
A bomb has a limited time, at a minimum of 90 and a maximum of 300. You can view the timer by examining it, unless the Proceed wire is cut. Once the timer hits zero, the bomb will detonate.
A bomb has a limited time, at a minimum of [protodata="SyndicateBomb" comp="OnUseTimerTrigger" member="ShortestDelayOption"/] seconds and a maximum of [protodata="SyndicateBomb" comp="OnUseTimerTrigger" member="LongestDelayOption"/] seconds. You can view the timer by examining it, unless the Proceed wire is cut. Once the timer hits zero, the bomb will detonate.
## Bolts
By default, once armed, a bomb will bolt itself to the ground. You must find the BOLT wire and cut it to disable the bolts, after which you can unwrench it and throw it into space.