#region Copyright & License Information /* * Copyright (c) The OpenRA Developers and Contributors * This file is part of OpenRA, which is free software. It is made * available to you under the terms of the GNU General Public License * as published by the Free Software Foundation, either version 3 of * the License, or (at your option) any later version. For more * information, see COPYING. */ #endregion using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using OpenRA.FileFormats; using OpenRA.Network; using OpenRA.Primitives; using OpenRA.Traits; using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets.Logic { public class ReplayBrowserLogic : ChromeLogic { [FluentReference("time")] const string Duration = "label-duration"; [FluentReference] const string Singleplayer = "options-replay-type.singleplayer"; [FluentReference] const string Multiplayer = "options-replay-type.multiplayer"; [FluentReference] const string Today = "options-replay-date.today"; [FluentReference] const string LastWeek = "options-replay-date.last-week"; [FluentReference] const string LastFortnight = "options-replay-date.last-fortnight"; [FluentReference] const string LastMonth = "options-replay-date.last-month"; [FluentReference] const string ReplayDurationVeryShort = "options-replay-duration.very-short"; [FluentReference] const string ReplayDurationShort = "options-replay-duration.short"; [FluentReference] const string ReplayDurationMedium = "options-replay-duration.medium"; [FluentReference] const string ReplayDurationLong = "options-replay-duration.long"; [FluentReference] const string RenameReplayTitle = "dialog-rename-replay.title"; [FluentReference] const string RenameReplayPrompt = "dialog-rename-replay.prompt"; [FluentReference] const string RenameReplayAccept = "dialog-rename-replay.confirm"; [FluentReference] const string DeleteReplayTitle = "dialog-delete-replay.title"; [FluentReference("replay")] const string DeleteReplayPrompt = "dialog-delete-replay.prompt"; [FluentReference] const string DeleteReplayAccept = "dialog-delete-replay.confirm"; [FluentReference] const string DeleteAllReplaysTitle = "dialog-delete-all-replays.title"; [FluentReference("count")] const string DeleteAllReplaysPrompt = "dialog-delete-all-replays.prompt"; [FluentReference] const string DeleteAllReplaysAccept = "dialog-delete-all-replays.confirm"; [FluentReference("file")] const string ReplayDeletionFailed = "notification-replay-deletion-failed"; [FluentReference] const string Players = "label-players"; [FluentReference("team")] const string TeamNumber = "label-team-name"; [FluentReference] const string NoTeam = "label-no-team"; [FluentReference] const string Victory = "options-winstate.victory"; [FluentReference] const string Defeat = "options-winstate.defeat"; static Filter filter = new(); readonly Widget panel; readonly ScrollPanelWidget replayList, playerList; readonly ScrollItemWidget playerTemplate, playerHeader; readonly List replays = new(); readonly Dictionary replayState = new(); readonly Action onStart; readonly ModData modData; readonly WebServices services; MapPreview map; ReplayMetadata selectedReplay; volatile bool cancelLoadingReplays; [ObjectCreator.UseCtor] public ReplayBrowserLogic(Widget widget, ModData modData, Action onExit, Action onStart) { map = MapCache.UnknownMap; panel = widget; services = modData.Manifest.Get(); this.modData = modData; this.onStart = onStart; Game.BeforeGameStart += OnGameStart; playerList = panel.Get("PLAYER_LIST"); playerHeader = playerList.Get("HEADER"); playerTemplate = playerList.Get("TEMPLATE"); playerList.RemoveChildren(); panel.Get("CANCEL_BUTTON").OnClick = () => { cancelLoadingReplays = true; Ui.CloseWindow(); onExit(); }; replayList = panel.Get("REPLAY_LIST"); var template = panel.Get("REPLAY_TEMPLATE"); var mod = modData.Manifest; var dir = Path.Combine(Platform.SupportDir, "Replays", mod.Id, mod.Metadata.Version); if (Directory.Exists(dir)) ThreadPool.QueueUserWorkItem(_ => LoadReplays(dir, template)); var watch = panel.Get("WATCH_BUTTON"); watch.IsDisabled = () => selectedReplay == null || map.Status != MapStatus.Available; watch.OnClick = WatchReplay; var mapPreviewRoot = panel.Get("MAP_PREVIEW_ROOT"); mapPreviewRoot.IsVisible = () => selectedReplay != null; panel.Get("REPLAY_INFO").IsVisible = () => selectedReplay != null; var spawnOccupants = new CachedTransform>(r => { // Avoid using .ToDictionary to improve robustness against replays defining duplicate spawn assignments var occupants = new Dictionary(); foreach (var p in r.GameInfo.Players) if (p.SpawnPoint != 0) occupants[p.SpawnPoint] = new SpawnOccupant(p); return occupants; }); var noSpawns = new HashSet(); var disabledSpawnPoints = new CachedTransform>(r => r.GameInfo.DisabledSpawnPoints ?? noSpawns); Ui.LoadWidget("MAP_PREVIEW", mapPreviewRoot, new WidgetArgs { { "orderManager", null }, { "getMap", (Func<(MapPreview, Session.MapStatus)>)(() => (map, Session.MapStatus.Playable)) }, { "onMouseDown", null }, { "getSpawnOccupants", (Func>)(() => spawnOccupants.Update(selectedReplay)) }, { "getDisabledSpawnPoints", (Func>)(() => disabledSpawnPoints.Update(selectedReplay)) }, { "showUnoccupiedSpawnpoints", false }, { "mapUpdatesEnabled", false }, { "onMapUpdate", (Action)(_ => { }) }, }); var replayDuration = new CachedTransform(r => FluentProvider.GetMessage(Duration, "time", WidgetUtils.FormatTimeSeconds((int)selectedReplay.GameInfo.Duration.TotalSeconds))); panel.Get("DURATION").GetText = () => replayDuration.Update(selectedReplay); SetupFilters(); SetupManagement(); } void LoadReplays(string dir, ScrollItemWidget template) { using (new Support.PerfTimer("Load replays")) { var loadedReplays = new ConcurrentBag(); Parallel.ForEach(Directory.GetFiles(dir, "*.orarep", SearchOption.AllDirectories), (fileName, pls) => { if (cancelLoadingReplays) { pls.Stop(); return; } var replay = ReplayMetadata.Read(fileName); if (replay != null) loadedReplays.Add(replay); }); if (cancelLoadingReplays) return; var sortedReplays = loadedReplays.OrderByDescending(replay => replay.GameInfo.StartTimeUtc).ToList(); Game.RunAfterTick(() => { replayList.RemoveChildren(); foreach (var replay in sortedReplays) AddReplay(replay, template); SetupReplayDependentFilters(); ApplyFilter(); }); } } void SetupFilters() { // Game type { var ddb = panel.GetOrNull("FLT_GAMETYPE_DROPDOWNBUTTON"); if (ddb != null) { // Using list to maintain the order var options = new List<(GameType GameType, string Text)> { (GameType.Any, ddb.GetText()), (GameType.Singleplayer, FluentProvider.GetMessage(Singleplayer)), (GameType.Multiplayer, FluentProvider.GetMessage(Multiplayer)) }; var lookup = options.ToDictionary(kvp => kvp.GameType, kvp => kvp.Text); ddb.GetText = () => lookup[filter.Type]; ddb.OnMouseDown = _ => { ScrollItemWidget SetupItem((GameType GameType, string Text) option, ScrollItemWidget tpl) { var item = ScrollItemWidget.Setup( tpl, () => filter.Type == option.GameType, () => { filter.Type = option.GameType; ApplyFilter(); }); item.Get("LABEL").GetText = () => option.Text; return item; } ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 330, options, SetupItem); }; } } // Date type { var ddb = panel.GetOrNull("FLT_DATE_DROPDOWNBUTTON"); if (ddb != null) { // Using list to maintain the order var options = new List<(DateType DateType, string Text)> { (DateType.Any, ddb.GetText()), (DateType.Today, FluentProvider.GetMessage(Today)), (DateType.LastWeek, FluentProvider.GetMessage(LastWeek)), (DateType.LastFortnight, FluentProvider.GetMessage(LastFortnight)), (DateType.LastMonth, FluentProvider.GetMessage(LastMonth)) }; var lookup = options.ToDictionary(kvp => kvp.DateType, kvp => kvp.Text); ddb.GetText = () => lookup[filter.Date]; ddb.OnMouseDown = _ => { ScrollItemWidget SetupItem((DateType DateType, string Text) option, ScrollItemWidget tpl) { var item = ScrollItemWidget.Setup( tpl, () => filter.Date == option.DateType, () => { filter.Date = option.DateType; ApplyFilter(); }); item.Get("LABEL").GetText = () => option.Text; return item; } ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 330, options, SetupItem); }; } } // Duration { var ddb = panel.GetOrNull("FLT_DURATION_DROPDOWNBUTTON"); if (ddb != null) { // Using list to maintain the order var options = new List<(DurationType DurationType, string Text)> { (DurationType.Any, ddb.GetText()), (DurationType.VeryShort, FluentProvider.GetMessage(ReplayDurationVeryShort)), (DurationType.Short, FluentProvider.GetMessage(ReplayDurationShort)), (DurationType.Medium, FluentProvider.GetMessage(ReplayDurationMedium)), (DurationType.Long, FluentProvider.GetMessage(ReplayDurationLong)) }; var lookup = options.ToDictionary(kvp => kvp.DurationType, kvp => kvp.Text); ddb.GetText = () => lookup[filter.Duration]; ddb.OnMouseDown = _ => { ScrollItemWidget SetupItem((DurationType DurationType, string Text) option, ScrollItemWidget tpl) { var item = ScrollItemWidget.Setup( tpl, () => filter.Duration == option.DurationType, () => { filter.Duration = option.DurationType; ApplyFilter(); }); item.Get("LABEL").GetText = () => option.Text; return item; } ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 330, options, SetupItem); }; } } // Outcome (depends on Player) { var ddb = panel.GetOrNull("FLT_OUTCOME_DROPDOWNBUTTON"); if (ddb != null) { ddb.IsDisabled = () => string.IsNullOrEmpty(filter.PlayerName); // Using list to maintain the order var options = new List<(WinState WinState, string Text)> { (WinState.Undefined, ddb.GetText()), (WinState.Lost, FluentProvider.GetMessage(Defeat)), (WinState.Won, FluentProvider.GetMessage(Victory)) }; var lookup = options.ToDictionary(kvp => kvp.WinState, kvp => kvp.Text); ddb.GetText = () => lookup[filter.Outcome]; ddb.OnMouseDown = _ => { ScrollItemWidget SetupItem((WinState WinState, string Text) option, ScrollItemWidget tpl) { var item = ScrollItemWidget.Setup( tpl, () => filter.Outcome == option.WinState, () => { filter.Outcome = option.WinState; ApplyFilter(); }); item.Get("LABEL").GetText = () => option.Text; return item; } ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 330, options, SetupItem); }; } } // Reset button { var button = panel.Get("FLT_RESET_BUTTON"); button.IsDisabled = () => filter.IsEmpty; button.OnClick = () => { filter = new Filter(); ApplyFilter(); }; } } void SetupReplayDependentFilters() { // Map { var ddb = panel.GetOrNull("FLT_MAPNAME_DROPDOWNBUTTON"); if (ddb != null) { var options = replays.Select(r => r.GameInfo.MapTitle).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); options.Sort(StringComparer.OrdinalIgnoreCase); options.Insert(0, null); // no filter var anyText = ddb.GetText(); ddb.GetText = () => string.IsNullOrEmpty(filter.MapName) ? anyText : filter.MapName; ddb.OnMouseDown = _ => { ScrollItemWidget SetupItem(string option, ScrollItemWidget tpl) { var item = ScrollItemWidget.Setup( tpl, () => string.Equals(filter.MapName, option, StringComparison.CurrentCultureIgnoreCase), () => { filter.MapName = option; ApplyFilter(); }); item.Get("LABEL").GetText = () => option ?? anyText; return item; } ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 330, options, SetupItem); }; } } // Players { var ddb = panel.GetOrNull("FLT_PLAYER_DROPDOWNBUTTON"); if (ddb != null) { var options = replays.SelectMany(r => r.GameInfo.Players.Select(p => r.GameInfo.ResolvedPlayerName(p))) .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); options.Sort(StringComparer.OrdinalIgnoreCase); options.Insert(0, null); // no filter var anyText = ddb.GetText(); ddb.GetText = () => string.IsNullOrEmpty(filter.PlayerName) ? anyText : filter.PlayerName; ddb.OnMouseDown = _ => { ScrollItemWidget SetupItem(string option, ScrollItemWidget tpl) { var item = ScrollItemWidget.Setup( tpl, () => string.Equals(filter.PlayerName, option, StringComparison.CurrentCultureIgnoreCase), () => { filter.PlayerName = option; ApplyFilter(); }); item.Get("LABEL").GetText = () => option ?? anyText; return item; } ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 330, options, SetupItem); }; } } // Faction (depends on Player) { var ddb = panel.GetOrNull("FLT_FACTION_DROPDOWNBUTTON"); if (ddb != null) { ddb.IsDisabled = () => string.IsNullOrEmpty(filter.PlayerName); var options = replays .SelectMany(r => r.GameInfo.Players.Select(p => p.FactionName).Where(n => !string.IsNullOrEmpty(n))) .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); options.Sort(StringComparer.OrdinalIgnoreCase); options.Insert(0, null); // no filter var anyText = ddb.GetText(); ddb.GetText = () => string.IsNullOrEmpty(filter.Faction) ? anyText : FluentProvider.GetMessage(filter.Faction); ddb.OnMouseDown = _ => { ScrollItemWidget SetupItem(string option, ScrollItemWidget tpl) { var item = ScrollItemWidget.Setup( tpl, () => string.Equals(filter.Faction, option, StringComparison.CurrentCultureIgnoreCase), () => { filter.Faction = option; ApplyFilter(); }); item.Get("LABEL").GetText = () => option != null ? FluentProvider.GetMessage(option) : anyText; return item; } ddb.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 330, options, SetupItem); }; } } } void SetupManagement() { var renameButton = panel.Get("MNG_RENSEL_BUTTON"); renameButton.IsDisabled = () => selectedReplay == null; renameButton.OnClick = () => { var r = selectedReplay; var initialName = Path.GetFileNameWithoutExtension(r.FilePath); var directoryName = Path.GetDirectoryName(r.FilePath); var invalidChars = Path.GetInvalidFileNameChars(); ConfirmationDialogs.TextInputPrompt(modData, RenameReplayTitle, RenameReplayPrompt, initialName, onAccept: newName => RenameReplay(r, newName), onCancel: null, acceptText: RenameReplayAccept, cancelText: null, inputValidator: newName => { if (newName == initialName) return false; if (string.IsNullOrWhiteSpace(newName)) return false; if (newName.IndexOfAny(invalidChars) >= 0) return false; if (File.Exists(Path.Combine(directoryName, newName))) return false; return true; }); }; void OnDeleteReplay(ReplayMetadata r, Action after) { ConfirmationDialogs.ButtonPrompt(modData, title: DeleteReplayTitle, text: DeleteReplayPrompt, textArguments: new object[] { "replay", Path.GetFileNameWithoutExtension(r.FilePath) }, onConfirm: () => { DeleteReplay(r); after?.Invoke(); }, confirmText: DeleteReplayAccept, onCancel: () => { }); } var deleteButton = panel.Get("MNG_DELSEL_BUTTON"); deleteButton.IsDisabled = () => selectedReplay == null; deleteButton.OnClick = () => { OnDeleteReplay(selectedReplay, () => { if (selectedReplay == null) SelectFirstVisibleReplay(); }); }; var deleteAllButton = panel.Get("MNG_DELALL_BUTTON"); deleteAllButton.IsDisabled = () => !replayState.Any(kvp => kvp.Value.Visible); deleteAllButton.OnClick = () => { var list = replayState.Where(kvp => kvp.Value.Visible).Select(kvp => kvp.Key).ToList(); if (list.Count == 0) return; if (list.Count == 1) { OnDeleteReplay(list[0], () => { if (selectedReplay == null) SelectFirstVisibleReplay(); }); return; } ConfirmationDialogs.ButtonPrompt(modData, title: DeleteAllReplaysTitle, text: DeleteAllReplaysPrompt, textArguments: new object[] { "count", list.Count }, onConfirm: () => { foreach (var replayMetadata in list) DeleteReplay(replayMetadata); if (selectedReplay == null) SelectFirstVisibleReplay(); }, confirmText: DeleteAllReplaysAccept, onCancel: () => { }); }; } void RenameReplay(ReplayMetadata replay, string newFilenameWithoutExtension) { try { var item = replayState[replay].Item; replay.RenameFile(newFilenameWithoutExtension); item.GetText = () => newFilenameWithoutExtension; var label = item.Get("TITLE"); WidgetUtils.TruncateLabelToTooltip(label, item.GetText()); } catch (Exception ex) { Log.Write("debug", ex.ToString()); } } void DeleteReplay(ReplayMetadata replay) { try { File.Delete(replay.FilePath); } catch (Exception ex) { TextNotificationsManager.Debug(FluentProvider.GetMessage(ReplayDeletionFailed, "file", replay.FilePath)); Log.Write("debug", ex.ToString()); return; } if (replay == selectedReplay) SelectReplay(null); replayList.RemoveChild(replayState[replay].Item); replays.Remove(replay); replayState.Remove(replay); } static bool EvaluateReplayVisibility(ReplayMetadata replay) { // Game type if ((filter.Type == GameType.Multiplayer && replay.GameInfo.IsSinglePlayer) || (filter.Type == GameType.Singleplayer && !replay.GameInfo.IsSinglePlayer)) return false; // Date type if (filter.Date != DateType.Any) { TimeSpan t; switch (filter.Date) { case DateType.Today: t = TimeSpan.FromDays(1d); break; case DateType.LastWeek: t = TimeSpan.FromDays(7d); break; case DateType.LastFortnight: t = TimeSpan.FromDays(14d); break; case DateType.LastMonth: default: t = TimeSpan.FromDays(30d); break; } if (replay.GameInfo.StartTimeUtc < DateTime.UtcNow - t) return false; } // Duration if (filter.Duration != DurationType.Any) { var minutes = replay.GameInfo.Duration.TotalMinutes; switch (filter.Duration) { case DurationType.VeryShort: if (minutes >= 5) return false; break; case DurationType.Short: if (minutes < 5 || minutes >= 20) return false; break; case DurationType.Medium: if (minutes < 20 || minutes >= 60) return false; break; case DurationType.Long: if (minutes < 60) return false; break; } } // Map if (!string.IsNullOrEmpty(filter.MapName) && !string.Equals(filter.MapName, replay.GameInfo.MapTitle, StringComparison.CurrentCultureIgnoreCase)) return false; // Player if (!string.IsNullOrEmpty(filter.PlayerName)) { var player = replay.GameInfo.Players.FirstOrDefault( p => string.Equals(filter.PlayerName, replay.GameInfo.ResolvedPlayerName(p), StringComparison.CurrentCultureIgnoreCase)); if (player == null) return false; // Outcome if (filter.Outcome != WinState.Undefined && filter.Outcome != player.Outcome) return false; // Faction if (!string.IsNullOrEmpty(filter.Faction) && !string.Equals(filter.Faction, player.FactionName, StringComparison.CurrentCultureIgnoreCase)) return false; } return true; } void ApplyFilter() { foreach (var replay in replays) replayState[replay].Visible = EvaluateReplayVisibility(replay); if (selectedReplay == null || !replayState[selectedReplay].Visible) SelectFirstVisibleReplay(); replayList.Layout.AdjustChildren(); replayList.ScrollToSelectedItem(); } void SelectFirstVisibleReplay() { SelectReplay(replays.FirstOrDefault(r => replayState[r].Visible)); } void SelectReplay(ReplayMetadata replay) { selectedReplay = replay; map = selectedReplay != null ? selectedReplay.GameInfo.MapPreview : MapCache.UnknownMap; if (replay == null) return; try { if (map.Status == MapStatus.Unavailable && Game.Settings.Game.AllowDownloading) modData.MapCache.QueryRemoteMapDetails(services.MapRepository, new[] { map.Uid }); var players = replay.GameInfo.Players .GroupBy(p => p.Team) .OrderBy(g => g.Key) .ToList(); var teams = new Dictionary>(); var noTeams = players.Count == 1; foreach (var p in players) { var label = noTeams ? FluentProvider.GetMessage(Players) : p.Key > 0 ? FluentProvider.GetMessage(TeamNumber, "team", p.Key) : FluentProvider.GetMessage(NoTeam); teams.Add(label, p); } playerList.RemoveChildren(); foreach (var kv in teams) { var group = kv.Key; if (group.Length > 0) { var header = ScrollItemWidget.Setup(playerHeader, () => false, () => { }); header.Get("LABEL").GetText = () => group; playerList.AddChild(header); } foreach (var option in kv.Value) { var o = option; var color = o.Color; var item = ScrollItemWidget.Setup(playerTemplate, () => false, () => { }); var label = item.Get("LABEL"); var font = Game.Renderer.Fonts[label.Font]; var name = WidgetUtils.TruncateText(replay.GameInfo.ResolvedPlayerName(o), label.Bounds.Width, font); label.GetText = () => name; label.GetColor = () => color; var flag = item.Get("FLAG"); flag.GetImageCollection = () => "flags"; var factionInfo = modData.DefaultRules.Actors[SystemActors.World].TraitInfos(); flag.GetImageName = () => (factionInfo != null && factionInfo.Any(f => f.InternalName == o.FactionId)) ? o.FactionId : "Random"; playerList.AddChild(item); } } } catch (Exception e) { Log.Write("debug", $"Exception while parsing replay: {replay}"); Log.Write("debug", e); SelectReplay(null); } } void WatchReplay() { if (selectedReplay != null && ReplayUtils.PromptConfirmReplayCompatibility(selectedReplay, modData)) { cancelLoadingReplays = true; DiscordService.UpdateStatus(DiscordState.WatchingReplay); Game.JoinReplay(selectedReplay.FilePath); } } void AddReplay(ReplayMetadata replay, ScrollItemWidget template) { replays.Add(replay); var item = ScrollItemWidget.Setup(template, () => selectedReplay == replay, () => SelectReplay(replay), () => WatchReplay()); replayState[replay] = new ReplayState { Item = item, Visible = true }; var itemText = Path.GetFileNameWithoutExtension(replay.FilePath); item.GetText = () => itemText; var label = item.Get("TITLE"); WidgetUtils.TruncateLabelToTooltip(label, itemText); item.IsVisible = () => replayState[replay].Visible; replayList.AddChild(item); } void OnGameStart() { Ui.CloseWindow(); onStart(); } bool disposed; protected override void Dispose(bool disposing) { if (disposing && !disposed) { disposed = true; Game.BeforeGameStart -= OnGameStart; } base.Dispose(disposing); } sealed class ReplayState { public bool Visible; public ScrollItemWidget Item; } sealed class Filter { public GameType Type; public DateType Date; public DurationType Duration; public WinState Outcome; public string PlayerName; public string MapName; public string Faction; public bool IsEmpty => Type == default && Date == default && Duration == default && Outcome == default && string.IsNullOrEmpty(PlayerName) && string.IsNullOrEmpty(MapName) && string.IsNullOrEmpty(Faction); } enum GameType { Any, Singleplayer, Multiplayer } enum DateType { Any, Today, LastWeek, LastFortnight, LastMonth } enum DurationType { Any, VeryShort, Short, Medium, Long } } }