콘텐츠로 이동

슬롯 Buy Feature 구현 구조

밸런스 데이터를 파싱해 balance group과 Buy Feature 설정을 만든다. 밸런스 그룹 안의 밸런스마다 머신 인스턴스를 만들고, BUY_FEATURE 노드가 있는 밸런스만 BuyFeature 객체를 갖는다.

if (root.isMember(SZ_BALANCE_GROUP))
{
Json::Value nodeBalanceGroup = root[SZ_BALANCE_GROUP];
for (int i = 0; i < (int)nodeBalanceGroup["balances"].size(); i++)
{
string balanceName = nodeBalanceGroup["balances"][i].asString();
Json::Value node = root[balanceName];
auto pMachine = SlotCore::CreateMachineInstance(machineId, node);
pMachine->balanceName = balanceName;
pMachine->weight = nodeBalanceGroup["weights"][i].asInt();
if (node.isMember(SZ_BUY_FEATURE))
{
LOG(DEV, "Buy Feature Machine, subID : %d", pMachine->GetSubId());
pMachine->buyFeature = new SlotCore::BuyFeature(pMachine->GetSubId(), node[SZ_BUY_FEATURE]);
}
pMachines.push_back(pMachine);
}
}

buyFeature 포인터는 Buy Feature 머신에만 new로 만들고, Machine 소멸자에서 해제한다.

SAFE_DELETE(buyFeature);

머신마다 weighted reel strip, prize 같은 정보를 따로 설정하고 기존 시뮬레이터와도 연동하려고, Buy Feature를 살 때 현재 머신을 Buy Feature 전용 머신으로 교체한다. 클라이언트가 보낸 subId로 머신을 찾는다.

// 클라에서 보내주는 msg.subId로 pMachine을 교체한다.
bool SlotLogic::ChangeMachine(int32_t subId)
{
this->pMachine = SlotMachineManager::GetInstance()->GetMachine(this->machineID, subId);
if (this->pMachine == nullptr)
{
LOG(ERR, __FUNCTION__" : Machine(%s) is NULL.", this->machineID.c_str());
throw std::runtime_error("pMachine is NULL");
}
else
{
#if _QA_TEST
LOG(DEV, "[BuyFeature] Change Machine ID : %d", this->pMachine->GetSubId());
#endif
}
return true;
}

Buy Feature는 대부분 피처로 진입하므로, 피처 cycle 중에는 플래그로 교체를 막고 cycle이 끝난 뒤 명시적으로 원래 밸런스로 되돌린다.

Buy Feature 스핀이면 릴을 멈춘 뒤 스핀 결과를 설정에 맞게 바꾼다.

void ReelBonusPaid::StopReels(ReelSpinResult* spinResult, ISpinParameter* param)
{
__super::StopReels(spinResult, param);
bool isBuyFeatureSpin = pMachine->isBuyFeatureSpin;
if (isBuyFeatureSpin) // 바이피쳐 스핀인 경우 임의로 스핀 결과를 교체한다.
{
bool result = true;
BuyFeature* buyFeature = pMachine->buyFeature;
if (buyFeature != nullptr)
result = buyFeature->ChangeSpinResult(spinResult, param);
pMachine->isBuyFeatureSpin = false;
if (result == false)
throw std::runtime_error("Invalid 'Buy a feature' config! buyFeatureType is invalid!"); // ProcessSpinReq에서 err 핸들링합니다.
}
}
/*
* Buyfeature Type 1 : 스핀 Result에 대해 특정 릴들에 1개의 특정 심볼을 보장 (릴 위치는 랜덤)
*/
/*
* Buyfeature Type 2: 스핀 Result에 대해 특정 심볼의 개수를 보장 (추가는 앞에서부터, 삭제는 뒤에서부터)
*/

난수는 슬롯 RNG 인터페이스를 그대로 쓴다. 따로 만든 난수 생성기도 mt19937이지만, 구현 차이로 결과가 달라지는 일을 피하려고 기존 인터페이스의 함수를 호출한다.

int randomWeight = pRNG->GetRandom(totalWeight); // ISlotRNG나 GPT로 만든 랜덤이나 둘다 mt19937이지만 혹시 몰라 ISlotRNG의 함수를 호출하여 사용

