Universal uplink codes (#38712)

* Halfway commit

* Finishing commit, maybe?

* Fix test, update ringtone UI to look nicer

* Fix command, add failsafe

* Documentation

* Can we just mark ValidatePrototypeId as obsolete please

* I'm too tired and my bones hurt

* Change uplink code generation method

* Move RingerAccessUplinkComponent to Server, because cheat clients could make use of it

* Fix uplink implant changes, review changes, repair broken serialization.

* cleanup and master merge

* forgot the Linq

* Linqd

* Our store system is pretty goddamn awful wow

---------

Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com>
This commit is contained in:
SlamBamActionman 2026-04-04 21:46:43 +02:00 • committed by GitHub
parent ef3929be86
commit 0d616cf6df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 525 additions and 114 deletions

View file

@ -0,0 +1,5 @@
using Content.Shared.Store;
namespace Content.Client.Store;
public sealed class StoreSystem : SharedStoreSystem;

View file

@ -1,7 +1,6 @@
using System.Linq;
using Content.Shared.Store;
using JetBrains.Annotations;
using System.Linq;
using Content.Shared.Store.Components;
using Robust.Client.UserInterface;
using Robust.Shared.Prototypes;
@ -11,6 +10,7 @@ namespace Content.Client.Store.Ui;
public sealed class StoreBoundUserInterface : BoundUserInterface
{
private IPrototypeManager _prototypeManager = default!;
private readonly StoreSystem _storeSystem = default!;
[ViewVariables]
private StoreMenu? _menu;
@ -23,6 +23,7 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
public StoreBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
{
_storeSystem = EntMan.System<StoreSystem>();
}
protected override void Open()
@ -30,12 +31,12 @@ public sealed class StoreBoundUserInterface : BoundUserInterface
base.Open();
_menu = this.CreateWindow<StoreMenu>();
if (EntMan.TryGetComponent<StoreComponent>(Owner, out var store))
_menu.Title = Loc.GetString(store.Name);
if (_storeSystem.TryGetStore(Owner, out var store))
_menu.Title = Loc.GetString(store.Value.Comp.Name);
_menu.OnListingButtonPressed += (_, listing) =>
{
SendMessage(new StoreBuyListingMessage(listing.ID));
SendMessage(new StoreBuyListingMessage(listing.ID, EntMan.GetNetEntity(Owner)));
};
_menu.OnCategoryButtonPressed += (_, category) =>

View file

@ -22,7 +22,7 @@ public sealed class StoreTests : GameTest
- type: entity
name: InventoryPdaDummy
id: InventoryPdaDummy
parent: BasePDA
parent: [BasePDA, StorePresetUplink]
components:
- type: Clothing
QuickEquip: false
@ -89,10 +89,13 @@ public sealed class StoreTests : GameTest
mindSystem.TransferTo(mind, human, mind: mind);
FixedPoint2 originalBalance = 20;
uplinkSystem.AddUplink(human, originalBalance, null, true);
uplinkSystem.AddUplink(human, originalBalance, out var notes, pda, null, true);
var storeComponent = entManager.GetComponent<StoreComponent>(pda);
var discountComponent = entManager.GetComponent<StoreDiscountComponent>(pda);
var remote = entManager.GetComponent<RemoteStoreComponent>(pda);
var storeEnt = remote.Store;
Assert.That(storeEnt.HasValue);
var storeComponent = entManager.GetComponent<StoreComponent>(storeEnt.Value);
var discountComponent = entManager.GetComponent<StoreDiscountComponent>(storeEnt.Value);
Assert.That(
discountComponent.Discounts,
Has.Exactly(6).Items,
@ -138,7 +141,7 @@ public sealed class StoreTests : GameTest
Assert.That(plainDiscountedCost.Value, Is.LessThan(prototypeCost.Value), "Expected discounted cost to be lower then prototype cost.");
var buyMsg = new StoreBuyListingMessage(discountedListingItem.ID){Actor = human};
var buyMsg = new StoreBuyListingMessage(discountedListingItem.ID, null){Actor = human};
server.EntMan.EventBus.RaiseLocalEvent(pda, buyMsg);
var newBalance = storeComponent.Balance[UplinkSystem.TelecrystalCurrencyPrototype];

View file

@ -18,6 +18,7 @@ using Robust.Shared.Random;
using System.Linq;
using System.Text;
using Content.Server.Codewords;
using Robust.Shared.Map;
namespace Content.Server.GameTicking.Rules;
@ -148,32 +149,22 @@ public sealed class TraitorRuleSystem : GameRuleSystem<TraitorRuleComponent>
private (Note[]?, string) RequestUplink(EntityUid traitor, FixedPoint2 startingBalance, string briefing)
{
var pda = _uplink.FindUplinkTarget(traitor);
Note[]? code = null;
Log.Debug($"MakeTraitor {ToPrettyString(traitor)} - Uplink add");
var uplinked = _uplink.AddUplink(traitor, startingBalance, pda, true);
var uplinked = _uplink.AddUplink(traitor, startingBalance, out var code, pda, giveDiscounts: true, bindToPda: false);
if (pda is not null && uplinked)
if (code != null && uplinked == AddUplinkResult.Pda)
{
Log.Debug($"MakeTraitor {ToPrettyString(traitor)} - Uplink is PDA");
// Codes are only generated if the uplink is a PDA
var ev = new GenerateUplinkCodeEvent();
RaiseLocalEvent(pda.Value, ref ev);
if (ev.Code is { } generatedCode)
{
code = generatedCode;
// If giveUplink is false the uplink code part is omitted
briefing = string.Format("{0}\n{1}",
briefing,
Loc.GetString("traitor-role-uplink-code-short", ("code", string.Join("-", code).Replace("sharp", "#"))));
return (code, briefing);
}
Log.Error($"MakeTraitor {ToPrettyString(traitor)} failed to generate an uplink code on {ToPrettyString(pda)}.");
// If giveUplink is false the uplink code part is omitted
briefing = string.Format("{0}\n{1}",
briefing,
Loc.GetString("traitor-role-uplink-code-short", ("code", string.Join("-", code).Replace("sharp", "#"))));
return (code, briefing);
}
else if (pda is null && uplinked)
if (uplinked == AddUplinkResult.Implant)
{
Log.Debug($"MakeTraitor {ToPrettyString(traitor)} - Uplink is implant");
briefing += "\n" + Loc.GetString("traitor-role-uplink-implant-short");
@ -183,6 +174,7 @@ public sealed class TraitorRuleSystem : GameRuleSystem<TraitorRuleComponent>
Log.Error($"MakeTraitor failed on {ToPrettyString(traitor)} - No uplink could be added");
}
return (null, briefing);
}

View file

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Content.Server.Access.Systems;
using Content.Server.AlertLevel;
using Content.Server.CartridgeLoader;
@ -17,6 +18,7 @@ using Content.Shared.Light;
using Content.Shared.Light.EntitySystems;
using Content.Shared.PDA;
using Content.Shared.PDA.Ringer;
using Content.Shared.Store.Components;
using Content.Shared.VoiceMask;
using Robust.Server.Containers;
using Robust.Server.GameObjects;
@ -189,7 +191,7 @@ namespace Content.Server.PDA
var address = GetDeviceNetAddress(uid);
var hasInstrument = HasComp<InstrumentComponent>(uid);
var showUplink = HasComp<UplinkComponent>(uid) && IsUnlocked(uid);
var showUplink = TryGetUnlockedStore(uid, out _);
UpdateStationName(uid, pda);
UpdateAlertLevel(uid, pda);
@ -274,8 +276,19 @@ namespace Content.Server.PDA
return;
// check if its locked again to prevent malicious clients opening locked uplinks
if (HasComp<UplinkComponent>(uid) && IsUnlocked(uid))
_store.ToggleUi(msg.Actor, uid);
if (TryGetUnlockedStore(uid, out var store))
{
if (store != uid)
{
if (TryComp<RemoteStoreComponent>(uid, out var remoteStore))
remoteStore.Store = store;
_store.ToggleUi(msg.Actor, store.Value, remoteAccess: uid);
}
else
{
_store.ToggleUi(msg.Actor, store.Value);
}
}
}
private void OnUiMessage(EntityUid uid, PdaComponent pda, PdaLockUplinkMessage msg)
@ -285,14 +298,24 @@ namespace Content.Server.PDA
if (TryComp<RingerUplinkComponent>(uid, out var uplink))
{
if (TryComp<RemoteStoreComponent>(uid, out var remoteStore))
remoteStore.Store = null;
_ringer.LockUplink((uid, uplink));
UpdatePdaUi(uid, pda);
}
}
private bool IsUnlocked(EntityUid uid)
/// <summary>
/// Returns the currently unlocked store, if there is one.
/// </summary>
private bool TryGetUnlockedStore(EntityUid uid, [NotNullWhen(true)] out EntityUid? store)
{
return !TryComp<RingerUplinkComponent>(uid, out var uplink) || uplink.Unlocked;
store = null;
if (!TryComp<RingerUplinkComponent>(uid, out var uplink) || !uplink.Unlocked || uplink.TargetStore == null)
return false;
store = uplink.TargetStore;
return true;
}
private void UpdateStationName(EntityUid uid, PdaComponent pda)

View file

@ -0,0 +1,24 @@
using Content.Shared.PDA;
namespace Content.Server.PDA.Ringer;
/// <summary>
/// Opens the store UI when a PDA's ringtone is set to the secret code.
/// Traitors are told the code when greeted.
/// </summary>
[RegisterComponent, Access(typeof(RingerSystem))]
public sealed partial class RingerAccessUplinkComponent : Component
{
/// <summary>
/// Notes to set ringtone to in order to lock or unlock the uplink.
/// Set via GenerateUplinkCodeEvent.
/// </summary>
[DataField]
public Note[]? Code;
/// <summary>
/// If set, the uplink store can only be opened with the given entity.
/// </summary>
[DataField]
public EntityUid? BoundEntity;
}

View file

@ -1,9 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Server.Store.Systems;
using Content.Shared.GameTicking;
using Content.Shared.PDA;
using Content.Shared.PDA.Ringer;
using Content.Shared.Store.Components;
using Robust.Shared.Random;
using Robust.Shared.Utility;
namespace Content.Server.PDA.Ringer;
@ -14,15 +16,35 @@ public sealed class RingerSystem : SharedRingerSystem
{
[Dependency] private readonly IRobustRandom _random = default!;
public static Note[] AllowedNotes =
{
Note.C,
Note.D,
Note.E,
Note.F,
Note.G,
Note.A,
Note.B
};
/// <summary>
/// Stores the serialized version of any ringtone that can be excluded from new ringtone generations.
/// </summary>
[ViewVariables]
public readonly HashSet<int> ReservedSerializedRingtones = new();
/// <inheritdoc/>
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<RingerComponent, MapInitEvent>(OnMapInit);
SubscribeLocalEvent<RingerComponent, CurrencyInsertAttemptEvent>(OnCurrencyInsert);
SubscribeLocalEvent<RingerUplinkComponent, GenerateUplinkCodeEvent>(OnGenerateUplinkCode);
SubscribeLocalEvent<RingerAccessUplinkComponent, GenerateUplinkCodeEvent>(OnGenerateUplinkCode);
SubscribeLocalEvent<RoundRestartCleanupEvent>(CleanupReserved);
InitialSetup();
}
/// <summary>
@ -30,7 +52,11 @@ public sealed class RingerSystem : SharedRingerSystem
/// </summary>
private void OnMapInit(Entity<RingerComponent> ent, ref MapInitEvent args)
{
UpdateRingerRingtone(ent, GenerateRingtone());
var ringtone = GenerateRingtone();
ringtone ??= new Note[RingtoneLength] { Note.A, Note.A, Note.A, Note.A, Note.A, Note.A }; // Fallback
UpdateRingerRingtone(ent, ringtone);
}
/// <summary>
@ -53,71 +79,209 @@ public sealed class RingerSystem : SharedRingerSystem
/// <summary>
/// Handles the <see cref="GenerateUplinkCodeEvent"/> for generating an uplink code.
/// </summary>
private void OnGenerateUplinkCode(Entity<RingerUplinkComponent> ent, ref GenerateUplinkCodeEvent ev)
private void OnGenerateUplinkCode(Entity<RingerAccessUplinkComponent> ent, ref GenerateUplinkCodeEvent ev)
{
var code = GenerateRingtone();
var code = GenerateRingtone(true, true);
// Set the code on the component
ent.Comp.Code = code;
// Return the code via the event
ev.Code = code;
}
private void InitialSetup()
{
ReservedSerializedRingtones.Clear();
}
/// <inheritdoc/>
public override bool TryToggleUplink(EntityUid uid, Note[] ringtone, EntityUid? user = null)
{
if (!TryComp<RingerUplinkComponent>(uid, out var uplink))
return false;
if (!HasComp<StoreComponent>(uid))
return false;
// Wasn't generated yet
if (uplink.Code is null)
return false;
// On the server, we always check if the code matches
if (!uplink.Code.SequenceEqual(ringtone))
if (!TryMatchRingtoneToStore(ringtone, out var store, uid))
return false;
uplink.TargetStore = store;
return ToggleUplinkInternal((uid, uplink));
}
/// <summary>
/// Generates a random ringtone using the C pentatonic scale.
/// Generates a random ringtone using the C major scale.
/// </summary>
/// <param name="excludeReserved">Exclude any ringtone registered to ReservedSerializedRingtones.</param>
/// <param name="reserveRingtone">Add the generated ringtone to ReservedSerializedRingtones. Requires ExcludeReserved to be true.</param>
/// <returns>An array of Notes representing the ringtone.</returns>
/// <remarks>The logic for this is on the Server so that we don't get a different result on the Client every time.</remarks>
private Note[] GenerateRingtone()
private Note[]? GenerateRingtone(bool excludeReserved = false, bool reserveRingtone = false)
{
// Default to using C pentatonic so it at least sounds not terrible.
return GenerateRingtone(new[]
{
Note.C,
Note.D,
Note.E,
Note.G,
Note.A
});
// Default to using C major so it at least sounds not terrible.
return GenerateRingtone(AllowedNotes, excludeReserved, reserveRingtone);
}
/// <summary>
/// Generates a random ringtone using the specified notes.
/// </summary>
/// <param name="notes">The notes to choose from when generating the ringtone.</param>
/// <param name="excludeReserved">Exclude any ringtone registered to ReservedSerializedRingtones.</param>
/// <param name="reserveRingtone">Add the generated ringtone to ReservedSerializedRingtones. Requires ExcludeReserved to be true.</param>
/// <returns>An array of Notes representing the ringtone.</returns>
/// <remarks>The logic for this is on the Server so that we don't get a different result on the Client every time.</remarks>
private Note[] GenerateRingtone(Note[] notes)
private Note[]? GenerateRingtone(Note[] notes, bool excludeReserved = false, bool reserveRingtone = false)
{
var ringtone = new Note[RingtoneLength];
var excludedRingtones = excludeReserved ? ReservedSerializedRingtones.ToArray() : null;
var maxPow = Math.Pow(notes.Length, RingtoneLength);
if (maxPow > int.MaxValue)
{
return null;
}
var generatedRingtone = NextIntInRangeButExclude(0, Convert.ToInt32(maxPow) - 1, excludedRingtones);
if (!TryDeserializeRingtone(notes, generatedRingtone, out var ringtone))
return null;
if (excludeReserved && reserveRingtone)
ReservedSerializedRingtones.Add(generatedRingtone);
return ringtone;
}
/// <summary>
/// Serialize a ringtone, representing it as an Int32.
/// </summary>
/// <param name="allowedNotes">The array of notes used to generate the ringtone.</param>
/// <param name="ringtone">The ringtone which needs to be serialized.</param>
/// <param name="serializedRingtone">The ringtone in a serialized format.</param>
/// <returns>Whether the ringtone could be serialized or not.</returns>
private bool TrySerializeRingtone(Note[] allowedNotes, Note[] ringtone, [NotNullWhen(true)] out int? serializedRingtone)
{
var noteLength = allowedNotes.Length;
// The serialization stores as an Int32, and therefore using Pow risks overshooting the max value, so we check for if that's a risk.
// If using 12 possible notes, you can have a ringtone sequence of 7 notes safely without overshooting.
var maxPow = Math.Pow(noteLength, ringtone.Length);
if (maxPow > int.MaxValue)
{
serializedRingtone = null;
return false;
}
var serializationValue = 0;
for (var i = 0; i < ringtone.Length; i++)
{
var pow = Math.Pow(noteLength, i);
var index = Array.IndexOf(allowedNotes, ringtone[i]);
if (index == -1)
{
serializedRingtone = null;
return false;
}
serializationValue += Convert.ToInt32(pow) * index;
}
serializedRingtone = serializationValue;
return true;
}
/// <summary>
/// Deserialize a serialized ringtone into a Note array.
/// </summary>
/// <param name="allowedNotes">The array of notes used to generate the ringtone.</param>
/// <param name="serializedRingtone">The ringtone in a serialized format.</param>
/// <param name="ringtone">The ringtone resulting from the deserialization.</param>
/// <returns>Whether the ringtone could be deserialized or not.</returns>
private bool TryDeserializeRingtone(Note[] allowedNotes, int serializedRingtone, [NotNullWhen(true)] out Note[]? ringtone)
{
var noteLength = allowedNotes.Length;
ringtone = new Note[RingtoneLength];
// The serialization stores as an Int32, and therefore using Pow risks overshooting the max value, so we check for if that's a risk.
// If using 12 possible notes, you can have a ringtone sequence of 7 notes safely without overshooting.
var maxPow = Math.Pow(noteLength, RingtoneLength);
if (maxPow > int.MaxValue)
{
ringtone = null;
return false;
}
for (var i = 0; i < RingtoneLength; i++)
{
ringtone[i] = _random.Pick(notes);
var pow = Math.Pow(noteLength, RingtoneLength - 1 - i);
var powInt = Convert.ToInt32(pow);
var val = serializedRingtone / powInt;
if (!AllowedNotes.TryGetValue(val, out var note))
{
ringtone = null;
return false;
}
ringtone[RingtoneLength - 1 - i] = note;
serializedRingtone -= val * powInt;
}
return ringtone;
return true;
}
/// <summary>
/// Try to get the store entity that has the matching ringer access.
/// </summary>
/// <param name="notes">Notes from the ringer.</param>
/// <param name="store">The store entity, if there is one.</param>
/// <param name="ringer">The entity providing the code.</param>
public bool TryMatchRingtoneToStore(Note[] notes, [NotNullWhen(true)] out EntityUid? store, EntityUid? ringer = null)
{
var query = EntityQueryEnumerator<RingerAccessUplinkComponent>();
while (query.MoveNext(out var uid, out var comp))
{
if (comp.Code != null && notes.SequenceEqual(comp.Code))
{
if (comp.BoundEntity != null && comp.BoundEntity != ringer)
break;
store = uid;
return true;
}
}
store = null;
return false;
}
private void CleanupReserved(RoundRestartCleanupEvent ev)
{
ReservedSerializedRingtones.Clear();
}
private int NextIntInRangeButExclude(int start, int end, int[]? excludes)
{
excludes ??= new int[0];
Array.Sort(excludes);
var rangeLength = end - start - excludes.Length;
var randomInt = _random.Next(rangeLength) + start;
for (var i = 0; i < excludes.Length; i++)
{
if (excludes[i] > randomInt)
{
return randomInt;
}
randomInt++;
}
return randomInt;
}
public void SetBoundUplinkEntity(Entity<RingerAccessUplinkComponent> entity, EntityUid? targetEntity)
{
entity.Comp.BoundEntity = targetEntity;
}
}

