기획 문서에서 내가 제일 좋아한 문장은 로그라인이 아니라 그 몇 줄 아래에 있었다.

싸구려 장비를 비싸게 팔면 → 용사가 죽을 확률↑, 유품 수익↑, 대신 명성↓

용사가 내 가게에서 장비를 사서 던전에 들어간다. 살아 돌아오면 단골이 되고, 죽으면 그 용사의 유품이 내 가게로 배달된다. 바가지를 씌울수록 용사는 더 죽고, 나는 그 유품으로 한 번 더 번다. 문서는 이걸 "도덕적으로 뻔뻔하지만 시스템적으로 정직한 트레이드오프"라고 불렀다. 읽자마자 이 게임은 된다고 생각했다.

이번 연재를 준비하면서 이 문장을 코드에서 찾아보기로 했다. 바가지가 용사를 죽이는 그 한 줄. 결론부터 말하면 찾지 못했다.

인트로 2/3 — 「당신은 용사가 아닙니다. 그 앞에서 잡화점을 하는 사장님입니다.」

화면에는 있었다

세계관이 없는 게임은 아니었다. 오히려 화면 곳곳에 있었다. 튜토리얼 앞에는 인트로 세 페이지가 붙어 있고, 컨셉 문서의 문장을 거의 그대로 옮겨 왔다. 손님 여섯 종에게는 12줄씩, 모두 72줄의 대사가 있다. 초보 용사는 "용돈 모아서 왔어요. 3주 걸렸어요."라고 하고, 베테랑은 "신참들한테 싸구려 팔지 마. 걔들 금방 죽어."라고 한다. 성기사는 "폭리는 일곱 가지 죄악 중 여덟 번째입니다."라고 말한다.

이 기획은 Claude 와 대화하며 만들었고, 문서 여섯 개와 규칙 파일 CLAUDE.md 로 정리해 빈 저장소와 함께 Claude Code 에 넘겼다. 그때 내가 붙인 말은 한 줄이었다.

클로드와 같이 상의한 내용과 방향도 첨부해 줄께.

"방향"은 문서에 다 있었다. 그러니 코드에도 있을 거라고 생각했다. 손님이 가게에 들어와 값을 보고, 장비를 사 들고 던전에서 죽기까지 순서대로 따라가 봤다.

인트로 3/3 — 「목표는 하나, 용사들의 지갑을 터는 것」

r̄ = 1.05 인 손님 한 명

첫 관문은 손님이다. 컨셉 문서는 가격의 재미를 "최적점이 손님 구성에 따라 계속 이동 → 플레이어가 계속 만지작거릴 이유"라고 설명했다. 초보 용사가 몰리면 싸게, 마법사가 몰려오면 비싸게. 1편에서 풀어 본 최적 배율 공식대로라면 초보 용사(r=1.4) 1.22배, 마법사(r=0.6) 2.54배로 두 배 넘게 차이가 난다.

그런데 가게에 손님이 몇 명 들어오든 시뮬레이션은 그들을 한 명씩 판정하지 않는다. 손님 구성을 담당하는 CustomerMix 의 핵심은 이 몇 줄이다.

/// 큰 dt 를 결정론적으로 처리하기 위해 개별 손님이 아니라
/// 가중 평균 전환율로 다룬다 (§5.3 dt 불변성의 전제).
public double AverageConversion(PricingModel pricing, double multiplier)
{
    double sum = 0.0;
    foreach (var e in _entries)
        sum += e.Weight * pricing.ConversionProbability(multiplier, e.Resistance);
    return sum / _totalWeight;
}

손님 종류마다 구매 확률을 구해 출현 비중으로 가중 평균을 낸다. 비중은 초보 0.45, 베테랑 0.25, 마법사 0.2, 첩자 0.1.

문제는 구매 확률 공식 1.25 − 0.45·m·r 이 저항 r 에 대해 직선이라는 데 있다. 직선의 가중 평균은 평균 지점의 직선 값과 같다.

Σ wᵢ·(1.25 − 0.45·m·rᵢ) / Σ wᵢ  =  1.25 − 0.45·m·r̄

r̄ = 0.45·1.4 + 0.25·1.0 + 0.2·0.6 + 0.1·0.5 = 1.05
m* = (2.75 + 0.45·1.05) / (1.98·1.05) ≈ 1.55

