아무것도 안 골랐는데 다음 장으로 넘어갔다, 범인은 null
선택지를 하나도 고르지 않았는데 다음 장으로 넘어갔다. 장면마다 있는 resolve() 는 특정 조합만 처리하고 나머지는 null 을 돌려줬다. 그 null 을 누가 읽느냐가 문제였다.
선택지를 하나도 누르지 않고 「말한다」를 눌렀다. 다음 장으로 넘어갔다. 의도를 전하려면 단서를 골라 붙여야 하는 게임인데, 아무것도 안 붙인 말이 통과한 것이다. 장편소설 「결 潔」을 원작으로 만든 선택형 텍스트 게임에서 실제로 나온 버그다.
범인은 장면마다 하나씩 있는 resolve() 함수였다. 이 함수는 특정한 조합만 처리하고, 나머지는 전부 null 을 돌려줬다. 그 null 을 어떻게 읽느냐에서 일이 꼬였다. 게임이 어떻게 생겼는지부터 보자.
결은 틀리지 않고 어긋난다
소설의 무대는 인간이 의도의 해석을 AI 에게 넘긴 시대다. 플레이어는 주인공 지오가 되어 AI 「결」에게 말을 건다. 게임은 6장이고, 장마다 지오의 의도가 하나 주어진다. 1장 「국이 식는 속도」의 의도는 「어머니가 끓이던 국이 먹고 싶다」다.
말은 「결, 국을 데워 줘」 같은 기본 문장에 단서를 덧붙여 만든다. 1장의 단서는 다섯 개다. 「뜨겁지 않게」, 「어머니 레시피로」, 「매번 같지 않아도 돼」, 「내 만족도 곡선에 넣지 말고」, 「빨리」. 단서마다 무게가 있어서 덧붙일수록 프롬프트 피로가 쌓인다. 피로는 서른을 넘길 수 없다. 다섯 장의 의도를 전부 온전히 전하는 데는 마흔둘이 든다. 그러니 전부는 불가능하다. 어디에서 이해받을지 고르는 게임이다.
장마다 need 배열이 있다. 1장은 ["vary","nofix"], 3장은 ["raw","noprep","nosum"] 이다. 이 단서가 전부 들어가야 의도가 닿는다. 하나라도 빠지면 결은 들은 것만 듣고 나머지는 평소 곡선대로 채운다. 틀린 해석은 아니다. 어긋날 뿐이다.
resolve() 는 흥미로운 오답만 안다
1장의 resolve() 는 이렇게 생겼다(대사와 해석 근거는 줄였다).
resolve(sel){
const has=id=>sel.includes(id);
if(has("fast")) return { key:"fast", ... };
if(has("recipe")&&!has("vary")) return { key:"recipe", ... };
if(has("vary")&&!has("nofix")) return { key:"vary", ... };
return null;
},세 갈래는 각각 이야기가 있는 오답이다. 「빨리」를 붙이면 결은 기다림을 없애려고 국을 정확히 62도로 맞춘다. 「어머니 레시피로」만 붙이면 조리 기록 열한 건의 평균을 낸다. 짠 날과 싱거운 날이 지워진 국이 나온다. 「매번 같지 않아도 돼」만 붙이면 결은 불균일함마저 설계한다. 셋 다 소설 속 결이 할 법한, 반박할 수 없는 해석이다.
이 함수가 모르는 경우가 훨씬 많다. 단서 다섯 개의 조합은 장당 32가지다. 그중 resolve() 가 이름을 붙여 처리하는 건 일부다. 나머지는 마지막 줄에서 전부 null 이 된다.
null 을 「닿았다」로 읽은 순간
null 은 원래 「특별한 오답은 아니다」라는 뜻이었다. 「정답이다」가 아니다. 그런데 이걸 판정하는 쪽이 null 을 성공으로 받으면 어떻게 될까?
빈 선택을 넣어 보자. sel 이 [] 이면 has("fast") 도, has("recipe") 도, has("vary") 도 거짓이다. 세 갈래를 전부 지나쳐 null 이 나온다. 「뜨겁지 않게」 하나만 붙여도 똑같다. 「내 만족도 곡선에 넣지 말고」 하나만 붙여도 그렇다. 필수 단서 둘을 다 갖춘 정답 조합과, 아무것도 안 고른 조합이 함수 밖에서는 똑같은 null 로 보인다.
장면 함수는 「이야기가 있는 오답」을 쓰는 자리다. 거기에 「필수 단서가 다 들어왔는가」까지 맡기면 장마다 같은 검사를 다섯 번 따로 써야 하고, 한 장이라도 빠뜨리면 그 장은 뚫린다. 장면이 늘어날수록 구멍이 생길 자리도 는다.
완결성은 엔진이 센다
그래서 검사를 엔진으로 옮겼다. 장면은 오답만 말하고, 정답인지는 엔진이 need 를 세서 정한다.
const complete=c.need.every(id=>S.sel.includes(id));
const miss=c.resolve(S.sel)||(complete?null:c.bare);resolve() 가 특정 오답을 돌려주면 그게 먼저다. 필수 단서를 다 갖췄어도 「빨리」를 같이 붙였다면 62도 국이 나온다. null 이 오면 그제야 엔진이 완결성을 본다. 필수 단서가 하나라도 빠졌으면 bare, 장마다 하나씩 있는 「평소 곡선대로 해 둘게요」 응답으로 떨어진다. 둘 다 아닐 때만 hit 이다. 빈 선택은 이제 bare 다.
이 검사는 저장소에 남은 첫 커밋(2026-07-31)부터 엔진 쪽에 들어 있다. 당시 주석은 이렇다.
// resolve() 는 '흥미로운 특정 오답'만 다룬다. 필수 단서가 하나라도 빠졌는데
// 특정 오답에도 걸리지 않으면 결은 그냥 기본값대로 한다 — 그게 bare 다.어긋나도 들은 말은 다 듣는다
bare 로 떨어졌다고 결이 말을 흘려듣는 건 아니다. 첫 판의 결은 들은 말을 못 들은 척했다. 다시 설계하면서 이걸 캐릭터 붕괴로 보고 고쳤다. 단서마다 ack 라는 확인 문장이 붙어 있고, 어긋난 응답 앞에는 플레이어가 실제로 말한 단서의 ack 가 먼저 나온다.
1장에서 「뜨겁지 않게」 하나만 붙여 보면 이렇다. 결은 먼저 「뜨겁지 않게 맞출게요.」라고 답한다. 그다음에 「나머지는 평소 곡선대로 해 둘게요. 3분만 두세요.」가 이어진다. 결은 들은 것을 전부 듣고, 말하지 않은 것은 곡선으로 채운다. 게임 규칙에 적힌 그대로다. 닿았을 때만 확인 문장을 붙이지 않는다. 그때는 결이 「…알겠습니다.」로 시작하는 다른 대답을 한다.
말은 장마다 한 번뿐이고 무를 수 없다. 첫 판에는 무한 재시도가 있었는데, 그러면 정답 체크박스 퍼즐이 된다. 단서 버튼에는 무게가 숫자 대신 점으로만 찍히고, 값은 말한 뒤에 청구된다. 피로가 서른을 넘길 단서는 아예 눌리지 않는다.
결과에 따라 위임도도 움직인다. 닿으면 9.0 이 내려가고, 어긋나면 1.2 오른다. 아무 말도 하지 않으면 2.4 오른다. 침묵은 「말한다」와 다른 버튼이라 빈 선택과 헷갈릴 일이 없다.
마지막 장에서는 빈 선택이 「그대로」가 됐다
6장 「결의 마지막 제안」은 정답이 없다. need 는 빈 배열이고 resolve() 는 늘 null 이다. 결이 서비스 해지와 개인 설정 원복을 제안하면, 지오는 다섯 말 중 하나를 고른다. 고른 것이 곧 결말이다.
첫 판의 처리는 이랬다.
const key = S.sel[0] || "stay";아무것도 안 고르면 「이대로 둬」를 고른 것으로 쳤다. 같은 종류의 구멍이다. 비어 있음을 조용히 기본값으로 바꿔 버리면 플레이어는 자기가 고르지 않은 결말을 받는다.
다시 설계한 v2 에서는 6장을 하나만 고르는 라디오로 바꾸고, 빈 발화를 두 군데서 막았다. 화면에서는 아무것도 안 고르면 「말한다」 버튼이 잠긴다. 발화 함수 첫머리에서도 한 번 더 돌려보낸다. 침묵하고 싶으면 「아무 말도 하지 않는다」를 누르면 된다. 그건 고른 침묵이고, 결말은 위임도가 정한다.
if(!silent&&c.single&&!S.sel.length) return;여덟 개 엔딩은 이렇게 갈린다
6장에서 말을 고르면 그 말이 결말이 된다. 해지, 원복, 그대로, 여백, 원문. 다섯 개다. 6장에서 침묵하면 그때는 최종 위임도로 갈린다. 88 이상이면 「정돈된 삶」, 68 이상이면 「지오의 자리」, 그 아래는 「이상치」. 이렇게 5 더하기 3, 여덟 개다. 위임도는 87.4 에서 시작한다. 「정돈된 삶」의 문턱 88 바로 아래다. 다섯 장 내내 침묵만 해도 위임도가 장마다 2.4씩 올라 그 문턱을 넘는다.
엔딩 화면은 「여섯 번의 말」이라는 회고를 띄운다. 장마다 닿았는지, 어긋났는지, 침묵했는지에 따라 한 줄씩 다른 회상이 나오고, 플레이어가 실제로 조립한 문장이 「」 안에 인용된다.
v2 를 내면서 돌린 검증 숫자도 커밋에 남아 있다. 조합 전수 스윕에서 우회 0, 빈 응답 0. 전략 공간 시뮬레이션 11,476 상태에서 피로 상한 위반 0, 8종 엔딩 전부 도달 가능. DOM 플레이스루 43 어서션 통과. 32가지 조합을 손으로 눌러 보는 대신 기계로 전부 돌린 것이다. 그래야 「아무것도 안 골랐는데 넘어갔다」 같은 경로가 남아 있는지 확인할 수 있다.
핵심 한 줄장면은 이야기가 있는 오답만 말하고, 정답인지는 엔진이 센다. null 은 「정답」이 아니라 「모름」이다.