Files
wwdpublic/Content.Server/NPC/Systems/NPCUtilitySystem.cs
RedFoxIV daf4f66414 "Proper" "Softcrit" "Support" (#1545)
# Description

Implements the softcrit functionality.
Similiar to critical state but spessmen will be able to communicate and
crawl around, but not pick up items.
Also supports configuring what is and isn't allowed in different
MobStates (per mob prototype): you can enable picking up items while in
softcrit so people can pick up their lasgun and continue shooting after
taking a 40x46mm to their ass cheeks from the guest nukies while being
dragged to safety.

![escape-from-tarkov-raid](https://github.com/user-attachments/assets/7f31702d-5677-4daf-a13d-8a9525fd3f9f)

<details> <summary><h1>Technical details</h1></summary>
New prototype type: "mobStateParams" (`MobStateParametersPrototype`)
Used to specify what can and can't be done when in a certain mobstate.
Of note that they are not actually bound to any `MobState` by
themselves. To assign a params prototype to a mobstate, use
`InitMobStateParams` in `MobStateComponent`.
It has to be a prototype because if I just did something akin to
`Dictionary<MobState, Dictionary<string, bool>>`, you'd have to check
the parent and copy every flag besides the one you wish to modify. That
is, if I understand how the prototype system works correctly, which I
frankly doubt. <!-- Working on softcrit made me hate prototypes. -->

MobStateComponent now has:
- `Dictionary<string, string> InitMobStateParams`, for storing "mobstate
- parameter prototype" pairs. `<string, string>` because it has to be
editable via mob prototypes. Named "mobStateParams" for mob prototypes.
- `public Dictionary<MobState, MobStateParametersPrototype>
MobStateParams` for actually storing the params for each state
- `public Dictionary<MobState, MobStateParametersOverride>
MobStateParamsOverrides` for storing overrides.
`MobStateParametersOverride` is a struct which mirrors all
`MobStateParametersPrototype`'s fields, except they're all nullable.
This is meant for code which wants to temporarily override some setting,
like a spell which allows dead people to talk. This is not the best
solution, but it should do at first. A better option would be tracking
each change separately, instead of hoping different systems overriding
the same flag will play nicely with eachother.
- a shitton of getter methods

TraitModifyMobState now has:
- `public Dictionary<string, string> Params` to specify a new prototype
to use.
- Important note: All values of `MobStateParametersPrototype` are
nullable, which is a hack to support `TraitModifyMobState`. This trait
takes one `MobStateParametersPrototype` per mobstate and applies all of
its non-null values. This way, a params prototype can be created which
will only have `pointing: true` and the trait can apply it (e.g. to
critstate, so we can spam pointing while dying like it's a game of turbo
dota)
- The above is why that wall of getters exists: They check the relevant
override struct, then the relevant prototype. If both are null, they
default to false (0f for floats.) The only exception is
OxyDamageOverlay, because it's used both for oxy damage overlay (if
null) and as a vision-limiting black void in crit..

MobStateSystem now has:
- a bunch of new "IsSomething"/"CanDoSomething" methods to check the
various flags, alongside rewritten old ones.
-
![image](https://github.com/user-attachments/assets/33a6b296-c12c-4311-9abe-90ca4288e871)
lookin ahh predicate factory

</details>
---

# TODO

done:
- [x] Make proper use of `MobStateSystem.IsIncapacitated()`.
done: some checks were changed, some left as they did what was (more or
less) intended.
<details>Previous `IsIncapacitated()` implementation simply checked if
person was in crit or dead. Now there is a `IsIncapacitated` flag in the
parameters, but it's heavily underutilized. I may need some help on this
one, since I don't know where would be a good place to check for it and
I absolutely will not just scour the entire build in search for them.
</details>

- [x] Separate force-dropping items from being downed
done: dropItemsOnEntering bool field. If true, will drop items upon
entering linked mobstate.
- [x] Don't drop items if `ForceDown` is true but `PickingUp` is also
true.
done: dropItemsOnEntering bool field. If true, will drop items upon
entering linked mobstate.
- [x] Actually check what are "conscious attempts" are used for
done: whether or not mob is conscious. Renamed the bool field
accordingly.
- [x] Look into adding a way to make people choke "slowly" in softcrit
as opposed to choking at "regular speed" in crit. Make that into a param
option? Make that into a float so the speed can be finetuned?
done: `BreathingMultiplier` float field added.
<details>
1f is regular breathing, 0.25 is "quarter-breathing". Air taken is
multiplied by `BreathingMultiplier` and suffocation damage taken (that
is dealt by RespiratorSystem, not all oxy damage) is multiplied by
`1-BreathingMultiplier`.
</details>

- [x] make sure the serializer actually does its job
done: it doesn't. Removed.
- [x] Make an option to prohibit using radio headsets while in softcrit
done: Requires Incapacitated parameter to be false to be able to use
headset radio.
- [x] Make sure it at least compiles

not done:
- [ ] probably move some other stuff to Params if it makes sense. Same
thing as with `IsIncapacitated` though: I kinda don't want to, at least
for now.

---

<details><summary><h1>No media</h1></summary>
<p>

:p

</p>
</details>

---

# Changelog

🆑
- add: Soft critical state. Crawl to safety, or to your doom - whatever
is closer.

---------

Signed-off-by: RedFoxIV <38788538+RedFoxIV@users.noreply.github.com>
Co-authored-by: VMSolidus <evilexecutive@gmail.com>

(cherry picked from commit 9a357c1774f1a783844a07b5414f504ca574d84c)
2025-02-15 00:12:50 +03:00

551 lines
19 KiB
C#

using Content.Server.Chemistry.Containers.EntitySystems;
using Content.Server.Fluids.EntitySystems;
using Content.Server.NPC.Queries;
using Content.Server.NPC.Queries.Considerations;
using Content.Server.NPC.Queries.Curves;
using Content.Server.NPC.Queries.Queries;
using Content.Server.Nutrition.Components;
using Content.Server.Nutrition.EntitySystems;
using Content.Server.Storage.Components;
using Content.Shared.Examine;
using Content.Shared.Fluids.Components;
using Content.Shared.Hands.Components;
using Content.Shared.Inventory;
using Content.Shared.Mobs.Systems;
using Content.Shared.NPC.Systems;
using Content.Shared.Nutrition.Components;
using Content.Shared.Nutrition.EntitySystems;
using Content.Shared.Tools.Systems;
using Content.Shared.Weapons.Melee;
using Content.Shared.Weapons.Ranged.Components;
using Content.Shared.Weapons.Ranged.Events;
using Content.Shared.Whitelist;
using Microsoft.Extensions.ObjectPool;
using Robust.Server.Containers;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
using System.Linq;
namespace Content.Server.NPC.Systems;
/// <summary>
/// Handles utility queries for NPCs.
/// </summary>
public sealed class NPCUtilitySystem : EntitySystem
{
[Dependency] private readonly IPrototypeManager _proto = default!;
[Dependency] private readonly ContainerSystem _container = default!;
[Dependency] private readonly DrinkSystem _drink = default!;
[Dependency] private readonly EntityLookupSystem _lookup = default!;
[Dependency] private readonly FoodSystem _food = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly MobStateSystem _mobState = default!;
[Dependency] private readonly NpcFactionSystem _npcFaction = default!;
[Dependency] private readonly OpenableSystem _openable = default!;
[Dependency] private readonly PuddleSystem _puddle = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
[Dependency] private readonly SolutionContainerSystem _solutions = default!;
[Dependency] private readonly WeldableSystem _weldable = default!;
[Dependency] private readonly ExamineSystemShared _examine = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
private EntityQuery<PuddleComponent> _puddleQuery;
private EntityQuery<TransformComponent> _xformQuery;
private ObjectPool<HashSet<EntityUid>> _entPool =
new DefaultObjectPool<HashSet<EntityUid>>(new SetPolicy<EntityUid>(), 256);
// Temporary caches.
private List<EntityUid> _entityList = new();
private HashSet<Entity<IComponent>> _entitySet = new();
private List<EntityPrototype.ComponentRegistryEntry> _compTypes = new();
public override void Initialize()
{
base.Initialize();
_puddleQuery = GetEntityQuery<PuddleComponent>();
_xformQuery = GetEntityQuery<TransformComponent>();
}
/// <summary>
/// Runs the UtilityQueryPrototype and returns the best-matching entities.
/// </summary>
/// <param name="bestOnly">Should we only return the entity with the best score.</param>
public UtilityResult GetEntities(
NPCBlackboard blackboard,
string proto,
bool bestOnly = true)
{
// TODO: PickHostilesop or whatever needs to juse be UtilityQueryOperator
var weh = _proto.Index<UtilityQueryPrototype>(proto);
var ents = _entPool.Get();
foreach (var query in weh.Query)
{
switch (query)
{
case UtilityQueryFilter filter:
Filter(blackboard, ents, filter);
break;
default:
Add(blackboard, ents, query);
break;
}
}
if (ents.Count == 0)
{
_entPool.Return(ents);
return UtilityResult.Empty;
}
var results = new Dictionary<EntityUid, float>();
var highestScore = 0f;
foreach (var ent in ents)
{
if (results.Count > weh.Limit)
break;
var score = 1f;
foreach (var con in weh.Considerations)
{
var conScore = GetScore(blackboard, ent, con);
var curve = con.Curve;
var curveScore = GetScore(curve, conScore);
var adjusted = GetAdjustedScore(curveScore, weh.Considerations.Count);
score *= adjusted;
// If the score is too low OR we only care about best entity then early out.
// Due to the adjusted score only being able to decrease it can never exceed the highest from here.
if (score <= 0f || bestOnly && score <= highestScore)
{
break;
}
}
if (score <= 0f)
continue;
highestScore = MathF.Max(score, highestScore);
results.Add(ent, score);
}
var result = new UtilityResult(results);
blackboard.Remove<EntityUid>(NPCBlackboard.UtilityTarget);
_entPool.Return(ents);
return result;
}
private float GetScore(IUtilityCurve curve, float conScore)
{
switch (curve)
{
case BoolCurve:
return conScore > 0f ? 1f : 0f;
case InverseBoolCurve:
return conScore.Equals(0f) ? 1f : 0f;
case PresetCurve presetCurve:
return GetScore(_proto.Index<UtilityCurvePresetPrototype>(presetCurve.Preset).Curve, conScore);
case QuadraticCurve quadraticCurve:
return Math.Clamp(quadraticCurve.Slope * MathF.Pow(conScore - quadraticCurve.XOffset, quadraticCurve.Exponent) + quadraticCurve.YOffset, 0f, 1f);
default:
throw new NotImplementedException();
}
}
private float GetScore(NPCBlackboard blackboard, EntityUid targetUid, UtilityConsideration consideration)
{
var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
switch (consideration)
{
case FoodValueCon:
{
if (!TryComp<FoodComponent>(targetUid, out var food))
return 0f;
// mice can't eat unpeeled bananas, need monkey's help
if (_openable.IsClosed(targetUid))
return 0f;
if (!_food.IsDigestibleBy(owner, targetUid, food))
return 0f;
var avoidBadFood = !HasComp<IgnoreBadFoodComponent>(owner);
// only eat when hungry or if it will eat anything
if (TryComp<HungerComponent>(owner, out var hunger) && hunger.CurrentThreshold > HungerThreshold.Okay && avoidBadFood)
return 0f;
// no mouse don't eat the uranium-235
if (avoidBadFood && HasComp<BadFoodComponent>(targetUid))
return 0f;
return 1f;
}
case DrinkValueCon:
{
if (!TryComp<DrinkComponent>(targetUid, out var drink))
return 0f;
// can't drink closed drinks
if (_openable.IsClosed(targetUid))
return 0f;
// only drink when thirsty
if (TryComp<ThirstComponent>(owner, out var thirst) && thirst.CurrentThirstThreshold > ThirstThreshold.Okay)
return 0f;
// no janicow don't drink the blood puddle
if (HasComp<BadDrinkComponent>(targetUid))
return 0f;
// needs to have something that will satiate thirst, mice wont try to drink 100% pure mutagen.
var hydration = _drink.TotalHydration(targetUid, drink);
if (hydration <= 1.0f)
return 0f;
return 1f;
}
case OrderedTargetCon:
{
if (!blackboard.TryGetValue<EntityUid>(NPCBlackboard.CurrentOrderedTarget, out var orderedTarget, EntityManager))
return 0f;
if (targetUid != orderedTarget)
return 0f;
return 1f;
}
case TargetAccessibleCon:
{
if (_container.TryGetContainingContainer(targetUid, out var container))
{
if (TryComp<EntityStorageComponent>(container.Owner, out var storageComponent))
{
if (storageComponent is { Open: false } && _weldable.IsWelded(container.Owner))
{
return 0.0f;
}
}
else
{
// If we're in a container (e.g. held or whatever) then we probably can't get it. Only exception
// Is a locker / crate
// TODO: Some mobs can break it so consider that.
return 0.0f;
}
}
// TODO: Pathfind there, though probably do it in a separate con.
return 1f;
}
case TargetAmmoMatchesCon:
{
if (!blackboard.TryGetValue(NPCBlackboard.ActiveHand, out Hand? activeHand, EntityManager) ||
!TryComp<BallisticAmmoProviderComponent>(activeHand.HeldEntity, out var heldGun))
{
return 0f;
}
if (_whitelistSystem.IsWhitelistFailOrNull(heldGun.Whitelist, targetUid))
{
return 0f;
}
return 1f;
}
case TargetDistanceCon:
{
var radius = blackboard.GetValueOrDefault<float>(blackboard.GetVisionRadiusKey(EntityManager), EntityManager);
if (!TryComp<TransformComponent>(targetUid, out var targetXform) ||
!TryComp<TransformComponent>(owner, out var xform))
{
return 0f;
}
if (!targetXform.Coordinates.TryDistance(EntityManager, _transform, xform.Coordinates,
out var distance))
{
return 0f;
}
return Math.Clamp(distance / radius, 0f, 1f);
}
case TargetAmmoCon:
{
if (!HasComp<GunComponent>(targetUid))
return 0f;
var ev = new GetAmmoCountEvent();
RaiseLocalEvent(targetUid, ref ev);
if (ev.Count == 0)
return 0f;
// Wat
if (ev.Capacity == 0)
return 1f;
return (float) ev.Count / ev.Capacity;
}
case TargetHealthCon:
{
return 0f;
}
case TargetInLOSCon:
{
var radius = blackboard.GetValueOrDefault<float>(blackboard.GetVisionRadiusKey(EntityManager), EntityManager);
return _examine.InRangeUnOccluded(owner, targetUid, radius + 0.5f, null) ? 1f : 0f;
}
case TargetInLOSOrCurrentCon:
{
var radius = blackboard.GetValueOrDefault<float>(blackboard.GetVisionRadiusKey(EntityManager), EntityManager);
const float bufferRange = 0.5f;
if (blackboard.TryGetValue<EntityUid>("Target", out var currentTarget, EntityManager) &&
currentTarget == targetUid &&
TryComp<TransformComponent>(owner, out var xform) &&
TryComp<TransformComponent>(targetUid, out var targetXform) &&
xform.Coordinates.TryDistance(EntityManager, _transform, targetXform.Coordinates, out var distance) &&
distance <= radius + bufferRange)
{
return 1f;
}
return _examine.InRangeUnOccluded(owner, targetUid, radius + bufferRange, null) ? 1f : 0f;
}
case TargetIsAliveCon:
{
return _mobState.IsAlive(targetUid) ? 1f : 0f;
}
case TargetIsThreateningCon:
{
return _mobState.IsThreatening(targetUid) ? 1f : 0f;
}
case TargetIsCritCon:
{
return _mobState.IsCritical(targetUid) ? 1f : 0f;
}
case TargetIsDeadCon:
{
return _mobState.IsDead(targetUid) ? 1f : 0f;
}
case TargetMeleeCon:
{
if (TryComp<MeleeWeaponComponent>(targetUid, out var melee))
{
return melee.Damage.GetTotal().Float() * melee.AttackRate / 100f;
}
return 0f;
}
default:
throw new NotImplementedException();
}
}
private float GetAdjustedScore(float score, int considerations)
{
/*
* Now using the geometric mean
* for n scores you take the n-th root of the scores multiplied
* e.g. a, b, c scores you take Math.Pow(a * b * c, 1/3)
* To get the ACTUAL geometric mean at any one stage you'd need to divide by the running consideration count
* however, the downside to this is it will fluctuate up and down over time.
* For our purposes if we go below the minimum threshold we want to cut it off, thus we take a
* "running geometric mean" which can only ever go down (and by the final value will equal the actual geometric mean).
*/
var adjusted = MathF.Pow(score, 1 / (float) considerations);
return Math.Clamp(adjusted, 0f, 1f);
}
private void Add(NPCBlackboard blackboard, HashSet<EntityUid> entities, UtilityQuery query)
{
var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
var vision = blackboard.GetValueOrDefault<float>(blackboard.GetVisionRadiusKey(EntityManager), EntityManager);
switch (query)
{
case ComponentQuery compQuery:
{
if (compQuery.Components.Count == 0)
return;
var mapPos = _transform.GetMapCoordinates(owner, xform: _xformQuery.GetComponent(owner));
_compTypes.Clear();
var i = -1;
EntityPrototype.ComponentRegistryEntry compZero = default!;
foreach (var compType in compQuery.Components.Values)
{
i++;
if (i == 0)
{
compZero = compType;
continue;
}
_compTypes.Add(compType);
}
_entitySet.Clear();
_lookup.GetEntitiesInRange(compZero.Component.GetType(), mapPos, vision, _entitySet);
foreach (var comp in _entitySet)
{
var ent = comp.Owner;
if (ent == owner)
continue;
var othersFound = true;
foreach (var compOther in _compTypes)
{
if (!HasComp(ent, compOther.Component.GetType()))
{
othersFound = false;
break;
}
}
if (!othersFound)
continue;
entities.Add(ent);
}
break;
}
case InventoryQuery:
{
if (!_inventory.TryGetContainerSlotEnumerator(owner, out var enumerator))
break;
while (enumerator.MoveNext(out var slot))
{
foreach (var child in slot.ContainedEntities)
{
RecursiveAdd(child, entities);
}
}
break;
}
case NearbyHostilesQuery:
{
foreach (var ent in _npcFaction.GetNearbyHostiles(owner, vision))
{
entities.Add(ent);
}
break;
}
default:
throw new NotImplementedException();
}
}
private void RecursiveAdd(EntityUid uid, HashSet<EntityUid> entities)
{
// TODO: Probably need a recursive struct enumerator on engine.
var xform = _xformQuery.GetComponent(uid);
var enumerator = xform.ChildEnumerator;
entities.Add(uid);
while (enumerator.MoveNext(out var child))
{
RecursiveAdd(child, entities);
}
}
private void Filter(NPCBlackboard blackboard, HashSet<EntityUid> entities, UtilityQueryFilter filter)
{
switch (filter)
{
case ComponentFilter compFilter:
{
_entityList.Clear();
foreach (var ent in entities)
{
foreach (var comp in compFilter.Components)
{
if (HasComp(ent, comp.Value.Component.GetType()))
continue;
_entityList.Add(ent);
break;
}
}
foreach (var ent in _entityList)
{
entities.Remove(ent);
}
break;
}
case PuddleFilter:
{
_entityList.Clear();
foreach (var ent in entities)
{
if (!_puddleQuery.TryGetComponent(ent, out var puddleComp) ||
!_solutions.TryGetSolution(ent, puddleComp.SolutionName, out _, out var sol) ||
_puddle.CanFullyEvaporate(sol))
{
_entityList.Add(ent);
}
}
foreach (var ent in _entityList)
{
entities.Remove(ent);
}
break;
}
default:
throw new NotImplementedException();
}
}
}
public readonly record struct UtilityResult(Dictionary<EntityUid, float> Entities)
{
public static readonly UtilityResult Empty = new(new Dictionary<EntityUid, float>());
public readonly Dictionary<EntityUid, float> Entities = Entities;
/// <summary>
/// Returns the entity with the highest score.
/// </summary>
public EntityUid GetHighest()
{
if (Entities.Count == 0)
return EntityUid.Invalid;
return Entities.MaxBy(x => x.Value).Key;
}
/// <summary>
/// Returns the entity with the lowest score. This does not consider entities with a 0 (invalid) score.
/// </summary>
public EntityUid GetLowest()
{
if (Entities.Count == 0)
return EntityUid.Invalid;
return Entities.MinBy(x => x.Value).Key;
}
}