Implement pure C# magnet puzzle rules and save validation

This commit is contained in:
NINI
2026-10-05 00:37:43 +09:00
parent fdd219660b
commit 92243fccb6
10 changed files with 771 additions and 0 deletions
@@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MagnetismHeroes.Core;
using NUnit.Framework;
namespace MagnetismHeroes.Tests.EditMode.Core
{
public sealed class BoardRulesTests
{
private static StageDefinition Composition(int[] initial, int[] target) => new StageDefinition(6, StageKind.Composition, initial, target);
private static StageDefinition Counting(int total) => new StageDefinition(total, StageKind.Counting, Enumerable.Repeat(1, total).ToArray(), new[] { total });
private static string Signature(BoardRules board) => string.Join(";", board.Groups.Select(g => g.id + ":" + string.Join(",", g.ballIds)))
+ "|" + string.Join(";", board.Balls.Select(b => b.id + ":" + b.position.x + "," + b.position.y + "," + b.position.z))
+ "|" + string.Join(",", board.CollectedBallIds) + "|" + string.Join(",", board.CountedBallIds);
[TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] [TestCase(5)]
[TestCase(6)] [TestCase(7)] [TestCase(8)] [TestCase(9)] [TestCase(10)]
public void EveryProperSubsetProducesExactlyTwoGroupsAndPreservesAllIds(int total)
{
for (var mask = 1; mask < (1 << total) - 1; mask++)
{
var board = new BoardRules(Composition(new[] { total }, new[] { total }));
var ids = board.Balls.Select(b => b.id).ToArray();
var selected = ids.Where((id, index) => (mask & (1 << index)) != 0).ToArray();
var result = board.TrySplit(board.Groups[0].id, selected, "selected");
Assert.That(result.Accepted, Is.True, $"N={total}, mask={mask}");
Assert.That(board.Groups.Length, Is.EqualTo(2));
Assert.That(board.Groups.Single(g => g.id == "selected").ballIds, Is.EquivalentTo(selected));
Assert.That(board.Groups.Single(g => g.id != "selected").ballIds, Is.EquivalentTo(ids.Except(selected)));
Assert.That(board.Groups.SelectMany(g => g.ballIds), Is.EquivalentTo(ids));
Assert.That(board.Balls.Select(b => b.id), Is.EquivalentTo(ids));
}
}
[Test]
public void InvalidSplitsNeverMutateBoard()
{
var board = new BoardRules(Composition(new[] { 3, 1 }, new[] { 2, 2 }));
var group = board.Groups[0];
var other = board.Groups[1];
var before = Signature(board);
var attempts = new[] { Array.Empty<string>(), group.ballIds, new[] { group.ballIds[0], group.ballIds[0] },
new[] { other.ballIds[0] }, new[] { "missing" } };
foreach (var selection in attempts)
{
Assert.That(board.TrySplit(group.id, selection, "new").Accepted, Is.False);
Assert.That(Signature(board), Is.EqualTo(before));
}
Assert.That(board.TrySplit(group.id, null, "new").Accepted, Is.False);
Assert.That(board.TrySplit(group.id, new[] { group.ballIds[0] }, other.id).Accepted, Is.False);
Assert.That(board.TrySplit(other.id, other.ballIds, "new").Accepted, Is.False);
Assert.That(Signature(board), Is.EqualTo(before));
}
[Test]
public void GoalUsesAllGroupsAsMultisetAndIgnoresShape()
{
var board = new BoardRules(Composition(new[] { 4 }, new[] { 2, 2 }));
Assert.That(board.IsSolved, Is.False);
var ids = board.Balls.Select(b => b.id).ToArray();
Assert.That(board.TrySplit(board.Groups[0].id, new[] { ids[0], ids[3] }, "two").Accepted, Is.True);
Assert.That(board.IsSolved, Is.True);
board.TryMoveGroup("two", new Position3(5, -3, 10));
Assert.That(board.IsSolved, Is.True);
board.TrySplit("two", new[] { ids[0] }, "one");
Assert.That(board.IsSolved, Is.False);
}
[Test]
public void MergePreservesIdsAndRejectsSelfOrUnknownGroups()
{
var board = new BoardRules(Composition(new[] { 2, 3 }, new[] { 5 }));
var original = board.Balls.Select(b => b.id).ToArray();
var groups = board.Groups;
var before = Signature(board);
Assert.That(board.TryMerge(groups[0].id, groups[0].id).Accepted, Is.False);
Assert.That(board.TryMerge(groups[0].id, "missing").Accepted, Is.False);
Assert.That(Signature(board), Is.EqualTo(before));
Assert.That(board.TryMerge(groups[0].id, groups[1].id).Accepted, Is.True);
Assert.That(board.Groups.Single().ballIds, Is.EquivalentTo(original));
Assert.That(board.BallCount, Is.EqualTo(5));
Assert.That(board.IsSolved, Is.True);
}
[Test]
public void CountingOneRequiresDragBeforeFirstCountAndDuplicateTapIsRejected()
{
var board = new BoardRules(Counting(1));
var ballId = board.Balls[0].id;
Assert.That(board.CanCount, Is.False);
Assert.That(board.TryCount(ballId).Accepted, Is.False);
Assert.That(board.TryMoveGroup(board.Groups[0].id, new Position3(0, 0, 0)).Accepted, Is.False);
Assert.That(board.IsSolved, Is.False);
Assert.That(board.TryMoveGroup(board.Groups[0].id, new Position3(1, 0, 0)).Accepted, Is.True);
Assert.That(board.CanCount, Is.True);
Assert.That(board.TryCount(ballId).Count, Is.EqualTo(1));
Assert.That(board.IsSolved, Is.True);
Assert.That(board.TryCount(ballId).Reason, Is.EqualTo(RejectionReason.AlreadyCounted));
Assert.That(board.CountedCount, Is.EqualTo(1));
}
[Test]
public void CollectedAndCountedIdsSurviveSplitMergeMoveAndRestore()
{
var definition = Counting(3);
var board = new BoardRules(definition);
var initial = board.Groups;
board.TryMerge(initial[0].id, initial[1].id);
Assert.That(board.CollectedBallIds.Length, Is.EqualTo(2));
Assert.That(board.TryCount(board.Balls[0].id).Accepted, Is.False);
board.TryMerge(initial[0].id, initial[2].id);
var firstId = board.Balls[0].id;
Assert.That(board.TryCount(firstId).Count, Is.EqualTo(1));
board.TrySplit(board.Groups[0].id, new[] { firstId }, "selected");
Assert.That(board.CanCount, Is.False);
Assert.That(board.TryCount(board.Balls[1].id).Accepted, Is.False);
board.TryMoveGroup("selected", new Position3(2, 1, -3));
var snapshot = board.ToPuzzleState(new CameraState());
var restored = new BoardRules(definition, snapshot);
restored.TryMerge(restored.Groups[0].id, "selected");
Assert.That(restored.CollectedBallIds.Length, Is.EqualTo(3));
Assert.That(restored.CountedBallIds, Is.EquivalentTo(new[] { firstId }));
Assert.That(restored.TryCount(firstId).Accepted, Is.False);
Assert.That(restored.TryCount(restored.Balls[1].id).Count, Is.EqualTo(2));
Assert.That(restored.IsSolved, Is.False);
Assert.That(restored.TryCount(restored.Balls[2].id).Count, Is.EqualTo(3));
Assert.That(restored.IsSolved, Is.True);
}
[TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] [TestCase(5)]
[TestCase(6)] [TestCase(7)] [TestCase(8)] [TestCase(9)] [TestCase(10)]
public void CountingEveryValueRequiresEveryCollectedBallToBeCountedOnce(int total)
{
var board = new BoardRules(Counting(total));
var groups = board.Groups;
board.TryMoveGroup(groups[0].id, new Position3(1, 0, 0));
for (var i = 1; i < groups.Length; i++) board.TryMerge(groups[0].id, groups[i].id);
var balls = board.Balls;
Assert.That(board.CanCount, Is.True);
Assert.That(board.IsSolved, Is.False);
for (var i = 0; i < total; i++)
{
Assert.That(board.TryCount(balls[i].id).Count, Is.EqualTo(i + 1));
Assert.That(board.TryCount(balls[i].id).Accepted, Is.False);
Assert.That(board.IsSolved, Is.EqualTo(i + 1 == total));
}
Assert.That(board.TryCount("missing").Accepted, Is.False);
Assert.That(board.CountedCount, Is.EqualTo(total));
}
[Test]
public void SupplyIsLockedInBothPuzzleKindsAndFreePlayAllowsZeroThroughTen()
{
foreach (var board in new[] { new BoardRules(Counting(1)), new BoardRules(Composition(new[] { 1 }, new[] { 1 })) })
{
Assert.That(board.TrySpawn("new", "group", new Position3()).Reason, Is.EqualTo(RejectionReason.PuzzleSupplyLocked));
Assert.That(board.TryReturn(board.Groups[0].id).Reason, Is.EqualTo(RejectionReason.PuzzleSupplyLocked));
}
var free = BoardRules.CreateFreePlay(new FreePlayState { sourceStageId = 1 });
Assert.That(free.BallCount, Is.Zero);
for (var i = 0; i < 10; i++) Assert.That(free.TrySpawn("ball" + i, "group" + i, new Position3()).Accepted, Is.True);
var before = Signature(free);
Assert.That(free.TrySpawn("extra", "extra", new Position3()).Reason, Is.EqualTo(RejectionReason.CapacityReached));
Assert.That(Signature(free), Is.EqualTo(before));
free.TryMerge("group0", "group1");
Assert.That(free.TryReturn("group0").Accepted, Is.True);
Assert.That(free.BallCount, Is.EqualTo(8));
Assert.That(free.TrySpawn("new", "new", new Position3()).Accepted, Is.True);
foreach (var group in free.Groups) free.TryReturn(group.id);
Assert.That(free.BallCount, Is.Zero);
Assert.That(free.Groups, Is.Empty);
Assert.That(free.IsSolved, Is.False);
Assert.That(free.TrySpawn("again", "again", new Position3()).Accepted, Is.True);
}
[Test]
public void DuplicateSpawnAndInvalidCoordinatesAreRejectedAtomically()
{
var free = BoardRules.CreateFreePlay(new FreePlayState { sourceStageId = 1 });
free.TrySpawn("ball", "group", new Position3(float.MaxValue, 0, 0));
var before = Signature(free);
Assert.That(free.TrySpawn("ball", "other", new Position3()).Accepted, Is.False);
Assert.That(free.TrySpawn("other", "group", new Position3()).Accepted, Is.False);
Assert.That(free.TrySpawn("invalid", "invalid", new Position3(float.NaN, 0, 0)).Accepted, Is.False);
Assert.That(free.TryMoveGroup("group", new Position3(float.MaxValue, 0, 0)).Accepted, Is.False);
Assert.That(free.TryMoveGroup("group", new Position3(0, float.PositiveInfinity, 0)).Accepted, Is.False);
Assert.That(Signature(free), Is.EqualTo(before));
}
[Test]
public void AGroupMoveThatOverflowsALaterBallCannotChangeEarlierBallsOrCollectThem()
{
var definition = new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 },
new[] { new Position3(), new Position3(float.MaxValue, 0, 0) });
var state = new BoardRules(definition).ToPuzzleState(new CameraState());
// A restored counting group can have no committed collection yet, so failed movement must not collect it.
state.groups = new[] { new GroupState { id = "group", ballIds = state.balls.Select(b => b.id).ToArray() } };
var board = new BoardRules(definition, state);
var before = Signature(board);
Assert.That(board.TryMoveGroup("group", new Position3(float.MaxValue, 0, 0)).Accepted, Is.False);
Assert.That(Signature(board), Is.EqualTo(before));
Assert.That(board.CanCount, Is.False);
}
[Test]
public void DefinitionsInputsAndReturnedSnapshotsCannotMutateOwnedBoard()
{
var initial = new[] { 2, 1 };
var target = new[] { 3 };
var positions = new[] { new Position3(1, 2, 3), new Position3(), new Position3() };
var definition = new StageDefinition(6, StageKind.Composition, initial, target, positions);
initial[0] = 10; target[0] = 1; positions[0] = new Position3(100, 0, 0);
var board = new BoardRules(definition);
var before = Signature(board);
var balls = board.Balls; balls[0].id = "changed"; balls[0].position = new Position3(8, 8, 8);
var groups = board.Groups; groups[0].ballIds[0] = "changed"; groups[0].id = "changed";
var camera = new CameraState();
var state = board.ToPuzzleState(camera);
var restored = new BoardRules(definition, state);
state.balls[0].position = new Position3(9, 9, 9); state.groups[0].ballIds[0] = "bad"; camera.distance = -1;
Assert.That(Signature(board), Is.EqualTo(before));
Assert.That(Signature(restored), Is.EqualTo(before));
Assert.That(board.Balls[0].position.x, Is.EqualTo(1));
}
[Test]
public void InvalidDefinitionsAreRejected()
{
Assert.Throws<ArgumentException>(() => new StageDefinition(0, StageKind.Composition, new[] { 1 }, new[] { 1 }));
Assert.Throws<ArgumentException>(() => Composition(new[] { 0, 2 }, new[] { 2 }));
Assert.Throws<ArgumentException>(() => Composition(new[] { 11 }, new[] { 11 }));
Assert.Throws<ArgumentException>(() => Composition(new[] { 2 }, new[] { 3 }));
Assert.Throws<ArgumentException>(() => new StageDefinition(1, StageKind.Counting, new[] { 2 }, new[] { 2 }));
Assert.Throws<ArgumentException>(() => new StageDefinition(1, StageKind.Counting, new[] { 1, 1 }, new[] { 1, 1 }));
Assert.Throws<ArgumentException>(() => new StageDefinition(1, (StageKind)10, new[] { 1 }, new[] { 1 }));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9a9e3b7c7f3f39e42838d06b916e58e3
@@ -0,0 +1,122 @@
using System;
using MagnetismHeroes.Core;
using NUnit.Framework;
namespace MagnetismHeroes.Tests.EditMode.Core
{
public sealed class SaveValidatorTests
{
private static readonly StageDefinition Count = new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 });
private static readonly StageDefinition Compose = new StageDefinition(6, StageKind.Composition, new[] { 2 }, new[] { 1, 1 });
private static readonly StageDefinition[] Definitions = { Count, Compose };
private static ProgressSave Valid() => new ProgressSave { puzzleStates = new[] { new BoardRules(Count).ToPuzzleState(new CameraState()) } };
[Test]
public void ValidPuzzleAndEmptyFreePlayAreAccepted()
{
var save = Valid();
save.completedStageIds = new[] { 2 };
save.seenTutorialIds = new[] { "merge", "split" };
save.latestFreePlay = new FreePlayState { sourceStageId = 2 };
Assert.That(SaveValidator.TryValidate(save, Definitions, out var error), Is.True, error);
}
[Test]
public void SchemaMissingCollectionsInvalidStagesAndDuplicateRecordsAreRejected()
{
Assert.That(SaveValidator.TryValidate(null, Definitions, out _), Is.False);
var save = Valid(); save.schemaVersion = 2;
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
save = Valid(); save.completedStageIds = new[] { 2, 2 };
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
save = Valid(); save.completedStageIds = new[] { 31 };
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
save = Valid(); save.seenTutorialIds = new[] { "unknown" };
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
save = Valid(); save.seenTutorialIds = new[] { "merge", "merge" };
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
save = Valid(); save.puzzleStates = new[] { save.puzzleStates[0], save.puzzleStates[0] };
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
save = Valid(); save.puzzleStates = null;
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
save = Valid(); save.puzzleStates[0].stageId = 3;
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
}
[Test]
public void InvalidMembershipCoordinatesCameraAndCountingRecordsRejectWholeSave()
{
Action<PuzzleState>[] corruptions = {
s => s.balls[1].id = s.balls[0].id,
s => s.groups[1].id = s.groups[0].id,
s => s.groups[0].ballIds = Array.Empty<string>(),
s => s.groups[0].ballIds = new[] { "missing" },
s => s.groups[1].ballIds = s.groups[0].ballIds,
s => s.groups = new[] { s.groups[0] },
s => s.balls[0].position.x = float.NaN,
s => s.balls = new[] { s.balls[0] },
s => s.collectedBallIds = new[] { "missing" },
s => s.collectedBallIds = new[] { s.balls[0].id, s.balls[0].id },
s => s.countedBallIds = new[] { s.balls[0].id },
s => { s.collectedBallIds = new[] { s.balls[0].id }; s.countedBallIds = new[] { s.balls[0].id, s.balls[0].id }; },
s => s.camera = null,
s => s.camera.distance = 0,
s => s.camera.distance = float.PositiveInfinity,
s => s.camera.target.z = float.NegativeInfinity,
s => s.camera.rotation = new Rotation4(0, 0, 0, 0),
s => s.collectedBallIds = null
};
foreach (var corrupt in corruptions)
{
var save = Valid(); corrupt(save.puzzleStates[0]);
Assert.That(SaveValidator.TryValidate(save, Definitions, out var error), Is.False);
Assert.That(error, Is.Not.Null.And.Not.Empty);
}
}
[Test]
public void CompositionCannotHaveCollectionOrCountingRecords()
{
var state = new BoardRules(Compose).ToPuzzleState(new CameraState());
state.collectedBallIds = new[] { state.balls[0].id };
Assert.That(SaveValidator.TryValidatePuzzle(state, Compose, out _), Is.False);
}
[Test]
public void FreePlaySourceMustBeCompletedAndEmptyBoardCannotContainGroups()
{
var save = Valid(); save.latestFreePlay = new FreePlayState { sourceStageId = 2 };
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
save.completedStageIds = new[] { 2 };
save.latestFreePlay.groups = new[] { new GroupState { id = "empty" } };
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
}
[Test]
public void FreePlayCannotRestoreElevenBallsAndRestoreRejectsInvalidInput()
{
var balls = new BallState[11];
var groups = new GroupState[11];
for (var i = 0; i < 11; i++)
{
balls[i] = new BallState { id = "ball" + i };
groups[i] = new GroupState { id = "group" + i, ballIds = new[] { balls[i].id } };
}
var state = new FreePlayState { sourceStageId = 2, balls = balls, groups = groups };
Assert.That(SaveValidator.TryValidateFreePlay(state, out _), Is.False);
Assert.Throws<ArgumentException>(() => BoardRules.CreateFreePlay(state));
var puzzle = Valid().puzzleStates[0]; puzzle.groups[0].ballIds[0] = "missing";
Assert.Throws<ArgumentException>(() => new BoardRules(Count, puzzle));
}
[Test]
public void CompletedStageIdsCannotDecreaseOnUpdateButReplayPuzzleIsAllowed()
{
var previous = Valid(); previous.completedStageIds = new[] { 2, 6 };
var next = Valid(); next.completedStageIds = new[] { 6 };
Assert.That(SaveValidator.TryValidateUpdate(previous, next, Definitions, out _), Is.False);
next.completedStageIds = new[] { 6, 2 };
Assert.That(SaveValidator.TryValidateUpdate(previous, next, Definitions, out var error), Is.True, error);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8413b204741df8f408785b39e1bbd68e