View file

@ -41,6 +41,16 @@ public sealed partial class StoreSystem
SubscribeLocalEvent<StoreComponent, StoreRequestWithdrawMessage>(OnRequestWithdraw);
SubscribeLocalEvent<StoreComponent, StoreRequestRefundMessage>(OnRequestRefund);
SubscribeLocalEvent<StoreComponent, RefundEntityDeletedEvent>(OnRefundEntityDeleted);
SubscribeLocalEvent<RemoteStoreComponent, StoreRequestUpdateInterfaceMessage>((e, c, ev) =>
RemoteStoreRelay((e, c), ev));
SubscribeLocalEvent<RemoteStoreComponent, StoreBuyListingMessage>((e, c, ev) =>
RemoteStoreRelay((e, c), ev));
SubscribeLocalEvent<RemoteStoreComponent, StoreRequestWithdrawMessage>((e, c, ev) =>
RemoteStoreRelay((e, c), ev));
SubscribeLocalEvent<RemoteStoreComponent, StoreRequestRefundMessage>((e, c, ev) =>
RemoteStoreRelay((e, c), ev));
SubscribeLocalEvent<RemoteStoreComponent, RefundEntityDeletedEvent>((e, c, ev) =>
RemoteStoreRelay((e, c), ev));
}
private void OnRefundEntityDeleted(Entity<StoreComponent> ent, ref RefundEntityDeletedEvent args)
@ -48,21 +58,34 @@ public sealed partial class StoreSystem
ent.Comp.BoughtEntities.Remove(args.Uid);
}
private void RemoteStoreRelay(Entity<RemoteStoreComponent> entity, object ev)
{
if (entity.Comp.Store == null || !TryComp<StoreComponent>(entity.Comp.Store, out var store))
return;
RaiseLocalEvent(entity.Comp.Store.Value, ev);
}
/// <summary>
/// Toggles the store Ui open and closed
/// </summary>
/// <param name="user">the person doing the toggling</param>
/// <param name="storeEnt">the store being toggled</param>
/// <param name="component"></param>
public void ToggleUi(EntityUid user, EntityUid storeEnt, StoreComponent? component = null)
/// <param name="remoteAccess">The entity remotely accessing the store, if any.</param>
/// <param name="remoteComponent">The remote access component, if any.</param>
public void ToggleUi(EntityUid user, EntityUid storeEnt, StoreComponent? component = null, EntityUid? remoteAccess = null, RemoteStoreComponent? remoteComponent = null)
{
if (!Resolve(storeEnt, ref component))
return;
if (remoteAccess != null && !Resolve(remoteAccess.Value, ref remoteComponent) && remoteComponent!.Store != storeEnt)
return;
if (!TryComp<ActorComponent>(user, out var actor))
return;
if (!_ui.TryToggleUi(storeEnt, StoreUiKey.Key, actor.PlayerSession))
if (!_ui.TryToggleUi(remoteAccess != null ? remoteAccess.Value : storeEnt, StoreUiKey.Key, actor.PlayerSession))
return;
UpdateUserInterface(user, storeEnt, component);
@ -114,9 +137,27 @@ public sealed partial class StoreSystem
var showFooter = HasComp<RingerUplinkComponent>(store);
var state = new StoreUpdateState(component.LastAvailableListings, allCurrency, showFooter, component.RefundAllowed);
UpdateRemoteStores(store, state);
_ui.SetUiState(store, StoreUiKey.Key, state);
}
/// <summary>
/// Updates any remote store connections to a specific store.
/// </summary>
/// <param name="store">The store being updated.</param>
/// <param name="state">The state being applied.</param>
public void UpdateRemoteStores(EntityUid store, StoreUpdateState state)
{
var query = EntityQueryEnumerator<RemoteStoreComponent, UserInterfaceComponent>();
while (query.MoveNext(out var uid, out var remote, out var ui))
{
if (remote.Store != store)
continue;
_ui.SetUiState((uid, ui), StoreUiKey.Key, state);
}
}
private void OnRequestUpdate(EntityUid uid, StoreComponent component, StoreRequestUpdateInterfaceMessage args)
{
UpdateUserInterface(args.Actor, GetEntity(args.Entity), component);
@ -284,7 +325,8 @@ public sealed partial class StoreSystem
$"{ToPrettyString(buyer):player} purchased listing \"{ListingLocalisationHelpers.GetLocalisedNameOrEntityName(listing, _proto)}\" from {ToPrettyString(uid)}{logExtraInfo}.");
listing.PurchaseAmount++; //track how many times something has been purchased
_audio.PlayEntity(component.BuySuccessSound, msg.Actor, uid); //cha-ching!
if (msg.SoundSource != null && GetEntity(msg.SoundSource) != null)
_audio.PlayEntity(component.BuySuccessSound, msg.Actor, GetEntity(msg.SoundSource.Value)); //cha-ching!
var buyFinished = new StoreBuyFinishedEvent
{

View file

@ -15,11 +15,7 @@ using Robust.Shared.Utility;
namespace Content.Server.Store.Systems;
/// <summary>
/// Manages general interactions with a store and different entities,
/// getting listings for stores, and interfacing with the store UI.
/// </summary>
public sealed partial class StoreSystem : EntitySystem
public sealed partial class StoreSystem : SharedStoreSystem
{
[Dependency] private readonly IPrototypeManager _proto = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
@ -95,23 +91,23 @@ public sealed partial class StoreSystem : EntitySystem
private void OnAfterInteract(EntityUid uid, CurrencyComponent component, AfterInteractEvent args)
{
if (args.Handled || !args.CanReach)
if (args.Handled || !args.CanReach || args.Target is not { } target)
return;
if (!TryComp<StoreComponent>(args.Target, out var store))
if (!TryGetStore(target, out var store))
return;
var ev = new CurrencyInsertAttemptEvent(args.User, args.Target.Value, args.Used, store);
RaiseLocalEvent(args.Target.Value, ev);
var ev = new CurrencyInsertAttemptEvent(args.User, target, args.Used, store.Value.Comp);
RaiseLocalEvent(target, ev);
if (ev.Cancelled)
return;
if (!TryAddCurrency((uid, component), (args.Target.Value, store)))
if (!TryAddCurrency((uid, component), (store.Value, store.Value.Comp)))
return;
args.Handled = true;
var msg = Loc.GetString("store-currency-inserted", ("used", args.Used), ("target", args.Target));
_popup.PopupEntity(msg, args.Target.Value, args.User);
var msg = Loc.GetString("store-currency-inserted", ("used", args.Used), ("target", target));
_popup.PopupEntity(msg, target, args.User);
}
private void OnImplantActivate(EntityUid uid, StoreComponent component, OpenUplinkImplantEvent args)

View file

@ -73,7 +73,13 @@ public sealed class AddUplinkCommand : LocalizedEntityCommands
}
// Finally add uplink
if (!_uplinkSystem.AddUplink(user, 20, uplinkEntity: uplinkEntity, giveDiscounts: isDiscounted))
var result = _uplinkSystem.AddUplink(user, 20, out var code, uplinkEntity: uplinkEntity, giveDiscounts: isDiscounted);
if (code != null && result == AddUplinkResult.Pda)
shell.WriteLine(Loc.GetString("add-uplink-command-success-pda", ("code", string.Join("-", code).Replace("sharp", "#"))));
else if (result == AddUplinkResult.Implant)
shell.WriteLine(Loc.GetString("add-uplink-command-success-implant"));
else if (result == AddUplinkResult.Failure)
shell.WriteLine(Loc.GetString("add-uplink-command-error-2"));
}

