Game/ReplayLevelRevisions.cs

Static helper that maps specific level IDs and revision indices to archived JSON paths, loads archived EditorLevel JSON from the mounted filesystem, caches parsed LevelDef objects, and resolves which LevelDef to use for replay based on recorded content hashes.

File Access
using System;
using System.Collections.Generic;
using Sandbox;

namespace BlockParty;

/// <summary>Original shipped layouts retained only for replay playback. These files stay outside
/// the live level registry, editor browser, and map. Append revisions before further gameplay edits;
/// never replace an archived file. The first revision is also the fallback for pre-hash recordings.</summary>
public static class ReplayLevelRevisions
{
	// Keep this in executable code: s&box hotload restores static field contents instead of
	// rerunning their initializers. A static dictionary would retain the old list of levels,
	// even after reload_levels, and newly archived levels would stay unreplayable until restart.
	// Index zero is the original/pre-hash revision; append later revisions at successive indices.
	private static string PathFor( string levelId, int revision ) => (levelId, revision) switch
	{
		("invaders", 0) => "levels/replay-revisions/invaders-v1.json",
		("onion", 0) => "levels/replay-revisions/onion-v1.json",
		("pacrat", 0) => "levels/replay-revisions/pacrat-v1.json",
		("tutorial", 0) => "levels/replay-revisions/tutorial-v1.json",
		("windswept", 0) => "levels/replay-revisions/windswept-v1.json",
		_ => null,
	};

	private static readonly Dictionary<string, LevelDef> Cache = new();

	/// <summary>Resolve the recorded layout without changing the current registry. Unknown hashes
	/// keep the current definition so RunData's existing hash gate rejects them. A missing original
	/// for a pre-hash run fails closed: there is no hash with which to reject an incorrect fallback.</summary>
	public static LevelDef Resolve( LevelDef current, string recordedHash )
	{
		if ( current is null )
			return current;

		if ( recordedHash is not null && current.MatchesContentHash( recordedHash ) )
			return current;

		for ( int revision = 0; PathFor( current.Id, revision ) is { } path; revision++ )
		{
			var archived = Load( path, current.Id );
			if ( recordedHash is null )
				return archived;
			if ( archived?.MatchesContentHash( recordedHash ) == true )
				return archived;
		}

		return current;
	}

	/// <summary>Called by reload_levels so newly archived revisions are available immediately.</summary>
	public static void ClearCache() => Cache.Clear();

	private static LevelDef Load( string path, string expectedId )
	{
		if ( Cache.TryGetValue( path, out var cached ) )
			return cached;

		// Do not cache a failed read: the mounted filesystem may still be warming up.
		if ( !FileSystem.Mounted.FileExists( path ) )
			return null;
		try
		{
			var editor = EditorLevel.FromJsonString( FileSystem.Mounted.ReadAllText( path ), out var error );
			if ( editor is null || editor.Id != expectedId )
			{
				Log.Warning( $"[BlockParty] replay revision '{path}' ignored: {error ?? "unexpected level id"}." );
				return null;
			}
			var level = editor.ToLevelDef();
			level.SourcePath = path;
			Cache[path] = level;
			return level;
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[BlockParty] replay revision '{path}' could not load: {ex.Message}" );
			return null;
		}
	}
}