diff --git a/Editor/Localization/ja.po b/Editor/Localization/ja.po index 6daa19a..3fb3ba8 100644 --- a/Editor/Localization/ja.po +++ b/Editor/Localization/ja.po @@ -1,4 +1,4 @@ -# Copyright (c) 2023 DeNA Co., Ltd. +# Copyright (c) 2023-2024 DeNA Co., Ltd. # This software is released under the MIT License. # # Package UI Localization is available in 2020.2 @@ -239,6 +239,25 @@ msgid "Agent to repeat execution" msgstr "リピート実行するAgentを指定します" +#: Editor/UI/Agents/TimeBombAgentEditor.cs + +# agent (same as OneTimeAgentEditor.cs) +msgid "Agent" +msgstr "Agent" + +# agent tooltip +msgid "Working Agent. If this Agent exits first, the TimeBombAgent will fail." +msgstr "実際に動作するAgent。このAgentが先に終了すると、TimeBombAgentは失敗します" + +# defuse message +msgid "Defuse Message" +msgstr "解除メッセージ" + +# defuse message tooltip +msgid "Defuse the time bomb when this message comes to the log first. Can specify regex." +msgstr "このメッセージが先にログに出力されたら、TimeBombAgentは正常終了します。正規表現を指定できます。" + + #: Editor/UI/Agents/ParallelCompositeAgentEditor.cs # agents diff --git a/Editor/UI/Agents/TimeBombAgentEditor.cs b/Editor/UI/Agents/TimeBombAgentEditor.cs new file mode 100644 index 0000000..2263774 --- /dev/null +++ b/Editor/UI/Agents/TimeBombAgentEditor.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2023-2024 DeNA Co., Ltd. +// This software is released under the MIT License. + +using DeNA.Anjin.Agents; +using UnityEditor; +using UnityEngine; + +namespace DeNA.Anjin.Editor.UI.Agents +{ + /// + /// Editor GUI for TimeBombAgent + /// + [CustomEditor(typeof(TimeBombAgent))] + public class TimeBombAgentEditor : UnityEditor.Editor + { + private static readonly string s_description = L10n.Tr("Description"); + private static readonly string s_descriptionTooltip = L10n.Tr("Description about this agent instance"); + private static readonly string s_agent = L10n.Tr("Agent"); + + private static readonly string s_agentTooltip = + L10n.Tr("Working Agent. If this Agent exits first, the TimeBombAgent will fail."); + + private static readonly string s_defuseMessage = L10n.Tr("Defuse Message"); + + private static readonly string s_defuseMessageTooltip = + L10n.Tr("Defuse the time bomb when this message comes to the log first. Can specify regex."); + + /// + public override void OnInspectorGUI() + { + serializedObject.Update(); + + EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(TimeBombAgent.description)), + new GUIContent(s_description, s_descriptionTooltip)); + EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(TimeBombAgent.agent)), + new GUIContent(s_agent, s_agentTooltip)); + EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(TimeBombAgent.defuseMessage)), + new GUIContent(s_defuseMessage, s_defuseMessageTooltip)); + + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Editor/UI/Agents/TimeBombAgentEditor.cs.meta b/Editor/UI/Agents/TimeBombAgentEditor.cs.meta new file mode 100644 index 0000000..6c249f4 --- /dev/null +++ b/Editor/UI/Agents/TimeBombAgentEditor.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f409694725f242948de11e2f0e568596 +timeCreated: 1710591304 \ No newline at end of file diff --git a/README.md b/README.md index 4bdfa73..5605f18 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ An Agent that executes multiple Agents in parallel. The instance of this Agent (.asset file) can have the following settings. -
Agents +
Agents
A list of Agents to be executed in parallel, which can be nested by specifying a CompositeAgent
@@ -294,7 +294,7 @@ For example, in a game where the title scene leads to a different path only for The Agent instance (.asset file) can contain the following settings. -
Agent +
Agent
Agent that can be executed only once, which can be nested by specifying a CompositeAgent
@@ -313,6 +313,23 @@ This Agent instance (.asset file) can contain the following.
+### TimeBombAgent + +An Agent that will fail if a defuse message is not received before the inner agent exits. + +For example, pass the out-game tutorial (Things that can be completed with tap operations on uGUI, such as smartphone games). + +1. Set `UGUIMonkeyAgent` as the `Agent`. It should not be able to operate except for the advancing button. Set the `Lifespan Sec` with a little margin. +2. Set the log message to be output when the tutorial is completed as the `Defuse Message`. + +This Agent instance (.asset file) can contain the following. + +
+
Agent
Working Agent. If this Agent exits first, the TimeBombAgent will fail.
+
Defuse Message
Defuse the time bomb when this message comes to the log first. Can specify regex.
+
+ + ### EmergencyExitAgent An Agent that monitors the appearance of the `EmergencyExit` component in the `DeNA.Anjin.Annotations` assembly and clicks on it as soon as it appears. diff --git a/README_ja.md b/README_ja.md index c1ca332..6946ea7 100644 --- a/README_ja.md +++ b/README_ja.md @@ -316,6 +316,23 @@ SerialCompositeAgentと組み合わせることで、シナリオを何周もし
+### TimeBombAgent + +内包するAgentが終了する前に解除メッセージを受信しないと失敗するAgent。 + +例えば、アウトゲームのチュートリアル(スマホゲームなどuGUIのタップ操作で完遂できるもの)を突破するには、次のように設定します。 + +1. `UGUIMonkeyAgent` を `Agent` に設定します。進行するボタン以外は操作できないはずです。`実行時間` は少し余裕をもって設定してください +2. チュートリアル完遂時にログに出力されるメッセージを `解除メッセージ` に設定します + +このAgentのインスタンス(.assetファイル)には以下を設定できます。 + +
+
Agent
実際に動作するAgent。このAgentが先に終了すると、TimeBombAgentは失敗します。
+
解除メッセージ
このメッセージが先にログに出力されたら、TimeBombAgentは正常終了します。正規表現でも指定できます。
+
+ + ### EmergencyExitAgent `DeNA.Anjin.Annotations` アセンブリに含まれる `EmergencyExit` コンポーネントの出現を監視し、表示されたら即クリックするAgentです。 diff --git a/Runtime/Agents/TimeBombAgent.cs b/Runtime/Agents/TimeBombAgent.cs new file mode 100644 index 0000000..1c8b7a5 --- /dev/null +++ b/Runtime/Agents/TimeBombAgent.cs @@ -0,0 +1,96 @@ +// Copyright (c) 2023-2024 DeNA Co., Ltd. +// This software is released under the MIT License. + +using System; +using System.Text.RegularExpressions; +using System.Threading; +using Cysharp.Threading.Tasks; +using UnityEngine; + +namespace DeNA.Anjin.Agents +{ + /// + /// An Agent that will fail if a defuse message is not received before the inner agent exits. + /// + /// For example, pass the out-game tutorial (Things that can be completed with tap operations on uGUI, such as smartphone games). + /// 1. Set `UGUIMonkeyAgent` as the `Agent`. It should not be able to operate except for the advancing button. Set the `Lifespan Sec` with a little margin. + /// 2. Set the log message to be output when the tutorial is completed as the `Defuse Message`. + /// + [CreateAssetMenu(fileName = "New TimeBombAgent", menuName = "Anjin/Time Bomb Agent", order = 17)] + public class TimeBombAgent : AbstractAgent + { + /// + /// Working Agent. If this Agent exits first, the TimeBombAgent will fail. + /// + public AbstractAgent agent; + + /// + /// Defuse the time bomb when this message comes to the log first. + /// Can specify regex. + /// + public string defuseMessage; + + private Regex DefuseMessageRegex => new Regex(defuseMessage); + private CancellationTokenSource _cts; + + private void OnEnable() + { + Application.logMessageReceived += HandleDefuseMessage; + } + + private void OnDisable() + { + Application.logMessageReceived -= HandleDefuseMessage; + } + + private void HandleDefuseMessage(string logString, string stackTrace, LogType type) + { + if (DefuseMessageRegex.IsMatch(logString)) + { + try + { + _cts?.Cancel(); + } + catch (ObjectDisposedException e) + { + // ignored + } + } + } + + /// + public override async UniTask Run(CancellationToken token) + { + Logger.Log($"Enter {this.name}.Run()"); + + using (var agentCts = new CancellationTokenSource()) // To cancel only the Working Agent. + { + _cts = agentCts; + + using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token, agentCts.Token)) + { + try + { + agent.Logger = Logger; + agent.Random = Random; // This Agent does not consume pseudo-random numbers, so passed on as is. + await agent.Run(linkedCts.Token); + + throw new TimeoutException( + $"Could not receive defuse message `{defuseMessage}` before the agent terminated."); + } + catch (OperationCanceledException e) + { + if (token.IsCancellationRequested) // The parent was cancelled. + { + throw; + } + + Logger.Log($"Working agent {agent.name} was cancelled."); + } + } + } + + Logger.Log($"Exit {this.name}.Run()"); + } + } +} diff --git a/Runtime/Agents/TimeBombAgent.cs.meta b/Runtime/Agents/TimeBombAgent.cs.meta new file mode 100644 index 0000000..451bca0 --- /dev/null +++ b/Runtime/Agents/TimeBombAgent.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ca9bf9ff59ed4ad2bf73f0a3ee90ac62 +timeCreated: 1710591291 \ No newline at end of file diff --git a/Tests/Runtime/Agents/TimeBombAgentTest.cs b/Tests/Runtime/Agents/TimeBombAgentTest.cs new file mode 100644 index 0000000..ec41722 --- /dev/null +++ b/Tests/Runtime/Agents/TimeBombAgentTest.cs @@ -0,0 +1,102 @@ +// Copyright (c) 2023-2024 DeNA Co., Ltd. +// This software is released under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Cysharp.Threading.Tasks; +using DeNA.Anjin.Utilities; +using NUnit.Framework; +using TestHelper.Attributes; +using UnityEngine; +using UnityEngine.TestTools; + +namespace DeNA.Anjin.Agents +{ + [TestFixture] + [UnityPlatform(RuntimePlatform.OSXEditor, RuntimePlatform.WindowsEditor, RuntimePlatform.LinuxEditor)] + public class TimeBombAgentTest + { + private static UGUIMonkeyAgent CreateMonkeyAgent(long lifespanSec) + { + var agent = ScriptableObject.CreateInstance(); + agent.name = nameof(UGUIMonkeyAgent); + agent.lifespanSec = lifespanSec; + agent.delayMillis = 100; + agent.secondsToErrorForNoInteractiveComponent = 0; // Disable check + agent.touchAndHoldDelayMillis = 200; + return agent; + } + + private static TimeBombAgent CreateTimeBombAgent(AbstractAgent workingAgent, string defuseMessage) + { + var agent = ScriptableObject.CreateInstance(); + agent.name = nameof(TimeBombAgent); + agent.agent = workingAgent; + agent.defuseMessage = defuseMessage; + return agent; + } + + [Test] + public async Task Run_CancelTask_StopAgent() + { + var monkeyAgent = CreateMonkeyAgent(5); + var agent = CreateTimeBombAgent(monkeyAgent, "^Never match!$"); + agent.Logger = new ConsoleLogger(Debug.unityLogger.logHandler); + agent.Random = new RandomFactory(0).CreateRandom(); + + using (var cts = new CancellationTokenSource()) + { + var task = agent.Run(cts.Token); + await UniTask.NextFrame(); + + cts.Cancel(); + await UniTask.NextFrame(); + + Assert.That(task.Status, Is.EqualTo(UniTaskStatus.Canceled)); + } + } + + [Test] + [LoadScene("Packages/com.dena.anjin/Tests/TestScenes/OutGameTutorial.unity")] + public async Task Run_Defuse_StopAgent() + { + var monkeyAgent = CreateMonkeyAgent(5); + var agent = CreateTimeBombAgent(monkeyAgent, "^Tutorial Completed!$"); + agent.Logger = new ConsoleLogger(Debug.unityLogger.logHandler); + agent.Random = new RandomFactory(0).CreateRandom(); + + LogAssert.Expect(LogType.Log, "Working agent UGUIMonkeyAgent was cancelled."); + LogAssert.Expect(LogType.Log, "Exit TimeBombAgent.Run()"); + + using (var cts = new CancellationTokenSource()) + { + await agent.Run(cts.Token); + } + } + + [Test] + [LoadScene("Packages/com.dena.anjin/Tests/TestScenes/OutGameTutorial.unity")] + public async Task Run_NotDefuse_ThrowsTimeoutException() + { + var monkeyAgent = CreateMonkeyAgent(1); + var agent = CreateTimeBombAgent(monkeyAgent, "^Never match!$"); + agent.Logger = new ConsoleLogger(Debug.unityLogger.logHandler); + agent.Random = new RandomFactory(0).CreateRandom(); + + using (var cts = new CancellationTokenSource()) + { + try + { + await agent.Run(cts.Token); + Assert.Fail("Should throw TimeoutException"); + } + catch (TimeoutException e) + { + Assert.That(e.Message, Is.EqualTo( + "Could not receive defuse message `^Never match!$` before the agent terminated.")); + } + } + } + } +} diff --git a/Tests/Runtime/Agents/TimeBombAgentTest.cs.meta b/Tests/Runtime/Agents/TimeBombAgentTest.cs.meta new file mode 100644 index 0000000..15d8e0a --- /dev/null +++ b/Tests/Runtime/Agents/TimeBombAgentTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: bb8c72e0325d47cfacb033ff1e65fec4 +timeCreated: 1710636942 \ No newline at end of file diff --git a/Tests/Runtime/TestComponents.meta b/Tests/Runtime/TestComponents.meta new file mode 100644 index 0000000..c687abd --- /dev/null +++ b/Tests/Runtime/TestComponents.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1e4599a67aab439c91b29adc488c7fb1 +timeCreated: 1710678519 \ No newline at end of file diff --git a/Tests/Runtime/TestComponents/OutGameTutorialButton.cs b/Tests/Runtime/TestComponents/OutGameTutorialButton.cs new file mode 100644 index 0000000..6105748 --- /dev/null +++ b/Tests/Runtime/TestComponents/OutGameTutorialButton.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2023-2024 DeNA Co., Ltd. +// This software is released under the MIT License. + +using UnityEngine; +using Button = UnityEngine.UI.Button; + +#pragma warning disable CS0618 // Type or member is obsolete + +namespace DeNA.Anjin.TestComponents +{ + [RequireComponent(typeof(Button))] + [AddComponentMenu("")] // Hide from "Add Component" picker + public class OutGameTutorialButton : MonoBehaviour + { + private static bool s_tutorialCompleted; + + private void Awake() + { + var button = GetComponent