3 Commits

Author SHA1 Message Date
6cf76d1019 implement CBA macros, fix for prod
- using a3go 0.3.2, no longer relies on ext callback for anything except RPT logging and waiting DB connect at postinit
- tested and functional
2023-10-12 15:42:54 -07:00
62fbe8b24c minor adjustments 2023-10-12 00:17:16 -07:00
71ec70ef6a fix pboprefix and update build commands 2023-10-10 20:37:36 -07:00
30 changed files with 506 additions and 462 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ hemtt.exe
*.bk
releases/
mariadb/db/
AttendanceTracker.config.json

View File

@@ -10,6 +10,7 @@ git_hash=6 # Default: 8
[files]
include=[
"AttendanceTracker.config.example.json",
# "AttendanceTracker.config.json", # used for copying active config during debugging
"LICENSE",
"README",
"mod.cpp",

View File

@@ -3,12 +3,12 @@
"mysqlHost": "localhost",
"mysqlPort": 3306,
"mysqlUser": "root",
"mysqlPassword": "password",
"mysqlPassword": "example",
"mysqlDatabase": "a3attendance"
},
"armaConfig": {
"dbUpdateInterval": "90s",
"debug": false,
"traceLogToFile": false
"trace": false
}
}

View File

@@ -113,7 +113,7 @@ In the event that the server crashes and a user has not been in the mission long
| --- | --- |
| worlds | Stores world information. |
| missions | Stores mission information. |
| Session | Stores rows that indicate player information and join/disconnect times. |
| sessions | Stores rows that indicate player information and join/disconnect times. |
### Worlds
@@ -159,39 +159,50 @@ Once it's built, copy the file from ./dist to the project root, then build the a
#### COMPILING FOR WINDOWS
These compile commands should be run from the project root.
```ps1
docker pull x1unix/go-mingw:1.20
# version is semantic + build date + git hash
# e.g. 1.0.0-2021-05-30-1a2b3c4d
$versionSem = '1.1.0'
$versionSem = '1.1.1'
$dateStr = Get-Date -Format 'yyyyMMdd'
$version = "$versionSem-$dateStr-$(git rev-parse --short HEAD)"
# Compile x64 Windows DLL
docker run --rm -it -v ${PWD}:/go/work -w /go/work -e GOARCH=amd64 -e CGO_ENABLED=1 x1unix/go-mingw:1.20 go build -o ./dist/AttendanceTracker_x64.dll -buildmode=c-shared -ldflags "-w -s -X main.EXTENSION_VERSION=$version" ./extension/AttendanceTracker/cmd
docker run --rm -it -v ${PWD}\extension\AttendanceTracker:/go/work -w /go/work -e GOARCH=amd64 -e CGO_ENABLED=1 x1unix/go-mingw:1.20 go build -o ./dist/AttendanceTracker_x64.dll -buildmode=c-shared -ldflags "-w -s -X main.EXTENSION_VERSION=$version" ./cmd
Move-Item -Path ./extension/AttendanceTracker/dist/AttendanceTracker_x64.dll -Destination ./AttendanceTracker_x64.dll -Force
# Compile x86 Windows DLL
docker run --rm -it -v ${PWD}:/go/work -w /go/work -e GOARCH=386 -e CGO_ENABLED=1 x1unix/go-mingw:1.20 go build -o ./dist/AttendanceTracker.dll -buildmode=c-shared -ldflags "-w -s -X main.EXTENSION_VERSION=$version" ./extension/AttendanceTracker/cmd
docker run --rm -it -v ${PWD}\extension\AttendanceTracker:/go/work -w /go/work -e GOARCH=386 -e CGO_ENABLED=1 x1unix/go-mingw:1.20 go build -o ./dist/AttendanceTracker.dll -buildmode=c-shared -ldflags "-w -s -X main.EXTENSION_VERSION=$version" ./cmd
Move-Item -Path ./extension/AttendanceTracker/dist/AttendanceTracker.dll -Destination ./AttendanceTracker.dll -Force
# Compile x64 Windows EXE
docker run --rm -it -v ${PWD}:/go/work -w /go/work -e GOARCH=amd64 -e CGO_ENABLED=1 x1unix/go-mingw:1.20 go build -o ./dist/AttendanceTracker_x64.exe -ldflags "-w -s -X main.EXTENSION_VERSION=$version" ./extension/AttendanceTracker/cmd
```
#### COMPILING FOR LINUX
```bash
```ps1
docker build -t indifox926/build-a3go:linux-so -f ./build/Dockerfile.build .
# Compile x64 Linux .so
docker run --rm -it -v ${PWD}:/app -e GOOS=linux -e GOARCH=amd64 -e CGO_ENABLED=1 indifox926/build-a3go:linux-so go build -o ./dist/AttendanceTracker_x64.so -linkshared -ldflags "-w -s -X main.EXTENSION_VERSION=$version" ./extension/AttendanceTracker/cmd
docker run --rm -it -v ${PWD}\extension\AttendanceTracker:/app -e GOOS=linux -e GOARCH=amd64 -e CGO_ENABLED=1 indifox926/build-a3go:linux-so go build -o ./dist/AttendanceTracker_x64.so -linkshared -ldflags "-w -s -X main.EXTENSION_VERSION=$version" ./cmd
Move-Item -Path ./extension/AttendanceTracker/dist/AttendanceTracker_x64.so -Destination ./AttendanceTracker_x64.so -Force
# Compile x86 Linux .so
docker run --rm -it -v ${PWD}:/app -e GOOS=linux -e GOARCH=386 -e CGO_ENABLED=1 indifox926/build-a3go:linux-so go build -o ./dist/AttendanceTracker.so -linkshared -ldflags "-w -s -X main.EXTENSION_VERSION=$version" ./extension/AttendanceTracker/cmd
docker run --rm -it -v ${PWD}\extension\AttendanceTracker:/app -e GOOS=linux -e GOARCH=386 -e CGO_ENABLED=1 indifox926/build-a3go:linux-so go build -o ./dist/AttendanceTracker.so -linkshared -ldflags "-w -s -X main.EXTENSION_VERSION=$version" ./cmd
Move-Item -Path ./extension/AttendanceTracker/dist/AttendanceTracker.so -Destination ./AttendanceTracker.so -Force
```
### Compile Addon
First, move the compiled dlls from extension/AttendanceTracker/dist to the project root.
First, move the compiled dlls from `extension/AttendanceTracker/dist` to the project root.
To prepare the addon, you'll need to download the [HEMTT](https://brettmayson.github.io/HEMTT/commands/build.html) binary, place it in the project root, and run the following command:

View File

@@ -1 +1 @@
x\addons\attendancetracker\main
x\attendancetracker\addons\main

View File

@@ -1,7 +1,7 @@
#include "script_mod.hpp"
#include "script_component.hpp"
class CfgPatches {
class AttendanceTracker {
class ADDON {
units[] = {};
weapons[] = {};
requiredVersion = 2.10;
@@ -17,20 +17,17 @@ class CfgPatches {
};
class CfgFunctions {
class attendanceTracker {
class ADDON {
class functions {
file = "x\addons\attendancetracker\main\functions";
class postInit {postInit = 1;};
class callbackHandler {postInit = 1;};
class getMissionHash {};
class getMissionInfo {};
class getSettings {};
class getWorldInfo {};
class log {};
class missionLoaded {};
class onPlayerConnected {};
class timestamp {};
class writePlayer {};
class postInit {
file = QPATHTOF(DOUBLES(fnc,postInit).sqf);
postInit = 1;
};
PATHTO_FNC(getMissionInfo);
PATHTO_FNC(getWorldInfo);
PATHTO_FNC(log);
PATHTO_FNC(missionLoaded);
PATHTO_FNC(onPlayerConnected);
};
};
};

View File

@@ -0,0 +1,13 @@
#include "script_component.hpp"
[
["missionName", missionName],
["missionStart", GVAR(missionStart)],
["missionHash", GVAR(missionHash)],
["briefingName", briefingName],
["missionNameSource", missionNameSource],
["onLoadName", getMissionConfigValue ["onLoadName", "Unknown"]],
["author", getMissionConfigValue ["author", "Unknown"]],
["serverName", serverName],
["serverProfile", profileName],
["worldName", toLower worldName]
];

View File

@@ -1,3 +1,5 @@
#include "script_component.hpp"
_world = ( configfile >> "CfgWorlds" >> worldName );
_author = getText( _world >> "author" );
_name = getText ( _world >> "description" );
@@ -5,7 +7,6 @@ _name = getText ( _world >> "description" );
_source = configSourceMod ( _world );
_workshopID = '';
{
if ( ( _x#1 ) == _source ) then {
_workshopID = _x#7;
@@ -13,8 +14,12 @@ _workshopID = '';
};
} foreach getLoadedModsInfo;
if (_workshopID isEqualTo "") then {
_workshopID = "0";
};
// [_name, _author, _workshopID];
_return = createHashMapFromArray [
_return = [
["author", _author],
["workshopID", _workshopID],
["displayName", _name],
@@ -24,5 +29,5 @@ _return = createHashMapFromArray [
["latitude", -1 * getNumber( _world >> "latitude" )],
["longitude", getNumber( _world >> "longitude" )]
];
[format["WorldInfo is: %1", _return]] call attendanceTracker_fnc_log;
["DEBUG", format["WorldInfo is: %1", _return]] call FUNC(log);
_return

31
addons/main/fnc_log.sqf Normal file
View File

@@ -0,0 +1,31 @@
#include "script_component.hpp"
if (!isServer) exitWith {};
if (typeName _this != "ARRAY") exitWith {
diag_log format ["[%1]: Invalid log params: %2", GVAR(logPrefix), _this];
};
params [
["_level", "INFO", [""]],
["_text", "", ["", []]]
];
if (_text isEqualType []) then {
_text = format ["%1", _text];
};
if (
_level == "DEBUG" &&
!GVAR(debug)
) exitWith {};
if (_text isEqualTo "") exitWith {};
diag_log formatText [
"[%1] %2: %3",
GVAR(logPrefix),
_level,
_text
];

View File

@@ -1 +1,3 @@
#include "script_component.hpp"
!(getClientStateNumber <= 5 || getClientStateNumber isEqualTo 11);

View File

@@ -0,0 +1,84 @@
#include "script_component.hpp"
params ["_id", "_uid", "_name", "_jip", "_owner", "_idstr"];
["DEBUG", format ["(EventHandler) PlayerConnected fired: %1", _this]] call FUNC(log);
if !(call FUNC(missionLoaded)) exitWith {
["DEBUG", format ["(EventHandler) PlayerConnected: Server is in Mission Asked, likely mission selection state. Skipping.."]] call FUNC(log);
};
private _userInfo = (getUserInfo _idstr);
if ((count _userInfo) isEqualTo 0) exitWith {
["DEBUG", format ["(EventHandler) PlayerConnected: No user info found for %1", _idstr]] call FUNC(log);
};
_userInfo params ["_playerID", "_ownerId", "_playerUID", "_profileName", "_displayName", "_steamName", "_clientState", "_isHC", "_adminState", "_networkInfo", "_unit"];
if (_isHC) exitWith {
[
"DEBUG",
format [
"(EventHandler) PlayerConnected: %1 is HC, skipping",
_playerID
]
] call FUNC(log);
};
// start CBA PFH
[
"DEBUG",
format [
"(EventHandler) PlayerConnected: Starting CBA PFH for %1",
_playerID
]
] call FUNC(log);
[{
params ["_args", "_handle"];
// every dbUpdateInterval, queue a wait for the mission to be logged
// times out after 30 seconds
// used to ensure joins at start of mission (during db connect) are logged
[{GVAR(missionLogged)}, {
// check if player is still connected
private _hash = _this;
private _clientStateNumber = 0;
private _userInfo = getUserInfo (_hash get "playerId");
if (_userInfo isEqualTo []) exitWith {
["DEBUG", format ["(EventHandler) PlayerConnected: %1 (UID) is no longer connected to the mission, exiting CBA PFH", _hash get "playerUID"]] call FUNC(log);
[_handle] call CBA_fnc_removePerFrameHandler;
};
_clientStateNumber = _userInfo select 6;
if (_clientStateNumber < 6) exitWith {
["DEBUG", format ["(EventHandler) PlayerConnected: %1 (UID) is no longer connected to the mission, exiting CBA PFH", _hash get "playerUID"]] call FUNC(log);
[_handle] call CBA_fnc_removePerFrameHandler;
};
["DEBUG", format [
"(EventHandler) PlayerConnected: %1 (UID) is connected to the mission, logging. data: %2",
_hash get "playerUID",
_hash
]] call FUNC(log);
GVAR(extensionName) callExtension [
":LOG:PRESENCE:", [
_hash
]];
},
_args, // args
30 // timeout
] call CBA_fnc_waitUntilAndExecute;
},
GVAR(updateInterval),
(createHashMapFromArray [ // args
["playerId", _playerID],
["playerUID", _playerUID],
["profileName", _profileName],
["steamName", _steamName],
["isJIP", _jip],
["roleDescription", if (roleDescription _unit isEqualTo "") then {"None"} else {roleDescription _unit}],
["missionHash", GVAR(missionHash)]
])
] call CBA_fnc_addPerFrameHandler;

View File

@@ -0,0 +1,94 @@
#include "script_component.hpp"
if (!isServer) exitWith {};
GVAR(attendanceTracker) = true;
GVAR(debug) = true;
GVAR(logPrefix) = "AttendanceTracker";
GVAR(extensionName) = "AttendanceTracker";
GVAR(missionLogged) = false;
addMissionEventHandler ["ExtensionCallback", {
params ["_name", "_function", "_data"];
if !(_name isEqualTo GVAR(extensionName)) exitWith {};
_dataArr = parseSimpleArray _data;
if (count _dataArr isEqualTo 0) exitWith {};
switch (_function) do {
case ":LOG:MISSION:SUCCESS:": {
GVAR(missionLogged) = true;
};
case ":LOG:": {
diag_log formatText[
"[%1] %2",
GVAR(logPrefix),
_dataArr select 0
];
};
default {
["DEBUG", format["%1", _dataArr]] call FUNC(log);
};
};
}];
// LOAD EXTENSION
GVAR(extensionName) callExtension ":START:";
// GET MISSION START TIMESTAMP AND UNIQUE HASH
private _missionHashData = parseSimpleArray ("AttendanceTracker" callExtension ":MISSION:HASH:");
if (count _missionHashData isEqualTo 0) exitWith {
["ERROR", "Failed to get mission hash, exiting"] call FUNC(log);
};
_missionHashData params ["_timestamp", "_hash"];
GVAR(missionStart) = _timestamp;
GVAR(missionHash) = _hash;
// PARSE SETTINGS
private _settings = parseSimpleArray (GVAR(extensionName) callExtension ":GET:SETTINGS:");
if (count _settings isEqualTo 0) exitWith {
["ERROR", "Failed to get settings, exiting"] call FUNC(log);
};
GVAR(settings) = createHashMapFromArray (_settings#0);
GVAR(debug) = GVAR(settings) getOrDefault ["debug", GVAR(debug)];
private _updateInterval = GVAR(settings) getOrDefault ["dbupdateinterval", 90];
// remove duration by removing the last index
_updateInterval = _updateInterval select [0, count _updateInterval - 1];
GVAR(updateInterval) = parseNumber _updateInterval;
// add player connected (to mission) handler
addMissionEventHandler ["PlayerConnected", {
_this call FUNC(onPlayerConnected);
}];
// we'll wait for the end of init (DB connect included) of the extension
// then we'll log the world and mission
// the response to THAT is handled above in the extension callback
// and will set GVAR(missionLogged) true
addMissionEventHandler ["ExtensionCallback", {
params ["_name", "_function", "_data"];
if !(_name isEqualTo GVAR(extensionName)) exitWith {};
if !(_function isEqualTo ":READY:") exitWith {};
// LOAD WORLD AND MISSION INFO
GVAR(worldInfo) = call FUNC(getWorldInfo);
GVAR(missionInfo) = call FUNC(getMissionInfo);
["INFO", (GVAR(extensionName) callExtension [
":LOG:MISSION:",
[
GVAR(worldInfo),
GVAR(missionInfo)
]
]) select 0] call FUNC(log);
// remove the handler
removeMissionEventHandler ["ExtensionCallback", _thisEventHandler];
}];

View File

@@ -1,24 +0,0 @@
addMissionEventHandler ["ExtensionCallback", {
params ["_name", "_function", "_data"];
if !(_name isEqualTo "AttendanceTracker") exitWith {};
if (ATDebug && _function isNotEqualTo ":LOG:") then {
diag_log format ["Raw callback: %1 _ %2", _function, _data];
};
_dataArr = parseSimpleArray _data;
if (count _dataArr < 1) exitWith {};
switch (_function) do {
case ":LOG:": {
diag_log formatText[
"[Attendance Tracker] %1",
_dataArr select 0
];
};
default {
[format["%1", _dataArr]] call attendanceTracker_fnc_log;
};
};
true;
}];

View File

@@ -1,19 +0,0 @@
addMissionEventHandler ["ExtensionCallback", {
params ["_extension", "_function", "_data"];
if !(_extension isEqualTo "AttendanceTracker") exitWith {};
if !(_function isEqualTo ":MISSION:HASH:") exitWith {};
_dataArr = parseSimpleArray _data;
if (count _dataArr < 1) exitWith {};
_dataArr params ["_startTime", "_hash"];
ATNamespace setVariable ["missionStartTime", call attendanceTracker_fnc_timestamp];
ATNamespace setVariable ["missionHash", _hash];
removeMissionEventHandler [
"ExtensionCallback",
_thisEventHandler
];
}];
"AttendanceTracker" callExtension ":MISSION:HASH:";

View File

@@ -1,12 +0,0 @@
createHashMapFromArray [
["missionName", missionName],
["missionStart", ATNamespace getVariable "missionStartTime"],
["missionHash", ATNamespace getVariable "missionHash"],
["briefingName", briefingName],
["missionNameSource", missionNameSource],
["onLoadName", getMissionConfigValue ["onLoadName", ""]],
["author", getMissionConfigValue ["author", ""]],
["serverName", serverName],
["serverProfile", profileName],
["worldName", toLower worldName]
];

View File

@@ -1,27 +0,0 @@
addMissionEventHandler ["ExtensionCallback", {
params ["_extension", "_function", "_data"];
if !(_extension isEqualTo "AttendanceTracker") exitWith {};
if !(_function isEqualTo ":GET:SETTINGS:") exitWith {};
_dataArr = parseSimpleArray _data;
diag_log format ["AT: Settings received: %1", _dataArr];
if (count _dataArr < 1) exitWith {};
private _settingsJSON = _dataArr select 0;
private _settingsNamespace = [_settingsJSON] call CBA_fnc_parseJSON;
{
ATNamespace setVariable [_x, _settingsNamespace getVariable _x];
} forEach (allVariables _settingsNamespace);
ATDebug = ATNamespace getVariable "debug";
ATUpdateDelay = ATNamespace getVariable "dbUpdateInterval";
// remove last character (unit of time) and parse to number
ATUpdateDelay = parseNumber (ATUpdateDelay select [0, count ATUpdateDelay - 1]);
removeMissionEventHandler [
"ExtensionCallback",
_thisEventHandler
];
}];
"AttendanceTracker" callExtension ":GET:SETTINGS:";

View File

@@ -1,17 +0,0 @@
#include "..\script_mod.hpp"
params [
["_message", "", [""]],
["_level", "INFO", [""]],
"_function"
];
if (isNil "_message") exitWith {false};
if (
missionNamespace getVariable ["ATDebug", true] &&
_level != "WARN" && _level != "ERROR"
) exitWith {};
LOG_SYS(_level, _message);
true;

View File

@@ -1,63 +0,0 @@
params ["_id", "_uid", "_name", "_jip", "_owner", "_idstr"];
[format ["(EventHandler) PlayerConnected fired: %1", _this], "DEBUG"] call attendanceTracker_fnc_log;
if !(call attendanceTracker_fnc_missionLoaded) exitWith {
[format ["(EventHandler) PlayerConnected: Server is in Mission Asked, likely mission selection state. Skipping.."], "DEBUG"] call attendanceTracker_fnc_log;
};
private _userInfo = (getUserInfo _idstr);
if ((count _userInfo) isEqualTo 0) exitWith {
[format ["(EventHandler) PlayerConnected: No user info found for %1", _idstr], "DEBUG"] call attendanceTracker_fnc_log;
};
_userInfo params ["_playerID", "_ownerId", "_playerUID", "_profileName", "_displayName", "_steamName", "_clientState", "_isHC", "_adminState", "_networkInfo", "_unit"];
if (_isHC) exitWith {
[
format [
"(EventHandler) PlayerConnected: %1 is HC, skipping",
_playerID
],
"DEBUG"
] call attendanceTracker_fnc_log;
};
// start CBA PFH
[
format [
"(EventHandler) PlayerConnected: Starting CBA PFH for %1",
_playerID
],
"DEBUG"
] call attendanceTracker_fnc_log;
[
{
params ["_args", "_handle"];
// check if player is still connected
_args params ["_playerID", "_playerUID", "_profileName", "_steamName", "_jip", "_roleDescription"];
private _userInfo = getUserInfo _playerID;
private _clientStateNumber = 0;
if (_userInfo isEqualTo []) exitWith {
[_handle] call CBA_fnc_removePerFrameHandler;
};
_clientStateNumber = _userInfo select 6;
if (_clientStateNumber < 6) exitWith {
[format ["(EventHandler) PlayerConnected: %1 (UID) is no longer connected to the mission, exiting CBA PFH", _playerID], "DEBUG"] call attendanceTracker_fnc_log;
[_handle] call CBA_fnc_removePerFrameHandler;
};
_args call attendanceTracker_fnc_writePlayer;
},
ATUpdateDelay,
[
_playerID,
_playerUID,
_profileName,
_steamName,
_jip,
roleDescription _unit
]
] call CBA_fnc_addPerFrameHandler;

View File

@@ -1,61 +0,0 @@
#include "..\script_mod.hpp"
if (!isServer) exitWith {};
ATNamespace = false call CBA_fnc_createNamespace;
ATDebug = true;
"AttendanceTracker" callExtension ":START:";
// we'll wait for the asynchronous init steps of the extension to finish, to confirm we have a DB connection and our config was loaded. If there are errors with either, the extension won't reply and initiate further during this mission.
addMissionEventHandler ["ExtensionCallback", {
params ["_name", "_function", "_data"];
if !(_name isEqualTo "AttendanceTracker") exitWith {};
if !(_function isEqualTo ":READY:") exitWith {};
call attendanceTracker_fnc_getMissionHash;
call attendanceTracker_fnc_getSettings;
[
{// wait until settings have been loaded from extension
!isNil {ATNamespace getVariable "missionHash"} &&
!isNil {ATDebug}
},
{
// get world and mission context
ATNamespace setVariable [
"worldContext",
call attendanceTracker_fnc_getWorldInfo
];
ATNamespace setVariable [
"missionContext",
call attendanceTracker_fnc_getMissionInfo
];
// write them to establish DB rows
"AttendanceTracker" callExtension [
":LOG:MISSION:",
[
[ATNamespace getVariable "missionContext"] call CBA_fnc_encodeJSON,
[ATNamespace getVariable "worldContext"] call CBA_fnc_encodeJSON
]
];
// add player connected (to mission) handler
addMissionEventHandler ["PlayerConnected", {
_this call attendanceTracker_fnc_onPlayerConnected;
}];
},
[],
10, // 10 second timeout
{ // timeout code
["Failed to load settings", "ERROR"] call attendanceTracker_fnc_log;
}
] call CBA_fnc_waitUntilAndExecute;
removeMissionEventHandler [
"ExtensionCallback",
_thisEventHandler
];
}];

View File

@@ -1,24 +0,0 @@
// (parseSimpleArray ("AttendanceTracker" callExtension "getTimestamp")) select 0;
// const time.RFC3339 untyped string = "2006-01-02T15:04:05Z07:00"
systemTimeUTC apply {if (_x < 10) then {"0" + str _x} else {str _x}} params [
"_year",
"_month",
"_day",
"_hour",
"_minute",
"_second",
"_millisecond"
];
format[
"%1-%2-%3T%4:%5:%6Z",
_year,
_month,
_day,
_hour,
_minute,
_second
];

View File

@@ -1,21 +0,0 @@
params [
["_playerId", ""],
["_playerUID", ""],
["_profileName", ""],
["_steamName", ""],
["_isJIP", false, [true, false]],
["_roleDescription", ""]
];
private _hash = +(ATNamespace getVariable ["missionContext", createHashMap]);
_hash set ["playerId", _playerId];
_hash set ["playerUID", _playerUID];
_hash set ["profileName", _profileName];
_hash set ["steamName", _steamName];
_hash set ["isJIP", _isJIP];
_hash set ["roleDescription", _roleDescription];
"AttendanceTracker" callExtension [":LOG:PRESENCE:", [[_hash] call CBA_fnc_encodeJSON]];
true;

View File

@@ -0,0 +1,4 @@
#define COMPONENT main
#define COMPONENT_BEAUTIFIED Main
#include "\x\attendancetracker\addons\main\script_mod.hpp"

View File

@@ -1,12 +1,8 @@
#include "script_version.hpp"
#define COMPONENT main
#define COMPONENT_BEAUTIFIED Main
#define MAINPREFIX x
#define PREFIX attendancetracker
#define PREFIX_BEAUTIFIED AttendanceTracker
#define SUBPREFIX addons
#define PREFIX AttendanceTracker
#include "\x\cba\addons\main\script_macros_common.hpp"

View File

@@ -1,7 +1,7 @@
#define MAJOR 1
#define MINOR 1
#define PATCH 0
#define BUILD 20231003
#define BUILD 20231012
#define VERSION 1.1
#define VERSION_STR MAJOR##.##MINOR##.##PATCH##.##BUILD

View File

@@ -13,7 +13,6 @@ import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
@@ -22,35 +21,25 @@ import (
"github.com/indig0fox/Arma3-AttendanceTracker/internal/util"
"github.com/indig0fox/a3go/a3interface"
"github.com/indig0fox/a3go/assemblyfinder"
"github.com/rs/zerolog"
)
const EXTENSION_NAME string = "AttendanceTracker"
const ADDON_NAME string = "AttendanceTracker"
const EXTENSION_VERSION string = "dev"
// file paths
const ATTENDANCE_TABLE string = "attendance"
const MISSIONS_TABLE string = "missions"
const WORLDS_TABLE string = "worlds"
var currentMissionID uint = 0
var RVExtensionChannels = map[string]chan string{
":START:": make(chan string),
":MISSION:HASH:": make(chan string),
":GET:SETTINGS:": make(chan string),
}
var RVExtensionArgsChannels = map[string]chan []string{
":LOG:MISSION:": make(chan []string),
":LOG:PRESENCE:": make(chan []string),
}
var (
EXTENSION_VERSION string = "DEVELOPMENT"
modulePath string
modulePathDir string
initSuccess bool // default false
loadedMission *Mission
loadedWorld *World
)
// configure log output
@@ -58,31 +47,28 @@ func init() {
a3interface.SetVersion(EXTENSION_VERSION)
a3interface.NewRegistration(":START:").
SetDefaultResponse(`["Extension beginning init process"]`).
SetFunction(onStartCommand).
SetRunInBackground(true).
SetRunInBackground(false).
Register()
a3interface.NewRegistration(":MISSION:HASH:").
SetDefaultResponse(`["Retrieving mission hash"]`).
SetFunction(onMissionHashCommand).
SetRunInBackground(true).
SetRunInBackground(false).
Register()
a3interface.NewRegistration(":GET:SETTINGS:").
SetDefaultResponse(`["Retrieving settings"]`).
SetFunction(onGetSettingsCommand).
SetRunInBackground(true).
SetRunInBackground(false).
Register()
a3interface.NewRegistration(":LOG:MISSION:").
SetDefaultResponse(`["Logging mission data"]`).
SetDefaultResponse(`Logging mission data`).
SetArgsFunction(onLogMissionArgsCommand).
SetRunInBackground(true).
Register()
a3interface.NewRegistration(":LOG:PRESENCE:").
SetDefaultResponse(`["Logging presence data"]`).
SetDefaultResponse(`Logging presence data`).
SetArgsFunction(onLogPresenceArgsCommand).
SetRunInBackground(true).
Register()
@@ -91,12 +77,7 @@ func init() {
var err error
modulePath = assemblyfinder.GetModulePath()
// get absolute path of module path
modulePathAbs, err := filepath.Abs(modulePath)
if err != nil {
panic(err)
}
modulePathDir = filepath.Dir(modulePathAbs)
modulePathDir = filepath.Dir(modulePath)
result, configErr := util.LoadConfig(modulePathDir)
logger.InitLoggers(&logger.LoggerOptionsType{
@@ -107,11 +88,13 @@ func init() {
EXTENSION_NAME,
EXTENSION_VERSION,
)),
AddonName: ADDON_NAME,
ExtensionName: EXTENSION_NAME,
Debug: util.ConfigJSON.GetBool("armaConfig.debug"),
Trace: util.ConfigJSON.GetBool("armaConfig.traceLogToFile"),
AddonName: ADDON_NAME,
ExtensionName: EXTENSION_NAME,
ExtensionVersion: EXTENSION_VERSION,
Debug: util.ConfigJSON.GetBool("armaConfig.debug"),
Trace: util.ConfigJSON.GetBool("armaConfig.trace"),
})
logger.RotateLogs()
if configErr != nil {
logger.Log.Error().Err(configErr).Msgf(`Error loading config`)
return
@@ -119,9 +102,7 @@ func init() {
logger.Log.Info().Msgf(result)
}
logger.RotateLogs()
logger.ArmaOnly.Info().Msgf(`%s v%s started`, EXTENSION_NAME, "0.0.0")
logger.Log.Info().Msgf(`%s v%s started`, EXTENSION_NAME, EXTENSION_VERSION)
logger.ArmaOnly.Info().Msgf(`Log path: %s`, logger.ActiveOptions.Path)
db.SetConfig(db.ConfigStruct{
@@ -151,9 +132,10 @@ func init() {
)
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error migrating database schema`)
} else {
logger.Log.Info().Msgf(`Database schema migrated`)
}
initSuccess = true
a3interface.WriteArmaCallback(
EXTENSION_NAME,
":READY:",
@@ -167,32 +149,22 @@ func onStartCommand(
ctx a3interface.ArmaExtensionContext,
data string,
) (string, error) {
logger.Log.Trace().Msgf(`RVExtension :START: requested`)
if !initSuccess {
logger.Log.Warn().Msgf(`Received another :START: command before init was complete, ignoring.`)
return "Initing!", nil
} else {
logger.RotateLogs()
a3interface.WriteArmaCallback(
EXTENSION_NAME,
":READY:",
)
return "Ready!", nil
}
logger.Log.Debug().Msgf(`RVExtension :START: requested`)
loadedWorld = nil
loadedMission = nil
return fmt.Sprintf(
`["%s v%s started"]`,
EXTENSION_NAME,
EXTENSION_VERSION,
), nil
}
func onMissionHashCommand(
ctx a3interface.ArmaExtensionContext,
data string,
) (string, error) {
logger.Log.Trace().Msgf(`RVExtension :MISSION:HASH: requested`)
logger.Log.Debug().Msgf(`RVExtension :MISSION:HASH: requested`)
timestamp, hash := getMissionHash()
a3interface.WriteArmaCallback(
EXTENSION_NAME,
":MISSION:HASH:",
timestamp,
hash,
)
return fmt.Sprintf(
`[%q, %q]`,
timestamp,
@@ -204,19 +176,14 @@ func onGetSettingsCommand(
ctx a3interface.ArmaExtensionContext,
data string,
) (string, error) {
logger.Log.Trace().Msg(`Settings requested`)
armaConfig, err := util.ConfigArmaFormat()
if err != nil {
logger.Log.Error().Err(err).Msg(`Error when marshaling arma config`)
return "", err
}
logger.Log.Trace().Str("armaConfig", armaConfig).Send()
a3interface.WriteArmaCallback(
EXTENSION_NAME,
":GET:SETTINGS:",
logger.Log.Debug().Msg(`RVExtension :GET:SETTINGS: requested`)
// get arma config
c := util.ConfigJSON.Get("armaConfig")
armaConfig := a3interface.ToArmaHashMap(c)
return fmt.Sprintf(
`[%s]`,
armaConfig,
)
return armaConfig, nil
), nil
}
func onLogMissionArgsCommand(
@@ -224,12 +191,27 @@ func onLogMissionArgsCommand(
command string,
args []string,
) (string, error) {
go func(data []string) {
writeWorldInfo(data[1])
writeMission(data[0])
}(args)
thisLogger := logger.Log.With().Str("command", command).Interface("ctx", ctx).Logger()
thisLogger.Debug().Msgf(`RVExtension :LOG:MISSION: requested`)
var err error
world, err := writeWorldInfo(args[0], thisLogger)
if err != nil {
return ``, err
}
loadedWorld = &world
return `["Logging mission data"]`, nil
mission, err := writeMission(args[1], thisLogger)
if err != nil {
return ``, err
}
loadedMission = &mission
a3interface.WriteArmaCallback(
EXTENSION_NAME,
":LOG:MISSION:SUCCESS:",
)
return ``, nil
}
func onLogPresenceArgsCommand(
@@ -237,8 +219,10 @@ func onLogPresenceArgsCommand(
command string,
args []string,
) (string, error) {
go writeAttendance(args[0])
return `["Logging presence data"]`, nil
thisLogger := logger.Log.With().Str("command", command).Interface("ctx", ctx).Logger()
thisLogger.Debug().Msgf(`RVExtension :LOG:PRESENCE: requested`)
writeAttendance(args[0], thisLogger)
return ``, nil
}
// getMissionHash will return the current time in UTC and an md5 hash of that time
@@ -248,7 +232,7 @@ func getMissionHash() (sqlTime, hashString string) {
nowTime := time.Now().UTC()
// mysql format
sqlTime = nowTime.Format("2006-01-02 15:04:05")
sqlTime = nowTime.Format(time.RFC3339)
hash := md5.Sum([]byte(sqlTime))
hashString = fmt.Sprintf(`%x`, hash)
@@ -291,86 +275,162 @@ func finalizeUnendedSessions() {
logger.Log.Info().Msgf(`Filled disconnect time of %d events.`, len(events))
}
func writeWorldInfo(worldInfo string) {
// worldInfo is json, parse it
var wi World
fixedString := unescapeArmaQuotes(worldInfo)
err := json.Unmarshal([]byte(fixedString), &wi)
func writeWorldInfo(worldInfo string, thisLogger zerolog.Logger) (World, error) {
parsedInterface, err := a3interface.ParseSQF(worldInfo)
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error when unmarshalling world info`)
return
thisLogger.Error().Err(err).Msgf(`Error when parsing world info`)
return World{}, err
}
parsedMap, err := a3interface.ParseSQFHashMap(parsedInterface)
if err != nil {
thisLogger.Error().Err(err).Msgf(`Error when parsing world info`)
return World{}, err
}
thisLogger.Trace().Msgf(`parsedMap: %+v`, parsedMap)
// create world object from map[string]interface{}
var wi = World{}
worldBytes, err := json.Marshal(parsedMap)
if err != nil {
thisLogger.Error().Err(err).Msgf(`Error when marshalling world info`)
return World{}, err
}
err = json.Unmarshal(worldBytes, &wi)
if err != nil {
thisLogger.Error().Err(err).Msgf(`Error when unmarshalling world info`)
return World{}, err
}
thisLogger.Trace().Msgf(`World info: %+v`, wi)
var dbWorld World
db.Client().Where("world_name = ?", wi.WorldName).First(&dbWorld)
// if world exists, use it
if dbWorld.ID > 0 {
thisLogger.Debug().Msgf(`World %s exists with ID %d.`, wi.WorldName, dbWorld.ID)
return dbWorld, nil
}
// write world if not exist
var dbWorld World
db.Client().Where("world_name = ?", wi.WorldName).First(&dbWorld)
if dbWorld.ID == 0 {
db.Client().Create(&wi)
if db.Client().Error != nil {
logger.Log.Error().Err(db.Client().Error).Msgf(`Error when creating world`)
return
}
logger.Log.Info().Msgf(`World %s created.`, wi.WorldName)
} else {
// don't do anything if exists
logger.Log.Debug().Msgf(`World %s exists with ID %d.`, wi.WorldName, dbWorld.ID)
db.Client().Create(&wi)
if db.Client().Error != nil {
thisLogger.Error().Err(db.Client().Error).Msgf(`Error when creating world`)
return World{}, db.Client().Error
}
thisLogger.Info().Msgf(`World %s created.`, wi.WorldName)
return wi, nil
}
func writeMission(missionJSON string) {
func writeMission(data string, thisLogger zerolog.Logger) (Mission, error) {
var err error
// writeLog(functionName, fmt.Sprintf(`["%s", "DEBUG"]`, Mission))
// Mission is json, parse it
var mi Mission
fixedString := fixEscapeQuotes(trimQuotes(missionJSON))
err = json.Unmarshal([]byte(fixedString), &mi)
parsedInterface, err := a3interface.ParseSQF(data)
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error when unmarshalling mission`)
return
thisLogger.Error().Err(err).Msgf(`Error when parsing mission info`)
return Mission{}, err
}
// get world from WorldName
var dbWorld World
db.Client().Where("world_name = ?", mi.WorldName).First(&dbWorld)
if dbWorld.ID == 0 {
logger.Log.Error().Msgf(`World %s not found.`, mi.WorldName)
return
parsedMap, err := a3interface.ParseSQFHashMap(parsedInterface)
if err != nil {
thisLogger.Error().Err(err).Msgf(`Error when parsing mission info`)
return Mission{}, err
}
mi.WorldID = dbWorld.ID
thisLogger.Trace().Msgf(`parsedMap: %+v`, parsedMap)
var mi Mission
// create mission object from map[string]interface{}
missionBytes, err := json.Marshal(parsedMap)
if err != nil {
thisLogger.Error().Err(err).Msgf(`Error when marshalling mission info`)
return Mission{}, err
}
err = json.Unmarshal(missionBytes, &mi)
if err != nil {
thisLogger.Error().Err(err).Msgf(`Error when unmarshalling mission info`)
return Mission{}, err
}
if loadedWorld == nil {
thisLogger.Error().Msgf(`Current world ID not set, cannot create mission`)
return Mission{}, err
}
if loadedWorld.ID == 0 {
thisLogger.Error().Msgf(`Current world ID is 0, cannot create mission`)
return Mission{}, err
}
mi.WorldID = loadedWorld.ID
// write mission to database
db.Client().Create(&mi)
if db.Client().Error != nil {
logger.Log.Error().Err(db.Client().Error).Msgf(`Error when creating mission`)
return
thisLogger.Error().Err(db.Client().Error).Msgf(`Error when creating mission`)
return Mission{}, db.Client().Error
}
logger.Log.Info().Msgf(`Mission %s created with ID %d`, mi.MissionName, mi.ID)
currentMissionID = mi.ID
thisLogger.Info().Msgf(`Mission %s created with ID %d`, mi.MissionName, mi.ID)
a3interface.WriteArmaCallback(
EXTENSION_NAME,
":LOG:MISSION:SUCCESS:",
"World and mission logged successfully.",
)
return mi, nil
}
func writeAttendance(data string) {
func writeAttendance(data string, thisLogger zerolog.Logger) {
var err error
// data is json, parse it
stringjson := unescapeArmaQuotes(data)
var event Session
err = json.Unmarshal([]byte(stringjson), &event)
parsedInterface, err := a3interface.ParseSQF(data)
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error when unmarshalling attendance`)
thisLogger.Error().Err(err).Str("data", data).Msgf(`Error when parsing attendance info`)
return
}
parsedMap, err := a3interface.ParseSQFHashMap(parsedInterface)
if err != nil {
thisLogger.Error().Err(err).Str("data", data).Msgf(`Error when parsing attendance info`)
return
}
thisLogger.Trace().Msgf(`parsedMap: %+v`, parsedMap)
var thisSession Session
// create session object from map[string]interface{}
sessionBytes, err := json.Marshal(parsedMap)
if err != nil {
thisLogger.Error().Err(err).Str("data", data).Msgf(`Error when marshalling attendance info`)
return
}
err = json.Unmarshal(sessionBytes, &thisSession)
if err != nil {
thisLogger.Error().Err(err).Str("data", data).Msgf(`Error when unmarshalling attendance info`)
return
}
thisLogger2 := thisLogger.With().
Str("playerId", thisSession.PlayerId).
Str("playerUID", thisSession.PlayerUID).
Str("profileName", thisSession.ProfileName).
Logger()
// search existing event
var dbEvent Session
db.Client().
Where(
"player_id = ? AND mission_hash = ?",
event.PlayerId,
event.MissionHash,
thisSession.PlayerId,
thisSession.MissionHash,
).
Order("join_time_utc desc").
First(&dbEvent)
if dbEvent.ID != 0 {
if dbEvent.ID > 0 {
// update disconnect time
dbEvent.DisconnectTimeUTC = sql.NullTime{
Time: time.Now(),
@@ -378,34 +438,32 @@ func writeAttendance(data string) {
}
err = db.Client().Save(&dbEvent).Error
if err != nil {
logger.Log.Error().Err(err).
thisLogger2.Error().Err(err).
Msgf(`Error when updating disconnect time for event %d`, dbEvent.ID)
return
}
logger.Log.Debug().Msgf(`Attendance updated for %s (%s)`,
dbEvent.ProfileName,
dbEvent.PlayerUID,
thisLogger2.Debug().Msgf(`Attendance updated with ID %d`,
dbEvent.ID,
)
} else {
// insert new row
event.JoinTimeUTC = sql.NullTime{
thisSession.JoinTimeUTC = sql.NullTime{
Time: time.Now(),
Valid: true,
}
if currentMissionID == 0 {
logger.Log.Error().Msgf(`Current mission ID not set, cannot create attendance event`)
if loadedMission == nil {
thisLogger2.Error().Msgf(`Current mission ID not set, cannot create attendance event`)
return
}
event.MissionID = currentMissionID
err = db.Client().Create(&event).Error
thisSession.MissionID = loadedMission.ID
err = db.Client().Create(&thisSession).Error
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error when creating attendance event`)
thisLogger2.Error().Err(err).Msgf(`Error when creating attendance event`)
return
}
logger.Log.Debug().Msgf(`Attendance created for %s (%s)`,
event.ProfileName,
event.PlayerUID,
thisLogger2.Info().Msgf(`Attendance created with ID %d`,
thisSession.ID,
)
}
}
@@ -416,20 +474,6 @@ func getTimestamp() string {
return time.Now().Format("2006-01-02 15:04:05")
}
func trimQuotes(s string) string {
// trim the start and end quotes from a string
return strings.Trim(s, `"`)
}
func fixEscapeQuotes(s string) string {
// fix the escape quotes in a string
return strings.Replace(s, `""`, `"`, -1)
}
func unescapeArmaQuotes(s string) string {
return fixEscapeQuotes(trimQuotes(s))
}
func main() {
// loadConfig()
// fmt.Println("Running DB connect/migrate to build schema...")

View File

@@ -30,9 +30,9 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

View File

@@ -213,8 +213,12 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -234,6 +238,8 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=

View File

@@ -52,9 +52,9 @@ func InitLoggers(o *LoggerOptionsType) {
ll = &lumberjack.Logger{
Filename: ActiveOptions.Path,
MaxSize: 5,
MaxBackups: 10,
MaxBackups: 8,
MaxAge: 14,
Compress: true,
Compress: false,
LocalTime: true,
}
@@ -66,7 +66,7 @@ func InitLoggers(o *LoggerOptionsType) {
armaLogFormatLevel := func(i interface{}) string {
return strings.ToUpper(
fmt.Sprintf(
"(%s)",
"%s:",
i,
))
}
@@ -117,13 +117,17 @@ func InitLoggers(o *LoggerOptionsType) {
NoColor: true,
FormatTimestamp: armaLogFormatTimestamp,
FormatLevel: armaLogFormatLevel,
FieldsExclude: []string{zerolog.CallerFieldName, "ctx"},
},
)).With().Timestamp().Logger()
)).With().Timestamp().Caller().Logger()
if ActiveOptions.Debug {
Log = Log.Level(zerolog.DebugLevel)
} else {
Log = Log.Level(zerolog.InfoLevel)
}
if ActiveOptions.Trace {
Log = Log.Level(zerolog.TraceLevel)
}
}

3
go.work Normal file
View File

@@ -0,0 +1,3 @@
go 1.20
use ./extension/AttendanceTracker

View File

@@ -0,0 +1,15 @@
version: '3.1'
services:
db:
image: mariadb
restart: always
environment:
- "MARIADB_ROOT_PASSWORD=example"
- "MARIADB_DATABASE=a3attendance"
volumes:
- ./db:/var/lib/mysql
ports:
- "3306:3306"