Operation: Re-Boot

When a leader suddenly disappears, things go south. This is precisely what happened in my Arma 3 unit. Things went south.

Our unit leader vanished from one day to the next, leaving us all in the dark. Nobody really knew what was going on. Not good. Let me tell you why.

Much of our unit's operations were manual processes. Ewww, right?!

One significant area impacted by our leader's departure was the recruiting process. This process typically unfolded as follows:

  1. A new candidate joins our Discord server.
  2. The candidate chats with the unit leader, and they schedule a meeting.
  3. During the meeting, they go through various tasks and questions about Arma 3.

While this is a simplified explanation, the onboarding process usually took about 30 Minutes. Now, nobody is here to fill in this void. We need a solution. As always better yesterday, than today.

A Spark of Innovation

Well, it is not all bad. Since there is nobody at the top holding the creative people back (e.g. me), some stuff can actually change for the better.

One of these things is the onboarding process. I'm taking care of this!

What do you think I'm going to do now? Sit down and go through the training with strangers on the internet?? Nope, fuck that. I'm going to automate that shit.

Automated Bootcamp

No no, I don't think you understand. I'm not just building a mission that every newcomer has to go through and then it is all good and toasty. As a developer, I don't need extra human interaction, especially if I can automate processes. I'm talking about the real deal.

What does this mean? Let me explain by showing you first the current diagram:

So, wtf is going on here? The flow isn't that complicated at all. The magic part is actually Pythia.

This mod allows you to execute Python scripts from Arma. Pretty dope, right?! At that point, you can do pretty much anything you like.

In my case, I use it as an API to a Flask app, which is also an API to my database. Because you can't have enough APIs in your Architecture

As for the database, let's keep it simple: Pocketbase!

PocketBase is an open source backend consisting of embedded database (SQLite) with realtime subscriptions, built-in auth management, convenient dashboard UI and simple REST-ish API.

Wait a minute

Some of you might ask now: Why not simply go the direct route from Pythia to the database or simply use INIDBI2?

INIDIBI2 is out right away. We are running on a Linux server baby. INIDIBI2 has only support for Windows.

Ok, but about the direct route from Pythia to the database? I've had two reasons. First, this way is easier to debug any issues and second, we want later to enhance the Flask app to do more, e.g. connect with Discord.

The flow

The player plays the mission on our server. Certain events trigger Pythia to send data over to Flask, which is then stored in the database. Simple, yet effective.

Code in Boots

Disclaimer:

Before I show push you in the code I want to make clear, at this point the project is a Beta version. We are at the stage of Make it work. Also, I'm not coding that much in SQF. Expect garbage code, a lot of garbage code.

This out of the way, let me show you the code:

/initPlayerLocal.sqf

if !(isMultiplayer) then
{
	["Play this in Multiplayer", true, 3] remoteExec ["BIS_fnc_endMission", 0, true];
};

waitUntil {
	!isNull findDisplay 46
};

RCT7playerData = nil;

[] execVM "scarCODE\ServerInfoMenu\sqf\initLocal.sqf";
[player] remoteExec ["RCT7_getFromDb", 2];

[] spawn {
	waitUntil {
		sleep 3;
		{
			publicVariable "RCT7playerData"
		} remoteExec ["call", 2];
		!(isNil "RCT7playerData");
	};
	createDialog 'RscDisplayServerInfoMenu';

	private _prefix = "Start";
	if (count RCT7playerData > 0) then {
		_prefix = "Resume";
	};
	_actionTitle = [_prefix, "Bootcamp"] joinString " ";

	player addAction [_actionTitle,

		{
			params ["_target", "_caller", "_actionId", "_arguments"];

			player removeAction _actionId;

			[] spawn RCT7Bootcamp_fnc_init;
		}
	];
};

Server-side

The most interesting part above is [player] remoteExec ["RCT7_getFromDb", 2]; so let me show you the code behind it:

/initServer.sqf

RCT7_dbQueue = [];

RCT7_writeToDb = {
	params["_player", "_section", "_key", "_value", ["_additional_pairs", []]];

	if !(isServer) exitWith {};

	_section_data = [ [_key, _value] ]; // Create section data array with the given key-value pair

	// Add additional key-value pairs if any
	{
		if (typeName _x == "ARRAY" && count _x == 2) then {
			// Check if it's a valid key-value pair
			_additional_key = _x select 0;
			_additional_value = _x select 1;
			_section_data pushBack [_additional_key, _additional_value];
		};
	} forEach _additional_pairs;

	_data = [_section, _section_data];
	["bootcamp.add", [getPlayerUID _player, name _player, _data]] call py3_fnc_callExtension;
};

