diff --git a/Content.Shared/_DEN/Requirements/Managers/PlayerRequirementManager.Context.cs b/Content.Shared/_DEN/Requirements/Managers/PlayerRequirementManager.Context.cs new file mode 100644 index 0000000000..1737ea121f --- /dev/null +++ b/Content.Shared/_DEN/Requirements/Managers/PlayerRequirementManager.Context.cs @@ -0,0 +1,22 @@ +using Content.Shared.Players.PlayTimeTracking; +using Content.Shared.Preferences; +using Robust.Shared.Prototypes; + +namespace Content.Shared._DEN.Requirements.Managers; + +/// +/// A record that represents a list of factors that will be checked against PlayerRequirements. +/// All fields are nullable. A null field represents an optional parameter. +/// +public partial record PlayerRequirementContext +{ + /// + /// The currently-selected character profile of the player we are going to check. + /// + public HumanoidCharacterProfile? Profile = null; + + /// + /// A dictionary of registered playtimes for the player. + /// + public Dictionary, TimeSpan>? Playtimes = null; +} diff --git a/Content.Shared/_DEN/Requirements/Managers/PlayerRequirementManager.cs b/Content.Shared/_DEN/Requirements/Managers/PlayerRequirementManager.cs new file mode 100644 index 0000000000..8eafb3d728 --- /dev/null +++ b/Content.Shared/_DEN/Requirements/Managers/PlayerRequirementManager.cs @@ -0,0 +1,37 @@ +using Content.Shared._DEN.Requirements.PlayerRequirements; +using JetBrains.Annotations; + +namespace Content.Shared._DEN.Requirements.Managers; + +public sealed partial class PlayerRequirementManager +{ + /// + /// Check a context against enumerable requirements and gets the final pass/fail status of these requirements. + /// + /// The context containing fields to check against the requirements. + /// An enumerable collection of requirements. + /// Whether or not this context passes *all* requirements. If even one fails, then this is false. + [PublicAPI] + public static bool CheckRequirements(PlayerRequirementContext context, IEnumerable requirements) + { + foreach (var requirement in requirements) + { + // Pre-check the requirement. This ensures our context has all the fields needed for the requirement. + // If the pre-check fails, whether or not this requirement passes depends on requirement.MustPassPreCheck. + // If you don't need to pass the pre-check, then it's an auto-success. + if (!requirement.PreCheck(context)) + { + if (requirement.MustPassPreCheck) + return false; + + continue; + } + + // Check the actual requirement, now. + if (!requirement.CheckRequirement(context)) + return false; + } + + return true; + } +} diff --git a/Content.Shared/_DEN/Requirements/PlayerRequirements/IPlayerRequirement.cs b/Content.Shared/_DEN/Requirements/PlayerRequirements/IPlayerRequirement.cs new file mode 100644 index 0000000000..14a99088db --- /dev/null +++ b/Content.Shared/_DEN/Requirements/PlayerRequirements/IPlayerRequirement.cs @@ -0,0 +1,67 @@ +using Content.Shared._DEN.Requirements.Managers; + +namespace Content.Shared._DEN.Requirements.PlayerRequirements; + +/// +/// This interface is used to check some parameters associated with a player (a +/// ) to determine whether or not the player is allowed to +/// use a certain feature. +/// +/// +/// This is a replacement of the old JobRequirement system. +/// +[ImplicitDataDefinitionForInheritors] +public partial interface IPlayerRequirement +{ + /// + /// Whether or not this requirement is inverted. + /// In all cases where this requirement should fail, it will succeed instead, and vice versa. + /// + [DataField] + bool Inverted { get; set; } + + /// + /// Requirements undergo a "PreCheck" to check if the context value has all relevant parameters. + /// If this is false, then the requirement auto-passes the requirement if the pre-check fails. + /// + /// + /// Say you have a requirement that checks your playtimes. In the pre-check, we check if the + /// context's playtimes are non-null. If your playtimes are null, we fail the pre-check. + /// + /// If MustPassPreCheck is false, then this will treat it as auto-passing the requirement; + /// we ignore the requirement entirely. If MustPassPreCheck is true, we auto-fail the + /// requirement instead. + /// + [DataField] + bool MustPassPreCheck { get; set; } + + /// + /// Check if the given context has all parameters required in order to perform an actual check. + /// + /// + /// Because context parameters are meant to be optional, we can use this information to ignore + /// (auto-succeed) checks where we lack all the needded parameters, depending on the value of + /// . + /// + /// A definition of parameters to check against the requirement. + /// + bool PreCheck(PlayerRequirementContext context); + + /// + /// Check a context's fields against this requirement to determine if it passes or fails. + /// + /// A definition of parameters to check against the requirement. + /// Whether or not the given context passes this requirement. + bool CheckRequirement(PlayerRequirementContext context); + + /// + /// Get a localized string representing how this requirement displays to players. + /// + /// + /// This should, ideally, display regardless if the context actually passes or not. + /// It should also display differently depending on the value of . + /// + /// A definition of parameters to check against the requirement. + /// An optional string representing the requirement text to display to a player. + string? GetReason(PlayerRequirementContext context); +} diff --git a/Content.Shared/_DEN/Requirements/PlayerRequirements/PlayerRequirements.Playtime.cs b/Content.Shared/_DEN/Requirements/PlayerRequirements/PlayerRequirements.Playtime.cs new file mode 100644 index 0000000000..9ccb893f1d --- /dev/null +++ b/Content.Shared/_DEN/Requirements/PlayerRequirements/PlayerRequirements.Playtime.cs @@ -0,0 +1,174 @@ +using Content.Shared._DEN.Requirements.Managers; +using Content.Shared.Roles; +using Robust.Shared.Prototypes; + +namespace Content.Shared._DEN.Requirements.PlayerRequirements; + +/// +/// An abstract class for playtime requirements that expect a playtime to be within +/// optional minimum and maximum parameters. +/// +public abstract partial class PlayerPlaytimeRequirement : IPlayerRequirement +{ + /// + [DataField] public bool Inverted { get; set; } = false; + + /// + [DataField] public bool MustPassPreCheck { get; set; } = false; + + /// + /// The minimum time you can have in this tracker. + /// + [DataField] public TimeSpan? MinTime = null; + + /// + /// The maximum time you can have in this tracker. + /// + [DataField] public TimeSpan? MaxTime = null; + + /// + public bool PreCheck(PlayerRequirementContext context) + { + return context.Playtimes != null; + } + + /// + public abstract bool CheckRequirement(PlayerRequirementContext context); + + /// + public abstract string? GetReason(PlayerRequirementContext context); + + /// + /// Check if a given playtime tracker fits within the minimum and maximum times of this requirement. + /// + /// The playtime to check. + /// Whether or not this playtime is valid. + protected bool IsValidPlaytime(TimeSpan playtime) + { + if (MinTime != null & playtime < MinTime) + return false; + + if (MaxTime != null & playtime > MaxTime) + return false; + + return true; + } + + /// + /// Gets a localized "reason" string for this requirement's playtime ranges. + /// + /// + /// For example: "Less than 30 minutes", "At least 120 minutes", "Between 20 minutes and 180 minutes". + /// + /// + /// A string describing how much playtime you should have. Null if both minimum and maximum as null. + /// + protected string? GetPlaytimeConstraintReason() + { + if (MinTime == null && MaxTime == null) + return null; + + var minTimeString = FormatPlaytime(MinTime); + var maxTimeString = FormatPlaytime(MaxTime); + + return (MinTime, MaxTime) switch + { + (not null, not null) => Loc.GetString("player-requirement-playtime-minmax-time", + ("minimum", minTimeString), ("maximum", maxTimeString)), + + (null, not null) => Loc.GetString("player-requirement-playtime-maximum-time", + ("playtime", maxTimeString)), + + (not null, null) => Loc.GetString("player-requirement-playtime-minimum-time", + ("playtime", minTimeString)), + + _ => null + }; + } + + /// + /// Gets a localized time string for the given playtime. + /// + /// The playtime to format. + /// A localized time string for this playtime. + private static string FormatPlaytime(TimeSpan? playtime) + { + var time = ((int?)playtime?.TotalMinutes) ?? 0; + + return playtime != null + ? Loc.GetString("player-requirement-playtime-time", ("playtime", time)) + : string.Empty; + } +} + +/// +/// Checks if a player's total playtime in a given department fits within a given playtime range. +/// +public sealed partial class PlayerDepartmentPlaytimeRequirement : PlayerPlaytimeRequirement +{ + /// + /// The department we should check against the requirement. + /// + [DataField(required: true)] + public ProtoId Department = default!; + + /// + public override bool CheckRequirement(PlayerRequirementContext context) + { + var playtime = GetDepartmentPlaytime(context); + if (playtime is null) + return false; + + return IsValidPlaytime(playtime.Value); + } + + /// + public override string? GetReason(PlayerRequirementContext context) + { + // Get the department name. + var protoMan = IoCManager.Resolve(); + if (!protoMan.TryIndex(Department, out var department)) + return null; + + var deptName = Loc.GetString(department.Name); + + // Get the playtime constraint string. + var playtimeString = GetPlaytimeConstraintReason(); + if (playtimeString == null) + return null; + + // E.g. "You must have 120 minutes in the Science department." + return Loc.GetString("player-requirement-department-playtime-reason", + ("timeConstraint", playtimeString), + ("department", deptName)); + } + + /// + /// Get the total playtime for this department. + /// + /// A definition of parameters to check against the requirement. + /// The total playtime of this department. Null if either context playtimes or department is invalid. + private TimeSpan? GetDepartmentPlaytime(PlayerRequirementContext context) + { + var protoMan = IoCManager.Resolve(); + var playtime = TimeSpan.Zero; + + if (context.Playtimes == null + || !protoMan.TryIndex(Department, out var department)) + return null; + + // Sum the playtimes of all roles in this department. + foreach (var roleId in department.Roles) + { + if (!protoMan.TryIndex(roleId, out var role)) + continue; + + if (!context.Playtimes.TryGetValue(role.PlayTimeTracker, out var roleTime)) + continue; + + playtime += roleTime; + } + + return playtime; + } +} diff --git a/Resources/Locale/en-US/_DEN/requirements/playtime-requirements.ftl b/Resources/Locale/en-US/_DEN/requirements/playtime-requirements.ftl new file mode 100644 index 0000000000..4da5160306 --- /dev/null +++ b/Resources/Locale/en-US/_DEN/requirements/playtime-requirements.ftl @@ -0,0 +1,6 @@ +player-requirement-playtime-time = {$playtime} minutes +player-requirement-playtime-minimum-time = at least {$playtime} +player-requirement-playtime-maximum-time = less than {$playtime} +player-requirement-playtime-minmax-time = between {$minimum} and {$maximum} + +player-requirement-department-playtime-reason = Must have {$timeConstraint} in the {$department} department.