server/src/neodeol/sim/과 client/src/sim/이 동일하게 구현해야 하는 명세.
한쪽만 고치면 두 플레이어가 서로 다른 착탄점을 본다.
지형 자동자는 분량이 커서 docs/terrain.md로 분리했다. 이 문서는 좌표계, 탄도, 폭발, 턴 진행을 다룬다.
step(state, inputs) -> new_state
asyncio, 소켓, 파일,time.time(),random.random()금지- 난수는 상태에 담긴 시드로 순수 해시를 돌린다 (
terrain.md§7.1) - 로깅도 하지 않는다.
state.events에 쌓아서 상위 레이어가 처리한다
이 제약 덕분에 시뮬레이션이 전부 테스트 가능하고, 리플레이가 가능하고, 나중에 통째로 다른 언어로 갈아끼울 수 있다.
모든 위치와 속도는 정수다. float 금지.
1 px = 16 subpx
1 cell = 2 px = 32 subpx (지형 격자 단위)
맵 = 1920 × 1080 px = 30720 × 17280 subpx
탱크 폭 = 24 px = 384 subpx
이 게임은 lockstep이라 결정론이 선택이 아니라 전제다. float를 쓰면 Python과 JS의 미세한 오차가 누적돼 착탄점이 몇 픽셀씩 어긋나고, 그게 명중과 빗나감을 가른다.
나눗셈은 2의 거듭제곱 제수와 산술 우측 시프트(
>>)로만 한다.
| 언어 | -7 // 2 |
-7 >> 1 |
|---|---|---|
| Python | -4 (floor) |
-4 |
JS (-7 / 2) | 0 |
-3 (trunc) |
-4 |
numpy int32 |
-4 (floor) |
-4 |
일반 나눗셈은 언어마다 음수 처리가 다르다. >>는 세 곳 전부 floor로 일치한다.
그래서 모든 스케일 상수를 2의 거듭제곱으로 잡았다.
JS에서 |0, Math.trunc(), / 사용 금지. >>와 Math.imul()만 쓴다.
위 규칙은 부호 있는 좌표·속도에만 적용된다. 해시와 체크섬은 다르다.
| 용도 | JS | Python / numpy | 왜 |
|---|---|---|---|
| 좌표·속도 나눗셈 | >> (산술) |
>> |
음수에서 floor 여야 한다 |
| 해시·체크섬 비트 연산 | >>> (논리) |
& 0xFFFFFFFF 후 >>, 또는 np.uint32 |
uint32 를 유지해야 한다 |
JS 의 >> 는 ToInt32 를 거쳐 부호 확장한다. 해시 중간값이 2^31 을 넘는 약 절반의 경우에
>> 를 쓰면 전혀 다른 값이 나온다 — docs/terrain.md §7.1.1 에 실측이 있다.
sim/ 안에서 >>> 가 등장할 수 있는 곳은 해시·체크섬 뿐이며, 그 외에는 여전히 금지다.
단항 마이너스가 >> 보다 먼저 묶이므로 -a >> s 는 (-a) >> s 로 계산된다.
>> 가 floor 라서 두 표기의 결과가 1 만큼 다르다.
v = 903552, s = 12
(-v) >> s = -221 ← 괄호 없이 쓰면 이 값이다
-(v >> s) = -220
항상 -(a >> s) 형태로 괄호를 명시한다. 이유: 좌우로 미러링한 같은 각도가 대칭이 되어야 한다.
(-a) >> s 를 쓰면 부호에 따라 크기가 1 씩 달라져 왼쪽으로 쏜 포탄이 오른쪽보다 미세하게 빠르다.
탄도 틱 = 60 Hz (16.667 ms) ← 카운터 A
자동자 스텝 = 정착 조건까지 반복 ← 카운터 B. A 와 무관하다
클라 렌더 = 60 fps, 프레임당 SUBSTEPS 회 자동자 스텝을 소비
세 카운터를 혼동하지 않는다.
| 카운터 | 정의 | 어디서 쓰나 |
|---|---|---|
| 탄도 틱 | 발사 시 0, 60Hz 로 증가 | §4.2 적분, MAX_FLIGHT_TICKS |
| 자동자 스텝 | 정착 시작 시 0, 스텝마다 1 증가 | terrain.md §7.1 해시 입력, MAX_SETTLE_STEPS |
| 렌더 프레임 | 클라이언트에만 존재 | SUBSTEPS |
terrain.md §3.2 가 예전에 hash16(src, tick) 이라 적었던 것은 자동자 스텝을 뜻한다.
탄도 틱으로 키를 잡으면 한 프레임의 SUBSTEPS 개 스텝이 모두 같은 해시를 써서
같은 방향·같은 게이트 결과를 반복한다.
서버는 실시간으로 틱을 돌리지 않는다. 턴제이므로 턴이 확정되는 순간 해결 전체를 한 번에 계산하고,
결과 이벤트만 남긴다. 클라이언트는 같은 계산을 60fps로 나눠 보여줄 뿐이다. docs/netcode.md §3.
런타임에 Math.sin() / math.sin()을 호출하지 않는다.
libm 구현이 플랫폼마다 마지막 자리에서 다를 수 있고, 그 한 자리가 lockstep을 깬다.
tables/trig.bin — 저장소에 커밋되는 바이너리 데이터 파일
레이아웃 SIN 배열 1801개 → COS 배열 1801개 (연속, 헤더 없음)
인덱스 0 ~ 1800 (데시도. 각도 × 10, 0.0° ~ 180.0°)
값 int16 리틀엔디언 sin/cos × 4096 (Q12 고정소수점)
크기 1801 × 2 × 2 = 7,204 바이트
-
두 배열이다.
SIN[i]=bin[i],COS[i]=bin[1801 + i] -
헤더도 매직 넘버도 없다. 파일 전체 해시를
test_trig_table이 고정값과 대조한다 -
COS는 90° 를 넘으면 음수다.int16이므로 그대로 담긴다.vx = (v0 * COS[deg10]) >> 12에서>>가 세 언어 모두 floor 이므로 음수도 일치한다 (§2.2) -
각도 범위를 0~180° 로 제한하는 것은 위쪽 반원만 쏘기 때문이다. 아래로 쏘는 무기가 생기면 범위를 늘리지 말고 부호를 호출부에서 처리한다 — 표가 커지면 커밋 해시가 바뀐다
-
생성 스크립트는
tools/gen_trig.py. 생성 결과를 커밋하고, 런타임에 재생성하지 않는다 -
서버와 클라이언트가 같은 파일을 읽는다. 클라는 빌드 시 임베드
-
사용:
vx = (v0 * COS[deg10]) >> 12
각도 단위는 데시도(0~1800) 로 통일한다. 라디안 변환이 시뮬레이션 안에 등장하면 안 된다.
v0 = (power * POWER_SCALE) >> 10 // power: 0~1500
tilt10 = tankTilt10(tank) // 좌우 궤도 지지점, -140~+140
shot10 = clamp(angle10 - tilt10, 0, 1800) // angle10은 차체 기준 상대각
vx = ((v0 * COS[shot10]) >> 12) // COS < 0 (각도 > 90°) 도 그대로
vy = -((v0 * SIN[shot10]) >> 12) // y축은 아래가 + . 괄호가 필수다 — §2.2
pos = 포신 끝 위치 (subpx)
좌우 궤도 아래의 가장 가까운 지지 셀 높이 차를 고정 폭으로 비교하고, 0.0~14.0° 삼각표에서
abs(rise*COS - run*SIN)이 가장 작은 데시도를 고른다. +경사는 화면 기준 시계 방향이다.
포탑 중심은 탱크 상단 중앙을 tilt10만큼 회전한 점이고, 포신 길이는 Phase 3.5 시각 검증에서
곡사포 실루엣에 맞춰 160 subpx = 10px로 확정한다. 발사점이 지형에 파묻혔을 때의 처리는 아직 정해지지 않았다.
이건 착탄점을 직접 바꾸는 값이라 두 구현이 다르면 즉시 갈라진다. docs/decisions.md 참조.
anchor.x = tank.x + ((TANK_H * signedSIN(tilt10)) >> 12)
anchor.y = tank.y - ((TANK_H * COS[abs(tilt10)]) >> 12)
pos.x = anchor.x + ((BARREL_LEN * COS[shot10]) >> 12)
pos.y = anchor.y - ((BARREL_LEN * SIN[shot10]) >> 12)
틱당 순서를 고정한다. 순서가 바뀌면 궤적이 달라진다.
1) vx += windAcceleration(wind, tick)
2) vy += GRAVITY
3) vx -= (vx * DRAG) >> 16 // 선형 항력. DRAG=0이면 생략 (§4.5)
4) 이동 + 충돌 판정 (§4.3)
Verlet이나 RK4를 쓰지 않는다. 이 게임에 필요한 건 정확도가 아니라 예측 가능성과 동일성이다.
WIND_SCALE_Q8 = 192이므로 수평 가속의 평균은 표시 바람의 75%다.
magnitude = abs(wind) * WIND_SCALE_Q8로 두고 틱 t(0부터)의 가속은
sign(wind) * (((magnitude * (t + 1)) >> 8) - ((magnitude * t) >> 8))이다.
약한 바람 ±1도 4틱에 ±3만큼 누적되어 소실되지 않으며 음수만 더 강하게 내림하지 않는다.
각 탄도 구간의 시작에서 t = 0으로 시작한다. 분열 후 자탄은 부모의 정수 속도를 이어받되
Q8 가속의 미적용 잔여(1 subpx/tick 미만)는 이어받지 않는다. 평지 사거리 계산도 같은 함수를 쓴다.
틱당 이동량이 여러 셀을 넘을 수 있으므로 한 틱을 잘게 나눠 검사한다.
steps = max(1, (|vx| + |vy|) >> 5) // 32 subpx = 1 cell 단위로 분할
각 서브스텝마다:
pos += (v / steps) // ← 이 나눗셈만 예외. 아래 참조
검사 순서: 지형 → 탱크 → 맵 경계
steps가 2의 거듭제곱이 아니므로 §2.2 규칙의 유일한 예외다. 반드시floorDiv(a, b)헬퍼를 정의해 양쪽에서 동일하게 구현한다.// TS const floorDiv = (a, b) => Math.floor(a / b);# Python — 기본 // 가 floor이므로 그대로. numpy는 스칼라만 쓸 것이 헬퍼 외에 다른 나눗셈이
sim/에 등장하면 리뷰에서 거부한다.
test_no_float(§9)은 이 헬퍼 한 곳만 화이트리스트로 예외 처리한다. 예외 규정을 안 두면 정적 검사가 자기 자신이 요구한 헬퍼를 잡아 항상 실패한다. 화이트리스트는 파일·함수 이름으로 고정하고, 다른 곳의/는 전부 거부한다.
잔차 처리. floorDiv(v, steps) 를 steps 번 더한 값은 v 와 같지 않다.
vx = 100 이면 steps = 200 >> 5 = 6, floorDiv(100, 6) = 16, 6 × 16 = 96 — 틱당 4 subpx(4%)를 잃는다.
누적되면 사거리가 짧아지고 두 구현이 같은 방식으로 잃지 않으면 갈라진다.
잔차를 위치에 직접 누적한다.
acc += v // 틱 시작 시 목표 총 이동량
각 서브스텝 k (1..steps):
target = floorDiv(v * k, steps) // 누적 목표
pos = tickStartPos + target // 차분이 아니라 절대 위치로 잡는다
이렇게 하면 마지막 서브스텝에서 target == v 가 되어 잔차가 0이다.
차분(pos += floorDiv(v, steps))을 반복하는 구현은 금지한다.
서브스텝 간격 하한 문제. L = |vx| + |vy| 일 때 서브스텝 이동량은 L / floor(L/32) ≥ 32 다.
L = 63 이면 steps = 1 이라 한 번에 63 subpx(약 2셀)를 건너뛰어 얇은 지형을 관통할 수 있다.
steps = max(1, (L >> 5) + 1) 로 바꾸면 이동량이 항상 32 subpx 이하가 된다. docs/decisions.md 참조.
| 대상 | 판정 |
|---|---|
| 지형 | 현재 셀이 EMPTY가 아니면 착탄 |
| 탱크 | AABB 겹침. 자기 자신은 발사 후 8틱간 무시 |
| 좌우 밖 | 통과시킨다 (다시 들어올 수 있음) |
| 위쪽 밖 | 통과시킨다 (고각 사격) |
| 아래쪽 밖 | 소멸 |
좌우 벽에서 반사시키지 않는다. 원작 일부에 있던 규칙이지만 학습을 어렵게 만든다.
MAX_FLIGHT_TICKS = 1800 // 30초
도달하면 그 자리에서 폭발 없이 소멸한다. 파워 0으로 하늘에 쏜 포탄이 영원히 남는 걸 막는다.
DRAG = 0으로 시작한다. 즉 항력 없음.
근거: 항력이 있으면 사거리-파워 관계가 비선형이 되어 감으로 익히기가 훨씬 어려워진다. 원작도 항력이 없거나 무시할 수준이었고, 그게 "한 번 맞춘 각도를 기억하는" 재미를 가능하게 했다. 무기 하나(예: 낙하산탄)에만 국소적으로 넣는 건 나중에 고려한다.
explode(cx, cy, weapon) :
1) 지형 카빙 terrain.md §8
2) 탱크 피해 판정
3) 이벤트 기록 (렌더러가 소비)
dist = 중심에서 탱크 중심까지 (subpx)
if dist >= blastRadius: 피해 없음
damage = (maxDamage * (blastRadius - dist)) >> DAMAGE_SHIFT
blastRadius의 단위는 subpx 다 (dist와 같아야 한다).terrain.md§8 의carve는 셀 반경을 받으므로>> 5변환을 호출자가 한다DAMAGE_SHIFT는 무기별 상수이며blastRadius와 짝을 이룬다.dist = 0에서 피해가 정확히maxDamage가 되려면blastRadius == 2^DAMAGE_SHIFT여야 한다. §2.2 가 일반 나눗셈을 금지하므로 무기의blastRadius는 2의 거듭제곱으로만 잡는다. 무기 테이블에blastRadius와damageShift를 함께 저장하고blastRadius == 1 << damageShift를 테스트로 강제한다.docs/decisions.mdC1
선형 감쇠. 제곱 감쇠를 쓰지 않는 이유: 근접 명중의 보상이 지나치게 가팔라져 조준 실력보다 운의 비중이 커진다.
거리 계산에 제곱근이 필요하다. 정수 제곱근 함수를 직접 구현해 양쪽에서 공유한다.
isqrt(n) : 뉴턴법 정수 버전. 결과는 floor(sqrt(n))
Math.sqrt() 금지. 이유는 §3과 동일.
분열탄처럼 한 발이 여러 detonation을 만들 수 있으므로 같은 발의 폭발은 한 묶음으로 적용한다.
1) 무기 계획에 기록된 detonation 순서로 전부 carve/deposit
2) 그 발의 전체 폭발에 대해 탱크 피해를 합산 적용 ← 카빙 전 위치 기준으로 계산
3) 연결성 검사 → 정착 → 재검사 루프 terrain.md §6.1. 변환 0 까지
4) 탱크 재배치 §6.1
5) 격자 체크섬 1회 terrain.md §7.2
3단계는 한 번이 아니라 루프다. terrain.md §6.1 이 "정착 후 연결성 재검사, 변환이 있으면
정착 재개"를 요구한다. 붕괴로 지지물이 흘러나가 새로 떠버린 ROCK 을 처리하기 위한 것이다.
⚠️ 탱크 재배치는 이 루프가 완전히 끝난 뒤에 한다. 첫 정착 직후에 앉히면 재개 정착에서 지형이 더 무너져 탱크가 공중에 남는다. 지형 체크섬은 같아도 탱크의 최종y, 낙하 거리, 낙하 피해, 매몰 판정이 전부 갈라지고 — 이건 지형 리싱크로도 원인이 잡히지 않는 종류의 어긋남이다.
PHASE_SETTLE의 종료 조건은 "연결성 변환이 0 이고 가동 셀이 0" 이다 (§7).
피해는 카빙 전 위치 기준으로 전부 계산한 뒤 한꺼번에 적용한다. 순차 적용하면 슬롯 1번의 폭발로 밀린 탱크가 슬롯 2번 폭발을 피하게 되어 슬롯 순서가 유불리를 만든다.
정착이 끝나면 모든 탱크를 지형에 다시 앉힌다.
1) 탱크 발밑 셀들을 검사
2) 지지 셀이 없으면 아래로 이동. 지지가 나올 때까지
3) 낙하 거리 기록 → 낙하 피해 적용
4) 흙에 파묻혔으면 매몰 상태 설정
5) 최종 위치 확정
| 항목 | 규칙 |
|---|---|
| 낙하 피해 | (fallPx - FALL_SAFE_PX) * FALL_DAMAGE_NUM >> FALL_DAMAGE_SHIFT |
| 낙하산 보유 | 낙하 피해 0, 낙하산 1개 소모 |
| 매몰 | 탱크 AABB의 80% 이상이 비-EMPTY 셀 → 턴당 지속 피해 |
| 화면 아래 이탈 | 즉사 |
탱크는 자동자에 참여하지 않는다. 즉 흙이 탱크를 밀어내지 않고 탱크 위에 그냥 쌓인다. 근거: 탱크를 자동자의 장애물로 넣으면 격자에 동적 셀이 생겨 규칙이 크게 복잡해지고, 얻는 재미가 그만큼 크지 않다. 대신 매몰 피해로 "묻히면 손해"를 표현한다.
angle10은 차체 기준 상대각이다. 실제 월드 발사각은 angle10 - tankTilt10이며 0~1800으로
클램프한다. 차체 경사는 지형 격자에서 매 발 결정론적으로 다시 파생하므로 별도 네트워크 필드가 없다.
PHASE_AIM 조준 입력 수집. 시뮬레이션 없음
PHASE_RESOLVE 활성 플레이어 발사 → 마지막 자탄 소멸/착탄까지 탄도 틱 진행
PHASE_SETTLE 폭발 적용 → 자동자 정착까지
PHASE_APPLY 탱크 재배치, 피해 정산, 사망 판정
PHASE_ROUND_END 라운드 종료 판정 → 상점 or 다음 생존 슬롯
각 페이즈는 결정론적으로 종료 조건에 도달한다. 실시간 타이머는 PHASE_AIM에만 존재하며,
타임아웃 처리 결과(입력 확정)까지가 서버의 책임이고 그 이후는 순수 계산이다.
시작값이며 확정값이 아니다. 밸런싱 전에 전부 바뀔 수 있다.
slideChance류 지형 상수는terrain.md§4에 있다.
| 상수 | 값 | 단위 | 비고 |
|---|---|---|---|
SUBPX |
16 | subpx/px | |
CELL_SUBPX |
32 | subpx | 2px |
TICK_HZ |
60 | Hz | |
GRAVITY |
12 | subpx/tick² | = 2700 px/s² (12 ÷ 16 × 60²). 아래 §8.1 |
POWER_SCALE |
624 | — | 파워 1000의 기존 탄도를 유지. B12·B22 |
POWER_MAX |
1500 | 파워 | B22 — 최대 역풍 45° 사거리 3230px, 무풍 4307px. v0 = 936000 >> 10 = 914 |
WIND_MAX |
4 | 표시 바람 단위 | 75% 계수 적용 전 최대치. §8.2 |
WIND_SCALE_Q8 |
192 | Q8 | 수평 가속 75%. 최대 실효 가속 3 subpx/tick² |
DRAG |
0 | Q16 | §4.5 |
MAX_FLIGHT_TICKS |
1800 | tick | 30초 |
SELF_HIT_IGNORE |
8 | tick | 발사 직후 자폭 방지 |
TANK_W / TANK_H |
384 / 256 | subpx | 24 × 16 px |
MAX_HP |
100 | ||
FALL_SAFE_PX |
24 | px | 이하 낙하는 무피해 |
FALL_DAMAGE_NUM / FALL_DAMAGE_SHIFT |
미확정 | — | §6.1 수식이 요구한다 |
DAMAGE_SHIFT |
무기별 | — | = log2(blastRadius). §5.1 |
AIM_SECONDS |
20 | s | 1턴차는 30 |
BARREL_LEN |
160 | subpx | 10px. 짧고 굵은 곡사포 포신. §4.1 |
지형 자동자 상수(
SUBSTEPS,SLIDE_CHANCE_*,MAX_SETTLE_STEPS)는docs/terrain.md§4 에 있다.SUBSTEPS는 시뮬레이션 상수가 아니라 표현 상수다 (terrain.md§3.4).기계 판독 사본:
server/src/neodeol/constants.py. 문서가 기준이고, 어긋나면 문서를 먼저 고친다. 미확정 상수는 그 파일의PROVISIONAL집합에 들어 있고GET /version이 목록을 노출한다.
POWER_SCALE = 320, power = 1000, 45° 발사:
v0 = (1000 * 320) >> 10 = 312 subpx/tick
COS[450] = SIN[450] = 2896 (Q12)
vx = vy = (312 * 2896) >> 12 = 220 subpx/tick
§4.2 적분을 정수로 그대로 돌린 결과 (node tools/ballistics-check.mjs).
닫힌형 v0² sin2θ / g 는 504 px 을 주지만 §4.3 의 서브스텝 절단 때문에 실제로는 491 px 이다.
lockstep 에서 진실은 적분 결과이므로 문서에는 이 값을 적는다.
POWER_SCALE |
GRAVITY |
v0 |
사거리 | 체공 | 맵 폭 1920 대비 |
|---|---|---|---|---|---|
| 320 (현재) | 12 | 312 | 491 px | 36 tick | 26% |
| 448 | 12 | 437 | 970 px | 51 tick | 50% ✅ |
| 320 | 6 | 312 | 995 px | 73 tick | 52% |
| 512 | 12 | 500 | 1,276 px | 58 tick | 66% |
즉 현재 시작값은 최대 파워로 화면의 1/4밖에 못 날아간다.
docs/roadmap.md Phase 1 의 완료 조건("적당한 대각선이 화면 절반쯤 날아간다")과 2배 어긋난다.
목표치를 검산 가능한 형태로 못박는다: 최대 파워 · 45° 에서 사거리 = 맵 폭의 100% ± 5%.
(decisions.md B12 — 맵 전체가 교전 범위여야 한다. 절반이면 맵 양끝의 두 탱크가 서로 닿지 않는다)
채택: POWER_SCALE = 624 → 파워 1000에서 1,899 px = 맵 폭의 98.9%, 체공 71 tick.
B22에서 상한만 1500으로 확장했다. 아래는 파워 1000의 비교표이며 최대 출력 표가 아니다.
POWER_SCALE |
사거리 | 맵 폭 대비 | 체공 |
|---|---|---|---|
| 616 | 1,846 px | 96.2% | 70 t |
| 624 | 1,899 px | 98.9% | 71 t |
| 632 | 1,953 px | 101.7% | 72 t |
| 640 | 1,998 px | 104.1% | 73 t |
GRAVITY 를 낮추는 방법도 같은 사거리를 낼 수 있지만 체공이 두 배가 되어 바람 영향이
그만큼 커진다. POWER_SCALE 쪽이 WIND_MAX 를 다시 잡을 필요가 없다.
최종 확정은 Phase 1 체감. docs/decisions.md C3.
바람은 수평 등가속도이므로 45° 사격의 수평 편차 / 사거리 비는 약 실효 가속/g 다.
아래는 계수 도입 전 §4.2 적분을 정수로 돌린 비교 자료다 (GRAVITY = 12).
아래 표는 계수 도입 전(100%)의 비교 자료다. 현재 편차/사거리 비는
WIND_MAX * (WIND_SCALE_Q8 / 256) / GRAVITY에 가깝고 파워와 거의 무관하다.
POWER_SCALE 을 320 에서 624 로 올려도 아래 비율은 그대로다.
WIND_MAX |
45° 편차 비율 | 80° 고각 편차 (사거리 649px) |
|---|---|---|
| 6 (중력의 절반) | 50% | 사거리의 285% |
| 4 | 33% | 1,234 px = 190% |
| 2 (중력의 1/6) | 17% | 617 px = 95% |
| 1 (중력의 1/8) | 8% | 308 px = 48% |
WIND_MAX = 6 이면 고각 사격은 조준값이 아니라 바람이 착탄점을 결정한다.
표시 범위는 4를 유지하되 2026-09-17 피드백으로 WIND_SCALE_Q8 = 192를 도입했다.
같은 수치에서도 탄도 영향이 25% 줄고 45° 최대 편차는 약 33%에서 25%로 낮아진다.
턴 갱신은 60% 유지, 나머지는 ±1이며 경계에서는 클램프한다. 급격한 돌풍과 즉각적인 방향 반전을 없앤다.
현재 POWER_SCALE = 624, 파워 1000에서의 정수 적분 결과:
| 조준각 | 무풍 | 역풍 -4 (기존 → 현재) | 순풍 +4 (기존 → 현재) |
|---|---|---|---|
| 45° | 1899px | 1266 → 1424px | 2532 → 2374px |
| 70° | 1226px | 102 → 383px | 2351 → 2070px |
| 80° | 648px | -586 → -277px | 1882 → 1573px |
음수는 발사 위치의 왼쪽에 떨어지는 경우다. 높은 각도에서는 여전히 바람 보정이 필요하지만, 체공 시간·중력·무풍 탄도는 바꾸지 않고 바람으로 생기는 편차만 약 25% 줄였다.
고각의 편차 폭주는 상수만으로 해결되지 않는다. WIND_MAX = 1 이어도 80° 에서 47% 다.
바람은 체공 시간의 제곱으로 누적되는데 고각은 체공이 길고 사거리가 짧기 때문이다.
편차 클램프(체공 틱에 비례하는 상한)를 넣을지 함께 결정해야 한다. docs/decisions.md C4.
sim/을 건드리는 PR은 전부 통과해야 한다. CLAUDE.md §결정론 게이트.
게이트 (1) — 서버 자기 자신과의 재현성. server/tests/test_determinism.py.
골든 파일 없이도 항상 돌아간다.
| 테스트 | 내용 |
|---|---|
test_replay_stable |
같은 시드 + 같은 입력 → 100회 반복해도 동일 체크섬 |
test_seeds_actually_differ |
시드가 결과에 실제로 먹는다 (시드를 무시하는 구현을 막는다) |
test_interleaved_runs_do_not_contaminate |
시드를 번갈아 돌려도 각자의 결과가 유지된다 |
test_settle_terminates |
무작위 지형에 대해 정착이 상한 내 종료 |
test_no_float |
sim/ 전체를 AST 파싱해 float 리터럴·/ 연산자·math.* 호출 검출 |
test_trig_table |
tables/trig.bin의 해시가 고정값과 일치 |
게이트 (2) — TS 와의 교차 검증. server/tests/test_cross_sim.py.
골든 리플레이가 있어야 성립하므로 파일을 분리했다.
| 테스트 | 내용 |
|---|---|
test_cross_sim |
골든 20개를 스텝 단위로 재생, 표본마다 체크섬·질량 비교 |
test_replay_stable |
장기 골든을 턴 단위로 재생 (60턴 / 1000턴은 slow) |
test_long_replay_is_long_enough |
턴이 no-op 인 리플레이로 완료 조건을 형식만 채우지 못하게 |
test_cross_sim_mapgen |
사이드카 없이 mapSeed에서 초기 격자·정착·스폰 대조 |
test_cross_sim_shots |
탄도·무기·피해·재배치 40발 대조 |
test_cross_sim_match |
intent·상점·라운드 전환을 포함한 두 라운드 대조 |
대조 단위가 두 가지인 이유. 턴 = carve/deposit → 정착 → 연결성 재검사 루프 전체다
(terrain.md §6.1). 스텝 대조 20개가 전부 통과한 뒤에 턴 대조가 이탈을 잡은 전례가 있다 —
원인은 자동자가 아니라 골든 생성기가 사이드카에 담기지 않은 상태(step 카운터)에
의존한 것이었고, 스텝 대조로는 영원히 안 잡힌다. 둘 중 하나만 남기지 않는다.
test_no_float이 과하다고 느껴질 수 있는데, 결정론 버그는 재현이 어렵고 발견이 늦다.
정적 검사로 잡을 수 있는 건 정적 검사로 잡는다.
선택지와 추천안은 docs/decisions.md 에 있다. 여기서는 목록만 유지한다.
-
GRAVITY/POWER_SCALE— B12 확정(사거리를 맵 폭에 맞춘다)에 따라POWER_SCALE = 624. 실측 1,899 px = 맵 폭의 98.9% 이고 Python·TS 가 같은 값을 낸다. §8.1,decisions.mdC3 -
WIND_MAX = 4,WIND_SCALE_Q8 = 192— 탄도 영향 75%, 60% 유지·최대 ±1 변화. §8.2,decisions.mdC4 - 발사점이 지형에 파묻혔을 때 처리 — §4.1 (
BARREL_LEN=160은 확정) -
DAMAGE_SHIFT와blastRadius의 2의 거듭제곱 제약 —damageShift를 따로 저장하지 않고blastRadius에서 파생하며, 2의 거듭제곱이 아니면 생성 시점에 던진다 (weapons.shiftOf). 두 값을 따로 두면 어긋날 수 있다.decisions.mdC1 - 서브스텝 이동량 하한 (
steps식 수정 여부) — §4.3 - 탱크가 경사에서 미끄러지는가 — §6.1 vs
game-design.md§8.1,decisions.mdB5 - 탱크 이동·아이템 사용 — 발사 전 슬롯 순 이동,
intent.moveDx/useShield.docs/match.md§5 - 순차 턴 — 표준 규칙으로 확정됐다 (
decisions.mdB14·C7). 별도 동시 모드는 MVP 밖 - 폭발 넉백의 존재 여부 — §5.2 의 근거 문장이 넉백을 전제하는데 규칙이 어디에도 없다
- 매몰 판정 임계값(
800‰)과 지속 피해량(6) — 값만 잠정이다. 구조는 확정됐다: 재배치의 밀어올리기가 같은 임계를 쓰고 턴당BURIAL_RELIEF_CELLS만큼 완화해 유한 턴에 반드시 풀린다 (decisions.mdB13,match.md§5 step 9) - 낙하 피해 계수 (
FALL_DAMAGE_NUM,FALL_DAMAGE_SHIFT) — 값만 잠정. 원인을 가리지 않는다는 규칙은 확정됐다 (match.md§5.1) - 탱크끼리의 충돌 처리 — 현재는 서로 통과. 겹쳐 앉을 수 있는데 그대로 둘지