diff --git a/Content.Server/Objectives/Components/ChangelingEscapeDepartmentConditionComponent.cs b/Content.Server/Objectives/Components/ChangelingEscapeDepartmentConditionComponent.cs new file mode 100644 index 0000000000..1cb63d2c45 --- /dev/null +++ b/Content.Server/Objectives/Components/ChangelingEscapeDepartmentConditionComponent.cs @@ -0,0 +1,23 @@ +using Content.Server.Objectives.Systems; +using Content.Shared.Roles; +using Robust.Shared.Prototypes; + +namespace Content.Server.Objectives.Components; + +/// +/// Requires that the changeling: +/// 1. Is on the emergency shuttle when docking to CentComm +/// 2. Is currently transformed into the identity of someone in the target department +/// 3. Is wearing an ID card with the matching dept +/// Depends on to know which target department to check for. +/// +[RegisterComponent] +[Access(typeof(ChangelingEscapeDepartmentConditionSystem))] +public sealed partial class ChangelingEscapeDepartmentConditionComponent : Component +{ + /// + /// The department that is targeted by the objective. + /// + [DataField] + public ProtoId? Department; +} diff --git a/Content.Server/Objectives/Components/TargetObjectiveComponent.cs b/Content.Server/Objectives/Components/TargetObjectiveComponent.cs index c0cd521bc7..39f3f8b4e5 100644 --- a/Content.Server/Objectives/Components/TargetObjectiveComponent.cs +++ b/Content.Server/Objectives/Components/TargetObjectiveComponent.cs @@ -7,7 +7,8 @@ public sealed partial class TargetObjectiveComponent : Component { /// /// Locale id for the objective title. - /// It is passed "targetName" and "job" arguments. + /// It is passed "targetName", "job" and "department" arguments. + /// Standing for the name of the target, their job, and their job's department. /// [DataField(required: true), ViewVariables(VVAccess.ReadWrite)] public string Title = string.Empty; diff --git a/Content.Server/Objectives/Systems/ChangelingEscapeDepartmentConditionSystem.cs b/Content.Server/Objectives/Systems/ChangelingEscapeDepartmentConditionSystem.cs new file mode 100644 index 0000000000..8f7d0b2b3e --- /dev/null +++ b/Content.Server/Objectives/Systems/ChangelingEscapeDepartmentConditionSystem.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; +using System.Linq; +using Content.Server.Objectives.Components; +using Content.Server.Shuttles.Systems; +using Content.Shared.Access.Systems; +using Content.Shared.Changeling.Systems; +using Content.Shared.Cuffs; +using Content.Shared.Mind; +using Content.Shared.Objectives.Components; +using Content.Shared.Roles.Jobs; +using Robust.Shared.Random; + +namespace Content.Server.Objectives.Systems; + +/// +/// Makes it so that works. +/// +public sealed partial class ChangelingEscapeDepartmentConditionSystem : EntitySystem +{ + [Dependency] private EmergencyShuttleSystem _emergencyShuttle = default!; + [Dependency] private SharedChangelingIdentitySystem _changelingIdentity = default!; + [Dependency] private SharedCuffableSystem _cuffable = default!; + [Dependency] private SharedIdCardSystem _idCard = default!; + [Dependency] private SharedMindSystem _mind = default!; + [Dependency] private TargetObjectiveSystem _target = default!; + [Dependency] private SharedJobSystem _job = default!; + [Dependency] private IRobustRandom _random = default!; + + [SubscribeLocalEvent] + private void OnAfterAssign(Entity ent, ref ObjectiveAfterAssignEvent args) + { + if (!_target.GetTarget(ent, out var target)) + return; + + // Does our target have a valid job? + if (!_job.MindTryGetJobId(target.Value, out var jobPrototype) || !jobPrototype.HasValue) + return; + + // We get the department of the job for our target, prioritizing the secondary departments (such as Command) + // We do this to allow the objective to roll Command. + // Otherwise RD/HoS/QM would return Science/Security/Cargo instead of Command. + if (!_job.TryGetSecondaryDepartmentsOrFallback(jobPrototype.Value, out var departmentPrototypes)) + return; + + Debug.Assert(departmentPrototypes.Count > 0, $"Attempted to assign a job without departments to objective {MetaData(ent).EntityPrototype}!"); + + // Get a random department. + // This is all done on the server, so no need to worry about mispredictions. + ent.Comp.Department = _random.Pick(departmentPrototypes); + } + + [SubscribeLocalEvent] + private void OnGetProgress(Entity ent, ref ObjectiveGetProgressEvent args) + { + args.Progress = GetProgress(ent, (args.MindId, args.Mind)); + } + + private float GetProgress(Entity ent, Entity mind) + { + var ownedEntity = mind.Comp.OwnedEntity; + if (ownedEntity == null || _mind.IsCharacterDeadIc(mind)) + return 0f; + + // Check 1: Must have transformed into a target with a job that is in the department + if (!_changelingIdentity.TryGetCurrentIdentityData(ownedEntity.Value, out var identityData)) + return 0f; // this should not happen + + // Our identity must have a job. + var job = identityData.OriginalJob; + if (!job.HasValue) + return 0f; + + if (ent.Comp.Department == null || !_job.JobIsInDepartment(job.Value, ent.Comp.Department.Value)) + return 0f; // The job of our identity is not in the target department or the department doesn't exist. + + // Check 2: Must escape alive. + if (!_emergencyShuttle.IsTargetEscaping(ownedEntity.Value)) + return 0.5f; + + if (_cuffable.IsCuffed(ownedEntity.Value)) + return 0.5f; + + // Check 3: Must wear an ID card with the target's name on it. + if (!_idCard.TryFindIdCard(ownedEntity.Value, out var idCard)) + return 0.75f; // is not wearing an id + + if (idCard.Comp.JobDepartments.All(idDept => idDept.Id != ent.Comp.Department)) + return 0.75f; // found ID is not of the correct department. + + return 1f; + } +} diff --git a/Content.Server/Objectives/Systems/TargetObjectiveSystem.cs b/Content.Server/Objectives/Systems/TargetObjectiveSystem.cs index 3dc9c0ce09..93d541b063 100644 --- a/Content.Server/Objectives/Systems/TargetObjectiveSystem.cs +++ b/Content.Server/Objectives/Systems/TargetObjectiveSystem.cs @@ -62,7 +62,16 @@ public sealed partial class TargetObjectiveSystem : EntitySystem } var jobName = _job.MindTryGetJobName(target); - return Loc.GetString(title, ("targetName", targetName), ("job", jobName)); + + var deptName = Loc.GetString("department-Unknown"); + if (_job.MindTryGetJobId(target, out var jobId)) + { + if (jobId.HasValue && _job.TryGetDepartment(jobId.Value, out var deptProto)) + { + deptName = Loc.GetString(deptProto.Name); + } + } + return Loc.GetString(title, ("targetName", targetName), ("job", jobName), ("department", deptName)); } } diff --git a/Content.Shared/EntityConditions/Conditions/Mind/DifferentDepartmentConditionSystem.cs b/Content.Shared/EntityConditions/Conditions/Mind/DifferentDepartmentConditionSystem.cs new file mode 100644 index 0000000000..76885fc82c --- /dev/null +++ b/Content.Shared/EntityConditions/Conditions/Mind/DifferentDepartmentConditionSystem.cs @@ -0,0 +1,58 @@ +using System.Linq; +using Content.Shared.Mind; +using Content.Shared.Roles; +using Content.Shared.Roles.Jobs; +using Content.Shared.Throwing; +using Robust.Shared.Prototypes; + +namespace Content.Shared.EntityConditions.Conditions.Mind; + +public sealed partial class DifferentDepartmentConditionSystem : EntityConditionSystem +{ + [Dependency] private SharedRoleSystem _roleSystem = default!; + [Dependency] private SharedJobSystem _jobSystem = default!; + + protected override void Condition(Entity entity, ref EntityConditionEvent args) + { + args.Result = !IsInvalid(entity, args.SourceEnt); + } + + private bool IsInvalid(Entity mind, EntityUid? exclude) + { + // no entity to exclude depts, so all depts are valid + if (!exclude.HasValue) + return false; + + if (!_jobSystem.MindTryGetJobId(exclude.Value, out var objJob)) + return false; // in no department, so all departments are valid + + if (!_jobSystem.MindTryGetJobId(mind.Owner, out var job)) + return false; // target in no department, so all depts are valid + + if (!objJob.HasValue || !job.HasValue) + throw new Exception("Unreachable statement after getting job proto from mind."); + + // get all departments + if (!_jobSystem.TryGetAllDepartments(objJob.Value, out var deptsA) || !_jobSystem.TryGetAllDepartments(job.Value, out var deptsB)) + throw new Exception("Job didn't have any department assigned."); + + // perform the department check + if (deptsA.Select(dept => dept.ID).Intersect(deptsB.Select(dept => dept.ID)).Any()) + return true; + + return false; + } +} + +/// +/// A condition that requires minds to have a job with a different department from the excluded entity's. +/// This uses mind roles, not ID cards. +/// +public sealed partial class DifferentDepartmentCondition : EntityConditionBase +{ + public override string EntityConditionGuidebookText(IPrototypeManager prototype) + { + return string.Empty; + } +} + diff --git a/Content.Shared/Roles/Jobs/SharedJobSystem.cs b/Content.Shared/Roles/Jobs/SharedJobSystem.cs index 99de88e408..a2bcc6f36d 100644 --- a/Content.Shared/Roles/Jobs/SharedJobSystem.cs +++ b/Content.Shared/Roles/Jobs/SharedJobSystem.cs @@ -117,6 +117,68 @@ public abstract partial class SharedJobSystem : EntitySystem return false; } + /// + /// Like but tries to get any non-primary department(s) first. + /// For all heads (including the captain), it will return Command, but for John Scientist it will return Science. + /// + /// True if a department was found, false if not. + public bool TryGetSecondaryDepartmentsOrFallback(ProtoId jobProto, [NotNullWhen(true)] out List? departmentPrototypes) + { + departmentPrototypes = new List(); + + // not sorting it since there should only be 1 primary department for a job. + // this is enforced by the job tests. + var departmentProtos = ProtoMan.EnumeratePrototypes(); + foreach (var department in departmentProtos) + { + if (!department.Primary && department.Roles.Contains(jobProto)) + { + departmentPrototypes.Add(department); + } + } + + if (departmentPrototypes.Count > 0) + return true; + + // if we don't have any secondary dept, try to get a primary + if (TryGetPrimaryDepartment(jobProto, out var primaryDept)) + { + departmentPrototypes.Add(primaryDept); + return true; + } + + // no department was found + return false; + } + + /// + /// Gets all departments for a mind's job (if any) and checks if the chosen department is among them. + /// + /// The mind to check for. + /// The department proto ID to check for. + /// True if the mind is a part of the department. + public bool MindIsInDepartment(EntityUid mind, ProtoId deptProto) + { + if (!MindTryGetJobId(mind, out var job)) + return false; + + if (!job.HasValue) + return false; + + return JobIsInDepartment(job.Value, deptProto); + } + + /// + /// Checks if the job is contained within a department, and returns true if it does. + /// + public bool JobIsInDepartment(ProtoId jobProto, ProtoId deptProto) + { + if (!TryGetAllDepartments(jobProto, out var depts)) + return false; + + return depts.Any(dept => dept.ID == deptProto); + } + /// /// Tries to get all the departments for a given job. Will return an empty list if none are found. /// diff --git a/Resources/Locale/en-US/objectives/conditions/changeling.ftl b/Resources/Locale/en-US/objectives/conditions/changeling.ftl index 6507ee97d8..27f48eaae1 100644 --- a/Resources/Locale/en-US/objectives/conditions/changeling.ftl +++ b/Resources/Locale/en-US/objectives/conditions/changeling.ftl @@ -2,3 +2,5 @@ objective-unique-identity-name = Devour {$count} unique humanoids. objective-unique-identity-description = We must devour {$count} different humanoids for sustenance. objective-escape-identity-title = Escape as {$targetName}, {CAPITALIZE($job)}. + +objective-escape-department-title = Escape as a member of the {CAPITALIZE($department)} department. diff --git a/Resources/Prototypes/Objectives/changeling.yml b/Resources/Prototypes/Objectives/changeling.yml index a2a9cc3b2b..ca513dbeca 100644 --- a/Resources/Prototypes/Objectives/changeling.yml +++ b/Resources/Prototypes/Objectives/changeling.yml @@ -17,7 +17,7 @@ description: We must stay alive at all cost. components: - type: Objective - difficulty: 1 + difficulty: 0.5 icon: sprite: Mobs/Species/Human/organs.rsi state: heart-on @@ -53,11 +53,37 @@ - type: ChangelingEscapeIdentityCondition - type: PickRandomPerson conditions: - - !type:ObjectiveTargetCondition + - !type:RoleCondition + inverted: true # Do not roll changelings here. Their initial identity will be basically impossible to get after they transform. + whitelist: + components: + - ChangelingRole + +- type: entity + parent: [BaseChangelingObjective, BaseLivingObjective] + id: ChangelingEscapeDepartmentObjective + description: We must escape with the identity & ID card of any member of this department. + components: + - type: Objective + difficulty: 1 + icon: + sprite: Objects/Fun/Plushies/atmosian.rsi + state: icon + - type: TargetObjective + title: objective-escape-department-title + - type: ChangelingEscapeDepartmentCondition + - type: PickRandomPerson + conditions: + - !type:RoleCondition # Do not roll changelings here. It will cause problems with weird job mindroles as they transform n stuff. inverted: true whitelist: components: - ChangelingRole + - !type:RoleCondition + whitelist: + components: + - JobRole # Our target has to be employed, obviously. + - !type:DifferentDepartmentCondition # I love spawning as a scientist and having to escape as a scientist - type: entity parent: [BaseChangelingObjective, BaseKillObjective] diff --git a/Resources/Prototypes/Objectives/objectiveGroups.yml b/Resources/Prototypes/Objectives/objectiveGroups.yml index 9ecc2cb8c5..363f840c96 100644 --- a/Resources/Prototypes/Objectives/objectiveGroups.yml +++ b/Resources/Prototypes/Objectives/objectiveGroups.yml @@ -157,8 +157,8 @@ id: ChangelingObjectiveGroupEscape weights: ChangelingSurviveObjective: 0.5 - ChangelingEscapeIdentityObjective: 1 - # eventually: escape as a job/dept + ChangelingEscapeDepartmentObjective: 1.0 + ChangelingEscapeIdentityObjective: 1.5 - type: weightedRandom id: ChangelingObjectiveGroupKillDummy