Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions S1API.Tests/Items/FurnitureApiCompatibilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ public void FurnitureBuilderExposesRuntimeAgnosticFluentSurface()

Assert.NotNull(createBuilder);
Assert.Equal(typeof(FurnitureDefinitionBuilder), createBuilder!.ReturnType);
AssertCreatorCloneOverload(typeof(string), "sourceItemId");
AssertCreatorCloneOverload(typeof(BuildableItemDefinition), "source");
AssertFluent(nameof(FurnitureDefinitionBuilder.WithBasicInfo), typeof(string), typeof(string), typeof(string));
AssertFluent(nameof(FurnitureDefinitionBuilder.WithModel), typeof(GameObject));
AssertFluent(nameof(FurnitureDefinitionBuilder.ConfigureModel), typeof(Action<GameObject>));
AssertFluent(nameof(FurnitureDefinitionBuilder.WithPlacement), typeof(FurniturePlacementMode));
AssertFluent(nameof(FurnitureDefinitionBuilder.WithFootprint), typeof(int), typeof(int));
AssertFluent(nameof(FurnitureDefinitionBuilder.WithSurfacePlacement), typeof(FurnitureSurfaceType), typeof(bool));
Expand Down Expand Up @@ -72,6 +75,24 @@ public void ModelAndIconRejectNull()

Assert.Throws<ArgumentNullException>(() => builder.WithModel(null!));
Assert.Throws<ArgumentNullException>(() => builder.WithIcon(null!));
Assert.Throws<ArgumentNullException>(() => builder.ConfigureModel(null!));
}

[Fact]
public void ConfigureModelRejectsCreateBuilderPath()
{
FurnitureDefinitionBuilder builder = FurnitureCreator.CreateBuilder();

Assert.Throws<InvalidOperationException>(
() => builder.ConfigureModel(_ => { }));
}

[Fact]
public void CloneFromRejectsInvalidPublicInputsBeforeNativeResolution()
{
Assert.Throws<ArgumentException>(() => FurnitureCreator.CloneFrom(" "));
Assert.Throws<ArgumentNullException>(
() => FurnitureCreator.CloneFrom((BuildableItemDefinition)null!));
}

[Fact]
Expand Down Expand Up @@ -101,6 +122,31 @@ public void BuildSoundRejectsUnknownValue()
() => builder.WithBuildSound((BuildSoundType)int.MaxValue));
}

[Theory]
[InlineData(0, BuildSoundType.Cardboard)]
[InlineData(1, BuildSoundType.Wood)]
[InlineData(2, BuildSoundType.Metal)]
public void NativeBuildSoundsMapBackToPublicValues(int nativeValue, BuildSoundType soundType)
{
#if IL2CPPMELON
var native = (Il2CppScheduleOne.ItemFramework.BuildableItemDefinition.EBuildSoundType)nativeValue;
#else
var native = (ScheduleOne.ItemFramework.BuildableItemDefinition.EBuildSoundType)nativeValue;
#endif
Assert.Equal(soundType, FurnitureBuildSoundMapper.FromNative(native));
}

private static void AssertCreatorCloneOverload(Type parameterType, string parameterName)
{
MethodInfo? method = typeof(FurnitureCreator).GetMethod(
nameof(FurnitureCreator.CloneFrom),
new[] { parameterType });

Assert.NotNull(method);
Assert.Equal(typeof(FurnitureDefinitionBuilder), method!.ReturnType);
Assert.Equal(parameterName, Assert.Single(method.GetParameters()).Name);
}

private static void AssertFluent(string name, params Type[] parameterTypes)
{
MethodInfo? method = typeof(FurnitureDefinitionBuilder).GetMethod(name, parameterTypes);
Expand Down
19 changes: 19 additions & 0 deletions S1API.Tests/Items/FurnitureApiCompileFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,23 @@ internal static FurnitureDefinitionBuilder Configure(GameObject model, Sprite ic
.WithStackLimit(4)
.WithIcon(icon);
}

internal static FurnitureDefinitionBuilder ConfigureNativeVariant(
string donorId,
Action<GameObject> configure)
{
return FurnitureCreator.CloneFrom(donorId)
.WithBasicInfo("example.mod:blue-chair", "Blue Chair", "A recolored chair.")
.ConfigureModel(configure)
.WithGeneratedIcon();
}

internal static FurnitureDefinitionBuilder ConfigureNativeVariant(
BuildableItemDefinition donor,
Action<GameObject> configure)
{
return FurnitureCreator.CloneFrom(donor)
.WithBasicInfo("example.mod:green-chair", "Green Chair", "Another recolored chair.")
.ConfigureModel(configure);
}
}
109 changes: 109 additions & 0 deletions S1API.Tests/Items/FurnitureClonePolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using S1API.Internal.Building;
using S1API.Items.Buildable;
using UnityEngine;

