콘텐츠로 이동

H5 미니게임 클라이언트 설계 - 계층, 라운드 상태, 프로토콜 이중 구조, fail-closed 실패 처리

두 미니게임 클라이언트는 PixiJS 기반 H5 프레임워크와 공용 게임 템플릿 위에 만들었다. 두 게임은 규칙이 다르지만 클라이언트 구조는 같다.

H5 미니게임 A H5 미니게임 B
라운드 형태 다단계. BET → 선택 요청 반복 → 캐시아웃 단발. BET 요청 한 번에 결과와 정산이 함께 온다
게임 루트 게임 루트 컴포넌트 하나가 상태와 조립을 소유 같음
금전 요청 진입점 RoundController.sendBet / sendReveal / sendCashout GameRoot.requestBet() → BetFlowController
wire 번역 지점 게임 어댑터 하나 wire.ts 하나
실패 정리 정본 SessionFailurePolicy.recoverAll wager-policy의 haltWagers

캔버스는 둘 다 1080×1920 세로 고정(enableRotation: false, coverScreen: true)이다. 결과와 잔액은 서버가 정하고 클라이언트는 연출만 한다.

화살표는 의존 방향이다. 역방향 호출은 전부 콜백이나 host 인터페이스로만 일어난다.

게임 A:

ServerSocket → ServerAdapter 공용 템플릿 계층
↓
게임 어댑터 anti-corruption 레이어. wire 포맷을 아는 유일한 곳
↓
RoundController BET/REVEAL/CASHOUT 요청·응답과 상태기계
↓
게임 루트 + 뷰 UI 입력·모듈 조립 허브

게임 B는 같은 원칙으로 층을 더 잘게 나눴다.

엔트리 index.html → src/main.ts (제품) · preview.html → src/preview.ts (씬 에디터 프리뷰, 도구 전용)
GameAdapter 목업을 로컬 서버로 바꾸는 것만 한다
↓
betSession · wire 베팅 대기(round_id·타임아웃·stale 폐기) + wire ↔ 내부 모델 변환
↓
BetFlowController 금전 요청 단일 진입점
↓
GameRoot (GameMain 구현체) 상태 소유자 + 배선 허브
↓
뷰 컨트롤러 · 팝업 src/game/{play,hud,betting,menu,popup,auto}/
↓
prefab 계층 src/game/prefab/ prefab 노드에서 값을 읽는다
↓
util 계층 src/game/util/ prefab·씬·게임 상태를 모르는 함수

공용 계층(src/common, src/network, src/intro)은 src/game을 import하지 않는다. 게임별 확장은 상속과 주입으로만 한다. Sequence를 상속한 게임 시퀀스, 게임 어댑터, 로컬 목업 서버, GameMain을 상속한 게임 루트가 그 결과물이다.

게임 루트가 유일한 조립 허브다

섹션 제목: “게임 루트가 유일한 조립 허브다”

UI도 라운드 규칙도 서로를 직접 모르고 전부 게임 루트를 거친다. 라운드 상태의 실제 소유자도 게임 루트다.

게임 A에서는 상태(m_state, m_roundId, m_balance, m_accumMult, m_lastWin 등)를 게임 루트가 필드로 갖고, RoundController는 IRoundHost의 getter/setter로 읽고 쓴다. 그래서 RoundController는 상태를 저장하지 않고 전이 규칙과 잠금만 담당한다. 게임 B는 상태를 GameState로 분리하고 변경 통지를 onChange 구독으로 받는다.

독립 가능한 책임은 별도 모듈로 빼고, 모듈은 host 인터페이스로만 상태를 읽고 쓴다.

모듈 책임
SessionFailurePolicy (A) 네트워크·프로토콜 실패의 fail-closed 정리. 팝업 표시만 게임 루트가 한다
wager-policy (B) 금전 요청 잠금의 유일한 소유자. 함수 모듈이다
settings-contract (B) 부팅 설정 계약 검증. 순수 함수 + 단위 테스트
BetFlowController (B) BET 1회: 요청 → 연출 → 정산 → 결과 기록 → 정리
오토베팅 규칙 / 루프 규칙(베팅액 증감·정지 판정)과 타이밍(언제 무엇을 기다릴지)을 분리
도움말·토스트·히스토리 등 각 UI 모듈

게임 루트 파일이 커지는 이유는 로직이 많아서가 아니라 소유자라서다. 생성 방식이 세 갈래라는 것을 구분하면 파일이 읽힌다.

  1. resident prefab에 붙은 @RegisterComponent 컴포넌트
  2. 시작 시점에 코드로 만드는 순수 TS 클래스
  3. 별도 prefab을 최초 오픈 시 복제하는 지연 팝업(히스토리·도움말·오토). 복제한 인스턴스는 씬이 끝날 때까지 재사용하고, 내부 노드는 팝업 루트 아래에서만 찾는다

게임 B는 start()에서 필수 @Serialize 필드가 비었으면 즉시 던진다. 배선이 끊긴 화면이 정상처럼 보이는 것을 막는 장치다.

게임 B는 재사용 코드를 “여러 곳에서 쓰인다”가 아니라 역할로 나눈다. 그 기준이 없으면 폴더 이름이 무엇을 담는지 말하지 못한다.

폴더 담는 것
prefab/ prefab 노드에서 값을 읽는다(색 토큰, 폰트 굵기 → family 체인, 라벨 ↔ 로케일 키 표)
util/ prefab·씬·게임 상태를 모르는 함수(금액, 로케일 조회 창구, 숫자 포맷, 취소 가능한 대기 등)

의존 방향은 game/<기능>/ → prefab/ → util/이고 역방향은 없다.

await new Promise(r => setTimeout(r, ms))는 핸들을 남기지 않아 씬이 내려간 뒤에도 타이머가 산다. CancellableWait를 필드에 두고 정리 시점에 cancel()한다. cancel()은 대기를 매달아 두지 않고 깨운다. 영원히 대기시키면 await 뒤의 finally 정리가 돌지 않는다.

intro 씬이 먼저 뜨고, 로그인에 성공해야 game 씬이 로드된다. 게임 루트는 그때 처음 존재한다.

