본문으로 건너뛰기

dotgimon 제작기 28. 톡 한 번 연결, 걸음으로 만나는 야생 몬스터, 저장 파일 서명

·39 min read·28 / 32

DOTGIMON은 Expo/React Native로 만드는 LCD 방식 가상 펫 게임이다. 폰 앱이 있고, 같은 게임 엔진을 QuickJS(앱 안에 내장하는 작은 JavaScript 엔진)로 돌리는 애플워치·Wear OS 앱이 있다.

앱을 지웠다 깔아도 펫이 남는, 로그인 없는 백업

앱을 지웠다 다시 깔면 펫이 사라진다. 로그인은 넣고 싶지 않았다. 설계서와 구현 계획(작업 21개)을 만들어 두었고, 새벽 3시 42분까지 구현을 이어 갔다.

백업은 기능이 단순해 보여도 잘못되면 펫을 잃는다. 그래서 설계서 맨 앞에 안전 원칙 여덟 가지를 두고, 모든 판단을 거기에 맞췄다. 그중 다섯 가지는 이렇다.

  • 기기 데이터를 서버 데이터로 덮어쓰지 않는다. 서버에서 받아오는 건 기기가 완전히 비어 있을 때뿐이다.
  • 새 설치는 서버를 확인하기 전에 새 펫을 만들지 않는다.
  • 복원은 전부 검사를 통과해야만 한 번에 넣는다.
  • 백업 실패는 게임에 영향을 주지 않는다. 화면 표시 없이 조용히 다시 시도한다.
  • 동의 없이는 아무것도 보내지 않는다.

계정은 로그인 없는 "기기 열쇠"다. iOS는 expo-secure-store(키-값을 기기의 보안 저장소에 암호화해 두는 Expo 모듈), Android는 Block Store(구글 Play 서비스가 제공하는 작은 인증 정보 저장소)에 무작위 열쇠를 둔다. Android용은 Block Store를 부르는 네이티브 코드를 앱 저장소 안의 Expo 모듈(modules/ 폴더)로 직접 만들었다. 열쇠를 읽는 것과 만드는 것을 분리해서, 일시적으로 못 읽는 상황에 새 열쇠를 만들어 덮어쓰는 일이 없게 했다.

서버는 Cloudflare Worker의 Durable Object(ID마다 하나씩 상태를 들고 실행되는 객체)로 만든 백업 보관소 Vault다. 최근 5벌, 날짜별 7일, 1년 만료 같은 보관 규칙은 순수 함수로 먼저 만들고 테스트했다. 요청 본문은 스트림으로 들어오는 조각의 크기를 하나씩 더해 가며 읽고, 상한을 넘으면 거기서 멈춘다.

부팅할 때 무엇을 할지는 상황별 경우의 수를 표로 정리하고, 그 표를 그대로 옮긴 순수 함수로 정했다. 서버 상태, 기존 프로필, 예전 버전 앱이 AsyncStorage(React Native의 키-값 저장소)에 남긴 저장 데이터가 있는지에 따라 시작 방식이 갈린다.

// src/cloud/boot-restore.ts
export function bootStart({ hasCloudState, hasProfile, hasLegacy }: BootFacts): BootStart {
  if (hasCloudState) return 'start'
  return hasProfile || hasLegacy ? 'adopt' : 'pull'
}
 
export function afterPull(kind: PullKind): PullAction {
  if (kind === 'ok') return 'import'
  return kind === 'error' ? 'fail' : 'create'
}

이미 앱을 쓰던 사람(adopt)은 서버에 묻지 않고 바로 올리기만 한다. 새로 설치한 기기(pull)만 서버에 묻는다.

서버가 답하지 않으면(fail) 새 펫(알)을 만들지 않고, LCD에 서버 연결을 다시 시도하는 ONLINE? 문구를 띄운다. 세 번 실패하면 그때 "새로 시작"을 고를 수 있다. 앱보다 새 버전의 백업이면 UPDATE 화면을 띄운다.