namespace S1API.Tests.Items;

public sealed class FurnitureClonePolicyTests
{
[Theory]
[InlineData("couch", "couch")]
[InlineData("Couch", "couch")]
public void VariantRejectsDonorIdReuse(string itemId, string donorId)
{
Assert.Throws<InvalidOperationException>(
() => FurnitureClonePolicy.ValidateNewId(itemId, donorId));
}

[Fact]
public void VariantAcceptsNewStableId()
{
FurnitureClonePolicy.ValidateNewId("example.mod:blue-couch", "couch");
}

[Fact]
public void CreateBuilderHasNoDonorIdentityConstraint()
{
FurnitureClonePolicy.ValidateNewId("example.mod:chair", donorId: null);
}

[Fact]
public void CloneBuilderLeavesIdentityUnsetAndCopiesSafeDefaults()
{
var footprint = new[]
{
new FurnitureFootprintCoordinate(0, 0),
new FurnitureFootprintCoordinate(1, 0),
};
Sprite icon = TestObjectFactory.CreateUninitialized<Sprite>();
var source = new FurnitureCloneSource(
"native-chair",
model: null!,
FurniturePlacementMode.Grid,
footprint,
FurnitureSurfaceType.Roof,
allowSurfaceRotation: false,
BuildSoundType.Metal,
stackLimit: 4,
purchasePrice: 125f,
resellMultiplier: 0.25f,
icon);

var builder = new FurnitureDefinitionBuilder(source);

Assert.Null(GetField<string?>(builder, "_id"));
Assert.Equal("native-chair", GetField<string>(builder, "_donorId"));
Assert.Same(footprint, GetField<object>(builder, "_donorFootprint"));
Assert.Equal(FurniturePlacementMode.Grid, GetField<FurniturePlacementMode>(builder, "_placementMode"));
Assert.Equal(2, GetField<int>(builder, "_footprintWidth"));
Assert.Equal(1, GetField<int>(builder, "_footprintDepth"));
Assert.Equal(FurnitureSurfaceType.Roof, GetField<FurnitureSurfaceType>(builder, "_surfaceTypes"));
Assert.False(GetField<bool>(builder, "_allowSurfaceRotation"));
Assert.Equal(BuildSoundType.Metal, GetField<BuildSoundType>(builder, "_buildSound"));
Assert.Equal(4, GetField<int>(builder, "_stackLimit"));
Assert.Equal(125f, GetField<float>(builder, "_purchasePrice"));
Assert.Equal(0.25f, GetField<float>(builder, "_resellMultiplier"));
Assert.Same(icon, GetField<Sprite>(builder, "_fallbackIcon"));
Assert.True(GetField<bool>(builder, "_generateIcon"));
Assert.False(GetField<bool>(builder, "_centerModelOnFootprint"));
Assert.True(GetField<bool>(builder, "_isolateRepresentationMaterials"));
}

[Fact]
public void ExplicitPlacementOverridesReplaceDonorDefaults()
{
var footprint = new[] { new FurnitureFootprintCoordinate(0, 0) };
var source = new FurnitureCloneSource(
"native-picture",
model: null!,
FurniturePlacementMode.Surface,
footprint,
FurnitureSurfaceType.Wall,
allowSurfaceRotation: true,
BuildSoundType.Wood,
stackLimit: 1,
purchasePrice: 1f,
resellMultiplier: 0.5f,
icon: null);
var builder = new FurnitureDefinitionBuilder(source)
.WithPlacement(FurniturePlacementMode.Grid)
.WithFootprint(3, 2)
.WithFootprint(4, 3)
.WithSurfacePlacement(FurnitureSurfaceType.Roof, allowRotation: false);

Assert.Equal(FurniturePlacementMode.Grid, GetField<FurniturePlacementMode>(builder, "_placementMode"));
Assert.Null(GetField<object?>(builder, "_donorFootprint"));
Assert.Equal(4, GetField<int>(builder, "_footprintWidth"));
Assert.Equal(3, GetField<int>(builder, "_footprintDepth"));
Assert.Equal(FurnitureSurfaceType.Roof, GetField<FurnitureSurfaceType>(builder, "_surfaceTypes"));
Assert.False(GetField<bool>(builder, "_allowSurfaceRotation"));
}

private static T GetField<T>(FurnitureDefinitionBuilder builder, string name)
{
object? value = typeof(FurnitureDefinitionBuilder)
.GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(builder);
return (T)value!;
}
}
12 changes: 12 additions & 0 deletions S1API/Internal/Building/FurnitureBuildSoundMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,17 @@ internal static S1ItemFramework.BuildableItemDefinition.EBuildSoundType ToNative
_ => throw new ArgumentOutOfRangeException(nameof(soundType)),
};
}

