From dcbef5668f974ff8251eb1c5e788e6f1f2a4f3af Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 24 Aug 2026 00:46:33 +0900 Subject: [PATCH 1/2] Report already-running Play as a no-op Play or Resume while a session is already running used to say "Play mode started" even when Changed was false, which looked like a new session. Return an explicit no-op when already playing and not paused. Co-authored-by: Cursor --- .../Editor/ControlPlayModeUseCaseTests.cs | 36 +++++++++++++++++++ .../ControlPlayModeConstants.cs | 3 ++ .../ControlPlayMode/ControlPlayModeUseCase.cs | 10 ++++++ 3 files changed, 49 insertions(+) diff --git a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs index 236bbd264..d0f47bd65 100644 --- a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs +++ b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs @@ -493,6 +493,42 @@ public async Task ExecuteAsync_WhenPlayResumesFromPause_OnlyClearsPauseAndReport Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); } + /// + /// What: Play while already running and not paused is a no-op with the already-running message. + /// + [Test] + public async Task ExecuteAsync_WhenPlayWhileAlreadyRunning_ReportsNoOpWithoutSideEffects() + { + FakeControlPlayModeEditorStateService editorState = new(isPlaying: true, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty()); + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + new StubCompilationFailureProvider(System.Array.Empty()), + new StubCompilationFailureGate(false), + quietSaver, + editorState, + new StubDomainReloadDropStateProvider()); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Play, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That( + response.Message, + Is.EqualTo("Play mode was already running; nothing to start or resume.")); + Assert.That(response.Changed, Is.False); + Assert.That(response.ResumedFromPause, Is.False); + Assert.That(response.Warning, Is.Empty); + Assert.That(editorState.IsPlaying, Is.True); + Assert.That(editorState.IsPaused, Is.False); + Assert.That(editorState.IsPlayingSetCount, Is.EqualTo(0)); + Assert.That(editorState.IsPausedSetCount, Is.EqualTo(0)); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); + } + [Test] public async Task ExecuteAsync_WhenPlayStartsFreshSession_SetsPlayingAndReportsWarning() { diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs index 29154e236..4d5a6dbb8 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs @@ -10,5 +10,8 @@ internal static class ControlPlayModeConstants internal const string StoppedByCliRunTestsCancel = "cli-run-tests-cancel"; internal const string StoppedByScriptCompilation = "script-compilation"; internal const string StoppedByUnknown = "unknown"; + + internal const string AlreadyRunningPlayMessage = + "Play mode was already running; nothing to start or resume."; } } diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs index 8d7a5483c..cfdd3fa3c 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs @@ -173,6 +173,16 @@ private ControlPlayModeActionResult ExecutePlayModeStart(bool wasPaused, bool wa true); } + // Why: already-running Play used to report "Play mode started" even when Changed + // was false, which made a no-op look like a new session. + if (wasPlaying && !wasPaused) + { + return ControlPlayModeActionResult.FromState( + ControlPlayModeConstants.AlreadyRunningPlayMessage, + false, + false); + } + // Why only when entering Play from Edit: SaveScene does not work while already playing, // and resume-from-pause must not rewrite Scene assets. if (!wasPlaying) From 54631da45a0f9857ea4cbba538fe69ccf9f65489 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 24 Aug 2026 01:04:54 +0900 Subject: [PATCH 2/2] Pin already-running Play no-op on the response and Resume path The CLI wait path reads response IsPlaying and IsPaused, and testers hit Resume, so those fields and the alias need the same no-op contract as Play. A failed compile gate must not hide that contract while a session is already running. Co-authored-by: Cursor --- .../Editor/ControlPlayModeUseCaseTests.cs | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs index d0f47bd65..c359930f5 100644 --- a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs +++ b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs @@ -522,8 +522,91 @@ public async Task ExecuteAsync_WhenPlayWhileAlreadyRunning_ReportsNoOpWithoutSid Assert.That(response.Changed, Is.False); Assert.That(response.ResumedFromPause, Is.False); Assert.That(response.Warning, Is.Empty); - Assert.That(editorState.IsPlaying, Is.True); - Assert.That(editorState.IsPaused, Is.False); + Assert.That(response.IsPlaying, Is.True); + Assert.That(response.IsPaused, Is.False); + Assert.That(editorState.IsPlayingSetCount, Is.EqualTo(0)); + Assert.That(editorState.IsPausedSetCount, Is.EqualTo(0)); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); + } + + /// + /// What: Resume while already running and not paused uses the same no-op contract as Play. + /// + [Test] + public async Task ExecuteAsync_WhenResumeWhileAlreadyRunning_ReportsNoOpWithoutSideEffects() + { + FakeControlPlayModeEditorStateService editorState = new(isPlaying: true, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty()); + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + new StubCompilationFailureProvider(System.Array.Empty()), + new StubCompilationFailureGate(false), + quietSaver, + editorState, + new StubDomainReloadDropStateProvider()); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Resume, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That( + response.Message, + Is.EqualTo("Play mode was already running; nothing to start or resume.")); + Assert.That(response.Changed, Is.False); + Assert.That(response.ResumedFromPause, Is.False); + Assert.That(response.Warning, Is.Empty); + Assert.That(response.IsPlaying, Is.True); + Assert.That(response.IsPaused, Is.False); + Assert.That(editorState.IsPlayingSetCount, Is.EqualTo(0)); + Assert.That(editorState.IsPausedSetCount, Is.EqualTo(0)); + Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0)); + } + + /// + /// What: a failed compile gate does not replace the already-running no-op while Play is live. + /// + [Test] + public async Task ExecuteAsync_WhenPlayWhileAlreadyRunningAndCompileFailed_ReportsNoOpNotCompileBlock() + { + FakeControlPlayModeEditorStateService editorState = new(isPlaying: true, isPaused: false); + StubEditorUnsavedChangesQuietSaver quietSaver = new( + saveFailures: System.Array.Empty(), + remainingAfterSave: System.Array.Empty()); + ControlPlayModeCompileError[] compileErrors = + { + new ControlPlayModeCompileError + { + Message = "CS1525: invalid expression", + File = "Assets/Scripts/Sample.cs", + Line = 3 + } + }; + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + new StubCompilationFailureProvider(compileErrors), + new StubCompilationFailureGate(true), + quietSaver, + editorState, + new StubDomainReloadDropStateProvider()); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Play, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That( + response.Message, + Is.EqualTo("Play mode was already running; nothing to start or resume.")); + Assert.That(response.Changed, Is.False); + Assert.That(response.ResumedFromPause, Is.False); + Assert.That(response.Warning, Is.Empty); + Assert.That(response.IsPlaying, Is.True); + Assert.That(response.IsPaused, Is.False); + Assert.That(response.BlockedByCompileErrors, Is.False); + Assert.That(response.CompileErrorCount, Is.EqualTo(0)); Assert.That(editorState.IsPlayingSetCount, Is.EqualTo(0)); Assert.That(editorState.IsPausedSetCount, Is.EqualTo(0)); Assert.That(quietSaver.SaveCallCount, Is.EqualTo(0));