일일 퍼즐 ID에 생성기 버전이 필요한 이유

일일 퍼즐의 보드는 날짜에서 만들어져요. 스도쿠 앱 DailySudoku가 쓰는 방식이에요. 같은 날짜 정수와 같은 core 빌드로 정상적인 일일 화면에 들어오면 iOS, Android, Web에서 같은 보드가 나와요. 그런데 생성 방식을 바꾸면 seed가 그대로여도 보드가 달라질 수 있어요. 저장, 분석, 리더보드의 키가 날짜만 담고 있으면 그 차이를 표현하기 어려워요.

기준은 2026년 8월 6일 develop 브랜치의 커밋 9c99930582be23d2c72d37fb46d77a488b9fe04c예요. 지금의 구조와 그 위에 얹으려는 제안을 나눠서 적어요. DailyPuzzleIdentity 타입과 device-local-v1 날짜 정책, g1과 g2 ID, schema v2, resolver는 모두 제안이고 아직 구현하지 않았어요.

지금 일일 퍼즐이 만들어지는 경로

일일 퍼즐 화면에 들어오면 아래 순서로 보드가 정해져요.

현재 시각
  -> 기기 로컬 시간대의 epochDay
  -> dailySeed(epochDay)
  -> generate(MEDIUM, seed)
  -> puzzleId = "DAILY-<epochDay>"

dailySeed는 SplitMix64 finalizer를 쓰고, 64비트 wrapping 덧셈과 wrapping 곱셈, logical shift로 계산해요. 생성기는 자체 RNG로 완성된 보드를 만든 뒤, 해답이 하나로 유지되는 clue(처음부터 채워져 있는 숫자)만 지워요. 이 core는 Rust이고, iOS와 Android는 UniFFI로, Web은 WebAssembly로 호출해요.

정상적인 일일 진입점에서 같은 날짜 정수와 같은 core 빌드를 쓰면 세 플랫폼의 새 보드와 해답이 일치해요. 이 결정성과 golden fixture는 공통 core의 테스트와 CI를 다룬 글에서 설명했어요.

날짜는 기기의 로컬 날짜예요

날짜 정수는 UTC 날짜가 아니고 기기의 로컬 날짜에서 구해요.

localEpochDay = floor((utcMillis + offsetAtInstant) / 86_400_000)

offset은 그 순간의 «로컬 빼기 UTC» 값이에요. 밀리초를 돌려주는 쪽은 Android의 TimeZone.getOffset이에요. iOS의 secondsFromGMT(for:)는 초를 돌려주므로 밀리초로 바꿔요. Web의 getTimezoneOffset은 «UTC 빼기 로컬»을 분으로 돌려주므로 부호를 뒤집고 60_000을 곱해요. 서머타임이 있어서 offset을 상수로 두면 안 돼요.

이 정책에서는 같은 UTC 순간에도 서울과 로스앤젤레스의 날짜 정수가 다를 수 있어요. 여행으로 시간대가 바뀌면 다음 실행의 ID에 영향을 줘요. 이미 시작한 게임의 날짜는 유지하고, 다음에 일일 퍼즐에 들어올 때 다시 판정해요. 전 세계가 같은 순간에 같은 보드를 받는다는 보장은 없어요. 반대로 서울과 도쿄처럼 같은 날짜 정수를 고른 기기는 core와 난이도가 같으면 같은 보드를 받아요. 그래서 시간대 ID는 식별 필드에서 빼도 돼요.

날짜만 담은 ID가 놓치는 것

DAILY-<epochDay>에는 난이도도 생성기 버전도 없어요. Rust가 저장하는 JSON의 version: 1은 직렬화 규격의 번호예요. migration이 없는 다른 schema는 읽기를 거부해요. 이 번호는 생성 알고리즘의 버전과 다른 것이에요.

생성 방식이 실제로 바뀐 사례가 있어요. Expert 난이도에서 최대 시도 횟수를 8에서 4,096으로 올렸고, seed 159에서는 종전의 25-clue fallback 대신 24-clue 보드가 나왔어요. 이것은 Expert의 변경 이력이고, 일일 퍼즐이 쓰는 MEDIUM이 바뀌었다는 증거는 아니에요. 생성 규칙이 바뀔 수 있다는 것을 보여 주는 사례로만 읽어야 해요.

