개발 노트 · 2026

Domynion

실시간 영토 확장 전략 게임증강 드래프트를 얹는 개인 프로젝트다. Python + PyQt6 로 만들었고, 일정 시간마다 판이 멈춰 카드 셋 중 하나를 고르는 계층이 이 게임의 고유한 층이다. openfront.io 에서 모티브를 얻어 제작했다.

아래는 만들면서 무엇이 어떻게 깨졌고 어떻게 알아냈는지의 기록이다. 기능 소개가 아니라 판단 기록이라 “이건 아직 안 쟀다” 도 그대로 적는다. 수치는 전부 실측이고, 각 노트 끝의 커밋에서 확인할 수 있다.

커밋
284
메운 구현 누락
128
테스트
1,275
통과
변이 누적
289
문서
11,251

위 다섯은 tools/site_stats.py 가 잰 값이다 — 2026-09-06 23:51 기준.

배경

노트를 읽기 전에 알면 되는 것.

이 장르는 규모가 크다. 판 하나에 지도 65만 타일 위에서 나라 72개 + 봇 400개가 동시에 움직이고, 전투·골드·동맹·핵·철도가 전부 맞물려 돈다. 규칙 하나를 바꾸면 다른 다섯이 따라 움직이는 구조다.

그래서 이 프로젝트의 어려움은 “어떻게 만드나” 가 아니라 “빠뜨린 것을 어떻게 알아내나” 다. 빠뜨린 규칙은 오류를 내지 않는다. 게임은 멀쩡히 돌아가고, 테스트도 통과하고, 그냥 의도한 것과 다른 게임이 되어 있을 뿐이다. 아래 노트 대부분이 그 이야기다.

tick
게임 시간의 최소 단위. 1초 = 10 tick 이다. 판 하나가 보통 2만 tick(약 35분) 쯤 간다.
구현 누락
참고 구현에는 있는데 우리에게 없는 규칙. 오류를 안 내는 게 특징이라 찾아내는 것 자체가 일이다. 지금까지 128개 를 메웠다.
변이 테스트
규칙을 지키는 코드 줄을 일부러 깨뜨려 테스트가 빨개지는지 재는 것. 빨개지면 잡힘, 그대로 통과하면 살아남음 — 후자는 테스트가 아무것도 안 재고 있었다는 뜻이다.
기준선
고정된 난수 씨앗으로 판을 끝까지 돌려 핵 발사 수·생존자·골드를 표로 뜬 것. 규칙을 건드리면 다시 떠서 조용한 변화를 잡는다.

반복해서 나오는 문장이 하나 있다 — “통과는 증거가 아니다.” 아무것도 검증하지 않는 테스트도 통과하기 때문이다. 아래 일곱 편 중 다섯 편이 테스트가 전부 초록인 상태에서 발견된 것이다.

노트

일곱 편. 기능이 아니라 서로 다른 교훈으로 골랐다.

실험 설계

켠 쪽이 더 나빴다 — 다른 판을 재고 있었다

증강 드래프트를 다 만들고 나서 물어야 할 것은 하나였다 — 정말 판이 달라지는가. 같은 난수 씨앗으로 켜고/끄고 돌려 비교했다. 그런데 결과가 상식과 정반대로 나왔다.

기대실제
seed 11 · 증강을 켜면 더 커진다
46,59420,983 (절반)
계수가 세거나 약해서 난 차이다
같은 seed 인데 다른 판이었다
테스트가 잡아 줄 것이다
테스트 29개도 전체 스위트도 전부 초록이었다
고친 뒤
생존판 영토 중앙 36,732108,162 (2.9배)

