Implement pure C# magnet puzzle rules and save validation
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user