생성기가 바뀐 채로 신구 앱이 함께 돌면 이런 상황이 생겨요. 옛 앱의 새 게임은 g1으로 만든 보드이고, 새 앱의 새 게임은 g2로 만든 보드이고, 복원한 게임은 저장돼 있던 g1 보드예요. 셋 모두 같은 DAILY-day를 쓸 수 있어요. 저장 규격이 호환되면 보드와 해답을 복원해서 옛 판을 이어 갈 수 있지만, 분석에서 외부 ID가 겹치는 문제는 남아요. 표준 Game Center와 Play Games에는 동적인 퍼즐 ID별로 점수를 나누는 기능이 없어요. 어떤 버전의 점수를 받을지는 제출하기 전에 정해야 해요. context와 score tag의 한계는 리더보드의 날짜 경계를 다룬 글에서 따로 다뤘어요.

제안하는 식별 정보

날짜 정책, 날짜 정수, 난이도, 생성 버전을 묶은 타입을 제안해요.

type DailyPuzzleIdentity = {
  dayPolicy: "device-local-v1";
  epochDay: number;
  difficulty: "MEDIUM";
  generatorVersion: number;
};

필드마다 책임이 달라요. dayPolicy는 날짜의 경계를, epochDay는 고른 날짜를, difficulty는 생성 목표를, generatorVersion은 seed 파생과 RNG와 생성 규칙을 가리켜요. dayPolicy를 넣어 두면 날짜 기준을 바꾸더라도 옛 identity에 새 날짜 규칙을 소급해서 적용하지 않아요. 시간대를 관측한 메타데이터와 dayPolicy는 역할이 달라요.

이 정보를 문자열 ID로 만들면 아래와 같아요.

DAILY-device-local-v1-20671-MEDIUM-g1

seed는 epochDay에서 다시 계산할 수 있으므로 ID에 넣을 필요가 없어요. 저장 데이터나 telemetry에서 진단과 fixture의 보조값으로는 쓸 수 있어요.

generatorVersion은 Cargo의 package version과 분리해요. UI나 광고 SDK만 고쳤다면 올릴 필요가 없어요. RNG, shuffle, 난이도 목표, clue 제거, 재시도를 바꿨다면 결과에 영향을 주는지에 따라 올려요. 한 번 발급한 버전의 뜻은 바꾸지 않아요. Rust가 identity와 보드를 한 결과로 묶어서 돌려주게 하면, g2 보드에 g1 표지가 붙는 혼합을 막을 수 있어요.

유일해 검사와 식별 검사는 다른 질문이에요

생성기는 clue를 하나 지울 때마다 solver로 해답을 최대 두 개까지 찾아보고, 지운 뒤에도 해답이 정확히 하나인 경우만 받아들여요. 이것은 새로 만든 보드에 대한 검사예요. DAILY-20671 같은 ID가 보드 하나만 가리키는지와는 별개의 문제예요.

solver invariant
  -> 이 board에는 해답이 정확히 하나인가

identity invariant
  -> 이 PuzzleIdentity는 배포·복원·분석 전체에서 같은 board를 뜻하는가

서로 다른 유일해 보드가 같은 ID를 쓰는 것도, 같은 보드에 ID가 여러 개 붙는 것도 solver로는 잡을 수 없어요.

fixture에도 한계가 있어요. fixture는 골라 둔 입력에서 결과가 달라졌는지만 알려 줘요. 버전 번호를 올리지 않고 생성 규칙을 바꾸는 실수를 반환 API만으로 자동으로 막을 수는 없어요. 보드 전체가 같은지까지 보장해야 한다면 canonical digest나 archive로 보완해요. hash는 달라졌다는 사실을 알려 줄 뿐이고, 왜 달라졌는지와 어느 generator로 다시 만들어야 하는지는 알려 주지 않아요. 그래서 identity의 버전 계약을 먼저 세우고, checksum을 더할지는 그다음에 판단해요.

저장 데이터를 옮기는 방법

schema v2에는 snapshot과 버전이 들어간 identity를 함께 적는 것을 제안해요. 기존 v1 저장본에 g1을 붙이는 것은 릴리스 기록으로 v1 전체가 g1이었다는 것을 확인한 경우에만 해요.

출처를 알 수 없는 저장본은 legacy-<digest>로 격리해요. digest는 given, solution, difficulty를 정규화한 canonical digest이고, 입력 내역, history, 경과 시간은 넣지 않아요. 이 저장본은 버전 비교의 대상에서 빼요. 보드나 버전이 없는 과거 기록은 legacy-unknown으로 남겨요.

새 게임에는 활성 버전을 쓰고, 과거 날짜를 다시 만들 때에는 기록된 버전을 써요.