업로더는 앱 시작, 10분마다, 백그라운드로 갈 때 올린다. 한 번에 하나만 보내고, 순번(seq)을 먼저 저장하고, HTTP 413(요청 본문이 너무 큼)이 오면 앱을 다시 켤 때까지 올리기를 멈춘다. 올리기 전에 백업 묶음(펫 저장과 생애 기록을 한데 담은 데이터)을 한 번 더 검사한다.

동의 화면과 설정의 '서버 백업' 켜기/끄기·지우기도 팝업 모달 없이 만들었다. 이 프로젝트는 창을 따로 띄우지 않고 LCD 화면 안에 픽셀 UI를 그린다는 규칙을 따른다. 첫 화면 위에 동의 카드가 뜨고, 설정은 도트 화면 안에서 바뀐다.

Android 자동 백업은 allowBackup=false(앱 데이터를 OS 자동 백업 대상에서 빼는 AndroidManifest 설정)로 껐다. OS 자동 백업이 옛 저장 파일을 되살려 서버 최신본을 밀어내는 일을 막기 위해서다.

개인정보 처리방침(한국어·영어)과 근처 통신·저장 방식 문서에도 서버 백업 절을 넣었다. 테스터 단계라서 운영 서버도 처음부터 복원을 켜 두기로 했다.

리뷰를 여러 번 돌리며 경계 상황을 고쳤다. 기존 유저가 취소했을 때 아무것도 쓰지 않기, 중복 탭, 업로드 대기 중 지우기 같은 것들이다. 자동 테스트와 wrangler dev(Cloudflare Worker를 로컬에서 돌리는 명령) 통합 테스트까지 확인했다. Android Block Store는 실기기에서 확인하지 않았다.

LINK를 톡 한 번에 붙이고, 충격 지문으로 상대를 가려냈다

근처 대전 LINK(가까이 있는 두 폰이 블루투스로 직접 연결해 펫끼리 싸우는 모드)는 블루투스로 근처를 확인한 뒤 두 폰을 톡 맞부딪치고 2초 대고 있어야 연결됐다. 실제로 해 보니 두 가지가 문제였다.

  • 대고 있는 동안 iOS NameDrop·AirDrop 창(아이폰끼리 가까이 대면 연락처·파일을 주고받자고 뜨는 시스템 창)이 떴다. 특히 윗면끼리 댈 때 그랬다.
  • 엉뚱한 폰이 끼어드는 걸 막는 판정이 너그러웠다. 두 톡이 0.7초 안이면 같은 접촉으로 봤다. 근처에서 연결 대기(READY) 화면을 켜 둔 다른 폰이 그 순간 어딘가에 부딪히면 엉뚱한 상대와 이어질 수 있었다.

오전에는 2초 방식을 다듬었다. 가속도의 세기만 보던 것을 x·y·z 세 축 값이 얼마나 바뀌었는지로 톡을 잡게 바꿔, 옆 모서리로 미는 톡도 인식하게 했다.

신호 세기에는 히스테리시스를 넣었다. 히스테리시스는 들어오는 기준과 나가는 기준을 다르게 둬서 경계에서 왔다 갔다 하지 않게 하는 방식이다. 블루투스 신호 세기(RSSI)가 -52dBm 이상이면 들어오고, 거기서 8dB 넘게 약해지면 나간다.

그러다 방향을 바꿨다. 대고 있을 필요 없이 톡 한 번에 연결하기로 했다.

톡 한 번이면 "이 폰과 부딪힌 게 맞나"를 시간만으로는 가를 수 없다. 그래서 톡의 충격을 지문처럼 기록해서 두 폰이 서로 비교한다. 언제 부딪혔나, 위아래 어느 쪽으로 밀렸나, 얼마나 세게 부딪혔나를 본다.

수평 방향은 비교하지 않는다. 두 폰의 나침반 기준이 달라서다. 위아래는 두 폰 모두 중력이라는 같은 기준이 있다. UWB(기기 사이 거리와 방향을 재는 초광대역 무선)는 아이폰 11 이후 일부 모델(16e·17e 제외)과 일부 안드로이드에서만 되고, 아이폰↔안드로이드가 안 돼서 쓰지 않았다.

