StationRecordsSystem.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. using System.Diagnostics.CodeAnalysis;
  2. using Content.Server.Access.Systems;
  3. using Content.Server.Forensics;
  4. using Content.Shared.Access.Components;
  5. using Content.Shared.Forensics.Components;
  6. using Content.Shared.GameTicking;
  7. using Content.Shared.Inventory;
  8. using Content.Shared.PDA;
  9. using Content.Shared.Preferences;
  10. using Content.Shared.Roles;
  11. using Content.Shared.StationRecords;
  12. using Robust.Shared.Enums;
  13. using Robust.Shared.Prototypes;
  14. using Robust.Shared.Random;
  15. namespace Content.Server.StationRecords.Systems;
  16. /// <summary>
  17. /// Station records.
  18. ///
  19. /// A station record is tied to an ID card, or anything that holds
  20. /// a station record's key. This key will determine access to a
  21. /// station record set's record entries, and it is imperative not
  22. /// to lose the item that holds the key under any circumstance.
  23. ///
  24. /// Records are mostly a roleplaying tool, but can have some
  25. /// functionality as well (i.e., security records indicating that
  26. /// a specific person holding an ID card with a linked key is
  27. /// currently under warrant, showing a crew manifest with user
  28. /// settable, custom titles).
  29. ///
  30. /// General records are tied into this system, as most crewmembers
  31. /// should have a general record - and most systems should probably
  32. /// depend on this general record being created. This is subject
  33. /// to change.
  34. /// </summary>
  35. public sealed class StationRecordsSystem : SharedStationRecordsSystem
  36. {
  37. [Dependency] private readonly InventorySystem _inventory = default!;
  38. [Dependency] private readonly StationRecordKeyStorageSystem _keyStorage = default!;
  39. [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
  40. [Dependency] private readonly IdCardSystem _idCard = default!;
  41. [Dependency] private readonly IRobustRandom _random = default!;
  42. public override void Initialize()
  43. {
  44. base.Initialize();
  45. SubscribeLocalEvent<PlayerSpawnCompleteEvent>(OnPlayerSpawn);
  46. SubscribeLocalEvent<EntityRenamedEvent>(OnRename);
  47. }
  48. private void OnPlayerSpawn(PlayerSpawnCompleteEvent args)
  49. {
  50. if (!TryComp<StationRecordsComponent>(args.Station, out var stationRecords))
  51. return;
  52. CreateGeneralRecord(args.Station, args.Mob, args.Profile, args.JobId, stationRecords);
  53. }
  54. private void OnRename(ref EntityRenamedEvent ev)
  55. {
  56. // When a player gets renamed their card gets changed to match.
  57. // Unfortunately this means that an event is called for it as well, and since TryFindIdCard will succeed if the
  58. // given entity is a card and the card itself is the key the record will be mistakenly renamed to the card's name
  59. // if we don't return early.
  60. // We also do not include the PDA itself being renamed, as that triggers the same event (e.g. for chameleon PDAs).
  61. if (HasComp<IdCardComponent>(ev.Uid) || HasComp<PdaComponent>(ev.Uid))
  62. return;
  63. if (_idCard.TryFindIdCard(ev.Uid, out var idCard))
  64. {
  65. if (TryComp(idCard, out StationRecordKeyStorageComponent? keyStorage)
  66. && keyStorage.Key is {} key)
  67. {
  68. if (TryGetRecord<GeneralStationRecord>(key, out var generalRecord))
  69. {
  70. generalRecord.Name = ev.NewName;
  71. }
  72. Synchronize(key);
  73. }
  74. }
  75. }
  76. private void CreateGeneralRecord(EntityUid station, EntityUid player, HumanoidCharacterProfile profile,
  77. string? jobId, StationRecordsComponent records)
  78. {
  79. // TODO make PlayerSpawnCompleteEvent.JobId a ProtoId
  80. if (string.IsNullOrEmpty(jobId)
  81. || !_prototypeManager.HasIndex<JobPrototype>(jobId))
  82. return;
  83. if (!_inventory.TryGetSlotEntity(player, "id", out var idUid))
  84. return;
  85. TryComp<FingerprintComponent>(player, out var fingerprintComponent);
  86. TryComp<DnaComponent>(player, out var dnaComponent);
  87. CreateGeneralRecord(station, idUid.Value, profile.Name, profile.Age, profile.Species, profile.Gender, jobId, fingerprintComponent?.Fingerprint, dnaComponent?.DNA, profile, records);
  88. }
  89. /// <summary>
  90. /// Create a general record to store in a station's record set.
  91. /// </summary>
  92. /// <remarks>
  93. /// This is tied into the record system, as any crew member's
  94. /// records should generally be dependent on some generic
  95. /// record with the bare minimum of information involved.
  96. /// </remarks>
  97. /// <param name="station">The entity uid of the station.</param>
  98. /// <param name="idUid">The entity uid of an entity's ID card. Can be null.</param>
  99. /// <param name="name">Name of the character.</param>
  100. /// <param name="species">Species of the character.</param>
  101. /// <param name="gender">Gender of the character.</param>
  102. /// <param name="jobId">
  103. /// The job to initially tie this record to. This must be a valid job loaded in, otherwise
  104. /// this call will cause an exception. Ensure that a general record starts out with a job
  105. /// that is currently a valid job prototype.
  106. /// </param>
  107. /// <param name="mobFingerprint">Fingerprint of the character.</param>
  108. /// <param name="dna">DNA of the character.</param>
  109. ///
  110. /// <param name="profile">
  111. /// Profile for the related player. This is so that other systems can get further information
  112. /// about the player character.
  113. /// Optional - other systems should anticipate this.
  114. /// </param>
  115. /// <param name="records">Station records component.</param>
  116. public void CreateGeneralRecord(
  117. EntityUid station,
  118. EntityUid? idUid,
  119. string name,
  120. int age,
  121. string species,
  122. Gender gender,
  123. string jobId,
  124. string? mobFingerprint,
  125. string? dna,
  126. HumanoidCharacterProfile profile,
  127. StationRecordsComponent records)
  128. {
  129. if (!_prototypeManager.TryIndex<JobPrototype>(jobId, out var jobPrototype))
  130. throw new ArgumentException($"Invalid job prototype ID: {jobId}");
  131. // when adding a record that already exists use the old one
  132. // this happens when respawning as the same character
  133. if (GetRecordByName(station, name, records) is {} id)
  134. {
  135. SetIdKey(idUid, new StationRecordKey(id, station));
  136. return;
  137. }
  138. var record = new GeneralStationRecord()
  139. {
  140. Name = name,
  141. Age = age,
  142. JobTitle = jobPrototype.LocalizedName,
  143. JobIcon = jobPrototype.Icon,
  144. JobPrototype = jobId,
  145. Species = species,
  146. Gender = gender,
  147. DisplayPriority = jobPrototype.RealDisplayWeight,
  148. Fingerprint = mobFingerprint,
  149. DNA = dna
  150. };
  151. var key = AddRecordEntry(station, record);
  152. if (!key.IsValid())
  153. {
  154. Log.Warning($"Failed to add general record entry for {name}");
  155. return;
  156. }
  157. SetIdKey(idUid, key);
  158. RaiseLocalEvent(new AfterGeneralRecordCreatedEvent(key, record, profile));
  159. }
  160. /// <summary>
  161. /// Set the station records key for an id/pda.
  162. /// </summary>
  163. public void SetIdKey(EntityUid? uid, StationRecordKey key)
  164. {
  165. if (uid is not {} idUid)
  166. return;
  167. var keyStorageEntity = idUid;
  168. if (TryComp<PdaComponent>(idUid, out var pda) && pda.ContainedId is {} id)
  169. {
  170. keyStorageEntity = id;
  171. }
  172. _keyStorage.AssignKey(keyStorageEntity, key);
  173. }
  174. /// <summary>
  175. /// Removes a record from this station.
  176. /// </summary>
  177. /// <param name="key">The station and key to remove.</param>
  178. /// <param name="records">Station records component.</param>
  179. /// <returns>True if the record was removed, false otherwise.</returns>
  180. public bool RemoveRecord(StationRecordKey key, StationRecordsComponent? records = null)
  181. {
  182. if (!Resolve(key.OriginStation, ref records))
  183. return false;
  184. if (records.Records.RemoveAllRecords(key.Id))
  185. {
  186. RaiseLocalEvent(new RecordRemovedEvent(key));
  187. return true;
  188. }
  189. return false;
  190. }
  191. /// <summary>
  192. /// Try to get a record from this station's record entries,
  193. /// from the provided station record key. Will always return
  194. /// null if the key does not match the station.
  195. /// </summary>
  196. /// <param name="key">Station and key to try and index from the record set.</param>
  197. /// <param name="entry">The resulting entry.</param>
  198. /// <param name="records">Station record component.</param>
  199. /// <typeparam name="T">Type to get from the record set.</typeparam>
  200. /// <returns>True if the record was obtained, false otherwise.</returns>
  201. public bool TryGetRecord<T>(StationRecordKey key, [NotNullWhen(true)] out T? entry, StationRecordsComponent? records = null)
  202. {
  203. entry = default;
  204. if (!Resolve(key.OriginStation, ref records))
  205. return false;
  206. return records.Records.TryGetRecordEntry(key.Id, out entry);
  207. }
  208. /// <summary>
  209. /// Gets a random record from the station's record entries.
  210. /// </summary>
  211. /// <param name="ent">The EntityId of the station from which you want to get the record.</param>
  212. /// <param name="entry">The resulting entry.</param>
  213. /// <typeparam name="T">Type to get from the record set.</typeparam>
  214. /// <returns>True if a record was obtained. False otherwise.</returns>
  215. public bool TryGetRandomRecord<T>(Entity<StationRecordsComponent?> ent, [NotNullWhen(true)] out T? entry)
  216. {
  217. entry = default;
  218. if (!Resolve(ent.Owner, ref ent.Comp))
  219. return false;
  220. if (ent.Comp.Records.Keys.Count == 0)
  221. return false;
  222. var key = _random.Pick(ent.Comp.Records.Keys);
  223. return ent.Comp.Records.TryGetRecordEntry(key, out entry);
  224. }
  225. /// <summary>
  226. /// Returns an id if a record with the same name exists.
  227. /// </summary>
  228. /// <remarks>
  229. /// Linear search so O(n) time complexity.
  230. /// </remarks>
  231. public uint? GetRecordByName(EntityUid station, string name, StationRecordsComponent? records = null)
  232. {
  233. if (!Resolve(station, ref records, false))
  234. return null;
  235. foreach (var (id, record) in GetRecordsOfType<GeneralStationRecord>(station, records))
  236. {
  237. if (record.Name == name)
  238. return id;
  239. }
  240. return null;
  241. }
  242. /// <summary>
  243. /// Get the name for a record, or an empty string if it has no record.
  244. /// </summary>
  245. public string RecordName(StationRecordKey key)
  246. {
  247. if (!TryGetRecord<GeneralStationRecord>(key, out var record))
  248. return string.Empty;
  249. return record.Name;
  250. }
  251. /// <summary>
  252. /// Gets all records of a specific type from a station.
  253. /// </summary>
  254. /// <param name="station">The station to get the records from.</param>
  255. /// <param name="records">Station records component.</param>
  256. /// <typeparam name="T">Type of record to fetch</typeparam>
  257. /// <returns>Enumerable of pairs with a station record key, and the entry in question of type T.</returns>
  258. public IEnumerable<(uint, T)> GetRecordsOfType<T>(EntityUid station, StationRecordsComponent? records = null)
  259. {
  260. if (!Resolve(station, ref records))
  261. return Array.Empty<(uint, T)>();
  262. return records.Records.GetRecordsOfType<T>();
  263. }
  264. /// <summary>
  265. /// Adds a new record entry to a station's record set.
  266. /// </summary>
  267. /// <param name="station">The station to add the record to.</param>
  268. /// <param name="record">The record to add.</param>
  269. /// <param name="records">Station records component.</param>
  270. /// <typeparam name="T">The type of record to add.</typeparam>
  271. public StationRecordKey AddRecordEntry<T>(EntityUid station, T record, StationRecordsComponent? records = null)
  272. {
  273. if (!Resolve(station, ref records))
  274. return StationRecordKey.Invalid;
  275. var id = records.Records.AddRecordEntry(record);
  276. if (id == null)
  277. return StationRecordKey.Invalid;
  278. return new StationRecordKey(id.Value, station);
  279. }
  280. /// <summary>
  281. /// Adds a record to an existing entry.
  282. /// </summary>
  283. /// <param name="key">The station and id of the existing entry.</param>
  284. /// <param name="record">The record to add.</param>
  285. /// <param name="records">Station records component.</param>
  286. /// <typeparam name="T">The type of record to add.</typeparam>
  287. public void AddRecordEntry<T>(StationRecordKey key, T record,
  288. StationRecordsComponent? records = null)
  289. {
  290. if (!Resolve(key.OriginStation, ref records))
  291. return;
  292. records.Records.AddRecordEntry(key.Id, record);
  293. }
  294. /// <summary>
  295. /// Synchronizes a station's records with any systems that need it.
  296. /// </summary>
  297. /// <param name="station">The station to synchronize any recently accessed records with..</param>
  298. /// <param name="records">Station records component.</param>
  299. public void Synchronize(EntityUid station, StationRecordsComponent? records = null)
  300. {
  301. if (!Resolve(station, ref records))
  302. return;
  303. foreach (var key in records.Records.GetRecentlyAccessed())
  304. {
  305. RaiseLocalEvent(new RecordModifiedEvent(new StationRecordKey(key, station)));
  306. }
  307. records.Records.ClearRecentlyAccessed();
  308. }
  309. /// <summary>
  310. /// Synchronizes a single record's entries for a station.
  311. /// </summary>
  312. /// <param name="key">The station and id of the record</param>
  313. /// <param name="records">Station records component.</param>
  314. public void Synchronize(StationRecordKey key, StationRecordsComponent? records = null)
  315. {
  316. if (!Resolve(key.OriginStation, ref records))
  317. return;
  318. RaiseLocalEvent(new RecordModifiedEvent(key));
  319. records.Records.RemoveFromRecentlyAccessed(key.Id);
  320. }
  321. #region Console system helpers
  322. /// <summary>
  323. /// Checks if a record should be skipped given a filter.
  324. /// Takes general record since even if you are using this for e.g. criminal records,
  325. /// you don't want to duplicate basic info like name and dna.
  326. /// Station records lets you do this nicely with multiple types having their own data.
  327. /// </summary>
  328. public bool IsSkipped(StationRecordsFilter? filter, GeneralStationRecord someRecord)
  329. {
  330. // if nothing is being filtered, show everything
  331. if (filter == null)
  332. return false;
  333. if (filter.Value.Length == 0)
  334. return false;
  335. var filterLowerCaseValue = filter.Value.ToLower();
  336. return filter.Type switch
  337. {
  338. StationRecordFilterType.Name =>
  339. !someRecord.Name.ToLower().Contains(filterLowerCaseValue),
  340. StationRecordFilterType.Prints => someRecord.Fingerprint != null
  341. && IsFilterWithSomeCodeValue(someRecord.Fingerprint, filterLowerCaseValue),
  342. StationRecordFilterType.DNA => someRecord.DNA != null
  343. && IsFilterWithSomeCodeValue(someRecord.DNA, filterLowerCaseValue),
  344. _ => throw new IndexOutOfRangeException(nameof(filter.Type)),
  345. };
  346. }
  347. private bool IsFilterWithSomeCodeValue(string value, string filter)
  348. {
  349. return !value.ToLower().StartsWith(filter);
  350. }
  351. /// <summary>
  352. /// Build a record listing of id to name for a station and filter.
  353. /// </summary>
  354. public Dictionary<uint, string> BuildListing(Entity<StationRecordsComponent> station, StationRecordsFilter? filter)
  355. {
  356. var listing = new Dictionary<uint, string>();
  357. var records = GetRecordsOfType<GeneralStationRecord>(station, station.Comp);
  358. foreach (var pair in records)
  359. {
  360. if (IsSkipped(filter, pair.Item2))
  361. continue;
  362. listing.Add(pair.Item1, pair.Item2.Name);
  363. }
  364. return listing;
  365. }
  366. #endregion
  367. }
  368. /// <summary>
  369. /// Base event for station record events
  370. /// </summary>
  371. public abstract class StationRecordEvent : EntityEventArgs
  372. {
  373. public readonly StationRecordKey Key;
  374. public EntityUid Station => Key.OriginStation;
  375. protected StationRecordEvent(StationRecordKey key)
  376. {
  377. Key = key;
  378. }
  379. }
  380. /// <summary>
  381. /// Event raised after the player's general profile is created.
  382. /// Systems that modify records on a station would have more use
  383. /// listening to this event, as it contains the character's record key.
  384. /// Also stores the general record reference, to save some time.
  385. /// </summary>
  386. public sealed class AfterGeneralRecordCreatedEvent : StationRecordEvent
  387. {
  388. public readonly GeneralStationRecord Record;
  389. /// <summary>
  390. /// Profile for the related player. This is so that other systems can get further information
  391. /// about the player character.
  392. /// Optional - other systems should anticipate this.
  393. /// </summary>
  394. public readonly HumanoidCharacterProfile Profile;
  395. public AfterGeneralRecordCreatedEvent(StationRecordKey key, GeneralStationRecord record,
  396. HumanoidCharacterProfile profile) : base(key)
  397. {
  398. Record = record;
  399. Profile = profile;
  400. }
  401. }
  402. /// <summary>
  403. /// Event raised after a record is removed. Only the key is given
  404. /// when the record is removed, so that any relevant systems/components
  405. /// that store record keys can then remove the key from their internal
  406. /// fields.
  407. /// </summary>
  408. public sealed class RecordRemovedEvent : StationRecordEvent
  409. {
  410. public RecordRemovedEvent(StationRecordKey key) : base(key)
  411. {
  412. }
  413. }
  414. /// <summary>
  415. /// Event raised after a record is modified. This is to
  416. /// inform other systems that records stored in this key
  417. /// may have changed.
  418. /// </summary>
  419. public sealed class RecordModifiedEvent : StationRecordEvent
  420. {
  421. public RecordModifiedEvent(StationRecordKey key) : base(key)
  422. {
  423. }
  424. }