PuzzleIdentity.generatorVersion == 1 -> g1 또는 저장된 g1 board
PuzzleIdentity.generatorVersion == 2 -> g2 또는 저장된 g2 board

지원하지 않는 과거 버전은 조용히 다른 버전으로 만들지 않고 오류로 알려요. 과거 판을 다시 플레이하는 기능이 없는 제품이라면, 진행 중인 저장본의 보드와 해답, 그리고 identity를 전달하는 경로만 있으면 돼요. replay나 서버 검증이 있을 때에는 옛 generator나 바꿀 수 없는 archive가 필요해요.

되돌릴 때 지켜야 할 것

g2에 문제가 있어서 되돌린다면, 버전을 아는 바이너리는 그대로 두고 새로 발급하는 것만 g2에서 g1으로 바꿔요. 이미 g2로 배정한 보드는 계속 g2예요.

순서도 정해 뒀어요. 먼저 v1과 v2를 모두 읽고 버전이 붙은 ID를 쓰되 g1만 발급하는 호환 빌드를 배포해요. 그다음에 빌드나 원격 정책으로 g2를 켜요. 되돌리는 기간에는 g1 생성과 g2 snapshot 읽기를 둘 다 유지해요. 버전을 모르는 옛 바이너리로 되돌아가면 안 돼요.

지금 구조에서는 이 전환을 한 번에 강제할 수 없어요. generator가 클라이언트 바이너리에 들어 있고 중앙에서 버전을 고르는 장치가 없어요. 서버나 Web을 되돌려도 이미 설치된 g2 모바일 앱은 계속 g2를 실행해요. 버전 관리는 혼합 배포에서 하루에 보드 하나를 강제하지 못해요. 그 요구가 있다면 단일 권위가 날짜와 생성 버전의 대응을 바꿀 수 없게 관리하고 옛 클라이언트의 처리까지 운영해야 해요. 서버가 identity나 보드를 발급하거나, 최소 앱 버전을 통제하는 방식이에요.

두 generator를 모두 내장하고 서버나 Remote Config로 고르는 구조라면 새 발급을 g1으로 바꿀 수 있어요. 저장된 g2 게임은 원래의 identity와 보드로 끝내요. g2 점수를 빼려면 제출 전에 막아야 하고, 버전별로 조회하려면 커스텀 백엔드나 미리 나눠 둔 리더보드가 필요해요.

지금 있는 검사와 더할 검사

현재는 parity fixture가 epoch day 4개와 난이도 5개의 조합에서 seed, givens, solution을 고정해요. 새 결과로 기존 fixture를 덮어쓰면 옛 버전의 계약이 사라져요. 고정된 64비트 seed 연산은 golden value로, 새 보드의 변화는 Rust 테스트와 parity로, 유일해는 clue 제거와 선택한 seed의 테스트로 검사해요. 4개와 5개의 조합을 검사한다고 해서 모든 입력이 증명되는 것은 아니에요.

전환할 때에는 g1 fixture를 그대로 두고 g2 fixture를 새 파일로 만들 것을 제안해요.

g1 fixture: 변경 금지
  (device-local-v1, day-before-cutover, MEDIUM, g1) -> board A

g2 fixture: 새 파일
  (device-local-v1, cutover-day, MEDIUM, g2) -> board B

resolver test
  저장된 g1 identity -> g1 또는 저장 snapshot
  새 g2 identity     -> g2

확인할 항목은 네 가지예요. 지원하는 빌드에서 g1 보드와 해답이 일치하는지, 버전이 다르면 외부 ID도 다른지, 되돌린 뒤에 g2를 g1으로 취급하지 않는지, identity와 보드의 버전이 섞이지 않는지예요. Swift, Kotlin, TypeScript 쪽에서는 알고리즘을 복제해서 검사하지 않고, ID의 저장과 복원, 분석과 점수 정책으로의 전달을 검사해요.

구현 순서는 Rust의 반환값 결합, schema v2의 identity 보존, 분석 키 교체, 점수 제출 시 버전 검사, 버전별 고정 fixture예요. 두 번째 generator가 생기기 전에는 작은 switch로 분기하면 충분하고, 별도의 factory나 범용 migration 체계를 먼저 만들 필요는 없어요.

생성 버전의 식별, 신구 공존 계약, 버전별 migration과 rollback은 모두 구현 전이에요. 지금 보장되는 결정성은 입력과 코드가 고정돼 있다는 조건 안에서의 것이고, 오래 유지되는 식별 계약과는 다른 것이에요. Game Center와 Play Games의 점수 분할 제약은 플랫폼이나 구성이 바뀌면 다시 확인해야 해요.