View file

@ -1,4 +1,5 @@
using System.Linq;
using Content.Server.PDA.Ringer;
using Content.Server.Store.Systems;
using Content.Server.StoreDiscount.Systems;
using Content.Shared.FixedPoint;
@ -9,6 +10,7 @@ using Content.Shared.Mind;
using Content.Shared.PDA;
using Content.Shared.Store;
using Content.Shared.Store.Components;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
namespace Content.Server.Traitor.Uplink;
@ -21,7 +23,9 @@ public sealed class UplinkSystem : EntitySystem
[Dependency] private readonly StoreSystem _store = default!;
[Dependency] private readonly SharedSubdermalImplantSystem _subdermalImplant = default!;
[Dependency] private readonly SharedMindSystem _mind = default!;
[Dependency] private readonly RingerSystem _ringer = default!;
public static readonly EntProtoId<StoreComponent> TraitorUplinkStore = "StorePresetRemoteUplink";
public static readonly ProtoId<CurrencyPrototype> TelecrystalCurrencyPrototype = "Telecrystal";
private static readonly EntProtoId FallbackUplinkImplant = "UplinkImplant";
private static readonly ProtoId<ListingPrototype> FallbackUplinkCatalog = "UplinkUplinkImplanter";
@ -31,28 +35,72 @@ public sealed class UplinkSystem : EntitySystem
/// </summary>
/// <param name="user">The person who is getting the uplink</param>
/// <param name="balance">The amount of currency on the uplink. If null, will just use the amount specified in the preset.</param>
/// <param name="code">The code which was generated, if any.</param>
/// <param name="uplinkEntity">The entity that will actually have the uplink functionality. Defaults to the PDA if null.</param>
/// <param name="storeEntity">The entity that will have the store in it.</param>
/// <param name="giveDiscounts">Marker that enables discounts for uplink items.</param>
/// <returns>Whether or not the uplink was added successfully</returns>
public bool AddUplink(
/// <param name="bindToPda">Binds the uplink to the specific uplink entity.</param>
/// <returns>Whether the uplink was added successfully to a PDA, implant or not at all.</returns>
public AddUplinkResult AddUplink(
EntityUid user,
FixedPoint2 balance,
out Note[]? code,
EntityUid? uplinkEntity = null,
bool giveDiscounts = false)
EntityUid? storeEntity = null,
bool giveDiscounts = false,
bool bindToPda = false)
{
// Try to find target item if none passed
code = null;
if (TryAddEntityUplink(user, balance, out var generatedCode, uplinkEntity, storeEntity, giveDiscounts, bindToPda))
{
code = generatedCode;
return AddUplinkResult.Pda;
}
if (TryImplantUplink(user, balance, giveDiscounts))
{
return AddUplinkResult.Implant;
}
return AddUplinkResult.Failure;
}
public bool TryAddEntityUplink(
EntityUid user,
FixedPoint2 balance,
out Note[]? code,
EntityUid? uplinkEntity,
EntityUid? storeEntity,
bool giveDiscounts = false,
bool bindToPda = false)
{
code = null;
storeEntity ??= Spawn(TraitorUplinkStore, MapCoordinates.Nullspace);
uplinkEntity ??= FindUplinkTarget(user);
if (uplinkEntity == null)
return ImplantUplink(user, balance, giveDiscounts);
return false;
EnsureComp<UplinkComponent>(uplinkEntity.Value);
var ev = new GenerateUplinkCodeEvent();
RaiseLocalEvent(storeEntity.Value, ref ev);
SetUplink(user, uplinkEntity.Value, balance, giveDiscounts);
if (ev.Code == null)
{
QueueDel(storeEntity);
return false;
}
// TODO add BUI. Currently can't be done outside of yaml -_-
// ^ What does this even mean?
code = ev.Code;
if (bindToPda)
{
var accessComp = EnsureComp<RingerAccessUplinkComponent>(storeEntity.Value);
_ringer.SetBoundUplinkEntity((storeEntity.Value, accessComp), uplinkEntity.Value);
}
SetUplink(user, storeEntity.Value, uplinkEntity.Value, balance, giveDiscounts);
return true;
}
@ -60,25 +108,35 @@ public sealed class UplinkSystem : EntitySystem
/// <summary>
/// Configure TC for the uplink
/// </summary>
private void SetUplink(EntityUid user, EntityUid uplink, FixedPoint2 balance, bool giveDiscounts)
private void SetUplink(EntityUid user, EntityUid store, EntityUid uplink, FixedPoint2 balance, bool giveDiscounts)
{
SetUplink(user, store, balance, giveDiscounts);
var remote = EnsureComp<RemoteStoreComponent>(uplink);
remote.Store = store;
}
/// <summary>
/// Configure TC for the uplink
/// </summary>
private void SetUplink(EntityUid user, EntityUid store, FixedPoint2 balance, bool giveDiscounts)
{
if (!_mind.TryGetMind(user, out var mind, out _))
return;
var store = EnsureComp<StoreComponent>(uplink);
var storeComp = EnsureComp<StoreComponent>(store);
store.AccountOwner = mind;
storeComp.AccountOwner = mind;
store.Balance.Clear();
storeComp.Balance.Clear();
_store.TryAddCurrency(new Dictionary<string, FixedPoint2> { { TelecrystalCurrencyPrototype, balance } },
uplink,
store);
store,
storeComp);
var uplinkInitializedEvent = new StoreInitializedEvent(
TargetUser: mind,
Store: uplink,
Store: store,
UseDiscounts: giveDiscounts,
Listings: _store.GetAvailableListings(mind, uplink, store)
Listings: _store.GetAvailableListings(mind, store, storeComp)
.ToArray());
RaiseLocalEvent(ref uplinkInitializedEvent);
}
@ -86,7 +144,7 @@ public sealed class UplinkSystem : EntitySystem
/// <summary>
/// Implant an uplink as a fallback measure if the traitor had no PDA
/// </summary>
private bool ImplantUplink(EntityUid user, FixedPoint2 balance, bool giveDiscounts)
public bool TryImplantUplink(EntityUid user, FixedPoint2 balance, bool giveDiscounts)
{
if (!_proto.Resolve<ListingPrototype>(FallbackUplinkCatalog, out var catalog))
return false;
@ -115,7 +173,7 @@ public sealed class UplinkSystem : EntitySystem
/// Finds the entity that can hold an uplink for a user.
/// Usually this is a pda in their pda slot, but can also be in their hands. (but not pockets or inside bag, etc.)
/// </summary>
public EntityUid? FindUplinkTarget(EntityUid user)
public Entity<RemoteStoreComponent>? FindUplinkTarget(EntityUid user)
{
// Try to find PDA in inventory
if (_inventorySystem.TryGetContainerSlotEnumerator(user, out var containerSlotEnumerator))
@ -124,18 +182,25 @@ public sealed class UplinkSystem : EntitySystem
{
var pdaUid = containerSlot.ContainedEntity;
if (HasComp<PdaComponent>(pdaUid) && HasComp<StoreComponent>(pdaUid))
return pdaUid;
if (HasComp<StoreComponent>(pdaUid) && TryComp<RemoteStoreComponent>(pdaUid, out var remote))
return (pdaUid.Value, remote);
}
}
// Also check hands
foreach (var item in _handsSystem.EnumerateHeld(user))
{
if (HasComp<PdaComponent>(item) && HasComp<StoreComponent>(item))
return item;
if (HasComp<StoreComponent>(item) && TryComp<RemoteStoreComponent>(item, out var remote))
return (item, remote);
}
return null;
}
}
public enum AddUplinkResult
{
Pda,
Implant,
Failure,
}