오해 실제
Sequence.useImplementation()으로 게임 시퀀스를 등록한다 코드에서 호출하지 않는다. intro prefab의 Sequence 노드에 붙은 componentName이 인스턴스를 만들고, 그 인스턴스가 싱글턴이 된다
게임 루트가 부팅부터 존재한다 game 씬 로드 시점에 생긴다. 그 전 단계 안내는 intro의 공용 팝업이 띄운다
씬이 바뀌면 네트워크 상태도 초기화된다 GameModule, SceneManager, Sequence는 dontDestroyOnLoad다. 동결 가드가 씬 리로드를 넘어 유지되는 이유다
start()에서 서버 설정을 쓸 수 있다 없다. 초기 표시는 폴백을 두고 onAllState에서 다시 채운다
game_setting은 RES_ALL_STATE에서만 온다 씬 리로드 등으로 GameModule에 설정이 이미 남아 있으면 onAllState가 호출되지 않는다. 게임 A는 start()가 같은 계약으로 그 값을 검증한다
start()에서 베팅 잠금을 풀어야 한다 게임 B의 networkReady 초기값은 false다. 베팅이 열리는 지점은 onAllState 하나뿐이다

GameModule이 부르는 GameMain의 훅(onAllState, onUpdateBalance, onStopAutoplay 등)은 optional이라 누락을 컴파일러가 잡아 주지 않는다. GameMain에 없는 세션 이벤트(연결 오류, 단절, 킥, 중복 로그인 등)는 Sequence.callback.addCallback으로 받고, 템플릿이 올리지 않는 이벤트는 게임 시퀀스의 오버라이드가 직접 올린다. 이벤트 이름은 as const 객체 하나에 모으고 시그니처는 인터페이스로 고정한다.

상태는 적고, 버그는 잠금에서 난다

섹션 제목: “상태는 적고, 버그는 잠금에서 난다”

게임 A의 라운드 상태는 4개뿐이고, 복잡한 부분은 전부 잠금 플래그 쪽이다. 클릭이 안 먹으면 먼저 잠금 플래그를 본다. 게임 B도 같다. phase는 세 값뿐이고, BET이 안 눌리면 먼저 canBet을 본다.

게임 B의 phase 전이:

stateDiagram-v2
[*] --> idle
idle --> rolling: BET
rolling --> result: 정산
result --> idle

게임 A의 잠금 규칙:

  • 셀 선택은 revealPending으로 한 요청만 in-flight다. 동시 전송하면 응답에 없는 셀을 열 수 있다.
  • 활성 베팅 ID는 RES_BET에서 채워지고 정산되면 비워진다. 활성 베팅이 없으면 캐시아웃 요청이 아예 나가지 않는다.
  • 이전 라운드 응답은 round_id로 버린다. 보드·진행도 초기화는 다음 RES_BET 처리 시에 하고, 종료 직후에는 idle로 액션만 먼저 연다.

게임 B의 canBet은 네트워크 준비, 설정 유효성, 잔액, 베팅 한도를 함께 본다. 한도 값은 onAllState의 game_setting에서 들어오고, 오기 전에는 최소 베팅이 코드 기본값이고 최대는 무한대다. 그래서 베팅 한도가 빠진 설정은 계약 검증이 치명으로 잠근다(아래 “설정 계약 검증”).

전송 결과를 세 가지로 구분한다

섹션 제목: “전송 결과를 세 가지로 구분한다”

게임 A의 전송 함수는 결과를 SendResult로 돌려준다. 이 구분을 지켜야 사용자에게 엉뚱한 토스트가 뜨지 않는다.

값 의미 UI 처리
Sent 전송 완료, 응답 대기 잠금 걸림 버튼 상태 갱신
Blocked 잠금에 걸려 전송 안 함 조용히 무시
Failed 전송 실패, 잡았던 잠금은 복구됨 토스트로 알림

셀 연타는 오류가 아니므로 in-flight 중복은 Blocked로 돌려주고, 호출측이 미리 세운 셀 요청 표시도 함께 되돌린다.

금전 요청은 단일 진입점으로만 보낸다

섹션 제목: “금전 요청은 단일 진입점으로만 보낸다”
  • 게임 A: 금전·셀 요청은 RoundController.sendBet / sendCashout / sendReveal로만 보낸다. 어댑터의 requestBet 등을 직접 부르면 잠금만 걸리고 전송이 빠지거나 그 반대가 된다.
  • 게임 B: UI와 오토플레이 모두 GameRoot.requestBet()을 거친다. BetFlowController가 잠금 선점부터 정리까지 한 라운드를 소유한다.
  • 오토베팅도 전송을 직접 하지 않고 host를 거쳐 같은 진입점으로 나간다. 수동 베팅과 경로가 갈리지 않는다.

게임 B는 BET 시점에 총 베팅금만 빠진 잔액을 보여 주고, 연출이 모두 끝난 뒤 정산값으로 바꾼다. 그 사이 들어온 서버 잔액은 GameState.holdBalance가 보관했다가 releaseBalance 때 반영한다. 연출 중에 최종 잔액이 보이면 결과를 미리 알려 주게 된다. 베팅이 서지 않으면 원래 잔액으로 풀되, 서버 잔액이 오면 그 값이 이긴다.

서버 어휘와 클라이언트 어휘가 다르고, 번역은 딱 한 군데서만 한다.

계층 특징
내부 프로토콜 클라이언트 어휘. 내부 심볼 이름, round_id 기준, camelCase 모델
실서버 wire 서버 어휘. snake_case, 서버 심볼 이름, 캐시아웃은 bet_id 기준, 대문자 enum, 마스킹된 보드

UI 코드에 bet_id 같은 wire 필드나 서버 심볼 이름이 보이면 잘못 들어간 것이다.

어댑터는 이름을 바꾸는 것 외에 서버 응답에 없는 정보도 채운다.

  • 캐시아웃 wire 키는 round_id가 아니라 bet_id이고, 그 매핑을 어댑터가 들고 있다.
  • 선택 응답에는 인덱스도 round_id도 없어서 요청 시점 컨텍스트(_pendingSelect)로 되채운다.
  • 실서버에는 명시적인 종료 유형 플래그가 없어, 응답 필드 유무로 종료 유형(실패 종료·자동 정산·수동 캐시아웃)을 추론해 내부 응답을 합성한다.
  • 게임 B는 서버가 BET 처리 안에서 지급까지 하므로 지급 실패 오류가 캐시아웃 이벤트로 온다. 이 이벤트를 베팅 오류로 함께 받는다.
내부 API wire 이벤트 비고
requestBet REQ_BET 오토는 선택 경로를 함께 보내고, 프리스핀은 freespin_id를 붙인다
requestReveal REQ_SELECT_NORMAL index 필수. 서버는 인덱스 없는 랜덤을 받지 않아 클라이언트가 미공개 칸을 골라 보낸다
requestCashout REQ_CASH_OUT bet_id 기준
requestBetHistory REQ_FIND_ALL_MY_BET wire는 size가 아니라 limit

