Add atomic composition controls and representative stages

This commit is contained in:
NINI
2026-10-05 05:03:40 +09:00
parent 1e7c258631
commit 4c411e446d
56 changed files with 5911 additions and 1601 deletions
@@ -0,0 +1,52 @@
using System.Linq;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Bootstrap;
using MagnetismHeroes.UnityRuntime.StageAssets;
using MagnetismHeroes.UnityRuntime.Presentation;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace MagnetismHeroes.Tests
{
public sealed class CompositionAssetTests
{
[TestCase(6, new[] { 1, 1 }, new[] { 2 })]
[TestCase(10, new[] { 2 }, new[] { 1, 1 })]
[TestCase(14, new[] { 3, 1 }, new[] { 2, 2 })]
public void InspectorStageMatchesRepresentativeRule(int id, int[] initial, int[] target)
{
var asset = AssetDatabase.LoadAssetAtPath<StageAsset>($"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset");
Assert.That(asset, Is.Not.Null); var definition = asset.ToDefinition();
Assert.That(definition.Id, Is.EqualTo(id)); Assert.That(definition.Kind, Is.EqualTo(StageKind.Composition));
Assert.That(definition.InitialGroups, Is.EqualTo(initial)); Assert.That(definition.TargetGroups, Is.EqualTo(target));
Assert.That(definition.BallPositions.Count, Is.EqualTo(initial.Sum()));
}
[Test] public void AppCatalogControlsAndDraftGuidesAreConnectedWithoutUnlockBypass()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
var serialized = new SerializedObject(prefab.GetComponent<GameApp>()); var stages = serialized.FindProperty("_stages");
Assert.That(stages.arraySize, Is.EqualTo(4)); var definitions = Enumerable.Range(0, stages.arraySize)
.Select(index => ((StageAsset)stages.GetArrayElementAtIndex(index).objectReferenceValue).ToDefinition()).ToArray();
Assert.That(definitions.Select(stage => stage.Id), Is.EqualTo(new[] { 1, 6, 10, 14 }));
var session = new GameSession(definitions, new EmptyStore()); Assert.That(session.IsUnlocked(6), Is.False);
Assert.That(session.StartStage(6), Is.False); Assert.That(session.IsUnlocked(10), Is.False); Assert.That(session.IsUnlocked(14), Is.False);
Assert.That(serialized.FindProperty("_compositionButtons").arraySize, Is.EqualTo(3));
foreach (string field in new[] { "_splitButton", "_cancelSplitButton" }) Assert.That(serialized.FindProperty(field).objectReferenceValue, Is.Not.Null);
}
private sealed class EmptyStore : IProgressStore
{
public bool TryLoad(out ProgressSave save, out string error) { save = new ProgressSave(); error = null; return true; }
public bool TrySave(ProgressSave save, out string error) { error = null; return true; }
}
[Test] public void IdenticalRowsCannotHideEachOthersUnitSpheresAfterContactPlacement()
{
var row = new[] { new Vector3(-.5f, 0, 0), new Vector3(.5f, 0, 0) };
var shifted = new[] { row[0] - Vector3.right, row[1] - Vector3.right };
float correction = ContactPlacement.Clearance(shifted, row, Vector3.left);
Assert.That(correction, Is.EqualTo(1).Within(.0001f));
foreach (var moving in shifted) foreach (var target in row)
Assert.That(Vector3.Distance(moving + Vector3.left * correction, target), Is.GreaterThanOrEqualTo(.9999f));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 82f243b65ae87844cbc4a8fcd1b00d4a
@@ -0,0 +1,82 @@
using System.Linq;
using MagnetismHeroes.Core;
using NUnit.Framework;
namespace MagnetismHeroes.Tests
{
public sealed class CompositionSessionTests
{
private sealed class SpyStore : IProgressStore
{
public ProgressSave Saved = new ProgressSave { completedStageIds = Enumerable.Range(1, 13).ToArray() };
public int Writes;
public bool TryLoad(out ProgressSave save, out string error) { save = Saved; error = null; return true; }
public bool TrySave(ProgressSave save, out string error) { Saved = save; Writes++; error = null; return true; }
}
[Test] public void MoveAndMergeCompleteWithOneWriteAndPreserveEveryIdentity()
{
var store = new SpyStore { Saved = new ProgressSave { completedStageIds = Enumerable.Range(1, 5).ToArray() } };
var session = new GameSession(new[] { new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) }, store);
session.StartStage(6); var groups = session.Board.Groups; var ids = session.Board.Balls.Select(ball => ball.id).ToArray();
var writes = store.Writes;
var result = session.MoveAndMerge(groups[0].id, new Position3(0.4f, 0.2f, 1), groups[1].id);
Assert.That(result.Accepted, Is.True); Assert.That(store.Writes, Is.EqualTo(writes + 1));
Assert.That(session.Screen, Is.EqualTo(SessionScreen.FreePlay));
Assert.That(session.Board.Groups.Single().ballIds, Is.EquivalentTo(ids));
Assert.That(store.Saved.puzzleStates, Is.Empty); Assert.That(store.Saved.latestFreePlay, Is.Not.Null);
Assert.That(store.Saved.completedStageIds, Is.EqualTo(Enumerable.Range(1, 6).ToArray()));
}
[Test] public void UnknownMergeTargetDoesNotMoveOrWrite()
{
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) }, store);
session.StartStage(6); var original = session.Board.Balls; var writes = store.Writes;
Assert.That(session.MoveAndMerge(session.Board.Groups[0].id, new Position3(1, 0, 0), "missing").Accepted, Is.False);
Assert.That(session.Board.Balls.Select(ball => ball.position), Is.EqualTo(original.Select(ball => ball.position)));
Assert.That(store.Writes, Is.EqualTo(writes));
}
[TestCase(0)] [TestCase(3)] public void EmptyAndEntireSplitKeepStateAndSave(int count)
{
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(14, StageKind.Composition, new[] { 3, 1 }, new[] { 2, 2 }) }, store);
session.StartStage(14); var group = session.Board.Groups[0]; var writes = store.Writes; var original = session.Board.Balls;
Assert.That(session.SplitAndMove(group.id, group.ballIds.Take(count).ToArray(), "new", new Position3(1, 1, 0)).Accepted, Is.False);
Assert.That(store.Writes, Is.EqualTo(writes)); Assert.That(session.Board.Groups.Length, Is.EqualTo(2));
Assert.That(session.Board.Balls.Select(ball => ball.position), Is.EqualTo(original.Select(ball => ball.position)));
}
[Test] public void DistantSelectionProducesExactlyTwoFriendsAndFinalCompositionIgnoresHistory()
{
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(14, StageKind.Composition, new[] { 3, 1 }, new[] { 2, 2 }) }, store);
session.StartStage(14); var groups = session.Board.Groups;
session.MoveAndMerge(groups[0].id, new Position3(0.1f, 0, 0), groups[1].id);
var all = session.Board.Groups.Single(); var ids = all.ballIds.ToArray(); var writes = store.Writes;
Assert.That(session.SplitAndMove(all.id, new[] { ids[0], ids[3] }, "split-result", new Position3(3, 0, 0)).Accepted, Is.True);
Assert.That(store.Writes, Is.EqualTo(writes + 1)); Assert.That(session.Screen, Is.EqualTo(SessionScreen.FreePlay));
Assert.That(session.Board.Groups.Select(group => group.ballIds.Length), Is.EqualTo(new[] { 2, 2 }));
Assert.That(session.Board.Groups.SelectMany(group => group.ballIds), Is.EquivalentTo(ids));
}
[Test] public void SplitAndMergeKeepCollectedAndCountedIdsThroughPuzzleRestore()
{
var definition = new StageDefinition(1, StageKind.Counting, new[] { 1, 1, 1 }, new[] { 3 });
var store = new SpyStore(); var session = new GameSession(new[] { definition }, store); session.StartStage(1);
var groups = session.Board.Groups; var ids = session.Board.Balls.Select(ball => ball.id).ToArray();
foreach (var group in groups) session.MoveGroup(group.id, new Position3(0, 1, 0));
session.Merge(groups[0].id, groups[1].id); session.Merge(groups[0].id, groups[2].id); session.Count(ids[0]);
var writes = store.Writes;
Assert.That(session.SplitAndMove(groups[0].id, new[] { ids[0], ids[2] }, "detached", new Position3(2, 0, 0)).Accepted, Is.True);
Assert.That(store.Writes, Is.EqualTo(writes + 1)); Assert.That(session.Board.CountedBallIds, Is.EqualTo(new[] { ids[0] }));
var restored = new GameSession(new[] { definition }, store); restored.StartStage(1);
Assert.That(restored.Board.CollectedBallIds, Is.EquivalentTo(ids)); Assert.That(restored.Board.CountedBallIds, Is.EqualTo(new[] { ids[0] }));
Assert.That(restored.Board.Groups.Length, Is.EqualTo(2));
Assert.That(restored.MoveAndMerge("detached", new Position3(-1, 0, 0), groups[0].id).Accepted, Is.True);
Assert.That(restored.Board.CountedBallIds, Is.EqualTo(new[] { ids[0] })); Assert.That(restored.Board.BallCount, Is.EqualTo(3));
}
[Test] public void InvalidCoordinatesNeverPartiallySplitOrMerge()
{
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(14, StageKind.Composition, new[] { 3, 1 }, new[] { 2, 2 }) }, store);
session.StartStage(14); var groups = session.Board.Groups; var original = session.Board.Balls; var writes = store.Writes;
Assert.That(session.MoveAndMerge(groups[0].id, new Position3(float.NaN, 0, 0), groups[1].id).Accepted, Is.False);
Assert.That(session.SplitAndMove(groups[0].id, new[] { groups[0].ballIds[0] }, "invalid", new Position3(float.PositiveInfinity, 0, 0)).Accepted, Is.False);
Assert.That(store.Writes, Is.EqualTo(writes)); Assert.That(session.Board.Groups.Select(group => group.ballIds.Length), Is.EqualTo(new[] { 3, 1 }));
Assert.That(session.Board.Balls.Select(ball => ball.position), Is.EqualTo(original.Select(ball => ball.position)));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d9ca4f319c077954381f2f4da39614e0