View file

@ -3,22 +3,21 @@ using Robust.Shared.GameStates;
namespace Content.Shared.PDA.Ringer;
/// <summary>
/// Opens the store UI when the ringstone is set to the secret code.
/// Makes a PDA able to open store UIs when the ringtone is set to a secret code.
/// Traitors are told the code when greeted.
/// </summary>
[RegisterComponent, NetworkedComponent, Access(typeof(SharedRingerSystem))]
public sealed partial class RingerUplinkComponent : Component
{
/// <summary>
/// Notes to set ringtone to in order to lock or unlock the uplink.
/// Set via GenerateUplinkCodeEvent.
/// </summary>
[DataField]
public Note[]? Code;
/// <summary>
/// Whether to show the toggle uplink button in PDA settings.
/// </summary>
[DataField]
public bool Unlocked;
/// <summary>
/// The store which the ringer is targetting.
/// </summary>
[DataField]
public EntityUid? TargetStore;
}

View file

@ -137,6 +137,7 @@ public abstract class SharedRingerSystem : EntitySystem
return;
ent.Comp.Unlocked = false;
ent.Comp.TargetStore = null;
UI.CloseUi(ent.Owner, StoreUiKey.Key);
}
@ -249,7 +250,10 @@ public abstract class SharedRingerSystem : EntitySystem
// Close store UI if we're locking
if (!ent.Comp.Unlocked)
{
ent.Comp.TargetStore = null;
UI.CloseUi(ent.Owner, StoreUiKey.Key);
}
return true;
}