어댑터는 성공 응답의 계약도 검사한다

섹션 제목: “어댑터는 성공 응답의 계약도 검사한다”

형태가 어긋나면 추측해서 진행하지 않는다.

  • 게임 A는 isValidBetRes / isValidSelectRes / isValidCashoutRes / isCompleteBoard로 검사하고, 어긋나면 reportProtocolError로 상위에 동결을 위임한다. 요청 타임아웃 watchdog(REQUEST_TIMEOUT_MS, 10초)도 어댑터에 있고 BET·SELECT·CASHOUT 모두가 대상이다.
  • 게임 B는 parseBetRes가 wire 스키마를 검증하고, 실패는 결과 불명(uncertain) 이다. 내부 camelCase 모델을 넣으면 거부되며, 그것이 테스트의 회귀 항목이다. 결과값은 허용 범위와 소수 격자까지 확인한다.

로컬 목업 서버도 실서버와 같은 wire 포맷으로 응답한다. 그래서 로컬과 실서버가 같은 변환 경로를 타고, 로컬에서 검증한 어댑터 동작이 실서버에서도 유효하다. 목업 응답을 내부 DTO로 바로 만들면 이 성질이 깨진다.

게임 B의 목업은 parseBetReq → decodeBetReq → 규칙 심사 → 정산 → encodeBetRes 순서로, 클라이언트 쪽과 같은 변환 함수를 서버 방향으로 쓴다. 직렬화 대칭은 테스트로 고정한다.

목업에서 지킬 것:

  • 거절 코드는 사유별로 나눈다. 형식 오류·한도 위반·잔액 부족을 하나의 “베팅 실패” 코드로 뭉치면, 그 코드가 종료성 코드 목록에 속해 있을 때 잔액이 부족했을 뿐인데 “세션이 만료됐으니 재시작하라”는 공용 팝업이 뜬다. 클라이언트의 코드별 분기도 로컬에서 검증되지 않는다.
  • 고지 대상 필드를 직접 채운다. 프레임워크 기본 목업에 기대지 않고 베팅 한도·RTP·최대 배율 등 도움말에 쓰는 필드를 모두 넣는다. 응답에 없는 필드는 로컬 모드에서 태그째 화면에 노출된다.
  • 목업이 만든 id로 히스토리 상세를 열지 않는다. 내역 응답의 bet_id를 0으로 내보내 상세 팝업의 빈 id 가드에서 멈추게 한다.
  • 실서버가 주지 않는 필드는 목업도 주지 않는다. 클라이언트가 보정해야 하는 필드는 게임 시퀀스가 공용 계층에 넘기기 전에 채운다.

게임 B의 betSession.arm()은 round_id를 발급해(세션 내 단조 증가) 대기 항목에 보관하고, receive()가 응답과 대조한다. 모듈 싱글턴이라 씬이 바뀌어도 같은 대기를 받는다. 대기는 한 번에 하나이고, 대기 중에 다시 arm()하면 rejected로 던진다.

불일치 응답은 버리고 현재 라운드 대기는 유지한다. 타임아웃 뒤 늦게 도착한 지난 라운드 결과를 새 라운드 것으로 오인하지 않기 위해서다. round_id가 없는 응답은 경고를 한 번만 남기고 통과시킨다.

RES_BET은 게임 시퀀스의 initialize()가 게임 메시지 게이트 앞에서 가로채 betSession.receive로 넘긴다. 씬 리로드 중 게이트가 닫혀 있어도 응답을 잃지 않아, 대기가 타임아웃으로 결과 불명이 되지 않는다. 같은 응답을 onReceiveEvent로 재전파하면 두 번 소비된다.

부가 필드 하나에 fail-closed를 걸지 않는다

섹션 제목: “부가 필드 하나에 fail-closed를 걸지 않는다”

게임 B의 bet_id는 히스토리 상세 URL에만 쓰는 옵셔널 필드다.

  • 폴백을 만들지 않는다. 없으면 null이다. 클라이언트가 임의 번호를 만들면 실서버에 없는 라운드를 조회한다.
  • 형식이 틀린 값(비정수·0·음수·문자열)은 “없음”으로 떨어뜨리고 응답은 통과시킨다. 응답을 버리면 호출부가 결과 불명으로 보고 게임을 페이지 재시작 전까지 동결시킨다. 정산 필드가 모두 정상인 응답은 서버 판단을 확인한 것이므로 결과 불명이 아니다. 상세 링크에만 쓰이는 필드 하나에 fail-closed를 걸면 서버가 bet_id: 0을 “없음” 센티넬로 쓰는 순간 모든 라운드가 잠긴다.

금전 요청은 fail-closed다. 베팅이나 캐시아웃 응답이 오지 않으면 결과 불명으로 보고, 클라이언트가 임의로 확정하지 않는다. 응답 없이 끊기면 화면에 승패를 그리지 않는다. 소켓은 reconnection: false라 자동 재접속도 하지 않고, 단절되면 재시작을 안내한다.

두 게임은 경계를 다르게 잡았다.

게임 A는 응답을 받지 못한 금전 요청을 결과 불명으로 보되, 서버가 wager를 적용하기 전에 확실히 거절한 오류(레이트리밋, 베팅 요청 형식 오류, 라운드 없음·무효, 캐시아웃 요청 형식 오류)를 isSafeRejectedWagerError()로 골라 해당 요청만 실패시킨다.

게임 B는 경계를 “서버 판단을 확인했는가” 하나로 둔다. 서버가 응답을 보냈고 성공이 아니면 그 요청은 서지 않은 것이므로 코드를 가리지 않고 거절(rejected)이고, 잠그지 않는다. 결과 불명(uncertain)은 서버 판단을 확인하지 못한 경로뿐이다. 타임아웃과 응답 해석 실패다. 대기 중 단절은 ended로 따로 둔다.

게임 B의 상황별 처리:

상황 처리 안내
베팅 응답 타임아웃(10초) uncertain → 잠금 재시작 안내
응답 스키마 불일치 uncertain → 잠금 재시작 안내
대기 중 연결 단절 ended. 잠그지 않는다. 결과를 이미 받았으면 연출 뒤 재시작 안내, 아니면 바로 안내 재시작 안내
서버 오류 응답(코드 무관) rejected. 잠그지 않는다 결과 코드가 있으면 공용 팝업, 없으면 게임 팝업
레이트리밋 어댑터가 오류 이벤트를 원래 요청 이벤트로 바꿔 넘긴다. 베팅이면 rejected 없음(공용 정책을 노출하지 않는다)
잔액 동기화 실패(10초) 잠금 재시작 안내
설정 계약 위반 잠금 재시작 안내
연결 끊김·킥·중복 로그인 오토 정지 + setNetworkReady(false) 공용 팝업
  • 게임 A: SessionFailurePolicy.recoverAll이 동결 → 어댑터 pending 폐기 → 라운드 잠금 해제 → 오토 종료 → UI 갱신 순서로 정리한다.
  • 게임 B: haltWagers(host) 하나가 오토베팅을 멈추고 setNetworkReady(false)를 한다.