어떻게 알아냈나

  1. 증강은 전부 유리한 계수다(병력 상한 +18%, 정복 비용 −14% …). 그런데 켠 쪽이 절반이다 — 계수 조정으로 설명되는 방향이 아니다.
  2. 그래서 값이 아니라 비교 자체가 성립하는지를 의심했다. 같은 씨앗을 넣었으니 두 판은 같아야 한다.
  3. 드래프트가 카드를 뽑을 때 판의 난수를 쓰고 있었다. 카드를 뽑느라 난수를 한 번 더 소비하면, 그 뒤로 스폰 위치도 전투 굴림도 AI 판단도 전부 어긋난다.
  4. 드래프트 전용 난수를 분리했다. ⚠ 켜든 끄든 만들어야 한다 — 조건부로 만들면 판을 만들 때 난수가 한 번 더 불려 고치려던 버그를 같은 자리에 다시 만든다.

검증은 테스트가 아니라 으로 했다. 첫 드래프트는 750 tick 에 열리므로 그 이전 구간은 양쪽이 같은 판이어야 한다.

[7 off] 400/900 tick  영토 4769
[7 on ] 400/900 tick  영토 4769      ← 정확히 같다

버그가 남아 있었다면 여기서 이미 갈렸다. 진행 로그가 자기 전제를 스스로 검증한다.

A/B 결과가 상식과 반대면 계수보다 난수 소비를 먼저 의심한다.
이 종류는 결과를 봐야만 나온다 — 드래프트 자체는 완벽하게 돌고 있었다.

변이 2 / 2 잡힘 A/B 24판 · 394초 17c3303 3e4e033
규칙 설계

안 끝나는 판 — 상한을 늘려서는 안 풀린다

기준선을 재는데 한 판이 22,000 tick 에서 안 끝났다. 상한을 25,000 으로 올렸더니 거기서도 안 끝났다.

그럴듯한 해법실제로 필요한 것
tick 상한을 더 올린다
안 풀린다. 두 번 연속 미종료가 그 증거다
승리 문턱에 하한을 20% 쯤 둔다
바닥이 없어야 한다 — 하한을 두면 그 아래에서 영원히 버틴다
기존 테스트는 그대로 통과할 것이다
하나가 깨졌다 — 시간 종료 승리가 도달 불가가 됐다
그대로 가져오면 된다
참고 구현은 기본 꺼짐이고 로비에서만 켠다. 싱글이라 로비가 없어 켤지 말지가 곧 판 규칙이었다

어떻게 알아냈나

  1. 미종료 판의 생존자 수가 2로 고정돼 있었다. 판이 느린 게 아니라 끝날 수 없는 상태다 — 둘이 남아 서로를 못 이긴다.
  2. 그러면 상한을 올리는 것은 답이 아니다. 승리 조건 쪽을 봐야 한다.
  3. 참고 구현에 이미 있었다. 시작 시각을 넘기면 지배 승리에 필요한 점유율이 분당 2%p 씩 내려가고, 바닥이 없다.
지배 승리에 필요한 점유율 · 경과 시간에 따라
0~30분80%
40분60%
50분40%
60분20%
70분0%
0%25% 50%75% 100%

바닥이 없는 것이 핵심이다. 하한을 20% 쯤 두면 그럴듯해 보이지만, 남은 둘이 각자 15% 씩 갖고 있으면 그 판은 여전히 안 끝난다. 0 에 닿는 순간 남은 사람 중 영토가 가장 넓은 쪽이 이기므로 판 길이가 70분으로 못 박힌다.

딸려서 승리 조건 하나가 사라졌다. 문턱이 70분에 0 이면 170분 하드 리밋에 닿을 수가 없다. 테스트가 틀린 게 아니라 규칙이 바뀐 결과였고, 참고 구현도 이 기능을 켜면 마찬가지다. 경로를 지우지 않고 테스트를 둘로 갈랐다 — 하나는 껐을 때 그 경로가 아직 도는지 재고, 하나는 켠 채 도달 불가임을 못 박는다.

교착을 푸는 장치에 하한을 두면 그 장치가 아니게 된다.
하한 아래에서 버티는 판이 정확히 우리가 풀려던 판이다.

변이 6 / 6 잡힘 측정 미완 — 45,000 tick 중 14,000 에서 중단 24b6683
문서 관리

안 여는 문서만 썩었다