RCT7_getFromDb = {
	_player = param[0, objNull, [objNull]];
	if !(isServer) exitWith {};

	_data = ["bootcamp.get_data", [getPlayerUID _player]] call py3_fnc_callExtension;
	RCT7playerData = _data;

    // no data could be found, so we assume a new recruite
    // Potential problem: DB and/or Flask are not running
	if (_data # 0 # 0 isEqualTo "error") exitWith {
		RCT7playerData = [];
	};

	publicVariable "RCT7playerData";
};

RCT7_addToDBQueue = {
	_item = param[0, [], [[]]];

	RCT7_dbQueue pushBack _item;
};

_runQueue = {
	[] spawn {
		while { true } do {
			sleep 1;
			if (count RCT7_dbQueue isEqualTo 0) then {
				continue;
			};

			_currentItem = RCT7_dbQueue # 0;
			_currentItem call RCT7_writeToDb;
			RCT7_dbQueue deleteAt 0;
		};
	};
};

call _runQueue;

So the server side on Arma is simply responsible for initializing the player with the proper data (stored in RCT7playerData). During the mission, the server then simply receives data from the client and pushes it to the flask API to be stored in the database.

Because I also ran into race conditions when it comes to writing to the database I've added a queue, so every new entry is added correctly in the database.

Don't judge me. Yes, I know it should be handled by the Flask app, but it was easier to do it here, so I'll take this as a technical debt and move this when I'm going to refactor the code.

Pythia-side

As Pythia dictates the setup I've created an empty mod with my Python code.

Behold my mighty Python code:

/bootcamp.py

import json
import threading
import http.client
import socket
import encodings.idna

URL="127.0.0.1"
PORT=5001


def send_post_request(playerUID: str, playerName: str, data: dict):
    endpoint = "/bootcamp"
    data = {"playerUID": playerUID, "playerName": playerName, "data": data}
    body = json.dumps(data).encode("utf-8")
    headers = {"Content-Type": "application/json", "Content-Length": len(body)}
    try:
        conn = http.client.HTTPConnection(URL, PORT, timeout=0.1)
        conn.request("POST", endpoint, body, headers)
        conn.getresponse()  # We're not waiting for the response
    except (http.client.HTTPException, socket.timeout):
        pass
    finally:
        conn.close()


def process_nested_data(data):
    if isinstance(data, dict):
        return [[key, process_nested_data(value)] for key, value in data.items()]
    elif isinstance(data, list):
        return [process_nested_data(item) for item in data]
    else:
        return data


def get_data(playerUID: str):
    endpoint = f"/bootcamp/{playerUID}"

    try:
        conn = http.client.HTTPConnection(URL, PORT)
        conn.request("GET", endpoint)
        response = conn.getresponse()

        if response.status != 200:
            return [["error", f"Could not get data for playerUID {playerUID}"]]
        json_data = json.loads(response.read().decode("utf-8"))
        return [[key, process_nested_data(value)] for key, value in json_data.items()]
    except (http.client.HTTPException, socket.timeout):
        return [["error", f"Could not get data for playerUID {playerUID}"]]
    finally:
        conn.close()


def convert_list(lst):
    if len(lst) < 2:
        raise ValueError("Input list has an incorrect format")

    section_name = lst[0]
    key_value_pairs = lst[1]

    res_dct = {section_name: {}}
    for pair in key_value_pairs:
        if len(pair) == 2:
            key, value = pair
            res_dct[section_name][key] = value
        else:
            raise ValueError("Each key-value pair must have exactly 2 elements")

    return res_dct