새 실패 경로를 만들 때 이 코드를 복제하지 않는다. 복제하면 한쪽만 갱신돼 잠금이 남거나 빠진다. 오토베팅은 응답 대기용 폴링이나 별도 타임아웃을 두지 않는다. 응답이 오지 않으면 어댑터 watchdog이 프로토콜 오류로 승격시키고, 그 경로가 정리 정책을 거쳐 오토를 멈춘다. 네트워크 실패로 오토는 일시정지하지 않고 정지한다.

잠금 플래그는 씬보다 오래 살아야 한다

섹션 제목: “잠금 플래그는 씬보다 오래 살아야 한다”

잠금은 씬을 다시 로드해도 살아남아야 한다. 두 게임은 같은 목적을 다른 위치로 풀었다.

  • 게임 A: 동결 상태를 dontDestroyOnLoad인 GameModule에 둔다(networkFrozen, roundGuardActive).
  • 게임 B: 잠금 플래그를 모듈 스코프 변수에 둔다. GameState는 씬과 함께 새로 만들어지므로, 거기 두면 잠근 뒤 씬이 다시 뜰 때 onAllState가 networkReady를 참으로 되돌린다. ESM 모듈은 페이지당 한 번만 평가되므로 GameModule에 두는 것과 같은 지속성을 준다. onAllState는 setNetworkReady(!isWagerLocked())로 그 값을 반영한다.

오류 안내의 소유자를 하나로 정한다

섹션 제목: “오류 안내의 소유자를 하나로 정한다”

안내 주체가 둘이라 겹칠 수 있다. 공용 Sequence는 결과 코드별 안내 팝업(점검·세션 만료 등)을 띄우고, 게임은 공용 팝업이 맡지 않는 베팅 실패에만 자체 팝업을 띄운다. 두 곳이 같은 오류를 각자 알리면 유저는 한 사고를 두 번 본다. 게임 B는 netErrorOwner(error) 하나가 판정한다.

소유자 대상
'common' 결과 코드가 있는 베팅 거절 + 베팅 외 이벤트 + 종료성 코드
'game' 결과 코드가 없는 클라이언트 자체 실패, 템플릿이 종료 코드로 잘못 분류한 API 서버 오류
'silent' 레이트리밋
  • 게임 시퀀스의 onNetError()는 콜백을 올린 뒤 'common'일 때만 공용 팝업으로 넘긴다. 콜백 전파는 판정과 무관하게 항상 일어난다. 게임 계층의 상태 복구는 안내 여부와 별개다.
  • 템플릿 표가 API 서버 호출 실패 코드를 로그인 실패·토큰 만료로 적어 종료성 목록에 넣은 경우가 있다. 이를 공용 팝업으로 보내면 버튼 없는 종료 팝업이 떠 게임이 멈춘다. 템플릿을 고치면 그 템플릿을 쓰는 모든 게임에 동기화해야 하므로, 실서버와 어긋난 분류는 게임 계층에서 바로잡는다.
  • 재시작만이 복구인 상황은 게임이 자체 팝업을 띄우지 않고 showRestartNotice() 한 창구로 보낸다. 실패가 이어져도(단절 → 잔액 확인 실패) 같은 팝업이 갱신되고 원래 원인이 가려지지 않는다. 이 안내는 높은 등급이라 뒤따르는 낮은 등급 안내에 덮이지 않는다.
  • 공용 시퀀스 인스턴스가 없어 안내가 통째로 사라질 상황이면 로그를 남기고 게임 팝업으로 대신 알린다. fail-closed로 잠긴 채 아무 안내도 없는 상태가 가장 나쁘다.
  • 게임 A는 안내를 계층이 나눠 갖는다. 단절·킥은 Sequence가, 프로토콜 오류 팝업은 게임 루트가 띄우므로 정리 정책 모듈은 토스트를 겹치지 않는다.

공용 안내 팝업은 종료성 안내를 높은 레벨로 띄우고, 그 뒤의 낮은 레벨 안내는 레벨 비교로 억제한다. 치명적 오류 위에 사소한 알림이 덮이지 않게 하기 위해서다. 확인 버튼을 누르면 종료성이 아닌 안내만 억제 기준을 되돌린다. 확인한 뒤에도 기준이 남으면 이후 안내가 조용히 사라지고, 종료성 안내는 닫아도 세션이 끝난 상태라 억제를 유지해야 한다.

서버 설정(game_setting)도 실패 경로다.

  • 게임 A: 실서버가 응답했는데 고지 대상 필드(베팅 한도·최대 배율·최대 노출·RTP)가 계약을 어기면 코드의 기본 설정으로 추측해 채우지 않고 fail-closed 한다. 도움말에 잘못된 수치를 고지하지 않기 위해서다. 목업은 경고만 남기고 진행한다.
  • 게임 B: 강도를 둘로 나눴다. 베팅 한도는 치명이다. 없으면 클라이언트 기본값으로 베팅을 열어 서버 한도를 넘는 요청을 보내므로 잠근다. 고지 필드는 경고다. 여기서 잠그면 서버가 필드를 안 보낼 때 게임이 뜨지 않는다.

도움말 노출 여부 같은 선택 필드는 서버 쪽에서도 필수 필드로 두지 않는다. 값이 없는 운영 설정에서 서버 예외가 나 게임 진입 자체가 막힌다. 클라이언트는 값이 false일 때만 감추고 없으면 노출해, 서버 배포 순서에 의존하지 않게 한다.

게임 B는 rejected이면 REQ_BALANCE를 보내고 그 조회가 끝날 때까지 라운드를 rolling에 둔다. 다음 판의 잔액 동기화가 이 응답을 자기 것으로 받지 않게 하려는 것이다. 조회가 실패해도 잠그지 않고, 서버 잔액이 없으면 베팅 전 잔액으로 풀어 베팅을 다시 연다. 결과 불명은 재조회하지 않고 잠근다.

