Game/DesyncReport.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace BlockParty;

/// <summary>
/// A replay-desync bug report written to <c>logs/</c> in the game data folder whenever a watched
/// replay fails its determinism check (see <see cref="GameManager"/>'s CheckReplayDesync), in every
/// build — a beta tester can just send the file. Self-contained for reproduction: the full run
/// payload (seed + recorded input, importable as a local replay), the resolved level's canonical
/// JSON, the recorded-vs-reproduced scores and block states, and — when the run was played THIS
/// session — the live/replay checkpoint trails with the first divergent step window.
/// </summary>
public static class DesyncReport
{
	/// <summary>Report files live in their own subfolder of the game data directory, beside
	/// <see cref="LocalLevels.DirName"/> and the gif exports.</summary>
	public const string DirName = "logs";

	private sealed class Report
	{
		public string CreatedAt { get; set; }
		public int SimVersion { get; set; }
		public int RunDataVersion { get; set; }
		public string GameIdent { get; set; }
		public bool IsEditor { get; set; }

		public int RecordedScore { get; set; }
		public int ReproducedScore { get; set; }
		public string RecordedBlocks { get; set; }
		public string ReproducedBlocks { get; set; }

		/// <summary>First step of the one-second window where the same-session trace saw the sim
		/// diverge; -1 when the trace couldn't compare (run not from this session).</summary>
		public int DivergenceStep { get; set; }
		public string LiveCheckpoints { get; set; }
		public string ReplayCheckpoints { get; set; }

		/// <summary>Every awarded side press, in order (step/block/side) — live run vs replay. The
		/// first entry where the two disagree is the divergence.</summary>
		public string LivePressEvents { get; set; }
		public string ReplayPressEvents { get; set; }

		/// <summary>Canonical level JSON the replay resolved (what the re-sim actually played).</summary>
		public string LevelJson { get; set; }

		/// <summary>The complete replay payload — drop into blockparty_replays.json (or hand to the
		/// developer) to re-watch this exact recording.</summary>
		public RunData Run { get; set; }
	}

	/// <summary>Write one report file (<c>logs/desync_s{seed}_{n}.json</c>) and log its full path.
	/// Never throws — diagnostics must not take down the replay end path.</summary>
	public static void Save( RunData run, int recordedScore, int reproducedScore,
		IReadOnlyList<BlockResult> reproducedBlocks, int divergenceStep,
		IReadOnlyList<uint> liveCheckpoints, IReadOnlyList<uint> replayCheckpoints,
		string livePressEvents = null, string replayPressEvents = null )
	{
		if ( run is null ) return;

		try
		{
			var report = new Report
			{
				CreatedAt = DateTimeOffset.Now.ToString( "u" ),
				SimVersion = Sim.VERSION,
				RunDataVersion = RunData.CURRENT_VERSION,
				GameIdent = Game.Ident,
				IsEditor = Game.IsEditor,
				RecordedScore = recordedScore,
				ReproducedScore = reproducedScore,
				RecordedBlocks = FormatBlocks( run.DecodeBlocks() ),
				ReproducedBlocks = reproducedBlocks is null
					? "(replay input exhausted before game-over)"
					: FormatBlocks( new RunData { Blocks = RunData.PackBlocks( reproducedBlocks ) }.DecodeBlocks() ),
				DivergenceStep = divergenceStep,
				LiveCheckpoints = FormatCheckpoints( liveCheckpoints ),
				ReplayCheckpoints = FormatCheckpoints( replayCheckpoints ),
				LivePressEvents = livePressEvents,
				ReplayPressEvents = replayPressEvents,
				LevelJson = run.Level is not null ? EditorLevel.FromLevelDef( run.Level ).ToJsonString() : null,
				Run = run,
			};

			FileSystem.Data.CreateDirectory( DirName );
			// One file per distinct watch of a run; a low counter keeps repeat watches from piling up.
			string path = null;
			for ( int i = 0; i < 10; i++ )
			{
				string candidate = $"{DirName}/desync_s{run.Seed}_{i}.json";
				if ( FileSystem.Data.FileExists( candidate ) ) continue;
				path = candidate;
				break;
			}
			path ??= $"{DirName}/desync_s{run.Seed}_9.json";

			FileSystem.Data.WriteJson( path, report );
			Log.Warning( $"BlockParty: desync report saved to {FileSystem.Data.GetFullPath( path )} — please send this file to the developer." );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"BlockParty: couldn't save desync report: {ex.Message}" );
		}
	}

	private static string FormatBlocks( List<BlockTallyState> blocks ) =>
		blocks is { Count: > 0 }
			? string.Join( "  ", blocks.Select( b =>
				$"{b.Type} p{b.Phase} {(b.Left ? "L" : "-")}{(b.Right ? "R" : "-")}{(b.Up ? "U" : "-")}{(b.Down ? "D" : "-")}" ) )
			: "(none)";

	// One hex hash per sim-second, space-separated — diffing the two strings by eye shows the first
	// divergent second even without the DivergenceStep field.
	private static string FormatCheckpoints( IReadOnlyList<uint> checkpoints ) =>
		checkpoints is { Count: > 0 }
			? string.Join( " ", checkpoints.Select( c => c.ToString( "x8" ) ) )
			: "";
}