307 lines
12 KiB
C#
307 lines
12 KiB
C#
using AnotherReplayReader.ReplayFile;
|
||
using AnotherReplayReader.Utils;
|
||
using OxyPlot;
|
||
using OxyPlot.Axes;
|
||
using System;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.Collections.Immutable;
|
||
using System.Linq;
|
||
using System.Windows;
|
||
using System.Windows.Threading;
|
||
|
||
namespace AnotherReplayReader.Apm
|
||
{
|
||
public class ApmPlotterFilterOptions
|
||
{
|
||
public bool CountUnknowns { get; }
|
||
public bool CountAutos { get; }
|
||
public bool CountClicks { get; }
|
||
|
||
public ApmPlotterFilterOptions(bool countUnknowns, bool countAutos, bool countClicks)
|
||
{
|
||
CountUnknowns = countUnknowns;
|
||
CountAutos = countAutos;
|
||
CountClicks = countClicks;
|
||
}
|
||
|
||
public bool ShouldSkip(int command)
|
||
{
|
||
if (!CountUnknowns && ApmPlotter.IsUnknown(command))
|
||
{
|
||
return true;
|
||
}
|
||
if (!CountAutos && ApmPlotter.IsAuto(command))
|
||
{
|
||
return true;
|
||
}
|
||
if (!CountClicks && ApmPlotter.IsClick(command))
|
||
{
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
internal class ApmPlotter
|
||
{
|
||
private static readonly ConcurrentDictionary<int, byte> UnknownCommands;
|
||
private static readonly ConcurrentDictionary<int, byte> AutoCommands;
|
||
private readonly ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> _commands;
|
||
|
||
public Replay Replay { get; }
|
||
public ImmutableSortedDictionary<int, Player> PlayersMap { get; }
|
||
public ImmutableArray<Player> PlayersArray { get; }
|
||
public ImmutableArray<TimeSpan> PlayerLifes { get; }
|
||
public ImmutableArray<TimeSpan> StricterPlayerLifes { get; }
|
||
public TimeSpan ReplayLength { get; }
|
||
public ImmutableArray<(TimeSpan, ImmutableArray<CommandChunk>)> Commands => _commands;
|
||
|
||
static ApmPlotter()
|
||
{
|
||
static KeyValuePair<int, byte> Create(int x) => new(x, default);
|
||
UnknownCommands = new(RA3Commands.UnknownCommands.Select(Create));
|
||
AutoCommands = new(RA3Commands.AutoCommands.Select(Create));
|
||
}
|
||
|
||
public ApmPlotter(Replay replay)
|
||
{
|
||
if (replay.Body is not { } body)
|
||
{
|
||
throw new InvalidOperationException("Replay must be fully parsed!");
|
||
}
|
||
|
||
Replay = replay;
|
||
|
||
var queryFull = from chunk in body
|
||
where chunk.Type is 1
|
||
from command in CommandChunk.Parse(chunk)
|
||
select command;
|
||
|
||
var playersIndices = queryFull
|
||
.Select(x => x.PlayerIndex)
|
||
.Distinct()
|
||
.OrderBy(x => x)
|
||
.ToArray();
|
||
|
||
// if length mismatch and last is post commentator, remove post commentator from replay players
|
||
var replayPlayers = replay.Players;
|
||
var postCommentatorIndex = replayPlayers.FindIndex(p => p.PlayerName == Replay.PostCommentator);
|
||
if (playersIndices.Length + 1 == replayPlayers.Length && postCommentatorIndex is int index)
|
||
{
|
||
replayPlayers = replayPlayers.RemoveAt(index);
|
||
}
|
||
|
||
if (playersIndices.Length != replayPlayers.Length)
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"Player count mismatch. Indices={playersIndices.Length}, Players={replayPlayers.Length}");
|
||
}
|
||
PlayersMap = playersIndices
|
||
.Zip(replayPlayers, (index, player) => new KeyValuePair<int, Player>(index, player))
|
||
.ToImmutableSortedDictionary();
|
||
PlayersArray = PlayersMap.Values.ToImmutableArray();
|
||
|
||
|
||
//var queryFull = from chunk in body
|
||
// where chunk.Type is 1
|
||
// from command in CommandChunk.Parse(chunk)
|
||
// select (chunk.Time, command);
|
||
//var sb = new System.Text.StringBuilder();
|
||
//var stringHashes = System.IO.File.ReadAllText(@"C:\Apps\RA3-MODSDK-X\builtmods\StringHashes.xml");
|
||
//XDocument doc = XDocument.Parse(stringHashes);
|
||
//XNamespace ns = "uri:ea.com:eala:asset";
|
||
//var table = doc
|
||
// .Descendants(ns + "StringHashTable")
|
||
// .FirstOrDefault(x => (string?)x.Attribute("id") == "StringHashBin_INSTANCEID");
|
||
|
||
//if (table == null)
|
||
// throw new InvalidOperationException("StringHashBin_INSTANCEID not found.");
|
||
|
||
//Dictionary<uint, string> hashTable = table
|
||
// .Elements(ns + "StringAndHash")
|
||
// .ToDictionary(
|
||
// x => uint.Parse(x.Attribute("Hash")!.Value),
|
||
// x => x.Attribute("Text")!.Value);
|
||
|
||
//foreach (var (time, command) in queryFull)
|
||
//{
|
||
// if (IsUnknown(command.CommandId) || IsAuto(command.CommandId))
|
||
// {
|
||
// continue;
|
||
// }
|
||
// var name = RA3Commands.GetCommandName(command.CommandId);
|
||
// sb.Append($"[{time}] 玩家 {command.PlayerIndex},{name}\r\n");
|
||
// foreach (var kv in command.Data)
|
||
// {
|
||
// // try to convert int32 or uint32 to hashes and retrieve text
|
||
// var textFromHash = string.Empty;
|
||
// uint hashValue = kv.Key switch
|
||
// {
|
||
// CommandArgumentType.Int32 => (uint)(int)kv.Value,
|
||
// CommandArgumentType.UInt32 => (uint)kv.Value,
|
||
// CommandArgumentType.UInt32_2 => (uint)kv.Value,
|
||
// _ => 0
|
||
// };
|
||
// if (hashTable.TryGetValue(hashValue, out var text))
|
||
// {
|
||
// sb.Append($" {text}\r\n");
|
||
// }
|
||
// else
|
||
// {
|
||
// sb.Append($" {kv.Key}: {kv.Value}\r\n");
|
||
// }
|
||
|
||
// }
|
||
//}
|
||
//流水账 = sb.ToString();
|
||
//System.IO.File.WriteAllText("流水账.txt", 流水账);
|
||
|
||
// get all commands
|
||
var query = from chunk in body
|
||
where chunk.Type is 1
|
||
select (chunk.Time, CommandChunk.Parse(chunk).ToImmutableArray());
|
||
_commands = query.ToImmutableArray();
|
||
|
||
var playerLifes = new SortedDictionary<int, TimeSpan>(PlayersMap.Keys.ToDictionary(x => x, _ => TimeSpan.Zero));
|
||
var threeSeconds = TimeSpan.FromSeconds(3);
|
||
var stricterLifes = new SortedDictionary<int, TimeSpan>(PlayersMap.Keys.ToDictionary(x => x, _ => TimeSpan.Zero));
|
||
foreach (var (time, commands) in _commands)
|
||
{
|
||
var estimatedTime = time + threeSeconds;
|
||
foreach (var command in commands)
|
||
{
|
||
var commandId = command.CommandId;
|
||
var playerIndex = command.PlayerIndex;
|
||
if (playerIndex != -1)
|
||
{
|
||
if (commandId == 0x221)
|
||
{
|
||
if (playerLifes[playerIndex] < estimatedTime)
|
||
{
|
||
playerLifes[playerIndex] = estimatedTime;
|
||
}
|
||
}
|
||
else if (!IsUnknown(commandId) && !IsAuto(commandId))
|
||
{
|
||
if (stricterLifes[playerIndex] < estimatedTime)
|
||
{
|
||
stricterLifes[playerIndex] = estimatedTime;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
PlayerLifes = playerLifes.Values.ToImmutableArray();
|
||
StricterPlayerLifes = stricterLifes.Values.ToImmutableArray();
|
||
ReplayLength = replay.Footer?.ReplayLength ?? PlayerLifes.Max();
|
||
}
|
||
|
||
public DataPoint[][] GetPoints(TimeSpan resolution, ApmPlotterFilterOptions options)
|
||
{
|
||
var lists = new SortedDictionary<int, List<int>>(PlayersMap.Keys.ToDictionary(x => x, _ => new List<int>()));
|
||
|
||
var currentTime = TimeSpan.Zero;
|
||
var currentIndex = 0;
|
||
foreach (var (time, commands) in _commands)
|
||
{
|
||
while (time >= currentTime + resolution)
|
||
{
|
||
currentTime += resolution;
|
||
++currentIndex;
|
||
}
|
||
foreach (var command in commands)
|
||
{
|
||
if (options.ShouldSkip(command.CommandId))
|
||
{
|
||
continue;
|
||
}
|
||
var list = lists[command.PlayerIndex];
|
||
var gap = currentIndex - list.Count;
|
||
if (gap >= 0)
|
||
{
|
||
list.AddRange(Enumerable.Repeat(0, gap + 1));
|
||
}
|
||
++lists[command.PlayerIndex][currentIndex];
|
||
}
|
||
}
|
||
var apmMultiplier = 1 / resolution.TotalMinutes;
|
||
DataPoint Create(int v, int i) => new(TimeSpanAxis.ToDouble(resolution) * i,
|
||
v * apmMultiplier);
|
||
return [.. lists.Select(l => l.Value.Select(Create).ToArray())];
|
||
}
|
||
|
||
public double[] CalculateAverageApm(ApmPlotterFilterOptions options)
|
||
{
|
||
var apm = new SortedDictionary<int, double>(PlayersMap.Keys.ToDictionary(x => x, _ => 0.0));
|
||
foreach (var (_, commands) in _commands)
|
||
{
|
||
foreach (var command in commands)
|
||
{
|
||
if (options.ShouldSkip(command.CommandId))
|
||
{
|
||
continue;
|
||
}
|
||
++apm[command.PlayerIndex];
|
||
}
|
||
}
|
||
return [.. apm.Select((kv, i) => kv.Value / PlayerLifes[i].TotalMinutes)];
|
||
}
|
||
|
||
public static double[] CalculateInstantApm(DataPoint[][] data, TimeSpan begin, TimeSpan end)
|
||
{
|
||
return [.. data.Select(playerData =>
|
||
{
|
||
return playerData
|
||
.SkipWhile(p => TimeSpanAxis.ToTimeSpan(p.X) < begin)
|
||
.TakeWhile(p => TimeSpanAxis.ToTimeSpan(p.X) < end)
|
||
.Average(p => new double?(p.Y)) ?? double.NaN;
|
||
})];
|
||
}
|
||
|
||
public Dictionary<int, int[]> GetCommandCounts(TimeSpan begin, TimeSpan end)
|
||
{
|
||
var playerCommands = new Dictionary<int, SortedDictionary<int, int>>();
|
||
|
||
foreach (var (time, commands) in _commands)
|
||
{
|
||
if (time < begin)
|
||
{
|
||
continue;
|
||
}
|
||
if (time >= end)
|
||
{
|
||
break;
|
||
}
|
||
foreach (var command in commands)
|
||
{
|
||
if (!playerCommands.TryGetValue(command.CommandId, out var commandCount))
|
||
{
|
||
commandCount = playerCommands[command.CommandId] = new(PlayersMap.Keys.ToDictionary(x => x, _ => 0));
|
||
}
|
||
commandCount[command.PlayerIndex] = commandCount[command.PlayerIndex] + 1;
|
||
}
|
||
}
|
||
return playerCommands.ToDictionary(kv => kv.Key, kv => kv.Value.Values.ToArray());
|
||
}
|
||
|
||
public static bool IsUnknown(int commandId)
|
||
{
|
||
if (UnknownCommands.ContainsKey(commandId))
|
||
{
|
||
return true;
|
||
}
|
||
if (RA3Commands.IsUnknownCommand(commandId))
|
||
{
|
||
UnknownCommands.TryAdd(commandId, default);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
public static bool IsAuto(int commandId) => AutoCommands.ContainsKey(commandId);
|
||
|
||
public static bool IsClick(int commandId) => commandId is 0x1F8 or 0x1F5;
|
||
}
|
||
}
|