초보 용사도, 마법사도, 마왕군 첩자도 시뮬레이션 안에서는 저항 1.05 짜리 손님 한 명으로 녹아 있었다. 배율을 0.001 간격으로 훑어 봐도 기대 이익이 가장 큰 곳은 1.55였다. 비중은 한 판 동안 거의 움직이지 않는다. 명성 트리로 첩자 비중을 0.3까지 끌어올려도 최적점은 1.68이다. 문서가 약속한 "두 배 넘는 이동"은 구조적으로 일어날 수 없었다.

M1 이 끝났을 때 이해가 안 된다고 한 나에게 Claude Code 는 이렇게 설명했었다.

지금은 초보 용사가 70%라 최적이 ×1.2 근처, 나중에 마법사가 몰려오면 ×2.5

문서의 표를 그대로 옮긴 설명이다. 실제로 초보 70%, 베테랑 30%면 평균 저항 1.28, 최적점은 1.31 근처다. 마법사가 몰려오는 사건은 끝까지 구현되지 않았다. 그 자리에서 계산기 한 번만 두드렸어도 알았을 숫자였다.

가격표를 보지 않는 마법사

시뮬레이션이 평균 한 명이라면, 화면에서 걸어 다니는 손님들은 무엇일까. 유료 에셋을 입히면서 상점 앞에 손님들이 오가게 됐는데, 그들을 만드는 CustomerFlowPresenter 의 주석이 답을 숨기지 않는다. 판매는 집계 시뮬이 처리하고, 여기서는 유입률에 비례해 손님이 "보이게" 만든다.

어떤 캐릭터를 내보낼지는 같은 spawnWeight 로 가중 랜덤을 굴려 정한다. 같은 비중이 두 군데서 쓰인다. 시뮬레이션에서는 평균을 내는 데, 화면에서는 주사위를 굴리는 데. 그리고 화면 쪽은 가격 배율을 한 번도 읽지 않는다. 진열대 앞에 선 마법사는 내 가격표를 보지 않는다. 그 마법사가 물건을 샀는지도 정해져 있지 않다. 판매는 같은 순간, 다른 곳에서 숫자로 따로 계산된다.

대사도 마찬가지였다. 말풍선의 문장을 고르는 함수 RandomLine 이 받는 건 그룹 이름과 난수뿐이다. 배율 0.8에서도 3.0에서도 초보 용사는 같은 확률로 같은 말을 한다. 데이터 스키마 문서에는 "대사가 이 게임의 절반입니다"와 "같은 대사가 두 번 보이는 순간 유머는 죽습니다"가 나란히 적혀 있었다. 앞의 문장은 "손님 유형별 최소 12줄" 이라는 숫자와 함께 있어서 테스트로 고정됐고, 뒤의 문장은 숫자가 없어서 그냥 흘러갔다.

나중에 넣은 팁 15종 중 14번은 이렇다. "손님마다 가격 저항이 다릅니다 — 마법사는 후하고, 초보 용사는 짭니다." 게임의 핵심 재미가 화면에서 보이지 않으니 글자로 알려 줘야 했고, 정작 그 차이는 시뮬레이션 안에서 평균으로 지워져 있었다.

사망 확률 식에는 가격이 없다

이제 마지막 관문, 내가 제일 좋아한 문장이 살아 있어야 할 곳이다. 원정 판정은 ExpeditionModel 이 맡는다. 이 모델 하나에만 테스트가 12개 붙어 있다.

/// 사망 확률. P = clamp(deathBase - deathSlope·(equipTier/dungeonTier), min, max)
double p = _expedition.deathBase
           - _expedition.deathSlope * ((double)equipTier / dungeonTier);
return Clamp(p, _expedition.deathMin, _expedition.deathMax);

deathBase 0.60, deathSlope 0.50. 장비가 25개 팔릴 때마다 용사 한 명이 원정을 떠나고, 던전 티어는 장비 티어보다 2 높다. 티어 1 장비면 사망 확률 약 43%, 티어 3이면 30%.

식의 입력은 장비 티어뿐이다. 가격 배율은 어디에도 없다. 바가지를 씌워도 용사는 더 죽지 않는다. 오히려 비싸게 팔면 덜 팔리고, 덜 팔리면 원정이 줄고, 원정이 줄면 유품도 줄어든다. 문서가 말한 방향과 정반대다. 바가지와 용사의 죽음 사이에 선이 없으니 "도덕적으로 뻔뻔한" 선택이라는 것도 없었다. 플레이어가 보는 건 "티어 1 장비를 산 용사가 돌아오지 않았습니다" 같은 하단 로그 한 줄이었다.