새 로직 때문에 예외가 날 수 있는 구간은 두 곳이다.

  1. 게임 로더가 콘텐츠를 로드할 때(ini로 머신 인스턴스 생성)
try
{
ServerConfig.Initialize(path);
(void)mode;
(void)service_util;
LOG(INF, "mgtw_on_load : path(%s), serviceMode(%d) [%s%s on %s] defines=[%s]",
path, mode, gGetRevision(), (gHasLocalModify() ? "+" : ""), gGetRevisionDate(), gGetDefines());
GameLoader::Load(service_util);
}
catch (...)
{
LOG(ERR, "mgtw_on_load : path(%s), serviceMode(%d) [%s%s on %s] defines=[%s]",
path, mode, gGetRevision(), (gHasLocalModify() ? "+" : ""), gGetRevisionDate(), gGetDefines());
}
  1. 머신 내부에서 Buy Feature 로직을 수행할 때
try
{
... 중략
}
catch (std::string msg)
{
LOG_ROOM(WRN, __FUNCTION__"(GSN: %I64d) %s", GSN, msg.c_str());
return RESULT_CODE::ERR_INVALID_PARAM;
}
catch (std::runtime_error err)
{
LOG_ROOM(WRN, __FUNCTION__"(GSN: %I64d) %s", GSN, err.what());
return RESULT_CODE::ERR_INVALID_PARAM;
}
catch (...)
{
LOG_ROOM(WRN, __FUNCTION__"(GSN: %I64d) ERR_ZSLOT_INTERNAL - Unknown error occurred.", GSN);
return RESULT_CODE::ERR_ZSLOT_INTERNAL;
}

QA 환경에서 룸 서버가 내려간 문제

섹션 제목: “QA 환경에서 룸 서버가 내려간 문제”

QA 중 룸 서버가 내려가는 일이 있었고, 원인별로 막았다.

기본 머신과 Buy Feature 머신의 베팅 구간이 달랐다. 유효하지 않은 총 베팅이 들어오면 _FORCE_EXIT_ASSERT가 걸린다.

bool bFoundLineBet = pMachine->IsValidTotalBet(msg.betTotal);
_FORCE_EXIT_ASSERT(bFoundLineBet, format("Invalid TotalBet, %I64d", msg.betTotal).c_str());
[11:16:37.434]<WRN> : [<roomId>]SlotMain::ProcessSpinReq(GSN: <gsn>) Invalid TotalBet, 15000
[11:16:37.436]<DEV> : [<roomId>]SlotMain::OnRcvSpinReq(gsn: <gsn>) Exit with error code, 4035.

QA 환경에는 안전 처리용 define이 빠져 있어 assert가 그대로 방을 터뜨렸다. 베팅 구간이 잘못되어도 적어도 방은 터지지 않게 _QA_TEST에서도 예외를 던지도록 했다.

#if defined(SAFE_ROOM) || defined(_QA_TEST)
#define _FORCE_EXIT_ASSERT(x, msg) \
do { if ( !(x) ) { \
LOG(ERR, "FORCE_EXIT() : %s\t\t%s(%d)", __FILE__, __FUNCTION__, __LINE__); \
LOG(ERR, msg); \
throw ServerException(); \
} } while (0)

잘못된 subId로 머신 교체에 실패하면 클라이언트에 에러 코드를 돌려주고 끝낸다.

if (Logic()->ChangeMachine(buyFeatureIdx) == false)
{
ans.errorCode = RESULT_CODE::ERR_INVALID_PARAM;
GetRoom()->SendTo(GSN, GroupID::SlotMachine, SlotMachine::Tag::SpinAns, ans);
LOG_ROOM(DEV, __FUNCTION__"(gsn: %I64d) Exit with error code Buyfeature, %d.", GSN, ans.errorCode);
return false;
}

포인터가 nullptr일 수 있는 코드에 nullptr 검사를 추가했다.

반드시 있어야 할 설정이 없거나 정의되지 않은 구간도 있었다. 이후 원칙을 정했다.

  • 룸 기반 클래스에서는 _FORCE_EXIT_ASSERT로 방을 터뜨리지 않는다.
  • 슬롯 머신 쪽에서는 ProcessSpinReq에서 클라이언트에 에러 코드를 넣어 돌려준다.

DB 쿼리를 추가하고 저장 프로시저를 바꿨다. Buy Feature 로그용 테이블 log_slot_buy_feature_free, log_slot_buy_feature_paid를 추가했다.