지문은 톡 직전 200ms의 평균(중력)과 톡 뒤 60ms의 충격을 비교해서 만든다. 두 폰이 서로 반대 방향으로 밀려야 정상이다. 내 폰이 위로 밀렸다면 상대 폰은 아래로 밀렸어야 한다.

아래 함수가 두 지문을 비교해, 걸리는 항목이 있으면 거절 사유를, 모두 통과하면 null을 돌려준다.

// src/nearby/bump-print.ts
export function samePrint(mine: unknown, theirs: unknown, skew: number): PrintReject | null {
  if (!isPrint(mine) || !isPrint(theirs) || !(skew >= 0)) return 'PRINT'
  if (Math.abs(mine.at - theirs.at) > BUMP_SYNC_MS + skew) return 'TIME'
  if (mine.vertRatio >= VERT_RATIO && theirs.vertRatio >= VERT_RATIO && Math.sign(mine.vert) === Math.sign(theirs.vert)) return 'VERT'
  const lo = Math.min(mine.strength, theirs.strength), hi = Math.max(mine.strength, theirs.strength)
  if (!(lo > 0) || hi / lo > STRENGTH_RATIO) return 'FORCE'
  return null
}

시각 비교(TIME)는 100ms에 시계 오차(skew)를 더한 범위다. 두 폰의 시계는 연결 뒤 메시지를 몇 번 주고받아, 왕복 시간이 제일 짧았던 회차에 맞춘다. 센서가 주는 시각은 "부팅 뒤 흐른 초"라서 그대로 쓰면 안 된다. 최근 센서 표본 중 전달 지연이 가장 짧았던 것을 기준 삼아, Date.now()와 같은 기준의 시각으로 바꾼다.

둘 다 크게 위아래로 움직였는데 방향이 같으면 VERT, 세기가 2.5배 넘게 차이 나면 FORCE로 거절한다. 두 폰이 각자 판정해서 둘 다 OK여야 대전이 성사된다. 하나라도 아니면 화면 표시 없이 READY로 돌아가고, 다시 톡하면 된다.

판정을 통과한 뒤에는 연결 성공을 알리는 CONNECT! 화면에서 취소할 수 없게 했다. 진동도 세 가지로 나눴다. 톡을 인식했을 때, 연결에 성공했을 때, 다시 톡해 달라고 할 때다(expo-haptics).

설계서를 쓰고, 계획을 짜고, XState(상태와 전이를 코드로 정의하는 JavaScript 라이브러리)로 만든 연결 상태 머신과 거기에 센서·블루투스 이벤트를 넘기는 연결 코드까지 하루 안에 끝냈다. 연결 직후 서로 정보를 주고받는 인사 단계에서 전송이 실패하거나 연결이 늦게 실패해도, FAIL 화면으로 끝나지 않고 READY로 돌아가게 고쳤다. 같은 상대와 연속 대전하는 테스트도 넣었다. 판정 기준값은 실기기에서 확인하지 않았다.

WILD는 GPS 칸으로 시작해서 걸음만 남겼다

STAGE 챌린지(층을 하나씩 올라가며 컴퓨터 적과 싸우는 도전 모드)는 기록 전용이라 진화 조건(대전 수·승수)에 들어가지 않는다. 그래서 주변에 대전 상대가 없으면 좋은 진화에 닿기 어려웠다. 걸어 다니면서 야생 몬스터를 만나 대전하고, 그 결과를 LINK 대전과 똑같이 진화 조건의 대전 수·승수로 쌓는 기능이 WILD다.

처음 설계는 150m 칸과 30분 시간대였다

일정 걸음을 걸으면 위치를 한 번 읽어 약 150m 격자 칸과 30분 시간대를 계산했다. 같은 칸·시간대면 같은 몬스터가 나왔다. 이번 시간대에 이미 쓴 칸이면 판정을 미뤘다. 서버 없이 기기 안에서 계산해도 누구에게나 같은 결과가 나오는 방식이었다.

