1
0

GameTicker.RoundFlow.cs 35 KB

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