아크 서버 플러그인 커스텀 명령어 확장 - 인자 파싱·권한 체크·설정 파일 연동! (ArkServerAPI C++) 🦖
목차13개 항목
안녕하세요! Mojangcam입니다 😎
지난 편에서는 아크 서버 플러그인 개발 기초 - ArkServerAPI 구조 완전 해설 로 플러그인의 골격을 봤어요. DllMain → Load() / Unload() 로 갈라지고, Load()에서 명령어를 등록하고 Unload()에서 전부 되돌린다는 것까지요.
근데 /hello 처럼 인자 없이 고정 메시지만 뱉는 명령어는 실전에서 거의 안 써요. 실제 서버에서 필요한 건 이런 것들이죠.
/announce 잠시 후 서버 재시작합니다처럼 뒤에 붙은 문장을 통째로 받아야 하고- 아무나 못 쓰게 권한을 확인해야 하고
- 메시지 문구를 고칠 때마다 DLL을 다시 빌드하고 싶지는 않아요
오늘은 이 세 가지를 붙여볼게요. 인자 파싱 → 권한 체크 → config.json 연동까지 하고 나면, 그다음부터는 기능만 갈아끼우면 됩니다 🙌
※ 이 글은 ARK: Survival Evolved(서바이벌 이볼브드) + 윈도우 서버 기준입니다.
🧠 왜 설정 파일로 빼야 할까요?
플러그인을 처음 만들면 메시지도, 권한 이름도 코드 안에 그냥 박아두게 돼요. 그런데 이렇게 하면 오타 하나 고치는 데도 Visual Studio를 켜서 다시 빌드하고, 서버를 내렸다 올려야 합니다.
파이썬에서 토큰을 .env로 빼듯이, C++ 플러그인은 config.json으로 빼는 게 정석이에요. 문구·권한 노드·색상 같은 건 전부 밖으로 꺼내두면 운영이 훨씬 편해집니다. 실제로 ArkShop이나 AdminsPermissions 같은 유명 플러그인도 전부 이 구조예요.
📌 준비물
- 지난 편에서 만든 플러그인 프로젝트 (Visual Studio 2019+ / Release / x64 / 동적 라이브러리)
- ArkServerAPI 본체 — GitHub 저장소
- Permissions 플러그인 — 권한 체크에 필요해요. API 배포처(GameServersHub)에서
Permissions플러그인을 받아ArkApi\Plugins\Permissions\에 설치하고, 프로젝트에Permissions.lib와 헤더(ArkPermissions.h) 경로를 추가하세요. - nlohmann/json — JSON 파서. 단일 헤더
json.hpp하나만 프로젝트에 넣으면 끝이에요. - 언어 표준 C++17 이상
# 이번 글의 플러그인 폴더 구조
ArkApi\Plugins\MyCommands\
├─ MyCommands.dll ← 폴더명과 동일해야 해요!
├─ PluginInfo.json
└─ config.json ← 오늘 새로 추가하는 파일📌 1단계 - 명령어 인자 파싱하기
채팅 명령어 콜백의 두 번째 인자 FString* message 에는 플레이어가 친 메시지 전체가 들어옵니다. /pm 모장캠 안녕하세요 라고 쳤다면 그 문장 통째로요.
그래서 인자를 쓰려면 직접 잘라야 하는데, 방법이 두 가지예요.
// 방법 ① 공백으로 토막내기 — 인자 개수가 정해진 명령어에 좋아요
TArray<FString> parsed;
message->ParseIntoArray(parsed, L" ", true);
// parsed[0] = "/pm", parsed[1] = "모장캠", parsed[2] = "안녕하세요" ...
if (!parsed.IsValidIndex(1))
return; // 인자가 없으면 여기서 컷// 방법 ② 앞부분만 떼어내기 — 뒤 문장을 통째로 받는 명령어에 좋아요
FString content = *message;
if (!content.RemoveFromStart("/announce "))
return;
// content = "잠시 후 서버 재시작합니다"하나씩 뜯어볼게요 💡
- ParseIntoArray(배열, 구분자, InCullEmpty) : 세 번째 값을
true로 두면 빈 토막을 버려요. 공백을 두 번 친 경우까지 알아서 정리해줍니다. - IsValidIndex(n) : 그 인덱스가 실제로 있는지 확인해요. 이걸 빼먹고
parsed[1]에 바로 접근하면 서버가 그대로 크래시납니다. 아크 플러그인 크래시 원인 1위예요. - RemoveFromStart("...") : 접두사를 떼어내고, 성공했으면
true를 돌려줘요. 뒤에 공백까지 포함해서 넘기는 게 포인트입니다. - ⚠️
ParseIntoArray는 호출할 때마다 배열을 비우고 다시 채워요. 누적용으로는 못 씁니다.
💡
/pm처럼 닉네임 뒤에 긴 문장이 붙는 명령어는 두 방법을 섞어 쓰면 안전해요. 먼저ParseIntoArray로 토막을 내서 닉네임만 가져오고, 내용은 인덱스 2번부터를 다시 이어 붙이는 식이에요. 이러면 플레이어가 공백을 여러 번 쳐도 깔끔하게 처리돼요.
🧩 숫자 인자를 안전하게 받는 법
/addpoints 100 처럼 숫자를 받을 땐 반드시 먼저 숫자인지 확인하세요. 문자열을 그냥 변환하면 예외가 터지거나 이상한 값이 들어갑니다.
if (!parsed.IsValidIndex(1) || !parsed[1].IsNumeric())
{
ArkApi::GetApiUtils().SendChatMessage(pc, L"서버", L"숫자를 입력해주세요. 예) /addpoints 100");
return;
}
const int amount = FCString::Atoi(*parsed[1]);- IsNumeric() : 숫자로만 이뤄졌는지 검사해요.
- FCString::Atoi(*문자열) :
FString을int로 바꿔줍니다.*를 붙여const TCHAR*로 넘기는 걸 잊지 마세요.
📌 2단계 - 권한 체크 붙이기 (Permissions 연동)
/announce 를 아무나 쓸 수 있으면 곤란하죠. Permissions 플러그인을 의존성으로 걸면 한 줄로 끝납니다.
#include <ArkPermissions.h>
#pragma comment(lib, "Permissions.lib")
const uint64 steam_id = ArkApi::IApiUtils::GetSteamIdFromController(pc);
if (!Permissions::IsPlayerHasPermission(steam_id, "MyCommands.Announce"))
{
ArkApi::GetApiUtils().SendChatMessage(pc, L"서버", L"권한이 없어요.");
return;
}하나씩 뜯어볼게요 💡
- GetSteamIdFromController(pc) :
IApiUtils의 static 함수라 클래스명으로 바로 부를 수 있어요. 인자는AController*라서AShooterPlayerController*를 그냥 넘겨도 됩니다. - Permissions::IsPlayerHasPermission(steam_id, 권한이름) :
bool을 돌려줘요. 권한 이름은내플러그인.기능처럼 점(.)으로 구분하는 관례를 따르는 게 좋아요. - 그룹 조회는 Permissions::GetPlayerGroups(steam_id) 로
TArray<FString>을 받을 수 있어요. PluginInfo.json의 Dependencies에"Permissions"를 꼭 넣어야 로드 순서가 보장됩니다.
📌 3단계 - config.json으로 설정 빼기
이제 문구와 권한 이름을 밖으로 꺼낼 차례예요. 플러그인 폴더에 config.json을 이렇게 만듭니다.
{
"AnnouncePrefix": "[공지] ",
"PermissionNodes": {
"Announce": "MyCommands.Announce",
"PrivateMessage": "MyCommands.Pm"
},
"Messages": {
"Sender": "서버",
"NoPermissions": "이 명령어를 사용할 권한이 없어요.",
"AnnounceUsage": "사용법: /announce <내용>",
"PmUsage": "사용법: /pm <닉네임> <내용>",
"PlayerNotFound": "'{}' 님을 찾을 수 없어요.",
"PmSent": "[귓속말 → {}] {}",
"PmReceived": "[귓속말 ← {}] {}",
"InfoLine": "닉네임: {} / SteamID: {} / 그룹: {}"
}
}읽는 코드는 이게 전부예요.
#include <fstream>
#include "json.hpp"
nlohmann::json config;
std::string GetConfigPath()
{
return ArkApi::Tools::GetCurrentDir() + "/ArkApi/Plugins/MyCommands/config.json";
}
void ReadConfig()
{
std::ifstream file{ GetConfigPath() };
if (!file.is_open())
throw std::runtime_error("config.json 을 열 수 없습니다");
file >> config;
file.close();
}하나씩 뜯어볼게요 💡
- ArkApi::Tools::GetCurrentDir() : 서버의
Win64폴더 경로를std::string으로 돌려줘요. 절대경로를 코드에 박지 말고 반드시 이걸 쓰세요. - file >> config : nlohmann/json이 알아서 파싱해줍니다. 문법이 깨져 있으면 예외를 던져요.
- 한글이 깨지는 문제가 여기서 제일 많이 나요.
config.json은 UTF-8로 저장하고, 코드에서는 반드시ArkApi::Tools::Utf8Decode()로 변환해서FString을 만들어야 합니다.
// JSON(UTF-8 std::string) → FString 변환 헬퍼
FString GetText(const std::string& key)
{
return FString(ArkApi::Tools::Utf8Decode(config["Messages"].value(key, "No message")).c_str());
}- .value(key, 기본값) : 키가 없어도 예외 대신 기본값을 돌려줘요. 설정 파일을 다뤄야 하는 플러그인에선 필수 습관입니다.
- 메시지 안의
{}는 자리표시자예요.SendChatMessage는 내부적으로 fmt 스타일 포맷을 쓰기 때문에%s가 아니라{}를 씁니다. 이거 헷갈리기 쉬워요!
📌 4단계 - 설정 리로드용 콘솔 명령어
문구만 고쳤는데 서버를 통째로 내리는 건 아깝잖아요. 콘솔 명령어로 config만 다시 읽게 만들어두면 운영이 아주 편해집니다.
void ReloadConsoleCommand(APlayerController* player_controller, FString* /*message*/, bool /*write_to_log*/)
{
auto* shooter_player = static_cast<AShooterPlayerController*>(player_controller);
try
{
ReadConfig();
Log::GetLog()->info("config.json 리로드 완료");
if (shooter_player != nullptr)
ArkApi::GetApiUtils().SendServerMessage(shooter_player, FColorList::Green, L"config.json 을 다시 읽었어요.");
}
catch (const std::exception& error)
{
Log::GetLog()->error("config.json 리로드 실패: {}", error.what());
}
}💡 nullptr 체크를 꼭 넣으세요. 서버 콘솔에서 직접 친 경우
player_controller가 비어 있을 수 있어서, 그냥 넘기면 서버가 튑니다.
📌 전체 코드 (복붙 OK)
// ArkServerAPI · ARK: Survival Evolved (Windows) 전용
// Visual Studio 2019+ / 구성: Release / 플랫폼: x64 / 구성 종류: 동적 라이브러리(.dll) / C++17 이상
// 의존 플러그인: Permissions (PluginInfo.json 의 Dependencies 에 "Permissions" 추가 필수)
// 외부 헤더: nlohmann/json 의 json.hpp 를 프로젝트에 포함
#include <fstream>
#include <API/ARK/Ark.h>
#include <API/UE/Math/ColorList.h>
#include <ArkPermissions.h>
#include "json.hpp"
#pragma comment(lib, "ArkApi.lib")
#pragma comment(lib, "Permissions.lib")
nlohmann::json config;
// ==================== 설정 파일 ====================
std::string GetConfigPath()
{
return ArkApi::Tools::GetCurrentDir() + "/ArkApi/Plugins/MyCommands/config.json";
}
void ReadConfig()
{
const std::string config_path = GetConfigPath();
std::ifstream file{ config_path };
if (!file.is_open())
throw std::runtime_error("config.json 을 열 수 없습니다: " + config_path);
file >> config;
file.close();
}
// JSON(UTF-8) 문자열을 FString 으로 변환
FString GetText(const std::string& key)
{
return FString(ArkApi::Tools::Utf8Decode(config["Messages"].value(key, "No message")).c_str());
}
FString GetNode(const std::string& key)
{
return FString(ArkApi::Tools::Utf8Decode(config["PermissionNodes"].value(key, "")).c_str());
}
// 권한 확인 + 없으면 안내 메시지까지
bool CheckPermission(AShooterPlayerController* pc, const std::string& node_key)
{
const uint64 steam_id = ArkApi::IApiUtils::GetSteamIdFromController(pc);
if (Permissions::IsPlayerHasPermission(steam_id, GetNode(node_key)))
return true;
ArkApi::GetApiUtils().SendChatMessage(pc, *GetText("Sender"), *GetText("NoPermissions"));
return false;
}
// ==================== /announce <내용> ====================
void AnnounceCommand(AShooterPlayerController* pc, FString* message, EChatSendMode::Type /*mode*/)
{
if (ArkApi::IApiUtils::IsPlayerDead(pc))
return;
if (!CheckPermission(pc, "Announce"))
return;
// "/announce " 를 떼어내면 남는 게 곧 공지 내용
FString content = *message;
if (!content.RemoveFromStart("/announce ") || content.IsEmpty())
{
ArkApi::GetApiUtils().SendChatMessage(pc, *GetText("Sender"), *GetText("AnnounceUsage"));
return;
}
const FString prefix = FString(
ArkApi::Tools::Utf8Decode(config.value("AnnouncePrefix", "[Notice] ")).c_str());
ArkApi::GetApiUtils().SendServerMessageToAll(FColorList::Yellow, *(prefix + content));
Log::GetLog()->info("[announce] {} : {}", ArkApi::IApiUtils::GetSteamIdFromController(pc),
ArkApi::Tools::Utf8Encode(*content));
}
// ==================== /pm <닉네임> <내용> ====================
void PmCommand(AShooterPlayerController* pc, FString* message, EChatSendMode::Type /*mode*/)
{
if (ArkApi::IApiUtils::IsPlayerDead(pc))
return;
if (!CheckPermission(pc, "PrivateMessage"))
return;
TArray<FString> parsed;
message->ParseIntoArray(parsed, L" ", true);
// parsed[0] = "/pm", parsed[1] = 닉네임, parsed[2] 부터 = 내용
if (!parsed.IsValidIndex(2))
{
ArkApi::GetApiUtils().SendChatMessage(pc, *GetText("Sender"), *GetText("PmUsage"));
return;
}
const FString target_name = parsed[1];
// 인덱스 2번부터를 다시 공백으로 이어 붙여 내용을 만들어요
// (공백을 여러 번 친 경우에도 안전해요)
FString content;
for (int32 i = 2; parsed.IsValidIndex(i); ++i)
{
if (i > 2)
content += L" ";
content += parsed[i];
}
TArray<AShooterPlayerController*> targets =
ArkApi::GetApiUtils().FindPlayerFromCharacterName(target_name, ESearchCase::IgnoreCase, false);
if (!targets.IsValidIndex(0))
{
ArkApi::GetApiUtils().SendChatMessage(pc, *GetText("Sender"), *GetText("PlayerNotFound"), *target_name);
return;
}
AShooterPlayerController* target = targets[0];
const FString my_name = ArkApi::IApiUtils::GetCharacterName(pc);
const FString target_display = ArkApi::IApiUtils::GetCharacterName(target);
ArkApi::GetApiUtils().SendChatMessage(target, *GetText("Sender"), *GetText("PmReceived"),
*my_name, *content);
ArkApi::GetApiUtils().SendChatMessage(pc, *GetText("Sender"), *GetText("PmSent"),
*target_display, *content);
}
// ==================== /info ====================
void InfoCommand(AShooterPlayerController* pc, FString* /*message*/, EChatSendMode::Type /*mode*/)
{
if (ArkApi::IApiUtils::IsPlayerDead(pc))
return;
const uint64 steam_id = ArkApi::IApiUtils::GetSteamIdFromController(pc);
const FString name = ArkApi::IApiUtils::GetCharacterName(pc);
TArray<FString> groups = Permissions::GetPlayerGroups(steam_id);
const FString group_text = groups.IsValidIndex(0) ? groups[0] : FString(L"Default");
ArkApi::GetApiUtils().SendServerMessage(pc, FColorList::Green, *GetText("InfoLine"),
*name, steam_id, *group_text);
}
// ==================== 콘솔: ReloadMyCommands ====================
void ReloadConsoleCommand(APlayerController* player_controller, FString* /*message*/, bool /*write_to_log*/)
{
auto* shooter_player = static_cast<AShooterPlayerController*>(player_controller);
try
{
ReadConfig();
Log::GetLog()->info("config.json 리로드 완료");
if (shooter_player != nullptr)
ArkApi::GetApiUtils().SendServerMessage(shooter_player, FColorList::Green,
L"config.json 을 다시 읽었어요.");
}
catch (const std::exception& error)
{
Log::GetLog()->error("config.json 리로드 실패: {}", error.what());
}
}
// ==================== 진입점 ====================
void Load()
{
Log::Get().Init("MyCommands");
try
{
ReadConfig();
}
catch (const std::exception& error)
{
Log::GetLog()->error(error.what());
throw; // 설정이 깨졌으면 로드를 중단
}
ArkApi::GetCommands().AddChatCommand("/announce", &AnnounceCommand);
ArkApi::GetCommands().AddChatCommand("/pm", &PmCommand);
ArkApi::GetCommands().AddChatCommand("/info", &InfoCommand);
ArkApi::GetCommands().AddConsoleCommand("ReloadMyCommands", &ReloadConsoleCommand);
Log::GetLog()->info("MyCommands 로드 완료");
}
void Unload()
{
ArkApi::GetCommands().RemoveChatCommand("/announce");
ArkApi::GetCommands().RemoveChatCommand("/pm");
ArkApi::GetCommands().RemoveChatCommand("/info");
ArkApi::GetCommands().RemoveConsoleCommand("ReloadMyCommands");
}
BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
Load();
break;
case DLL_PROCESS_DETACH:
Unload();
break;
}
return TRUE;
}같이 넣을 PluginInfo.json 도 잊지 마세요. Dependencies가 이번 편의 핵심 변경점이에요.
{
"FullName": "My Commands",
"Description": "커스텀 명령어 확장 예제 (인자 파싱·권한·설정)",
"Version": 1.0,
"MinApiVersion": 3.5,
"Dependencies": ["Permissions"]
}💡
MinApiVersion은 예시값이에요. 본인 서버에 설치된 실제 ArkServerAPI 버전에 맞춰주세요. 이 값이 서버 API 버전보다 높으면 로드를 거부합니다.
📌 실행 결과
MyCommands.dll · PluginInfo.json · config.json 세 개를 ArkApi\Plugins\MyCommands\ 에 넣고 서버를 재시작해보세요. (플러그인은 핫 리로드가 안 됩니다.)
- 로그(
ArkApi.log)에MyCommands 로드 완료가 찍히면 성공이에요. - 권한이 없는 계정으로
/announce 테스트→ "이 명령어를 사용할 권한이 없어요." 안내가 옵니다. - 권한을 준 계정으로
/announce 잠시 후 재시작합니다→ 접속자 전원 화면에[공지] 잠시 후 재시작합니다가 노란색으로 떠요. /pm 모장캠 안녕하세요→ 상대에게는[귓속말 ← 내닉네임] 안녕하세요, 나에게는[귓속말 → 모장캠] 안녕하세요가 보입니다./info→ 내 닉네임·SteamID·권한 그룹이 초록색 서버 메시지로 표시돼요.config.json의 문구를 고치고 콘솔에ReloadMyCommands→ 서버 재시작 없이 바로 반영됩니다.
📌 자주 나는 에러
1. 명령어를 치자마자 서버가 튀어요
- 원인:
parsed[1]처럼 인덱스 검사 없이 배열에 접근한 경우예요. 압도적 1위입니다. - 해결: 배열 접근 전에 무조건
parsed.IsValidIndex(n)으로 막으세요. 플레이어는 언제든/pm만 덜렁 칠 수 있어요.
2. 플러그인이 로드되다가 조용히 죽어요 (config 관련)
- 원인 ①:
config.json경로가 틀렸어요. 폴더명·파일명 오타를 확인하세요. - 원인 ②: JSON 문법이 깨졌어요. 마지막 항목 뒤의 콤마(
,)가 제일 흔한 범인이에요. - 원인 ③:
Load()안에서 예외를 잡지 않은 경우예요. 위 코드처럼try/catch로 감싸고 로그를 남기면 원인이 바로 보입니다.
3. LNK2019 - Permissions 관련 외부 기호를 찾을 수 없음
- 원인:
Permissions.lib를 링커가 못 찾는 경우예요. - 해결:
#pragma comment(lib, "Permissions.lib")가 있는지, 추가 라이브러리 디렉터리에 Permissions 플러그인의 lib 폴더를 넣었는지 확인하세요.
4. 한글 메시지가 ??? 로 깨져요
- 원인:
config.json을 ANSI로 저장했거나,Utf8Decode를 거치지 않고 넘긴 경우예요. - 해결: 파일을 UTF-8로 저장하고(메모장 → 다른 이름으로 저장 → 인코딩 UTF-8), 코드에서는
ArkApi::Tools::Utf8Decode()를 반드시 통과시키세요.
5. 메시지에 {} 자리가 그대로 나와요
- 원인:
%s같은 C 스타일 포맷을 쓴 경우예요. - 해결:
SendChatMessage·SendServerMessage는 fmt 스타일이라 자리표시자가{}입니다. 전달하는 인자 개수와{}개수도 맞춰주세요.
6. Permissions 플러그인이 없다고 나와요
- 원인:
PluginInfo.json의Dependencies에"Permissions"를 안 넣었거나, Permissions 플러그인 자체가 설치돼 있지 않은 경우예요. - 해결: 두 가지를 모두 확인하세요. 의존성 플러그인이 먼저 로드돼야 합니다.
💬 마무리
오늘 붙인 건 딱 세 가지예요.
- 인자 파싱 — 토막내기는
ParseIntoArray+IsValidIndex, 뒤 문장 통째로는RemoveFromStart - 권한 체크 —
Permissions::IsPlayerHasPermission(steam_id, "내플러그인.기능")한 줄 + Dependencies 등록 - 설정 분리 —
config.json+ nlohmann/json, 한글은Utf8Decode, 리로드 명령어까지
이 세 개가 붙으면 이제 "내 서버 전용 기능"을 만들 준비가 끝난 거예요. 다음 아크 편에서는 이벤트 후킹으로 넘어가서, 플레이어 접속·사망 같은 순간을 가로채 자동으로 동작하는 플러그인을 만들어볼게요. 기대해주세요 🙌
📚 아크 서버 플러그인 시리즈
- 아크 서버 플러그인 개발 기초 - ArkServerAPI 구조 완전 해설 (Load·Unload·명령어 등록)
- 커스텀 명령어 확장 - 인자 파싱·권한 체크·설정 파일 연동 ← 지금 이 글!
- 이벤트 후킹으로 자동 동작 만들기 (예정)