오후에 작업 14개로 나눈 계획대로 구현했다. 위도·경도를 격자 칸 번호로 바꾸는 계산 함수, 확률표와 조우 판정, 걸음 조건, 야생 몬스터 능력치, 탐색 화면, 대전을 시작하는 순간 승패를 미리 저장하는 처리까지다. 위치는 탐색 중에만 읽고 칸 번호로 바꿔 버리고, 서버로 보내지 않는다고 개인정보 처리방침에 적었다.

선택 화면은 미리보기를 보고 바로 고쳤다. 선택지가 하나만 보여서 다른 선택지와 누를 버튼을 알 수 없었기 때문이다. ◀ BATTLE ▶처럼 화살표를 두고, 맨 아래에 A:NEXT B:OK(A 버튼은 다음 선택지, B 버튼은 확정) 안내를 붙였다.

도망(RUN)을 고르면 내 몬스터가 뒤돌아 왼쪽 화면 밖으로 걸어 나가며 ESCAPED가 뜬다. 속도는 홈 화면에서 몬스터가 걸어 다닐 때와 같은 240ms마다 도트 2칸이다. 다 나가면 왼쪽 끝에서 다시 걸어 들어와 탐색으로 이어진다.

실기기에서 몬스터가 안 나왔고, GPS를 들어냈다

저녁에 실기기에서 걸어 보니 몬스터가 잘 나오지 않았다. 원인을 보려고 환경 변수 EXPO_PUBLIC_WILD_DIAG=1을 켰을 때만 보이는 개발자 전용 진단 두 줄을 붙였다. 걸음 부족, 위치 권한, 정확도, 시간 초과, 위치 서비스 꺼짐 중 무엇인지 LCD 아래에 보여 주는 줄이다. 걸음 조건도 처음 정한 값보다 크게 줄였다.

그리고 30분 뒤 결정이 바뀌었다. GPS를 완전히 없앴다. 권한, 정확도, 시간 초과가 실기기에서는 모두 "몬스터가 안 나온다"는 같은 증상으로 보였다.

이제 WILD 화면이 켜져 있는 동안 만보기로 센 걸음이 일정 수에 이르면 바로 조우가 일어나고, 내 단계에 맞는 몬스터가 무작위로 나온다. 지도 칸, 30분 시간대, 이미 쓴 칸 기록, 위치 권한, 정확도·시간 초과 처리가 모두 빠졌다. 위치를 읽던 Expo 모듈 expo-location과 진단 줄도 같이 지웠다. 개인정보 처리방침의 위치 항목도 '걸음 수'로 바꿨다.

위치는 "같은 장소에서 같은 몬스터"라는 재미를 주지만, 그 대가로 권한, 실내 정확도, 시간 초과 같은 실패 경로를 모두 떠안는다. 걷기만 남기니 실패할 수 있는 곳이 걸음 센서 하나로 줄었다.

같은 날 밤 실기기에서 올라온 문제도 고쳤다.

  • 조우 걸음 수를 줄였더니 몬스터가 너무 자주 나온다는 보고가 와서 다시 늘렸다.
  • 밤에 걷다 만나면 등장 알림이 울리지 않았다. 원인은 밤 10시부터 아침 10시까지 돌봄 호출(배고픔·똥·아픔을 알리는 진동)을 미뤄 두는 무적 시간대 규칙이었다. 직접 걷는 중이면 펫을 내버려 둬서 울리는 호출이 아니니, 등장 알림은 무적 시간대에도 울리게 했다.
  • 야생 대전이 끝나면 곧바로 탐색으로 가지 않고, LINK처럼 홈 가운데에서 WIN!/LOSE 말풍선과 감정 표현 동작(2.4초)을 보인 뒤 탐색으로 돌아가도록 바꿨다.
  • 대전 화면 머리글을 BATTLE 대신 WILD로 바꿨다.

iPhone에서는 WILD를 켠 채 앱이 가려져 있는 동안 걸은 걸음을, 돌아올 때 iOS 걸음 기록(Pedometer.getStepCountAsync)에서 그 구간만큼 읽어 한꺼번에 넣게 했다. 앱을 완전히 종료하면 그 사이 걸음은 세지 않는다.