문서가 둘이다. 대조 문서(참고 구현과 우리 코드를 나란히 놓은 것, 8,700줄)와 설계 문서(우리가 얹는 계층, 460줄). 둘 다 같은 규칙을 지키기로 돼 있었는데 한쪽만 썩었다.

설계 문서가 적어 둔 것실제 코드
상한 = 250 + 영토 × 22
2 × (타일^0.6 × 1000 + 50000) + Σ도시레벨 × 250000
지형에 이 있다 (방어 1.20)
숲이 없다. 평지·구릉·산악 셋뿐
시간 종료 15분
170분, 그마저 도달 불가
도구 도움말: “안 주면 900초에 판을 자른다”
실제 10,200초
대조 문서 (전투 공식 상수 37개)
불일치 0

어떻게 알아냈나

  1. 증강 UI 를 만들다 설계 문서의 카드 표를 다시 읽었다. 표에 이미 삭제한 카드 둘이 그대로 있었다.
  2. 이상해서 절마다 코드와 대조했다. 규칙 절이 한 줄도 안 맞았다.
  3. 가장 나빴던 것은 “미해결” 로 표시된 항목이었다 — “할인 두 장이면 비용이 음수가 된다. 하한이 막고 있다.” 실측하니 두 카드는 축이 달라 합산 자체가 안 일어난다. 일어날 수 없는 계산을 미해결 문제로 적어 두고 있었다.
  4. 그리고 하한도 막고 있지 않았다. 하한은 축마다 걸리는데 코드는 을 쓴다 — 실측 배율 0.179 로 하한 아래다.
  5. 같은 잣대를 대조 문서에도 돌렸다. 불일치 0 이었다.

여기가 이 노트의 요점이다. 두 문서에 같은 규칙이 걸려 있었는데 한쪽만 썩었다. 차이는 규칙이 아니었다 — 대조 문서는 세션마다 절을 붙이며 계속 읽었고, 설계 문서는 방향이 바뀔 때 머리말만 붙이고 본문을 안 열었다.

그리고 설계 문서 마지막 절이 “다음에 할 일” 이었는데, 대조 문서에도 같은 절이 있었다. 계획이 두 자리에서 경쟁하고 있었다. 설계 문서 쪽이 가리키던 상수 넷은 하나도 존재하지 않았다.

문서를 지키는 것은 규칙이 아니라 다시 읽는 빈도다.
그래서 규칙 대신 구조를 바꿨다 — 공식을 지우고 “어디를 보면 되는지” 만 남겼다. 베껴 적은 수식이 썩은 원인이었다.

틀린 문장 스물 남짓 재개 지점이 두 자리에 있었다 94c99c3 a540edd
테스트 방법론

한 번도 발동한 적 없는 규칙

증강 카드는 계수다. 병력 상한 +18%, 정복 비용 −14% 같은 것들이 각자 에 실린다. 문서와 코드 주석이 둘 다 앞자리에 이 원칙을 적어 뒀다 — “같은 축에 여러 카드가 실리면 더한다.”

있다고 믿은 것실제
합산 규칙이 돌고 있다
한 번도 돈 적이 없다. 쌓이는 것은 레벨뿐
그 규칙을 재는 테스트가 있다
이름은 same_axis_cards_add 인데 재료는 한 장의 Lv2 였다
주석이 산수를 적어 뒀으니 맞겠지
0.486 이라 적혀 있고 실제는 0.306틀린 주석과 함께 통과하고 있었다
테스트를 더 쓰면 잡힌다
새로 쓴 테스트도 못 잡았다 — 재료를 손으로 만들어 그 코드를 아예 안 탔다

어떻게 알아냈나

  1. 다음에 카드를 늘린다면 어느 축에 놓을지 보려고 분포를 한 줄로 세었다. 카드 10장이 축 10개에 정확히 하나씩이었다.
  2. 그러면 합산 규칙은 발동할 조합 자체가 없다.
  3. 확인차 합산 코드를 덮어쓰기로 바꾸는 변이를 넣었다. 살아남았다 — 두 구현의 동작이 같기 때문이다.
  4. 축마다 카드가 하나뿐이니 누적값이 늘 0 이고, 0.0 + xx 는 같다.
  5. 합성 카드를 임시로 얹어 같은 축 두 장을 만들고 나서야 잡혔다.
