dotgimon 제작기 21. 8자리 번호 코드 대전과 QR 제거
dotgimon의 폰 대전은 Nearby로 두 기기를 연결했다. 워치 앱에는 이 통신 경로가 없었다. 폰과 워치, 친구의 워치끼리도 대전과 합체를 하려면 화면으로 보여 주고 손으로 입력할 수 있는 방식이 필요했다.
서버에 번호를 등록하는 대신 몬스터의 전투 정보를 숫자 안에 담는 쪽을 골랐다. 코드만 서로 읽어 주면 각 기기가 같은 입력으로 결과를 계산하는 방식이었다.
시간대 복원부터 고쳤다
코드 대전을 만들기 전에 워치 엔진의 수면 테스트가 잘못된 기준 파일을 만들고 있었다. KST 기준 취침 시각인데 골든 JSON에는 sleeping: false가 들어갔다. 골든은 Node와 QuickJS의 실행 결과를 비교하는 기준 파일이다.
테스트가 임시로 TZ를 뉴욕으로 바꾼 뒤 복원하는 부분이 문제였다. 원래 환경 변수가 없었다면 저장해 둔 값은 undefined다. 이를 process.env.TZ에 대입하면 문자열 "undefined"가 들어간다.
Node의 process.env 문서도 이런 문자열 변환과 delete를 통한 제거를 설명한다. 복원 코드를 값의 유무에 따라 나눴다.
const tz = process.env.TZ
process.env.TZ = 'America/New_York'
try {
// 다른 시간대의 수면 시나리오 실행
} finally {
if (tz === undefined) delete process.env.TZ
else process.env.TZ = tz
}당시 환경에서는 잘못 복원한 뒤 실행한 테스트가 UTC 기준 결과를 만들었다. 원래 없던 값은 지우도록 고치고 KST 기준으로 골든 파일을 다시 만들었다.
사람이 입력할 길이부터 줄였다
설계 초안은 48비트를 십진수로 표현하는 긴 코드였다. 사람이 화면을 보며 손으로 옮기기에는 부담스러웠다. 전체 길이를 8자리로 정하고 마지막 한 자리는 오타 검사에 썼다.
데이터를 담을 수 있는 범위는 7자리, 천만 미만이었다. 전투에서 실제로 읽는 값을 따라가 보니 종, 근력, 훈련, MEGA 훈련, 걷기, 체형만 있으면 됐다. 배고픔이나 칭호, 전적은 결과 계산에 필요하지 않았다.
체중도 정확한 숫자 대신 QUICK·BALANCE·POWER 중 어떤 체형인지 저장했다. 각 체형을 대표하는 체중으로 복원해도 같은 분류를 유지하게 했다.
훈련은 8 단위, 걷기는 16 단위로 내림했다. 정밀도를 줄인 대신 숫자 안에 들어갈 수 있는 경우의 수를 줄였다. 필드마다 진법이 다른 혼합 진법으로 묶었다.
| 필드 | 경우의 수 | 코드에서 복원한 값 |
|---|---|---|
| 종 | 111 | 고정 종 목록의 위치 |
| 체형·근력 | 3 × 5 | 체형 대표 체중, 근력 0~4 |
| 훈련·MEGA·걷기 | 7 × 4 × 4 | 각각 8·8·16 단위 |
| 모드 | 2 | 대전 또는 합체 |
| 난수 칸 | 25 | 코드를 만들 때 선택한 값 |
전체 곱은 9,324,000으로 7자리에 들어갔다. 내림 때문에 실제 능력치와 복원한 값은 달라진다. 그래서 상대만 코드로 복원하지 않고, 내 몬스터도 내 코드에서 복원한 값으로 계산했다.
한쪽은 원래 능력치, 다른 쪽은 줄인 능력치를 쓰면 같은 대전에서 결과가 갈릴 수 있었다. 양쪽에 같은 손실을 적용하는 것이 이 방식의 조건이었다.
종 목록은 도감 순서를 사용했다. 순서가 바뀌면 같은 숫자가 다른 몬스터가 될 수 있어 버전 변경이 필요한 형식으로 취급했다. 테스트에서는 종 수의 상한과 앞쪽 네 종의 순서를 확인했다.
난수 칸은 재대전에서 다른 전투 시드를 만들 수 있게 넣었다. 다만 25가지 값이라 같은 코드가 다시 나올 수 있다. 시드가 달라도 승패가 같을 수 있어서 매번 다른 결과를 보장하는 기능은 아니었다.
검사 한 자리는 오타를 거르는 데 썼다
숫자를 옮겨 적는 과정에서 틀릴 수 있어 Damm 검사 자리를 붙였다. 10×10 표에 숫자를 차례로 넣어 상태를 바꾸고 마지막 검사값을 구하는 방식이다.
검사할 때는 데이터 앞에 코드 버전과 역할을 함께 넣었다. 게스트 응답에는 답하려는 호스트 코드도 포함했다.
const damm = (digits: string) =>
[...digits].reduce((c, d) => DAMM[c][Number(d)], 0)
const context = (hostCode?: string) =>
`${LINK_CODE_VERSION}${hostCode ? `2${hostCode}` : '1'}`숫자 본문만 맞아 보이는 다른 조합도 거르려는 처리였다. 하지만 한 자리 검사 상태에는 충돌이 가능하다. 다른 호스트 문맥이나 여러 자리 오류를 모두 구별하는 인증 장치로 보지는 않았다.
샘플 코드 세 개에서 각 자리의 숫자를 다른 아홉 숫자로 바꾸고, 인접한 서로 다른 숫자도 모두 뒤집어 봤다. 그 범위에서 잘못된 코드를 거부하는지 테스트했다. 대전 가능한 모든 종은 세 체형을 코드로 바꿨다가 복원해 같은 체형으로 읽히는지도 확인했다.
잘못된 숫자가 검사에 통과할 가능성은 남았다. 대전 시작 전에 상대 몬스터 이름을 보여 주는 확인 화면을 둔 이유였다.
호스트가 보여 주고 게스트가 응답했다
코드 교환은 역할을 나눠 만들었다. 호스트가 먼저 코드를 보여 주고, 게스트가 그 코드를 입력해 응답 코드를 만들었다. 호스트가 응답을 입력하면 양쪽에 같은 코드 쌍이 생겼다.
두 코드를 이어 붙인 값의 FNV 해시로 세션 id를 만들고, 복원한 몬스터를 기존 LinkPayload에 넣었다. 새 전투 규칙을 쓰지 않고 simulateBattle과 기존 결과 적용 함수를 재사용했다. 사용한 세션의 중복 적용을 막는 저장 처리도 그대로 연결했다.
폰의 CONNECT 화면에는 번호 코드 진입 버튼을 넣었다. CodeLinkModal에서 숫자 키패드로 입력하고 여덟 자리가 차면 검사했다.
QR을 지우니 카메라 권한도 없어졌다
기존 QR용 LinkModal은 어느 화면에서도 렌더되지 않았다. 실제 폰 대전 진입점은 Nearby였으므로 사용하지 않는 QR 화면과 단계 컴포넌트를 지웠다. 공통 버튼과 헬퍼만 새 코드 화면으로 옮겼다.
expo-camera와 react-native-qrcode-svg 의존성도 제거했다. 앱 설정의 카메라 권한과 한국어·영어 권한 안내 문구를 함께 지웠다. 사용하지 않는 화면만 숨기는 대신 앱이 요청하는 권한까지 줄었다.
앱 아이콘은 반점 알 도트로 바꿨다. 원본 스프라이트의 첫 프레임에서 잉크 픽셀을 골라 정수 배율로 그리는 Python 스크립트를 만들었다. 아이콘과 스플래시, 파비콘을 같은 그림과 색으로 생성했다.
며칠 키우는 대신 개발용 입구를 뒀다
기기 간 대전을 자동으로 확인하려면 양쪽에 대전 가능한 몬스터가 있어야 했다. 테스트마다 알부터 키울 수는 없어서 개발용 딥링크를 만들었다.
testPet으로 종을 지정하고 testLink로 대전·합체 진입을 고르게 했다. 화면은 개발 빌드에서만 이 값을 Device에 전달했다.
const { testPet, testLink } = useLocalSearchParams<{
testPet?: string
testLink?: string
}>()
return __DEV__
? <Device testPet={testPet} testLink={testLink} />
: <Device />입력을 받는 쪽에서도 __DEV__를 확인했다. 존재하지 않는 종 id는 현재 펫을 건드리지 않고 무시했다. 유효한 종은 새 알로 리셋한 뒤 건강한 테스트 펫으로 바꿔 저장했다.
Maestro에는 A·B·C 표시와 설정·도감·기록을 열고 닫는 기본 검사를 추가했다. 기기 두 대의 번호 교환도 스크립트로 연결했다. Android 에뮬레이터를 호스트, iOS 시뮬레이터를 게스트로 두고 화면 계층의 텍스트에서 번호를 읽어 상대 기기에 입력하는 방식이었다.
개발 메뉴 버튼이 설정 아이콘을 가리는 문제도 있어서 테스트 시작 때 버튼을 옮겼다. 이날 만든 것은 이런 자동 조작 경로와 코드 교환 기반이었다.
번호 코드에는 전투에 필요한 값만 남겼다. 사람 손으로 옮길 길이를 정하고 나니 체중의 정확한 값과 표시용 기록을 구분할 수 있었다. 카메라를 쓰던 경로도 코드와 권한까지 함께 정리됐다.