8월 20일, 클로저스는 출시 전부터 말이 많던 메이드 코스튬, <하우스 키퍼>를 출시하였습니다. 클로저스를 모르는 분들을 위해 간략히 설명드리자면 코스튬은 자신의 캐릭터의 외관을 바꿔주고, 부가 옵션으로 캐릭터를 강하게 해주는 아이템입니다. 이번 코스튬은 메이드, 집사복 형태를 하고 있어서 엄청 큰 기대를 받고 있었죠. 하지만 이번 메이드에 특전으로 붙은 부가 옵션이 너무 강력해서 오히려 유저들의 반감을 샀습니다.1

메이드 코스튬은 A, B, C, D타입이 존재하는데요, 이 중 A, B타입은 현금으로 바로 구입이 가능하고 C 타입과 D타입은 입수하려면 일종의 도박성 컨텐츠, 통돌이에 손을 대야합니다. 통돌이는 1회당 900원으로, 8.01% 확률로 메이드 코스튬 중 1부위를 무작위로 얻을 수 있습니다.

자, 그럼 메이드 코스튬 풀세트를 갖춰입기 위해서는 평균적으로 통돌이를 몇번 해야할까요? 저는 수학적인 계산엔 약한 관계로 시뮬레이터를 만들어서 계산해보았습니다.


import collections
import random

Good = collections.namedtuple('Good', ['name', 'count'])
goods_s = [
    Good('하우스키퍼 건 블레이드 [D타입] (2성)', 1),
    Good('하우스키퍼 단정한 올림 머리 [D타입] (2성)', 1),
    Good('하우스키퍼 품격있는 자켓 [D타입] (2성)', 1),
    Good('하우스키퍼 정장 바지 [D타입] (2성)', 1),
    Good('하우스키퍼 정장 구두 [D타입] (2성)', 1),
    Good('하우스키퍼 단정한 장갑 [D타입] (2성)', 1),
    ...
]

일단 보상으로 나오는 아이템 정보를 namedtuple을 사용해서 저장했습니다. 다른 이유는 없고 보다 보기 좋게 하기 위해서입니다. 소스엔 다 적지 않지만 goods_s 외에도 goods_a등의 변수가 존재합니다.

complete_point = []

trying = 100000

set_item_count = 6

result_inventory = collections.defaultdict(int)

chance_s = .0801
chance_a = chance_s + .006
chance_b = chance_a + .3949

complete_point는 종료 시점, 즉 구입을 더 안해도 되는 시점의 구매 횟수를 저장할 배열입니다. trying은 총 몇 번 시도할지 저장하는 변수입니다. 루리웹에 자료를 올릴때는 적은 횟수로 했지만 이번엔 PyPy를 사용해서 좀 더 많은 횟수를 실험해보았습니다. set_item_count는 코스튬 한 세트가 몇개의 아이템으로 구성되는지입니다. 무기, 헤어, 상의, 하의, 장갑, 신발의 총 6개로 구성됩니다. result_inventory는 기본값이 있는 dict형으로, key값으로 아이템 이름을, value로는 획득한 아이템의 갯수를 저장합니다. chance_* 변수들은 각 도전 확률을 저장합니다. if문에 사용해야하므로 확률을 합산하는 점에 주목해주시면 됩니다.

실제 작업을 하는 for문은 내용이 긴 관계로 두번에 나눠서 설명하겠습니다.

for n in range(trying):
    inventory = collections.defaultdict(int)

    for t in range(1, 10000):
        rand = random.random()
        if rand <= chance_s:
            good = random.choice(goods_s)
        elif rand <= chance_a:
            good = random.choice(goods_a)
        elif rand <= chance_b:
            good = random.choice(goods_b)
        else:
            good = random.choice(goods_c)

inventory는 해당 시도때의 실제 인벤토리 상태를 나타냅니다. 내부에 있는 for문을 통해 구매를 시뮬레이션합니다. random.random() 함수는 [0., 1.)내의 실수를 반환합니다. 이 값을 이용해서 어느 아이템을 받게 될지를 정했습니다.

        inventory[good.name] += good.count

        keys = inventory.keys()

        for i in range(set_item_count):
            if goods_s[i].name not in keys and \
                goods_s[i+set_item_count].name not in keys:
                break
        else:
            complete_point.append(t)
            for name, count in inventory.items():
                result_inventory[name] += count
            break