이날 WILD는 설계서대로 만든 것과 실제로 남은 것이 꽤 달랐다. 설계서 본문은 고치지 않고 남겨 두고, 맨 위와 끝에 "2026-10-15 결정" 절을 쌓아 무엇이 대체됐는지 적었다. 대체된 내용과 지금 값이 한 문서에 같이 있어서, 같은 날 세 번 바뀐 값도 헷갈리지 않았다.

모든 성숙기가 모든 완전체로 가던 진화 구조를 좁혔다

몬스터는 유년기 → 성장기 → 성숙기 → 완전체 → 궁극체 순으로 진화하고, 진화는 같은 버전(몬스터를 여섯 무리로 나눈 묶음) 안에서만 이어진다.

성숙기에서 완전체로 갈 때는 모든 성숙기가 그 버전의 완전체 전부에 닿았고, 그중 둘은 조건 없이 나왔다. 좋은 쪽으로 가는 조건은 친구 대전뿐이라 혼자 하는 사람은 잘 키워도 좋은 완전체에 못 갔다. 도감의 진화 트리도 선이 너무 많아 알아보기 어려웠다. 버전 1 기준 선 66개 중 33개가 성숙기에서 완전체로 가는 구간에 몰려 있었다.

  • 완전체를 버전마다 최고·우수·보편 세 등급과, 돌봄 실수가 많을 때 가는 더티로 나눴다.
  • 성숙기는 성장기 때의 돌봄에 따라 정통·보통·방치 계열로 나눴다. 최고는 정통 계열에서만 간다.
  • 우수는 혼자서도(돌봄 + 훈련 미니게임) 갈 수 있다. 최고는 돌봄 + 훈련 + 대전인데, WILD가 들어왔으니 혼자서도 갈 수 있다.
  • 더티 완전체는 더티 성숙기에서만 간다. 완전체에서 궁극체로는 완전체마다 궁극체 하나로 좁혔다.

등급별 능력치 배율은 두지 않았다. 처음에는 두려 했지만, 좋은 등급 조건에 이미 훈련이 들어 있고 훈련은 진화해도 이어진다. 배율까지 더하면 이중 보상이다. 좋은 등급의 보상은 희귀한 종과 도감 수집이다.

선을 어떻게 그려도 엉키는 문제는 그리는 방식이 아니라 진화 구조를 고쳐서 풀었다. "모든 몬스터가 모든 다음 단계로 갈 수 있으면 안 된다"를 원칙으로 적었다.

문턱은 감이 아니라 시뮬레이션으로 정했다

돌봄 습관, 하루 훈련량, 친구 대전과 WILD 습관이 다른 가상 유저들을 돌려 등급별 도달률을 보고 조건 값을 조정했다. 하루 10·20·40세트를 하는 "훈련 많이 하는 유저" 기준으로 다시 재서 문턱을 올리기도 했다.

시뮬레이션 등급 모델은 뒤이어 실제 규칙 모듈과 합쳤다. 시뮬레이션이 고른 진화 결과와, 실제 규칙이 갈래 목록을 위에서부터 훑어 처음 조건이 맞은 결과가 같은지 테스트로 대조한다. 문턱 값은 table.ts 상수 한 곳에만 둔다.

모자란 칸에 신규 9종을 채웠다

버전 1·2의 단계별 종 구성(22종)에 맞춰 다른 버전의 모자란 칸을 채웠다. 버전 6 성숙기 3종, 조그레스(두 마리를 연결해 합체 진화시키는 기능) 완전체 3쌍(6종)이다. 이름도 몇 개 정했다(코드 이름 pegasky는 루엔페가몬).

원화가 아직 승인되지 않은 종은 도감에 ? 그림으로만 올렸다. 원화 파일이 있어도 도트 변환 스크립트(art:import)를 거치기 전에는 앱에 싣지 않는다. 그림이 없는 몬스터로 가는 도감 화살표는 숨겼다.

훈련을 더 한 쪽이 지던 전투를 하루에 세 번 다시 짰다

