diff --git a/docs/fsm-diagrams.md b/docs/fsm-diagrams.md
index af08fee6ac..4b7fa604da 100644
--- a/docs/fsm-diagrams.md
+++ b/docs/fsm-diagrams.md
@@ -106,6 +106,19 @@ Terminate:::terminate --> Terminate:::terminate
```
+## [ExamplePlayFSM](/src/software/ai/hl/stp/play/example/example_play_fsm.h)
+
+```mermaid
+
+stateDiagram-v2
+classDef terminate fill:white,color:black,font-weight:bold
+direction LR
+[*] --> MoveState
+MoveState --> MoveState : moveToPosition
+Terminate:::terminate --> Terminate:::terminate
+
+```
+
## [FreeKickPlayFSM](/src/software/ai/hl/stp/play/free_kick/free_kick_play_fsm.h)
```mermaid
diff --git a/src/software/ai/hl/stp/play/BUILD b/src/software/ai/hl/stp/play/BUILD
index 7f2c78772d..bd9c16119d 100644
--- a/src/software/ai/hl/stp/play/BUILD
+++ b/src/software/ai/hl/stp/play/BUILD
@@ -6,20 +6,6 @@ load("@simulated_tests_deps//:requirements.bzl", "requirement")
# "factory" design pattern to work are linked in
# https://www.bfilipek.com/2018/02/static-vars-static-lib.html
-cc_library(
- name = "example_play",
- srcs = ["example_play.cpp"],
- hdrs = ["example_play.h"],
- deps = [
- ":play",
- "//shared:constants",
- "//software/ai/hl/stp/tactic/move:move_tactic",
- "//software/logger",
- "//software/util/generic_factory",
- ],
- alwayslink = True,
-)
-
cc_library(
name = "kickoff_enemy_play",
srcs = ["kickoff_enemy_play.cpp"],
@@ -119,7 +105,6 @@ cc_library(
cc_library(
name = "all_plays",
deps = [
- ":example_play",
":kickoff_enemy_play",
":kickoff_friendly_play",
":shoot_or_chip_play",
@@ -129,6 +114,7 @@ cc_library(
"//software/ai/hl/stp/play/defense:defense_play",
"//software/ai/hl/stp/play/enemy_ball_placement:enemy_ball_placement_play",
"//software/ai/hl/stp/play/enemy_free_kick:enemy_free_kick_play",
+ "//software/ai/hl/stp/play/example:example_play",
"//software/ai/hl/stp/play/free_kick:free_kick_play",
"//software/ai/hl/stp/play/halt_play",
"//software/ai/hl/stp/play/hardware_challenge_plays:dribbling_parcour_play",
@@ -142,20 +128,6 @@ cc_library(
],
)
-cc_test(
- name = "example_play_test",
- srcs = ["example_play_test.cpp"],
- deps = [
- "//shared/test_util:tbots_gtest_main",
- "//software/ai/hl/stp/play:example_play",
- "//software/simulated_tests:simulated_er_force_sim_play_test_fixture",
- "//software/simulated_tests/validation:validation_function",
- "//software/test_util",
- "//software/time:duration",
- "//software/world",
- ],
-)
-
cc_test(
name = "kickoff_friendly_play_cpp_test",
srcs = ["kickoff_friendly_play_test.cpp"],
diff --git a/src/software/ai/hl/stp/play/example/BUILD b/src/software/ai/hl/stp/play/example/BUILD
new file mode 100644
index 0000000000..91ba8b5d23
--- /dev/null
+++ b/src/software/ai/hl/stp/play/example/BUILD
@@ -0,0 +1,63 @@
+package(default_visibility = ["//visibility:public"])
+
+load("@simulated_tests_deps//:requirements.bzl", "requirement")
+
+cc_library(
+ name = "example_play",
+ srcs = [
+ "example_play.cpp",
+ "example_play_fsm.cpp",
+ ],
+ hdrs = [
+ "example_play.h",
+ "example_play_fsm.h",
+ ],
+ deps = [
+ "//shared:constants",
+ "//software/ai/hl/stp/play",
+ "//software/ai/hl/stp/tactic/move:move_tactic",
+ "//software/logger",
+ "//software/util/generic_factory",
+ ],
+ alwayslink = True,
+)
+
+cc_test(
+ name = "example_play_test",
+ srcs = ["example_play_test.cpp"],
+ deps = [
+ "//shared/test_util:tbots_gtest_main",
+ "//software/ai/hl/stp/play/example:example_play",
+ "//software/simulated_tests:simulated_er_force_sim_play_test_fixture",
+ "//software/simulated_tests/validation:validation_function",
+ "//software/test_util",
+ "//software/time:duration",
+ "//software/world",
+ ],
+)
+
+cc_test(
+ name = "example_play_fsm_test",
+ srcs = ["example_play_fsm_test.cpp"],
+ deps = [
+ ":example_play",
+ "//shared/test_util:tbots_gtest_main",
+ "//software/test_util",
+ ],
+)
+
+py_test(
+ name = "example_play_test_py",
+ srcs = ["example_play_test.py"],
+ main = "example_play_test.py",
+ # TODO (#2619) Remove tag to run in parallel
+ tags = [
+ "exclusive",
+ ],
+ deps = [
+ "//software:conftest",
+ "//software/simulated_tests:speed_threshold_helpers",
+ "//software/simulated_tests:validation",
+ requirement("pytest"),
+ ],
+)
diff --git a/src/software/ai/hl/stp/play/example/example_play.cpp b/src/software/ai/hl/stp/play/example/example_play.cpp
new file mode 100644
index 0000000000..ecece8d333
--- /dev/null
+++ b/src/software/ai/hl/stp/play/example/example_play.cpp
@@ -0,0 +1,31 @@
+#include "software/ai/hl/stp/play/example/example_play.h"
+
+#include "shared/constants.h"
+#include "software/util/generic_factory/generic_factory.h"
+
+ExamplePlay::ExamplePlay(TbotsProto::AiConfig config)
+ : Play(config, false), fsm{ExamplePlayFSM{}}, control_params{}
+{
+}
+
+void ExamplePlay::getNextTactics(TacticCoroutine::push_type &yield,
+ const WorldPtr &world_ptr)
+{
+ // This function doesn't get called and it will be removed once coroutines are phased
+ // out
+}
+
+void ExamplePlay::updateTactics(const PlayUpdate &play_update)
+{
+ fsm.process_event(ExamplePlayFSM::Update(control_params, play_update));
+}
+
+std::vector ExamplePlay::getState()
+{
+ std::vector state;
+ state.emplace_back(objectTypeName(*this) + " - " + getCurrentFullStateName(fsm));
+ return state;
+}
+
+// Register this play in the genericFactory
+static TGenericFactory factory;
diff --git a/src/software/ai/hl/stp/play/example/example_play.h b/src/software/ai/hl/stp/play/example/example_play.h
new file mode 100644
index 0000000000..debf856cdf
--- /dev/null
+++ b/src/software/ai/hl/stp/play/example/example_play.h
@@ -0,0 +1,26 @@
+#pragma once
+
+#include "software/ai/hl/stp/play/example/example_play_fsm.h"
+
+/**
+ * An example play that moves the robots in a circle around the ball
+ */
+class ExamplePlay : public Play
+{
+ public:
+ /**
+ * Creates an example play
+ *
+ * @param ai_config the play config for this play
+ */
+ ExamplePlay(TbotsProto::AiConfig config);
+
+ void getNextTactics(TacticCoroutine::push_type &yield,
+ const WorldPtr &world_ptr) override;
+ void updateTactics(const PlayUpdate &play_update) override;
+ std::vector getState() override;
+
+ private:
+ FSM fsm;
+ ExamplePlayFSM::ControlParams control_params;
+};
diff --git a/src/software/ai/hl/stp/play/example/example_play_fsm.cpp b/src/software/ai/hl/stp/play/example/example_play_fsm.cpp
new file mode 100644
index 0000000000..a3528a40e1
--- /dev/null
+++ b/src/software/ai/hl/stp/play/example/example_play_fsm.cpp
@@ -0,0 +1,29 @@
+#include "software/ai/hl/stp/play/example/example_play_fsm.h"
+
+ExamplePlayFSM::ExamplePlayFSM() : move_tactics(DIV_A_NUM_ROBOTS)
+{
+ std::generate(move_tactics.begin(), move_tactics.end(),
+ []() { return std::make_shared(); });
+}
+
+void ExamplePlayFSM::moveToPosition(const Update &event)
+{
+ // The angle between each robot spaced out in a circle around the ball
+ Angle angle_between_robots = Angle::full() / static_cast(move_tactics.size());
+
+ for (size_t k = 0; k < move_tactics.size(); k++)
+ {
+ move_tactics[k]->updateControlParams(
+ event.common.world_ptr->ball().position() +
+ Vector::createFromAngle(angle_between_robots *
+ static_cast(k + 1)),
+ (angle_between_robots * static_cast(k + 1)) + Angle::half());
+ }
+
+ // Set the Tactics this Play wants to run, in order of priority.
+ // If there are fewer robots in play, robots at the end of the list will not be
+ // assigned
+ TacticVector result = {};
+ result.insert(result.end(), move_tactics.begin(), move_tactics.end());
+ event.common.set_tactics({result});
+}
diff --git a/src/software/ai/hl/stp/play/example/example_play_fsm.h b/src/software/ai/hl/stp/play/example/example_play_fsm.h
new file mode 100644
index 0000000000..53b6947b74
--- /dev/null
+++ b/src/software/ai/hl/stp/play/example/example_play_fsm.h
@@ -0,0 +1,51 @@
+#pragma once
+
+#include "proto/parameters.pb.h"
+#include "shared/constants.h"
+#include "software/ai/hl/stp/play/play.h"
+#include "software/ai/hl/stp/tactic/move/move_tactic.h"
+#include "software/logger/logger.h"
+
+/**
+ * An example play that moves the robots in a circle around the ball
+ */
+struct ExamplePlayFSM
+{
+ class MoveState;
+
+ struct ControlParams
+ {
+ };
+
+ DEFINE_PLAY_UPDATE_STRUCT_WITH_CONTROL_AND_COMMON_PARAMS
+
+ /**
+ * Creates an example play FSM
+ */
+ explicit ExamplePlayFSM();
+
+ /**
+ * Action that moves the robots to certain positions around the ball
+ *
+ * @param event the ExamplePlayFSM Update event
+ */
+ void moveToPosition(const Update& event);
+
+ auto operator()()
+ {
+ using namespace boost::sml;
+
+ DEFINE_SML_STATE(MoveState)
+
+ DEFINE_SML_EVENT(Update)
+
+ DEFINE_SML_ACTION(moveToPosition)
+
+ return make_transition_table(
+ // src_state + event [guard] / action = dest_state
+ *MoveState_S + Update_E / moveToPosition_A = MoveState_S, X + Update_E = X);
+ }
+
+ private:
+ std::vector> move_tactics;
+};
diff --git a/src/software/ai/hl/stp/play/example/example_play_fsm_test.cpp b/src/software/ai/hl/stp/play/example/example_play_fsm_test.cpp
new file mode 100644
index 0000000000..8bf6bc5a83
--- /dev/null
+++ b/src/software/ai/hl/stp/play/example/example_play_fsm_test.cpp
@@ -0,0 +1,27 @@
+#include "software/ai/hl/stp/play/example/example_play_fsm.h"
+
+#include
+
+#include "proto/parameters.pb.h"
+#include "software/test_util/equal_within_tolerance.h"
+#include "software/test_util/test_util.h"
+
+TEST(ExamplePlayFSMTest, test_transitions)
+{
+ std::shared_ptr world = ::TestUtil::createBlankTestingWorld();
+ TbotsProto::AiConfig ai_config;
+
+ FSM fsm(ExamplePlayFSM{});
+
+ EXPECT_TRUE(fsm.is(boost::sml::state));
+
+ int num_tactics = 6;
+
+ fsm.process_event(ExamplePlayFSM::Update(
+ ExamplePlayFSM::ControlParams{},
+ PlayUpdate(
+ world, num_tactics, [](PriorityTacticVector new_tactics) {},
+ InterPlayCommunication{}, [](InterPlayCommunication comm) {})));
+
+ EXPECT_TRUE(fsm.is(boost::sml::state));
+}
diff --git a/src/software/ai/hl/stp/play/example_play_test.cpp b/src/software/ai/hl/stp/play/example/example_play_test.cpp
similarity index 97%
rename from src/software/ai/hl/stp/play/example_play_test.cpp
rename to src/software/ai/hl/stp/play/example/example_play_test.cpp
index 2d39958651..bee24a35ec 100644
--- a/src/software/ai/hl/stp/play/example_play_test.cpp
+++ b/src/software/ai/hl/stp/play/example/example_play_test.cpp
@@ -1,4 +1,4 @@
-#include "software/ai/hl/stp/play/example_play.h"
+#include "software/ai/hl/stp/play/example/example_play.h"
#include
diff --git a/src/software/ai/hl/stp/play/example/example_play_test.py b/src/software/ai/hl/stp/play/example/example_play_test.py
new file mode 100644
index 0000000000..ef5560feb1
--- /dev/null
+++ b/src/software/ai/hl/stp/play/example/example_play_test.py
@@ -0,0 +1,100 @@
+import sys
+
+import pytest
+
+import software.python_bindings as tbots_cpp
+from software.simulated_tests.robot_enters_region import (
+ NumberOfRobotsEventuallyExitsRegion,
+ NumberOfRobotsEventuallyEntersRegion,
+)
+from software.simulated_tests.robot_speed_threshold import *
+from proto.message_translation.tbots_protobuf import create_world_state
+from proto.ssl_gc_common_pb2 import Team
+from proto.play_pb2 import Play, PlayName
+
+
+def test_example_play(simulated_test_runner):
+ ball_initial_pos = tbots_cpp.Point(0, 0)
+
+ def setup(*args):
+ # Setup Bots
+ blue_bots = [
+ tbots_cpp.Point(-3, 2.5),
+ tbots_cpp.Point(-3, 1.5),
+ tbots_cpp.Point(-3, 0.5),
+ tbots_cpp.Point(-3, -0.5),
+ tbots_cpp.Point(-3, -1.5),
+ tbots_cpp.Point(-3, -2.5),
+ ]
+
+ yellow_bots = [
+ tbots_cpp.Point(1, 0),
+ tbots_cpp.Point(1, 2.5),
+ tbots_cpp.Point(1, -2.5),
+ tbots_cpp.Field.createSSLDivisionBField().enemyGoalCenter(),
+ tbots_cpp.Field.createSSLDivisionBField()
+ .enemyDefenseArea()
+ .negXNegYCorner(),
+ tbots_cpp.Field.createSSLDivisionBField()
+ .enemyDefenseArea()
+ .negXPosYCorner(),
+ ]
+
+ # Force play override here
+ blue_play = Play()
+ blue_play.name = PlayName.ExamplePlay
+
+ yellow_play = Play()
+ yellow_play.name = PlayName.HaltPlay
+
+ simulated_test_runner.blue_full_system_proto_unix_io.send_proto(Play, blue_play)
+ simulated_test_runner.yellow_full_system_proto_unix_io.send_proto(
+ Play, yellow_play
+ )
+
+ # Game Controller Setup
+ simulated_test_runner.gamecontroller.send_gc_command(
+ gc_command=Command.Type.STOP, team=Team.UNKNOWN
+ )
+ simulated_test_runner.gamecontroller.send_gc_command(
+ gc_command=Command.Type.NORMAL_START, team=Team.BLUE
+ )
+ simulated_test_runner.gamecontroller.send_gc_command(
+ gc_command=Command.Type.DIRECT, team=Team.BLUE
+ )
+
+ # Create world state
+ simulated_test_runner.simulator_proto_unix_io.send_proto(
+ WorldState,
+ create_world_state(
+ yellow_robot_locations=yellow_bots,
+ blue_robot_locations=blue_bots,
+ ball_location=ball_initial_pos,
+ ball_velocity=tbots_cpp.Vector(0, 0),
+ ),
+ )
+
+ # params just have to be a list of length 1 to ensure the test runs at least once
+ simulated_test_runner.run_test(
+ setup=setup,
+ params=[0],
+ inv_always_validation_sequence_set=[[]],
+ inv_eventually_validation_sequence_set=[
+ [
+ NumberOfRobotsEventuallyEntersRegion(
+ region=tbots_cpp.Circle(ball_initial_pos, 1.1), req_robot_cnt=6
+ ),
+ NumberOfRobotsEventuallyExitsRegion(
+ region=tbots_cpp.Circle(ball_initial_pos, 0.9), req_robot_cnt=6
+ ),
+ ]
+ ],
+ ag_always_validation_sequence_set=[[]],
+ ag_eventually_validation_sequence_set=[[]],
+ test_timeout_s=10,
+ )
+
+
+if __name__ == "__main__":
+ # Run the test, -s disables all capturing at -vv increases verbosity
+ sys.exit(pytest.main([__file__, "-svv"]))
diff --git a/src/software/ai/hl/stp/play/example_play.cpp b/src/software/ai/hl/stp/play/example_play.cpp
deleted file mode 100644
index e9480f767b..0000000000
--- a/src/software/ai/hl/stp/play/example_play.cpp
+++ /dev/null
@@ -1,42 +0,0 @@
-#include "software/ai/hl/stp/play/example_play.h"
-
-#include "software/ai/hl/stp/tactic/move/move_tactic.h"
-#include "software/util/generic_factory/generic_factory.h"
-
-ExamplePlay::ExamplePlay(TbotsProto::AiConfig config) : Play(config, false) {}
-
-void ExamplePlay::getNextTactics(TacticCoroutine::push_type &yield,
- const WorldPtr &world_ptr)
-{
- std::vector> move_tactics(DIV_A_NUM_ROBOTS);
- std::generate(move_tactics.begin(), move_tactics.end(),
- []() { return std::make_shared(); });
-
- // Continue to loop to demonstrate the example play indefinitely
- do
- {
- // The angle between each robot spaced out in a circle around the ball
- Angle angle_between_robots =
- Angle::full() / static_cast(move_tactics.size());
-
- for (size_t k = 0; k < move_tactics.size(); k++)
- {
- move_tactics[k]->updateControlParams(
- world_ptr->ball().position() +
- Vector::createFromAngle(angle_between_robots *
- static_cast(k + 1)),
- (angle_between_robots * static_cast(k + 1)) + Angle::half());
- }
-
- // yield the Tactics this Play wants to run, in order of priority
- // If there are fewer robots in play, robots at the end of the list will not be
- // assigned
- TacticVector result = {};
- result.insert(result.end(), move_tactics.begin(), move_tactics.end());
- yield({result});
-
- } while (true);
-}
-
-// Register this play in the genericFactory
-static TGenericFactory factory;
diff --git a/src/software/ai/hl/stp/play/example_play.h b/src/software/ai/hl/stp/play/example_play.h
deleted file mode 100644
index e42c936767..0000000000
--- a/src/software/ai/hl/stp/play/example_play.h
+++ /dev/null
@@ -1,16 +0,0 @@
-#pragma once
-
-#include "proto/parameters.pb.h"
-#include "software/ai/hl/stp/play/play.h"
-
-/**
- * An example Play that moves the robots in a circle around the ball
- */
-class ExamplePlay : public Play
-{
- public:
- explicit ExamplePlay(TbotsProto::AiConfig config);
-
- void getNextTactics(TacticCoroutine::push_type &yield,
- const WorldPtr &world_ptr) override;
-};
diff --git a/src/software/ai/hl/stp/tactic/keep_away/keep_away_fsm.h b/src/software/ai/hl/stp/tactic/keep_away/keep_away_fsm.h
index 7afb334997..38283aa7d5 100644
--- a/src/software/ai/hl/stp/tactic/keep_away/keep_away_fsm.h
+++ b/src/software/ai/hl/stp/tactic/keep_away/keep_away_fsm.h
@@ -11,7 +11,7 @@ struct KeepAwayFSM
*
* @param ai_config The config to fetch parameters from
*/
- explicit KeepAwayFSM(const TbotsProto::AiConfig& ai_config) : ai_config(ai_config){};
+ explicit KeepAwayFSM(const TbotsProto::AiConfig& ai_config) : ai_config(ai_config) {};
struct ControlParams
{