The game now starts from Bootstrap.unity, which additively loads Systems (the dialogue runner, skills, UI layer, camera rig and the player) and then a level (geometry, light, navmesh, reveal cameras). The level becomes the active scene so new objects and lighting land there. The player moved into Systems. It had been parented under Room_101 — under level geometry — and it is persistent content, not level content. Moving it also removes most of the cross-scene breakage on its own. Unity nulls any serialized reference that crosses a scene boundary. Measured rather than guessed: the pre-split scene was pulled from git and its null references diffed against the split result. Exactly seven were lost — both NPCs' dialogueRunner, dialogueUI and player, plus the reveal camera's tracking target. A first naive audit reported 210, which turned out to be pre-existing Unity defaults like Image.m_Material; the baseline diff is what separated the two. Each of the seven now resolves at runtime. SceneServices.Resolve fills in a null Inspector reference by searching the loaded scenes, keeping an assigned value if there is one; CinemachineFollowsPlayer binds the reveal camera once the player exists. Both follow the pattern already used by CameraFramingVolume and by NPCStandIn's player lookup, rather than introducing a new one. Assets/Tests/PlayMode is new, and it immediately paid for itself. BootstrapTests loads Bootstrap and asserts the whole game comes up; on its first run it caught a real bug the split had introduced. The player's NavMeshAgent lives in Systems while the NavMesh is baked into the level, so during load the agent exists off-mesh and ResetPath logs an error. ClickToMoveController now guards on agent.isOnNavMesh rather than a bare null check. No EditMode test could have seen that, because none of it has run yet. Two of those checks reach their types by name through reflection: NPCStandIn and the Cinematics namespace live in Assembly-CSharp, and an asmdef test assembly cannot reference the predefined assemblies. Per-area asmdefs remove the need. Build settings list all three scenes with Bootstrap at index 0, which LoadSceneAsync by name requires. EditMode 42/42, PlayMode 5/5, YarnCheck 9 files / 35 nodes. Co-Authored-By: Claude Opus 5 <[email protected]>
122 lines
4.8 KiB
C#
122 lines
4.8 KiB
C#
using System.Collections;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using NUnit.Framework;
|
|
using Unity.Cinemachine;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
using UnityEngine.TestTools;
|
|
using Yarn.Unity;
|
|
|
|
namespace NightclubArcadia.PlayMode.Tests
|
|
{
|
|
/// <summary>
|
|
/// Proves the scene split actually comes up.
|
|
///
|
|
/// Splitting Systems out of the level nulled seven serialized references that
|
|
/// Unity cannot carry across a scene boundary — the two NPCs' dialogue runner, UI
|
|
/// and player fields, and the reveal camera's tracking target. Each is supposed to
|
|
/// be resolved at runtime instead. An EditMode test cannot see any of that, because
|
|
/// none of it has run. This can.
|
|
///
|
|
/// If this suite fails, the game does not start. Treat it that way.
|
|
///
|
|
/// Two checks reach their types by name through reflection rather than referencing
|
|
/// them. NPCStandIn and NightclubArcadia.Cinematics live in Assembly-CSharp, and an
|
|
/// assembly-definition test assembly cannot reference the predefined assemblies —
|
|
/// only the other way round. Giving the runtime code its own asmdefs
|
|
/// (docs/restructure-plan.md Phase 4.1) removes the need for this; until then the
|
|
/// reflection is the accommodation, and it is confined to these two tests.
|
|
/// </summary>
|
|
public class BootstrapTests
|
|
{
|
|
const string Bootstrap = "Bootstrap";
|
|
const string Systems = "Systems";
|
|
const string Level = "SC101_ConferenceHall";
|
|
|
|
[UnitySetUp]
|
|
public IEnumerator LoadFromBootstrap()
|
|
{
|
|
yield return SceneManager.LoadSceneAsync(Bootstrap, LoadSceneMode.Single);
|
|
|
|
// GameBootstrap loads the other two additively over a few frames.
|
|
var deadline = Time.realtimeSinceStartup + 20f;
|
|
while (Time.realtimeSinceStartup < deadline)
|
|
{
|
|
if (SceneManager.GetSceneByName(Systems).isLoaded &&
|
|
SceneManager.GetSceneByName(Level).isLoaded)
|
|
{
|
|
break;
|
|
}
|
|
yield return null;
|
|
}
|
|
|
|
// let Awake/Start run on everything that just loaded
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
yield return null;
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Bootstrap_LoadsSystemsAndLevel()
|
|
{
|
|
Assert.IsTrue(SceneManager.GetSceneByName(Systems).isLoaded, "Systems scene did not load");
|
|
Assert.IsTrue(SceneManager.GetSceneByName(Level).isLoaded, "Level scene did not load");
|
|
Assert.AreEqual(Level, SceneManager.GetActiveScene().name,
|
|
"The level should be the active scene, so new objects and lighting land there");
|
|
}
|
|
|
|
[Test]
|
|
public void ThePlayerExists_AndIsTagged()
|
|
{
|
|
var player = GameObject.FindGameObjectWithTag("Player");
|
|
Assert.IsNotNull(player, "No object tagged Player — the reveal camera and the NPCs both need it");
|
|
}
|
|
|
|
[Test]
|
|
public void TheDialogueRunnerExists()
|
|
{
|
|
Assert.IsNotNull(Object.FindFirstObjectByType<DialogueRunner>(),
|
|
"No DialogueRunner in any loaded scene");
|
|
}
|
|
|
|
[Test]
|
|
public void EveryNpc_ResolvedItsDialogueRunner()
|
|
{
|
|
var npcs = Object.FindObjectsByType<MonoBehaviour>(FindObjectsInactive.Include, FindObjectsSortMode.None)
|
|
.Where(m => m != null && m.GetType().Name == "NPCStandIn")
|
|
.ToArray();
|
|
Assert.IsNotEmpty(npcs, "Expected NPCs in the level scene");
|
|
|
|
foreach (var npc in npcs)
|
|
{
|
|
var field = npc.GetType().GetField("dialogueRunner",
|
|
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
|
Assert.IsNotNull(field, "NPCStandIn no longer has a dialogueRunner field");
|
|
|
|
Assert.IsNotNull(field.GetValue(npc) as Object,
|
|
$"'{npc.name}' did not resolve a DialogueRunner. It lives in the Systems scene, " +
|
|
"so the serialized reference is gone and SceneServices.Resolve has to find it.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void RevealCameras_BoundToThePlayer()
|
|
{
|
|
var bound = Object.FindObjectsByType<CinemachineCamera>(FindObjectsInactive.Include, FindObjectsSortMode.None)
|
|
.Where(cam => cam.GetComponents<MonoBehaviour>()
|
|
.Any(m => m != null && m.GetType().Name == "CinemachineFollowsPlayer"))
|
|
.ToArray();
|
|
Assert.IsNotEmpty(bound, "Expected at least one runtime-bound camera");
|
|
|
|
foreach (var cam in bound)
|
|
{
|
|
Assert.IsNotNull(cam.Follow,
|
|
$"'{cam.name}' never bound its Follow target to the player");
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|