internal static BuildSoundType FromNative(
S1ItemFramework.BuildableItemDefinition.EBuildSoundType soundType)
{
return soundType switch
{
S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Cardboard => BuildSoundType.Cardboard,
S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Wood => BuildSoundType.Wood,
S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Metal => BuildSoundType.Metal,
_ => throw new ArgumentOutOfRangeException(nameof(soundType)),
};
}
}
}
17 changes: 17 additions & 0 deletions S1API/Internal/Building/FurnitureClonePolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;

namespace S1API.Internal.Building
{
internal static class FurnitureClonePolicy
{
internal static void ValidateNewId(string itemId, string? donorId)
{
if (donorId != null &&
string.Equals(itemId, donorId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"A furniture variant must use a new stable item ID.");
}
}
}
}
62 changes: 62 additions & 0 deletions S1API/Internal/Building/FurnitureCloneSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Collections.Generic;
using S1API.Items.Buildable;
using UnityEngine;

namespace S1API.Internal.Building
{
/// <summary>
/// Builder-owned presentation and safe scalar defaults extracted from a furniture donor.
/// </summary>
internal sealed class FurnitureCloneSource
{
internal FurnitureCloneSource(
string donorId,
GameObject model,
FurniturePlacementMode placementMode,
IReadOnlyList<FurnitureFootprintCoordinate>? footprint,
FurnitureSurfaceType surfaceTypes,
bool allowSurfaceRotation,
BuildSoundType buildSound,
int stackLimit,
float purchasePrice,
float resellMultiplier,
Sprite? icon)
{
DonorId = donorId;
Model = model;
PlacementMode = placementMode;
Footprint = footprint;
SurfaceTypes = surfaceTypes;
AllowSurfaceRotation = allowSurfaceRotation;
BuildSound = buildSound;
StackLimit = stackLimit;
PurchasePrice = purchasePrice;
ResellMultiplier = resellMultiplier;
Icon = icon;
}

internal string DonorId { get; }
internal GameObject Model { get; }
internal FurniturePlacementMode PlacementMode { get; }
internal IReadOnlyList<FurnitureFootprintCoordinate>? Footprint { get; }
internal FurnitureSurfaceType SurfaceTypes { get; }
internal bool AllowSurfaceRotation { get; }
internal BuildSoundType BuildSound { get; }
internal int StackLimit { get; }
internal float PurchasePrice { get; }
internal float ResellMultiplier { get; }
internal Sprite? Icon { get; }
}

internal readonly struct FurnitureFootprintCoordinate
{
internal FurnitureFootprintCoordinate(int x, int y)
{
X = x;
Y = y;
}

internal int X { get; }
internal int Y { get; }
}
}
18 changes: 14 additions & 4 deletions S1API/Internal/Building/FurnitureIconRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,17 @@ internal static class FurnitureIconRuntime
internal static void Queue(
BuildableItemDefinition definition,
Transform model,
int resolution)
int resolution,
bool isolateMaterials)
{
bool startProcessor = false;
lock (Gate)
{
Pending.Enqueue(new Request(definition, model, resolution));
Pending.Enqueue(new Request(
definition,
model,
resolution,
isolateMaterials));
if (!_processing)
{
_processing = true;
Expand Down Expand Up @@ -183,7 +188,9 @@ private static bool TryCreatePreview(
return false;
}

iconModel = InactiveObjectCloner.CloneGameObject(request.Model.gameObject);
iconModel = request.IsolateMaterials
? FurnitureVisualCloner.CloneOwnedVisual(request.Model.gameObject)
: InactiveObjectCloner.CloneGameObject(request.Model.gameObject);
if (iconModel == null)
{
failure = "the source model could not be cloned";
Expand Down Expand Up @@ -287,16 +294,19 @@ private sealed class Request
internal Request(
BuildableItemDefinition definition,
Transform model,
int resolution)
int resolution,
bool isolateMaterials)
{
Definition = definition;
Model = model;
Resolution = resolution;
IsolateMaterials = isolateMaterials;
}

internal BuildableItemDefinition Definition { get; }
internal Transform Model { get; }
internal int Resolution { get; }
internal bool IsolateMaterials { get; }
}
}
}
Loading
Loading