메가히트는 훈련 미니게임에서 게이지 상단에 멈춘 최고 판정이고, 대전에서는 치명타로 이어진다. 메가히트 114회·훈련 42회 성장기가 메가히트 80회·훈련 30회 성장기에게 졌다. 시뮬레이션으로 보니 훈련을 더 한 쪽의 승률이 약 43%였다.

원인은 상성(몬스터 타입끼리의 유불리)에서 밀린 것이었고, 메가히트는 둘 다 상한을 넘어서 차이가 없었다. 체감은 "훈련을 두 배 가까이 했는데 압도적으로 진다"였다.

첫 안은 HP·공격을 누적 훈련 수에서 로그 곡선으로 뽑은 '훈련 성과 곡선'으로 정했고, 둘째 안부터는 진화 단계마다 정한 상수로 바꿨다.

항목첫 안둘째 안확정 안
HP·공격 기본훈련 성과 곡선단계별 상수단계별 상수
훈련 효과HP·공격상대 대비 비율로 회피상대와의 횟수 차이로 회피 + 공격, 한도 있음
걷기공격 보너스전투에서 뺌전투에서 빼고 야생 조우에만
챌린지 적층별 실효 성과층 구간별 단계 + 훈련 수둘째 안 구조

실효 성과는 첫 안에서 HP·공격 계산에 쓰던 값으로, 훈련 성과에 성장 단계·상성 보정을 더한 값이다. 둘째 안의 챌린지 적은 층 구간마다 적의 성장 단계를 정하고, 층이 오를수록 적의 훈련 수를 늘리는 구조다.

비율 대신 횟수 차이를 고른 기준은 하나였다. 훈련 한 번의 가치가 언제나 같아야 공정하다. 100회 차이는 1주 차든 3년 차든 같은 무게다. 대가로, 앞선 차이는 둘이 똑같이 훈련하는 한 줄지 않는다.

전투는 일반 공격, 회피, 메가히트 세 가지로만 판가름하게 정리했다. 버전마다 다르게 준 능력치 계수, 몸집 유형(체형)별 보정, 걸음 수 보너스, 명중률은 전투에서 뺐다. 상성은 "상대가 훈련을 몇 회 더 한 것"으로 바꿨다. 불리한 쪽도 그만큼 더 훈련하면 반반이 된다.

메가히트는 훈련 때 잘 친 비율이 높을수록 전투에서도 잘 터진다. 같은 단계에서 아무리 앞서도 승률이 100%가 되지 않게 한도를 뒀다.

LINK는 두 기기가 각자 계산해서 같은 결과를 내야 한다. 그래서 훈련 수 차이를 tanh 곡선으로 바꾼 값은 소수 6자리로 양자화(값을 정해진 자릿수로 맞추는 것)했다. 절댓값에서 반올림한 뒤 부호를 붙여, 두 쪽이 정확히 부호만 다르게 했다.

워치는 QuickJS, 폰은 Hermes라는 서로 다른 JavaScript 엔진으로 같은 계산을 돌린다. 두 엔진이 Math.log1p(1+x의 자연로그)를 1ulp(이웃한 두 부동소수점 값 사이의 간격 하나) 다르게 계산해 반올림이 뒤집힌 일을 이미 겪었다. 한쪽만 결과가 달라지면 LINK 양쪽 전적이 갈린다.

챌린지는 "얼마나 훈련하고 진화했나"를 큰 숫자로 자랑하는 곳으로 두고, 층 구간마다 적의 단계가 올라가게 했다. 구간 끝은 진화해야 넘는다. 야생 상대는 반대로 항상 내 단계에 맞춘다.

걷기·상성 관련 앱 문구도 새 규칙에 맞췄다. "공격력이 오른다"를 지우고, 걸으면 야생 몬스터를 만난다고 바꿨다. 상성은 숫자 없이 "유리"라고만 쓴다.

패치가 들어간 뒤 진화 시뮬레이션을 다시 돌렸다. 대전 승률이 들어간 진화 조건은 패치 전보다 도달이 크게 어려워졌고, 그 결과도 밸런스 문서에 그대로 남겼다.

저장 파일을 고친 펫이 정상 펫처럼 대전에 나갈 수 있었다