잔액 이벤트는 요청과 짝이 없다. 어떤 잔액 응답이든 리비전을 올리고 대기 중인 waiter를 전부 깨운다. 잔액 요청이 레이트리밋이나 API 오류로 거절되면 대기를 바로 실패시키고, 응답이 아예 없을 때만 타임아웃까지 간다. RES_BET에 잔액이 들어 있으면 그 값을 그대로 쓰고 별도 조회를 보내지 않는다.

오토베팅은 규칙과 타이밍을 다른 파일로 나눈다.

  • 규칙 계층은 시간도 네트워크도 모른다. 베팅액 조정(On Win/On Loss 증감과 한도 클램프)과 정지 판정만 한다. 여기에 타이머나 서버 호출을 넣으면 규칙만 따로 검증할 수 없게 된다.
  • 루프 계층은 규칙을 모르고 언제 무엇을 기다릴지만 안다. 지연 상수는 한 객체에 모은다.
  • 사용자 STOP은 즉시 멈추지 않는다. 라운드 간격 대기는 바로 깨우지만 진행 중인 라운드는 끝까지 가고 다음 경계에서 종료한다.
  • 증감 결과는 서버 한도로 자르는 함수 하나를 거친다. 루프에 코드 상한을 또 두면 경로가 갈린다. 세션 중 서버 한도가 갱신되면 진행 중인 베팅액을 다시 클램프한다.
  • 정지 사유는 열거형 하나로 관리하고, 사용자 피드백(토스트, 베팅 UI 플래시, 공용 오류 문구)을 사유별로 나눈다.
  • 결과 팝업을 닫을 때 닫힘 콜백을 거치지 않으면 루프가 재개 신호를 못 받아 계속 대기한다. 반대로 새 BET이 시작될 때는 결과 팝업을 콜백 없이 조용히 닫는다. 오토 재개와 섞이면 안 되기 때문이다.
  • 게임 B는 결과 팝업을 라운드 안에서 닫힐 때까지 기다리므로 루프에 별도 일시정지 상태가 없다. 팝업은 탭하거나 최대 표시 시간이 지나면 닫혀 오토가 멈춰 서지 않는다.

정적 UI의 정본은 씬 에디터로 편집하는 game.prefab이다. 이 경계를 넘으면 에디터 편집이 코드에 덮여 사라진다.

  • 좌표·크기·폰트·색 초기값은 prefab에만 둔다. 코드는 읽어서 적용만 한다.
  • 렌더 순서도 prefab이 정한다. 코드에서 컨테이너를 만들어 addChildAt으로 끼우지 않는다.
  • 고정 노드 참조는 각 기능 컴포넌트의 @Serialize 필드로 받는다. 게임 루트가 모든 leaf를 대신 들지 않으며, 이름 조회는 규칙적 반복 집합(셀 0~24 등)에만 남기고 자기 하위에서만 찾는다.
  • 클릭은 prefab의 Button + 상태 스케일 + sendMessage가 기본이다. 코드로 클릭을 붙이는 폴백은 Collider 없는 노드에만 쓴다.
  • 캔버스 크기는 프레임워크에 맡기고 main.ts에서 renderer.resize로 고정하지 않는다. 고정하면 coverScreen: true가 무력화돼 여백이 캔버스 밖에 남아 PixiJS가 그리지 못한다.
  • prefab에 저장된 값이 디자인 의도와 다를 수 있다(disabled 스냅샷, 재사용 노드의 잔재 크기 등). 코드 덮어쓰기를 제거할 때는 prefab 값이 실제로 맞는지 먼저 확인한다.
game.prefab 노드별 component + serializeFields
├─ GameRoot → Menu/Auto/Result/Keypad/System, BettingController 등 컴포넌트 참조
└─ 각 컴포넌트 → 자기 Button/Toggle/Slider/Text/GameObject/Animation 참조
runtime @Serialize 필드를 검증하고 initialize/init으로 상태·협력 객체만 주입

보이지 않는 더미 노드의 위치·스케일·색을 코드가 읽어 계산에 쓴다. 지우면 레이아웃이 깨진다. marker가 담당하는 값은 노드 자체가 그 값을 눈에 보이게 표현하는 것(위치·크기·색)까지다. 노드가 숫자 보관함 역할만 하는 값은 marker로 두지 않고 상수로 둔다.

색 토큰은 prefab의 비활성 marker 노드가 소유하고, 부팅에서 전량 검증한다. marker가 빠지면 즉시 실패하며, 디자인 값 fallback을 코드에 두지 않는다. 컨트롤러가 생성 시점에 토큰을 읽으므로 팔레트 로드는 컨트롤러 생성보다 먼저 와야 한다.

prefab을 손으로 편집할 때의 함정

섹션 제목: “prefab을 손으로 편집할 때의 함정”

대부분 컴파일 에러가 아니라 화면에서만 드러난다. 손 편집은 JSON을 재직렬화하지 않고 해당 필드만 치환해 원문 포맷을 보존한다.

  • 자식은 배열 맨 뒤에 추가한다. @Serialize 참조 경로가 자식 인덱스 기반이라, 앞이나 중간에 끼우면 뒤 노드의 인덱스가 밀려 그 부모 기준 배선이 통째로 어긋난다. 탭 헤더 앞에 배경 Sprite 하나를 넣었더니 탭이 화면에서 사라지는 식이다.
  • LayoutGroup이 붙은 부모에서는 position이 무시된다. 좌표를 직접 잡아야 하는 노드는 LayoutGroup이 없는 부모에 둔다. 부모를 옮기면 listener 상대 경로도 고친다.
  • Text의 기준점은 align/alignVertical이 정한다. pivot은 Text 위치에 영향이 없다. 캡션(top)과 값(center)을 같은 기준으로 계산하면 값이 높이의 절반만큼 올라가 캡션을 덮는다.
  • 텍스처를 바꾸는 노드는 트리에 붙고 켜진 상태에서 바꾼 뒤 updateTransform(true)를 부른다. 프레임워크가 비활성·트리 밖 노드의 transform을 적용하지 않아, leaf가 자리표시 이미지 기준 배율로 남고 수십 배 크기로 그려진다.
  • 좌표는 눈대중으로 고치지 않고 dev 빌드에서 실제 getBounds()를 측정한다.
  • prefab을 통째로 쓰는 스크립트는 명시적 강제 플래그가 없으면 실행을 거부하게 만든다.

coverScreen: true는 콘텐츠 크기를 유지한 채 캔버스만 창 전체로 확장하고, 게임 루트에는 마스크가 없다. 그래서 디자인 좌표 x<0, x>1080도 그대로 그려져 레터박스 여백 위에 보인다. 세로 폰은 좌우 여백이 0이라 드러나지 않고 가로로 넓은 창에서만 보이므로 놓치기 쉽다.

