Code/V2/Application/InventoryLayoutService.cs

Inventory layout service and related value types. Defines ItemShape and IItemShapeCatalog, and provides InventoryLayoutService which adds, moves, removes and transfers items on a grid, finds first-fit positions, and validates placement collisions using an item shape catalog.

File AccessNetworking
#nullable enable

using System;
using System.Collections.Generic;
using System.Linq;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;

namespace Hexagon.V2.Application;

public readonly struct ItemShape : IEquatable<ItemShape>
{
	public int Width { get; }
	public int Height { get; }
	public InventoryGridSize GridSize => new( Width, Height );

	public ItemShape( int width, int height )
	{
		if ( width <= 0 ) throw new ArgumentOutOfRangeException( nameof(width) );
		if ( height <= 0 ) throw new ArgumentOutOfRangeException( nameof(height) );
		Width = width;
		Height = height;
	}

	public bool Equals( ItemShape other ) => Width == other.Width && Height == other.Height;
	public override bool Equals( object? obj ) => obj is ItemShape other && Equals( other );
	public override int GetHashCode() => HashCode.Combine( Width, Height );
	public static bool operator ==( ItemShape left, ItemShape right ) => left.Equals( right );
	public static bool operator !=( ItemShape left, ItemShape right ) => !left.Equals( right );
}

public interface IItemShapeCatalog
{
	bool TryGetShape( DefinitionId definitionId, out ItemShape shape );
	bool TryGetShape( ItemId itemId, out ItemShape shape );
}

public readonly record struct InventoryTransferResult( InventoryRecord Source, InventoryRecord Target );

/// <summary>
/// Pure inventory aggregate operations. No method resolves items globally: callers
/// must supply the item record already proven to belong to the source inventory.
/// </summary>
public sealed class InventoryLayoutService
{
	private readonly IItemShapeCatalog _catalog;

	public InventoryLayoutService( IItemShapeCatalog catalog ) =>
		_catalog = catalog ?? throw new ArgumentNullException( nameof(catalog) );

	public OperationResult<InventoryRecord> AddAt(
		InventoryRecord inventory,
		ItemRecord item,
		int x,
		int y,
		IReadOnlyDictionary<ItemId, DefinitionId>? stagedDefinitions = null )
		=> AddAt( inventory, item, new InventoryGridPosition( x, y ), stagedDefinitions );

	public OperationResult<InventoryRecord> AddAt(
		InventoryRecord inventory,
		ItemRecord item,
		InventoryGridPosition position,
		IReadOnlyDictionary<ItemId, DefinitionId>? stagedDefinitions = null )
	{
		if ( inventory.Find( item.Id ) is not null )
			return OperationResult<InventoryRecord>.Failure( ErrorCode.Conflict, "Item is already in the inventory." );

		if ( !_catalog.TryGetShape( item.Definition, out var shape ) )
			return OperationResult<InventoryRecord>.Failure( ErrorCode.UnknownDefinition, $"Unknown item definition '{item.Definition}'." );

		if ( !CanFit( inventory, position, shape, null, stagedDefinitions ) )
			return OperationResult<InventoryRecord>.Failure( ErrorCode.Conflict, "Item does not fit at the requested position." );

		return OperationResult<InventoryRecord>.Success( inventory with
		{
			Placements = inventory.Placements.Append( new InventoryPlacement( item.Id, position ) ).ToArray()
		} );
	}

	public OperationResult<InventoryRecord> Move(
		InventoryRecord inventory,
		ItemRecord item,
		int x,
		int y,
		IReadOnlyDictionary<ItemId, DefinitionId>? stagedDefinitions = null )
		=> Move( inventory, item, new InventoryGridPosition( x, y ), stagedDefinitions );

	public OperationResult<InventoryRecord> Move(
		InventoryRecord inventory,
		ItemRecord item,
		InventoryGridPosition position,
		IReadOnlyDictionary<ItemId, DefinitionId>? stagedDefinitions = null )
	{
		if ( inventory.Find( item.Id ) is null )
			return OperationResult<InventoryRecord>.Failure( ErrorCode.NotFound, "Item is not a member of the inventory." );

		if ( !_catalog.TryGetShape( item.Definition, out var shape ) )
			return OperationResult<InventoryRecord>.Failure( ErrorCode.UnknownDefinition, $"Unknown item definition '{item.Definition}'." );

		if ( !CanFit( inventory, position, shape, item.Id, stagedDefinitions ) )
			return OperationResult<InventoryRecord>.Failure( ErrorCode.Conflict, "Item does not fit at the requested position." );

		return OperationResult<InventoryRecord>.Success( inventory with
		{
			Placements = inventory.Placements
				.Select( placement => placement.ItemId == item.Id ? new InventoryPlacement( item.Id, position ) : placement )
				.ToArray()
		} );
	}