values[field] = values.get(field, 0.0) + value_at(aug, level)
values[field] = value_at(aug, level)   ← 결과가 같다

규칙을 지울 수도 있었지만 카드를 늘리면 되살아날 규칙이다. 대신 깨어나는 순간을 알리는 테스트를 뒀다 — 열한 번째 카드를 이미 있는 축에 얹으면 그 테스트가 깨지고, 실패 메시지가 “이제 설계 문서의 할인 계산을 다시 재야 한다” 고 알려 준다.

같은 날 같은 모양을 하나 더 만났다. 할인 하한도 카드로는 절대 닿을 수 없는 값을 막고 있었다. 보호 장치가 있고, 그 장치를 재는 테스트도 있고, 그 상황이 게임에서 일어날 수 없었다.

“막지 않았으면 무엇이 일어났을 것인가” 를 단언해도 빠져나간다. 빠진 것은 다음 질문이다 — “그 일이 이 게임에서 실제로 일어날 수 있는가.”

변이 1 살아남음 → 재료 문제 고친 뒤 3 / 3 잡힘 22d1284
계측

“축이 있는가” 와 “축이 걸리는가” 는 다른 질문

예전에 카드 둘이 갈 곳을 잃은 적이 있다. 구현하면서 지형과 이동 규칙이 통째로 바뀌었는데 카드는 그대로 남아 있었다 — 항해술(바다 건너기)은 배가 물길만 이어지면 어디든 가서 곱할 자리가 없어졌고, 삼림 순찰대는 지형에 숲이 없어서 갈 곳이 없어졌다. 그래서 모든 계수 축이 코드에 등장하는지 재는 테스트를 뒀다.

이번에는 다른 질문을 던졌다 — 한 판에 몇 번 걸리는가.

한 판(8,258 tick)에서 계수가 곱해진 횟수 · 로그 눈금
강행군497,947
정예 병단342,374
산악병325,415
개척단155,573
징집령104,785
비옥한 땅8,519
교역로213
상륙전9
1100 1만100만

상륙전은 한 판에 9번 걸린다 — 정예 병단의 1/38,000 이다. 그 축은 배가 도착했는데 목적지가 이미 내 땅일 때만 걸리고, 걸려 봐야 퇴각 벌점의 일부를 깎는다. 막으려던 자리가 그 자리에 다시 생겼다. 그때는 축이 없어서 꽝이었고, 지금은 축이 거의 안 걸려서 꽝이다. 그리고 기존 테스트는 초록이다.

여기서 두 번 넘어졌다

  1. 계수 함수를 감쌀 때 인자 개수를 잘못 짚었다. 비슷한 이름의 다른 함수 시그니처를 썼다 — 즉시 오류라 금방 알았다.
  2. 사람이 없는 판으로 쟀다. 증강은 사람만 받는데 관전 모드로 돌렸다. 그러면 병력 상한 함수가 봇·AI 분기에서 먼저 반환해 계수 줄에 도달조차 안 한다. 그 축이 0회로 나왔고, 하마터면 “이 카드가 배선 안 됐다” 로 읽을 뻔했다.

둘 다 도구 머리말에 박아 뒀다. 다음에 다시 만들면 또 같은 데서 넘어진다.

축이 있는가와 축이 걸리는가는 다른 질문이다.
다만 횟수만으로 세기를 판정하면 안 된다 — 교역로는 213회지만 한 번에 큰 골드가 걸린다. 결론이 나오는 것은 9회짜리 하나뿐이다.

재료 오류 2회 도구로 굳힘 · tools/axis_hits.py 8f63dd3 2b205fd
대조 검증

병력을 10배로 보여주고 있었다

참고 구현에서 화면에 숫자를 찍는 함수를 열어 보니 이렇게 돼 있었다.

