턴제라서 Bulwark보다 근본적으로 쉽다. 클라이언트 예측도, 지연 보상도, 히트박스 되감기도 없다. 대신 결정론적 lockstep의 요구사항이 훨씬 엄격하다. 실시간 게임은 스냅샷으로 오차를 덮을 수 있지만, lockstep은 덮을 방법이 없다.
클라이언트 → 서버 : 이번 턴의 의도 (각도, 파워, 무기) ... 턴당 1회, ~12바이트
서버 → 클라이언트 : 활성 슬롯의 의도 + 시드 + 체크섬 ... 턴당 1회, ~24바이트
지형을 네트워크로 보내지 않는다 (절대 규칙 4). 서버와 모든 클라이언트가 같은 시드에서 같은 맵을 생성하고, 같은 입력으로 같은 결과에 도달한다.
턴당 트래픽이 100바이트 수준이라 룸 하나의 대역폭이 사실상 0이다.
성능 문제는 전부 결정론 문제로 치환됐다. 그래서 simulation.md §9의 테스트가 이 프로젝트의 실질적 안전망이다.
| 대안 | 검토 결과 |
|---|---|
| 결정론적 lockstep ✅ | 대역폭 최소. 리플레이가 공짜. 관전이 공짜. 대신 결정론이 깨지면 즉시 치명적 |
| 서버가 지형 diff 전송 | 큰 붕괴 한 번에 수만 셀이 바뀐다. RLE로 줄여도 턴당 수십 KB. lockstep 대비 이득이 전혀 없다 |
| 서버가 궤적 폴리라인만 전송 | 지형은 어차피 클라가 계산해야 한다. 절반만 lockstep이면 결정론 요구는 그대로인데 이득만 없다 |
| P2P lockstep (서버 없음) | 치팅 검증 불가. 한 명 끊기면 판이 멈춘다 |
서버도 동일 시뮬레이션을 돌린다. 서버는 lockstep의 참가자가 아니라 심판이다. 서버 결과가 진실이고, 클라이언트 체크섬이 다르면 클라이언트가 틀린 것이다.
매치 시작 시 서버는 matchInit 뒤 초기 fullState 한 번을 보내고 첫 turnBegin을 연다.
Canvas 클라이언트가 맵 생성 구현이나 로딩 타이밍과 무관하게 동일한 기준 격자에서 시작하게 하는
부트스트랩 경로다. 이후 정상 턴에서는 격자를 보내지 않고 intent와 결과 체크섬만 교환한다.
┌─────────────────────────────────────────┐
│ TURN_BEGIN │
│ 서버 → 전원 : turnNo, activeSlot, │
│ deadline, wind │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ AIM (20s) │
│ 활성 클라 → 서버 : intent │
│ 다른 클라는 관전만 한다 │
└──────────────────┬──────────────────────┘
↓ 확정 or 타임아웃
┌─────────────────────────────────────────┐
│ TURN_RESOLVE │
│ 서버 → 전원 : activeSlot, intent, │
│ turnSeed │
│ 서버: 한 발의 전체 해결 계산 │
│ 클라: 같은 계산을 60fps로 재생 │
└──────────────────┬──────────────────────┘
↓ 서버 계산 완료
┌─────────────────────────────────────────┐
│ TURN_RESULT │
│ 서버 → 전원 : 체크섬, HP, 사망, 골드 │
│ 클라: 자기 계산 결과와 대조 │
└─────────────────────────────────────────┘
서버는 TURN_RESOLVE를 보낸 직후 계산을 끝내고 TURN_RESULT까지 곧바로 보낸다.
클라이언트는 애니메이션을 재생하는 동안 이미 결과를 손에 쥐고 있다.
다음 턴 시작만 클라이언트 재생이 끝날 때까지 기다린다.
next_turn_delay = max(모든 클라의 예상 재생 시간, 서버 최소 대기)
단, 상한 8초. 넘으면 그냥 진행한다.
느린 클라이언트 하나가 판 전체를 잡아두면 안 된다. 재생을 못 따라온 클라는 결과 상태로 스냅한다.
AIM 단계에서는 활성 플레이어의 확정 전 각도·파워를 중계하지 않는다. 초안은 매 프레임 바뀌는
표현 상태이고 lockstep 입력이 아니기 때문이다. 확정 또는 타임아웃 뒤 TURN_RESOLVE에서 intent를
전원에게 공개하며, 다른 플레이어는 그 한 발의 비행·착탄·정착을 본 뒤 자기 턴을 시작한다.
| 상황 | 처리 |
|---|---|
| 체크섬 불일치 | 클라가 RESYNC_REQ → 전체 상태 수신 |
| 재접속 | 접속 시 전체 상태 수신 |
| 관전자 입장 | 전체 상태 수신 |
mapSeed, turnNo, activeSlot, phase, 전원 상태(위치·HP·인벤토리·골드), wind
+ 지형 격자 (gzip 압축)
격자는 압축 전 518,400 바이트이고 값이 6종(EMPTY 포함)뿐이며 공간 상관이 극단적으로 높다.
어떤 범용 압축을 써도 잘 줄어든다. 리싱크가 빈번하지 않은 한 문제되지 않는다.
| 항목 | 값 |
|---|---|
| 논리 형식 | MaterialGrid 바이트 배열. idx = y*960 + x 오름차순 (terrain.md §1) |
| 압축 전 길이 | 정확히 518,400 바이트. 상수다 |
| 값 범위 | 0~5 (terrain.md §2 의 재질 enum). 그 밖의 값은 프로토콜 위반 |
| 와이어 타입 | msgpack bin |
| 부분 전송 | 없다. 격자는 항상 전체를 보낸다 |
수신 측은 길이가 518,400 인지, 모든 값이 0~5 인지 검사한 뒤 적용한다. 길이가 상수라서 검사가 공짜이고, 여기서 거르지 않으면 잘린 격자를 그대로 채택해 그 다음 턴부터 매 턴 체크섬 불일치로 증상이 나타난다 — 원인 추적이 가장 어려운 형태다.
격자를 적용한 뒤 활성 행 마스크는 비운 상태로 시작한다. terrain.md §5.1·§5.2 대로
마스크와 스텝 카운터는 턴을 넘기지 않으므로 페이로드에 넣지 않는다.
격자에서 마스크를 재계산해서도 안 된다 — 서버와 갈라진다.
uAge 격자를 함께 보낼지는 decisions.md B8. 격자는 gzip으로 압축한다. 브라우저의 표준
DecompressionStream("gzip")을 바로 쓸 수 있고, 리싱크는 정상 턴 경로가 아니므로 zstd 대비 압축률
차이보다 무의존성이 중요하다.
조용히 복구만 하고 넘어가지 않는다.
텔레메트리로 반드시 전송 : mapSeed, turnNo, activeSlot, intent, 클라 체크섬, 서버 체크섬,
클라 빌드 해시, 브라우저/OS
이 로그만 있으면 로컬에서 정확히 재현할 수 있다. 재현 가능한 리플레이가 결정론 버그의 유일한 해결 수단이다.
RESYNC 발생률을 대시보드 최상단 지표로 둔다. 이 숫자가 0이 아니면 다른 작업보다 우선한다.
msgpack. 모든 메시지는 {t: <type>, ...} 형태.
필드마다 타입·폭·단위를 적는다. 이게 없으면 Python 이 int, TS 가 number 로 보내고
어느 쪽이 어디서 잘랐는지 아무도 모르는 상태가 된다. lockstep 에서 그건 곧 갈라짐이다.
| 규약 | 내용 | 근거 |
|---|---|---|
| 부동소수점 금지 | 와이어에도 float 를 싣지 않는다. 모든 수치는 정수 |
절대 규칙 2, simulation.md §2.1 |
| 위치·속도 | subpx 정수 (1 px = 16 subpx) |
simulation.md §2.1 |
| 각도 | 데시도 정수 0~1800 (각도 × 10) | simulation.md §3 |
| 비율·확률 | Q8 정수 0~256 | terrain.md §4.1 |
| 시각·기간 | ms 정수 | — |
| 시드·체크섬 | uint32 |
terrain.md §7.1 의 hash32 정의역, §7.2 |
| 격자 | msgpack bin. 재질 enum 0~5 의 바이트 배열 |
terrain.md §1·§2 |
배열은 슬롯 번호 오름차순으로 정렬해 보낸다. players[], scores[]가 해당한다.
한 턴의 intent는 activeSlot 한 명의 값이므로 배열이 아니다. 슬롯 번호 자체의 정의·할당 주체는
decisions.md B10이다.
버전 필드는 셋이고 수명주기가 다르다.
| 필드 | 무엇 | 누가 계산하나 | 언제 바뀌나 |
|---|---|---|---|
protocolVersion |
와이어 형식 신원. constants.PROTOCOL_VERSION (현재 3) |
서버 | 메시지 구조가 바뀔 때. 손으로 올린다 |
ruleHash |
규칙 지문. sim/rules 의 정수 수열 FNV-1a |
양쪽이 각자 | 규칙 값이 바뀔 때. 자동 |
simVersion |
서버 상수 전체의 SHA-256. constants.SIM_VERSION |
서버만 | 상수가 하나라도 바뀔 때. 자동 |
핸드셰이크가 검사하는 것은 ruleHash 다.
⚠
simVersion으로 검사하면 안 된다 — 한동안 그렇게 하다 구멍이 났다.SIM_VERSION은constants.py전체의 해시라 Python 만 계산할 수 있다. 그래서 클라이언트는GET /version으로 받아 접속할 때 되돌려 보냈고, 서버는 그걸 자기 값과 비교했다 — 동어반복이라 원리적으로 불일치가 나지 않았고, 규칙이 다른 두 빌드가 같은 방에 들어갈 수 있었다. desync 가 나기 전까지 아무도 모른다.
ruleHash는 다르다. 양쪽이 각자의 규칙 표에서 계산하므로 값이 갈라져 있으면 지문이 갈라진다.client/src/sim/rules.ts와server/src/neodeol/sim/rules.py가 같은 정수 수열을 만들고, 그 일치는test_rule_hash_matches_typescript가 지킨다.
simVersion 은 남겨 둔다 — 서버 빌드 식별과 /version 표시에 쓰고, constants.py 를
만졌는데 지문이 안 바뀌는 경우(표현 상수만 바뀜)를 구분할 수 있다.
메시지 구조만 바뀐 릴리스에서 ruleHash 가 흔들려선 안 되고, 규칙만 바뀐 릴리스에서
protocolVersion 이 그대로여야 한다. _EXCLUDED_FROM_HASH 가 PROTOCOL_VERSION 을
SIM_VERSION 해시에서 빼는 것이 같은 분리다.
지문에 무엇이 들어가는가. 시뮬레이션 결과를 바꾸는 값 전부 — 무기 수치, 지질
프로파일, 매치 규칙, 탄도 상수, 자동자 확률·저항. 빠지는 것은 무기 이름·설명 같은
표현 텍스트와 SUBSTEPS 같은 표현 상수다. 이름을 번역했다고 같은 방에 못 들어가면 곤란하다.
WebSocket 경로는 /ws/rooms/{roomCode}다. 업그레이드 쿼리에 token, protocolVersion,
ruleHash, buildHash를 보낸다. 개발 로비의 token은 256비트 불투명 문자열이며 매치 시작 뒤에도
같은 슬롯으로 재접속하는 키다.
t |
필드 | 타입·폭 | 단위·범위 | 설명 |
|---|---|---|---|---|
intent |
turnNo |
uint32 | — | 현재 턴이어야 한다(§5.3) |
activeSlot |
uint8 | 0~5 | turnBegin.activeSlot과 같아야 하며, 송신자의 슬롯이어야 한다 |
|
angle10 |
uint16 | 차체 기준 데시도 0~1800 | 월드 발사각은 angle10 - tankTilt10. 상한은 trig.bin 배열 경계 |
|
power |
uint16 | 0~1500 | v0 = (power * POWER_SCALE) >> 10 의 정의역 |
|
weaponId |
uint8 | — | 무기 테이블 인덱스. 테이블 스키마는 decisions.md C1 |
|
moveDx |
int16 | cell | 연료 이동 의도. docs/match.md §5.1 |
|
useShield |
bool | — | 이번 턴 차폐막 사용 | |
playbackDone |
turnNo |
uint32 | — | 해당 턴의 로컬 재생 완료 |
checksum |
uint32 | — | 로컬 최종 격자 체크섬. 불일치면 desync + fullState |
|
buy |
roundNo |
uint8 | — | 현재 상점 라운드 |
kind |
str enum | weapon/item |
한 요청에 하나만 구매 | |
weaponId |
uint8 | — | kind=weapon일 때 |
|
itemKey |
str enum | shield/parachute/fuel/anemo | kind=item일 때 |
|
shopReady |
roundNo |
uint8 | — | 구매 완료. 전원 ready 또는 30초 뒤 다음 라운드 |
resyncReq |
turnNo, myChecksum |
uint32 | — | 복구 요청. 좌석당 턴 상한 RESYNC_PER_TURN(2) |
myChecksum |
uint32 | — | 격자 체크섬 (terrain.md §7.2) |
|
pong |
t0 |
uint64 | ms | ping(§5.2)이 보낸 값을 그대로 반사한다 |
join에simVersion이 없던 것은 표의 누락이다. §7.3 이 이미 "join 시 클라가 simVersion 전송"을 규정하고, 서버는GET /version으로 같은 값을 노출한다(docs/development.md§5). 불일치 시 반환할error.code표는decisions.mdB10turnNo는 매치 전역으로 증가하고 라운드가 바뀌어도 리셋하지 않는다 (docs/match.md§1.3)
t |
필드 | 타입·폭 | 단위·범위 | 설명 |
|---|---|---|---|---|
hello |
protocolVersion/simVersion |
uint16/str | — | WebSocket 핸드셰이크 승인과 규칙 신원 |
roomCode/mySlot/status |
str/uint8/str | — | 재접속한 룸·슬롯·현재 페이즈 | |
roomState |
roomCode/status/maxPlayers |
str/str/uint8 | — | 로비와 연결 상태 스냅샷 |
players[]/telemetry |
배열/map | — | 간소 플레이어 목록과 턴·리싱크 카운트 | |
matchInit |
mapSeed |
uint32 | — | terrain.md §7.1 hash32 의 seed 인자와 같은 폭이어야 한다 |
mySlot |
uint8 | 0~5 | 수신자 자신의 슬롯. 슬롯 정의·할당은 decisions.md B10 |
|
players[] |
배열 | 슬롯 오름차순 | 스키마는 decisions.md B10 |
|
rules |
map | — | 라운드 수·인원·모드. decisions.md B10 |
|
turnBegin |
turnNo |
uint32 | — | 매치 전역 증가. 라운드에서 리셋하지 않음 |
activeSlot |
uint8 | 0~5 | 이번 턴에 intent를 낼 수 있는 유일한 슬롯 | |
deadlineMs |
uint64 | Unix epoch ms | 라운드 첫 발 30초, 이후 20초 | |
wind |
int16 | subpx/tick² | 직전 바람과 mapSeed, turnNo에서 파생한 이번 턴 권위값. 평상시 변화 1, 돌풍 최대 3 |
|
turnResolve |
turnNo |
uint32 | — | |
activeSlot |
uint8 | 0~5 | turnBegin과 같아야 한다 |
|
turnSeed |
uint32 | — | = hash32(mapSeed, turnNo, 0, 0). 파생값이므로 표시·검증용 참고값이다 |
|
intent |
map | — | §5.1 intent의 5개 게임 입력 필드. 활성 슬롯 한 명의 값 |
|
turnResult |
turnNo |
uint32 | — | |
checksum |
uint32 | — | 격자만. terrain.md §7.2 |
|
players[] |
배열 | 슬롯 오름차순 | 스키마는 decisions.md B10 |
|
events[] |
배열 | — | 권위 표현·진단 이벤트. t와 해당 정수 필드로 구성 |
|
phase |
str enum | aim/shop/done | 권위 매치 페이즈 | |
nextActiveSlot |
uint8/null | 0~5 | 다음 턴 슬롯. 라운드 종료면 null | |
roundEnd |
roundNo |
uint8 | — | |
scores[] |
배열 | 슬롯 오름차순 | 점수 공식은 decisions.md B7 |
|
shopOpenMs |
uint32 | ms | 상점 30초(game-design.md §7) → 30000 |
|
roundStart |
roundNo/wind |
uint8/int16 | —/subpx/tick² | 새 라운드 권위 상태 |
spawnCells[]/players[] |
배열/배열 | cell/슬롯 오름차순 | 재스폰 위치와 전체 플레이어 상태 | |
matchEnd |
finalScores[] |
배열 | 슬롯 오름차순 | |
buyResult |
ok |
bool | — | 구매 성공 여부 |
player |
map | — | 요청 슬롯의 전체 권위 상태와 인벤토리 | |
fullState |
state |
map | — | match.md §1.3 전체 상태 |
checksum |
uint32 | — | 압축 전 격자 체크섬 | |
gridGzip |
bin | gzip | 압축 해제 후 정확히 518,400바이트 | |
desync |
turnNo |
uint32 | — | 불일치 턴 |
clientChecksum/serverChecksum |
uint32 | — | 텔레메트리와 복구 진입용 | |
ping |
t0 |
uint64 | ms | 10초 간격(§6). 클라는 pong 으로 그대로 반사한다 |
error |
code |
uint16 | — | 코드 표는 decisions.md B10 |
msg |
str | — | 사람이 읽는 설명. 클라가 이 문자열로 분기하지 않는다 |
ping 이 표에 없던 것은 누락이다. §6 이 10초 간격 ping/pong 을 규정하고 §5.1 에 pong 이 있다.
players[]는 slot,name,x,y,hp,alive,buried,angle10,power,gold,weaponId,ammo[],items, score,kills,damageDone,shieldUp,connected를 가진다. items는
shield,parachute,fuel,anemo 정수 맵이다. 로비 roomState.players[]는 이 중
slot,name,connected,host만 보낸다.
슬롯은 서버가 로비 입장 순서대로 가장 낮은 빈 번호를 배정한다. 매치 시작 뒤 슬롯을 재사용하거나 압축하지 않는다. 연결이 끊겨도 token으로 같은 슬롯에 복귀한다.
| 메서드·경로 | 요청 | 응답·역할 |
|---|---|---|
POST /api/rooms |
name,maxPlayers |
룸 생성. roomCode,token,slot,protocolVersion,simVersion,wsPath |
POST /api/rooms/{code}/join |
name |
가장 낮은 빈 슬롯 참가와 재접속 token 발급 |
GET /api/rooms/{code} |
— | 현재 roomState 조회. 개발·진단용 |
POST /api/rooms/{code}/start |
token |
호스트 시작. WebSocket start와 같은 개발 편의 경로 |
| code | 이름 | 의미 |
|---|---|---|
| 1000 | BAD_MESSAGE | msgpack/필드 형식 오류 |
| 1001 | ROOM_NOT_FOUND | 룸 코드 없음 |
| 1002 | ROOM_FULL | 정원 초과 |
| 1003 | TOKEN_INVALID | 재접속 token 불일치 |
| 1004 | VERSION_MISMATCH | protocol/sim 버전 불일치 |
| 1005 | NOT_HOST | 호스트 전용 요청 |
| 1006 | ROOM_STARTED | 시작 뒤 로비 변경 요청 |
| 1007 | NOT_ACTIVE | 활성 슬롯이 아닌 intent |
| 1008 | TURN_MISMATCH | turnNo가 현재 턴과 다름 |
| 1009 | BAD_PHASE | 현재 페이즈에서 허용되지 않는 요청 |
| 1010 | PURCHASE_REJECTED | 골드·대상·탄약 규칙으로 구매 거절 |
| 1011 | INTERNAL | 서버 내부 오류. 상세 예외는 클라에 노출하지 않음 |
지형 격자만이다. terrain.md §7.2 가 확정했으므로 여기서는 참조만 한다.
| 항목 | 값 |
|---|---|
| 알고리즘 | FNV-1a 32bit |
| 입력 | MaterialGrid 바이트 518,400개, idx = y*960 + x 오름차순 |
| 폭 | uint32 |
| 계산 시점 | 최종 정착 완료 직후. 정착 후 연결성 검사가 ROCK→SCREE 변환을 만들어 정착이 재개되면(terrain.md §6.1) 그 마지막 정착 뒤에 계산한다 |
| 제외 | 활성 행 마스크, 자동자 스텝 카운터 — terrain.md §5.2 대로 턴을 넘기지 않으므로 상태가 아니다 |
| 제외 | 탱크 위치·HP·골드·인벤토리 |
플레이어 상태는 체크섬에 섞지 않고 turnResult.players[] 로 직접 대조한다.
근거: 지형이 갈라진 것과 피해 정산이 갈라진 것은 복구 비용이 전혀 다른 두 문제다.
한 값에 섞으면 불일치가 났을 때 519 KB 격자를 다시 받는 것 말고 할 수 있는 일이 없어진다.
대조가 실패했을 때의 처리는 decisions.md B10.
resyncReq.myChecksum 도 같은 값이며, 같은 시점에 계산한 것이어야 한다.
resyncReq 에는 예산이 있다. 좌석 하나가 한 턴에 받을 수 있는 fullState 는
RESYNC_PER_TURN(현재 2)회이고, 넘으면 BAD_PHASE 로 거부한다.
절대 규칙 4 는 전체 지형 전송을 접속·재접속·체크섬 불일치로 한정한다. 그런데 이 핸들러는 한동안 횟수·턴·페이즈 제한이 전혀 없었고
myChecksum을 대조조차 하지 않아서, 어떤 좌석이든 루프로 보내면 서버가 518,400 바이트(gzip 전)를 계속 뿜었다. "정상 경로가 아니다" 가 문서에만 있고 코드로 강제되지 않았다.진짜 복구는 한 번이면 되고, 두 번째부터는 클라가 복구를 못 하고 있다는 뜻이라 더 보내도 같은 결과다. 재접속하면 예산이 초기화된다.
RESYNC_PER_TURN 은 와이어 정책이라 규칙 지문(§5)에 들어가지 않는다 — 값이 달라도
두 클라이언트는 같은 게임을 계산한다. SIM_VERSION 해시에서도 제외한다.
서버는 intent를 반드시 검증한다.
turnNo가 현재 턴인가
angle10 ∈ [0, 1800]
power ∈ [0, 1500]
weaponId를 실제로 보유하고 있는가, 탄약이 남았는가
이미 이번 턴에 확정하지 않았는가
플레이어가 살아있는가
검증 실패 시 필드별로 직전 턴 값을 사용한다. 무효/소진 무기는 직전 무기, 그것도 소진이면 표준탄으로 폴백한다. 첫 턴은 탱크 기본 각도·파워·표준탄을 쓴다. 연결을 끊지 않는다. 근거: 버그로 잘못된 값이 나갈 수도 있는데 그때마다 판에서 튕기면 사용자 경험이 최악이다. 단, 검증 실패율은 텔레메트리로 수집해 치팅 시도를 탐지한다.
| 항목 | 처리 |
|---|---|
| 조준 중 끊김 | 직전 턴 값으로 자동 발사. 자리 유지 |
| 60초 이상 끊김 | Phase 4 기반에서는 직전 값 자동 발사를 유지. AI 인계는 Phase 6 |
| 재접속 | fullState 수신 후 즉시 복귀 |
| 하트비트 | 10초 간격 ping/pong |
| 룸 유휴 | 전원 이탈 후 60초에 파기 |
끊긴 사람을 즉시 제거하지 않는다. 6인전에서 한 명이 사라지면 지형 서사가 무너지고 밸런스가 깨진다. AI 인계가 훨씬 낫다.
Bulwark의 인프라 문서를 그대로 재사용한다. 틱 루프 관련 부분만 걷어내면 된다.
| 항목 | 구성 |
|---|---|
| 룸 서버 | EKS StatefulSet + 룸 어피니티 라우팅 |
| 로비 | stateless Deployment. 매치메이킹 후 JWT 발급 |
| 룸 상태 | Redis |
| 전적/상점 | PostgreSQL |
| 드레이닝 | 신규 룸 배정 중단 → 진행 중 매치 종료 대기 → 파드 종료 |
- 30Hz 스냅샷 브로드캐스트가 없다. 룸당 CPU와 대역폭이 한 자릿수 퍼센트 수준
- 파드당 수용 룸 수가 훨씬 많다
- 지연 시간 요구가 낮아 리전 배치가 자유롭다
- 서버가 턴당 시뮬레이션을 한 번에 계산한다. 대형 붕괴가 있는 턴은 수백 ms의 CPU 스파이크가 생긴다
- 이걸 이벤트 루프에서 그냥 돌리면 같은 파드의 다른 룸이 전부 멈춘다
턴 해결 계산은 반드시 별도 프로세스 풀에서 돌린다.
ProcessPoolExecutor또는 전용 워커.sim/이 순수 함수이므로 프로세스 경계를 넘기기 쉽다 — 입력은(state, intent, seed), 출력은(new_state, events)뿐이다. 순수성 제약이 여기서 실질적인 이득으로 돌아온다.
sim/ 코드가 바뀌면 결정론이 바뀔 수 있다. 서버와 클라이언트의 시뮬레이션 버전을 명시적으로 맞춘다.
join 시 클라가 simVersion 전송 → 서버와 불일치하면 새로고침 유도
simVersion 은 상수 집합과 RULES_VERSION의 해시다. server/src/neodeol/constants.py 의
SIM_VERSION — 해시 대상 상수 전체를 정렬 JSON 으로 직렬화한 SHA-256 의 앞 16자리다
(docs/development.md §5). 상수값이 그대로인 절차·알고리즘 변경은 RULES_VERSION을 올린다.
이전 판의 정의("client/src/sim/ 전체 + tables/trig.bin 의 해시")는 성립할 수 없다.
핸드셰이크는 두 값이 같은지를 보는데, TypeScript 소스의 해시와 Python 소스의 해시가
같아질 방법이 없다. 서버는 client/src/sim/ 을 해시할 수도 없다. 대조 대상은
양쪽이 같은 문서 표를 구현하면 같은 값이 나오는 것, 즉 상수 집합의 해시여야 한다.
양쪽이 같은 값을 내려면 정규화 규칙도 같아야 한다. constants.py 의 구현이 기준이다.
| 단계 | 규칙 |
|---|---|
| 대상 | 대문자 상수 전체에서 SIM_VERSION·PROTOCOL_VERSION·PROVISIONAL·TRIG_TABLE_PATH 를 뺀다 (화이트리스트가 아니라 블랙리스트 — 상수를 추가했는데 해시가 안 바뀌는 사고를 막는다) |
| dict 값 | 키를 문자열로 바꾸고 키 오름차순 |
| 직렬화 | JSON, 키 정렬, 구분자에 공백 없음 ("," / ":") |
| 해시 | SHA-256, 앞 16자 소문자 hex |
SUBSTEPS 는 대상에 없다 — terrain.md §3.4 대로 표현 상수이므로, 값이 다른 두 클라이언트도
같은 방에 들어갈 수 있어야 한다.
빌드 신원은 buildHash 라는 별도 필드로 분리하고 대조하지 않는다.
§4.3 의 텔레메트리 항목이 이미 "클라 빌드 해시"를 따로 요구한다. 이걸 대조에 쓰면
클라 릴리스마다 접속이 전부 거부된다.
접속 전에 GET /version 으로 서버 값을 미리 볼 수 있다(docs/development.md §5).
RULES_VERSION갱신을 잊는 사고는 교차 골든 CI가 잡는다. 런타임 게이트와 실증 게이트를 둘 다 유지한다 (decisions.mdB9).
- 재생 대기 상한 8초의 적정성 (§3.1)
- AI 인계 시점 60초의 적정성
- 관전 모드에서 활성 플레이어의 확정 전 조준 초안을 보여줄지
- 리플레이 저장 —
mapSeed + 시간순 intent만 저장하면 되므로 매우 저렴하다. 어느 단계에서 넣을지 - 랭크 매치에서
simVersion불일치 시 매치 무효 처리 여부 - 상수는 같고
sim/코드만 다른 빌드를 무엇이 잡는가 (§7.3) — 지문·SIM_VERSION은 값만 해시하므로 절차만 바뀐 빌드는 못 잡는다. 그건 원래 지문의 일이 아니고 골든 리플레이가 잡는다 (CLAUDE.md§결정론 게이트). 상수가 그대로인sim/변경은RULES_VERSION을 손으로 올리고, 그걸 잊었을 때의 두 번째 그물이 골든이다 — 지금 25개(자동자 20 · 장기 2 · 발사 · 맵생성 · 매치)가 스텝·턴 두 단위로 대조한다 - 한 턴 재생 길이의 상한과 §3.1 의 8초 — 비행 상한 30초(
MAX_FLIGHT_TICKS = 1800)에 단발의 다중 자탄 정착 시간까지 더하면 8초 상한으로 담기지 않는 경우가 있다 - "직전 턴 값"이 없을 때의 폴백 (§5.3, §6) —
normalizeIntent가 답한다. 각도·파워는 플레이어에 남아 있는 현재 값으로 떨어지고 (makePlayer초기값은 슬롯 짝수 45.0° · 홀수 135.0° · 파워 600), 무기는 보유 탄약이 없으면 표준탄(0)으로, 이동은 0, 차폐막은 미사용으로 떨어진다. 라운드 첫 턴·부활 직후에도 값이 항상 존재한다