막힌 이유를 말하지 않는 버튼은 버그로 읽힌다
「도토만 레벨업된다」 「각성이 안 된다」 「템을 다 팔았다」. 테스터에겐 셋 다 버그였다. 코드로 보면 대부분 설계대로 움직였다. 차이는 화면에 붙은 문장 한 줄이었다.
「도토만 레벨업할 수 있다.」 「브루니 승급·각성을 못 한다.」 「처음에 잘못 눌러서 템을 다 팔고 시작했다.」 방치형 RPG 「브루니 키우기」의 첫 플레이 테스트(2026-10-14)에서 외부 테스터가 남긴 말이다. 테스터 쪽에서 보면 셋 다 버그다. 버튼을 눌렀는데 안 되거나, 누르지 말았어야 할 결과가 났으니까.
코드를 열어 보면 사정이 다르다. 대부분은 설계대로 움직인 결과였다. 레벨업 거절도, 각성 잠금도, 일괄 판매도 일부러 그렇게 만들었다. 그럼 버그가 아닌 걸까?
도토만 레벨업이 되는 이유
동료 레벨업은 core/growth_ops.gd 의 level_up() 이 맡는다. 첫 줄부터 브루니를 걸러 낸다.
static func level_up(cid: String, n: int) -> bool:
last_error = ""
if cid == "c001":
return _fail("브루니는 경험치로 저절로 레벨이 올라요.")c001 이 브루니다. 동료는 물감 방울과 골드를 써서 손으로 올리고, 브루니는 경험치로 저절로 오른다. 장비를 팔면 골드와 함께 bruni_exp 가 들어오고, 방치로도 쌓인다. 설계로는 깔끔하게 나뉜 두 갈래다.
초반 파티에 동료가 도토 하나뿐이면 손으로 올릴 수 있는 것도 도토뿐이다. 테스터의 「도토만 레벨업할 수 있다」는 관찰로는 정확했다. 틀린 건 해석이었다. 막혔다고 읽었는데, 실은 다른 길로 오르고 있었다.
거절은 하나 더 있다. 동료 레벨업 자체도 기능 해금(comp_level)에 걸려 있다. 아직 열리지 않았으면 Features.reason() 이 돌려준 문장이 그대로 실패 이유가 된다. 테스터가 「아직 스테이지 해금을 못 했다」를 같은 줄에 적은 걸 보면, 두 거절을 한 덩어리로 겪었을 수도 있다.
거절 문장은 코드에 있다. 파일 머리 주석이 규칙을 정해 둔다. 실패하면 false 를 돌려주고, last_error 에 화면에 그대로 띄울 해요체 문장을 남기고, 실패 때는 아무것도 바꾸지 않는다. 이유를 말할 통로는 처음부터 있었던 셈이다. 다만 이 문장은 level_up() 이 실패해야 생긴다. 원인 메모의 결론도 그 지점이다. 설계 의도는 맞지만, 화면에서 그 이유가 안 보이면 버그로 읽힌다. 그래서 브루니 경험치가 어디서 들어오는지를 성장 화면에 같이 보여 주기로 했다.
각성은 초반에 원래 못 한다
각성 쪽도 비슷하다. awaken() 은 별(★)부터 본다.
if int(c.star) < GrowthData.max_star():
return _fail("★%d가 되면 각성할 수 있어요." % GrowthData.max_star())max_star() 는 star_cap 표의 가장 큰 키다. economy.json 기준으로 5. 그러니 ★5 가 되기 전에는 각성 버튼이 열리지 않는다. 각성에 드는 깨움돌은 이벤트와 원정 상점에서만 나온다. 게임을 막 시작한 사람이 각성을 못 하는 건 정상이다.
이 거절 문장은 조건을 말한다. ★5 라는 조건. 하지만 그 조건을 언제 채울 수 있는지는 말하지 않는다. 메모가 짚은 문제도 정확히 여기였다. 버튼과 안내가 막힌 이유를 「언제 열리는지」로 말해 주지 않는다.
같은 코드베이스 안에 좋은 예가 있다. 시스템 명세서는 레벨 상한에 닿은 동료의 레벨업 버튼을 승급 전까지 「승급 필요」로 바꾸게 했다. 버튼이 회색으로 죽는 대신 다음에 할 일을 말한다. 승급이 끝까지 가면 나오는 문장도 그렇다. 「가장 높은 별이에요. 이제 각성할 수 있어요.」 막힌 자리에서 다음 문을 가리킨다.
대표 거절 문장에 「어디서」가 있는지 봤다
growth_ops.gd 의 대표 거절 문장을 재화별로 놓고 보면 차이가 드러난다.
| 모자란 것 | 거절 문장 | 어디서 얻는지 |
|---|---|---|
| 골드 | 「골드가 모자라요. 방치하거나 골드 동굴에서 모을 수 있어요.」 | 주 경로만 있음 |
| 물감 방울 | 「물감 방울이 모자라요. 물감 샘에서 많이 얻을 수 있어요.」 | 주 경로만 있음 |
| 깨움돌 | 「깨움돌이 모자라요. 이벤트와 원정 상점에서 얻어요.」 | 있음 |
| 기억 조각 | 「기억 조각이 모자라요. 모집에서 같은 동료가 나오면 조각이 돼요.」 | 있음 |
| 무지개 조각 | 「무지개 조각이 모자라요.」 | 없음 |
| 속성 정수 | 「(정수 이름)이 n개 모자라요.」 | 없음 |
| 각성 조건 | 「★5가 되면 각성할 수 있어요.」 | 조건만 |
무지개 조각 줄이 눈에 걸린다. 그리고 브루니 승급에 드는 게 바로 이것이다.
브루니 승급은 아직 추정이다
원인 메모는 승급을 「추정」으로 남겼다. 승급은 기능 해금(Features.is_open("star"))과 조각이 충분해야 열린다. 걱정은 이랬다. 조각이 중복 모집으로만 생기는 구조라면, 모집에서 나오지 않는 브루니는 영영 못 올린다.
코드를 보면 그 걱정은 반만 맞다. star_cost() 는 브루니(c001)일 때 기억 조각 대신 무지개 조각만 센다. 시스템 명세서에도 같은 말이 있다. 브루니는 기억 조각이 없어 무지개 조각만 쓴다. 여기에 ★ 단계 골드가 붙는다. 길이 막힌 건 아니다.
남는 질문은 무지개 조각을 초반에 얻을 수 있느냐다. 이건 아직 확인하지 않았다. 그리고 확인하기 전부터 하나는 보인다. 브루니가 승급에 실패하면 「무지개 조각이 모자라요.」 한 줄만 나온다. 어디서 얻는지는 없다. 테스터가 「승급을 못 한다」고 쓴 건 이 문장 앞에서 멈췄기 때문일 수도 있다.
일괄 판매는 잠금 말고는 전부 판다
세 피드백 중 제일 아팠을 건 판매다. 「잘못 눌러서 템을 다 팔고 시작했다 → 계속 죽었다.」
core/growth_gear.gd 의 판매는 잠근 장비만 건너뛴다.
## 등급 이하 일괄 판매 대상 uid(잠금 제외)
static func uids_at_or_below(grade_no: int) -> Array:
var out: Array = []
for g in Save.data.gear_bag:
if not bool(g.get("lock", false)) and DB.has("item", str(g.item)) and GrowthData.grade_no(str(DB.item(str(g.item)).grade)) <= grade_no:
out.append(int(g.uid))
return out가방(gear_bag)에서 그 등급 이하이고 잠기지 않은 건 모두 대상이다. 확인 단계는 이 함수에 없다. 끼고 있는 장비는 사정이 다르다. 파일 머리 주석대로 장착 장비는 가방 밖에 따로 적힌다. 일괄 판매가 건드리는 건 가방뿐이다.
판매가 일어나는 자리는 하나 더 있다. 항아리에서 나온 장비를 바로 끼우면 equip_new() 가 원래 끼던 장비를 판다. 화면 사양에 「장착하면 빠진 장비는 판매」라고 적어 둔 동작이다. 이것도 설계대로지만, 무엇이 팔렸는지 눈에 안 띄면 장비가 사라진 걸로 보이기 쉽다.
그러면 「다 팔았다」는 무엇이었을까? 메모는 이 항목도 추정으로 두었다. 시작 장비가 장착되지 않은 채 가방에만 들어가 있었다면, 일괄 판매 한 번에 전부 사라진다. 그 상태로 브루니가 앞줄에서 모든 공격을 받아 내면 튜토리얼에서도 죽는다. 이것도 아직 재현하지 않은 그림이다.
막는 방법은 메모에 세 개가 적혀 있다. 시작 장비를 자동으로 끼우고 잠그기. 일괄 판매에 확인 창 붙이기. 튜토리얼 동안 판매 잠그기. 판매 코드는 하나도 틀리지 않았다. 처음 하는 사람이 그 버튼의 무게를 모를 뿐이다.
화면에 「언제·어디서」를 붙인다
메모의 다음 할 일 3번은 한 줄이다. 브루니 레벨(자동), 승급(조각 출처), 각성(★5·깨움돌)의 「언제·어디서」를 성장 화면에 표기한다.
이번 피드백에서 규칙 자체를 바꿔야 한다고 확인된 건 적 표적 규칙 하나였다. 나머지는 확인된 것만 놓고 보면 설계대로 동작했다. 그런데 테스터 화면에서는 그 차이가 보이지 않는다. 의도한 잠금과 고장 난 버튼은 똑같이 「안 눌린다」로 보인다. 둘을 가르는 건 그 옆에 붙은 문장 한 줄이다.
핵심 한 줄막는 건 설계지만, 막힌 이유를 말하지 않으면 버그다.