inventory 변수에 실제 아이템을 저장했습니다. 그리고 inventory의 key값들, 즉 아이템 이름을 가져다가 검사를 하게 되었습니다. if문의 조건인 goods_s[i+set_item_count].name not in keys에 대해 설명을 하자면, 통돌이에서는 같은 아이템이 2성과 3성이라는 다른 등급으로 나올 수 있습니다. 2성이 나오건 3성이 나오건 상관하지 않게 하기 위해 설치한 장치입니다.

이 부분에서는 Python의 for - else 구문을 사용했는데요, 뒤에 따라오는 else단락은 앞에 있는 for문에서 break이 발생하지 않은 경우에 실행됩니다. for문 내에 있는 조건절은 내가 원하는 아이템이 인벤토리에 들어왔는지 여부를 봐서 없으면 break하므로, else가 실행되었다는 것은 모든 아이템이 갖춰졌다는 의미입니다. complete_point에 완료시점을 저장하고 result_inventory에 현재 인벤토리 내용을 합산합니다.

need_buying_count = int(sum(complete_point)/trying)

print(
    '{:,}번 시뮬레이션 해본 결과 평균 {:,}회 구매해야합니다.'.format(
        trying,
        need_buying_count,
    )
)
print('이를 위해서는 평균 {:,}원이 필요합니다.'.format(need_buying_count*900))
print('관측된 바, 최소 {:,}회, 최대 {:,}회의 구매가 필요했습니다.'.format(
    min(complete_point),
    max(complete_point)
))
print('다음은 시뮬레이션 결과 평균적으로 얻을 수 있는 아이템의 갯수입니다.')
for item, count in result_inventory.items():
    print('{}: {:,.3f}개'.format(item, count/trying))

나머지는 출력을 위한 코드입니다. 실제 실행 결과는 다음과 같은 형식으로 나옵니다.

100,000번 시뮬레이션 해본 결과 평균 734회 구매해야합니다.
이를 위해서는 평균 660,600원이 필요합니다.
관측된 바, 최소 35회, 최대 5,231회의 구매가 필요했습니다.
다음은 시뮬레이션 결과 평균적으로 얻을 수 있는 아이템의 갯수입니다.
말렉 크리스탈: 96.781개
하우스키퍼 단아한 앞치마 [D타입] (2성): 0.982개
하우스키퍼 구두 [D타입] (3성): 0.982개
하우스키퍼 단정한 장갑 [D타입] (2성): 1.964개
하우스키퍼 허리조임 앞치마 [D타입] (2성): 0.983개
고급 위상 섬유: 483.499개
하우스키퍼 품격있는 올빼머리 [D타입] (3성): 0.984개
하우스키퍼 정장 구두 [D타입] (2성): 1.960개
하우스키퍼 정장 구두 [D타입] (3성): 1.965개
하우스키퍼 손목 아대 [D타입] (3성): 0.983개
신속 듀얼회복 캡슐: 1,271.384개
하우스키퍼 건 블레이드 [D타입] (2성): 0.983개
대 성공의 기운: 2.203개
...

시뮬레이션 결과 평균 734회를 구매해야하고, 그에 따라 66만원 가량이 필요하다는 것을 알 수 있습니다. 시행 횟수를 더 늘리면 늘릴수록 더욱 정확도가 높아지겠지만 60만원도 넘는 돈이 든다는 점을 알 수 있는 것으로 충분한 결과라고 생각하고 중지했습니다.2


이번에 사용한 코드의 문법은 Python 3입니다. 따라서 PyPy3을 사용해서 실행했습니다. 그냥 일반 Python을 사용했을때는 4분 이상 소요되었지만 PyPy를 사용하였을때는 2분 30초대의 시간이 필요했습니다. 지금은 수치상 큰 차이는 없지만 시행 횟수가 더 커진다면 유의미한 격차가 벌어질 것으로 예상됩니다.

실제 확률 계산 및 평균치 추정이 수학적 접근으로 어려울 경우에는 이런 시뮬레이션 방법도 나쁘지 않다고 생각합니다. 다만 시행 횟수가 너무 적으면 잘못된 결과가 나올 확률이 높고, 시행 횟수가 너무 많으면 너무 오래걸리니 적당한 절충값을 찾는게 중요한 것 같습니다.


  1. 기존에도 3성 코스튬이라는 것이 존재했는데, 이번 메이드복을 3성으로 강화하여 착용한 경우에는 파티원 전원에게 굵직한 수치의 추가 혜택이 돌아가도록 되어있어 논란이 되었습니다.

  2. 직감적으로 통돌이가 비싸다는 것을 아는 분들이 상당수였지만, 저는 얼마나 터무니없는 접근법인지 정확하게 감이 오지 않아서 계산해보게 되었습니다.