여기까지 오고 나서 처음 든 생각은 "AI 가 이걸 빠뜨렸구나"였다.

범인은 문서였다

확인하려고 문서 여섯 개를 코드 옆에 펼쳤다. 어디서 빠졌는지 찾으려던 대조였는데, 결과는 반대였다. 빠진 게 없었다.

경제 문서 §3.6 의 사망 확률 공식은 clamp(0.60 - 0.50 * (equipTier / dungeonTier), 0.02, 0.90) 이다. 코드와 숫자 하나 다르지 않다. 그리고 같은 절의 설계 의도 칸에는 "싸구려를 비싸게 팔면 유품 수익"이 적혀 있다. 한 절 안에서 의도와 공식이 서로 다른 말을 하고 있었다. Claude Code 는 공식을 구현했다. 구현할 수 있는 건 공식뿐이었다.

CustomerMix 주석의 "§5.3 dt 불변성의 전제"도 아키텍처 문서의 절 번호다. "큰 dt 가 들어와도 결과가 같아야 합니다." 앱을 8시간 꺼 뒀다 켜도 한 번에 정산되려면 손님을 한 명씩 굴리는 대신 평균을 내야 한다. 아키텍처 문서가 가장 강하게 요구한 성질을 가장 성실하게 지킨 결과가 평균 손님 한 명이었다. 컨셉 문서는 손님마다 최적점이 다르다고 약속했고, 아키텍처 문서는 그 차이를 평균으로 지우라고 요구했다.

나중에 내가 "유품의 목적도 궁금해"라고 물었을 때 Claude Code 는 문서의 설계 의도에서 세 가지를 정리해 줬다. 세 번째가 바로 "싸구려를 비싸게 팔면 용사가 더 죽어서 유품이 늘어난다"였다. 공식에는 없는 의도가 설명이 되어 나에게 돌아왔고, 나는 그 설명에 납득했다. 그 대화 끝에 생긴 건 [모두 감정]·[모두 판매] 버튼이었다.

AI 는 문서를 잘못 읽지 않았다. 문서끼리 서로 다른 말을 했고, 그 사이를 이어 볼 사람은 문서를 넘긴 나였다. 넘기기 전에 딱 한 번 "이 식에서 바가지가 용사를 죽이나?"라고 물었으면 됐다. 공식은 한 줄이었고 변수는 세 개뿐이었다.

성기사 한 명

그래도 예외가 하나 있었다. 성기사다.

시뮬레이션은 90초마다 PaladinVisited 이벤트를 낸다. 진열대 중 하나라도 배율 1.0을 넘으면 평판 -3, 전부 정가면 +2. 화면은 그 이벤트를 받아 성기사를 걸어 들어오게 하고, 판정 결과에 맞는 말을 시킨다.

private void OnPaladinVisited(bool fair)
{
    var walker = Spawn(_paladinPrefab, null);
    string line = _localization.RandomLine("cust.cust_paladin", _random);
    if (!fair) line = _localization.Get("ui.paladin.angry");
    walker.GetComponentInChildren<SpeechBubbleView>(true)?.Show(line, 3f);
}

정의로운 성기사라는 세계관, 정가가 아니면 감점이라는 규칙, 걸어 들어와 화를 내는 캐릭터라는 화면. 셋이 한 번에 맞물린 곳은 「호구마트」 전체에서 이 함수 하나였다. 이 성기사만은 내 가격표를 본다. 내가 바가지를 씌우면 화를 낸다. 세계관이 숫자에 닿는다는 게 어떤 모양인지 이 몇 줄이 보여 준다.

이 모양을 마법사에게, 초보 용사에게, 던전으로 떠나는 용사에게 넓혔어야 했다. 그 판단은 문서에도 규칙 파일에도 없었고, 있어야 했던 곳은 내 머릿속이었다. 세계관은 내가 쓴 문장이 아니라 상의 끝에 받아 든 문장이었기 때문에, 그게 코드 어디에 있는지 물어볼 생각조차 하지 못했다.

다음 편은 같은 일이 수익화에서 어떻게 반복됐는지다. 재미를 확인하기도 전에 나는 "상점은 미리 만들 수 있지 않나?"라고 물었고, Claude Code 는 그것도 아주 잘 만들었다.