diff --git a/Assets/MagnetismHeroes/Core/BoardRules.cs b/Assets/MagnetismHeroes/Core/BoardRules.cs new file mode 100644 index 0000000..49f3ad1 --- /dev/null +++ b/Assets/MagnetismHeroes/Core/BoardRules.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MagnetismHeroes.Core +{ + public sealed class BoardRules + { + private readonly Dictionary _balls = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary> _groups = new Dictionary>(StringComparer.Ordinal); + private readonly HashSet _collected = new HashSet(StringComparer.Ordinal); + private readonly HashSet _counted = new HashSet(StringComparer.Ordinal); + private readonly StageDefinition _definition; + + public BoardMode Mode { get; } + public int StageId { get; } + public int BallCount => _balls.Count; + public int CountedCount => _counted.Count; + public bool CanCount => Mode == BoardMode.Counting && _collected.Count == _balls.Count && _groups.Count == 1; + public bool IsSolved + { + get + { + if (Mode == BoardMode.FreePlay) return false; + if (Mode == BoardMode.Counting) return CanCount && _counted.Count == _definition.Total; + return _groups.Values.Select(ids => ids.Count).OrderBy(size => size) + .SequenceEqual(_definition.TargetGroups.OrderBy(size => size)); + } + } + // Detached snapshots keep callers from changing membership or committed positions. + public BallState[] Balls => _balls.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => new BallState { id = pair.Key, position = pair.Value }).ToArray(); + public GroupState[] Groups => _groups.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => new GroupState { id = pair.Key, ballIds = pair.Value.ToArray() }).ToArray(); + public string[] CollectedBallIds => _collected.OrderBy(id => id, StringComparer.Ordinal).ToArray(); + public string[] CountedBallIds => _counted.OrderBy(id => id, StringComparer.Ordinal).ToArray(); + + public BoardRules(StageDefinition definition, PuzzleState restored = null) + { + _definition = definition ?? throw new ArgumentNullException(nameof(definition)); + StageId = definition.Id; + Mode = definition.Kind == StageKind.Counting ? BoardMode.Counting : BoardMode.Composition; + if (restored != null) + { + if (!SaveValidator.TryValidatePuzzle(restored, definition, out var error)) throw new ArgumentException(error, nameof(restored)); + CopyBoard(restored.balls, restored.groups); + _collected.UnionWith(restored.collectedBallIds); + _counted.UnionWith(restored.countedBallIds); + return; + } + var ballIndex = 0; + for (var groupIndex = 0; groupIndex < definition.InitialGroups.Count; groupIndex++) + { + var group = new List(); + for (var i = 0; i < definition.InitialGroups[groupIndex]; i++) + { + var id = $"s{StageId:D2}-b{ballIndex + 1:D2}"; + _balls.Add(id, definition.BallPositions[ballIndex++]); + group.Add(id); + } + _groups.Add($"s{StageId:D2}-g{groupIndex + 1:D2}", group); + } + } + + private BoardRules(FreePlayState state) + { + if (!SaveValidator.TryValidateFreePlay(state, out var error)) throw new ArgumentException(error, nameof(state)); + Mode = BoardMode.FreePlay; + StageId = state.sourceStageId; + CopyBoard(state.balls, state.groups); + } + + public static BoardRules CreateFreePlay(FreePlayState state) => new BoardRules(state); + + public RuleResult TryMoveGroup(string groupId, Position3 delta) + { + if (!ValidId(groupId) || !_groups.TryGetValue(groupId, out var members)) return RuleResult.Reject(RejectionReason.UnknownId); + if (!delta.IsValid) return RuleResult.Reject(RejectionReason.InvalidPosition); + var positions = new Position3[members.Count]; + var moved = false; + for (var i = 0; i < members.Count; i++) + { + var current = _balls[members[i]]; + positions[i] = new Position3(current.x + delta.x, current.y + delta.y, current.z + delta.z); + if (!positions[i].IsValid) return RuleResult.Reject(RejectionReason.InvalidPosition); + moved |= positions[i].x != current.x || positions[i].y != current.y || positions[i].z != current.z; + } + if (!moved) return RuleResult.Reject(RejectionReason.NoChange); + for (var i = 0; i < members.Count; i++) _balls[members[i]] = positions[i]; + if (Mode == BoardMode.Counting) _collected.UnionWith(members); + return RuleResult.Success(); + } + + // Both groups participated in a committed drop, so all their balls are collected. + public RuleResult TryMerge(string firstGroupId, string secondGroupId) + { + if (!ValidId(firstGroupId) || !ValidId(secondGroupId) || !_groups.TryGetValue(firstGroupId, out var first) + || !_groups.TryGetValue(secondGroupId, out var second)) return RuleResult.Reject(RejectionReason.UnknownId); + if (firstGroupId == secondGroupId) return RuleResult.Reject(RejectionReason.InvalidGroup); + first.AddRange(second); + _groups.Remove(secondGroupId); + if (Mode == BoardMode.Counting) _collected.UnionWith(first); + return RuleResult.Success(); + } + + public RuleResult TrySplit(string groupId, IReadOnlyList selectedBallIds, string newGroupId) + { + if (!ValidId(groupId) || !_groups.TryGetValue(groupId, out var members)) return RuleResult.Reject(RejectionReason.UnknownId); + if (!ValidId(newGroupId) || _groups.ContainsKey(newGroupId)) return RuleResult.Reject(RejectionReason.DuplicateId); + if (selectedBallIds == null || selectedBallIds.Count == 0 || selectedBallIds.Count >= members.Count) + return RuleResult.Reject(RejectionReason.InvalidSelection); + var selected = new HashSet(StringComparer.Ordinal); + foreach (var id in selectedBallIds) + if (!ValidId(id) || !members.Contains(id) || !selected.Add(id)) return RuleResult.Reject(RejectionReason.InvalidSelection); + // Connectivity does not enter the rule: exactly selected/rest, even for distant balls. + var selectedMembers = members.Where(selected.Contains).ToList(); + var remaining = members.Where(id => !selected.Contains(id)).ToList(); + _groups[groupId] = remaining; + _groups.Add(newGroupId, selectedMembers); + return RuleResult.Success(); + } + + public RuleResult TryCount(string ballId) + { + if (!ValidId(ballId) || !_balls.ContainsKey(ballId)) return RuleResult.Reject(RejectionReason.UnknownId); + if (!CanCount) return RuleResult.Reject(RejectionReason.NotCollected); + if (!_counted.Add(ballId)) return RuleResult.Reject(RejectionReason.AlreadyCounted); + return RuleResult.Success(_counted.Count); + } + + public RuleResult TrySpawn(string ballId, string groupId, Position3 position) + { + if (Mode != BoardMode.FreePlay) return RuleResult.Reject(RejectionReason.PuzzleSupplyLocked); + if (_balls.Count >= 10) return RuleResult.Reject(RejectionReason.CapacityReached); + if (!ValidId(ballId) || !ValidId(groupId) || _balls.ContainsKey(ballId) || _groups.ContainsKey(groupId)) return RuleResult.Reject(RejectionReason.DuplicateId); + if (!position.IsValid) return RuleResult.Reject(RejectionReason.InvalidPosition); + _balls.Add(ballId, position); + _groups.Add(groupId, new List { ballId }); + return RuleResult.Success(); + } + + public RuleResult TryReturn(string groupId) + { + if (Mode != BoardMode.FreePlay) return RuleResult.Reject(RejectionReason.PuzzleSupplyLocked); + if (!ValidId(groupId) || !_groups.TryGetValue(groupId, out var members)) return RuleResult.Reject(RejectionReason.UnknownId); + foreach (var id in members) _balls.Remove(id); + _groups.Remove(groupId); + return RuleResult.Success(); + } + + public PuzzleState ToPuzzleState(CameraState camera) + { + if (Mode == BoardMode.FreePlay) throw new InvalidOperationException("Free play cannot be saved as a puzzle."); + return new PuzzleState { stageId = StageId, balls = Balls, groups = Groups, + collectedBallIds = CollectedBallIds, countedBallIds = CountedBallIds, camera = CopyCamera(camera) }; + } + + // Used for the successful puzzle -> free play boundary; count markers are intentionally absent. + public FreePlayState ToFreePlayState(CameraState camera) => new FreePlayState + { sourceStageId = StageId, balls = Balls, groups = Groups, camera = CopyCamera(camera) }; + + private void CopyBoard(BallState[] balls, GroupState[] groups) + { + foreach (var ball in balls) _balls.Add(ball.id, ball.position); + foreach (var group in groups) _groups.Add(group.id, new List(group.ballIds)); + } + + private static CameraState CopyCamera(CameraState camera) + { + if (!SaveValidator.ValidCamera(camera)) throw new ArgumentException("Invalid camera.", nameof(camera)); + return new CameraState { target = camera.target, rotation = camera.rotation, distance = camera.distance }; + } + + internal static bool ValidId(string id) => !string.IsNullOrWhiteSpace(id); + } +} diff --git a/Assets/MagnetismHeroes/Core/BoardRules.cs.meta b/Assets/MagnetismHeroes/Core/BoardRules.cs.meta new file mode 100644 index 0000000..fbd8d1c --- /dev/null +++ b/Assets/MagnetismHeroes/Core/BoardRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6a13e5eea59bf5145a3098f5d20ac58e \ No newline at end of file diff --git a/Assets/MagnetismHeroes/Core/GameData.cs b/Assets/MagnetismHeroes/Core/GameData.cs new file mode 100644 index 0000000..d99b21b --- /dev/null +++ b/Assets/MagnetismHeroes/Core/GameData.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; + +namespace MagnetismHeroes.Core +{ + public enum StageKind { Counting, Composition } + public enum BoardMode { Counting, Composition, FreePlay } + public enum RejectionReason { None, UnknownId, InvalidSelection, DuplicateId, InvalidPosition, NotCollected, AlreadyCounted, PuzzleSupplyLocked, CapacityReached, InvalidGroup, NoChange } + + public readonly struct RuleResult + { + public bool Accepted { get; } + public RejectionReason Reason { get; } + public int Count { get; } + private RuleResult(bool accepted, RejectionReason reason, int count) { Accepted = accepted; Reason = reason; Count = count; } + public static RuleResult Success(int count = 0) => new RuleResult(true, RejectionReason.None, count); + public static RuleResult Reject(RejectionReason reason) => new RuleResult(false, reason, 0); + } + + [Serializable] + public struct Position3 + { + public float x, y, z; + public Position3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; } + public bool IsValid => Finite(x) && Finite(y) && Finite(z); + internal static bool Finite(float value) => !float.IsNaN(value) && !float.IsInfinity(value); + } + + [Serializable] + public struct Rotation4 + { + public float x, y, z, w; + public Rotation4(float x, float y, float z, float w) { this.x = x; this.y = y; this.z = z; this.w = w; } + public bool IsValid => Position3.Finite(x) && Position3.Finite(y) && Position3.Finite(z) && Position3.Finite(w) + && Math.Abs((double)x * x + (double)y * y + (double)z * z + (double)w * w - 1d) < 0.001d; + } + + // Lower camel case fields are the versioned JSON wire contract, also usable by JsonUtility. + [Serializable] public sealed class BallState { public string id; public Position3 position; } + [Serializable] public sealed class GroupState { public string id; public string[] ballIds = Array.Empty(); } + [Serializable] public sealed class CameraState + { + public Position3 target; + public Rotation4 rotation = new Rotation4(0, 0, 0, 1); + public float distance = 8; + } + [Serializable] public sealed class PuzzleState + { + public int stageId; + public BallState[] balls = Array.Empty(); + public GroupState[] groups = Array.Empty(); + public string[] collectedBallIds = Array.Empty(); + public string[] countedBallIds = Array.Empty(); + public CameraState camera = new CameraState(); + } + [Serializable] public sealed class FreePlayState + { + public int sourceStageId; + public BallState[] balls = Array.Empty(); + public GroupState[] groups = Array.Empty(); + public CameraState camera = new CameraState(); + } + [Serializable] public sealed class ProgressSave + { + public int schemaVersion = 1; + public int[] completedStageIds = Array.Empty(); + public string[] seenTutorialIds = Array.Empty(); + public PuzzleState[] puzzleStates = Array.Empty(); + public FreePlayState latestFreePlay; + } + + public sealed class DecorationPlacement + { + public string AssetKey { get; } + public Position3 Position { get; } + public Rotation4 Rotation { get; } + public Position3 Scale { get; } + + public DecorationPlacement(string assetKey, Position3 position, Rotation4 rotation, Position3 scale) + { + if (string.IsNullOrWhiteSpace(assetKey) || !position.IsValid || !rotation.IsValid || !scale.IsValid + || scale.x <= 0 || scale.y <= 0 || scale.z <= 0) throw new ArgumentException("Invalid decoration key or transform."); + AssetKey = assetKey; + Position = position; + Rotation = rotation; + Scale = scale; + } + } + + public sealed class StageLayout + { + public IReadOnlyList BallPositions { get; } + public IReadOnlyList Decorations { get; } + + public StageLayout(Position3[] ballPositions, DecorationPlacement[] decorations = null) + { + if (ballPositions == null) throw new ArgumentException("Ball layout is required."); + var positions = (Position3[])ballPositions.Clone(); + foreach (var position in positions) if (!position.IsValid) throw new ArgumentException("Layout positions must be finite."); + var placements = decorations == null ? Array.Empty() : (DecorationPlacement[])decorations.Clone(); + foreach (var placement in placements) if (placement == null) throw new ArgumentException("Decoration placements cannot be null."); + BallPositions = Array.AsReadOnly(positions); + Decorations = Array.AsReadOnly(placements); + } + } + + public sealed class StageDefinition + { + public int Id { get; } + public StageKind Kind { get; } + public IReadOnlyList InitialGroups { get; } + public IReadOnlyList TargetGroups { get; } + public StageLayout Layout { get; } + public IReadOnlyList BallPositions => Layout.BallPositions; + public int Total { get; } + + public StageDefinition(int id, StageKind kind, int[] initialGroups, int[] targetGroups, Position3[] ballPositions = null, + DecorationPlacement[] decorations = null) + { + if (id < 1 || id > 30 || (kind != StageKind.Counting && kind != StageKind.Composition)) + throw new ArgumentException("Invalid stage ID or kind."); + var initial = CopyGroups(initialGroups); + var target = CopyGroups(targetGroups); + var total = 0; + foreach (var size in initial) total += size; + var targetTotal = 0; + foreach (var size in target) targetTotal += size; + if (total != targetTotal || total > 10) throw new ArgumentException("Stage totals must match and be within 1..10."); + if (kind == StageKind.Counting) + { + foreach (var size in initial) if (size != 1) throw new ArgumentException("Counting starts with individual balls."); + if (target.Length != 1) throw new ArgumentException("Counting has one target group."); + } + if (ballPositions != null && ballPositions.Length != total) throw new ArgumentException("Layout must position every ball."); + var positions = ballPositions == null ? new Position3[total] : (Position3[])ballPositions.Clone(); + for (var i = 0; i < total; i++) + { + if (ballPositions == null) positions[i] = new Position3(i * 1.4f, 0, 0); + if (!positions[i].IsValid) throw new ArgumentException("Layout positions must be finite."); + } + Id = id; + Kind = kind; + Total = total; + InitialGroups = Array.AsReadOnly(initial); + TargetGroups = Array.AsReadOnly(target); + Layout = new StageLayout(positions, decorations); + } + + private static int[] CopyGroups(int[] groups) + { + if (groups == null || groups.Length == 0 || groups.Length > 10) throw new ArgumentException("Groups must be nonempty."); + var copy = (int[])groups.Clone(); + foreach (var size in copy) if (size < 1 || size > 10) throw new ArgumentException("Group size must be within 1..10."); + return copy; + } + } + + public interface IProgressStore + { + bool TryLoad(out ProgressSave save, out string error); + bool TrySave(ProgressSave save, out string error); + } +} diff --git a/Assets/MagnetismHeroes/Core/GameData.cs.meta b/Assets/MagnetismHeroes/Core/GameData.cs.meta new file mode 100644 index 0000000..529ad97 --- /dev/null +++ b/Assets/MagnetismHeroes/Core/GameData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 47bdee7aa8035b14cb5d993b0b1f5e5d \ No newline at end of file diff --git a/Assets/MagnetismHeroes/Core/SaveValidator.cs b/Assets/MagnetismHeroes/Core/SaveValidator.cs new file mode 100644 index 0000000..55110c7 --- /dev/null +++ b/Assets/MagnetismHeroes/Core/SaveValidator.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MagnetismHeroes.Core +{ + public static class SaveValidator + { + public static bool TryValidate(ProgressSave save, IReadOnlyList definitions, out string error) + { + if (save == null || save.schemaVersion != 1) return Fail("Missing save or unsupported schema version.", out error); + if (definitions == null || definitions.Any(definition => definition == null) + || definitions.Select(definition => definition.Id).Distinct().Count() != definitions.Count) + return Fail("Invalid definition catalog.", out error); + if (save.completedStageIds == null || save.completedStageIds.Any(id => id < 1 || id > 30) + || save.completedStageIds.Distinct().Count() != save.completedStageIds.Length) + return Fail("Invalid completed stage IDs.", out error); + if (save.seenTutorialIds == null || save.seenTutorialIds.Any(id => id != "merge" && id != "split") + || save.seenTutorialIds.Distinct().Count() != save.seenTutorialIds.Length) + return Fail("Invalid tutorial IDs.", out error); + if (save.puzzleStates == null || save.puzzleStates.Length > 30 || save.puzzleStates.Any(state => state == null) + || save.puzzleStates.Select(state => state.stageId).Distinct().Count() != save.puzzleStates.Length) + return Fail("Invalid puzzle state list.", out error); + foreach (var state in save.puzzleStates) + { + var definition = definitions.FirstOrDefault(candidate => candidate.Id == state.stageId); + if (!TryValidatePuzzle(state, definition, out error)) return false; + } + if (save.latestFreePlay != null) + { + if (!save.completedStageIds.Contains(save.latestFreePlay.sourceStageId)) return Fail("Free play source was not completed.", out error); + if (!TryValidateFreePlay(save.latestFreePlay, out error)) return false; + } + error = null; + return true; + } + + public static bool TryValidateUpdate(ProgressSave previous, ProgressSave next, IReadOnlyList definitions, out string error) + { + if (!TryValidate(previous, definitions, out error) || !TryValidate(next, definitions, out error)) return false; + if (previous.completedStageIds.Except(next.completedStageIds).Any()) return Fail("Completed stage IDs cannot decrease.", out error); + return true; + } + + public static bool TryValidatePuzzle(PuzzleState state, StageDefinition definition, out string error) + { + if (state == null || definition == null || state.stageId != definition.Id) return Fail("Puzzle stage has no matching definition.", out error); + if (!ValidateBoard(state.balls, state.groups, state.camera, out var ballIds, out error)) return false; + if (state.balls.Length != definition.Total) return Fail("Puzzle total differs from its definition.", out error); + if (!ValidateSubset(state.collectedBallIds, ballIds, out error)) return false; + if (!ValidateSubset(state.countedBallIds, new HashSet(state.collectedBallIds, StringComparer.Ordinal), out error)) return false; + if (definition.Kind == StageKind.Composition && (state.collectedBallIds.Length != 0 || state.countedBallIds.Length != 0)) + return Fail("Composition puzzles cannot contain counting records.", out error); + error = null; + return true; + } + + public static bool TryValidateFreePlay(FreePlayState state, out string error) + { + if (state == null || state.sourceStageId < 1 || state.sourceStageId > 30) return Fail("Invalid free play source.", out error); + return ValidateBoard(state.balls, state.groups, state.camera, out _, out error); + } + + private static bool ValidateBoard(BallState[] balls, GroupState[] groups, CameraState camera, out HashSet ballIds, out string error) + { + ballIds = new HashSet(StringComparer.Ordinal); + if (balls == null || groups == null || balls.Length > 10 || !ValidCamera(camera)) return Fail("Invalid board collections or camera.", out error); + foreach (var ball in balls) + if (ball == null || !BoardRules.ValidId(ball.id) || !ballIds.Add(ball.id) || !ball.position.IsValid) + return Fail("Invalid or duplicate ball ID or position.", out error); + var groupIds = new HashSet(StringComparer.Ordinal); + var membership = new HashSet(StringComparer.Ordinal); + foreach (var group in groups) + { + if (group == null || !BoardRules.ValidId(group.id) || !groupIds.Add(group.id) || group.ballIds == null || group.ballIds.Length == 0) + return Fail("Invalid, duplicate, or empty group.", out error); + foreach (var id in group.ballIds) + if (!BoardRules.ValidId(id) || !ballIds.Contains(id) || !membership.Add(id)) return Fail("Invalid or duplicate group membership.", out error); + } + if (!membership.SetEquals(ballIds)) return Fail("Every ball must belong to exactly one group.", out error); + error = null; + return true; + } + + private static bool ValidateSubset(string[] ids, HashSet allowed, out string error) + { + if (ids == null || ids.Any(id => !BoardRules.ValidId(id) || !allowed.Contains(id)) || ids.Distinct().Count() != ids.Length) + return Fail("Counting IDs must be unique subsets of their parent set.", out error); + error = null; + return true; + } + + internal static bool ValidCamera(CameraState camera) => camera != null && camera.target.IsValid && camera.rotation.IsValid + && Position3.Finite(camera.distance) && camera.distance > 0; + + private static bool Fail(string message, out string error) { error = message; return false; } + } +} diff --git a/Assets/MagnetismHeroes/Core/SaveValidator.cs.meta b/Assets/MagnetismHeroes/Core/SaveValidator.cs.meta new file mode 100644 index 0000000..a903bd6 --- /dev/null +++ b/Assets/MagnetismHeroes/Core/SaveValidator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0beb15120d13ace43aa57346895685ab \ No newline at end of file diff --git a/Assets/MagnetismHeroes/Tests/EditMode/Core/BoardRulesTests.cs b/Assets/MagnetismHeroes/Tests/EditMode/Core/BoardRulesTests.cs new file mode 100644 index 0000000..95e4449 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/EditMode/Core/BoardRulesTests.cs @@ -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(), 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(() => new StageDefinition(0, StageKind.Composition, new[] { 1 }, new[] { 1 })); + Assert.Throws(() => Composition(new[] { 0, 2 }, new[] { 2 })); + Assert.Throws(() => Composition(new[] { 11 }, new[] { 11 })); + Assert.Throws(() => Composition(new[] { 2 }, new[] { 3 })); + Assert.Throws(() => new StageDefinition(1, StageKind.Counting, new[] { 2 }, new[] { 2 })); + Assert.Throws(() => new StageDefinition(1, StageKind.Counting, new[] { 1, 1 }, new[] { 1, 1 })); + Assert.Throws(() => new StageDefinition(1, (StageKind)10, new[] { 1 }, new[] { 1 })); + } + } +} diff --git a/Assets/MagnetismHeroes/Tests/EditMode/Core/BoardRulesTests.cs.meta b/Assets/MagnetismHeroes/Tests/EditMode/Core/BoardRulesTests.cs.meta new file mode 100644 index 0000000..3f8eec4 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/EditMode/Core/BoardRulesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9a9e3b7c7f3f39e42838d06b916e58e3 \ No newline at end of file diff --git a/Assets/MagnetismHeroes/Tests/EditMode/Core/SaveValidatorTests.cs b/Assets/MagnetismHeroes/Tests/EditMode/Core/SaveValidatorTests.cs new file mode 100644 index 0000000..2a87316 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/EditMode/Core/SaveValidatorTests.cs @@ -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[] 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(), + 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(() => BoardRules.CreateFreePlay(state)); + var puzzle = Valid().puzzleStates[0]; puzzle.groups[0].ballIds[0] = "missing"; + Assert.Throws(() => 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); + } + } +} diff --git a/Assets/MagnetismHeroes/Tests/EditMode/Core/SaveValidatorTests.cs.meta b/Assets/MagnetismHeroes/Tests/EditMode/Core/SaveValidatorTests.cs.meta new file mode 100644 index 0000000..eb67868 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/EditMode/Core/SaveValidatorTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8413b204741df8f408785b39e1bbd68e \ No newline at end of file diff --git a/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs b/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs new file mode 100644 index 0000000..afbdbe3 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MagnetismHeroes.Core; +using NUnit.Framework; + +namespace MagnetismHeroes.Tests.EditMode.Core +{ + public sealed class StageLayoutTests + { + private static readonly Rotation4 Identity = new Rotation4(0, 0, 0, 1); + private static readonly Position3 UnitScale = new Position3(1, 1, 1); + + [Test] + public void SameBallPositionsWithDifferentDecorationsRemainDistinctAndDoNotChangeTheMath() + { + var positions = new[] { new Position3(1, 2, 3), new Position3(4, 5, 6) }; + var garden = new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }, positions, + new[] { new DecorationPlacement("garden/arch", new Position3(3, 0, 1), Identity, UnitScale) }); + var cloud = new StageDefinition(7, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }, positions, + new[] { new DecorationPlacement("cloud/platform", new Position3(3, 4, 1), Identity, new Position3(2, 1, 2)) }); + Assert.That(garden.Layout.Decorations.Count, Is.EqualTo(1)); + Assert.That(cloud.Layout.Decorations.Count, Is.EqualTo(1)); + Assert.That(garden.Layout.Decorations[0].AssetKey, Is.EqualTo("garden/arch")); + Assert.That(cloud.Layout.Decorations[0].AssetKey, Is.EqualTo("cloud/platform")); + Assert.That(garden.Layout.BallPositions, Is.EqualTo(cloud.Layout.BallPositions)); + foreach (var definition in new[] { garden, cloud }) + { + var board = new BoardRules(definition); + Assert.That(board.BallCount, Is.EqualTo(2)); + Assert.That(board.IsSolved, Is.False); + Assert.That(board.TryMerge(board.Groups[0].id, board.Groups[1].id).Accepted, Is.True); + Assert.That(board.IsSolved, Is.True); + Assert.That(board.BallCount, Is.EqualTo(2)); + } + } + + [Test] + public void InputArraysAndReturnedTransformCopiesCannotChangeFixedLayout() + { + var positions = new[] { new Position3(1, 2, 3) }; + var decorations = new[] { new DecorationPlacement("workshop/bench", new Position3(4, 5, 6), Identity, UnitScale) }; + var definition = new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 }, positions, decorations); + positions[0] = new Position3(100, 0, 0); + decorations[0] = new DecorationPlacement("changed", new Position3(), Identity, UnitScale); + Assert.That(definition.Layout.Decorations.Count, Is.EqualTo(1)); + Assert.That(definition.BallPositions[0].x, Is.EqualTo(1)); + Assert.That(definition.Layout.Decorations[0].AssetKey, Is.EqualTo("workshop/bench")); + var positionCopy = definition.Layout.Decorations[0].Position; + var rotationCopy = definition.Layout.Decorations[0].Rotation; + var scaleCopy = definition.Layout.Decorations[0].Scale; + positionCopy.x = 100; rotationCopy.w = 0; scaleCopy.x = 100; + Assert.That(definition.Layout.Decorations[0].Position.x, Is.EqualTo(4)); + Assert.That(definition.Layout.Decorations[0].Rotation.w, Is.EqualTo(1)); + Assert.That(definition.Layout.Decorations[0].Scale.x, Is.EqualTo(1)); + Assert.Throws(() => ((IList)definition.Layout.Decorations)[0] = decorations[0]); + Assert.Throws(() => ((IList)definition.Layout.BallPositions)[0] = positions[0]); + } + + [Test] + public void PuzzleAndFreePlaySourceIdsSelectOriginalFixedDecorationLayoutOnRestore() + { + var definition = new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }, + new[] { new Position3(), new Position3(1, 0, 0) }, + new[] { new DecorationPlacement("garden/slide", new Position3(0, 0, 8), Identity, UnitScale) }); + var catalog = new[] { definition }; + var board = new BoardRules(definition); + board.TryMoveGroup(board.Groups[0].id, new Position3(5, 0, 0)); + var puzzle = board.ToPuzzleState(new CameraState()); + var restoredDefinition = catalog.Single(stage => stage.Id == puzzle.stageId); + var restored = new BoardRules(restoredDefinition, puzzle); + Assert.That(restoredDefinition.Layout.Decorations.Count, Is.EqualTo(1)); + Assert.That(restoredDefinition.Layout.Decorations[0].AssetKey, Is.EqualTo("garden/slide")); + Assert.That(restored.Balls[0].position.x, Is.EqualTo(5)); + Assert.That(restoredDefinition.BallPositions[0].x, Is.Zero); + var free = restored.ToFreePlayState(new CameraState()); + var freeDefinition = catalog.Single(stage => stage.Id == free.sourceStageId); + Assert.That(freeDefinition.Layout.Decorations[0].Position.z, Is.EqualTo(8)); + Assert.That(BoardRules.CreateFreePlay(free).BallCount, Is.EqualTo(2)); + } + + [Test] + public void InvalidDecorationTransformsAndMissingEntriesAreRejected() + { + Assert.Throws(() => new DecorationPlacement("", new Position3(), Identity, UnitScale)); + Assert.Throws(() => new DecorationPlacement("arch", new Position3(float.NaN, 0, 0), Identity, UnitScale)); + Assert.Throws(() => new DecorationPlacement("arch", new Position3(), new Rotation4(), UnitScale)); + Assert.Throws(() => new DecorationPlacement("arch", new Position3(), Identity, new Position3(0, 1, 1))); + Assert.Throws(() => new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 }, + decorations: new DecorationPlacement[] { null })); + } + } +} diff --git a/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs.meta b/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs.meta new file mode 100644 index 0000000..b6c1585 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d13c7f6ddacc35d40a7a177a7749b18c \ No newline at end of file