feat: add validated magnet number rules

This commit is contained in:
NINI
2026-10-05 00:49:46 +09:00
12 changed files with 903 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace MagnetismHeroes.Core
{
public sealed class BoardRules
{
private readonly Dictionary<string, Position3> _balls = new Dictionary<string, Position3>(StringComparer.Ordinal);
private readonly Dictionary<string, List<string>> _groups = new Dictionary<string, List<string>>(StringComparer.Ordinal);
private readonly HashSet<string> _collected = new HashSet<string>(StringComparer.Ordinal);
private readonly HashSet<string> _counted = new HashSet<string>(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<string>();
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<string> 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<string>(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<string> { 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<string>(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);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6a13e5eea59bf5145a3098f5d20ac58e
+163
View File
@@ -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<string>(); }
[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<BallState>();
public GroupState[] groups = Array.Empty<GroupState>();
public string[] collectedBallIds = Array.Empty<string>();
public string[] countedBallIds = Array.Empty<string>();
public CameraState camera = new CameraState();
}
[Serializable] public sealed class FreePlayState
{
public int sourceStageId;
public BallState[] balls = Array.Empty<BallState>();
public GroupState[] groups = Array.Empty<GroupState>();
public CameraState camera = new CameraState();
}
[Serializable] public sealed class ProgressSave
{
public int schemaVersion = 1;
public int[] completedStageIds = Array.Empty<int>();
public string[] seenTutorialIds = Array.Empty<string>();
public PuzzleState[] puzzleStates = Array.Empty<PuzzleState>();
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<Position3> BallPositions { get; }
public IReadOnlyList<DecorationPlacement> 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>() : (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<int> InitialGroups { get; }
public IReadOnlyList<int> TargetGroups { get; }
public StageLayout Layout { get; }
public IReadOnlyList<Position3> 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);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47bdee7aa8035b14cb5d993b0b1f5e5d
@@ -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<StageDefinition> 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<StageDefinition> 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<string>(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<string> ballIds, out string error)
{
ballIds = new HashSet<string>(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<string>(StringComparer.Ordinal);
var membership = new HashSet<string>(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<string> 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; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0beb15120d13ace43aa57346895685ab
@@ -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
@@ -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<NotSupportedException>(() => ((IList<DecorationPlacement>)definition.Layout.Decorations)[0] = decorations[0]);
Assert.Throws<NotSupportedException>(() => ((IList<Position3>)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<ArgumentException>(() => new DecorationPlacement("", new Position3(), Identity, UnitScale));
Assert.Throws<ArgumentException>(() => new DecorationPlacement("arch", new Position3(float.NaN, 0, 0), Identity, UnitScale));
Assert.Throws<ArgumentException>(() => new DecorationPlacement("arch", new Position3(), new Rotation4(), UnitScale));
Assert.Throws<ArgumentException>(() => new DecorationPlacement("arch", new Position3(), Identity, new Position3(0, 1, 1)));
Assert.Throws<ArgumentException>(() => new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 },
decorations: new DecorationPlacement[] { null }));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d13c7f6ddacc35d40a7a177a7749b18c