Implement the playable first counting puzzle with committed input, persistence and Korean voice

This commit is contained in:
NINI
2026-10-05 01:57:21 +09:00
parent 8d4ffb6550
commit ab63ef532d
157 changed files with 72108 additions and 0 deletions
@@ -0,0 +1,41 @@
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using TMPro;
using MagnetismHeroes.UnityRuntime.StageAssets;
using MagnetismHeroes.UnityRuntime.Bootstrap;
namespace MagnetismHeroes.Tests
{
public sealed class FirstSliceAssetTests
{
[Test] public void FirstPuzzleHasAnExecutableAppPrefab()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
Assert.That(prefab, Is.Not.Null, "The first puzzle must have a real Inspector-editable app prefab.");
Assert.That(prefab.GetComponent<GameApp>(), Is.Not.Null);
var serialized = new SerializedObject(prefab.GetComponent<GameApp>());
foreach (var field in new[] { "_homePanel", "_selectionPanel", "_playPanel", "_world", "_instruction", "_counter", "_stageTitle", "_saveNotice",
"_playButton", "_continueButton", "_stageOneButton", "_homeButton", "_selectionHomeButton", "_presenter", "_orbit", "_input", "_voice" })
Assert.That(serialized.FindProperty(field).objectReferenceValue, Is.Not.Null, field);
}
[Test] public void StaticKoreanFontContainsEveryVisibleFirstSliceCharacter()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
var texts = prefab.GetComponentsInChildren<TMP_Text>(true);
foreach (var text in texts)
{
Assert.That(text.font.atlasPopulationMode, Is.EqualTo(AtlasPopulationMode.Static));
Assert.That(text.enableAutoSizing, Is.False);
foreach (var character in text.text) Assert.That(text.font.HasCharacter(character), Is.True, text.name + ": " + character);
}
}
[Test] public void StageAssetConvertsToAnIndependentDefinition()
{
var stage = AssetDatabase.LoadAssetAtPath<StageAsset>("Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage01.asset");
var before = EditorJsonUtility.ToJson(stage); var definition = stage.ToDefinition();
Assert.That(definition.Id, Is.EqualTo(1)); Assert.That(definition.Total, Is.EqualTo(1));
Assert.That(EditorJsonUtility.ToJson(stage), Is.EqualTo(before));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 599347c36cd01ad4fa166c4c6b6f3b77
@@ -0,0 +1,72 @@
using System.Linq;
using MagnetismHeroes.Core;
using NUnit.Framework;
namespace MagnetismHeroes.Tests
{
public sealed class GameSessionTests
{
private sealed class MemoryStore : IProgressStore
{
public ProgressSave Save = new ProgressSave();
public string LoadWarning;
public bool TryLoad(out ProgressSave save, out string error) { save = Save; error = LoadWarning; return true; }
public bool TrySave(ProgressSave save, out string error) { Save = save; error = null; return true; }
}
private static StageDefinition One() => new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 });
[Test] public void LoadRecoveryWarningIsRetainedForDiagnostics()
{
var session = new GameSession(new[] { One() }, new MemoryStore { LoadWarning = "Recovered valid backup" });
Assert.That(session.LastSaveError, Is.EqualTo("Recovered valid backup"));
}
[Test] public void FirstStageCannotCompleteWithoutACommittedCollection()
{
var store = new MemoryStore(); var session = new GameSession(new[] { One() }, store);
Assert.That(session.StartStage(1), Is.True);
var ball = session.Board.Balls.Single(); var group = session.Board.Groups.Single();
Assert.That(session.Count(ball.id).Reason, Is.EqualTo(RejectionReason.NotCollected));
Assert.That(session.MoveGroup(group.id, new Position3()).Accepted, Is.False);
Assert.That(store.Save.completedStageIds, Is.Empty);
Assert.That(session.Screen, Is.EqualTo(SessionScreen.Puzzle));
}
[Test] public void MoveAndCountCreateOneAtomicCompletionAndKeepTheSameBoard()
{
var store = new MemoryStore(); var session = new GameSession(new[] { One() }, store); session.StartStage(1);
var ballId = session.Board.Balls.Single().id; var groupId = session.Board.Groups.Single().id;
session.MoveGroup(groupId, new Position3(1, 2, -1));
var position = session.Board.Balls.Single().position;
Assert.That(session.Count(ballId).Count, Is.EqualTo(1));
Assert.That(session.Screen, Is.EqualTo(SessionScreen.FreePlay));
Assert.That(store.Save.completedStageIds, Is.EqualTo(new[] { 1 }));
Assert.That(store.Save.puzzleStates, Is.Empty);
Assert.That(store.Save.latestFreePlay.balls.Single().position, Is.EqualTo(position));
Assert.That(session.Count(ballId).Accepted, Is.False);
Assert.That(store.Save.completedStageIds, Is.EqualTo(new[] { 1 }));
}
[Test] public void IncompleteBoardRestoresCommittedPositionCameraAndCollection()
{
var store = new MemoryStore(); var session = new GameSession(new[] { One() }, store); session.StartStage(1);
session.MoveGroup(session.Board.Groups.Single().id, new Position3(1, 2, 3));
var camera = GameSession.DefaultCamera(); camera.distance = 11;
session.CommitCamera(camera); session.ShowHome();
var resumed = new GameSession(new[] { One() }, store); resumed.StartStage(1);
Assert.That(resumed.Board.Balls.Single().position, Is.EqualTo(new Position3(1, 2, 3)));
Assert.That(resumed.Board.CollectedBallIds.Length, Is.EqualTo(1));
Assert.That(resumed.Board.CountedCount, Is.Zero);
Assert.That(resumed.Camera.distance, Is.EqualTo(11));
Assert.That(resumed.Screen, Is.EqualTo(SessionScreen.Puzzle));
}
[Test] public void CompletedStageReplayKeepsCompletionAndItsPreviousFreePlaySeparately()
{
var store = new MemoryStore(); var session = new GameSession(new[] { One() }, store); session.StartStage(1);
session.MoveGroup(session.Board.Groups.Single().id, new Position3(2, 0, 0)); session.Count(session.Board.Balls.Single().id);
var freePosition = store.Save.latestFreePlay.balls.Single().position;
Assert.That(session.IsUnlocked(2), Is.True); Assert.That(session.IsUnlocked(3), Is.False);
session.StartStage(1); session.MoveGroup(session.Board.Groups.Single().id, new Position3(1, 0, 0));
Assert.That(store.Save.completedStageIds, Is.EqualTo(new[] { 1 }));
Assert.That(store.Save.latestFreePlay.balls.Single().position, Is.EqualTo(freePosition));
Assert.That(store.Save.puzzleStates.Single().stageId, Is.EqualTo(1));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 39eee6770356bc246bc854f34306e1fb
@@ -0,0 +1,103 @@
using System;
using System.IO;
using System.Linq;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Persistence;
using NUnit.Framework;
namespace MagnetismHeroes.Tests
{
public sealed class LocalProgressStoreTests
{
private string _directory, _path;
private StageDefinition _stage;
[SetUp] public void SetUp()
{
_directory = Path.Combine(Path.GetTempPath(), "MagnetismHeroes-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_directory);
_path = Path.Combine(_directory, "progress.json");
_stage = new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 });
}
[TearDown] public void TearDown() => Directory.Delete(_directory, true);
private LocalProgressStore Store() => new LocalProgressStore(_path, new[] { _stage });
private ProgressSave Puzzle()
{ var board = new BoardRules(_stage); return new ProgressSave { puzzleStates = new[] { board.ToPuzzleState(GameSession.DefaultCamera()) } }; }
[Test] public void CommittedPositionAndCameraRoundTripWithoutPartialLoad()
{
var progress = Puzzle(); progress.puzzleStates[0].balls[0].position = new Position3(1, -2, 3);
progress.puzzleStates[0].camera.distance = 10;
Assert.That(Store().TrySave(progress, out var error), Is.True, error);
Assert.That(Store().TryLoad(out var loaded, out error), Is.True, error);
Assert.That(loaded.puzzleStates[0].balls[0].position, Is.EqualTo(new Position3(1, -2, 3)));
Assert.That(loaded.puzzleStates[0].camera.distance, Is.EqualTo(10));
}
[TestCase("position")]
[TestCase("x")]
[TestCase("camera")]
[TestCase("countedBallIds")]
[TestCase("latestFreePlay")]
public void MissingRequiredFieldsAreRejectedInsteadOfDefaulted(string field)
{
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
var json = File.ReadAllText(_path).Replace("\"" + field + "\":", "\"removed_" + field + "\":");
File.WriteAllText(_path, json);
Assert.That(Store().TryLoad(out var loaded, out var error), Is.False);
Assert.That(loaded, Is.Null); Assert.That(error, Does.Contain(field));
}
[Test] public void DuplicateJsonKeysAndTrailingContentAreRejected()
{
File.WriteAllText(_path, "{\"schemaVersion\":1,\"schemaVersion\":1}");
Assert.That(Store().TryLoad(out _, out _), Is.False);
File.Delete(_path); Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
File.WriteAllText(_path, File.ReadAllText(_path) + " {}");
Assert.That(Store().TryLoad(out _, out _), Is.False);
}
[Test] public void ValidBackupRecoversWholeBoardAndDamagedOriginalSurvivesNextSave()
{
var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True);
progress.puzzleStates[0].camera.distance = 12; Assert.That(store.TrySave(progress, out _), Is.True);
File.WriteAllText(_path, "broken-primary");
Assert.That(store.TryLoad(out var loaded, out var error), Is.True, error);
Assert.That(loaded.puzzleStates[0].camera.distance, Is.EqualTo(8));
Assert.That(error, Does.Contain("Recovered"));
Assert.That(store.TrySave(loaded, out error), Is.True, error);
var damaged = Directory.GetFiles(_directory, "*.damaged-*").Single();
Assert.That(File.ReadAllText(damaged), Is.EqualTo("broken-primary"));
Assert.That(File.ReadAllText(_path + ".bak"), Does.Not.Contain("broken-primary"));
Assert.That(File.Exists(_path + ".tmp"), Is.False);
}
[Test] public void WithoutValidBackupLoaderDoesNotOverwriteDamagedEvidence()
{
File.WriteAllText(_path, "broken-primary"); File.WriteAllText(_path + ".bak", "broken-backup");
var store = Store(); Assert.That(store.TryLoad(out _, out _), Is.False);
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"));
}
[Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave()
{
var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True);
var before = File.ReadAllBytes(_path); progress.puzzleStates[0].groups[0].ballIds = new[] { "missing" };
Assert.That(store.TrySave(progress, out _), Is.False); Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before));
}
[TestCase("schemaVersion", "null")]
[TestCase("schemaVersion", "\"1\"")]
[TestCase("schemaVersion", "1.5")]
[TestCase("distance", "null")]
[TestCase("distance", "\"8\"")]
[TestCase("x", "null")]
[TestCase("x", "\"0\"")]
public void WrongScalarTypesAreRejected(string field, string value)
{
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
var json = File.ReadAllText(_path);
json = System.Text.RegularExpressions.Regex.Replace(json, "\"" + field + "\":\\s*-?[0-9]+(?:\\.[0-9]+)?", "\"" + field + "\":" + value);
File.WriteAllText(_path, json); Assert.That(Store().TryLoad(out var loaded, out _), Is.False); Assert.That(loaded, Is.Null);
}
[Test] public void SavedJsonContainsOnlyContractFields()
{
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
Assert.That(File.ReadAllText(_path), Does.Not.Contain("IsValid"));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1d3b301c187eab84086f1a6504b44942