테스터가 저장 파일(앱의 SQLite DB pixelpet.db 안 current_save 행)을 직접 고쳤다. 누적 훈련을 999로 올리고, 진화 조건 없이 종을 바꿨다. 앱은 고친 값을 그대로 믿었고, 이 펫은 근처 대전과 코드 대전(번호 코드를 주고받아 서버를 거쳐 하는 원격 대전)에 정상 펫처럼 나갈 수 있었다.

파일에 닿는 길은 세 가지다. 기기 백업을 고쳐 복원하기(탈옥 없이 된다), 루팅·탈옥 기기에서 파일을 직접 고치기, 시뮬레이터에서 고치기. 이날은 변조 방지 1단계로 첫째 길을 없애고 둘째 길을 잡아내는 데 집중했다. 서버가 코드 대전 승패를 판정하는 설계도 같이 썼다.

  • 변조를 발견하면 대전만 막는다. 키우기는 계속할 수 있다. 상대 화면에는 보이지 않는다.
  • 변조 표시는 그 펫이 끝날 때(새 알)까지 남는다.
  • 펫 저장을 기기 백업과 기기 간 이전에서 뺀다. 폰을 바꾸면 새로 시작한다.
  • 이미 쓰던 테스터는 업데이트 뒤 첫 실행에서 한 번 도장(저장 내용이 고쳐지지 않았음을 확인하는 서명값)을 찍는다. 이 허용은 배포일 + 14일로 기간을 둔다.

도장은 DB에 쓰는 문자열 그대로 찍었다

저장할 때마다 저장 원문과 변조 펫 목록을 HMAC-SHA256(비밀 열쇠와 내용을 함께 해시해서, 열쇠 없이는 같은 값을 만들 수 없는 서명)으로 묶어 도장을 찍는다. 열쇠는 SecureStore에 둔 무작위 32바이트다. 아래 함수가 도장에 들어갈 입력을 만든다.

// src/storage/seal.ts
export function sealInput(profileId: string, tampered: string, text: string): string {
  return `${SEAL_DOMAIN}\n${profileId}\n${tampered}\n${text}`
}

도장 입력은 DB에 쓰는 문자열 그대로다. 읽은 값을 파싱해 다시 문자열로 만들어 비교하면 키 순서나 숫자 표기가 달라져, 고치지 않은 저장도 변조로 잘못 판정된다(거짓 변조).

변조 목록을 도장 안에 넣자는 건 리뷰에서 나온 지적이었다. 목록을 따로 두면 그 항목 하나만 지워서 표시를 없앨 수 있다.

저장(write)이 동기 함수라서 HMAC도 동기여야 한다. 그래서 동기 HMAC-SHA256을 직접 구현하고, 표준 문서 RFC 4231에 실린 입력·정답 쌍(테스트 벡터)으로 확인했다. 코드는 src/storage/ 안에 두어 워치 앱에 싣는 코드 묶음에는 섞이지 않는다. 같은 내용을 2초마다 저장하는 경우에는 마지막 입력을 기억해 다시 계산하지 않는다.

쓰는 순서가 거짓 변조를 가른다

부팅할 때 도장 확인은 판정표대로 한다. 도장이 맞으면 정상, 안 맞으면 변조다. 저장은 있는데 도장을 담은 DB 행이 없고, 도장을 찍기 시작했다는 "도장 시작" 표시가 남아 있으면 변조다.

쓰는 순서도 정했다. "도장 시작" 표시는 첫 도장이 DB에 커밋된 다음에 쓴다. 반대로 하면 그 사이에 앱이 죽었을 때 거짓 변조가 난다.

열쇠를 일시적으로 못 읽는 경우는 따로 다뤘다. 그 프로세스에서는 확인도 저장도 하지 않고 대전만 막는다. 도장 없이 저장하면 다음 실행에서 옛 도장과 맞지 않아 거짓 변조가 나기 때문이다.

열쇠를 못 읽는 동안 저장을 미뤄 두는 이 관문을 SealGate로 만들었다. 앱이 포그라운드로 돌아올 때마다 다시 읽고, 읽히면 확인을 마친 뒤 저장한다.