인게임 결과 API에는 Buy Feature 타입을 넣었다. 아웃게임 요소는 인게임 결과 호출로 통합되어 있다. bet_money는 총 베팅이 아니라 해당 스핀에서 실제로 소비한 금액이다. Buy Feature 구매가 있으면 구매 금액이 총 베팅보다 크므로 bet_money는 구매 금액이 된다.

// 아웃게임 요소는 WebCall_InGameResult 통합되었다.
// betMoney의 정의는 totalBet이 아닌 해당 스핀에서 소비된 총 금액(ex. buyfeatureBet이 있는 경우 buyfeatureBet > totalBet이므로 betMoney는 buyfeatureBet이다.)
Json::Value root;
root["bet_money"] = betMoney;
root["game_mode"] = (int32_t)GetGameInfoSrl();
root["store_type"] = gameUserInfo.storeType;
root["win_money"] = winMoney;
root["slot_srl"] = slotSrl;
root["buy_feature_type"] = pPlayer->m_InGameResultData.m_BuyFeatureType;
pPlayer->m_InGameResultData.Clear();
Json::FastWriter fastWriter;
LOG(DEV, __FUNCTION__" %s", fastWriter.write(root).c_str());
m_WebCallMan.WebAPICallReq(&gameUserInfo, WebCallType::ingame_result, root);

하나의 밸런스 파일 안에 여러 밸런스를 두고 weight에 따라 동적으로 고를 수 있게 했다.

"BALANCE_GROUP" :
{
"balances": ["balance1", "balance2", "balance3"],
"weights": [1, 2, 3]
},

타입 1은 프리스핀·슈퍼 프리스핀 구매, 타입 2는 특정 심볼을 모으는 캐시 링크류 피처 구매에 쓴다.

  • 타입 1: reels[i]가 1인 릴 중에서 weight로 뽑은 count만큼 랜덤 릴에 symbolId가 항상 나오도록 보장한다. 행 위치는 항상 랜덤이다.
  • 타입 2: 릴과 상관없이 weight로 뽑은 count만큼 symbolId가 나오도록 보장한다.
"BUY_FEATURE":
{
"type" : 1,
"symbolId" : 10,
"reels": [1, 1, 1, 1, 1],
"counts": [3, 4, 5],
"weights": [5, 3, 2]
},
"BUY_FEATURE":
{
"type" : 2,
"symbolId" : 11,
"counts": [6, 7, 8, 9, 10],
"weights": [1, 1, 1, 1, 1]
},

밸런스마다 subId(로그 Code1에 남는 값)와 feature_type(Buy Feature 로그 테이블에 적재할 문자열)을 둔다.

"balance1":
{
"name" : "SlotName",
"subId" : 141, // 부로그 Code1에 남는 값
"feature_type" : "bf_feature", // log_slot_buy_feature 테이블에 적재할 string 정보
...
}

기존의 유저별 밸런스 개념인 mathId와 Buy Feature ID를 subId 하나로 통합했다.

subId : 0 -> default balance
subId : 1~9 -> math balance
subId : 100~ -> buyfeature balance
ex) subId : 123
십의 자리 2 : 클라 <-> 서버 통신하는 구매한 buyfeature Id
일의 자리 3 : 해당 밸런스의 인덱스

시뮬레이터는 마지막 인자로 Buy Feature ID를 받아 구매 종류별로 RTP를 따로 돌린다.

터미널 창
SlotSimulatorZ.exe [슬롯이름] [밸런스경로] [스핀횟수] [통계파일] [바이피쳐 ID]
SlotSimulatorZ.exe <슬롯ID> ..\data\<슬롯ID>\<슬롯ID>.<슬롯이름>_BuyFeature_TriggerUp.json 10 ..\data\config_statistics.json 10
SlotSimulatorZ.exe <슬롯ID> ..\data\<슬롯ID>\<슬롯ID>.<슬롯이름>_BuyFeature_Normal.json 10 ..\data\config_statistics.json 20
SlotSimulatorZ.exe <슬롯ID> ..\data\<슬롯ID>\<슬롯ID>.<슬롯이름>_BuyFeature_Super.json 10 ..\data\config_statistics.json 30

밸런스 값은 운영 어드민의 슬롯 밸런싱 화면에서 관리한다.