diff --git a/CLAUDE.md b/CLAUDE.md
index d62ddf7..efc2887 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -482,9 +482,18 @@ should not be — see `docs/restructure-plan.md`.
2. **Serialized references cannot cross a scene boundary.** Unity nulls them. The split cost
exactly seven: both NPCs' `dialogueRunner`, `dialogueUI` and `player`, plus the reveal
- camera's tracking target. All are resolved at runtime now — `SceneServices.Resolve` for the
- first, `CinemachineFollowsPlayer` for the last. Anything new that a level object needs from
- Systems must follow that pattern, and `BootstrapTests` is where you prove it works.
+ camera's tracking target. All are resolved at runtime now — `SceneServices` for the first,
+ `CinemachineFollowsPlayer` for the last.
+
+ **Resolve at the point of use, never in `Awake`.** A level object can wake up before the
+ scene holding its dependency exists — open the level on its own, or in the wrong order, and
+ `Awake` runs with no Systems scene in sight. That produced
+ `[SceneServices] Bartender: could not resolve dialogueRunner`, and because the result was
+ cached once, it stayed broken for the whole session. `SceneServices.TryResolve` is the silent
+ lookup for speculative calls; `SceneServices.Resolve` logs and belongs only where the
+ dependency is genuinely needed. `NPCStandIn.ResolvedDialogueRunner` is the pattern to copy.
+ `SceneOrderTests` holds the property, and asserts it is actually reproducing the race rather
+ than passing for free.
3. **The player's NavMeshAgent outlives the NavMesh.** The player is in Systems, the NavMesh is
baked into the level, so there is a window during load where the agent exists and is not on a
diff --git a/NightclubArcadia/Assets/Scripts/Core/SceneServices.cs b/NightclubArcadia/Assets/Scripts/Core/SceneServices.cs
index d32cd05..9fdf3d4 100644
--- a/NightclubArcadia/Assets/Scripts/Core/SceneServices.cs
+++ b/NightclubArcadia/Assets/Scripts/Core/SceneServices.cs
@@ -17,22 +17,40 @@ namespace NightclubArcadia.Core
///
public static class SceneServices
{
- /// Inspector value if set, otherwise the first one in any loaded scene.
- public static T Resolve(T assigned, Object context, string field) where T : Object
+ ///
+ /// Inspector value if set, otherwise the first one in any loaded scene. Silent:
+ /// returns null when nothing is found.
+ ///
+ /// Use this when the answer may legitimately not exist yet — during Awake, for
+ /// instance, when the scene holding it may not have loaded.
+ ///
+ public static T TryResolve(T assigned) where T : Object
{
if (assigned != null)
{
return assigned;
}
- var found = Object.FindFirstObjectByType();
+ // Include inactive: a system parked inactive at startup is still the object
+ // we mean, and excluding it produces a confusing "not found" for something
+ // sitting right there in the hierarchy.
+ return Object.FindFirstObjectByType(FindObjectsInactive.Include);
+ }
+
+ ///
+ /// As TryResolve, but logs an error when nothing is found. Only call this at the
+ /// point the dependency is actually needed — calling it during Awake reports a
+ /// failure that may simply be a scene that has not finished loading.
+ ///
+ public static T Resolve(T assigned, Object context, string field) where T : Object
+ {
+ var found = TryResolve(assigned);
if (found == null)
{
Debug.LogError(
$"[SceneServices] {context?.name}: could not resolve {field} ({typeof(T).Name}). " +
"Is the Systems scene loaded?", context);
}
-
return found;
}
diff --git a/NightclubArcadia/Assets/Scripts/Dialogue/DialogueInteractable.cs b/NightclubArcadia/Assets/Scripts/Dialogue/DialogueInteractable.cs
index 0f1cb2f..f2bef9b 100644
--- a/NightclubArcadia/Assets/Scripts/Dialogue/DialogueInteractable.cs
+++ b/NightclubArcadia/Assets/Scripts/Dialogue/DialogueInteractable.cs
@@ -21,8 +21,9 @@ namespace NightclubArcadia.Dialogue
void Awake()
{
- // Same cross-scene resolution as NPCStandIn: the runner is in Systems.
- dialogueRunner = SceneServices.Resolve(dialogueRunner, this, nameof(dialogueRunner));
+ // Best-effort and silent; the real lookup happens at the point of use, since
+ // Systems may not have loaded when this level object awakes. See Runner.
+ dialogueRunner = SceneServices.TryResolve(dialogueRunner);
interactAction = new InputAction("Interact");
interactAction.AddBinding("/e");
@@ -36,6 +37,15 @@ namespace NightclubArcadia.Dialogue
};
}
+ ///
+ /// The dialogue runner, resolved on demand rather than in Awake, so that load
+ /// order between this level scene and Systems does not matter. Cached once found.
+ ///
+ DialogueRunner Runner =>
+ dialogueRunner != null
+ ? dialogueRunner
+ : dialogueRunner = SceneServices.TryResolve(null);
+
void OnEnable() => interactAction?.Enable();
void OnDisable() => interactAction?.Disable();
@@ -57,6 +67,16 @@ namespace NightclubArcadia.Dialogue
async void StartConversation()
{
+ var runner = Runner;
+ if (runner == null)
+ {
+ Debug.LogError(
+ $"[DialogueInteractable] {name}: no DialogueRunner in any loaded scene. " +
+ "It lives in Systems.unity — open Bootstrap, or Tools → Nightclub Arcadia → " +
+ "Open Game Scenes.", this);
+ return;
+ }
+
// Player controls are locked by DialogueTransitionController off the
// runner's onDialogueStart event — nothing to do for that here.
if (DialogueUIVisibility.Show(dialogueUI))
@@ -67,7 +87,7 @@ namespace NightclubArcadia.Dialogue
await Awaitable.NextFrameAsync();
}
- await dialogueRunner.StartDialogue(yarnNodeName);
+ await runner.StartDialogue(yarnNodeName);
}
}
}
diff --git a/NightclubArcadia/Assets/Scripts/NPCs/NPCStandIn.cs b/NightclubArcadia/Assets/Scripts/NPCs/NPCStandIn.cs
index b4e91ff..ba26c70 100644
--- a/NightclubArcadia/Assets/Scripts/NPCs/NPCStandIn.cs
+++ b/NightclubArcadia/Assets/Scripts/NPCs/NPCStandIn.cs
@@ -54,9 +54,9 @@ public class NPCStandIn : MonoBehaviour
rend = GetComponent();
rend.material.color = npcColor;
- // The dialogue runner lives in the Systems scene, so this reference cannot be
- // serialized from a level scene. An Inspector value still wins if one is set.
- dialogueRunner = SceneServices.Resolve(dialogueRunner, this, nameof(dialogueRunner));
+ // Best-effort only, and silent. The runner lives in the Systems scene, which may
+ // not have loaded yet when this level's objects awake — see ResolvedDialogueRunner.
+ dialogueRunner = SceneServices.TryResolve(dialogueRunner);
mainCam = Camera.main;
@@ -78,6 +78,20 @@ public class NPCStandIn : MonoBehaviour
};
}
+ ///
+ /// The dialogue runner, resolved on demand.
+ ///
+ /// Not resolved once in Awake: the runner lives in the Systems scene, and an NPC in
+ /// a level scene can wake up before that scene exists — loading the level on its own,
+ /// or in a different order, used to log "could not resolve dialogueRunner" and then
+ /// stay broken for the rest of the session. Looking it up at the point of use makes
+ /// load order irrelevant, and the result is cached, so this costs one search.
+ ///
+ public DialogueRunner ResolvedDialogueRunner =>
+ dialogueRunner != null
+ ? dialogueRunner
+ : dialogueRunner = SceneServices.TryResolve(null);
+
void OnEnable() => interactAction?.Enable();
void OnDisable() => interactAction?.Disable();
@@ -153,9 +167,13 @@ public class NPCStandIn : MonoBehaviour
public void Interact()
{
- if (dialogueRunner == null)
+ var runner = ResolvedDialogueRunner;
+ if (runner == null)
{
- Debug.LogWarning($"[NPCStandIn] No DialogueRunner assigned on {npcName}");
+ Debug.LogError(
+ $"[NPCStandIn] {npcName}: no DialogueRunner in any loaded scene. " +
+ "It lives in Systems.unity — open Bootstrap, or Tools → Nightclub Arcadia → " +
+ "Open Game Scenes.", this);
return;
}
@@ -164,7 +182,7 @@ public class NPCStandIn : MonoBehaviour
return;
}
- if (!dialogueRunner.IsDialogueRunning)
+ if (!runner.IsDialogueRunning)
{
StartDialogueAsync();
}
@@ -182,7 +200,7 @@ public class NPCStandIn : MonoBehaviour
await Awaitable.NextFrameAsync();
}
- await dialogueRunner.StartDialogue(yarnStartNode);
+ await ResolvedDialogueRunner.StartDialogue(yarnStartNode);
}
///
@@ -197,10 +215,16 @@ public class NPCStandIn : MonoBehaviour
return dialogueUI;
}
- var canvas = dialogueRunner.GetComponentInChildren