설계서는 리뷰를 두 번 받았고, 구현 계획도 리뷰를 받은 뒤 밤 11시부터 구현을 시작했다.

무음 모드부터 오류 화면까지 손본 것들

  • 무음 모드: 설정의 소리·진동 키를 무음 모드 하나로 합쳤다. 끄면 기기 설정(아이폰 무음 스위치, 기기 볼륨)을 따른다. 기존에 소리나 진동을 꺼 둔 사용자는 무음 모드 ON으로 옮긴다.
  • 진화 단계 표기: 상태창(STATUS)과 도감에 진화 단계를 LV. I~LV. VI로 쓴다. LCD 글꼴의 로마 숫자 V가 U와 헷갈려서 다시 그렸고, 시계 아이콘도 둥글게 고쳤다.
  • STAGE 표기: 대전 머리글을 STAGE n으로, 최고 기록은 상태창 2쪽 STAGE 줄로 옮겼다. 최고 기록은 진화·조그레스에서도 유지되고 새 알에서만 0이다.
  • 오류 화면: 그리다가 멈추면 하얀 화면 대신 LCD 오류 화면(버전·메시지·스택 앞줄·RETRY)을 띄우는 루트 ErrorBoundary를 넣었다.
  • 수면 Z: 불 켜진 채 자면 Z를 2개로 늘려 그림 밖에 띄우고, 잠은 가운데(똥이 있으면 옆)에서 잔다.
  • 동작 그림 교체: 종마다 골라 둔 동작 프레임(스프라이트, 동작 장면을 한 장에 이어 붙인 PNG)을 게임 애니메이션에 넣고, 후보를 비교하던 옛 스크립트를 정리했다.
  • 동기화: 진화까지 쌓는 시간을 화면 프레임 간격이 아니라 실제로 흐른 시간으로 세고, 워치는 폰 상태를 따르게 고쳤다.
  • QR 데이터 검사: 대전용 QR에 챌린지 적(NPC)에게만 쓰는 전투 단계 값 s를 끼워 넣으면 형식 오류(FORMAT)로 거부한다.
  • 완전체 공격 연출: 빙 도는 효과(orbit)를 쓰는 공격 그림에서 바깥 테두리 깜빡임을 뺐다.
  • 문서: 개인정보 처리방침에 LINK가 내보내는 임시 무작위 번호와 상대에게 알리는 톡 시각, 플랫폼별 호출 알림 내용을 넣었다. 앱 안 가이드북의 비타민(근력 게이지)·잠·훈련 체중·대기 시간 쪽도 고쳤다.

밸런스를 바꿀 때마다 대전 기록이 지워지게 둘 수는 없었다

운영 배포 때 대전 기록을 초기화하려고, 챌린지 규칙 버전 번호(CHALLENGE_RULESET)를 올리면 기록이 지워지게 해 둔 상태였다. 이날 이것을 되돌렸다. 규칙 번호는 밸런스가 바뀔 때마다 올라가는데, 그때마다 STAGE 최고 기록과 전적이 날아가면 안 된다. 기록 초기화는 사용자만 정한다.

기록을 지우는 기준은 규칙 번호가 아니라 아래 두 상수다. BATTLE_RECORD_EPOCH는 새로 쌓이는 기록에 찍는 시기 번호이고, 이 번호가 BATTLE_RECORD_RESET_MIN보다 낮은 기록은 지운다. 코드 주석의 "도장"은 이 시기 번호를 가리키고, 저장 파일 서명과는 관계없다.

// src/game/battle-record.ts
export const BATTLE_RECORD_EPOCH = 4
/** 이 값 이상의 도장을 가진 기록은 살아남는다. 1 = 첫 시기부터 지금까지 전부 유지 */
export const BATTLE_RECORD_RESET_MIN = 1

초기화하려면 두 상수를 같이 올린다. 규칙 버전과는 아무 관계가 없다. 같은 규칙을 AI 코딩 에이전트가 읽는 프로젝트 지침(AGENTS.md)에도 적어서, 에이전트가 밸런스를 바꾸다가 기록을 지우는 일이 없게 했다.