feat: add validated magnet number rules
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user