Files
wwdpublic/Content.Server/VendingMachines/VendingMachineSystem.cs
WarMechanic 93ece39fc2 Cherry-Pick "EMP Grenade Actually Sabotages Power" From Wizden (#516)
<!-- Please read these guidelines before opening your PR:
https://docs.spacestation14.io/en/getting-started/pr-guideline -->
<!-- The text between the arrows are comments - they will not be visible
on your PR. -->

## About the PR
<!-- What did you change in this PR? -->
EMP has been changed to target APC-powered-devices (most electrical
devices) as well as batteries to disable them. This means EMP can
interfere with autolathes, airlocks, atmos devices, substations and
SMES. The power draw of a single EMP grenade now cuts out a substation,
and the disabling effect prevents further recharge until it subsides.

EMP duration now also stacks, which creates a novel way to quietly black
out the station by attacking engineering SMES with 3 EMP grenades (6tc
EMP bundle) to black out the station for 3 minutes.

Edit, here's a detailed changelog of the PR,
Functionality:
- EMP disable has been generalised to kill and prevent further function
of every device/battery by interrupting recharge
- As a result of the above, some hard coded interactions have been
culled
- EMP disable duration now stacks with multiple EMP blasts
- EMP is now capable of draining from gravity generators
- The Charger system has been slightly reworked to facilitate
communication between batteries and chargers

Results:
- EMP grenade can disable basically every powered machine, most notably
doors
- EMP grenade has had its power drain upped to 2.7MW, which is slightly
more than a substation and 1/3 a SMES
- EMP grenade can now instantly kill substations
- EMP grenade can now instantly kill gravity generators
- 3 EMP grenades (6tc) can be used to kill SMES and disable recharge for
3 minutes with no evidence on the power monitor.

## Why / Balance
<!-- Why was it changed? Link any discussions or issues here. Please
discuss how this would affect game balance. -->
EMP at 2tc has a relatively low value-proposition when compared to C4
which is also 2tc. While EMP can probably black out one (or two if
you're lucky) APCs and can be used as a defensive option against
Stun/Lasers. C4 can be used to cut wires, substations, SMES, generators,
doors, reinforced walls, people and the list probably continues.

New EMP can be used to soft-bomb station power in an explosion that
isn't globally alarming (salv boom). Targeting the captain's office
directly may let you crowbar in and steal the locker but it leaves
ephemeral evidence in the form of everything electrical shimmering blue.
Opting to bomb substations blacks out a wider area, providing several
degrees of separation from your target. That is to say, new EMP grenade
favours map knowledge and rewards better stealth.

## Technical details
<!-- If this is a code change, summarize at high level how your new code
works. This makes it easier to review. -->
- `C.S/.../EmpSystem.cs` uses TryComp to turn on/off charging for
`C.S/Power/Components/PowerNetworkBatteryComponent`
- `C.S/Power/EntitySystems/PowerReceiverSystem.cs` listens to
`EmpPulseEvent` to turn off. Requests to turn back on are additionally
intercepted by `EmpSystem.cs` and cancelled.
- `C.S/.../GravityGeneratorSystem.cs` listens to `EmpPulseEvent` and
converts energy consumption to a normalised charge
- `C.S/Power/EntitySystems/ApcSystem.cs` no longer toggles its breaker,
but still listens to `EmpPulseEvent` for updating visuals.
- `C.S/Power/EntitySystems/ChargerSystem.cs` was refactored to add a
`ChargingComponent` flag to power cells instead of `ActiveCharger` on
itself. Battery and Charger communicate through this flag. Listens to
`EmpPulseEvent` for updating its state machine. New
`ChargerUpdateStatusEvent` allows batteries to update the charger's
status.
- `C.S/Power/EntitySystems/BatterySystem.cs` can now be disabled, and
checks for disabling before updating its charge. Raises
`ChargerUpdateStatusEvent` when hearing `EmpDisabledRemoved` to tell its
charger to start charging again.
- `C.S/Power/PowerWireAction.cs` checks for `EmpDisabledComponent`
before turning power back on.
- `C.S/SurveillanceCamera/Systems/SurveillanceCameraSystem.cs` and
`C.S/VendingMachines/VendingMachineSystem.cs` had redundant
`EmpPulseEvent` listeners culled.
- `Resources/Prototypes/Entities/Objects/Weapons/Throwable/grenades.yml`
buffed EMP grenade.

## Media
<!-- 
PRs which make ingame changes (adding clothing, items, new features,
etc) are required to have media attached that showcase the changes.
Small fixes/refactors are exempt.
Any media may be used in SS14 progress reports, with clear credit given.

If you're unsure whether your PR will require media, ask a maintainer.

Check the box below to confirm that you have in fact seen this (put an X
in the brackets, like [X]):
-->
https://www.youtube.com/embed/rSVph6OIg1s?si=8o4bx9Vx16B6usuu - outdated
video demonstrating changes on a wizden map
https://www.youtube.com/embed/B3iPhLcfs-0?si=trB1HY2ccjMf96Bj -
electrical anomaly crit with updated emp

- [x] I have added screenshots/videos to this PR showcasing its changes
ingame, **or** this PR does not require an ingame showcase

**Changelog**
<!--
Make players aware of new features and changes that could affect how
they play the game by adding a Changelog entry. Please read the
Changelog guidelines located at:
https://docs.spacestation14.io/en/getting-started/pr-guideline#changelog
-->

🆑
- tweak: EMP Grenades can now disable basically any electrical device,
and stack in disable duration.

---------

Signed-off-by: VMSolidus <evilexecutive@gmail.com>
Co-authored-by: VMSolidus <evilexecutive@gmail.com>
2024-08-06 16:47:49 -04:00

500 lines
20 KiB
C#

using System.Linq;
using System.Numerics;
using Content.Server.Advertise;
using Content.Server.Advertise.Components;
using Content.Server.Cargo.Systems;
using Content.Server.Emp;
using Content.Server.Power.Components;
using Content.Server.Power.EntitySystems;
using Content.Shared.Access.Components;
using Content.Shared.Access.Systems;
using Content.Shared.Actions;
using Content.Shared.Damage;
using Content.Shared.Destructible;
using Content.Shared.DoAfter;
using Content.Shared.Emag.Components;
using Content.Shared.Emag.Systems;
using Content.Shared.Emp;
using Content.Shared.Popups;
using Content.Shared.Throwing;
using Content.Shared.UserInterface;
using Content.Shared.VendingMachines;
using Robust.Server.GameObjects;
using Robust.Shared.Audio;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Timing;
namespace Content.Server.VendingMachines
{
public sealed class VendingMachineSystem : SharedVendingMachineSystem
{
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly AccessReaderSystem _accessReader = default!;
[Dependency] private readonly AppearanceSystem _appearanceSystem = default!;
[Dependency] private readonly SharedActionsSystem _action = default!;
[Dependency] private readonly PricingSystem _pricing = default!;
[Dependency] private readonly ThrowingSystem _throwingSystem = default!;
[Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly SpeakOnUIClosedSystem _speakOnUIClosed = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<VendingMachineComponent, MapInitEvent>(OnComponentMapInit);
SubscribeLocalEvent<VendingMachineComponent, PowerChangedEvent>(OnPowerChanged);
SubscribeLocalEvent<VendingMachineComponent, BreakageEventArgs>(OnBreak);
SubscribeLocalEvent<VendingMachineComponent, GotEmaggedEvent>(OnEmagged);
SubscribeLocalEvent<VendingMachineComponent, DamageChangedEvent>(OnDamage);
SubscribeLocalEvent<VendingMachineComponent, PriceCalculationEvent>(OnVendingPrice);
SubscribeLocalEvent<VendingMachineComponent, ActivatableUIOpenAttemptEvent>(OnActivatableUIOpenAttempt);
Subs.BuiEvents<VendingMachineComponent>(VendingMachineUiKey.Key, subs =>
{
subs.Event<BoundUIOpenedEvent>(OnBoundUIOpened);
subs.Event<VendingMachineEjectMessage>(OnInventoryEjectMessage);
});
SubscribeLocalEvent<VendingMachineComponent, VendingMachineSelfDispenseEvent>(OnSelfDispense);
SubscribeLocalEvent<VendingMachineComponent, RestockDoAfterEvent>(OnDoAfter);
SubscribeLocalEvent<VendingMachineRestockComponent, PriceCalculationEvent>(OnPriceCalculation);
}
private void OnComponentMapInit(EntityUid uid, VendingMachineComponent component, MapInitEvent args)
{
_action.AddAction(uid, ref component.ActionEntity, component.Action, uid);
Dirty(uid, component);
}
private void OnVendingPrice(EntityUid uid, VendingMachineComponent component, ref PriceCalculationEvent args)
{
var price = 0.0;
foreach (var entry in component.Inventory.Values)
{
if (!PrototypeManager.TryIndex<EntityPrototype>(entry.ID, out var proto))
{
Log.Error($"Unable to find entity prototype {entry.ID} on {ToPrettyString(uid)} vending.");
continue;
}
price += entry.Amount * _pricing.GetEstimatedPrice(proto);
}
args.Price += price;
}
protected override void OnComponentInit(EntityUid uid, VendingMachineComponent component, ComponentInit args)
{
base.OnComponentInit(uid, component, args);
if (HasComp<ApcPowerReceiverComponent>(uid))
{
TryUpdateVisualState(uid, component);
}
}
private void OnActivatableUIOpenAttempt(EntityUid uid, VendingMachineComponent component, ActivatableUIOpenAttemptEvent args)
{
if (component.Broken)
args.Cancel();
}
private void OnBoundUIOpened(EntityUid uid, VendingMachineComponent component, BoundUIOpenedEvent args)
{
UpdateVendingMachineInterfaceState(uid, component);
}
private void UpdateVendingMachineInterfaceState(EntityUid uid, VendingMachineComponent component)
{
var state = new VendingMachineInterfaceState(GetAllInventory(uid, component));
_userInterfaceSystem.TrySetUiState(uid, VendingMachineUiKey.Key, state);
}
private void OnInventoryEjectMessage(EntityUid uid, VendingMachineComponent component, VendingMachineEjectMessage args)
{
if (!this.IsPowered(uid, EntityManager))
return;
if (args.Session.AttachedEntity is not { Valid: true } entity || Deleted(entity))
return;
AuthorizedVend(uid, entity, args.Type, args.ID, component);
}
private void OnPowerChanged(EntityUid uid, VendingMachineComponent component, ref PowerChangedEvent args)
{
TryUpdateVisualState(uid, component);
}
private void OnBreak(EntityUid uid, VendingMachineComponent vendComponent, BreakageEventArgs eventArgs)
{
vendComponent.Broken = true;
TryUpdateVisualState(uid, vendComponent);
}
private void OnEmagged(EntityUid uid, VendingMachineComponent component, ref GotEmaggedEvent args)
{
// only emag if there are emag-only items
args.Handled = component.EmaggedInventory.Count > 0;
}
private void OnDamage(EntityUid uid, VendingMachineComponent component, DamageChangedEvent args)
{
if (component.Broken || component.DispenseOnHitCoolingDown ||
component.DispenseOnHitChance == null || args.DamageDelta == null)
return;
if (args.DamageIncreased && args.DamageDelta.GetTotal() >= component.DispenseOnHitThreshold &&
_random.Prob(component.DispenseOnHitChance.Value))
{
if (component.DispenseOnHitCooldown > 0f)
component.DispenseOnHitCoolingDown = true;
EjectRandom(uid, throwItem: true, forceEject: true, component);
}
}
private void OnSelfDispense(EntityUid uid, VendingMachineComponent component, VendingMachineSelfDispenseEvent args)
{
if (args.Handled)
return;
args.Handled = true;
EjectRandom(uid, throwItem: true, forceEject: false, component);
}
private void OnDoAfter(EntityUid uid, VendingMachineComponent component, DoAfterEvent args)
{
if (args.Handled || args.Cancelled || args.Args.Used == null)
return;
if (!TryComp<VendingMachineRestockComponent>(args.Args.Used, out var restockComponent))
{
Log.Error($"{ToPrettyString(args.Args.User)} tried to restock {ToPrettyString(uid)} with {ToPrettyString(args.Args.Used.Value)} which did not have a VendingMachineRestockComponent.");
return;
}
TryRestockInventory(uid, component);
Popup.PopupEntity(Loc.GetString("vending-machine-restock-done", ("this", args.Args.Used), ("user", args.Args.User), ("target", uid)), args.Args.User, PopupType.Medium);
Audio.PlayPvs(restockComponent.SoundRestockDone, uid, AudioParams.Default.WithVolume(-2f).WithVariation(0.2f));
Del(args.Args.Used.Value);
args.Handled = true;
}
/// <summary>
/// Sets the <see cref="VendingMachineComponent.CanShoot"/> property of the vending machine.
/// </summary>
public void SetShooting(EntityUid uid, bool canShoot, VendingMachineComponent? component = null)
{
if (!Resolve(uid, ref component))
return;
component.CanShoot = canShoot;
}
public void Deny(EntityUid uid, VendingMachineComponent? vendComponent = null)
{
if (!Resolve(uid, ref vendComponent))
return;
if (vendComponent.Denying)
return;
vendComponent.Denying = true;
Audio.PlayPvs(vendComponent.SoundDeny, uid, AudioParams.Default.WithVolume(-2f));
TryUpdateVisualState(uid, vendComponent);
}
/// <summary>
/// Checks if the user is authorized to use this vending machine
/// </summary>
/// <param name="uid"></param>
/// <param name="sender">Entity trying to use the vending machine</param>
/// <param name="vendComponent"></param>
public bool IsAuthorized(EntityUid uid, EntityUid sender, VendingMachineComponent? vendComponent = null)
{
if (!Resolve(uid, ref vendComponent))
return false;
if (!TryComp<AccessReaderComponent>(uid, out var accessReader))
return true;
if (_accessReader.IsAllowed(sender, uid, accessReader) || HasComp<EmaggedComponent>(uid))
return true;
Popup.PopupEntity(Loc.GetString("vending-machine-component-try-eject-access-denied"), uid);
Deny(uid, vendComponent);
return false;
}
/// <summary>
/// Tries to eject the provided item. Will do nothing if the vending machine is incapable of ejecting, already ejecting
/// or the item doesn't exist in its inventory.
/// </summary>
/// <param name="uid"></param>
/// <param name="type">The type of inventory the item is from</param>
/// <param name="itemId">The prototype ID of the item</param>
/// <param name="throwItem">Whether the item should be thrown in a random direction after ejection</param>
/// <param name="vendComponent"></param>
public void TryEjectVendorItem(EntityUid uid, InventoryType type, string itemId, bool throwItem, VendingMachineComponent? vendComponent = null)
{
if (!Resolve(uid, ref vendComponent))
return;
if (vendComponent.Ejecting || vendComponent.Broken || !this.IsPowered(uid, EntityManager))
{
return;
}
var entry = GetEntry(uid, itemId, type, vendComponent);
if (entry == null)
{
Popup.PopupEntity(Loc.GetString("vending-machine-component-try-eject-invalid-item"), uid);
Deny(uid, vendComponent);
return;
}
if (entry.Amount <= 0)
{
Popup.PopupEntity(Loc.GetString("vending-machine-component-try-eject-out-of-stock"), uid);
Deny(uid, vendComponent);
return;
}
if (string.IsNullOrEmpty(entry.ID))
return;
// Start Ejecting, and prevent users from ordering while anim playing
vendComponent.Ejecting = true;
vendComponent.NextItemToEject = entry.ID;
vendComponent.ThrowNextItem = throwItem;
if (TryComp(uid, out SpeakOnUIClosedComponent? speakComponent))
_speakOnUIClosed.TrySetFlag((uid, speakComponent));
entry.Amount--;
UpdateVendingMachineInterfaceState(uid, vendComponent);
TryUpdateVisualState(uid, vendComponent);
Audio.PlayPvs(vendComponent.SoundVend, uid);
}
/// <summary>
/// Checks whether the user is authorized to use the vending machine, then ejects the provided item if true
/// </summary>
/// <param name="uid"></param>
/// <param name="sender">Entity that is trying to use the vending machine</param>
/// <param name="type">The type of inventory the item is from</param>
/// <param name="itemId">The prototype ID of the item</param>
/// <param name="component"></param>
public void AuthorizedVend(EntityUid uid, EntityUid sender, InventoryType type, string itemId, VendingMachineComponent component)
{
if (IsAuthorized(uid, sender, component))
{
TryEjectVendorItem(uid, type, itemId, component.CanShoot, component);
}
}
/// <summary>
/// Tries to update the visuals of the component based on its current state.
/// </summary>
public void TryUpdateVisualState(EntityUid uid, VendingMachineComponent? vendComponent = null)
{
if (!Resolve(uid, ref vendComponent))
return;
var finalState = VendingMachineVisualState.Normal;
if (vendComponent.Broken)
{
finalState = VendingMachineVisualState.Broken;
}
else if (vendComponent.Ejecting)
{
finalState = VendingMachineVisualState.Eject;
}
else if (vendComponent.Denying)
{
finalState = VendingMachineVisualState.Deny;
}
else if (!this.IsPowered(uid, EntityManager))
{
finalState = VendingMachineVisualState.Off;
}
_appearanceSystem.SetData(uid, VendingMachineVisuals.VisualState, finalState);
}
/// <summary>
/// Ejects a random item from the available stock. Will do nothing if the vending machine is empty.
/// </summary>
/// <param name="uid"></param>
/// <param name="throwItem">Whether to throw the item in a random direction after dispensing it.</param>
/// <param name="forceEject">Whether to skip the regular ejection checks and immediately dispense the item without animation.</param>
/// <param name="vendComponent"></param>
public void EjectRandom(EntityUid uid, bool throwItem, bool forceEject = false, VendingMachineComponent? vendComponent = null)
{
if (!Resolve(uid, ref vendComponent))
return;
var availableItems = GetAvailableInventory(uid, vendComponent);
if (availableItems.Count <= 0)
return;
var item = _random.Pick(availableItems);
if (forceEject)
{
vendComponent.NextItemToEject = item.ID;
vendComponent.ThrowNextItem = throwItem;
var entry = GetEntry(uid, item.ID, item.Type, vendComponent);
if (entry != null)
entry.Amount--;
EjectItem(uid, vendComponent, forceEject);
}
else
{
TryEjectVendorItem(uid, item.Type, item.ID, throwItem, vendComponent);
}
}
private void EjectItem(EntityUid uid, VendingMachineComponent? vendComponent = null, bool forceEject = false)
{
if (!Resolve(uid, ref vendComponent))
return;
// No need to update the visual state because we never changed it during a forced eject
if (!forceEject)
TryUpdateVisualState(uid, vendComponent);
if (string.IsNullOrEmpty(vendComponent.NextItemToEject))
{
vendComponent.ThrowNextItem = false;
return;
}
var ent = Spawn(vendComponent.NextItemToEject, Transform(uid).Coordinates);
if (vendComponent.ThrowNextItem)
{
var range = vendComponent.NonLimitedEjectRange;
var direction = new Vector2(_random.NextFloat(-range, range), _random.NextFloat(-range, range));
_throwingSystem.TryThrow(ent, direction, vendComponent.NonLimitedEjectForce);
}
vendComponent.NextItemToEject = null;
vendComponent.ThrowNextItem = false;
}
private VendingMachineInventoryEntry? GetEntry(EntityUid uid, string entryId, InventoryType type, VendingMachineComponent? component = null)
{
if (!Resolve(uid, ref component))
return null;
if (type == InventoryType.Emagged && HasComp<EmaggedComponent>(uid))
return component.EmaggedInventory.GetValueOrDefault(entryId);
if (type == InventoryType.Contraband && component.Contraband)
return component.ContrabandInventory.GetValueOrDefault(entryId);
return component.Inventory.GetValueOrDefault(entryId);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var query = EntityQueryEnumerator<VendingMachineComponent>();
while (query.MoveNext(out var uid, out var comp))
{
if (comp.Ejecting)
{
comp.EjectAccumulator += frameTime;
if (comp.EjectAccumulator >= comp.EjectDelay)
{
comp.EjectAccumulator = 0f;
comp.Ejecting = false;
EjectItem(uid, comp);
}
}
if (comp.Denying)
{
comp.DenyAccumulator += frameTime;
if (comp.DenyAccumulator >= comp.DenyDelay)
{
comp.DenyAccumulator = 0f;
comp.Denying = false;
TryUpdateVisualState(uid, comp);
}
}
if (comp.DispenseOnHitCoolingDown)
{
comp.DispenseOnHitAccumulator += frameTime;
if (comp.DispenseOnHitAccumulator >= comp.DispenseOnHitCooldown)
{
comp.DispenseOnHitAccumulator = 0f;
comp.DispenseOnHitCoolingDown = false;
}
}
}
var disabled = EntityQueryEnumerator<EmpDisabledComponent, VendingMachineComponent>();
while (disabled.MoveNext(out var uid, out _, out var comp))
{
if (comp.NextEmpEject < _timing.CurTime)
{
EjectRandom(uid, true, false, comp);
comp.NextEmpEject += TimeSpan.FromSeconds(5 * comp.EjectDelay);
}
}
}
public void TryRestockInventory(EntityUid uid, VendingMachineComponent? vendComponent = null)
{
if (!Resolve(uid, ref vendComponent))
return;
RestockInventoryFromPrototype(uid, vendComponent);
UpdateVendingMachineInterfaceState(uid, vendComponent);
TryUpdateVisualState(uid, vendComponent);
}
private void OnPriceCalculation(EntityUid uid, VendingMachineRestockComponent component, ref PriceCalculationEvent args)
{
List<double> priceSets = new();
// Find the most expensive inventory and use that as the highest price.
foreach (var vendingInventory in component.CanRestock)
{
double total = 0;
if (PrototypeManager.TryIndex(vendingInventory, out VendingMachineInventoryPrototype? inventoryPrototype))
{
foreach (var (item, amount) in inventoryPrototype.StartingInventory)
{
if (PrototypeManager.TryIndex(item, out EntityPrototype? entity))
total += _pricing.GetEstimatedPrice(entity) * amount;
}
}
priceSets.Add(total);
}
args.Price += priceSets.Max();
}
}
}