StationRecordsSystem.cs 21 KB

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