def add(playerUID: str, playerName: str, data: list):
    post_thread = threading.Thread(
        target=send_post_request, args=(playerUID, playerName, convert_list(data))
    )
    post_thread.start()

    return True
    ```

The only functions used in Arma are get_data to get the user data and add to add something to the database.

I know. I know. It is not good, but it works, so it is good.

No requests? Yes!

My goal here was to keep the Python environment as clean as possible. Reason: Performance. While I was testing my scripts  requests the initial loading of the data was a lot slower than without it, so I decided to ditch it.

Because I also use asynchronous requests here I don't need to worry about waiting for a response from the Flask API. The only downside of this is, the DB or Flask might crash and the process won't be stored.

How do I plan to fix this? Like in the old saying: Don't fix it, when it's not broken. I'm not going to solve this until it actually appears.

Flask-side

The Flask app is as simple as it can be. I'm just showing it, so you have the full picture:

/app.py

from flask import Flask, request, jsonify
from pb import client

collection = client.collection("bootcamp")
app = Flask(__name__)


@app.route("/bootcamp", methods=["POST"])
def index():
    data = request.get_json()
    print(data)

    uid = data["playerUID"]
    record = collection.get_list(1, 1, {"filter": f"playerUID = {uid}"})

    if not record.items:
        collection.create(data)
        return {"message": "OK"}

    item = record.items[0]

    if item.data is None:
        item.data = {}

    section = list(data["data"].keys())[0]

    if section not in item.data:
        item.data[section] = {}

    for key, value in data["data"][section].items():
        if key in item.data[section] and isinstance(value, list):
            item.data[section][key].extend(value)
        else:
            item.data[section][key] = value
    updated_item_data = {
        "playerUID": item.player_uid,
        "playerName": item.player_name,
        "data": item.data,
    }
    collection.update(item.id, updated_item_data)

    return {"message": "OK"}


@app.route("/bootcamp/<playerUID>", methods=["GET"])
def get_player_data(playerUID):
    record = collection.get_list(1, 1, {"filter": f"playerUID = {playerUID}"})

    if not record.items:
        return {"message": "Player not found"}, 404

    item = record.items[0]
    data = jsonify(
        {
            "playerUID": str(item.player_uid),
            "playerName": item.player_name,
            "data": item.data,
        }
    )

    return data


if __name__ == "__main__":
    app.run()

/pb.py

from pocketbase import PocketBase
import os

client = PocketBase("http://127.0.0.1:8090")
admin_data = client.admins.auth_with_password(
    os.environ["PB_USERNAME"], os.environ["PB_PASSWORD"]
)

Easy right? Let's move on to the fun part now!

The BootCamp

The BootCamp consists of the following sections:

  1. Radio Familiarization
  2. Understanding 3D Reports
  3. Throwing Grenades
  4. Map Reading
  5. Close Quarters Combat
  6. Anti-Tank Usage
  7. Anti-Air Usage
  8. Formations
  9. Bounding
  10. ACE Medical

Let me show you the 3D Report section because it covers everything in one go:

/functions/trainings/fn_mapTrainingModule.sqf

/*
	Author: Eduard Schwarzkopf
	
	Description:
	Map training module
	
	Parameter(s):
	0: Object - module that has all elements synched to it
	
	Returns:
	true
*/

// Initialize variables and objects
_module = param[0, objNull, [objNull]];
_syncedObjects = synchronizedObjects _module;

private _triggerObj = nil;
private _targetController = nil;
private _targetClusterList = [];

// Iterate through synced objects and classify them
{
	private _syncedObj = _x;

	    // Check if object is a Target Cluster
	if (_syncedObj isKindOf "Logic" && ["TargetCluster", str _syncedObj] call BIS_fnc_inString) then {
		_targetClusterList pushBack _syncedObj;
		continue;
	};

	    // Check if object is a Target Controller
	if (_syncedObj isKindOf "Logic" && ["TargetController", str _syncedObj] call BIS_fnc_inString) then {
		_targetController = _syncedObj;
		continue;
	};
} forEach _syncedObjects;

// Initialize Target Controller
[_targetController, 0] call RCT7Bootcamp_fnc_handleTargets;

// Initialize fired event counters
firedCount = 0;
_firedIndex = player addEventHandler ["Fired", {
	firedCount = firedCount + 1;
}];

// Initialize and iterate through target clusters
_index = 0;
_magSize = getNumber (configfile >> "CfgMagazines" >> (getArray (configFile >> "CfgWeapons" >> currentWeapon player >> "magazines") # 0) >> "count");
_count = count(_targetClusterList);

// Initialize earplug task
call RCT7Bootcamp_fnc_earplugTask;

// Create the main task for Map Training
private _mainTask = "MapTraining";
[_mainTask, "Finish the Map Training", "Follow the instructions", "intel", "CREATED", true, true, -1] call RCT7Bootcamp_fnc_taskCreate;

// Start the player section
player call RCT7Bootcamp_fnc_sectionStart;

// Iterate through target clusters and manage shooting tasks
while { _count isNotEqualTo _index } do {
	1 call RCT7Bootcamp_fnc_handleMags;
	_targetCluster = _targetClusterList select _index;

	    // get list of valid targets
	_targetList = [];
	{
		if (_x isKindOf "TargetBase") then {
			_targetList pushBack _x;
		};
	} forEach (synchronizedObjects _targetCluster);

	    // set up variables for shooting tasks
	_targetCount = count(_targetList);
	    _invalidTargetCluster =  + _targetClusterList; // copy array
	_invalidTargetCluster deleteAt _index;
	_grid = mapGridPosition (_targetList # 0);

	    // Initialize database section name
	dbSectionName = ["Grid", _grid] joinString "-";

	    // Initialize shot counters
	shotsValid = 0;
	shotsInvalid = 0;
	_shotsMissed = 0;
	firedCount = 0;

	    // set up task description and subtask variables
	_taskDescription = ["Shoot all", _targetCount, "targets at grid:<br/><br/>", _grid select [0, 3], _grid select [3, 5]] joinString " ";
	_subTaskId = ["TargetCluster", _index] joinString "_";
	_subTaskTitle = [_index + 1, "Hit the correct Targets"] joinString " - ";

	    // Create subtask
	systemChat ([_subTaskId, _mainTask] joinString ".....");
	[[_subTaskId, _mainTask], _subTaskTitle, _taskDescription, "search"] call RCT7Bootcamp_fnc_taskCreate;

	// Add event handlers for invalid targets
	{
		{
			_invalidTarget = _x;
			_invalidTarget addMPEventHandler ["MPHit", {
				params ["_unit", "_source", "_damage", "_instigator"];
				                // invalid
				if (_unit animationPhase "terc" isEqualTo 0) then {
					player call RCT7Bootcamp_fnc_targetHitInvalid;
					shotsInvalid = shotsInvalid + 1;
					_grid = mapGridPosition _unit;

					[[player, dbSectionName, "shotsInvalid", shotsInvalid, [["wrongTargetList", [_grid]]]]] remoteExec ["RCT7_addToDBQueue", 2];

					_unit removeAllMPEventHandlers "MPHit";
				};
			}];
		} forEach synchronizedObjects _x;
	} forEach _invalidTargetCluster;

	    // Add event handlers for valid targets
	{
		_x addMPEventHandler ["MPHit", {
			params ["_unit", "_source", "_damage", "_instigator"];
			player call RCT7Bootcamp_fnc_targetHitValid;
			shotsValid = shotsValid + 1;
			[[player, dbSectionName, "shotsValid", shotsValid]] remoteExec ["RCT7_addToDBQueue", 2];
			_unit removeAllMPEventHandlers "MPHit";
		}];
	} forEach _targetList;

	    // Wait until all targets are hit or player runs out of ammo
	_time = time;
	waitUntil {
		_targetCount isEqualTo shotsValid || _magSize isEqualTo firedCount
	};

	    // set subtask state and update index
	[_subTaskId] call RCT7Bootcamp_fnc_taskSetState;
	_index = _index + 1;
	_shotsMissed = firedCount - (shotsInvalid + shotsValid);
	[[player, dbSectionName, "shotsMissed", _shotsMissed, [["time", time - _time - 2]]]] remoteExec ["RCT7_addToDBQueue", 2];
	sleep 1;

	    // Reset target animations and remove event handlers
	(synchronizedObjects _targetController) apply {
		_x animate["terc", 0];
		_x removeAllMPEventHandlers "MPHit";
	};

	    // Show hint for the next target cluster
	if (_count > _index) then {
		["Next in", 3] call RCT7Bootcamp_fnc_cooldownHint;
	};
};

// Remove player fired event handler
player removeEventHandler ["Fired", _firedIndex];

// set target controller state and finish the section
[_targetController, 1] call RCT7Bootcamp_fnc_handleTargets;
player call RCT7Bootcamp_fnc_sectionFinished;
[_mainTask, "SUCCEEDED", true, true] call RCT7Bootcamp_fnc_taskSetState;
0 call RCT7Bootcamp_fnc_handleMags;

true;

The flow is actually simple. Let me show you the setup:

A little bit of explanation. The module is just an object that has everything synched together. This way I'm able to get all the other objects in the code without using global variables all over the place.

The TargetController has all pop-up targets synched. The reason is so I can control the state of the targets in one place. This means I can pop them up and down how I need this, e.g. when the section is started or finished. Nothing really fancy here.

Now for the more interesting part: Each of the TargetCluster Modules are synched with popup targets. In this case 3 targets on each Cluster. Then the loop goes through each cluster of targets and tells the player to shoot those targets at their grid.

The player gets the task of finding the correct cluster of targets and hitting all of them. Each shot is registered if it is a valid hit, missed shot, an invalid hit and which invalid cluster was hit. While I'm on it I also track the time the player needed to complete each section.

All sections are basically the same and the code is not that complicated once you get the idea. You can boil it down to the following components:

  1. Creating tasks a player needs to follow
  2. Wait until the task is complete
  3. Send the data to the server so it can be stored in the database
  4. Give the next task until the end of the section is reached

Mission Accomplished: A New Era for the Unit

In conclusion, Operation: Re-Boot demonstrates how the sudden disappearance of a leader can spark innovation and drive development.

The BootCamp is still in a Beta-Release but I'm proud of the current state. This will help us a lot of getting new recruits on board a lot faster.

Since every section is saved after the completion the recruit can simply resume where he left off.

Another great feature is if we decide to add a new training, everybody can just go through the BootCamp again and just complete the new section. This can be pretty much anything we need at this point.

As I've said, this is just in Beta, but this is going to be a great help to get new people on board. Stay tuned for any updates on this.

You can find the code here: https://github.com/Regimental-Combat-Team-VII/RCT-7-Bootcamp.Stratis

For any questions hit me up on Twitter.

Subscribe to Eduard Schwarzkopf

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe