Editor/Layers/ArchLayerHash.cs

Static utility for computing stable hashes identifying architecture layers and scopes in an ArchPlan. It folds various plan elements (layers, parts, instances, assemblies, buildings, roads, platforms, pipes, brackets) into 64-bit hashes to decide layer identity, grouping, and scope keys used for rebuild gating.

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

namespace Sunless.Architecture;

// WHAT MAKES A LAYER THE SAME LAYER - asked here and nowhere else, the way ArchLayerOrder is the one answer about
// order. Identity is the SERIALISED form, because a fold written field by field forgets the field added next month
// and a forgotten field is a change that draws nothing.
public static class ArchLayerHash
{
	public static ulong Of( object payload )
	{
		return payload is null ? ArchHash.Seed : ArchHash.Of( ArchStorage.Keyed( payload ) );
	}

	// With no group in the plan every scope is ungrouped, so its own form is already in the plan-wide term and its key
	// moves whenever it does. The gate could never skip one, and asking costs a serialise per rebuild.
	public static bool Worth( ArchPlan plan ) => plan is { Assemblies.Count: > 0 };

	// Order-dependent, because a group is a scope over an ORDERED operation stack: the same two members swapped
	// build different geometry, so they must not roll up to the same key. Nested groups fold whole.
	public static ulong Group( ArchPlan plan, ArchSiteAssembly group )
	{
		var hash = ArchHash.Fold( ArchHash.Seed, group.Id );

		hash = ArchHash.Fold( hash, Of( group ) );

		foreach ( var childId in group.Children )
		{
			if ( ArchLayerGroups.Find( plan, childId ) is { } nested )
			{
				hash = ArchHash.Fold( hash, Group( plan, nested ) );

				continue;
			}

			hash = ArchHash.Fold( hash, Of( Payload( plan, childId ) ) );
		}

		return hash;
	}

	// Everything that reaches OUT of every scope, so a scope may only be skipped when none of it moved. A platform is
	// in here because a pad sets a grade lift ArchCarveHosts looks for across every building; an ungrouped layer is
	// because its reach is the whole plan by definition. Conservative on purpose - folding in too much costs a
	// rebuild, leaving something out costs a wrong building.
	public static ulong Wide( ArchPlan plan, ArchKit kit, int terrain )
	{
		var hash = ArchHash.Fold( ArchHash.Seed, terrain );

		hash = ArchHash.Fold( hash, Of( kit ) );

		foreach ( var record in plan.Layers )
		{
			hash = ArchHash.Fold( hash, Of( record ) );
		}

		// A piece's mode lives on the plan rather than on the payload, so nothing else in here would move when
		// one changes and the gate would skip the scope holding it.
		foreach ( var record in plan.Parts )
		{
			hash = ArchHash.Fold( hash, Of( record ) );
		}

		foreach ( var link in plan.Links )
		{
			hash = ArchHash.Fold( hash, Of( link ) );
		}

		foreach ( var instance in plan.Instances )
		{
			hash = ArchHash.Fold( hash, Of( instance ) );
		}

		foreach ( var group in plan.Assemblies )
		{
			hash = ArchHash.Fold( hash, ArchHash.Fold( ArchHash.Seed, group.Id ) );
			hash = ArchHash.Fold( hash, Of( group ) );
		}

		foreach ( var platform in plan.Buildings.SelectMany( building => building.Platforms ) )
		{
			hash = ArchHash.Fold( hash, Of( platform ) );
		}

		// A services corridor reaches out of whatever scope holds it by definition: what it steps over is every
		// other corridor in the plan, so one moving inside its own group changes a run in somebody else's. Left
		// out, the gate skipped that other scope and it kept a hump over a bundle that had gone.
		foreach ( var run in plan.Buildings.SelectMany( building => building.Pipes ) )
		{
			hash = ArchHash.Fold( hash, Of( run ) );
		}

		// And a hanger is sized off those runs, so it moves whenever they do.
		foreach ( var bracket in plan.Buildings.SelectMany( building => building.Brackets ) )
		{
			hash = ArchHash.Fold( hash, Of( bracket ) );
		}

		foreach ( var scope in plan.Buildings.Where( building => ArchLayerGroups.Holding( plan, building.Id ) is null ) )
		{
			hash = ArchHash.Fold( hash, Of( scope ) );
		}

		foreach ( var road in plan.Roads().Where( road => ArchLayerGroups.Holding( plan, road.Id ) is null ) )
		{
			hash = ArchHash.Fold( hash, Of( road ) );
		}

		return hash;
	}

	// One building's or one road's key: what reaches everywhere, plus the scope it stands in. Held by a group,
	// that is the group's roll-up; held by none, its own form is already inside the wide term, so the two
	// together say "nothing anywhere in the plan that could touch this has moved".
	public static ulong Scope( ulong wide, ArchPlan plan, int itemId )
	{
		var hash = ArchHash.Fold( wide, itemId );

		if ( ArchLayerGroups.Holding( plan, itemId ) is { } group )
		{
			return ArchHash.Fold( hash, Group( plan, group ) );
		}

		return ArchHash.Fold( hash, Of( Payload( plan, itemId ) ) );
	}

	// Only the roots a scope can be: a group's member list holds ids of any kind, and what is not a building or a
	// road is folded through the tree instead of hunted for here.
	static object Payload( ArchPlan plan, int itemId )
	{
		if ( plan.FindBuilding( itemId ) is { } building )
		{
			return building;
		}

		if ( plan.Roads().FirstOrDefault( road => road.Id == itemId ) is { } road )
		{
			return road;
		}

		return plan.AllRooms().FirstOrDefault( room => room.Id == itemId );
	}
}