GameTicker.RoundFlow.cs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995
  1. using System.Linq;
  2. using System.Numerics;
  3. using Content.Server.Announcements;
  4. using Content.Server.Discord;
  5. using Content.Server.GameTicking.Events;
  6. using Content.Server.Ghost;
  7. using Content.Server.Maps;
  8. using Content.Server.Roles;
  9. using Content.Shared.CCVar;
  10. using Content.Shared.Database;
  11. using Content.Shared.GameTicking;
  12. using Content.Shared.Mind;
  13. using Content.Shared.Players;
  14. using Content.Shared.Preferences;
  15. using JetBrains.Annotations;
  16. using Prometheus;
  17. using Robust.Shared.Asynchronous;
  18. using Robust.Shared.Audio;
  19. using Robust.Shared.EntitySerialization;
  20. using Robust.Shared.EntitySerialization.Systems;
  21. using Robust.Shared.Map;
  22. using Robust.Shared.Map.Components;
  23. using Robust.Shared.Network;
  24. using Robust.Shared.Player;
  25. using Robust.Shared.Random;
  26. using Robust.Shared.Utility;
  27. namespace Content.Server.GameTicking
  28. {
  29. public sealed partial class GameTicker
  30. {
  31. [Dependency] private readonly DiscordWebhook _discord = default!;
  32. [Dependency] private readonly RoleSystem _role = default!;
  33. [Dependency] private readonly ITaskManager _taskManager = default!;
  34. private static readonly Counter RoundNumberMetric = Metrics.CreateCounter(
  35. "ss14_round_number",
  36. "Round number.");
  37. private static readonly Gauge RoundLengthMetric = Metrics.CreateGauge(
  38. "ss14_round_length",
  39. "Round length in seconds.");
  40. #if EXCEPTION_TOLERANCE
  41. [ViewVariables]
  42. private int _roundStartFailCount = 0;
  43. #endif
  44. [ViewVariables]
  45. private bool _startingRound;
  46. [ViewVariables]
  47. private GameRunLevel _runLevel;
  48. private RoundEndMessageEvent.RoundEndPlayerInfo[]? _replayRoundPlayerInfo;
  49. private string? _replayRoundText;
  50. [ViewVariables]
  51. public GameRunLevel RunLevel
  52. {
  53. get => _runLevel;
  54. private set
  55. {
  56. // Game admins can run `restartroundnow` while still in-lobby, which'd break things with this check.
  57. // if (_runLevel == value) return;
  58. var old = _runLevel;
  59. _runLevel = value;
  60. RaiseLocalEvent(new GameRunLevelChangedEvent(old, value));
  61. }
  62. }
  63. /// <summary>
  64. /// Returns true if the round's map is eligible to be updated.
  65. /// </summary>
  66. /// <returns></returns>
  67. public bool CanUpdateMap()
  68. {
  69. return RunLevel == GameRunLevel.PreRoundLobby &&
  70. _roundStartTime - RoundPreloadTime > _gameTiming.CurTime;
  71. }
  72. /// <summary>
  73. /// Loads all the maps for the given round.
  74. /// </summary>
  75. /// <remarks>
  76. /// Must be called before the runlevel is set to InRound.
  77. /// </remarks>
  78. private void LoadMaps()
  79. {
  80. if (_mapManager.MapExists(DefaultMap))
  81. return;
  82. AddGamePresetRules();
  83. var maps = new List<GameMapPrototype>();
  84. // the map might have been force-set by something
  85. // (i.e. votemap or forcemap)
  86. var mainStationMap = _gameMapManager.GetSelectedMap();
  87. if (mainStationMap == null)
  88. {
  89. // otherwise set the map using the config rules
  90. _gameMapManager.SelectMapByConfigRules();
  91. mainStationMap = _gameMapManager.GetSelectedMap();
  92. }
  93. // Small chance the above could return no map.
  94. // ideally SelectMapByConfigRules will always find a valid map
  95. if (mainStationMap != null)
  96. {
  97. maps.Add(mainStationMap);
  98. }
  99. else
  100. {
  101. throw new Exception("invalid config; couldn't select a valid station map!");
  102. }
  103. if (CurrentPreset?.MapPool != null &&
  104. _prototypeManager.TryIndex<GameMapPoolPrototype>(CurrentPreset.MapPool, out var pool) &&
  105. !pool.Maps.Contains(mainStationMap.ID))
  106. {
  107. var msg = Loc.GetString("game-ticker-start-round-invalid-map",
  108. ("map", mainStationMap.MapName),
  109. ("mode", Loc.GetString(CurrentPreset.ModeTitle)));
  110. Log.Debug(msg);
  111. SendServerMessage(msg);
  112. }
  113. // Let game rules dictate what maps we should load.
  114. RaiseLocalEvent(new LoadingMapsEvent(maps));
  115. if (maps.Count == 0)
  116. {
  117. _map.CreateMap(out var mapId, runMapInit: false);
  118. DefaultMap = mapId;
  119. return;
  120. }
  121. for (var i = 0; i < maps.Count; i++)
  122. {
  123. LoadGameMap(maps[i], out var mapId);
  124. DebugTools.Assert(!_map.IsInitialized(mapId));
  125. if (i == 0)
  126. DefaultMap = mapId;
  127. }
  128. }
  129. public PreGameMapLoad RaisePreLoad(
  130. GameMapPrototype proto,
  131. DeserializationOptions? opts = null,
  132. Vector2? offset = null,
  133. Angle? rot = null)
  134. {
  135. offset ??= proto.MaxRandomOffset != 0f
  136. ? _robustRandom.NextVector2(proto.MaxRandomOffset)
  137. : Vector2.Zero;
  138. rot ??= proto.RandomRotation
  139. ? _robustRandom.NextAngle()
  140. : Angle.Zero;
  141. opts ??= DeserializationOptions.Default;
  142. var ev = new PreGameMapLoad(proto, opts.Value, offset.Value, rot.Value);
  143. RaiseLocalEvent(ev);
  144. return ev;
  145. }
  146. /// <summary>
  147. /// Loads a new map, allowing systems interested in it to handle loading events.
  148. /// In the base game, this is required to be used if you want to load a station.
  149. /// This does not initialze maps, unles specified via the <see cref="DeserializationOptions"/>.
  150. /// </summary>
  151. /// <remarks>
  152. /// This is basically a wrapper around a <see cref="MapLoaderSystem"/> method that auto generate
  153. /// some <see cref="MapLoadOptions"/> using information in a prototype, and raise some events to allow content
  154. /// to modify the options and react to the map creation.
  155. /// </remarks>
  156. /// <param name="proto">Game map prototype to load in.</param>
  157. /// <param name="mapId">The id of the map that was loaded.</param>
  158. /// <param name="options">Entity loading options, including whether the maps should be initialized.</param>
  159. /// <param name="stationName">Name to assign to the loaded station.</param>
  160. /// <returns>All loaded entities and grids.</returns>
  161. public IReadOnlyList<EntityUid> LoadGameMap(
  162. GameMapPrototype proto,
  163. out MapId mapId,
  164. DeserializationOptions? options = null,
  165. string? stationName = null,
  166. Vector2? offset = null,
  167. Angle? rot = null)
  168. {
  169. var ev = RaisePreLoad(proto, options, offset, rot);
  170. if (ev.GameMap.IsGrid)
  171. {
  172. var mapUid = _map.CreateMap(out mapId);
  173. if (!_loader.TryLoadGrid(mapId,
  174. ev.GameMap.MapPath,
  175. out var grid,
  176. ev.Options,
  177. ev.Offset,
  178. ev.Rotation))
  179. {
  180. throw new Exception($"Failed to load game-map grid {ev.GameMap.ID}");
  181. }
  182. _metaData.SetEntityName(mapUid, proto.MapName);
  183. var g = new List<EntityUid> {grid.Value.Owner};
  184. RaiseLocalEvent(new PostGameMapLoad(proto, mapId, g, stationName));
  185. return g;
  186. }
  187. if (!_loader.TryLoadMap(ev.GameMap.MapPath,
  188. out var map,
  189. out var grids,
  190. ev.Options,
  191. ev.Offset,
  192. ev.Rotation))
  193. {
  194. throw new Exception($"Failed to load game map {ev.GameMap.ID}");
  195. }
  196. mapId = map.Value.Comp.MapId;
  197. _metaData.SetEntityName(map.Value.Owner, proto.MapName);
  198. var gridUids = grids.Select(x => x.Owner).ToList();
  199. RaiseLocalEvent(new PostGameMapLoad(proto, mapId, gridUids, stationName));
  200. return gridUids;
  201. }
  202. /// <summary>
  203. /// Variant of <see cref="LoadGameMap"/> that attempts to assign the provided <see cref="MapId"/> to the
  204. /// loaded map.
  205. /// </summary>
  206. public IReadOnlyList<EntityUid> LoadGameMapWithId(
  207. GameMapPrototype proto,
  208. MapId mapId,
  209. DeserializationOptions? opts = null,
  210. string? stationName = null,
  211. Vector2? offset = null,
  212. Angle? rot = null)
  213. {
  214. var ev = RaisePreLoad(proto, opts, offset, rot);
  215. if (ev.GameMap.IsGrid)
  216. {
  217. var mapUid = _map.CreateMap(mapId);
  218. if (!_loader.TryLoadGrid(mapId,
  219. ev.GameMap.MapPath,
  220. out var grid,
  221. ev.Options,
  222. ev.Offset,
  223. ev.Rotation))
  224. {
  225. throw new Exception($"Failed to load game-map grid {ev.GameMap.ID}");
  226. }
  227. _metaData.SetEntityName(mapUid, proto.MapName);
  228. var g = new List<EntityUid> {grid.Value.Owner};
  229. RaiseLocalEvent(new PostGameMapLoad(proto, mapId, g, stationName));
  230. return g;
  231. }
  232. if (!_loader.TryLoadMapWithId(
  233. mapId,
  234. ev.GameMap.MapPath,
  235. out var map,
  236. out var grids,
  237. ev.Options,
  238. ev.Offset,
  239. ev.Rotation))
  240. {
  241. throw new Exception($"Failed to load map");
  242. }
  243. _metaData.SetEntityName(map.Value.Owner, proto.MapName);
  244. var gridUids = grids.Select(x => x.Owner).ToList();
  245. RaiseLocalEvent(new PostGameMapLoad(proto, mapId, gridUids, stationName));
  246. return gridUids;
  247. }
  248. /// <summary>
  249. /// Variant of <see cref="LoadGameMap"/> that loads and then merges a game map onto an existing map.
  250. /// </summary>
  251. public IReadOnlyList<EntityUid> MergeGameMap(
  252. GameMapPrototype proto,
  253. MapId targetMap,
  254. DeserializationOptions? opts = null,
  255. string? stationName = null,
  256. Vector2? offset = null,
  257. Angle? rot = null)
  258. {
  259. // TODO MAP LOADING use a new event?
  260. // This is quite different from the other methods, which will actually create a **new** map.
  261. var ev = RaisePreLoad(proto, opts, offset, rot);
  262. if (ev.GameMap.IsGrid)
  263. {
  264. if (!_loader.TryLoadGrid(targetMap,
  265. ev.GameMap.MapPath,
  266. out var grid,
  267. ev.Options,
  268. ev.Offset,
  269. ev.Rotation))
  270. {
  271. throw new Exception($"Failed to load game-map grid {ev.GameMap.ID}");
  272. }
  273. var g = new List<EntityUid> {grid.Value.Owner};
  274. // TODO MAP LOADING use a new event?
  275. RaiseLocalEvent(new PostGameMapLoad(proto, targetMap, g, stationName));
  276. return g;
  277. }
  278. if (!_loader.TryMergeMap(targetMap,
  279. ev.GameMap.MapPath,
  280. out var grids,
  281. ev.Options,
  282. ev.Offset,
  283. ev.Rotation))
  284. {
  285. throw new Exception($"Failed to load map");
  286. }
  287. var gridUids = grids.Select(x => x.Owner).ToList();
  288. // TODO MAP LOADING use a new event?
  289. RaiseLocalEvent(new PostGameMapLoad(proto, targetMap, gridUids, stationName));
  290. return gridUids;
  291. }
  292. public int ReadyPlayerCount()
  293. {
  294. var total = 0;
  295. foreach (var (userId, status) in _playerGameStatuses)
  296. {
  297. if (LobbyEnabled && status == PlayerGameStatus.NotReadyToPlay)
  298. continue;
  299. if (!_playerManager.TryGetSessionById(userId, out _))
  300. continue;
  301. total++;
  302. }
  303. return total;
  304. }
  305. public void StartRound(bool force = false)
  306. {
  307. #if EXCEPTION_TOLERANCE
  308. try
  309. {
  310. #endif
  311. // If this game ticker is a dummy or the round is already being started, do nothing!
  312. if (DummyTicker || _startingRound)
  313. return;
  314. _startingRound = true;
  315. if (RoundId == 0)
  316. IncrementRoundNumber();
  317. ReplayStartRound();
  318. DebugTools.Assert(RunLevel == GameRunLevel.PreRoundLobby);
  319. _sawmill.Info("Starting round!");
  320. SendServerMessage(Loc.GetString("game-ticker-start-round"));
  321. var readyPlayers = new List<ICommonSession>();
  322. var readyPlayerProfiles = new Dictionary<NetUserId, HumanoidCharacterProfile>();
  323. var autoDeAdmin = _cfg.GetCVar(CCVars.AdminDeadminOnJoin);
  324. foreach (var (userId, status) in _playerGameStatuses)
  325. {
  326. if (LobbyEnabled && status != PlayerGameStatus.ReadyToPlay) continue;
  327. if (!_playerManager.TryGetSessionById(userId, out var session)) continue;
  328. if (autoDeAdmin && _adminManager.IsAdmin(session))
  329. {
  330. _adminManager.DeAdmin(session);
  331. }
  332. #if DEBUG
  333. DebugTools.Assert(_userDb.IsLoadComplete(session), $"Player was readied up but didn't have user DB data loaded yet??");
  334. #endif
  335. readyPlayers.Add(session);
  336. HumanoidCharacterProfile profile;
  337. if (_prefsManager.TryGetCachedPreferences(userId, out var preferences))
  338. {
  339. profile = (HumanoidCharacterProfile) preferences.SelectedCharacter;
  340. }
  341. else
  342. {
  343. profile = HumanoidCharacterProfile.Random();
  344. }
  345. readyPlayerProfiles.Add(userId, profile);
  346. }
  347. DebugTools.AssertEqual(readyPlayers.Count, ReadyPlayerCount());
  348. // Just in case it hasn't been loaded previously we'll try loading it.
  349. LoadMaps();
  350. // map has been selected so update the lobby info text
  351. // applies to players who didn't ready up
  352. UpdateInfoText();
  353. StartGamePresetRules();
  354. RoundLengthMetric.Set(0);
  355. var startingEvent = new RoundStartingEvent(RoundId);
  356. RaiseLocalEvent(startingEvent);
  357. var origReadyPlayers = readyPlayers.ToArray();
  358. if (!StartPreset(origReadyPlayers, force))
  359. {
  360. _startingRound = false;
  361. return;
  362. }
  363. // MapInitialize *before* spawning players, our codebase is too shit to do it afterwards...
  364. _map.InitializeMap(DefaultMap);
  365. SpawnPlayers(readyPlayers, readyPlayerProfiles, force);
  366. _roundStartDateTime = DateTime.UtcNow;
  367. RunLevel = GameRunLevel.InRound;
  368. RoundStartTimeSpan = _gameTiming.CurTime;
  369. SendStatusToAll();
  370. ReqWindowAttentionAll();
  371. UpdateLateJoinStatus();
  372. AnnounceRound();
  373. UpdateInfoText();
  374. SendRoundStartedDiscordMessage();
  375. #if EXCEPTION_TOLERANCE
  376. }
  377. catch (Exception e)
  378. {
  379. _roundStartFailCount++;
  380. if (RoundStartFailShutdownCount > 0 && _roundStartFailCount >= RoundStartFailShutdownCount)
  381. {
  382. _sawmill.Fatal($"Failed to start a round {_roundStartFailCount} time(s) in a row... Shutting down!");
  383. _runtimeLog.LogException(e, nameof(GameTicker));
  384. _baseServer.Shutdown("Restarting server");
  385. return;
  386. }
  387. _sawmill.Error($"Exception caught while trying to start the round! Restarting round...");
  388. _runtimeLog.LogException(e, nameof(GameTicker));
  389. _startingRound = false;
  390. RestartRound();
  391. return;
  392. }
  393. // Round started successfully! Reset counter...
  394. _roundStartFailCount = 0;
  395. #endif
  396. _startingRound = false;
  397. }
  398. private void RefreshLateJoinAllowed()
  399. {
  400. var refresh = new RefreshLateJoinAllowedEvent();
  401. RaiseLocalEvent(refresh);
  402. DisallowLateJoin = refresh.DisallowLateJoin;
  403. }
  404. public void EndRound(string text = "")
  405. {
  406. // If this game ticker is a dummy, do nothing!
  407. if (DummyTicker)
  408. return;
  409. DebugTools.Assert(RunLevel == GameRunLevel.InRound);
  410. _sawmill.Info("Ending round!");
  411. RunLevel = GameRunLevel.PostRound;
  412. try
  413. {
  414. ShowRoundEndScoreboard(text);
  415. }
  416. catch (Exception e)
  417. {
  418. Log.Error($"Error while showing round end scoreboard: {e}");
  419. }
  420. try
  421. {
  422. SendRoundEndDiscordMessage();
  423. }
  424. catch (Exception e)
  425. {
  426. Log.Error($"Error while sending round end Discord message: {e}");
  427. }
  428. }
  429. public void ShowRoundEndScoreboard(string text = "")
  430. {
  431. // Log end of round
  432. _adminLogger.Add(LogType.EmergencyShuttle, LogImpact.High, $"Round ended, showing summary");
  433. //Tell every client the round has ended.
  434. var gamemodeTitle = CurrentPreset != null ? Loc.GetString(CurrentPreset.ModeTitle) : string.Empty;
  435. // Let things add text here.
  436. var textEv = new RoundEndTextAppendEvent();
  437. RaiseLocalEvent(textEv);
  438. var roundEndText = $"{text}\n{textEv.Text}";
  439. //Get the timespan of the round.
  440. var roundDuration = RoundDuration();
  441. //Generate a list of basic player info to display in the end round summary.
  442. var listOfPlayerInfo = new List<RoundEndMessageEvent.RoundEndPlayerInfo>();
  443. // Grab the great big book of all the Minds, we'll need them for this.
  444. var allMinds = EntityQueryEnumerator<MindComponent>();
  445. var pvsOverride = _cfg.GetCVar(CCVars.RoundEndPVSOverrides);
  446. while (allMinds.MoveNext(out var mindId, out var mind))
  447. {
  448. // TODO don't list redundant observer roles?
  449. // I.e., if a player was an observer ghost, then a hamster ghost role, maybe just list hamster and not
  450. // the observer role?
  451. var userId = mind.UserId ?? mind.OriginalOwnerUserId;
  452. var connected = false;
  453. var observer = _role.MindHasRole<ObserverRoleComponent>(mindId);
  454. // Continuing
  455. if (userId != null && _playerManager.ValidSessionId(userId.Value))
  456. {
  457. connected = true;
  458. }
  459. ContentPlayerData? contentPlayerData = null;
  460. if (userId != null && _playerManager.TryGetPlayerData(userId.Value, out var playerData))
  461. {
  462. contentPlayerData = playerData.ContentData();
  463. }
  464. // Finish
  465. var antag = _roles.MindIsAntagonist(mindId);
  466. var playerIcName = "Unknown";
  467. if (mind.CharacterName != null)
  468. playerIcName = mind.CharacterName;
  469. else if (mind.CurrentEntity != null && TryName(mind.CurrentEntity.Value, out var icName))
  470. playerIcName = icName;
  471. if (TryGetEntity(mind.OriginalOwnedEntity, out var entity) && pvsOverride)
  472. {
  473. _pvsOverride.AddGlobalOverride(GetNetEntity(entity.Value), recursive: true);
  474. }
  475. var roles = _roles.MindGetAllRoleInfo(mindId);
  476. var playerEndRoundInfo = new RoundEndMessageEvent.RoundEndPlayerInfo()
  477. {
  478. // Note that contentPlayerData?.Name sticks around after the player is disconnected.
  479. // This is as opposed to ply?.Name which doesn't.
  480. PlayerOOCName = contentPlayerData?.Name ?? "(IMPOSSIBLE: REGISTERED MIND WITH NO OWNER)",
  481. // Character name takes precedence over current entity name
  482. PlayerICName = playerIcName,
  483. PlayerGuid = userId,
  484. PlayerNetEntity = GetNetEntity(entity),
  485. Role = antag
  486. ? roles.First(role => role.Antagonist).Name
  487. : roles.FirstOrDefault().Name ?? Loc.GetString("game-ticker-unknown-role"),
  488. Antag = antag,
  489. JobPrototypes = roles.Where(role => !role.Antagonist).Select(role => role.Prototype).ToArray(),
  490. AntagPrototypes = roles.Where(role => role.Antagonist).Select(role => role.Prototype).ToArray(),
  491. Observer = observer,
  492. Connected = connected
  493. };
  494. listOfPlayerInfo.Add(playerEndRoundInfo);
  495. }
  496. // This ordering mechanism isn't great (no ordering of minds) but functions
  497. var listOfPlayerInfoFinal = listOfPlayerInfo.OrderBy(pi => pi.PlayerOOCName).ToArray();
  498. var sound = RoundEndSoundCollection == null ? null : _audio.ResolveSound(new SoundCollectionSpecifier(RoundEndSoundCollection));
  499. var roundEndMessageEvent = new RoundEndMessageEvent(
  500. gamemodeTitle,
  501. roundEndText,
  502. roundDuration,
  503. RoundId,
  504. listOfPlayerInfoFinal.Length,
  505. listOfPlayerInfoFinal,
  506. sound
  507. );
  508. RaiseNetworkEvent(roundEndMessageEvent);
  509. RaiseLocalEvent(roundEndMessageEvent);
  510. _replayRoundPlayerInfo = listOfPlayerInfoFinal;
  511. _replayRoundText = roundEndText;
  512. }
  513. private async void SendRoundEndDiscordMessage()
  514. {
  515. try
  516. {
  517. if (_webhookIdentifier == null)
  518. return;
  519. var duration = RoundDuration();
  520. var content = Loc.GetString("discord-round-notifications-end",
  521. ("id", RoundId),
  522. ("hours", Math.Truncate(duration.TotalHours)),
  523. ("minutes", duration.Minutes),
  524. ("seconds", duration.Seconds));
  525. var payload = new WebhookPayload { Content = content };
  526. await _discord.CreateMessage(_webhookIdentifier.Value, payload);
  527. if (DiscordRoundEndRole == null)
  528. return;
  529. content = Loc.GetString("discord-round-notifications-end-ping", ("roleId", DiscordRoundEndRole));
  530. payload = new WebhookPayload { Content = content };
  531. payload.AllowedMentions.AllowRoleMentions();
  532. await _discord.CreateMessage(_webhookIdentifier.Value, payload);
  533. }
  534. catch (Exception e)
  535. {
  536. Log.Error($"Error while sending discord round end message:\n{e}");
  537. }
  538. }
  539. public void RestartRound()
  540. {
  541. // If this game ticker is a dummy, do nothing!
  542. if (DummyTicker)
  543. return;
  544. ReplayEndRound();
  545. // Handle restart for server update
  546. if (_serverUpdates.RoundEnded())
  547. return;
  548. _sawmill.Info("Restarting round!");
  549. SendServerMessage(Loc.GetString("game-ticker-restart-round"));
  550. RoundNumberMetric.Inc();
  551. PlayersJoinedRoundNormally = 0;
  552. RunLevel = GameRunLevel.PreRoundLobby;
  553. RandomizeLobbyBackground();
  554. ResettingCleanup();
  555. IncrementRoundNumber();
  556. SendRoundStartingDiscordMessage();
  557. if (!LobbyEnabled)
  558. {
  559. StartRound();
  560. }
  561. else
  562. {
  563. if (_playerManager.PlayerCount == 0)
  564. _roundStartCountdownHasNotStartedYetDueToNoPlayers = true;
  565. else
  566. _roundStartTime = _gameTiming.CurTime + LobbyDuration;
  567. SendStatusToAll();
  568. UpdateInfoText();
  569. ReqWindowAttentionAll();
  570. }
  571. }
  572. private async void SendRoundStartingDiscordMessage()
  573. {
  574. try
  575. {
  576. if (_webhookIdentifier == null)
  577. return;
  578. var content = Loc.GetString("discord-round-notifications-new");
  579. var payload = new WebhookPayload { Content = content };
  580. await _discord.CreateMessage(_webhookIdentifier.Value, payload);
  581. }
  582. catch (Exception e)
  583. {
  584. Log.Error($"Error while sending discord round starting message:\n{e}");
  585. }
  586. }
  587. /// <summary>
  588. /// Cleanup that has to run to clear up anything from the previous round.
  589. /// Stuff like wiping the previous map clean.
  590. /// </summary>
  591. private void ResettingCleanup()
  592. {
  593. // Move everybody currently in the server to lobby.
  594. foreach (var player in _playerManager.Sessions)
  595. {
  596. PlayerJoinLobby(player);
  597. }
  598. // Round restart cleanup event, so entity systems can reset.
  599. var ev = new RoundRestartCleanupEvent();
  600. RaiseLocalEvent(ev);
  601. // So clients' entity systems can clean up too...
  602. RaiseNetworkEvent(ev);
  603. EntityManager.FlushEntities();
  604. _mapManager.Restart();
  605. _banManager.Restart();
  606. _gameMapManager.ClearSelectedMap();
  607. // Clear up any game rules.
  608. ClearGameRules();
  609. CurrentPreset = null;
  610. _allPreviousGameRules.Clear();
  611. DisallowLateJoin = false;
  612. _playerGameStatuses.Clear();
  613. foreach (var session in _playerManager.Sessions)
  614. {
  615. _playerGameStatuses[session.UserId] = LobbyEnabled ? PlayerGameStatus.NotReadyToPlay : PlayerGameStatus.ReadyToPlay;
  616. }
  617. }
  618. public bool DelayStart(TimeSpan time)
  619. {
  620. if (_runLevel != GameRunLevel.PreRoundLobby)
  621. {
  622. return false;
  623. }
  624. _roundStartTime += time;
  625. RaiseNetworkEvent(new TickerLobbyCountdownEvent(_roundStartTime, Paused));
  626. _chatManager.DispatchServerAnnouncement(Loc.GetString("game-ticker-delay-start", ("seconds", time.TotalSeconds)));
  627. return true;
  628. }
  629. private void UpdateRoundFlow(float frameTime)
  630. {
  631. if (RunLevel == GameRunLevel.InRound)
  632. {
  633. RoundLengthMetric.Inc(frameTime);
  634. }
  635. if (_roundStartTime == TimeSpan.Zero ||
  636. RunLevel != GameRunLevel.PreRoundLobby ||
  637. Paused ||
  638. _roundStartTime - RoundPreloadTime > _gameTiming.CurTime ||
  639. _roundStartCountdownHasNotStartedYetDueToNoPlayers)
  640. {
  641. return;
  642. }
  643. if (_roundStartTime < _gameTiming.CurTime)
  644. {
  645. StartRound();
  646. }
  647. // Preload maps so we can start faster
  648. else if (_roundStartTime - RoundPreloadTime < _gameTiming.CurTime)
  649. {
  650. LoadMaps();
  651. }
  652. }
  653. private void AnnounceRound()
  654. {
  655. if (CurrentPreset == null) return;
  656. var options = _prototypeManager.EnumeratePrototypes<RoundAnnouncementPrototype>().ToList();
  657. if (options.Count == 0)
  658. return;
  659. var proto = _robustRandom.Pick(options);
  660. if (proto.Message != null)
  661. _chatSystem.DispatchGlobalAnnouncement(Loc.GetString(proto.Message), playSound: true);
  662. if (proto.Sound != null)
  663. _audio.PlayGlobal(proto.Sound, Filter.Broadcast(), true);
  664. }
  665. private async void SendRoundStartedDiscordMessage()
  666. {
  667. try
  668. {
  669. if (_webhookIdentifier == null)
  670. return;
  671. var mapName = _gameMapManager.GetSelectedMap()?.MapName ?? Loc.GetString("discord-round-notifications-unknown-map");
  672. var content = Loc.GetString("discord-round-notifications-started", ("id", RoundId), ("map", mapName));
  673. var payload = new WebhookPayload { Content = content };
  674. await _discord.CreateMessage(_webhookIdentifier.Value, payload);
  675. }
  676. catch (Exception e)
  677. {
  678. Log.Error($"Error while sending discord round start message:\n{e}");
  679. }
  680. }
  681. }
  682. public enum GameRunLevel
  683. {
  684. PreRoundLobby = 0,
  685. InRound = 1,
  686. PostRound = 2
  687. }
  688. public sealed class GameRunLevelChangedEvent
  689. {
  690. public GameRunLevel Old { get; }
  691. public GameRunLevel New { get; }
  692. public GameRunLevelChangedEvent(GameRunLevel old, GameRunLevel @new)
  693. {
  694. Old = old;
  695. New = @new;
  696. }
  697. }
  698. /// <summary>
  699. /// Event raised before maps are loaded in pre-round setup.
  700. /// Contains a list of game map prototypes to load; modify it if you want to load different maps,
  701. /// for example as part of a game rule.
  702. /// </summary>
  703. [PublicAPI]
  704. public sealed class LoadingMapsEvent : EntityEventArgs
  705. {
  706. public List<GameMapPrototype> Maps;
  707. public LoadingMapsEvent(List<GameMapPrototype> maps)
  708. {
  709. Maps = maps;
  710. }
  711. }
  712. /// <summary>
  713. /// Event raised before the game loads a given map.
  714. /// This event is mutable, and load options should be tweaked if necessary.
  715. /// </summary>
  716. /// <remarks>
  717. /// You likely want to subscribe to this after StationSystem.
  718. /// </remarks>
  719. [PublicAPI]
  720. public sealed class PreGameMapLoad(GameMapPrototype gameMap, DeserializationOptions options, Vector2 offset, Angle rotation) : EntityEventArgs
  721. {
  722. public readonly GameMapPrototype GameMap = gameMap;
  723. public DeserializationOptions Options = options;
  724. public Vector2 Offset = offset;
  725. public Angle Rotation = rotation;
  726. }
  727. /// <summary>
  728. /// Event raised after the game loads a given map.
  729. /// </summary>
  730. /// <remarks>
  731. /// You likely want to subscribe to this after StationSystem.
  732. /// </remarks>
  733. [PublicAPI]
  734. public sealed class PostGameMapLoad : EntityEventArgs
  735. {
  736. public readonly GameMapPrototype GameMap;
  737. public readonly MapId Map;
  738. public readonly IReadOnlyList<EntityUid> Grids;
  739. public readonly string? StationName;
  740. public PostGameMapLoad(GameMapPrototype gameMap, MapId map, IReadOnlyList<EntityUid> grids, string? stationName)
  741. {
  742. GameMap = gameMap;
  743. Map = map;
  744. Grids = grids;
  745. StationName = stationName;
  746. }
  747. }
  748. /// <summary>
  749. /// Event raised to refresh the late join status.
  750. /// If you want to disallow late joins, listen to this and call Disallow.
  751. /// </summary>
  752. public sealed class RefreshLateJoinAllowedEvent
  753. {
  754. public bool DisallowLateJoin { get; private set; } = false;
  755. public void Disallow()
  756. {
  757. DisallowLateJoin = true;
  758. }
  759. }
  760. /// <summary>
  761. /// Attempt event raised on round start.
  762. /// This can be listened to by GameRule systems to cancel round start if some condition is not met, like player count.
  763. /// </summary>
  764. public sealed class RoundStartAttemptEvent : CancellableEntityEventArgs
  765. {
  766. public ICommonSession[] Players { get; }
  767. public bool Forced { get; }
  768. public RoundStartAttemptEvent(ICommonSession[] players, bool forced)
  769. {
  770. Players = players;
  771. Forced = forced;
  772. }
  773. }
  774. /// <summary>
  775. /// Event raised before readied up players are spawned and given jobs by the GameTicker.
  776. /// You can use this to spawn people off-station, like in the case of nuke ops or wizard.
  777. /// Remove the players you spawned from the PlayerPool and call <see cref="GameTicker.PlayerJoinGame"/> on them.
  778. /// </summary>
  779. public sealed class RulePlayerSpawningEvent
  780. {
  781. /// <summary>
  782. /// Pool of players to be spawned.
  783. /// If you want to handle a specific player being spawned, remove it from this list and do what you need.
  784. /// </summary>
  785. /// <remarks>If you spawn a player by yourself from this event, don't forget to call <see cref="GameTicker.PlayerJoinGame"/> on them.</remarks>
  786. public List<ICommonSession> PlayerPool { get; }
  787. public IReadOnlyDictionary<NetUserId, HumanoidCharacterProfile> Profiles { get; }
  788. public bool Forced { get; }
  789. public RulePlayerSpawningEvent(List<ICommonSession> playerPool, IReadOnlyDictionary<NetUserId, HumanoidCharacterProfile> profiles, bool forced)
  790. {
  791. PlayerPool = playerPool;
  792. Profiles = profiles;
  793. Forced = forced;
  794. }
  795. }
  796. /// <summary>
  797. /// Event raised after players were assigned jobs by the GameTicker and have been spawned in.
  798. /// You can give on-station people special roles by listening to this event.
  799. /// </summary>
  800. public sealed class RulePlayerJobsAssignedEvent
  801. {
  802. public ICommonSession[] Players { get; }
  803. public IReadOnlyDictionary<NetUserId, HumanoidCharacterProfile> Profiles { get; }
  804. public bool Forced { get; }
  805. public RulePlayerJobsAssignedEvent(ICommonSession[] players, IReadOnlyDictionary<NetUserId, HumanoidCharacterProfile> profiles, bool forced)
  806. {
  807. Players = players;
  808. Profiles = profiles;
  809. Forced = forced;
  810. }
  811. }
  812. /// <summary>
  813. /// Event raised to allow subscribers to add text to the round end summary screen.
  814. /// </summary>
  815. public sealed class RoundEndTextAppendEvent
  816. {
  817. private bool _doNewLine;
  818. /// <summary>
  819. /// Text to display in the round end summary screen.
  820. /// </summary>
  821. public string Text { get; private set; } = string.Empty;
  822. /// <summary>
  823. /// Invoke this method to add text to the round end summary screen.
  824. /// </summary>
  825. /// <param name="text"></param>
  826. public void AddLine(string text)
  827. {
  828. if (_doNewLine)
  829. Text += "\n";
  830. Text += text;
  831. _doNewLine = true;
  832. }
  833. }
  834. }