	public OperationResult<InventoryRecord> Remove( InventoryRecord inventory, ItemId itemId )
	{
		if ( inventory.Find( itemId ) is null )
			return OperationResult<InventoryRecord>.Failure( ErrorCode.NotFound, "Item is not a member of the inventory." );

		return OperationResult<InventoryRecord>.Success( inventory with
		{
			Placements = inventory.Placements.Where( placement => placement.ItemId != itemId ).ToArray()
		} );
	}

	public OperationResult<InventoryTransferResult> Transfer(
		InventoryRecord source,
		InventoryRecord target,
		ItemRecord item,
		int x,
		int y,
		IReadOnlyDictionary<ItemId, DefinitionId>? stagedDefinitions = null ) =>
		Transfer( source, target, item, new InventoryGridPosition( x, y ), stagedDefinitions );

	public OperationResult<InventoryTransferResult> Transfer(
		InventoryRecord source,
		InventoryRecord target,
		ItemRecord item,
		InventoryGridPosition position,
		IReadOnlyDictionary<ItemId, DefinitionId>? stagedDefinitions = null )
	{
		if ( source.Id == target.Id )
		{
			var moved = Move( source, item, position, stagedDefinitions );
			return moved.Succeeded
				? OperationResult<InventoryTransferResult>.Success( new InventoryTransferResult( moved.Value, moved.Value ) )
				: OperationResult<InventoryTransferResult>.Failure( moved.Error!.Code, moved.Error.Message );
		}

		if ( source.Find( item.Id ) is null )
			return OperationResult<InventoryTransferResult>.Failure( ErrorCode.NotFound, "Item is not a member of the source inventory." );

		var added = AddAt( target, item, position, stagedDefinitions );
		if ( added.Failed )
			return OperationResult<InventoryTransferResult>.Failure( added.Error!.Code, added.Error.Message );

		var removed = Remove( source, item.Id );
		if ( removed.Failed )
			return OperationResult<InventoryTransferResult>.Failure( removed.Error!.Code, removed.Error.Message );

		return OperationResult<InventoryTransferResult>.Success( new InventoryTransferResult( removed.Value, added.Value ) );
	}

	public OperationResult<(int X, int Y)> FindFirstFit(
		InventoryRecord inventory,
		ItemRecord item,
		IReadOnlyDictionary<ItemId, DefinitionId>? stagedDefinitions = null )
	{
		if ( !_catalog.TryGetShape( item.Definition, out var shape ) )
			return OperationResult<(int X, int Y)>.Failure( ErrorCode.UnknownDefinition, $"Unknown item definition '{item.Definition}'." );

		for ( var y = 0; y <= inventory.Height - shape.Height; y++ )
		{
			for ( var x = 0; x <= inventory.Width - shape.Width; x++ )
			{
				if ( CanFit( inventory, new InventoryGridPosition( x, y ), shape, null, stagedDefinitions ) )
					return OperationResult<(int X, int Y)>.Success( (x, y) );
			}
		}

		return OperationResult<(int X, int Y)>.Failure( ErrorCode.Conflict, "Inventory has no space for the item." );
	}

	private bool CanFit(
		InventoryRecord inventory,
		InventoryGridPosition position,
		ItemShape shape,
		ItemId? excludedItem,
		IReadOnlyDictionary<ItemId, DefinitionId>? stagedDefinitions )
	{
		if ( !InventoryGeometry.Fits( inventory.GridSize, position, shape.GridSize ) )
			return false;
		var requested = new InventoryRectangle( position, shape.GridSize );

		foreach ( var placement in inventory.Placements )
		{
			if ( excludedItem is not null && placement.ItemId == excludedItem.Value ) continue;
			// A committed inventory cannot contain an item with an unknown definition. The
			// aggregate validator catches this before persistence; fail closed here as well.
			ItemShape placedShape;
			if ( stagedDefinitions is not null && stagedDefinitions.TryGetValue( placement.ItemId, out var stagedDefinition ) )
			{
				if ( !_catalog.TryGetShape( stagedDefinition, out placedShape ) ) return false;
			}
			else if ( !_catalog.TryGetShape( placement.ItemId, out placedShape ) )
			{
				return false;
			}
			var occupied = new InventoryRectangle( placement.Position, placedShape.GridSize );
			if ( requested.Overlaps( occupied ) ) return false;
		}

		return true;
	}

}