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