화면 밖에서 들어오는 연출을 쓰는 동적 레이어는 prefab에 builtin Mask(Rect)로 1080×1920 클립을 두고, 런타임은 그 아래에 동적 노드만 만들고 지운다. 검증은 가로로 넓은 창으로 한다. 콘텐츠와 비율이 같은 캡처 크기로는 이 결함을 잡지 못한다.

팝업은 성격이 다른 두 계열이다.

  • 게임 팝업: 트윈이 거의 없다. resident 팝업과 최초 오픈 시 복제한 지연 팝업 모두 이후에는 active 토글로 재사용한다.
  • 공용 안내: 공용 팝업 컴포넌트 하나로 처리한다. dontDestroyOnLoad인 Sequence가 들고 있어 game 씬에서도 그대로 동작한다.

지연 팝업 루트는 resident 토스트 노드 바로 앞에 삽입해 일반 UI보다 위, 토스트보다 아래에 둔다. 숫자 인덱스를 하드코딩하지 않고 토스트 노드의 현재 자식 인덱스를 기준으로 삼는다.

공용 안내의 dim + 입력 차단막은 템플릿에서 팝업 관리자가 토글한다. 게임 A처럼 팝업 관리자를 가져오지 않은 저장소에서는 차단막이 한 번도 켜지지 않아, 안내가 떠 있는 동안 뒤의 인게임 버튼이 그대로 눌렸다. 공용 팝업이 차단막을 직접 배선해 show()에서 켜고 hide 클립 종료 이벤트에서 끄도록 로컬 패치했다. 차단막은 팝업의 형제이자 앞 인덱스여야 한다. 뒤로 가면 팝업 버튼 자신이 차단막에 가린다. 템플릿을 다시 받을 때 이 차이를 되돌리면 같은 결함으로 돌아간다.

팝업 배선이 없으면(프리뷰 등) 경고만 남기고 안내를 건너뛴다. DOM으로 팝업을 만들어 대신 띄우지 않는다.

리플레이는 viewer 방식이다. 게임 본체가 ?replay=true&bet_id=… 같은 파라미터로 재생 모드로 뜬다.

Intro → Sequence.startReplay → 게임 씬 로드 → Sequence.afterLoadGame → deliverReplay
→ GET {재생 API 호스트}/client/replay?operator&game_id&bet_id&hash
→ replayGame.parse → GameModule.onReplay → GameRoot.onReplay
→ (A) 어댑터.startReplay(재생 서버) → runReplay: BET → SELECT… → (CASH_OUT) → 정산 표시 → ReplaySession.finish
→ (B) BetFlowController.runReplay: 굴림 → 결과 팝업 → ReplaySession.finish

서버 결과는 평소 베팅 응답과 같은 검사·변환 경로를 타고, 화면은 평소 연출을 그대로 재생한다.

  • 게임 A는 재생 서버가 재생 데이터로 BET / SELECT / CASH_OUT wire 응답을 만들고, 어댑터가 소켓 대신 이 응답을 평소와 같은 경로(검증 → 내부 DTO → RoundController)로 흘려보낸다. 요청도 평소처럼 단일 진입점으로 나가고, 응답 대기 잠금과 라운드 식별자 대조도 그대로 걸린다.
  • 게임 B는 재생 결과를 평소 베팅 응답과 같은 parseBetRes·decodeBetRes로 다시 확인한다.
  • 베팅, 잔액 동기화, 히스토리 기록은 하지 않는다. 잔액은 0에서 시작해 정산 후 그 판의 총 지급액을 보인다.

재생 데이터는 { result_code, error_msg, value: { config, result } } 형태를 가정하고, 서버 키를 보는 곳은 parse 한 곳이다. 서버 필드명이 바뀌면 그 함수만 고친다. 검사는 두 단계다. parse가 키와 타입을 맞추고, 게임 규칙 검사(보드 크기, 인덱스 범위·중복, 결과 길이 등)가 이어진 뒤 평소 응답 검증을 한 번 더 지난다. 어긋나면 ReplayParseError로 재생 실패 팝업이 뜬다.

게임 동작에서 지킬 것:

  • 중복 재생은 플래그로 막는다. 단계 대기에는 시간 제한을 두고, 예외는 ReplaySession.fail()로 보낸다. 씬이 파괴되면 결과를 무시한다.
  • 다시 보기 버튼은 BET 버튼 자리에 두고 canReplay()일 때만 켠다.
  • 재생 중에는 입력·오토·메뉴·베팅액 조정을 잠근다. 프리스핀과 튜토리얼은 로그인 정보가 없어 뜨지 않는다.
  • 같은 bet_id는 항상 같은 그림이어야 한다. 게임 B는 연출용 난수를 bet_id에서 시드한 생성기로 만들되, 착지 위치·도착 순서·묶음 크기 생성기를 따로 만든다. 한쪽이 뽑는 횟수가 화면 크기에 따라 달라져도 다른 쪽이 흔들리지 않는다. 결과를 가리지 않는 장식 효과(파티클, 효과음 변주)만 Math.random이다.

로컬 확인은 ?replay=true&bet_id=<번호>&server=local로 재생 목업을 읽는다. bet_id가 판을 고르고 같은 번호는 항상 같은 판이다. 정상 판 몇 개와 함께 오류 봉투, 형식 오류 판을 번호로 준비해 실패 팝업 경로도 로컬에서 본다.

목업 판정은 한 곳에서만 한다. 게임 B는 connect()에서 템플릿과 같은 식 IS_DEV && (useLocalServer || !apiUrl)으로 정하고, 게임 쪽에서 URL·호스트를 다시 파싱하지 않는다.

project.config.ts의 apiUrl·betDetailUrl은 의도적으로 비워 둔다. 값을 넣으면 배포 환경에서 참조되지도 않는 내부 호스트 문자열이 빌드 청크에 남아 노출된다. 배포 환경의 URL은 접속 호스트로 계산하므로 설정값이 필요 없다.

목적 방법
localhost 목업(기본) 없음. apiUrl이 비어 있어 목업으로 뜬다
배포 호스트에서 목업 ?server=local
localhost에서 실서버 ?server=real&apiUrl=<url>

?apiUrl= 단독은 동작하지 않는다. 목업 판정이 먼저 참이 되면 apiUrl을 빈 문자열로 덮어 목업으로 간다. ?server=real이 그 플래그를 내리는 역할이라 둘을 함께 줘야 한다. ?server=real만 주고 apiUrl을 생략하면 “apiUrl is not set” 안내가 뜬다. 설정값이 비어 있으니 fail-closed로 정상이다. 서버 전환 파라미터는 IS_DEV 전용이라 production 빌드에서는 무시된다. IS_DEV는 QA 빌드에서도 참이므로, QA 빌드가 실서버로 붙으려면 useLocalServer가 false여야 한다.

