diff --git a/source/network/tests/test_FSM.h b/source/network/tests/test_FSM.h
new file mode 100644
index 0000000000..eaae766394
--- /dev/null
+++ b/source/network/tests/test_FSM.h
@@ -0,0 +1,163 @@
+/* Copyright (C) 2023 Wildfire Games.
+ * This file is part of 0 A.D.
+ *
+ * 0 A.D. is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * 0 A.D. is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with 0 A.D. If not, see .
+ */
+
+#include "lib/self_test.h"
+
+#include "network/FSM.h"
+
+#include
+
+class TestFSM : public CxxTest::TestSuite
+{
+ struct FSMGlobalState
+ {
+ std::array occurCount{0, 0, 0};
+ };
+
+ template
+ static bool editGlobal(void* state, const CFsmEvent*)
+ {
+ ++std::get(reinterpret_cast(state)->occurCount);
+ return true;
+ }
+
+ static bool editParam(void*, CFsmEvent* event)
+ {
+ ++*reinterpret_cast(event->GetParamRef());
+ return true;
+ }
+
+ enum class State : unsigned int
+ {
+ zero,
+ one,
+ two
+ };
+
+ enum class Instruction : unsigned int
+ {
+ toZero,
+ toOne,
+ toTwo
+ };
+
+public:
+
+ void test_global()
+ {
+ FSMGlobalState globalState;
+ CFsm FSMObject;
+
+ /*
+ Corresponding pseudocode
+
+ while (true)
+ {
+ // state zero
+ await nextInstruction();
+
+ // state one
+ const auto cond = await nextInstruction();
+
+ if (cond == instruction::toOne)
+ {
+ //state two
+ await nextInstruction();
+ }
+ }
+ */
+
+ FSMObject.AddTransition(static_cast(State::zero),
+ static_cast(Instruction::toOne), static_cast(State::one),
+ reinterpret_cast(&editGlobal<1>), static_cast(&globalState));
+ FSMObject.AddTransition(static_cast(State::one),
+ static_cast(Instruction::toTwo), static_cast(State::two),
+ reinterpret_cast(&editGlobal<2>), static_cast(&globalState));
+ FSMObject.AddTransition(static_cast(State::one),
+ static_cast(Instruction::toZero), static_cast(State::zero),
+ reinterpret_cast(&editGlobal<0>), static_cast(&globalState));
+ FSMObject.AddTransition(static_cast(State::two),
+ static_cast(Instruction::toZero), static_cast(State::zero),
+ reinterpret_cast(&editGlobal<0>), static_cast(&globalState));
+
+ FSMObject.SetFirstState(static_cast(State::zero));
+
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toOne), nullptr));
+ TS_ASSERT_EQUALS(std::get<1>(globalState.occurCount), 1);
+ TS_ASSERT_EQUALS(FSMObject.GetCurrState(), static_cast(State::one));
+
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toTwo), nullptr));
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toZero), nullptr));
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toOne), nullptr));
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toZero), nullptr));
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toOne), nullptr));
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toZero), nullptr));
+ TS_ASSERT_EQUALS(std::get<0>(globalState.occurCount), 3);
+ TS_ASSERT_EQUALS(std::get<1>(globalState.occurCount), 3);
+ TS_ASSERT_EQUALS(std::get<2>(globalState.occurCount), 1);
+
+ // Some transitions do not exist.
+ TS_ASSERT(!FSMObject.Update(static_cast(Instruction::toZero), nullptr));
+ TS_ASSERT(!FSMObject.Update(static_cast(Instruction::toTwo), nullptr));
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toOne), nullptr));
+ TS_ASSERT(!FSMObject.Update(static_cast(Instruction::toOne), nullptr));
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toTwo), nullptr));
+ TS_ASSERT(!FSMObject.Update(static_cast(Instruction::toTwo), nullptr));
+ TS_ASSERT_EQUALS(std::get<0>(globalState.occurCount), 3);
+ TS_ASSERT_EQUALS(std::get<1>(globalState.occurCount), 4);
+ TS_ASSERT_EQUALS(std::get<2>(globalState.occurCount), 2);
+ }
+
+ void test_param()
+ {
+ FSMGlobalState globalState;
+ CFsm FSMObject;
+
+ // Equal to the FSM in test_global.
+ FSMObject.AddTransition(static_cast(State::zero),
+ static_cast(Instruction::toOne), static_cast(State::one),
+ reinterpret_cast(&editParam), nullptr);
+ FSMObject.AddTransition(static_cast(State::one),
+ static_cast(Instruction::toTwo), static_cast(State::two),
+ reinterpret_cast(&editParam), nullptr);
+ FSMObject.AddTransition(static_cast(State::one),
+ static_cast(Instruction::toZero), static_cast(State::zero),
+ reinterpret_cast(&editParam), nullptr);
+ FSMObject.AddTransition(static_cast(State::two),
+ static_cast(Instruction::toZero), static_cast(State::zero),
+ reinterpret_cast(&editParam), nullptr);
+
+ FSMObject.SetFirstState(static_cast(State::zero));
+
+ // Some transitions do not exist.
+ TS_ASSERT(!FSMObject.Update(static_cast(Instruction::toZero),
+ static_cast(&std::get<0>(globalState.occurCount))));
+ TS_ASSERT(!FSMObject.Update(static_cast(Instruction::toTwo),
+ static_cast(&std::get<2>(globalState.occurCount))));
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toOne),
+ static_cast(&std::get<1>(globalState.occurCount))));
+ TS_ASSERT(!FSMObject.Update(static_cast(Instruction::toOne),
+ static_cast(&std::get<1>(globalState.occurCount))));
+ TS_ASSERT(FSMObject.Update(static_cast(Instruction::toTwo),
+ static_cast(&std::get<2>(globalState.occurCount))));
+ TS_ASSERT(!FSMObject.Update(static_cast(Instruction::toTwo),
+ static_cast(&std::get<2>(globalState.occurCount))));
+ TS_ASSERT_EQUALS(std::get<0>(globalState.occurCount), 0);
+ TS_ASSERT_EQUALS(std::get<1>(globalState.occurCount), 1);
+ TS_ASSERT_EQUALS(std::get<2>(globalState.occurCount), 1);
+ }
+};