Complete persistence boundaries and free-play supply with device verification

This commit is contained in:
NINI
2026-10-05 11:30:12 +09:00
parent a651e5ca7f
commit e84eb1a35e
21 changed files with 1786 additions and 95 deletions
@@ -26,9 +26,9 @@ namespace MagnetismHeroes.Tests
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
var serialized = new SerializedObject(prefab.GetComponent<GameApp>()); var stages = serialized.FindProperty("_stages");
Assert.That(stages.arraySize, Is.EqualTo(4)); var definitions = Enumerable.Range(0, stages.arraySize)
Assert.That(stages.arraySize, Is.EqualTo(5)); var definitions = Enumerable.Range(0, stages.arraySize)
.Select(index => ((StageAsset)stages.GetArrayElementAtIndex(index).objectReferenceValue).ToDefinition()).ToArray();
Assert.That(definitions.Select(stage => stage.Id), Is.EqualTo(new[] { 1, 6, 10, 14 }));
Assert.That(definitions.Select(stage => stage.Id), Is.EqualTo(new[] { 1, 2, 6, 10, 14 }));
var session = new GameSession(definitions, new EmptyStore()); Assert.That(session.IsUnlocked(6), Is.False);
Assert.That(session.StartStage(6), Is.False); Assert.That(session.IsUnlocked(10), Is.False); Assert.That(session.IsUnlocked(14), Is.False);
Assert.That(serialized.FindProperty("_compositionButtons").arraySize, Is.EqualTo(3));
@@ -73,6 +73,7 @@ namespace MagnetismHeroes.Tests
Assert.That(File.ReadAllText(_path), Is.EqualTo("broken-primary"));
Assert.That(store.TrySave(new ProgressSave(), out var error), Is.True, error);
Assert.That(Directory.GetFiles(_directory, "*.damaged-*").Select(File.ReadAllText), Does.Contain("broken-primary"));
Assert.That(File.ReadAllText(_path + ".bak"), Is.EqualTo("broken-backup"));
}
[Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave()
{
@@ -99,5 +100,23 @@ namespace MagnetismHeroes.Tests
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
Assert.That(File.ReadAllText(_path), Does.Not.Contain("IsValid"));
}
[Test] public void LockedTemporaryFileReportsFailureAndKeepsTheLastValidSave()
{
var store = Store(); Assert.That(store.TrySave(Puzzle(), out _), Is.True);
Assert.That(store.TrySave(Puzzle(), out _), Is.True);
var before = File.ReadAllBytes(_path);
var backupBefore = File.ReadAllBytes(_path + ".bak");
using (var locked = new FileStream(_path + ".tmp", FileMode.Create, FileAccess.ReadWrite, FileShare.None))
{
string error = null; bool saved = true;
Assert.DoesNotThrow(() => saved = store.TrySave(Puzzle(), out error));
Assert.That(saved, Is.False); Assert.That(error, Is.Not.Empty);
Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before));
Assert.That(File.ReadAllBytes(_path + ".bak"), Is.EqualTo(backupBefore));
}
Assert.That(store.TryLoad(out var restored, out _), Is.True);
Assert.That(restored.puzzleStates.Length, Is.EqualTo(1));
Assert.That(store.TrySave(Puzzle(), out var recoveredError), Is.True, recoveredError);
}
}
}
@@ -0,0 +1,95 @@
using System;
using System.IO;
using System.Linq;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Persistence;
using NUnit.Framework;
namespace MagnetismHeroes.Tests
{
public sealed class PersistenceSessionTests
{
private string _directory, _path;
private static readonly StageDefinition[] Definitions = {
new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 }),
new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 }),
new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) };
private sealed class RecordedStore : IProgressStore
{
private readonly LocalProgressStore _store;
public int Writes;
public RecordedStore(string path) { _store = new LocalProgressStore(path, Definitions); }
public bool TryLoad(out ProgressSave save, out string error) => _store.TryLoad(out save, out error);
public bool TrySave(ProgressSave save, out string error) { Writes++; return _store.TrySave(save, out error); }
}
[SetUp] public void SetUp()
{ _directory = Path.Combine(Path.GetTempPath(), "MagnetismHeroes-persistence-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_directory); _path = Path.Combine(_directory, "progress.json"); }
[TearDown] public void TearDown() => Directory.Delete(_directory, true);
private ProgressSave Load()
{ Assert.That(new LocalProgressStore(_path, Definitions).TryLoad(out var save, out var error), Is.True, error); return save; }
private static CameraState Camera(float distance) => new CameraState {
target = new Position3(1, .8f, -2), rotation = new Rotation4(0, .6f, 0, .8f), distance = distance };
private static void CompleteOne(GameSession session)
{ session.StartStage(1); session.MoveGroup(session.Board.Groups.Single().id, new Position3(.5f, .8f, 0)); session.Count(session.Board.Balls.Single().id); }
[Test] public void ActualFileAlternatesTwoUnfinishedBoardsAndLatestFreeWithoutLosingAnyField()
{
var store = new RecordedStore(_path); var session = new GameSession(Definitions, store); CompleteOne(session);
session.Spawn(new Position3(-2, .8f, 0)); session.CommitCamera(Camera(9)); session.ShowHome();
session.StartStage(2); var groups = session.Board.Groups;
session.MoveAndMerge(groups[0].id, new Position3(.6f, 1.1f, -2), groups[1].id);
session.Count(session.Board.Balls[0].id); session.CommitCamera(Camera(11)); session.ShowHome();
var fixture = Load(); fixture.completedStageIds = new[] { 1, 2, 3, 4, 5 };
Assert.That(new LocalProgressStore(_path, Definitions).TrySave(fixture, out var error), Is.True, error);
session = new GameSession(Definitions, new RecordedStore(_path)); session.StartStage(6);
session.MoveGroup(session.Board.Groups[0].id, new Position3(-1, .8f, 2)); session.CommitCamera(Camera(7)); session.ShowHome();
var before = File.ReadAllBytes(_path); var saved = Load();
Assert.That(saved.puzzleStates.Length, Is.EqualTo(2));
Assert.That(saved.puzzleStates.Single(state => state.stageId == 2).countedBallIds.Length, Is.EqualTo(1));
foreach (int id in new[] { 2, 6, 2, 6 })
{
session = new GameSession(Definitions, new RecordedStore(_path)); Assert.That(session.Screen, Is.EqualTo(SessionScreen.Home));
Assert.That(session.StartStage(id), Is.True); var expected = saved.puzzleStates.Single(state => state.stageId == id);
AssertBoard(session.Board, expected.balls, expected.groups); AssertCamera(session.Camera, expected.camera);
Assert.That(session.Board.CollectedBallIds, Is.EqualTo(expected.collectedBallIds));
Assert.That(session.Board.CountedBallIds, Is.EqualTo(expected.countedBallIds)); session.ShowHome();
Assert.That(session.ContinueFreePlay(), Is.True); AssertBoard(session.Board, saved.latestFreePlay.balls, saved.latestFreePlay.groups);
AssertCamera(session.Camera, saved.latestFreePlay.camera); session.ShowHome();
Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before), "Exact deterministic JSON bytes retain both unfinished plus global Free.");
}
}
[Test] public void CompletionIsOneWriteReplacesOnlyLatestFreeAndReplayKeepsProgress()
{
var store = new RecordedStore(_path); var session = new GameSession(Definitions, store); CompleteOne(session);
session.StartStage(2); session.MoveAndMerge(session.Board.Groups[0].id, new Position3(.5f, 0, 0), session.Board.Groups[1].id);
session.Count(session.Board.Balls[0].id); int before = store.Writes;
session.Count(session.Board.Balls[1].id); Assert.That(store.Writes, Is.EqualTo(before + 1));
var completed = Load(); Assert.That(completed.completedStageIds, Is.EqualTo(new[] { 1, 2 }));
Assert.That(completed.puzzleStates, Is.Empty); Assert.That(completed.latestFreePlay.sourceStageId, Is.EqualTo(2));
session.StartStage(1); session.MoveGroup(session.Board.Groups[0].id, new Position3(.2f, 0, 0));
var replay = Load(); Assert.That(replay.completedStageIds, Is.EqualTo(new[] { 1, 2 }));
Assert.That(replay.puzzleStates.Single().stageId, Is.EqualTo(1)); Assert.That(replay.latestFreePlay.sourceStageId, Is.EqualTo(2));
session.Count(session.Board.Balls.Single().id); Assert.That(Load().latestFreePlay.sourceStageId, Is.EqualTo(1));
Assert.That(Load().completedStageIds, Is.EqualTo(new[] { 1, 2 }));
}
[Test] public void TutorialRecordsAreUniqueValidatedAndPersistAcrossFreeAndPuzzleTransitions()
{
var store = new RecordedStore(_path); var session = new GameSession(Definitions, store);
Assert.That(session.MarkTutorialSeen("merge"), Is.True); int before = store.Writes;
Assert.That(session.MarkTutorialSeen("merge"), Is.False); Assert.That(session.MarkTutorialSeen("unknown"), Is.False);
Assert.That(store.Writes, Is.EqualTo(before)); CompleteOne(session); Assert.That(session.MarkTutorialSeen("split"), Is.True);
session.StartStage(1); session.ShowHome(); session = new GameSession(Definitions, new RecordedStore(_path));
Assert.That(session.HasSeenTutorial("merge"), Is.True); Assert.That(session.HasSeenTutorial("split"), Is.True);
Assert.That(Load().seenTutorialIds, Is.EqualTo(new[] { "merge", "split" }));
}
private static void AssertBoard(BoardRules board, BallState[] balls, GroupState[] groups)
{
Assert.That(board.Balls.Select(ball => ball.id), Is.EqualTo(balls.Select(ball => ball.id)));
Assert.That(board.Balls.Select(ball => ball.position), Is.EqualTo(balls.Select(ball => ball.position)));
Assert.That(board.Groups.Select(group => group.id), Is.EqualTo(groups.Select(group => group.id)));
for (int index = 0; index < groups.Length; index++) Assert.That(board.Groups[index].ballIds, Is.EqualTo(groups[index].ballIds));
}
private static void AssertCamera(CameraState actual, CameraState expected)
{ Assert.That(actual.target, Is.EqualTo(expected.target)); Assert.That(actual.rotation, Is.EqualTo(expected.rotation)); Assert.That(actual.distance, Is.EqualTo(expected.distance)); }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8666ab89be2e7ab4a96f90ee79122b2f