export function renderTroops(troops: number): string {
  return renderNumber(troops / 10);
}

참고 구현은 병력을 표시할 때 10으로 나눈다. 우리는 날것을 찍고 있었다 — 시작 병력 25,000 을 참고 구현은 2.5K 라고 쓴다. 화면의 모든 병력 숫자가 실제의 10배였다.

우리가 하던 것참고 구현
한 자리만 고치면 된다
병력이 뜨는 거의 모든 곳에 쓰인다 — 한 자리만 고치면 화면 안에서 축이 갈린다. 열여섯 자리를 한 번에 바꿨다
골드도 같이 나누면 되겠지
골드는 안 나눈다. 합치면 골드가 1/10 이 된다
“적당히 K 로 줄이기”
구간마다 자릿수가 다른 다 (1K~10K 소수 둘 · 10K~100K 하나 · 100K 이상 정수)
반올림하면 되겠지
내림이 먼저다. 반올림이면 9,99910.00K 가 되어 다음 구간 표기와 섞인다
난이도 impossible 시작 병력 25,000
참고 구현 31,250가장 어려운 난이도가 그 아래와 같았다

어떻게 알아냈나

  1. 렌더링 폴더는 대부분 셰이더라 규칙이 없다고 보고 넘긴 곳이다. 그런데 파일 하나의 머리말이 스스로 → CPU progress 라고 적어 뒀다.
  2. 그래서 같은 폴더 32개의 머리말만 다시 훑었고, 그중 하나가 number formatting 이었다.
  3. 구간 경계에서 틀리기 딱 좋은 표라 눈으로 옮기지 않고 참고 구현을 실제로 실행해 대조했다.
  4. 참고 구현 파일을 통째로 불러오면 클라이언트 트리 전체가 딸려 와 실행되지 않는다. 그래서 그 두 함수의 본문만 떼어 돌렸다 — 로직은 한 글자도 안 건드린다.
  5. 같은 자리에서 시작 병력 표를 대조하다 값 하나가 더 나왔다. ⚠ 테스트가 넷 중 하나만 재고 있었다.

표를 재는 테스트는 모든 칸을 재야 한다.
하나만 재면 나머지가 조용히 틀릴 수 있고, 실제로 그랬다. 넷을 다 재고 단조 증가까지 단언하도록 고쳤다 — 두 난이도가 같아지면 그 난이도는 뜻이 없다.

참고 구현 대조 54건 · 불일치 0 값 대조 도구가 현재 고장 — 백로그 34c330d
훑는 방법

“작다” 고 적어 둔 항목이 넷이었다

백로그에 이런 항목이 있었다 — “사일로 재장전 진행바. 값은 이미 있다. 그리기만 없다. 언제든(작다).” 시간이 남아서 집었는데 작지 않았다.

집을 때 생각한 것실제
막대 하나 그리면 끝난다
참고 구현의 막대 하나가 을 보여 준다 — 철거 예정 · 건설 진행 · 재장전
재장전만 없다
철거 예정이 아예 없었다. 건물을 철거하면 30초 동안 계속 돌아가는데 화면에 아무 표시가 없었다 — 눌렀는지조차 알 수 없다
그 파일만 보면 된다
같은 폴더 32개 머리말을 마저 읽었더니 사거리 패스 셋이 나왔고, 그게 건설 미리보기(677줄) 로 이어졌다 — 통째로 없던 기능이다
677줄을 다 구현해야 한다
아니다. 규칙은 반경 넷뿐이다 — 나머지는 “이미 있는 것을 어떻게 보여 주나” 라 백로그로 넘겼다

왜 반경이 규칙인가

  1. SAM — 사거리가 레벨을 탄다(Lv1 70 · Lv3 90). 업그레이드 미리보기에서 Lv1 반경을 그리면 덮는 범위를 과소평가한다.
  2. 공장역 사거리 110 안에 있어야 철도가 이어진다. 모르고 놓으면 왜 안 이어지는지 알 방법이 없고 골드는 이미 나갔다.
  3. 바깥 반경을 그린다. 안쪽(전멸)을 그리면 원자탄이 12 대 30 이라 피해를 2.5배 과소평가한다.

