using System.Diagnostics.CodeAnalysis; using System.Linq; using Content.Shared.GameTicking; using Content.Shared.PDA; using Content.Shared.PDA.Ringer; using Content.Shared.Store; using Robust.Shared.Random; using Robust.Shared.Utility; namespace Content.Server.PDA.Ringer; /// /// Handles the server-side logic for . /// public sealed partial class RingerSystem : SharedRingerSystem { [Dependency] private IRobustRandom _random = default!; public static Note[] AllowedNotes = { Note.C, Note.D, Note.E, Note.F, Note.G, Note.A, Note.B }; /// /// Stores the serialized version of any ringtone that can be excluded from new ringtone generations. /// [ViewVariables] public readonly HashSet ReservedSerializedRingtones = new(); /// public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnMapInit); SubscribeLocalEvent(OnCurrencyInsert); SubscribeLocalEvent(OnGenerateUplinkCode); SubscribeLocalEvent(CleanupReserved); InitialSetup(); } /// /// Randomizes a ringtone for on . /// private void OnMapInit(Entity ent, ref MapInitEvent args) { var ringtone = GenerateRingtone(); ringtone ??= new Note[RingtoneLength] { Note.A, Note.A, Note.A, Note.A, Note.A, Note.A }; // Fallback UpdateRingerRingtone(ent, ringtone); } /// /// Handles the for . /// private void OnCurrencyInsert(Entity ent, ref CurrencyInsertAttemptEvent args) { // TODO: Store isn't predicted, can't move it to shared if (!TryComp(ent, out var uplink)) { args.Cancel(); return; } // if the store can be locked, it must be unlocked first before inserting currency. Stops traitor checking. if (!uplink.Unlocked) args.Cancel(); } /// /// Handles the for generating an uplink code. /// private void OnGenerateUplinkCode(Entity ent, ref GenerateUplinkCodeEvent ev) { 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(); } /// public override bool TryToggleUplink(Entity entity, Note[] ringtone, EntityUid? user = null) { if (!Resolve(entity, ref entity.Comp)) return false; // On the server, we always check if the code matches if (!TryMatchRingtoneToStore(ringtone, out var store, entity)) return false; // If the store is not this entity, we make sure to properly set the remote store. if (store != entity.Owner) Store.SetRemoteStore(entity.Owner, store); return ToggleUplinkInternal((entity, entity.Comp)); } /// /// Generates a random ringtone using the C major scale. /// /// Exclude any ringtone registered to ReservedSerializedRingtones. /// Add the generated ringtone to ReservedSerializedRingtones. Requires ExcludeReserved to be true. /// An array of Notes representing the ringtone. /// The logic for this is on the Server so that we don't get a different result on the Client every time. private Note[]? GenerateRingtone(bool excludeReserved = false, bool reserveRingtone = false) { // Default to using C major so it at least sounds not terrible. return GenerateRingtone(AllowedNotes, excludeReserved, reserveRingtone); } /// /// Generates a random ringtone using the specified notes. /// /// The notes to choose from when generating the ringtone. /// Exclude any ringtone registered to ReservedSerializedRingtones. /// Add the generated ringtone to ReservedSerializedRingtones. Requires ExcludeReserved to be true. /// An array of Notes representing the ringtone. /// The logic for this is on the Server so that we don't get a different result on the Client every time. private Note[]? GenerateRingtone(Note[] notes, bool excludeReserved = false, bool reserveRingtone = false) { 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; } /// /// Serialize a ringtone, representing it as an Int32. /// /// The array of notes used to generate the ringtone. /// The ringtone which needs to be serialized. /// The ringtone in a serialized format. /// Whether the ringtone could be serialized or not. 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; } /// /// Deserialize a serialized ringtone into a Note array. /// /// The array of notes used to generate the ringtone. /// The ringtone in a serialized format. /// The ringtone resulting from the deserialization. /// Whether the ringtone could be deserialized or not. 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++) { 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 true; } /// /// Try to get the store entity that has the matching ringer access. /// /// Notes from the ringer. /// The store entity, if there is one. /// The entity providing the code. public bool TryMatchRingtoneToStore(Note[] notes, [NotNullWhen(true)] out EntityUid? store, EntityUid? ringer = null) { var query = EntityQueryEnumerator(); 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 entity, EntityUid? targetEntity) { entity.Comp.BoundEntity = targetEntity; } } /// /// Event raised to generate a new uplink code for a PDA. /// [ByRefEvent] public record struct GenerateUplinkCodeEvent { /// /// The generated uplink code (filled in by the event handler). /// public Note[]? Code; }