View file

@ -0,0 +1,17 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Store.Components;
/// <summary>
/// This component manages a store which players can use to purchase different listings
/// through the ui. The currency, listings, and categories are defined in yaml.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class RemoteStoreComponent : Component
{
/// <summary>
/// The store which is currently being targetted for remote opening
/// </summary>
[DataField]
public EntityUid? Store;
}

View file

@ -0,0 +1,59 @@
using System.Diagnostics.CodeAnalysis;
using Content.Shared.Store.Components;
namespace Content.Shared.Store;
/// <summary>
/// Manages general interactions with a store and different entities,
/// getting listings for stores, and interfacing with the store UI.
/// </summary>
public abstract partial class SharedStoreSystem : EntitySystem
{
[Dependency] protected readonly EntityQuery<StoreComponent> StoreQuery = default!;
[Dependency] protected readonly EntityQuery<RemoteStoreComponent> RemoteStoreQuery = default!;
/// <summary>
/// Attempts to find a store connected to this entity.
/// First checking for a <see cref="StoreComponent"/> on this entity,
/// then checking for a <see cref="RemoteStoreComponent"/> to find a remotely connected store.
/// </summary>
/// <param name="entity">Entity we're checking for an attached store on</param>
/// <param name="store">Store entity we're returning.</param>
/// <returns>True if a store was found.</returns>
public bool TryGetStore(Entity<RemoteStoreComponent?> entity, [NotNullWhen(true)] out Entity<StoreComponent>? store)
{
store = GetStore(entity);
return store != null;
}
/// <summary>
/// Attempts to find a store connected to this entity.
/// First checking for a <see cref="StoreComponent"/> on this entity,
/// then checking for a <see cref="RemoteStoreComponent"/> to find a remotely connected store.
/// </summary>
/// <param name="entity">Entity we're checking for an attached store on</param>
/// <returns>The store entity and component if found.</returns>
public Entity<StoreComponent>? GetStore(Entity<RemoteStoreComponent?> entity)
{
if (StoreQuery.TryComp(entity, out var storeComp))
return (entity, storeComp);
return GetRemoteStore(entity);
}
/// <summary>
/// Attempts to find a remote store connected to this entity.
/// Checking for a <see cref="RemoteStoreComponent"/> with an attached store entity.
/// </summary>
/// <param name="entity">Entity we're checking for an attached store on</param>
/// <returns>The store entity and component if found.</returns>
public Entity<StoreComponent>? GetRemoteStore(Entity<RemoteStoreComponent?> entity)
{
if (RemoteStoreQuery.Resolve(entity, ref entity.Comp, false)
&& entity.Comp.Store != null
&& StoreQuery.TryComp(entity.Comp.Store, out var storeComp))
return (entity.Comp.Store.Value, storeComp);
return null;
}
}

