슬롯 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의 함수를 호출하여 사용예외 처리
섹션 제목: “예외 처리”새 로직 때문에 예외가 날 수 있는 구간은 두 곳이다.
- 게임 로더가 콘텐츠를 로드할 때(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());}- 머신 내부에서 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 중 룸 서버가 내려가는 일이 있었고, 원인별로 막았다.
1. 베팅 구간 설정 실수
섹션 제목: “1. 베팅 구간 설정 실수”기본 머신과 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)2. subId 설정 실수
섹션 제목: “2. subId 설정 실수”잘못된 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;}3. nullptr 접근
섹션 제목: “3. nullptr 접근”포인터가 nullptr일 수 있는 코드에 nullptr 검사를 추가했다.
4. 필수 설정 누락
섹션 제목: “4. 필수 설정 누락”반드시 있어야 할 설정이 없거나 정의되지 않은 구간도 있었다. 이후 원칙을 정했다.
- 룸 기반 클래스에서는
_FORCE_EXIT_ASSERT로 방을 터뜨리지 않는다. - 슬롯 머신 쪽에서는
ProcessSpinReq에서 클라이언트에 에러 코드를 넣어 돌려준다.
로그와 API
섹션 제목: “로그와 API”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]},Buy Feature 타입
섹션 제목: “Buy Feature 타입”타입 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]},Buy Feature 전용 키
섹션 제목: “Buy Feature 전용 키”밸런스마다 subId(로그 Code1에 남는 값)와 feature_type(Buy Feature 로그 테이블에 적재할 문자열)을 둔다.
"balance1":{ "name" : "SlotName", "subId" : 141, // 부로그 Code1에 남는 값 "feature_type" : "bf_feature", // log_slot_buy_feature 테이블에 적재할 string 정보...}subId
섹션 제목: “subId”기존의 유저별 밸런스 개념인 mathId와 Buy Feature ID를 subId 하나로 통합했다.
subId : 0 -> default balancesubId : 1~9 -> math balancesubId : 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 10SlotSimulatorZ.exe <슬롯ID> ..\data\<슬롯ID>\<슬롯ID>.<슬롯이름>_BuyFeature_Normal.json 10 ..\data\config_statistics.json 20SlotSimulatorZ.exe <슬롯ID> ..\data\<슬롯ID>\<슬롯ID>.<슬롯이름>_BuyFeature_Super.json 10 ..\data\config_statistics.json 30밸런스 값은 운영 어드민의 슬롯 밸런싱 화면에서 관리한다.