588 lines
56 KiB
C#
588 lines
56 KiB
C#
#if UNITY_EDITOR
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using MagnetismHeroes.Core;
|
|
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
|
using MagnetismHeroes.UnityRuntime.Presentation;
|
|
using NUnit.Framework;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using UnityEngine.TestTools;
|
|
using TMPro;
|
|
using Unity.Collections;
|
|
|
|
namespace MagnetismHeroes.Tests
|
|
{
|
|
public sealed class PlaygroundResumePlayTests
|
|
{
|
|
private sealed class Store : IProgressStore
|
|
{
|
|
public ProgressSave Save;
|
|
public int Writes;
|
|
public bool FailWrites;
|
|
public bool TryLoad(out ProgressSave save, out string error) { save = Clone(Save); error = null; return true; }
|
|
public bool TrySave(ProgressSave save, out string error) { Writes++; error = FailWrites ? "test storage unavailable" : null; if (FailWrites) return false; Save = Clone(save); return true; }
|
|
}
|
|
private GameApp _app;
|
|
private Store _store;
|
|
private readonly List<Object> _fixtures = new List<Object>();
|
|
private static ProgressSave Clone(ProgressSave value)
|
|
{
|
|
var copy = JsonUtility.FromJson<ProgressSave>(JsonUtility.ToJson(value));
|
|
// JsonUtility recreates a null nested serializable class; the store contract keeps null.
|
|
if (value.latestFreePlay == null) copy.latestFreePlay = null;
|
|
return copy;
|
|
}
|
|
private static BallState Ball(string id, float x) => new BallState { id = id, position = new Position3(x, 5, 0) };
|
|
private static GroupState Group(string id, params string[] balls) => new GroupState { id = id, ballIds = balls };
|
|
[UnitySetUp] public IEnumerator Setup()
|
|
{
|
|
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled = false;
|
|
_app = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab")).GetComponent<GameApp>();
|
|
_store = new Store { Save = new ProgressSave { completedStageIds = Enumerable.Range(1, 30).ToArray(), seenTutorialIds = new[] { "merge", "split" } } };
|
|
_app.Initialize(_store);
|
|
yield return null;
|
|
}
|
|
[UnityTearDown] public IEnumerator Teardown() { foreach (var fixture in _fixtures) Object.Destroy(fixture); _fixtures.Clear(); Object.Destroy(_app.gameObject); yield return null; }
|
|
private void Reload() { _app.Initialize(_store); }
|
|
private TMP_Text Text(string field) => (TMP_Text)new SerializedObject(_app).FindProperty(field).objectReferenceValue;
|
|
private BoxCollider Box(Vector3 center, Vector3 size)
|
|
{
|
|
var obstacle = new GameObject("test-only-restore-obstacle"); _fixtures.Add(obstacle); obstacle.transform.position = center;
|
|
var box = obstacle.AddComponent<BoxCollider>(); box.size = size; Physics.SyncTransforms(); return box;
|
|
}
|
|
private void Free(Vector3 point)
|
|
{
|
|
_store.Save.latestFreePlay = new FreePlayState { sourceStageId = 6,
|
|
balls = new[] { new BallState { id = "legacy-free", position = BoardPresenter.ToPosition(point) } },
|
|
groups = new[] { Group("legacy-free-group", "legacy-free") }, camera = GameSession.DefaultCamera() };
|
|
Reload();
|
|
}
|
|
private void MeshDiagnostic(MeshCollider collider, Vector3 source, Vector3 candidate)
|
|
{
|
|
var cache = AssetDatabase.LoadAssetAtPath<PlaygroundRestoreGeometry>("Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset");
|
|
Assert.That(cache.TryGet(collider.sharedMesh, out var vertices, out var triangles), Is.True);
|
|
using (var dataArray = MeshUtility.AcquireReadOnlyMeshData(collider.sharedMesh))
|
|
{
|
|
var data = dataArray[0]; using var buffer = new NativeArray<Vector3>(data.vertexCount, Allocator.Temp); data.GetVertices(buffer);
|
|
Assert.That(vertices.SequenceEqual(buffer.ToArray()), Is.True, "Cache local vertices must equal actual imported collider MeshData.");
|
|
var indices = data.indexFormat == UnityEngine.Rendering.IndexFormat.UInt16 ? data.GetIndexData<ushort>().ToArray().Select(index => (int)index).ToArray() : data.GetIndexData<uint>().ToArray().Select(index => (int)index).ToArray();
|
|
var actual = Enumerable.Range(0, data.subMeshCount).SelectMany(submesh => { var descriptor = data.GetSubMesh(submesh); return Enumerable.Range(descriptor.indexStart, descriptor.indexCount).Select(index => indices[index] + descriptor.baseVertex); }).ToArray();
|
|
Assert.That(triangles.SequenceEqual(actual), Is.True, "Cache triangle indices must equal actual imported collider MeshData.");
|
|
}
|
|
foreach (var point in new[] { source, candidate })
|
|
{
|
|
float closest = float.PositiveInfinity; Vector3 surface = Vector3.zero; int closestTriangle = -1;
|
|
for (int index = 0; index < triangles.Length; index += 3)
|
|
{
|
|
var a = collider.transform.TransformPoint(vertices[triangles[index]]); var b = collider.transform.TransformPoint(vertices[triangles[index+1]]); var c = collider.transform.TransformPoint(vertices[triangles[index+2]]);
|
|
var normal = Vector3.Cross(b-a,c-a); if (normal.sqrMagnitude < .000000001f) continue;
|
|
var projected = point - normal * (Vector3.Dot(point-a,normal)/normal.sqrMagnitude);
|
|
var candidates = new List<Vector3>();
|
|
if (Vector3.Dot(Vector3.Cross(b-a,projected-a),normal)>=0 && Vector3.Dot(Vector3.Cross(c-b,projected-b),normal)>=0 && Vector3.Dot(Vector3.Cross(a-c,projected-c),normal)>=0) candidates.Add(projected);
|
|
foreach (var edge in new[] { (a,b), (b,c), (c,a) }) candidates.Add(edge.Item1 + (edge.Item2-edge.Item1) * Mathf.Clamp01(Vector3.Dot(point-edge.Item1,edge.Item2-edge.Item1)/(edge.Item2-edge.Item1).sqrMagnitude));
|
|
foreach (var nearest in candidates) if (Vector3.Distance(point,nearest)<closest) { closest=Vector3.Distance(point,nearest);surface=nearest;closestTriangle=index/3; }
|
|
}
|
|
bool original = Physics.queriesHitBackfaces;
|
|
try
|
|
{
|
|
var crossings = _app.Environment.GetType().GetMethod("SurfaceCrossings", System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic);
|
|
foreach (var direction in new[] {new Vector3(1,.371f,.529f).normalized,new Vector3(.217f,1,.613f).normalized,new Vector3(.419f,.293f,1).normalized})
|
|
{
|
|
Physics.queriesHitBackfaces=false; int entries=(int)crossings.Invoke(_app.Environment,new object[]{collider,point,direction});
|
|
Physics.queriesHitBackfaces=true; int total=(int)crossings.Invoke(_app.Environment,new object[]{collider,point,direction});
|
|
Debug.Log($"[ProgressRestoreMeshDiagnostic] point={point:R}, direction={direction:R}, nativeEntries={entries}, nativeCrossings={total}");
|
|
}
|
|
}
|
|
finally {Physics.queriesHitBackfaces=original;}
|
|
Debug.Log($"[ProgressRestoreMeshDiagnostic] mesh={collider.sharedMesh.name}, point={point:R}, closestSurface={surface:R}, distance={closest:R}, triangle={closestTriangle}, position={collider.transform.position:R}, rotation={collider.transform.rotation:R}, scale={collider.transform.lossyScale:R}, vertices={vertices.Length}, triangles={triangles.Length/3}");
|
|
}
|
|
}
|
|
private delegate void MeshDistance(Vector3 point, out Vector3 nearest, out float distance, out bool inside);
|
|
[UnityTest] public IEnumerator ApprovedTreeDistanceMatchesFrozenReferenceAndNativeAndSyntheticEntriesRemainStable()
|
|
{
|
|
_store.Save.puzzleStates=new[]{new BoardRules(_app.Session.Definitions.First(stage=>stage.Id==2)).ToPuzzleState(GameSession.DefaultCamera()),new BoardRules(_app.Session.Definitions.First(stage=>stage.Id==9)).ToPuzzleState(GameSession.DefaultCamera())};
|
|
Reload();_app.StartStage(6);yield return null;
|
|
var cache=(PlaygroundRestoreGeometry)new SerializedObject(_app).FindProperty("_restoreGeometry").objectReferenceValue;
|
|
var meshType=typeof(PlaygroundResume).GetNestedType("RestoreMesh",System.Reflection.BindingFlags.NonPublic);
|
|
// The same authored stage 6 tree (loop index 5) now frames the board from behind.
|
|
var nativePoint=new Vector3(1.6815286f,1.6242015f,7.1185765f);
|
|
var native=_app.Environment.GetComponentsInChildren<MeshCollider>().Single(mesh=>mesh.enabled&&mesh.sharedMesh.name=="RoundCanopyTree"
|
|
&&Mathf.Abs(Mathf.DeltaAngle(mesh.transform.parent.localEulerAngles.y,235))<.001f
|
|
&&Vector3.Distance(mesh.transform.parent.localScale,Vector3.one*1.11f)<.00001f);
|
|
Assert.That(Vector3.Distance(native.transform.parent.localPosition,new Vector3(1.8652403f,-.56261694f,7.073643f)),Is.LessThan(.001f));
|
|
Assert.That(Vector3.Distance(native.bounds.center,nativePoint),Is.LessThan(.001f));
|
|
foreach(string fixture in new[]{"native-stage6","synthetic-tree"})
|
|
{
|
|
var collider=native;
|
|
if(fixture=="synthetic-tree")
|
|
{
|
|
foreach(var item in _app.Environment.GetComponentsInChildren<Collider>()) if(!item.isTrigger&&!item.name.Contains("Clearing")) item.enabled=false;
|
|
var obstacle=Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/RoundCanopyTree.prefab"),_app.Environment.transform);_fixtures.Add(obstacle);
|
|
obstacle.transform.localScale=Vector3.one*2;collider=obstacle.GetComponentInChildren<MeshCollider>();Physics.SyncTransforms();
|
|
obstacle.transform.position+=new Vector3(0,3,0)-collider.bounds.center;Physics.SyncTransforms();
|
|
}
|
|
Assert.That(collider.sharedMesh.isReadable,Is.False);Assert.That(cache.TryGet(collider.sharedMesh,out var local,out var triangles),Is.True);
|
|
Assert.That(local.Length,Is.EqualTo(4187));Assert.That(triangles.Length/3,Is.EqualTo(4840));
|
|
var world=local.Select(collider.transform.TransformPoint).ToArray();
|
|
var geometry=System.Activator.CreateInstance(meshType,new object[]{collider,local,triangles});Assert.That(meshType.GetProperty("IsClosed").GetValue(geometry),Is.True);
|
|
var distance=(MeshDistance)meshType.GetMethod("Distance").CreateDelegate(typeof(MeshDistance),geometry);
|
|
var samples=new List<Vector3>{collider.bounds.center,collider.bounds.min-Vector3.one,collider.bounds.max+Vector3.one};
|
|
for(int index=0;index<24;index++)
|
|
{
|
|
int triangle=(index*(triangles.Length/3)/24)*3;var a=world[triangles[triangle]];var b=world[triangles[triangle+1]];var c=world[triangles[triangle+2]];
|
|
var point=(a+b+c)/3;var normal=Vector3.Cross(b-a,c-a).normalized;
|
|
samples.Add(point);samples.Add(point+normal*.002f);samples.Add(point-normal*.002f);
|
|
}
|
|
// Keep the original 75 samples, then cover vertices/edges on both sides of numerical surface boundaries.
|
|
for(int index=0;index<24;index++)
|
|
{
|
|
int triangle=(index*(triangles.Length/3)/24)*3;var a=world[triangles[triangle]];var b=world[triangles[triangle+1]];var c=world[triangles[triangle+2]];
|
|
var normal=Vector3.Cross(b-a,c-a).normalized;var edge=(a+b)*.5f;
|
|
foreach(var point in new[]{a,edge}) {samples.Add(point);samples.Add(point+normal*.000001f);samples.Add(point-normal*.000001f);}
|
|
}
|
|
foreach(var axis in new[]{Vector3.right,Vector3.up,Vector3.forward}) {samples.Add(collider.bounds.center+axis*1000);samples.Add(collider.bounds.center-axis*1000);}
|
|
int insideSamples=0,outsideSamples=0;
|
|
foreach(var point in samples)
|
|
{
|
|
ReferenceMeshDistance(world,triangles,point,out var expectedNearest,out float expectedDistance,out bool expectedInside,out double winding);
|
|
distance(point,out var actualNearest,out float actualDistance,out bool actualInside);
|
|
Assert.That(actualNearest.x,Is.EqualTo(expectedNearest.x));Assert.That(actualNearest.y,Is.EqualTo(expectedNearest.y));Assert.That(actualNearest.z,Is.EqualTo(expectedNearest.z));
|
|
Assert.That(actualDistance,Is.EqualTo(expectedDistance));Assert.That(actualInside,Is.EqualTo(expectedInside));Assert.That(actualInside,Is.EqualTo(System.Math.Abs(winding)>System.Math.PI*2));
|
|
if(expectedInside) insideSamples++;else outsideSamples++;
|
|
}
|
|
Assert.That(insideSamples,Is.GreaterThan(0));Assert.That(outsideSamples,Is.GreaterThan(0));
|
|
AssetDatabase.TryGetGUIDAndLocalFileIdentifier(collider.sharedMesh,out string guid,out long fileId);
|
|
Debug.Log($"[ResumeMagnitudeReference] fixture={fixture}, meshGuid={guid}, fileId={fileId}, geometryHash={PlaygroundRestoreGeometry.Hash(local,triangles)}, vertices={local.Length}, triangles={triangles.Length/3}, position={collider.transform.position:R}, rotation={collider.transform.rotation:R}, scale={collider.transform.lossyScale:R}, samples={samples.Count}, inside={insideSamples}, outside={outsideSamples}");
|
|
var timingPoints=samples.Take(12).ToArray();
|
|
for(int warmup=0;warmup<2;warmup++) foreach(var point in timingPoints) {ReferenceMeshDistance(world,triangles,point,out _,out _,out _,out _);distance(point,out _,out _,out _);}
|
|
for(int batch=0;batch<5;batch++)
|
|
{
|
|
var timer=System.Diagnostics.Stopwatch.StartNew();foreach(var point in timingPoints) ReferenceMeshDistance(world,triangles,point,out _,out _,out _,out _);timer.Stop();double referenceMilliseconds=timer.Elapsed.TotalMilliseconds;
|
|
timer.Restart();foreach(var point in timingPoints) ReferenceMeshDistance(world,triangles,point,out _,out _,out _,out _,false);timer.Stop();double nearestMilliseconds=timer.Elapsed.TotalMilliseconds;
|
|
timer.Restart();foreach(var point in timingPoints) distance(point,out _,out _,out _);timer.Stop();
|
|
Debug.Log($"[ResumeMagnitudeQueryTiming] fixture={fixture}, batch={batch}, queries={timingPoints.Length}, referenceMs={referenceMilliseconds:R}, nearestOnlyMs={nearestMilliseconds:R}, runtimeMs={timer.Elapsed.TotalMilliseconds:R}");
|
|
}
|
|
string puzzles=string.Join("|",_store.Save.puzzleStates.Select(state=>JsonUtility.ToJson(state)));var completed=(int[])_store.Save.completedStageIds.Clone();var tutorials=(string[])_store.Save.seenTutorialIds.Clone();
|
|
string expected=null;
|
|
for(int iteration=0;iteration<4;iteration++)
|
|
{
|
|
_store.Save.latestFreePlay=new FreePlayState{sourceStageId=6,balls=new[]{new BallState{id="native-tree-interior",position=BoardPresenter.ToPosition(collider.bounds.center)}},groups=new[]{Group("native-group","native-tree-interior")},camera=GameSession.DefaultCamera()};
|
|
Reload();int writes=_store.Writes;var timer=System.Diagnostics.Stopwatch.StartNew();_app.ContinueFreePlay();timer.Stop();yield return null;
|
|
Assert.That(_app.Session.Screen,Is.EqualTo(SessionScreen.FreePlay));Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board),Is.True);Assert.That(_store.Writes,Is.EqualTo(writes+1));
|
|
Assert.That(_app.Session.Board.Balls.Select(ball=>ball.id),Is.EqualTo(new[]{"native-tree-interior"}));Assert.That(_app.Session.Board.Groups.Single().id,Is.EqualTo("native-group"));
|
|
Assert.That(string.Join("|",_store.Save.puzzleStates.Select(state=>JsonUtility.ToJson(state))),Is.EqualTo(puzzles));Assert.That(_store.Save.completedStageIds,Is.EqualTo(completed));Assert.That(_store.Save.seenTutorialIds,Is.EqualTo(tutorials));
|
|
string result=JsonUtility.ToJson(_store.Save.latestFreePlay);if(expected==null) expected=result;else Assert.That(result,Is.EqualTo(expected));
|
|
Debug.Log($"[ResumeMagnitudeEntryTiming] fixture={fixture}, warmup={iteration==0}, sample={iteration-1}, utc={System.DateTime.UtcNow:O}, entryMs={timer.Elapsed.TotalMilliseconds:R}, result={result}");
|
|
}
|
|
_app.ShowHome();string stable=JsonUtility.ToJson(_store.Save.latestFreePlay);_app.ContinueFreePlay();yield return null;Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay),Is.EqualTo(stable));Assert.That(_app.Instruction,Is.Not.EqualTo("친구를 놀이터로 다시 보냈어요."));
|
|
}
|
|
}
|
|
[UnityTest] public IEnumerator ClosedComponentsAndUnsupportedTopologyMatchTheFrozenWindingAtEdgesOverlapsAndRemotePoints()
|
|
{
|
|
var primitive=GameObject.CreatePrimitive(PrimitiveType.Cube);_fixtures.Add(primitive);
|
|
var cube=primitive.GetComponent<MeshFilter>().sharedMesh;var cubeVertices=cube.vertices;var cubeTriangles=cube.triangles;primitive.SetActive(false);
|
|
var meshType=typeof(PlaygroundResume).GetNestedType("RestoreMesh",System.Reflection.BindingFlags.NonPublic);
|
|
foreach(string configuration in new[]{"overlap","opposite","vertex-touch","open","collapsed","thin"})
|
|
{
|
|
var vertices=new List<Vector3>();var triangles=new List<int>();
|
|
foreach(var center in new[]{Vector3.zero,configuration=="vertex-touch"?Vector3.one:new Vector3(.3f,.2f,.1f),new Vector3(7,3,-4)})
|
|
{
|
|
int offset=vertices.Count;vertices.AddRange(cubeVertices.Select(point=>point+center));var indices=cubeTriangles;
|
|
if(configuration=="opposite"&&offset==cubeVertices.Length) indices=cubeTriangles.Reverse().ToArray();
|
|
triangles.AddRange(indices.Select(index=>offset+index));
|
|
}
|
|
if(configuration=="open") triangles.RemoveRange(0,3);
|
|
var mesh=new Mesh();mesh.vertices=vertices.ToArray();mesh.triangles=triangles.ToArray();mesh.RecalculateBounds();_fixtures.Add(mesh);
|
|
var obstacle=new GameObject("test-only-component-distance");_fixtures.Add(obstacle);obstacle.transform.SetPositionAndRotation(new Vector3(3,-2,5),Quaternion.Euler(23,37,11));obstacle.transform.localScale=new Vector3(1.7f,configuration=="collapsed"?0:configuration=="thin"?.0001f:.65f,-1.1f);
|
|
var collider=obstacle.AddComponent<MeshCollider>();collider.sharedMesh=mesh;var world=vertices.Select(collider.transform.TransformPoint).ToArray();
|
|
var geometry=System.Activator.CreateInstance(meshType,new object[]{collider,vertices.ToArray(),triangles.ToArray()});bool closed=(bool)meshType.GetProperty("IsClosed").GetValue(geometry);
|
|
Assert.That(closed,Is.EqualTo(configuration!="open"));var distance=(MeshDistance)meshType.GetMethod("Distance").CreateDelegate(typeof(MeshDistance),geometry);
|
|
var points=new List<Vector3>{obstacle.transform.TransformPoint(new Vector3(.15f,.1f,.05f)),obstacle.transform.TransformPoint(new Vector3(7,3,-4)),Vector3.one*1000,-Vector3.one*1000};
|
|
for(int index=0;index<triangles.Count;index+=3)
|
|
{
|
|
var a=world[triangles[index]];var b=world[triangles[index+1]];var c=world[triangles[index+2]];var normal=Vector3.Cross(b-a,c-a).normalized;
|
|
foreach(var point in new[]{a,(a+b)*.5f,(a+b+c)/3}) {points.Add(point);points.Add(point+normal*.000001f);points.Add(point-normal*.000001f);}
|
|
}
|
|
// Component faces just beyond the winding bound, including singular and non-finite query controls.
|
|
for(int offset=0;offset<world.Length;offset+=cubeVertices.Length)
|
|
{
|
|
var part=world.Skip(offset).Take(cubeVertices.Length).ToArray();var min=part.Aggregate(Vector3.Min);var max=part.Aggregate(Vector3.Max);
|
|
for(int axis=0;axis<3;axis++) foreach(float face in new[]{min[axis],max[axis]})
|
|
{
|
|
var point=(min+max)*.5f;point[axis]=face;var delta=Vector3.zero;delta[axis]=.0002f;
|
|
points.Add(point);points.Add(point+delta);points.Add(point-delta);
|
|
}
|
|
}
|
|
points.Add(new Vector3(float.NaN,0,0));points.Add(new Vector3(float.PositiveInfinity,0,0));points.Add(new Vector3(float.NegativeInfinity,0,0));
|
|
foreach(var point in points)
|
|
{
|
|
ReferenceMeshDistance(world,triangles.ToArray(),point,out var expectedNearest,out float expectedDistance,out bool expectedInside,out _,closed);
|
|
distance(point,out var actualNearest,out float actualDistance,out bool actualInside);
|
|
Assert.That(actualNearest,Is.EqualTo(expectedNearest));Assert.That(actualDistance,Is.EqualTo(expectedDistance));Assert.That(actualInside,Is.EqualTo(expectedInside),configuration+" "+point.ToString("R"));
|
|
}
|
|
Debug.Log($"[ResumeDistancePathology] configuration={configuration}, closed={closed}, samples={points.Count}, exact=True");
|
|
obstacle.SetActive(false);
|
|
}
|
|
yield return null;
|
|
}
|
|
private static void ReferenceMeshDistance(Vector3[] vertices, int[] triangles, Vector3 point, out Vector3 nearest, out float distance, out bool inside, out double winding, bool closed = true)
|
|
{
|
|
nearest = Vector3.zero; float squared = float.PositiveInfinity; winding = 0;
|
|
for (int index = 0; index < triangles.Length; index += 3)
|
|
{
|
|
var a = vertices[triangles[index]]; var b = vertices[triangles[index + 1]]; var c = vertices[triangles[index + 2]];
|
|
var candidate = ReferenceClosestTriangle(point, a, b, c); float candidateSquared = (candidate - point).sqrMagnitude;
|
|
if (candidateSquared < squared) { squared = candidateSquared; nearest = candidate; }
|
|
if (!closed) continue;
|
|
a -= point; b -= point; c -= point;
|
|
double numerator = Vector3.Dot(a, Vector3.Cross(b, c));
|
|
double denominator = (double)a.magnitude * b.magnitude * c.magnitude + (double)Vector3.Dot(a, b) * c.magnitude
|
|
+ (double)Vector3.Dot(b, c) * a.magnitude + (double)Vector3.Dot(c, a) * b.magnitude;
|
|
winding += 2 * System.Math.Atan2(numerator, denominator);
|
|
}
|
|
distance = Mathf.Sqrt(squared); inside = closed && System.Math.Abs(winding) > System.Math.PI * 2;
|
|
}
|
|
private static Vector3 ReferenceClosestTriangle(Vector3 point, Vector3 a, Vector3 b, Vector3 c)
|
|
{
|
|
var ab = b - a; var ac = c - a; var ap = point - a; float d1 = Vector3.Dot(ab, ap), d2 = Vector3.Dot(ac, ap);
|
|
if (d1 <= 0 && d2 <= 0) return a;
|
|
var bp = point - b; float d3 = Vector3.Dot(ab, bp), d4 = Vector3.Dot(ac, bp);
|
|
if (d3 >= 0 && d4 <= d3) return b;
|
|
float vc = d1 * d4 - d3 * d2; if (vc <= 0 && d1 >= 0 && d3 <= 0) return a + ab * (d1 / (d1 - d3));
|
|
var cp = point - c; float d5 = Vector3.Dot(ab, cp), d6 = Vector3.Dot(ac, cp);
|
|
if (d6 >= 0 && d5 <= d6) return c;
|
|
float vb = d5 * d2 - d1 * d6; if (vb <= 0 && d2 >= 0 && d6 <= 0) return a + ac * (d2 / (d2 - d6));
|
|
float va = d3 * d6 - d5 * d4; if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) return b + (c - b) * ((d4 - d3) / (d4 - d3 + d5 - d6));
|
|
float inverse = 1 / (va + vb + vc); return a + ab * (vb * inverse) + ac * (vc * inverse);
|
|
}
|
|
[UnityTest] public IEnumerator ActualGardenSplitFiveResumeRepairsWithoutChangingThreeAndTwoMembershipOrOtherSlots()
|
|
{
|
|
_store.Save.puzzleStates=new[]{new BoardRules(_app.Session.Definitions.First(stage=>stage.Id==2)).ToPuzzleState(GameSession.DefaultCamera()),new BoardRules(_app.Session.Definitions.First(stage=>stage.Id==6)).ToPuzzleState(GameSession.DefaultCamera())};
|
|
Reload();_app.StartStage(9);
|
|
var camera = new CameraState {target=new Position3(0,.6f,0),distance=11,rotation=new Rotation4(-.022557568f,.96225017f,.08418597f,.2578342f)};
|
|
var saved = new FreePlayState {sourceStageId=9,camera=camera,
|
|
balls=new[] {
|
|
new BallState {id="s09-b01",position=new Position3(-1.0351909f,.8f,.60371524f)},
|
|
new BallState {id="s09-b02",position=new Position3(-.03519098f,.8f,.60371524f)},
|
|
new BallState {id="s09-b03",position=new Position3(-3.9800465f,.8f,1.649168f)},
|
|
new BallState {id="s09-b04",position=new Position3(.964809f,.8f,.60371524f)},
|
|
new BallState {id="s09-b05",position=new Position3(-2.9800465f,.8f,1.649168f)}},
|
|
groups=new[] {new GroupState {id="s09-g01",ballIds=new[] {"s09-b01","s09-b02","s09-b04"}},new GroupState {id="s09-g01-split",ballIds=new[] {"s09-b03","s09-b05"}}}};
|
|
var rawRotation=new Quaternion(camera.rotation.x,camera.rotation.y,camera.rotation.z,camera.rotation.w);
|
|
var rawEye=BoardPresenter.ToVector(camera.target)+Vector3.up*.65f-rawRotation*Vector3.forward*camera.distance;
|
|
Assert.That(rawEye.y,Is.LessThan(_app.Environment.PlaygroundBounds.min.y),"The exact old Content camera is underground and must be corrected separately from the friends.");
|
|
var source=saved.balls.ToDictionary(ball=>ball.id,ball=>BoardPresenter.ToVector(ball.position));
|
|
var fixedPoints=saved.groups[0].ballIds.Select(id=>source[id]).ToArray();
|
|
Assert.That(fixedPoints.All(point=>_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point))),Is.True);
|
|
var movedPoints=saved.groups[1].ballIds.Select(id=>source[id]).ToArray();
|
|
Assert.That(movedPoints.All(point=>_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point))),Is.False);
|
|
Assert.That(movedPoints.All(point=>_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point+Vector3.forward))&&fixedPoints.All(other=>Vector3.Distance(point+Vector3.forward,other)>=.999f)),Is.True,"A valid one-unit escape proves this board is repairable.");
|
|
float sampledUpperBound=1;
|
|
for(int index=0;index<64;index++)
|
|
{
|
|
float y=1-2*(index+.5f)/64,angle=index*2.39996323f;
|
|
var direction=new Vector3(Mathf.Sqrt(1-y*y)*Mathf.Cos(angle),y,Mathf.Sqrt(1-y*y)*Mathf.Sin(angle));
|
|
for(float radius=.01f;radius<sampledUpperBound;radius+=.01f)
|
|
if(movedPoints.All(point=>_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point+direction*radius))&&fixedPoints.All(other=>Vector3.Distance(point+direction*radius,other)>=.999f))) {sampledUpperBound=radius;break;}
|
|
}
|
|
_store.Save.latestFreePlay=saved; string puzzles=string.Join("|",_store.Save.puzzleStates.Select(state=>JsonUtility.ToJson(state))); var completed=(int[])_store.Save.completedStageIds.Clone(); var tutorials=(string[])_store.Save.seenTutorialIds.Clone();
|
|
Reload();int before=_store.Writes;_app.ContinueFreePlay();yield return null;
|
|
Assert.That(_app.Session.Board,Is.Not.Null,"The exact existing Content split-five record must enter after certified repair.");
|
|
Assert.That(_app.Session.Screen,Is.EqualTo(SessionScreen.FreePlay));Assert.That(_app.Session.Board.StageId,Is.EqualTo(9));
|
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board),Is.True);Assert.That(_store.Writes,Is.EqualTo(before+1));
|
|
foreach(var group in saved.groups)
|
|
{
|
|
var result=_app.Session.Board.Groups.Single(value=>value.id==group.id);Assert.That(result.ballIds,Is.EquivalentTo(group.ballIds));
|
|
var target=_app.Session.Board.Balls.ToDictionary(ball=>ball.id,ball=>BoardPresenter.ToVector(ball.position));
|
|
foreach(string id in group.ballIds) Assert.That(Vector3.Distance(target[id]-target[group.ballIds[0]],source[id]-source[group.ballIds[0]]),Is.LessThan(.0001f));
|
|
}
|
|
var restored=_app.Session.Board.Balls.ToDictionary(ball=>ball.id,ball=>BoardPresenter.ToVector(ball.position));
|
|
Assert.That(restored.Keys,Is.EquivalentTo(source.Keys));Assert.That(_app.Session.Board.Groups.Length,Is.EqualTo(2));
|
|
foreach(string id in saved.groups[0].ballIds) Assert.That(restored[id],Is.EqualTo(source[id]),"Already safe friends stay exactly placed.");
|
|
float distance=Vector3.Distance(restored["s09-b03"],source["s09-b03"]);
|
|
Assert.That(distance,Is.LessThanOrEqualTo(sampledUpperBound+.011f));
|
|
foreach(string id in saved.groups[1].ballIds) foreach(string other in saved.groups[0].ballIds) Assert.That(Vector3.Distance(restored[id],restored[other]),Is.GreaterThanOrEqualTo(.999f));
|
|
Assert.That(string.Join("|",_store.Save.puzzleStates.Select(state=>JsonUtility.ToJson(state))),Is.EqualTo(puzzles));Assert.That(_store.Save.completedStageIds,Is.EqualTo(completed));Assert.That(_store.Save.seenTutorialIds,Is.EqualTo(tutorials));
|
|
Assert.That(_app.Orbit.Camera.transform.position.y,Is.GreaterThanOrEqualTo(_app.Environment.PlaygroundBounds.min.y+.35f-.001f));
|
|
Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay.camera),Is.EqualTo(JsonUtility.ToJson(_app.Orbit.Snapshot())));
|
|
Debug.Log($"[GardenRestoreActual] utc={System.DateTime.UtcNow:O}, distance={distance:R}, independentRadialUpperBound={sampledUpperBound:R}");
|
|
_app.ShowHome();string corrected=JsonUtility.ToJson(_store.Save.latestFreePlay);_app.ContinueFreePlay();yield return null;
|
|
Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay),Is.EqualTo(corrected));Assert.That(_app.Instruction,Is.Not.EqualTo("친구를 놀이터로 다시 보냈어요."));
|
|
}
|
|
[UnityTest] public IEnumerator CountingResumeRepairsTheWholeGroupWithoutCollectingOrCompletingAndWritesOnce()
|
|
{
|
|
var puzzle = new PuzzleState { stageId = 2, balls = new[] { Ball("old-a", 30), Ball("old-b", 31) },
|
|
groups = new[] { Group("old-group", "old-a", "old-b") }, collectedBallIds = new[] { "old-a" },
|
|
countedBallIds = new[] { "old-a" }, camera = GameSession.DefaultCamera() };
|
|
_store.Save.puzzleStates = new[] { puzzle }; Reload();
|
|
var progress = (int[])_store.Save.completedStageIds.Clone(); int before = _store.Writes;
|
|
_app.StartStage(2); yield return null;
|
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True, "Actual GameApp must preflight legacy positions before displaying/saving them.");
|
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle));
|
|
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EqualTo(new[] { "old-a", "old-b" }));
|
|
Assert.That(_app.Session.Board.Groups.Single().id, Is.EqualTo("old-group"));
|
|
Assert.That(_app.Session.Board.CountedBallIds, Is.EqualTo(puzzle.countedBallIds));
|
|
Assert.That(_app.Session.Board.CollectedBallIds, Is.EqualTo(puzzle.collectedBallIds));
|
|
var positions = _app.Session.Board.Balls.Select(ball => BoardPresenter.ToVector(ball.position)).ToArray();
|
|
Assert.That(positions[1] - positions[0], Is.EqualTo(Vector3.right));
|
|
Assert.That(positions[1].x, Is.EqualTo(_app.Environment.PlaygroundBounds.max.x - .5f).Within(.002f));
|
|
Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
|
Assert.That(_store.Save.completedStageIds, Is.EqualTo(progress));
|
|
Assert.That(_app.Instruction, Is.EqualTo("친구를 놀이터로 다시 보냈어요."));
|
|
foreach (char character in _app.Instruction) Assert.That(Text("_instruction").font.HasCharacter(character), Is.True, "Approved static atlas must contain every restore character.");
|
|
_app.ShowHome(); _app.StartStage(2); yield return null;
|
|
Assert.That(_app.Instruction, Is.Not.EqualTo("친구를 놀이터로 다시 보냈어요."));
|
|
}
|
|
[UnityTest] public IEnumerator BoxInteriorMovesToTheNearestActualSafeSurfaceRatherThanAFullBoundaryEscape()
|
|
{
|
|
_app.StartStage(6); Box(new Vector3(0, 5, 0), new Vector3(2, 2, 2)); Free(new Vector3(0, 5, 0));
|
|
_app.ContinueFreePlay(); yield return null;
|
|
var restored = BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position);
|
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
|
Assert.That(Vector3.Distance(restored, new Vector3(0, 5, 0)), Is.InRange(1.498f, 1.501f));
|
|
Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("legacy-free"));
|
|
}
|
|
[UnityTest] public IEnumerator NonReadableAsymmetricClosedMeshUsesCachedTrianglesAndFindsItsNearestSafeFace()
|
|
{
|
|
_app.StartStage(6);
|
|
foreach (var collider in _app.Environment.GetComponentsInChildren<Collider>()) if (!collider.isTrigger && !collider.name.Contains("Clearing")) collider.enabled = false;
|
|
var obstacle = new GameObject("test-only-asymmetric-closed-mesh"); _fixtures.Add(obstacle);
|
|
obstacle.transform.SetParent(_app.Environment.transform, true);
|
|
obstacle.transform.SetPositionAndRotation(new Vector3(0, 3, 0), Quaternion.Euler(0, 31, 0));
|
|
var vertices = new[] { new Vector3(-1,-2,-3),new Vector3(1,-2,-3),new Vector3(1,2,-3),new Vector3(-1,2,-3),
|
|
new Vector3(-1,-2,3),new Vector3(1,-2,3),new Vector3(1,2,3),new Vector3(-1,2,3) };
|
|
var triangles = new[] { 0,2,1,0,3,2,4,5,6,4,6,7,0,1,5,0,5,4,3,7,6,3,6,2,0,4,7,0,7,3,1,2,6,1,6,5 };
|
|
var mesh = new Mesh { vertices = vertices, triangles = triangles }; mesh.RecalculateBounds(); _fixtures.Add(mesh);
|
|
var colliderMesh = obstacle.AddComponent<MeshCollider>(); colliderMesh.sharedMesh = mesh;
|
|
var original = AssetDatabase.LoadAssetAtPath<PlaygroundRestoreGeometry>("Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset");
|
|
var cache = Object.Instantiate(original); _fixtures.Add(cache);
|
|
var entries = new List<PlaygroundRestoreGeometry.Entry>();
|
|
foreach (var actual in _app.Environment.GetComponentsInChildren<MeshCollider>())
|
|
if (original.TryGet(actual.sharedMesh, out var sourceVertices, out var sourceTriangles)) entries.Add(new PlaygroundRestoreGeometry.Entry {
|
|
mesh = actual.sharedMesh, vertices = sourceVertices, triangles = sourceTriangles, bounds = actual.sharedMesh.bounds,
|
|
geometrySha256 = PlaygroundRestoreGeometry.Hash(sourceVertices, sourceTriangles) });
|
|
entries.Add(new PlaygroundRestoreGeometry.Entry { mesh = mesh, vertices = vertices, triangles = triangles, bounds = mesh.bounds,
|
|
geometrySha256 = PlaygroundRestoreGeometry.Hash(vertices, triangles) }); cache.SetEntries(entries.ToArray());
|
|
mesh.UploadMeshData(true); Assert.That(mesh.isReadable, Is.False);
|
|
_app.BindRestoreGeometry(cache); Physics.SyncTransforms(); Free(obstacle.transform.position);
|
|
_app.ContinueFreePlay(); yield return null;
|
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
|
var delta = BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position) - obstacle.transform.position;
|
|
Assert.That(delta.magnitude, Is.InRange(1.498f, 1.501f));
|
|
Assert.That(Mathf.Abs(Vector3.Dot(delta.normalized, obstacle.transform.right)), Is.GreaterThan(.999f));
|
|
Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("legacy-free"));
|
|
}
|
|
[UnityTest] public IEnumerator NearestCorrectionAccountsForAnotherObstacleAcrossAnOtherwiseSafeEscapeInterval()
|
|
{
|
|
_app.StartStage(6);
|
|
Box(new Vector3(0,5,0),new Vector3(2,2,2)); Box(new Vector3(-1.7f,5,0),new Vector3(.2f,2,.4f));
|
|
var point = new Vector3(-.8f,5,0); Free(point); _app.ContinueFreePlay(); yield return null;
|
|
Assert.That(_app.Session.Screen,Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board),Is.True);
|
|
float expected = Mathf.Sqrt(.699f*.699f + Mathf.Pow(.2f+Mathf.Sqrt(.499f*.499f-.101f*.101f),2));
|
|
float distance = Vector3.Distance(point,BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position));
|
|
Assert.That(distance,Is.InRange(expected-.003f,expected+.011f));
|
|
Assert.That(_app.Session.Board.Balls.Single().id,Is.EqualTo("legacy-free"));
|
|
}
|
|
[UnityTest] public IEnumerator ExhaustiveTerrainCellExclusionCanResetWithoutMistakingACappedSearchForProof()
|
|
{
|
|
var state = new PuzzleState { stageId=2, balls=new[] {new BallState{id="covered-a",position=new Position3(-8,.05f,-6)},new BallState{id="covered-b",position=new Position3(8,6,6)}},
|
|
groups=new[]{Group("covered-group","covered-a","covered-b")},camera=GameSession.DefaultCamera()};
|
|
_store.Save.puzzleStates=new[]{state};
|
|
// Neither solid contains the entire center domain. Their actual sphere-clearance excludes every cell.
|
|
Box(new Vector3(-8.075f,.05f,-6),new Vector3(.15f,.3f,.3f)); Box(new Vector3(-7.925f,.05f,-6),new Vector3(.15f,.3f,.3f));
|
|
Reload();int before=_store.Writes;_app.StartStage(2);yield return null;
|
|
Assert.That(_app.Session.Board.Balls.Select(ball=>ball.id),Is.EqualTo(new[]{"s02-b01","s02-b02"}));
|
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board),Is.True);Assert.That(_store.Writes,Is.EqualTo(before+1));
|
|
Assert.That(_app.Instruction,Is.EqualTo("친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요."));
|
|
}
|
|
[UnityTest] public IEnumerator UnsupportedSolidSearchFailureKeepsTheRecordWithoutResettingOrWriting()
|
|
{
|
|
_app.StartStage(6);
|
|
foreach (var collider in _app.Environment.GetComponentsInChildren<Collider>()) if (!collider.isTrigger) collider.enabled = false;
|
|
var obstacle = new GameObject("test-only-unsupported-restore-solid"); _fixtures.Add(obstacle);
|
|
obstacle.transform.position = new Vector3(0,3,0); var capsule = obstacle.AddComponent<CapsuleCollider>(); capsule.radius = 50; capsule.height = 100;
|
|
Free(new Vector3(0,3,0)); string saved = JsonUtility.ToJson(_store.Save); int before = _store.Writes;
|
|
_app.ContinueFreePlay(); yield return null;
|
|
Assert.That(_app.Session.Screen,Is.EqualTo(SessionScreen.Home)); Assert.That(_app.Session.Board,Is.Null);
|
|
Assert.That(_store.Writes,Is.EqualTo(before)); Assert.That(JsonUtility.ToJson(_store.Save),Is.EqualTo(saved));
|
|
Assert.That(_app.Instruction,Is.Not.EqualTo("친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요."));
|
|
}
|
|
[UnityTest] public IEnumerator CorruptGeometryChecksumKeepsTheExistingRecordInsteadOfTreatingSearchFailureAsImpossible()
|
|
{
|
|
_app.StartStage(6); var cache = Object.Instantiate(AssetDatabase.LoadAssetAtPath<PlaygroundRestoreGeometry>("Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset")); _fixtures.Add(cache);
|
|
var serialized = new SerializedObject(cache); var entries = serialized.FindProperty("_entries");
|
|
for (int index = 0; index < entries.arraySize; index++) entries.GetArrayElementAtIndex(index).FindPropertyRelative("geometrySha256").stringValue = "corrupt";
|
|
serialized.ApplyModifiedPropertiesWithoutUndo(); _app.BindRestoreGeometry(cache);
|
|
Free(new Vector3(30, 5, 0)); string saved = JsonUtility.ToJson(_store.Save); int before = _store.Writes;
|
|
_app.ContinueFreePlay(); yield return null;
|
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Home)); Assert.That(_app.Session.Board, Is.Null);
|
|
Assert.That(_store.Writes, Is.EqualTo(before)); Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved));
|
|
Assert.That(_app.Instruction, Is.Not.EqualTo("친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요."));
|
|
}
|
|
[UnityTest] public IEnumerator ApprovedUnreadableRockAndOverlappingTreeDeepInteriorsResumeThroughTheRuntimeCache()
|
|
{
|
|
_app.StartStage(6);
|
|
foreach (var collider in _app.Environment.GetComponentsInChildren<Collider>()) if (!collider.isTrigger && !collider.name.Contains("Clearing")) collider.enabled = false;
|
|
foreach (string name in new[] { "SlateBoulder", "RoundCanopyTree" })
|
|
{
|
|
var obstacle = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/" + name + ".prefab"), _app.Environment.transform);
|
|
_fixtures.Add(obstacle); obstacle.transform.localScale = Vector3.one * (name == "SlateBoulder" ? 3 : 2);
|
|
var collider = obstacle.GetComponentInChildren<MeshCollider>(); Physics.SyncTransforms();
|
|
obstacle.transform.position += new Vector3(0, 3, 0) - collider.bounds.center; Physics.SyncTransforms();
|
|
Assert.That(collider.sharedMesh.isReadable, Is.False, "Actual approved imports stay non-readable.");
|
|
Vector3 point = collider.bounds.center; bool found = !_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point));
|
|
for (float y = Mathf.Max(.6f, collider.bounds.min.y + .5f); !found && y < Mathf.Min(5.8f, collider.bounds.max.y - .5f); y += .1f)
|
|
{ point = new Vector3(collider.bounds.center.x, y, collider.bounds.center.z); found = !_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)); }
|
|
Assert.That(found, Is.True, "Boundary injection must start inside the actual approved solid.");
|
|
Free(point); int before = _store.Writes;
|
|
var elapsed = System.Diagnostics.Stopwatch.StartNew(); _app.ContinueFreePlay(); elapsed.Stop(); yield return null;
|
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay), name + " must repair rather than merely retain the unsafe legacy save.");
|
|
Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("legacy-free")); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
|
Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
|
float distance = Vector3.Distance(point, BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position));
|
|
float sampledUpperBound = float.PositiveInfinity;
|
|
Vector3 sampledPoint = point;
|
|
// Independent actual-physics rays sample every radial interval; no binary assumption about solid gaps.
|
|
for (int index = 0; index < 96; index++)
|
|
{
|
|
float y = 1 - 2 * (index + .5f) / 96; float angle = index * 2.39996323f;
|
|
var direction = new Vector3(Mathf.Sqrt(1-y*y)*Mathf.Cos(angle), y, Mathf.Sqrt(1-y*y)*Mathf.Sin(angle));
|
|
for (float radius = .02f; radius <= Mathf.Min(4, sampledUpperBound); radius += .02f)
|
|
if (_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point + direction * radius))) { sampledUpperBound = radius; sampledPoint = point + direction * radius; break; }
|
|
}
|
|
var sphere = _app.Presenter.Balls["legacy-free"].GetComponent<SphereCollider>();
|
|
bool penetration = Physics.ComputePenetration(sphere, sampledPoint, Quaternion.identity, collider, collider.transform.position, collider.transform.rotation, out _, out float depth);
|
|
bool overlap = Physics.OverlapSphere(sampledPoint, .5f, ~0, QueryTriggerInteraction.Ignore).Contains(collider);
|
|
bool ray = collider.Raycast(new Ray(sampledPoint, (point - sampledPoint).normalized), out var hit, 6);
|
|
Debug.Log($"[ProgressRestoreActual] approved={name}, utc={System.DateTime.UtcNow:O}, ms={elapsed.ElapsedMilliseconds}, source={point:R}, restoredDistance={distance:R}, sampledSafe={sampledPoint:R}, radialUpperBound={sampledUpperBound:R}, overlap={overlap}, penetration={penetration}, depth={depth:R}, inwardRay={ray}, surfaceDistance={hit.distance:R}");
|
|
MeshDiagnostic(collider,point,sampledPoint);
|
|
Assert.That(distance, Is.LessThanOrEqualTo(sampledUpperBound + .011f));
|
|
Debug.Log($"[ProgressRestoreActual] approved={name}, utc={System.DateTime.UtcNow:O}, ms={elapsed.ElapsedMilliseconds}, distance={distance:R}, independentRadialUpperBound={sampledUpperBound:R}");
|
|
obstacle.SetActive(false);
|
|
}
|
|
}
|
|
[UnityTest] public IEnumerator ClosedBoxCoversEveryRigidCandidateButInitialPuzzleFitsAndOnlyAffectedSlotResets()
|
|
{
|
|
var state = new PuzzleState { stageId = 2,
|
|
balls = new[] { new BallState { id = "blocked-a", position = new Position3(-8, .05f, -6) }, new BallState { id = "blocked-b", position = new Position3(8, 6, 6) } },
|
|
groups = new[] { Group("blocked-group", "blocked-a", "blocked-b") },
|
|
collectedBallIds = new[] { "blocked-a" }, countedBallIds = new[] { "blocked-a" }, camera = new CameraState { distance = 12 } };
|
|
_store.Save.puzzleStates = new[] { new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 1)).ToPuzzleState(GameSession.DefaultCamera()), state,
|
|
new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 6)).ToPuzzleState(GameSession.DefaultCamera()) };
|
|
_store.Save.latestFreePlay = new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 6)).ToFreePlayState(GameSession.DefaultCamera());
|
|
string otherOne = JsonUtility.ToJson(_store.Save.puzzleStates[0]), otherSix = JsonUtility.ToJson(_store.Save.puzzleStates[2]), free = JsonUtility.ToJson(_store.Save.latestFreePlay);
|
|
Box(new Vector3(-8, .05f, -6), Vector3.one * .3f); Reload(); int before = _store.Writes;
|
|
_app.StartStage(2); yield return null;
|
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
|
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EqualTo(new[] { "s02-b01", "s02-b02" }));
|
|
Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty); Assert.That(_app.Session.Board.CountedBallIds, Is.Empty);
|
|
Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
|
Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[0]), Is.EqualTo(otherOne)); Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[2]), Is.EqualTo(otherSix));
|
|
Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay), Is.EqualTo(free));
|
|
Assert.That(_app.Session.Camera.distance, Is.EqualTo(8));
|
|
}
|
|
[UnityTest] public IEnumerator MissingCacheCannotResetAnImpossibleBoardAndFailedWriteRestoresPreviousStageAndCamera()
|
|
{
|
|
_store.Save.puzzleStates = new[] { new PuzzleState { stageId = 2, balls = new[] { Ball("wide-a", -30), Ball("wide-b", 30) },
|
|
groups = new[] { Group("wide-group", "wide-a", "wide-b") }, camera = GameSession.DefaultCamera() } };
|
|
Reload(); _app.StartStage(6);
|
|
var manual = new CameraState { target = new Position3(0, .6f, 0), rotation = new Rotation4(0, 0, 0, 1), distance = 11 };
|
|
_app.Session.CommitCamera(manual); _app.Orbit.Apply(manual);
|
|
var current = _app.Session.Board; string theme = _app.Environment.Theme, saved = JsonUtility.ToJson(_store.Save); int before = _store.Writes;
|
|
_app.BindRestoreGeometry(null); _app.StartStage(2); yield return null;
|
|
Assert.That(_app.Session.Board, Is.SameAs(current)); Assert.That(_app.Environment.Theme, Is.EqualTo(theme));
|
|
Assert.That(_store.Writes, Is.EqualTo(before)); Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved));
|
|
Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(JsonUtility.ToJson(manual)));
|
|
_app.BindRestoreGeometry(AssetDatabase.LoadAssetAtPath<PlaygroundRestoreGeometry>("Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset"));
|
|
_store.FailWrites = true; _app.StartStage(2); yield return null;
|
|
Assert.That(_app.Session.Board, Is.SameAs(current)); Assert.That(_app.Environment.Theme, Is.EqualTo(theme));
|
|
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
|
Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(JsonUtility.ToJson(manual)));
|
|
Assert.That(Text("_saveNotice").text, Is.EqualTo("지금은 저장하지 못했어요. 다시 해볼게요."));
|
|
Assert.That(_app.Instruction, Is.Not.EqualTo("친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요."));
|
|
}
|
|
[UnityTest] public IEnumerator RepairedUnseenTutorialDoesNotChangeTutorialRecordAddAWriteOrOverwriteTheNotice()
|
|
{
|
|
_store.Save.completedStageIds = Enumerable.Range(1, 5).ToArray(); _store.Save.seenTutorialIds = new string[0];
|
|
_store.Save.puzzleStates = new[] { new PuzzleState { stageId = 6, balls = new[] { Ball("demo-a", 30), Ball("demo-b", 33) },
|
|
groups = new[] { Group("demo-first", "demo-a"), Group("demo-second", "demo-b") }, camera = GameSession.DefaultCamera() } };
|
|
Reload(); int before = _store.Writes; _app.StartStage(6); yield return null;
|
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
|
Assert.That(_store.Writes, Is.EqualTo(before + 1)); Assert.That(_store.Save.seenTutorialIds, Is.Empty);
|
|
Assert.That(_app.Instruction, Is.EqualTo("친구를 놀이터로 다시 보냈어요."));
|
|
}
|
|
[UnityTest] public IEnumerator TargetSceneryReplacesInactivePreviousStageCollidersBeforeRestorePreflight()
|
|
{
|
|
_app.StartStage(6); _app.ShowHome();
|
|
var stale = Box(new Vector3(0, 3, 0), new Vector3(40, 40, 40)); stale.transform.SetParent(_app.Environment.transform, true);
|
|
_store.Save.puzzleStates = new[] { new PuzzleState { stageId = 2, balls = new[] { Ball("target-a", 30), Ball("target-b", 31) },
|
|
groups = new[] { Group("target-group", "target-a", "target-b") }, camera = GameSession.DefaultCamera() } };
|
|
Reload(); _app.StartStage(2); yield return null;
|
|
Assert.That(_app.Session.Board.StageId, Is.EqualTo(2)); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
|
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EqualTo(new[] { "target-a", "target-b" }));
|
|
Assert.That(_app.Environment.gameObject.activeInHierarchy, Is.True);
|
|
}
|
|
[UnityTest] public IEnumerator SafeResumeKeepsExactPuzzleFreeProgressAndManualCamera()
|
|
{
|
|
var definition = _app.Session.Definitions.First(stage => stage.Id == 2);
|
|
var camera = new CameraState { target = new Position3(0, .6f, 0), rotation = new Rotation4(0, 0, 0, 1), distance = 11 };
|
|
_store.Save.puzzleStates = new[] { new BoardRules(definition).ToPuzzleState(camera) };
|
|
_store.Save.latestFreePlay = new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 6)).ToFreePlayState(GameSession.DefaultCamera());
|
|
string expected = JsonUtility.ToJson(_store.Save); Reload();
|
|
_app.StartStage(2); yield return null;
|
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
|
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(expected));
|
|
Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(JsonUtility.ToJson(camera)));
|
|
Assert.That(_app.Instruction, Is.Not.EqualTo("친구를 놀이터로 다시 보냈어요."));
|
|
}
|
|
[UnityTest] public IEnumerator RepairableFreeKeepsSourceShapeAndAllOtherSlotsWithNoAutomaticMerge()
|
|
{
|
|
_store.Save.puzzleStates = new[] { new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 2)).ToPuzzleState(GameSession.DefaultCamera()),
|
|
new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 6)).ToPuzzleState(GameSession.DefaultCamera()) };
|
|
_store.Save.latestFreePlay = new FreePlayState { sourceStageId = 6,
|
|
balls = new[] { Ball("free-a", 30), Ball("free-b", 31), Ball("free-c", 34) },
|
|
groups = new[] { Group("free-pair", "free-a", "free-b"), Group("free-single", "free-c") }, camera = GameSession.DefaultCamera() };
|
|
string firstPuzzle = JsonUtility.ToJson(_store.Save.puzzleStates[0]), secondPuzzle = JsonUtility.ToJson(_store.Save.puzzleStates[1]);
|
|
Reload(); int before = _store.Writes; _app.ContinueFreePlay(); yield return null;
|
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
|
Assert.That(_app.Session.Board.StageId, Is.EqualTo(6));
|
|
Assert.That(_app.Session.Board.Groups.Select(group => group.id), Is.EqualTo(new[] { "free-pair", "free-single" }));
|
|
var balls = _app.Session.Board.Balls.ToDictionary(ball => ball.id, ball => BoardPresenter.ToVector(ball.position));
|
|
Assert.That(balls["free-b"] - balls["free-a"], Is.EqualTo(Vector3.right));
|
|
foreach (string pair in new[] { "free-a", "free-b" }) Assert.That(Vector3.Distance(balls[pair], balls["free-c"]), Is.GreaterThanOrEqualTo(.999f));
|
|
Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[0]), Is.EqualTo(firstPuzzle));
|
|
Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[1]), Is.EqualTo(secondPuzzle));
|
|
Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
|
}
|
|
[UnityTest] public IEnumerator ImpossibleFreeResumeResetsOnlyLatestFreeToSourceInitialAndKeepsPuzzleSlotsAndProgress()
|
|
{
|
|
var definition = _app.Session.Definitions.First(stage => stage.Id == 6);
|
|
var initial = new BoardRules(definition);
|
|
_store.Save.puzzleStates = new[] { new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 2)).ToPuzzleState(GameSession.DefaultCamera()), initial.ToPuzzleState(GameSession.DefaultCamera()) };
|
|
_store.Save.latestFreePlay = new FreePlayState { sourceStageId = 6, balls = new[] { Ball("wide-a", -30), Ball("wide-b", 30) },
|
|
groups = new[] { Group("wide-group", "wide-a", "wide-b") }, camera = new CameraState { distance = 12, target = new Position3(1, 1, 1) } };
|
|
var puzzles = JsonUtility.ToJson(_store.Save); Reload(); int before = _store.Writes;
|
|
_app.ContinueFreePlay(); yield return null;
|
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
|
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EqualTo(initial.Balls.Select(ball => ball.id)));
|
|
Assert.That(_app.Session.Board.Balls.Select(ball => ball.position), Is.EqualTo(initial.Balls.Select(ball => ball.position)));
|
|
Assert.That(_store.Save.puzzleStates.Length, Is.EqualTo(2));
|
|
var expected = JsonUtility.FromJson<ProgressSave>(puzzles);
|
|
Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[0]), Is.EqualTo(JsonUtility.ToJson(expected.puzzleStates[0])));
|
|
Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[1]), Is.EqualTo(JsonUtility.ToJson(expected.puzzleStates[1])));
|
|
Assert.That(_store.Save.completedStageIds, Is.EqualTo(expected.completedStageIds));
|
|
Assert.That(_store.Save.seenTutorialIds, Is.EqualTo(expected.seenTutorialIds));
|
|
Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
|
Assert.That(_app.Session.Camera.distance, Is.EqualTo(GameSession.DefaultCamera().distance));
|
|
Assert.That(_app.Instruction, Is.EqualTo("친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요."));
|
|
foreach (char character in _app.Instruction) Assert.That(Text("_instruction").font.HasCharacter(character), Is.True);
|
|
Text("_instruction").ForceMeshUpdate(); Assert.That(Text("_instruction").textBounds.size.x, Is.LessThanOrEqualTo(Text("_instruction").rectTransform.rect.width + 1));
|
|
}
|
|
}
|
|
}
|
|
#endif
|