1
0

GameTicker.RoundFlow.cs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997
  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. var soundEnd = new SoundPathSpecifier("/Audio/Announcements/civ_announcement.ogg");
  511. _audio.PlayGlobal(_audio.ResolveSound(soundEnd), Filter.Broadcast(), true);
  512. _replayRoundPlayerInfo = listOfPlayerInfoFinal;
  513. _replayRoundText = roundEndText;
  514. }
  515. private async void SendRoundEndDiscordMessage()
  516. {
  517. try
  518. {
  519. if (_webhookIdentifier == null)
  520. return;
  521. var duration = RoundDuration();
  522. var content = Loc.GetString("discord-round-notifications-end",
  523. ("id", RoundId),
  524. ("hours", Math.Truncate(duration.TotalHours)),
  525. ("minutes", duration.Minutes),
  526. ("seconds", duration.Seconds));
  527. var payload = new WebhookPayload { Content = content };
  528. await _discord.CreateMessage(_webhookIdentifier.Value, payload);
  529. if (DiscordRoundEndRole == null)
  530. return;
  531. content = Loc.GetString("discord-round-notifications-end-ping", ("roleId", DiscordRoundEndRole));
  532. payload = new WebhookPayload { Content = content };
  533. payload.AllowedMentions.AllowRoleMentions();
  534. await _discord.CreateMessage(_webhookIdentifier.Value, payload);
  535. }
  536. catch (Exception e)
  537. {
  538. Log.Error($"Error while sending discord round end message:\n{e}");
  539. }
  540. }
  541. public void RestartRound()
  542. {
  543. // If this game ticker is a dummy, do nothing!
  544. if (DummyTicker)
  545. return;
  546. ReplayEndRound();
  547. // Handle restart for server update
  548. if (_serverUpdates.RoundEnded())
  549. return;
  550. _sawmill.Info("Restarting round!");
  551. SendServerMessage(Loc.GetString("game-ticker-restart-round"));
  552. RoundNumberMetric.Inc();
  553. PlayersJoinedRoundNormally = 0;
  554. RunLevel = GameRunLevel.PreRoundLobby;
  555. RandomizeLobbyBackground();
  556. ResettingCleanup();
  557. IncrementRoundNumber();
  558. SendRoundStartingDiscordMessage();
  559. if (!LobbyEnabled)
  560. {
  561. StartRound();
  562. }
  563. else
  564. {
  565. if (_playerManager.PlayerCount == 0)
  566. _roundStartCountdownHasNotStartedYetDueToNoPlayers = true;
  567. else
  568. _roundStartTime = _gameTiming.CurTime + LobbyDuration;
  569. SendStatusToAll();
  570. UpdateInfoText();
  571. ReqWindowAttentionAll();
  572. }
  573. }
  574. private async void SendRoundStartingDiscordMessage()
  575. {
  576. try
  577. {
  578. if (_webhookIdentifier == null)
  579. return;
  580. var content = Loc.GetString("discord-round-notifications-new");
  581. var payload = new WebhookPayload { Content = content };
  582. await _discord.CreateMessage(_webhookIdentifier.Value, payload);
  583. }
  584. catch (Exception e)
  585. {
  586. Log.Error($"Error while sending discord round starting message:\n{e}");
  587. }
  588. }
  589. /// <summary>
  590. /// Cleanup that has to run to clear up anything from the previous round.
  591. /// Stuff like wiping the previous map clean.
  592. /// </summary>
  593. private void ResettingCleanup()
  594. {
  595. // Move everybody currently in the server to lobby.
  596. foreach (var player in _playerManager.Sessions)
  597. {
  598. PlayerJoinLobby(player);
  599. }
  600. // Round restart cleanup event, so entity systems can reset.
  601. var ev = new RoundRestartCleanupEvent();
  602. RaiseLocalEvent(ev);
  603. // So clients' entity systems can clean up too...
  604. RaiseNetworkEvent(ev);
  605. EntityManager.FlushEntities();
  606. _mapManager.Restart();
  607. _banManager.Restart();
  608. _gameMapManager.ClearSelectedMap();
  609. // Clear up any game rules.
  610. ClearGameRules();
  611. CurrentPreset = null;
  612. _allPreviousGameRules.Clear();
  613. DisallowLateJoin = false;
  614. _playerGameStatuses.Clear();
  615. foreach (var session in _playerManager.Sessions)
  616. {
  617. _playerGameStatuses[session.UserId] = LobbyEnabled ? PlayerGameStatus.NotReadyToPlay : PlayerGameStatus.ReadyToPlay;
  618. }
  619. }
  620. public bool DelayStart(TimeSpan time)
  621. {
  622. if (_runLevel != GameRunLevel.PreRoundLobby)
  623. {
  624. return false;
  625. }
  626. _roundStartTime += time;
  627. RaiseNetworkEvent(new TickerLobbyCountdownEvent(_roundStartTime, Paused));
  628. _chatManager.DispatchServerAnnouncement(Loc.GetString("game-ticker-delay-start", ("seconds", time.TotalSeconds)));
  629. return true;
  630. }
  631. private void UpdateRoundFlow(float frameTime)
  632. {
  633. if (RunLevel == GameRunLevel.InRound)
  634. {
  635. RoundLengthMetric.Inc(frameTime);
  636. }
  637. if (_roundStartTime == TimeSpan.Zero ||
  638. RunLevel != GameRunLevel.PreRoundLobby ||
  639. Paused ||
  640. _roundStartTime - RoundPreloadTime > _gameTiming.CurTime ||
  641. _roundStartCountdownHasNotStartedYetDueToNoPlayers)
  642. {
  643. return;
  644. }
  645. if (_roundStartTime < _gameTiming.CurTime)
  646. {
  647. StartRound();
  648. }
  649. // Preload maps so we can start faster
  650. else if (_roundStartTime - RoundPreloadTime < _gameTiming.CurTime)
  651. {
  652. LoadMaps();
  653. }
  654. }
  655. private void AnnounceRound()
  656. {
  657. if (CurrentPreset == null) return;
  658. var options = _prototypeManager.EnumeratePrototypes<RoundAnnouncementPrototype>().ToList();
  659. if (options.Count == 0)
  660. return;
  661. var proto = _robustRandom.Pick(options);
  662. if (proto.Message != null)
  663. _chatSystem.DispatchGlobalAnnouncement(Loc.GetString(proto.Message), playSound: true);
  664. if (proto.Sound != null)
  665. _audio.PlayGlobal(proto.Sound, Filter.Broadcast(), true);
  666. }
  667. private async void SendRoundStartedDiscordMessage()
  668. {
  669. try
  670. {
  671. if (_webhookIdentifier == null)
  672. return;
  673. var mapName = _gameMapManager.GetSelectedMap()?.MapName ?? Loc.GetString("discord-round-notifications-unknown-map");
  674. var content = Loc.GetString("discord-round-notifications-started", ("id", RoundId), ("map", mapName));
  675. var payload = new WebhookPayload { Content = content };
  676. await _discord.CreateMessage(_webhookIdentifier.Value, payload);
  677. }
  678. catch (Exception e)
  679. {
  680. Log.Error($"Error while sending discord round start message:\n{e}");
  681. }
  682. }
  683. }
  684. public enum GameRunLevel
  685. {
  686. PreRoundLobby = 0,
  687. InRound = 1,
  688. PostRound = 2
  689. }
  690. public sealed class GameRunLevelChangedEvent
  691. {
  692. public GameRunLevel Old { get; }
  693. public GameRunLevel New { get; }
  694. public GameRunLevelChangedEvent(GameRunLevel old, GameRunLevel @new)
  695. {
  696. Old = old;
  697. New = @new;
  698. }
  699. }
  700. /// <summary>
  701. /// Event raised before maps are loaded in pre-round setup.
  702. /// Contains a list of game map prototypes to load; modify it if you want to load different maps,
  703. /// for example as part of a game rule.
  704. /// </summary>
  705. [PublicAPI]
  706. public sealed class LoadingMapsEvent : EntityEventArgs
  707. {
  708. public List<GameMapPrototype> Maps;
  709. public LoadingMapsEvent(List<GameMapPrototype> maps)
  710. {
  711. Maps = maps;
  712. }
  713. }
  714. /// <summary>
  715. /// Event raised before the game loads a given map.
  716. /// This event is mutable, and load options should be tweaked if necessary.
  717. /// </summary>
  718. /// <remarks>
  719. /// You likely want to subscribe to this after StationSystem.
  720. /// </remarks>
  721. [PublicAPI]
  722. public sealed class PreGameMapLoad(GameMapPrototype gameMap, DeserializationOptions options, Vector2 offset, Angle rotation) : EntityEventArgs
  723. {
  724. public readonly GameMapPrototype GameMap = gameMap;
  725. public DeserializationOptions Options = options;
  726. public Vector2 Offset = offset;
  727. public Angle Rotation = rotation;
  728. }
  729. /// <summary>
  730. /// Event raised after the game loads a given map.
  731. /// </summary>
  732. /// <remarks>
  733. /// You likely want to subscribe to this after StationSystem.
  734. /// </remarks>
  735. [PublicAPI]
  736. public sealed class PostGameMapLoad : EntityEventArgs
  737. {
  738. public readonly GameMapPrototype GameMap;
  739. public readonly MapId Map;
  740. public readonly IReadOnlyList<EntityUid> Grids;
  741. public readonly string? StationName;
  742. public PostGameMapLoad(GameMapPrototype gameMap, MapId map, IReadOnlyList<EntityUid> grids, string? stationName)
  743. {
  744. GameMap = gameMap;
  745. Map = map;
  746. Grids = grids;
  747. StationName = stationName;
  748. }
  749. }
  750. /// <summary>
  751. /// Event raised to refresh the late join status.
  752. /// If you want to disallow late joins, listen to this and call Disallow.
  753. /// </summary>
  754. public sealed class RefreshLateJoinAllowedEvent
  755. {
  756. public bool DisallowLateJoin { get; private set; } = false;
  757. public void Disallow()
  758. {
  759. DisallowLateJoin = true;
  760. }
  761. }
  762. /// <summary>
  763. /// Attempt event raised on round start.
  764. /// This can be listened to by GameRule systems to cancel round start if some condition is not met, like player count.
  765. /// </summary>
  766. public sealed class RoundStartAttemptEvent : CancellableEntityEventArgs
  767. {
  768. public ICommonSession[] Players { get; }
  769. public bool Forced { get; }
  770. public RoundStartAttemptEvent(ICommonSession[] players, bool forced)
  771. {
  772. Players = players;
  773. Forced = forced;
  774. }
  775. }
  776. /// <summary>
  777. /// Event raised before readied up players are spawned and given jobs by the GameTicker.
  778. /// You can use this to spawn people off-station, like in the case of nuke ops or wizard.
  779. /// Remove the players you spawned from the PlayerPool and call <see cref="GameTicker.PlayerJoinGame"/> on them.
  780. /// </summary>
  781. public sealed class RulePlayerSpawningEvent
  782. {
  783. /// <summary>
  784. /// Pool of players to be spawned.
  785. /// If you want to handle a specific player being spawned, remove it from this list and do what you need.
  786. /// </summary>
  787. /// <remarks>If you spawn a player by yourself from this event, don't forget to call <see cref="GameTicker.PlayerJoinGame"/> on them.</remarks>
  788. public List<ICommonSession> PlayerPool { get; }
  789. public IReadOnlyDictionary<NetUserId, HumanoidCharacterProfile> Profiles { get; }
  790. public bool Forced { get; }
  791. public RulePlayerSpawningEvent(List<ICommonSession> playerPool, IReadOnlyDictionary<NetUserId, HumanoidCharacterProfile> profiles, bool forced)
  792. {
  793. PlayerPool = playerPool;
  794. Profiles = profiles;
  795. Forced = forced;
  796. }
  797. }
  798. /// <summary>
  799. /// Event raised after players were assigned jobs by the GameTicker and have been spawned in.
  800. /// You can give on-station people special roles by listening to this event.
  801. /// </summary>
  802. public sealed class RulePlayerJobsAssignedEvent
  803. {
  804. public ICommonSession[] Players { get; }
  805. public IReadOnlyDictionary<NetUserId, HumanoidCharacterProfile> Profiles { get; }
  806. public bool Forced { get; }
  807. public RulePlayerJobsAssignedEvent(ICommonSession[] players, IReadOnlyDictionary<NetUserId, HumanoidCharacterProfile> profiles, bool forced)
  808. {
  809. Players = players;
  810. Profiles = profiles;
  811. Forced = forced;
  812. }
  813. }
  814. /// <summary>
  815. /// Event raised to allow subscribers to add text to the round end summary screen.
  816. /// </summary>
  817. public sealed class RoundEndTextAppendEvent
  818. {
  819. private bool _doNewLine;
  820. /// <summary>
  821. /// Text to display in the round end summary screen.
  822. /// </summary>
  823. public string Text { get; private set; } = string.Empty;
  824. /// <summary>
  825. /// Invoke this method to add text to the round end summary screen.
  826. /// </summary>
  827. /// <param name="text"></param>
  828. public void AddLine(string text)
  829. {
  830. if (_doNewLine)
  831. Text += "\n";
  832. Text += text;
  833. _doNewLine = true;
  834. }
  835. }
  836. }