순수 함수 테스트만으로는 “안 그려진다” 를 못 잡는다

계산을 순수 함수로 옮기고 테스트도 통과했다. 그런데 그리는 쪽이 그 함수를 안 부르면 테스트는 전부 초록인 채 화면에는 아무것도 안 뜬다. 그래서 화면을 실제로 그려 막대 색 픽셀을 셌다.

존재가 아니라 개수를 센다. 처음엔 색이 있는지만 봤더니 “채우는 폭이 비율을 안 탄다” 변이가 살아남았다. 25% 와 75% 를 그려 픽셀 수를 견주도록 고치고 나서야 잡혔다.

폴더 이름으로 넘긴 곳은 파일 머리말로 한 번 더 훑는다.
그 파일은 자기가 CPU 계산이라고 직접 적어 뒀다. 그리고 “작다” 고 적어 둔 항목을 믿지 않는다 — 그 판단은 우리에게 무엇이 없는지 모르는 상태에서 내린 것이다.

변이 15 / 15 잡힘 재료 오류 3회 구현 누락 124~127 3efe0c6 b3630fc ff4e253

검증 장치

“되는지” 를 재는 방법들.

이 프로젝트에서 가장 많이 쓰는 문장은 “통과는 증거가 아니다” 다. 그래서 테스트 외에 네 가지를 더 쓴다. 위 노트 일곱 편 중 다섯 편이 이 장치들에서 나왔다.

장치무엇을 잡나
mutate.py · 변이 테스트
규칙을 지키는 줄을 일부러 깨뜨려 테스트가 빨개지는지 잰다. 살아남으면 테스트가 아무것도 안 재고 있었다는 뜻이다 — 그게 오늘만 네 번 나왔고, 네 번 다 테스트가 아니라 재료 문제였다
balance.py · 기준선
고정 씨앗으로 판을 끝까지 돌려 핵 발사·생존자·골드를 표로 뜬다. 규칙을 건드리면 다시 떠서 조용한 변화를 잡는다
augment_ab.py · A/B
같은 씨앗을 켜고/끄고 돌려 짝으로 비교한다. 죽은 판의 0 을 중앙값에 섞지 않는다
axis_hits.py · 축 계측
각 계수가 한 판에 몇 번 곱해지는지 센다 — 코드를 읽어서는 안 보이는 것
oracle.mts · 값 대조
참고 구현을 실제로 실행해 같은 입력에 같은 출력이 나오는지 본다. 현재 고장 — README 의 “185/185 일치” 는 그 뒤로 한 번도 안 돌려 본 값이다

지금 상태

끝난 것과 안 끝난 것.

규칙 구현은 사실상 끝났다. 참고 구현의 코드를 전부 훑었고, 남은 것은 백로그에 조건과 함께 적혀 있다 — “언젠가” 가 아니라 “무엇이 갖춰지면” 으로. 그 위에 얹는 증강 계층은 카드 10종까지 만들었고, 켜면 판이 달라진다는 것까지 측정으로 확인했다.

항목상태
규칙 구현
참고 구현을 전부 훑었다. 구현 누락 128개를 메웠다
증강 계층
카드 10종 · 드래프트 · 보유 표시. 켜면 생존판 영토 중앙이 2.9배
교착 방지(Overtime)
규칙·테스트는 끝. “안 끝나던 판이 실제로 끝나는가” 는 아직 못 쟀다 — 판당 2시간 이상이라 중단했다
기준선
없다. 교착 방지를 켜면서 옛 기준선이 무효가 됐고, 재측정이 미완이다
값 대조 도구
고장. Node 런타임이 올라가면서 참고 구현을 그대로 실행하지 못하게 됐다
상륙전 카드
한 판에 9번 걸린다 — 갈아끼울지 A/B 로 정할 차례

다른 저장소

노트는 아직 Domynion 만 있다.