CombatEventTimelineRenderer.cs
1 using GUNRPG.Core.Events; 2 using GUNRPG.Core.Operators; 3 using Microsoft.FSharp.Core; 4 using Plotly.NET; 5 using Plotly.NET.CSharp; 6 using Plotly.NET.ImageExport; 7 using Plotly.NET.LayoutObjects; 8 9 namespace GUNRPG.Core.Rendering; 10 11 public sealed class CombatEventTimelineRenderer 12 { 13 private const int MinEventDurationMs = 2; 14 15 public IReadOnlyList<CombatEventTimelineEntry> BuildTimelineEntries( 16 IReadOnlyList<ISimulationEvent> events, 17 Operator player, 18 Operator enemy, 19 IReadOnlyList<CombatEventTimelineEntry>? additionalEntries = null) 20 { 21 var entries = events 22 .Select(evt => ToTimelineEntry(evt, player, enemy)) 23 .ToList(); 24 25 if (additionalEntries != null && additionalEntries.Count > 0) 26 { 27 entries.AddRange(additionalEntries); 28 } 29 30 entries = entries 31 .OrderBy(entry => entry.StartTimeMs) 32 .ThenBy(entry => entry.ActorName, StringComparer.Ordinal) 33 .ToList(); 34 35 return entries; 36 } 37 38 public void RenderTimeline( 39 IReadOnlyList<CombatEventTimelineEntry> entries, 40 string outputPath) 41 { 42 if (entries.Count == 0) 43 { 44 Console.WriteLine("No combat events captured for timeline rendering."); 45 return; 46 } 47 48 var (labels, durations, bases) = BuildChartSeries(entries); 49 var timelineBarTraces = new List<GenericChart>(entries.Count); 50 51 for (int i = 0; i < labels.Count; i++) 52 { 53 var trace = Plotly.NET.CSharp.Chart.Bar<double, string, string>( 54 new[] { durations[i] }, 55 Keys: new[] { labels[i] }, 56 Base: bases[i], 57 Width: 0.6, 58 ShowLegend: false); 59 timelineBarTraces.Add(trace); 60 } 61 62 var chart = Plotly.NET.Chart.Combine(timelineBarTraces); 63 64 var orientation = Trace2DStyle.Bar<double, double, double, double, double, double, double, double, double, double, double, double, double, double, double, double, double, Trace>( 65 Orientation: FSharpOption<StyleParam.Orientation>.Some(StyleParam.Orientation.Horizontal)); 66 chart = Plotly.NET.GenericChart.mapTrace(orientation, chart); 67 68 chart = Plotly.NET.CSharp.GenericChartExtensions.WithXAxisStyle<double, double, string>( 69 chart, 70 TitleText: "Simulated Time (ms)"); 71 chart = Plotly.NET.CSharp.GenericChartExtensions.WithYAxisStyle<double, double, string>( 72 chart, 73 TitleText: "Combat Events", 74 CategoryOrder: StyleParam.CategoryOrder.Array, 75 CategoryArray: labels); 76 77 var title = Plotly.NET.Title.init(FSharpOption<string>.Some("Combat Event Timeline")); 78 var margin = Margin.init<int, int, int, int, int, bool>( 79 Left: FSharpOption<int>.Some(280), 80 Right: FSharpOption<int>.Some(40), 81 Top: FSharpOption<int>.Some(60), 82 Bottom: FSharpOption<int>.Some(60), 83 Pad: FSharpOption<int>.Some(8), 84 Autoexpand: FSharpOption<bool>.Some(true)); 85 86 chart = Plotly.NET.GenericChartExtensions.WithLayoutStyle<string>( 87 chart, 88 Title: FSharpOption<Plotly.NET.Title>.Some(title), 89 Margin: FSharpOption<Margin>.Some(margin), 90 Height: FSharpOption<int>.Some(Math.Clamp(entries.Count * 28, 320, 1400))); 91 92 SaveChart(chart, outputPath); 93 } 94 95 private static void SaveChart(GenericChart chart, string outputPath) 96 { 97 var extension = Path.GetExtension(outputPath); 98 if (string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase)) 99 { 100 try 101 { 102 chart.SavePNG( 103 outputPath, 104 EngineType: FSharpOption<ExportEngine>.Some(ExportEngine.PuppeteerSharp), 105 Width: FSharpOption<int>.Some(1600), 106 Height: FSharpOption<int>.Some(900)); 107 return; 108 } 109 catch (Exception ex) 110 { 111 var fallbackPath = Path.ChangeExtension(outputPath, ".html"); 112 Plotly.NET.GenericChartExtensions.SaveHtml( 113 chart, 114 fallbackPath, 115 FSharpOption<bool>.Some(true)); 116 Console.WriteLine("PNG export failed. Saved HTML fallback instead:"); 117 Console.WriteLine($" {fallbackPath}"); 118 Console.WriteLine($" Details: {ex.GetType().Name} - {ex.Message}"); 119 Console.WriteLine(" Hint: Ensure PuppeteerSharp dependencies are available."); 120 return; 121 } 122 } 123 124 Plotly.NET.GenericChartExtensions.SaveHtml( 125 chart, 126 outputPath, 127 FSharpOption<bool>.Some(true)); 128 } 129 130 private static CombatEventTimelineEntry ToTimelineEntry( 131 ISimulationEvent evt, 132 Operator player, 133 Operator enemy) 134 { 135 var actorName = ResolveActorName(evt.OperatorId, player, enemy); 136 var (start, end) = ResolveEventWindow(evt); 137 138 return new CombatEventTimelineEntry(FormatEventType(evt), start, end, actorName); 139 } 140 141 private static int InferDurationMs(ISimulationEvent evt) 142 { 143 return evt switch 144 { 145 ShotFiredEvent shot => Math.Max((int)Math.Round((double)shot.TravelTimeMs, MidpointRounding.AwayFromZero), MinEventDurationMs), 146 DamageAppliedEvent => MinEventDurationMs, 147 ShotMissedEvent => MinEventDurationMs, 148 ReloadCompleteEvent reload => Math.Max(reload.ActionDurationMs, MinEventDurationMs), 149 ADSCompleteEvent ads => Math.Max(ads.ActionDurationMs, MinEventDurationMs), 150 ADSTransitionUpdateEvent adsUpdate => Math.Max(adsUpdate.ActionDurationMs, MinEventDurationMs), 151 MovementIntervalEvent movement => Math.Max(movement.IntervalDurationMs, MinEventDurationMs), 152 SlideCompleteEvent slide => Math.Max(slide.ActionDurationMs, MinEventDurationMs), 153 MicroReactionEvent microReaction => Math.Max(microReaction.ActionDurationMs, MinEventDurationMs), 154 SuppressionStartedEvent => MinEventDurationMs, 155 SuppressionUpdatedEvent => MinEventDurationMs, 156 SuppressionEndedEvent suppressionEnded => Math.Max((int)suppressionEnded.DurationMs, MinEventDurationMs), 157 MovementStartedEvent movementStarted => Math.Max((int)movementStarted.DurationMs, MinEventDurationMs), 158 MovementCancelledEvent => MinEventDurationMs, 159 MovementEndedEvent => MinEventDurationMs, 160 CoverEnteredEvent => MinEventDurationMs, 161 CoverExitedEvent => MinEventDurationMs, 162 _ => MinEventDurationMs 163 }; 164 } 165 166 private static (int start, int end) ResolveEventWindow(ISimulationEvent evt) 167 { 168 int eventTime = (int)Math.Clamp(evt.EventTimeMs, int.MinValue, int.MaxValue); 169 int duration = Math.Max(InferDurationMs(evt), MinEventDurationMs); 170 int start = eventTime; 171 int end = eventTime + duration; 172 173 switch (evt) 174 { 175 case ReloadCompleteEvent reload: 176 start = eventTime - Math.Max(reload.ActionDurationMs, MinEventDurationMs); 177 end = eventTime; 178 break; 179 case ADSCompleteEvent ads: 180 start = eventTime - Math.Max(ads.ActionDurationMs, MinEventDurationMs); 181 end = eventTime; 182 break; 183 case ADSTransitionUpdateEvent adsUpdate: 184 start = eventTime - Math.Max(adsUpdate.ActionDurationMs, MinEventDurationMs); 185 end = eventTime; 186 break; 187 case MovementIntervalEvent movement: 188 start = eventTime - Math.Max(movement.IntervalDurationMs, MinEventDurationMs); 189 end = eventTime; 190 break; 191 case SlideCompleteEvent slide: 192 start = eventTime - Math.Max(slide.ActionDurationMs, MinEventDurationMs); 193 end = eventTime; 194 break; 195 case SuppressionEndedEvent suppressionEnded: 196 // Suppression ended event represents the entire duration 197 start = eventTime - (int)suppressionEnded.DurationMs; 198 end = eventTime; 199 break; 200 case MovementStartedEvent movementStarted: 201 // Movement shows duration from start to end 202 start = eventTime; 203 end = (int)movementStarted.EndTimeMs; 204 break; 205 case MovementCancelledEvent movementCancelled: 206 // Show cancelled movement as a bar ending at cancellation time 207 // We don't have the original start time, so just show a short marker 208 start = eventTime - MinEventDurationMs; 209 end = eventTime; 210 break; 211 } 212 213 start = Math.Max(start, 0); 214 if (end <= start) 215 { 216 end = start + MinEventDurationMs; 217 } 218 219 return (start, end); 220 } 221 222 private static string? ResolveActorName(Guid operatorId, Operator player, Operator enemy) 223 { 224 if (operatorId == player.Id) 225 { 226 return player.Name; 227 } 228 229 if (operatorId == enemy.Id) 230 { 231 return enemy.Name; 232 } 233 234 return null; 235 } 236 237 private static string FormatEventType(ISimulationEvent evt) 238 { 239 string name = evt.GetType().Name; 240 return name.EndsWith("Event", StringComparison.Ordinal) 241 ? name[..^"Event".Length] 242 : name; 243 } 244 245 private static (List<string> labels, List<double> durations, List<double> bases) BuildChartSeries( 246 IReadOnlyList<CombatEventTimelineEntry> entries) 247 { 248 var labels = new List<string>(entries.Count); 249 var durations = new List<double>(entries.Count); 250 var bases = new List<double>(entries.Count); 251 252 foreach (var entry in entries) 253 { 254 int duration = entry.DurationMs <= 0 ? MinEventDurationMs : entry.DurationMs; 255 string actorLabel = string.IsNullOrWhiteSpace(entry.ActorName) ? "Unknown" : entry.ActorName!; 256 string label = entry.Detail == null 257 ? FormattableString.Invariant($"{entry.StartTimeMs}ms | {actorLabel} | {entry.EventType}") 258 : FormattableString.Invariant($"{entry.StartTimeMs}ms | {actorLabel} | {entry.EventType} ({entry.Detail})"); 259 260 labels.Add(label); 261 durations.Add(duration); 262 bases.Add(entry.StartTimeMs); 263 } 264 265 return (labels, durations, bases); 266 } 267 }