게임 B의 목업은 URL 파라미터로 실패를 주입한다. 목업에서만 파싱하므로 실서버 연결에는 영향이 없고, 알 수 없는 값은 무시한다.

파라미터 동작
mock=timeout 베팅 응답을 보내지 않아 10초 결과 불명 경로를 재현한다
mock=drop 베팅 요청을 받은 뒤 로컬 연결을 끊는다
mock=nobalance 잔액 부족으로 거절한다
mock=code:<n> 결과 코드 n으로 거절한다
mock=slow:<ms> 정상 베팅 응답과 잔액 응답을 늦춘다
mock=ratelimit 실서버와 같은 레이트리밋 오류 봉투를 보낸다
mock=stale · mock=stale:<ms> 다른 round_id 응답을 먼저 보낸 뒤 정상 응답을 보낸다
seed=<integer> 같은 URL에서 같은 난수 결과 순서를 재현한다
force=win|lose 승리·패배 경계값을 강제한다. seed보다 우선한다

파라미터는 조합할 수 있다. ?mock=stale:11000&seed=7은 stale 응답이 버려진 뒤 정상 응답보다 10초 타임아웃이 먼저 오는 경계를 관찰한다.

목업이 실서버와 다르게 동작하는 부분은 기록해 둔다. 예를 들어 만료된 프리스핀 ID를 목업이 거절하지 않으면, 로컬에서는 클라이언트 가드와 결과 팝업까지만 검증되고 “만료 후 서버가 베팅을 거절하는” 경로는 실서버 QA로 확인해야 한다.

템플릿에서 내려온 코드 다루기

섹션 제목: “템플릿에서 내려온 코드 다루기”

src/common, src/network, src/intro, main.ts의 대부분은 공용 게임 템플릿에서 내려온 파일이다. 업스트림에서 다시 받을 수 있어야 하므로 이동하거나 rename하지 않고, 수정도 꼭 필요한 범위로 제한한다. 게임 전용 확장은 src/game/에 두고 상속·주입으로 붙인다. PascalCase 파일명도 템플릿 원본 이름이라 kebab-case로 바꾸지 않는다. 공용 파일을 고쳤다면 템플릿으로 업스트림할지 함께 판단한다.

템플릿은 git remote 머지로 들어온다. 로컬 템플릿 체크아웃은 오래됐을 수 있어서, 거기 없는 파일이 곧 “저장소가 만든 파일”이라는 뜻이 아니다. 판별은 머지 라인으로 한다. 템플릿 커밋은 머지의 second parent 쪽에만 있고 first-parent 이력에는 나타나지 않는다.

터미널 창
# 저장소가 직접 만진 이력만 (first-parent = 우리 작업)
git log --first-parent --oneline -- <path>
# 전체 이력 (템플릿 머지 포함)
git log --oneline -- <path>
# 둘의 차이가 템플릿에서 온 변경이다.
# first-parent 결과가 비어 있으면 → 100% 템플릿 원본, 손대지 않는다.
# 템플릿 머지 커밋 목록
git log --oneline --merges | grep game-template

import 0건은 미사용 기준이 아니다

섹션 제목: “import 0건은 미사용 기준이 아니다”

@RegisterComponent 컴포넌트는 import.meta.glob('/src/**/*.ts')로 지연 등록되므로, 아무도 import하지 않아도 prefab이 componentName 문자열로 생성한다. 판정에는 세 경로를 다 본다.

터미널 창
grep -rn "Foo" src --include=*.ts # 1) static import
grep -rn "@RegisterComponent" src --include=*.ts # 2) 등록명(마지막 세그먼트)
grep -rl '"Foo"' raw-assets --include=*.prefab --include=*.scene # 3) prefab componentName

반대로 prefab에 componentName이 남아 있어도 등록된 클래스가 없으면 노드는 조용히 생성되지 않는다. @RegisterComponent 문자열은 파일명 rename과 무관하며, 바꾸면 prefab이 컴포넌트를 못 찾는다.

  • 저장소의 언어 파일은 생성물의 사본이다. 정본은 기획 저장소의 원본 시트 → 다국어 생성 → 언어팩 → 게임 저장소 사본의 체인이다. 맨 앞의 시트를 고쳐야 다음 export에서 되돌아가지 않는다.
  • 전 로케일이 같은 키 집합을 유지한다. 키를 전 로케일에 먼저 넣고 그다음 코드·prefab을 바꾼다.
  • common → game 순으로 병합돼 game이 이긴다. 양쪽에 같은 키가 있어도 “중복”으로 지우지 않는다. 지우면 표기가 조용히 바뀐다.
  • 로케일 조회는 한 함수만 지나가게 한다. 프레임워크 조회 함수는 키가 없으면 키 문자열을, 값이 비면 "<key> not found"를 돌려주는데 둘 다 truthy라 if (text) 가드를 통과해 화면에 키가 노출된다. 조회 창구가 이를 걸러 null을 돌려준다.
  • prefab 정적 라벨 ↔ 로케일 키 매핑은 표 하나에 모은다. 같은 노드를 두 키로 채우는 드리프트를 막고, 감사 스크립트가 이 표를 읽어 현지화되지 않은 노드를 찾는다.
  • 공용 로케일의 숫자 키는 실서버 결과 코드 체계와 같은 뜻이고, 템플릿의 결과 코드 이름표와는 어긋날 수 있다. 그래서 베팅 오류 문구는 실서버 의미가 확인된 코드만 공용 로케일에서 조회하고, 나머지는 서버 문구 → 템플릿 폴백 순서로 쓴다. 게임 계층에 결과 코드별 문구 표를 따로 두면 드리프트가 생긴다.
  • 지원 로케일에 없는 언어로 문구를 박지 않는다. 그 언어가 모든 로케일에서 그대로 노출된다.
  • 도움말 본문은 <data>필드명</data> 치환과 <visible={필드명}> 숨김 태그를 쓴다. 치환 실패는 태그를 화면에 남기므로, 도움말에 태그 문자열이 보이면 번역 오타가 아니라 서버 응답에 그 필드가 없다는 신호다.
  • 폰트는 번들 에셋 하나만 쓰고 index.html에 @font-face를 두지 않는다. 번들이 같은 파일을 이미 내려받으므로 CSS로 한 벌 더 올리면 같은 바이트를 두 번 배포한다.
  • 굵기는 CSS fontWeight가 아니라 폰트 파일 선택이다. FontFace가 weight normal 단일로 등록되므로 굵은 글리프에 fontWeight: '700'을 주면 브라우저가 synthetic bold를 덧씌워 글자꼴이 왜곡된다. 굵기 → 파일 매핑을 한 모듈에 두고 그 밖에서는 fontWeight를 지정하지 않는다.
  • prefab이 소유하는 fontSize·fill은 코드가 다시 넘기지 않는다. 넘기면 에디터 편집이 조용히 무효화된다.