View file

@ -37,9 +37,10 @@ public sealed class StoreRequestUpdateInterfaceMessage : BoundUserInterfaceMessa
}
[Serializable, NetSerializable]
public sealed class StoreBuyListingMessage(ProtoId<ListingPrototype> listing) : BoundUserInterfaceMessage
public sealed class StoreBuyListingMessage(ProtoId<ListingPrototype> listing, NetEntity? soundSource) : BoundUserInterfaceMessage
{
public ProtoId<ListingPrototype> Listing = listing;
public NetEntity? SoundSource = soundSource;
}
[Serializable, NetSerializable]

View file

@ -5,4 +5,6 @@ add-uplink-command-completion-1 = Username (defaults to self)
add-uplink-command-completion-2 = Uplink uid (default to PDA)
add-uplink-command-completion-3 = Is uplink discount enabled
add-uplink-command-error-1 = Selected player doesn't control any entity
add-uplink-command-error-2 = Failed to add uplink to the player
add-uplink-command-error-2 = Failed to add uplink to the player
add-uplink-command-success-pda = Uplink added to player PDA with code {$code}
add-uplink-command-success-implant = Uplink added to player as an implant

View file

@ -1,6 +1,6 @@
- type: entity
abstract: true
parent: [ BaseItem, StorePresetUplink ] #PDA's have uplinks so they have to inherit the data.
parent: [ BaseItem ]
id: BasePDA
name: PDA
description: Personal Data Assistant.
@ -107,6 +107,7 @@
type: InstrumentBoundUserInterface
enum.HealthAnalyzerUiKey.Key:
type: HealthAnalyzerBoundUserInterface
- type: RemoteStore # PDAs can access uplinks so they need this component.
- type: Tag
tags:
- DoorBumpOpener

View file

@ -51,3 +51,10 @@
- ChangelingAbilities
currencyWhitelist:
- ChangelingDNA
- type: entity
parent: StorePresetUplink
id: StorePresetRemoteUplink
categories: [ HideSpawnMenu ]
components:
- type: RingerAccessUplink