렌더링: GPU 복구 워치독과 오토베팅

섹션 제목: “렌더링: GPU 복구 워치독과 오토베팅”

프레임워크는 iOS GPU 회수에 대비해 두 가지를 한다. 컨텍스트 손실(webglcontextlost) 시 지수 백오프로 restoreContext, 그리고 이벤트 없이 텍스처만 회수된 경우 runners.contextChange로 전체 재업로드와 canvas Text 재생성이다. 재업로드는 터치 기기에서만 돈다.

트리거 조건
사용자 입력(pointerdown·touchstart·keydown) 마지막 활동에서 30초 이상 경과
탭 복귀(visibilitychange·pageshow·focus) 세 이벤트가 겹치면 rAF로 1회 병합
5초 워치독 30초 이상 방치 + 마지막 재업로드에서 8초 경과 + 화면 표시 중

게임 A는 이 복구를 켰다가 껐다(config.rendering.gpuRecovery = false). 워치독이 입력 없는 구간을 방치로 판정해 주기적으로 전체 텍스처를 다시 올리는데, 오토베팅은 사용자 입력 없이 라운드를 돌아 정확히 그 조건에 들어가 히칭이 났다. headless 계측 스크립트로 입력 없는 구간의 재업로드 횟수를 세어 확인했다.

텍스트 재생성이 필요한 이유는 PixiJS v8 CanvasTextSystem이 업로드 직후 소스 캔버스를 CanvasPool로 반납하는 기본 동작 때문이다. 백버퍼 상한과 해상도 산정(resolution = 0, 자동)은 프레임워크에 맡기고 게임이 값을 복제하지 않는다.

  • terserOptions.mangle.properties를 켜지 않는다. @Serialize 직렬화가 프로퍼티명에 의존해 prefab 로딩이 실패한다.
  • reflect-metadata를 프레임워크 청크로 옮기지 않는다. CJS interop 순환 참조로 production에서 Cannot access 'M' before initialization이 난다.
  • 게임 B는 Pixi·DOM을 타지 않는 순수 계산(배당 계산, wire 스키마 검증, 오류 안내 소유자 판정, 목업 정산, 설정 계약, 잔액 보류, 오토 증감)을 vitest로 검증한다. 테스트는 src/ 밖 tests/에 두고 src/ 구조를 미러링한다. src/ 안에 두면 dist에 vitest 런타임과 테스트 청크가 섞인다.
  • 정산·확률·금액 계산을 건드리면 같은 커밋에서 테스트를 함께 고친다.
  • 게임 A는 유닛 테스트 없이 npx tsc --noEmit과 playwright-core 스모크로 검증한다. 스크립트는 시스템 Chrome/Edge를 headless로 띄워 dev 서버에서 시나리오를 돌리고 결과 JSON과 스크린샷을 남긴다.
  • QA 스크립트가 부팅 완료를 기다릴 수 있게 window.__READY__ 같은 신호를 두고 Window 타입에 선언한다.
  • 금액 칸 넘침 회귀는 기호·천단위 구분자·소수 구분자가 모두 붙어 금액 문자열이 가장 긴 통화·로케일 조합으로 돌린다. 거기서 안 넘치면 다른 통화도 안 넘친다.
  • lock 파일을 지우고 npm install하지 않고 npm ci를 쓴다. vitest 4 환경에서 lock 삭제 후 재설치가 깨진다.
하면 안 되는 것 무엇이 깨지나
@RegisterComponent 등록 문자열 변경 prefab이 컴포넌트를 못 찾아 씬 로딩에 실패하거나 노드가 조용히 생성되지 않는다
UI 코드에서 wire 필드·서버 심볼 직접 사용 단일 번역 지점이 무너져 실서버와 목업 동작이 갈라진다
단일 진입점 밖에서 금전 요청 전송 잠금·잔액 보류·대기 등록이 빠진다
목업이 내부 DTO로 직접 응답 로컬과 실서버가 다른 경로를 타서 변환 버그를 로컬에서 못 잡는다
서버 오류 응답을 결과 불명으로 분류 잔액 부족 한 번에 게임이 영구 동결된다
목업 거절 코드를 하나로 뭉치기 잔액 부족에 “세션 만료”가 뜨고 코드별 분기가 로컬에서 검증되지 않는다
잠금 플래그를 씬과 함께 사는 객체로 옮기기 씬 리로드 뒤 잠금이 풀려 fail-closed가 fail-open이 된다
실패 경로마다 정리 코드 복제 한쪽만 갱신돼 잠금이 남거나 빠진다
설정 계약 위반 시 코드 기본값으로 대체 진행 도움말에 실제 서버와 다른 RTP·한도를 고지하게 된다
부가 필드 형식 오류로 응답 전체를 거부 모든 라운드가 결과 불명으로 잠긴다
부가 필드에 폴백 id 생성 실서버에 없는 라운드를 조회한다
셀 선택 동시 전송 in-flight 가드를 우회해 응답에 없는 셀이 열린다
규칙 계층에 타이머나 서버 호출 추가 순수 규칙 계층이 오염돼 규칙만 따로 검증할 수 없게 된다
결과 팝업을 닫힘 콜백 없이 닫기 오토베팅이 재개 신호를 못 받아 계속 대기한다
좌표·폰트·색을 코드로 이동 에디터 편집이 반영되지 않는다
prefab 자식을 배열 중간에 삽입 인덱스 기반 @Serialize 경로가 밀려 배선이 통째로 어긋난다
레이아웃·색 marker 노드 삭제 부팅 검증이 실패하거나 레이아웃 계산이 깨진다
동적 레이어의 클립 마스크 제거 가로 창 레터박스 여백에 화면 밖 연출이 보인다
게임 계층에 결과 코드 문구 표 추가 공용 로케일 숫자 키와 드리프트가 생긴다
공용 프레임워크 서브모듈 직접 수정 필요한 확장은 상속이나 주입으로 한다
테스트를 src/ 안에 추가 프로덕션 번들에 vitest 런타임이 섞인다