개발자가 이 문서만 보고 Godot 프로젝트를 만들고, 데이터를 읽고, 저장하고, 서버와 주고받고, 스토어에 올릴 수 있게 쓰는 것이 목표입니다. 표의 숫자 중 파일 크기·저장 크기·요청 예산·보상 예시·검사 결과는 gen/tech.py 가 실제 데이터로 계산한 값입니다. 바깥 사실은 조사 문서를 따르고 확인 못 한 것은 「미확인」으로 적었습니다.
이 문서는 「브루니 키우기: 바랜 섬」을 Godot 로 실제로 짓는 데 필요한 구조를 정합니다. 무엇을 어느 파일에 두고, 어떤 함수가 무엇을 하며, 데이터와 저장이 어떤 모양이고, 서버가 어디까지 맡는지를 적습니다. 수치 밸런스와 화면 그림은 다른 문서가 정하고 여기서는 그 값을 담는 그릇을 정합니다.
항목
값
근거
엔진
Godot 4.7.2 stable · GDScript (표준판, .NET 아님)
바이블 12-3 · /me/game-spec 01장
렌더러
Compatibility (gl_compatibility) — 모바일 기본값이 Mobile 이라 직접 바꿈
godot-tech §4-1
화면
세로 720×1280 기준 · stretch mode canvas_items · aspect keep_width(긴 화면은 위아래로 늘어남)
바이블 1장
Android
targetSdk 36 · minSdk 24 · AAB · 커스텀 빌드 템플릿
godot-tech §4-2 (Play: 2026-08-31부터 API 36)
iOS
1차판 출시 8주 뒤(2027-08-09) — 같은 프로젝트에서 내보냄
바이블 12-1
전투 틱
100ms 고정 · 웨이브 간격 1000ms
data/combat.json
데이터
res://data/*.json 17개 — 부팅 때 오토로드 DB 에 상주
바이블 12-3
저장
user://save.json — 원자적 쓰기 · HMAC-SHA256 · AES 암호화 · 서버 백업
godot-google-play-billing 3.2.0 · iOS godot-storekit2(대체 hrk4649) · 서버 검증 필수
바이블 12-3 · godot-tech §3
광고
보상형만 6자리 · godot-admob (대체 Poing Studios v5)
바이블 9-4
다국어
한국어 먼저 · UI 문자열 CSV(keys,ko,en) · 대본은 JSON {ko,en}
godot-tech §6
구성도
game-design/data설계 JSON (문서와 같은 원본)
export_data.py
res://data런타임 JSON 17개
부팅 1회
DB 오토로드Dictionary 상주
게임 (기기)user://save.json
HTTPS
Cloudflare Worker/v1/* 13개
SQL
D1계정·백업·결제·기록
스토어 결제Play Billing · StoreKit 2
토큰/JWS
Worker 검증Google API · Apple 서명
지급 결정
우편함「수령」 = 사용 시작
bruni03
bruni · 게임기술 명세서
02 / Project Tree
프로젝트 폴더 구조
게임 프로젝트는 bruni-world 와 다른 비공개 저장소에 둡니다(24장 「바이블 추가 제안」). 폴더 이름은 역할 하나만 뜻하게 정했습니다 — 화면 코드는 scenes/, 노드 없는 계산은 core/, 전역 상태는 autoload/ 입니다. 계산을 화면 코드에서 떼어 두어야 전투와 모집을 그리기 없이 시험할 수 있습니다.
경로
담는 것
비고
project.godot
프로젝트 설정 — 렌더러 gl_compatibility · 창 720×1280 · stretch canvas_items/keep_width · 오토로드 14개
게임용 Cloudflare 계정에 배포 (바이블 12-2). 모집 확률표는 빌드 때 data/gacha.json 을 그대로 묶음
(저장소) design/
game-design/data 를 복사해 둔 원본(진실은 bruni-world 저장소)
export_data.py 의 입력
이름 규칙파일·폴더는 snake_case, 클래스는 PascalCase(class_name), 시그널은 과거형 stage_cleared, 상수는 UPPER_CASE. 그림·소리 파일명은 바이블 12-4 를 그대로 씁니다(char/c001_battle.png). 데이터 id 는 코드에 문자열로 적지 않고 DB 조회로만 씁니다 — 예외는 브루니 c001 하나입니다.
bruni04
bruni · 게임기술 명세서
03 / Autoloads
자동 로드 목록과 부팅 순서
오토로드는 14개입니다. 순서가 곧 초기화 순서라 위 줄이 아래 줄에 의존하지 않게 배치했습니다. 화면 스크립트는 다른 화면을 직접 부르지 않고 오토로드와 Bus 시그널만 씁니다.
#
이름
파일
책임
의존
1
Log
autoload/log.gd
로컬 로그 링버퍼(최근 500줄) · 오류 수집 · 디버그 빌드 화면 출력
—
2
DB
autoload/db.gd
res://data/*.json 을 부팅 때 한 번 읽어 Dictionary 로 상주 · id 조회 · 공개 차수 필터
Log
3
Clock
autoload/clock.gd
서버 시각 동기·세션 ticks 기준 현재 시각 · 04:00 KST 일일 리셋·월요일 주간 리셋 판정
Log, Net(지연 연결)
4
Save
autoload/save.gd
계정 상태 Dictionary 보관 · 원자적 저장 · HMAC 서명 · 버전 이전 · 서버 백업
DB, Clock, Log
5
Wallet
autoload/wallet.gd
재화 12종 증감의 유일한 입구 · 빛구슬 유료/무료 분리 장부 · 메모리 가림(XOR)
Save, Bus
6
Bus
autoload/bus.gd
전역 시그널 모음 — 미션·이벤트 기록·화면 갱신이 구독
—
7
Net
autoload/net.gd
HTTPRequest 풀 · 재시도 큐(지수 대기 2·4·8·16·32초) · 요청 서명
Log
8
Remote
autoload/remote.gd
원격 설정(이벤트 달력·배너·최소 버전·기능 스위치) 받아 캐시
Net, Save
9
Analytics
autoload/analytics.gd
이벤트 기록 큐 · 30초 또는 20건마다 묶어 전송 · 오프라인 보관(최대 500건)
Net, Clock, Bus
10
Ads
autoload/ads.gd
보상형 광고 6자리 단일 입구 · 하루 한도 · 광고 생략권 분기 · UMP 동의
Wallet, Save, Analytics
11
IAP
autoload/iap.gd
결제 플러그인 감싸기(Android/iOS 한 인터페이스) · 서버 검증 · 우편 지급
Net, Save, Analytics
12
Missions
autoload/missions.gd
Bus 시그널을 받아 일일·주간·업적 120개 진행도 갱신
Bus, Save, DB, Clock
13
Audio
autoload/audio.gd
배경음 교차 재생 · 효과음 풀(동시 8) · 설정 음량
Save
14
Router
autoload/router.gd
화면 전환 · 팝업 스택 · Android 뒤로 가기 · 로딩 가림막
Audio
부팅 순서 (boot.gd)
단계
하는 일
실패하면
목표 시간
1
DB.load_all()
오류 화면 + Log 전송 시도 (데이터 파일이 깨진 빌드)
800ms 이하
2
Save.load_or_new()
save.bak → 새 계정 + 「복구 코드가 있으면 설정에서 복구」 안내
100ms 이하
3
Clock.sync() — 3초 제한
오프라인 모드 — 기기 시계 + pending_verify
3,000ms 상한
4
Remote.fetch() — 비동기
마지막 캐시
기다리지 않음
5
Missions.reset_if_needed()
—
—
6
IAP.init_store() · Ads 초기화 — 비동기
상점 버튼만 비활성
기다리지 않음
7
첫 실행이면 title, 아니면 main(home)
—
합계 3.0초 이내
bruni05
bruni · 게임기술 명세서
04 / Scenes
씬 목록 (33개)
화면 하나 = 씬 하나입니다. 와이어프레임·문구·이동 지도는 화면 명세서가 정하고, 여기서는 파일과 루트 노드만 고정합니다. 화면 명세서의 화면 id 는 이 표의 id 와 같아야 합니다.
id
파일
루트
무엇
1차판
boot
scenes/boot/boot.tscn
Control
로고 · DB 적재 · 저장 불러오기 · 서버 시각 · 원격 설정
●
title
scenes/title/title.tscn
Control
첫 실행 약관·개인정보 동의 · UMP 동의 · 클라우드 복구 버튼
●
main
scenes/main/main.tscn
Node
상주 뼈대 — 화면 칸(ScreenSlot) · 팝업 층 · 재화 막대 · 하단 탭
●
home
scenes/home/home.tscn
Control
방치 사냥 화면(전투 축소판) · 방치 보상 버튼 · 판 도전
●
battle
scenes/battle/battle.tscn
Node2D
판 전투 — 웨이브·보스·결과
●
battle_result
scenes/battle_result/battle_result.tscn
Control
승패·보상·다음 판/재도전
●
stage_map
scenes/stage_map/stage_map.tscn
Control
장 지도 · 판 목록 · 추천 전투력
●
idle_reward
scenes/idle_reward/idle_reward.tscn
Control
방치 보상 팝업 · 광고 2배 · 빠른 방치
●
pot
scenes/pot/pot.tscn
Control
물감 항아리 뽑기·항아리 레벨·자동 판매 규칙
●
gear
scenes/gear/gear.tscn
Control
브루니 장비 6부위 · 가방 · 강화 · 제작
●
bruni
scenes/bruni/bruni.tscn
Control
브루니 레벨·색 전환·능력치
●
companions
scenes/companions/companions.tscn
Control
동료 목록 · 정렬·필터
●
companion_detail
scenes/companion_detail/companion_detail.tscn
Control
레벨업·승급·각성·스킬 강화·인연·선물
●
party
scenes/party/party.tscn
Control
출전 5칸(전열 2·후열 3) 편성
●
recruit
scenes/recruit/recruit.tscn
Control
모집 배너·1회/10회·천장 교환·확률 보기 버튼
●
recruit_result
scenes/recruit_result/recruit_result.tscn
Control
모집 연출·결과 카드
●
prob_info
scenes/prob_info/prob_info.tscn
Control
확률 정보 — 동료·항아리·상자 개별 확률, 천장 횟수별 확률
●
shop
scenes/shop/shop.tscn
Control
빛구슬·꾸러미·정기권·원정 상점·빛구슬 상점
●
pass
scenes/pass/pass.tscn
Control
성장 패스 30단계
●
mission
scenes/mission/mission.tscn
Control
일일·주간·업적 탭
●
attendance
scenes/attendance/attendance.tscn
Control
출석 28칸
●
story_list
scenes/story_list/story_list.tscn
Control
본편 장·화 목록 · 다시 보기
●
story_player
scenes/story_player/story_player.tscn
Control
대본 재생 — 배경·서 있는 그림·대사·선택지·건너뛰기
●
char_story
scenes/char_story/char_story.tscn
Control
개인 이야기 목록(동료별 3화)
●
dungeon
scenes/dungeon/dungeon.tscn
Control
일일 던전 3곳 입장
●
tower
scenes/tower/tower.tscn
Control
시련의 탑 100층
—
raid
scenes/raid/raid.tscn
Control
주간 토벌 요일 보스
—
event_hub
scenes/event_hub/event_hub.tscn
Control
진행 중 이벤트·이벤트 판·이벤트 상점
●
mail
scenes/mail/mail.tscn
Control
우편함 — 결제 지급·보상·「수령」
●
relic
scenes/relic/relic.tscn
Control
유물 24종 보유 효과·강화
●
settings
scenes/settings/settings.tscn
Control
음량·배속·필살기 수동·언어·계정 연결·복구 코드·약관
●
popup_confirm
ui/popup_confirm.tscn
PanelContainer
공통 확인 창(재화 소모 확인·청약철회 불가 문구)
●
toast
ui/toast.tscn
PanelContainer
짧은 알림 2초
●
bruni06
bruni · 게임기술 명세서
05 / Scene Trees
씬 트리
자주 바뀌지 않는 큰 씬의 노드 구성입니다. 들여쓰기 칸 수가 깊이입니다. 판정 코드는 그리기 노드에 넣지 않습니다 — unit.gd 는 그리기만, 판정은 battle.gd 입니다.
서버가 돌려준 모집 결과 1건 적용 — pull.seq ≤ save.gacha.seq 면 건너뜀(같은 결과 두 번 적용 방지). 신규면 획득, 중복이면 기억 조각·무지개 조각 환원(gacha.json duplicate), gacha.log 앞에 넣고 500건 넘으면 뒤에서 버림
core/gacha_apply.gd
verify_sig(pull: Dictionary) -> bool
응답 서명(Ed25519, 공개 키는 앱 상수) 확인 — 틀리면 적용하지 않고 Log + save_tamper 기록
core/pot_roll.gd
draw(pot_lv: int, rng: Rng) -> String
gacha.json pot.levels 가중치 → 등급 → 그 등급 장비 12종 균등
core/rng.gd
Rng.new(seed: int, stream: int)
PCG32(pcg_basic) — gen/pcg32.py 와 같은 정의. stream 1 전투 · 3 항아리 · 4 드롭 · 5 상점 진열(2 모집은 서버). Godot RandomNumberGenerator 는 randf() 가 u32 를 여러 개 쓸 수 있어 쓰지 않음
pay = gem·ticket·free·ad. 실행 전 확인 창(청약철회 불가 문구). 서버 연결 없으면 버튼 비활성. await Net POST /v1/gacha/pull → GachaApply.verify_sig → apply → Wallet 차감(서버가 정한 무료·유료 분할 그대로) → 즉시 write_now
scenes/recruit/recruit.gd
exchange_stardust(char_id: String) -> void
별가루 200 → 픽업(또는 상시 SSR) 1명
scenes/prob_info/prob_info.gd
render(table_id: String) -> void
DB 의 gacha.json 표를 그대로 그림 — 숫자를 코드에 적지 않음
scenes/story_player/story_player.gd
play(ep_id: String) -> void
대본 로드(장 단위 파일) → 줄 재생
scenes/story_player/story_player.gd
_exec(cmd: Dictionary) -> void
{op, args} — 연출 7종(bg·bgm·se·show·hide·shake·flash)은 즉시, 진행 6종(cutscene·battle·goto_stage·join·unlock·chapter_end)은 화 끝에 실행. 목록은 script_ch1.json commands
모집 판정의 유일한 자리. gacha_state 읽기 → 비용을 무료(freeHave 한도) 먼저·유료(gem_ledger 잔액) 나중으로 나눔 → 유료 부족이면 402 → PCG32(stream 2, 시드 = 서버 난수 64비트) 로 등급·개별 → 천장·입문·10번째 칸 반영 → gacha_pulls·gacha_state·(유료분 있으면) gem_ledger 를 한 batch 로 쓰기 → {seq, ids, free_used, paid_used, stardust, state, sig}
server/src/gacha.ts
sign(res: PullRes): string
Ed25519(비밀 키 wrangler secret) — 앱은 공개 키로 확인
server/src/save.ts
restore(uid: string): Promise<SaveRes>
GET /v1/save — 백업 본문의 wallet.gem_paid·gem_paid_lots 를 gem_ledger 잔액으로 맞추고, gacha.seq 보다 뒤인 gacha_pulls 를 replay[] 로 같이 보냄(되돌린 저장에서도 서버가 굴린 결과는 사라지지 않음)
server/src/save.ts
check(uid: string, body: Save, prev: Save | null): Flag[]
PUT /v1/save 이상치 검사 — 19장 표
bruni13
bruni · 게임기술 명세서
06 / Mission Hooks
미션 유형 38종 → 발신 위치
Missions.on_event(type, amount, ctx) 를 누가 언제 부르는지 정한 표입니다. 행은 missions.json 에 실제로 있는 유형 38종 전부이고, 표에 없는 유형이 데이터에 생기면 gen/tech.py 가 멈춥니다. 집계 방식은 셋입니다 — add 는 진행도에 amount 를 더하고, max 는 지금 상태값으로 덮고(큰 쪽), has 는 ctx 가 목표와 맞으면 1 입니다. max·has 유형은 부팅·저장 복구 뒤 Missions.recheck_all() 이 저장 상태에서 다시 계산하므로 이벤트를 놓쳐도 업적이 막히지 않습니다.
유형
미션 수
미션 id
발신 위치(함수)
집계
amount 뜻
login
1
m_d01
missions.gd reset_if_needed()
add
1 — 그날 첫 실행(날이 바뀐 직후 1회)
idle_claim
1
m_d02
idle_reward.gd claim()
add
1 — 수령 1회(광고 2배도 1회)
stage_clear
1
m_d03
battle.gd finish(true)
add
1 — 이긴 판 1개(첫 클리어·반복·반복 보상 없는 연습 포함)
pot_open
2
m_d04~m_w03
pot.gd draw(n)
add
n — 연 횟수(자동 열기 포함)
gear_enh
2
m_d05~m_w09
gear.gd enhance(slot)
add
1 — 강화 1단계
unit_lvup
2
m_d06~m_w08
companion_detail.gd level_up(n)
add
n — 올린 레벨 수
recruit
2
m_d07~m_w04
recruit.gd pull() — 서버 응답 적용 뒤
add
n — 모집 횟수(10회 묶음 = 10, 광고 무료 1)
dungeon
3
m_d08~m_w05
dungeon.gd enter() 의 전투 끝
add
1 — 입장 1회(승패 무관)
quick_idle
2
m_d10~m_w12
idle_reward.gd quick(kind)
add
1 — free·ad·gem 모두
gift
2
m_d11~m_w11
companion_detail.gd give_gift(item)
add
1 — 선물 1개
skill_up
2
m_d12~m_w10
companion_detail.gd skill_up(slot)
add
1 — 스킬 1단계
ad_slot
1
m_d13
ads.gd show_rewarded(slot) 가 true
add
1 — 보상 받은 1회(생략권 즉시 지급 포함)
gear_sell
1
m_d14
gear.gd sell(uids) · pot.gd 자동 판매
add
판매한 장비 수
daily_done
1
m_d15
missions.gd claim(m_d##)
add
1 — 일일 미션 완료(받기 기준) 1개
login_days
1
m_w01
missions.gd reset_if_needed() 날 바뀜
add
1 — 그 주 접속한 날
stage_first
1
m_w02
stage_reward.gd first_clear()
add
1 — 첫 클리어 판 1개
raid
1
m_w06
raid.gd enter(boss)
add
1 — 도전 1회
raid_kill
1
m_w07
raid.gd finish(dmg) 100% 구간
add
1 — 처치(100% 구간) 1회
tower_floor
1
m_w13
tower.gd finish(true)
add
1 — 오른 층 1개
daily_done_days
1
m_w14
missions.gd claim() 으로 그날 daily_done 10 도달
add
1 — 그날 처음 10개가 된 순간 1회
weekly_done
1
m_w15
missions.gd claim(m_w##)
add
1 — 주간 미션 완료 1개
stage_reach
30
m_a01~m_a30
stage_reward.gd first_clear() · recheck_all()
max
prog.max_stage (판 번호)
episode_read
8
m_a31~m_a38
story_player.gd 화 끝 · recheck_all()
has
ctx.ep == target(ep_n_10) 이면 1
unit_count
8
m_a39~m_a46
gacha_apply.gd apply() 신규 · story join · recheck_all()
동료 스킬은 코드가 아니라 데이터의 짧은 배열로 적혀 있습니다(["dmg", "front", 1.1]). core/effect_dsl.gd 가 첫 칸(연산자)으로 분기합니다. 아래 표는 characters.json 을 실제로 훑어 센 값입니다 — 새 연산자가 데이터에 나타나면 이 표와 분기를 같이 늘립니다.
연산자
칸 수
인자
쓰인 횟수
데이터 예
dmg
3
대상, 계수
86
c001_s1 ["dmg", "front", 1.1]
special
4
id, 매개변수 객체, 설명문 → special_handlers.gd
26
c001_s2 ["special", "bruni_color", {}, "브루니의 현재 색에 따라
special 효과는 일반 연산자로 표현하기 어려운 고유 규칙입니다. core/special_handlers.gd 에 id 마다 함수 하나(_sp_<id>(params, src, ctx))를 둡니다. 데이터에 있는 special 은 24종입니다. 이 중 하나라도 처리 함수가 없으면 DB.load_all() 이 디버그 빌드에서 멈춥니다.
id
횟수
스킬
매개변수
규칙(데이터 설명문)
bruni_color
1
c001_s2
{}
브루니의 현재 색에 따라 추가 효과 (08장 표)
color_variety
1
c001_s3
{"per_attr": 2, "max": 10}
출전 동료 속성 1종마다 아군 전체 공격력 +2% (최대 +10%)
execute
1
c006_s2
{"hp_below": 30, "mul": 1.5}
대상 체력 30% 이하이면 이 피해 ×1.5
same_target_stack
1
c019_s3
{"per": 5, "max_stack": 5}
같은 대상을 연속으로 공격할 때마다 피해 +5% (최대 5회, 대상이 바뀌면 초기화)
gauge_steal
1
c020_s1
{"n": 5}
대상 게이지 −5, 자신 게이지 +5
self_heal_pct
2
c021_s3, c024_s3
{"pct": 2}
자신 최대 체력 2% 회복
vs_stained
1
c022_s3
{"pct": 12}
얼룩 상태의 적에게 주는 피해 +12%
heal_low_bonus
1
c025_s3
{"hp_below": 50, "pct": 25}
체력 50% 이하 아군을 회복할 때 회복량 +25%
double_s1
1
c027_s3
{}
S1 을 한 번 더 쓴다 (게이지 +20 도 한 번 더)
crit_bonus
1
c029_s2
{"pct": 30}
이 공격만 치명률 +30%p
first_act
1
c032_s3
{}
전투 첫 행동은 아군·적 통틀어 자신이 먼저
sure_crit
2
c033_s2, c047_s3
{}
반드시 치명
spd_ramp
1
c037_s3
{"per": 2, "max_stack": 10}
행동마다 속도 +2% (최대 10회, +20%)
refund_on_kill
1
c039_s2
{"gauge": 50}
이 공격으로 쓰러뜨리면 게이지 50 반환
stack_atk
1
c039_s3
{"per": 12, "max_stack": 3}
자신 공격력 +12% (최대 3회, 전투 끝까지)
wait_then_buff
1
c043_s3
{"wait": 5, "pct": 30}
처음 5초 동안 행동하지 않고, 그 뒤 전투 끝까지 공격력 +30%
resist_attr
1
c045_s3
{"attr": "홍", "pct": 15}
홍 속성 적에게 받는 피해 −15%
cover
1
c048_s3
{}
쓰러질 피해를 받는 아군 대신 그 피해를 받는다
start_gauge
1
c049_s3
{"n": 20}
전투 시작 게이지 +20
s1_spill
1
c052_s3
{"chance": 20}
20% 확률로 S1 이 적 전체로 번진다 (계수 그대로)
random_debuff
1
c054_s2
{"pool": ["atk_down", "def_down&quo
적마다 공격력↓·방어력↓·속도↓ 중 1개 20% 6초 (무작위)
third_hit
1
c056_s3
{"pct": 40}
같은 대상 3번째 공격마다 피해 +40%
lifeshare
1
c057_s3
{"pct": 10}
준 피해의 10% 만큼 체력 비율 최저 아군 회복
role_count
1
c058_s3
{"role": "사격", "per": 4}
출전한 사격 동료 1명마다 자신 공격력 +4% (자신 포함)
bruni16
bruni · 게임기술 명세서
08 / Data Files
데이터 파일 목록과 적재
런타임에 들어가는 데이터는 17개 파일입니다. 크기는 지금 저장소에 있는 파일을 그대로 잰 값이고, 「런타임」은 _ 로 시작하는 문서용 키를 빼고 공백 없이 다시 쓴 크기입니다. 데이터 원본은 각 문서가 만들고 이 문서는 모양만 고정합니다.
파일
레코드
원본 KB
런타임 KB
gzip KB
characters
60
107.7
71.5
13.5
enemies
80
99.8
66.7
11.5
bosses
24
68.9
39.2
7.7
items
150
197.0
133.6
15.4
stages
300
496.4
268.6
31.2
story
80
113.7
91.5
25.7
script_ch1
10
143.2
90.7
20.7
char_stories
180
119.4
87.2
19.1
growth
—
66.7
46.1
12.0
combat
—
33.6
27.3
7.7
gacha
5
99.3
59.9
5.9
shop
—
20.4
14.5
3.1
missions
15+15+90
26.6
17.8
3.0
attendance
28
3.3
1.8
0.6
events
18
32.3
27.3
4.5
economy
—
7.3
3.2
1.7
sounds
—
45.2
36.0
6.4
합계
1,680.8
1,082.8
189.6
문서 전용이라 넣지 않는 파일: economy_sim.json 1,479.1KB · plan.json 26.8KB · roster.json 18.6KB · stage_frame.json 4.8KB. 런타임 합계 1.06MB 는 조사의 「수백 KB」 추정보다 큽니다 — 대부분 stages.json(300판 × 웨이브·보상·어려움)입니다. 부팅 때 한 번 읽는 방식은 유지하되 저사양 실기기 파싱 시간을 1주차에 재고(미확인), 800ms 를 넘으면 빌드 때 .res(바이너리 Resource)로 바꿉니다(godot-tech §4-4).
원칙
내용
원본 하나
game-design/data 가 진실. res://data 는 export_data.py 산출물 — 손대면 다음 내보내기에 덮임
문서용 필드
_doc·_note 처럼 `_` 로 시작하는 키는 내보낼 때 제거. tip·gimmick 같은 설명문은 게임 화면에도 쓰므로 남김
공개 차수
모든 레코드에 wave(또는 release/wave_release/first_release)가 있음 — DB.released() 로 거름. 미공개 레코드도 파일에 있음(업데이트 때 데이터만 바꾸지 않도록)
대본 분할
script_ch1.json ~ script_ch8.json 장 단위 — 이야기 화면 진입 때만 읽고 닫으면 버림
manifest.json
{data_ver, files{name: sha256 앞 16자}} — 빌드 때 생성, 부팅 때 해시 대조(디버그)
번역
UI: res://data/i18n/ui.csv (keys,ko,en) · 데이터 이름·대사: {ko, en} 필드 — 1차판은 ko 만 채움
bruni17
bruni · 게임기술 명세서
09 / JSON Schema
데이터 스키마 — 동료
아래 표의 범위는 설계값입니다 — 판 1~300, 동료 c001~c060, 보상은 [재화·아이템 id, 수량 ≥1] 튜플처럼 손으로 정했고(gen/tech_schema.py), 지금 데이터에서 뽑은 값이 아닙니다. 같은 내용이 JSON Schema(2020-12) 로 game-design/data/schema/<이름>.schema.json 에 있고, 11장에서 이 스키마로 데이터 17개 파일을 실제로 검사합니다. 「null 허용」은 값이 없을 수 있는 필드, 「칸별」은 튜플(prefixItems)의 칸마다 제약입니다.
동료 — characters.json · characters[]
필드
형
필수
제약(설계 범위)
뜻
id
enum
●
id 목록 60개 (c001~c060)
c001~c060 고정
name
string
●
—
한국어 이름(화면)
name_en
string
●
—
영문 이름
species
string
●
—
종
grade
enum
●
SSR·SR·R
SSR·SR·R
star_start
integer
●
1~5
태생 ★
attr
enum
●
홍·녹·청·황·묵
속성(한글)
attr_id
enum
●
red·green·blue·yellow·ink
red·green·blue·yellow·ink
role
enum
●
돌격·타격·사격·술사·치유·지원
역할(한글)
role_id
enum
●
tank·striker·shooter·caster·healer·support
tank·striker·shooter·caster·healer·support
position
enum
●
전열·후열
전열·후열
faction
string
●
—
소속 세력
personality
string
●
—
성격 한 줄
verbal_tic
string
●
—
말버릇(대본용)
indiv
object
●
—
개성 보정 ±%
stats_lv1
object
●
—
Lv1 ★태생 능력치(역할·등급 배율 반영 후)
curve
enum
●
early·std·late·bruni
성장 곡선 id → meta.curves
skills
[object, object, object]
●
칸별: — / — / —
S1·S2·S3 3개
art
object
●
—
그림 4종 경로
acquire
string
●
—
획득 경로 한 줄(화면)
acquire_detail
object
●
—
획득 경로
wave
string
●
^W[0-6]\+?$
공개 차수 W0~W6
first_release
boolean
●
—
1차판 포함
bruni18
bruni · 게임기술 명세서
09 / JSON Schema
데이터 스키마 — 판
판 — stages.json · stages[]
필드
형
필수
제약(설계 범위)
뜻
id
enum
●
id 목록 300개 (s001~s300)
s001~s300
no
integer
●
1~300
판 번호
chapter
integer
●
1~8
장
wave_release
string
●
^W[0-6]\+?$
공개 차수
area
string
●
—
구역 이름
area_no
integer
●
1~10
장 안 구역 번호
name
string
●
—
판 이름(화면)
kind
enum
●
normal·boss
normal·boss
boss
null|enum
●
— 또는 id 목록 48개 (b001~b024+)
보스 id(+ 강화판)
chapter_boss
boolean
●
—
장 보스 판
time_limit_s
enum
●
60·90
60·90
waves
array>
●
길이 1~3
웨이브별 [적 id, 수]
enemy_count
integer
●
1~15
적 총수
main_attr
enum
●
홍·녹·청·황·묵
적 주 속성
advice_attr
enum
●
홍·녹·청·황·묵
권장 속성(화면 힌트)
enemy_hp_total
integer
●
1~
적 체력 합(검산용)
m
number
●
1~
판 배율 m(s) = 1 + 0.125 × (s − 1) + 0.00005 × (s − 1)² (curve.py)
req_cp
integer
●
1~
추천 전투력
first_reward
array<[enum, integer]>
●
길이 0~12
첫 클리어 보상 [재화, 수]
first_reward_items
array<[enum, integer]>
●
길이 0~12
첫 클리어 아이템
repeat_reward
array<[enum, integer]>
●
길이 0~12
반복 보상
repeat_drop_roll
boolean
●
—
반복 시 적 드롭 굴림
episode_unlock
null|string
●
null 허용
이 판 클리어로 열리는 화
unlocks
array
●
길이 0~6
기능 해금 id(던전·색 등)
bg
string
●
^bg/ch[1-8]_[a-z0-9_]+\.png$
배경 파일
hard
object
●
—
어려움 난이도(완성판)
bruni19
bruni · 게임기술 명세서
09 / JSON Schema
데이터 스키마 — 적·보스
적 — enemies.json · enemies[]
필드
형
필수
제약(설계 범위)
뜻
id
enum
●
id 목록 80개 (e001~e080)
e001~e080
name
string
●
—
이름
species
string
●
—
본래 동물
chapter
integer
●
1~8
장
rank
enum
●
normal·elite
normal·elite
elite
boolean
●
—
정예 여부
type
enum
●
tank·striker·shooter·caster·healer·support
역할형 6종 → types
attr
enum
●
홍·녹·청·황·묵
속성
attr_id
enum
●
red·green·blue·yellow·ink
속성 id
tweak
object
●
키 ^(atk|hp|def|spd)$
개별 배율 조정
stages
object
●
—
등장 판 처음·끝
basic
object
●
—
기본 공격 계수·대상
special
object
●
—
특수기 1개
passive
null|object
●
null 허용
패시브(없으면 null)
weak
object
●
—
약점 속성·공략
art
string
●
^enemy/e\d{3}_battle\.png$
전투 시트
first_release
boolean
●
—
1차판 포함
drops
array
●
길이 0~8
드롭 [{item, pct}]
stat_first
object
●
—
첫 등장 판 능력치
stat_last
object
●
—
마지막 등장 판 능력치
보스 — bosses.json · bosses[]
필드
형
필수
제약(설계 범위)
뜻
id
enum
●
id 목록 24개 (b001~b024)
b001~b024
name
string
●
—
이름
species
string
●
—
본모습
kind
string
●
—
장·중간·콘텐츠 보스
attr
enum
●
홍·녹·청·황·묵·이벤트마다
속성
attr_id
enum
●
red·green·blue·yellow·ink·event
속성 id
mul
object
●
—
보스 배율
phases
array
●
길이 1~5
페이즈(체력 구간·속성·스킬)
plus_skill
null|object
●
null 허용
강화판 추가 패턴
gimmick
string
●
—
기믹 설명(화면)
tip
string
●
—
공략 힌트(화면)
release
string
●
^W[0-6]\+?$
공개 차수
weak_attr
null|string
●
null 허용
약점 속성
art
string
●
^boss/b\d{3}_battle\.png$
전투 시트
canvas
enum
●
128·160·192·256
그림 캔버스(px)
appear
array
●
길이 0~6
등장 판과 그 판 능력치
reward_first
array<[enum, integer]>
길이 0~12
첫 처치 보상
reward_repeat
array<[enum, number]>
길이 0~8
반복 처치 보상
reward_first_plus
null|array<[enum, integer]>
null 허용 · 길이 0~12
—
chapter
integer
0~8
장(콘텐츠 보스는 0)
day
string
—
—
raid_tiers
array
길이 1~30
—
raid_reward
object
—
—
event_levels
array
—
—
reward_note
string
—
—
bruni20
bruni · 게임기술 명세서
09 / JSON Schema
데이터 스키마 — 아이템
아이템은 분류(cat)마다 쓰는 필드가 달라 필수 표시가 id·name·cat 정도에만 붙습니다. 장비(gear)는 slot·line·grade·main·cp, 유물(relic)은 effect·pity, 재료·소모품은 kind·stack 을 씁니다.
아이템 — items.json · items[]
필드
형
필수
제약(설계 범위)
뜻
id
enum
●
id 목록 150개 (i001~i150)
i001~i150
name
string
●
—
이름
cat
enum
●
gear·relic·material·consumable
gear·relic·material·consumable
icon
string
●
^item/i\d{3}\.png$
아이콘 32×32
stack
integer
●
1~999,999
겹침 상한
release
string
●
^W[0-6]\+?$
공개 차수
slot
enum
weapon·hat·body·hands·feet·acc
부위 id
slot_ko
string
—
부위
line
enum
blade·guard
계열 blade·guard
line_ko
string
—
계열(날·결)
grade
enum
g1·g2·g3·g4·g5·g6·g7
등급 g1~g7
grade_ko
string
—
등급
main
object
키 ^(atk|hp|def)$ 최대 3개
주 능력치
cp
integer
1~
전투력 기여
subs
array
길이 0~4
부 능력치
set
string
^set_(blade|guard)_g[1-7]$
세트 id
craft
null|object
null 허용
제작 재료(g4 이상)
acquire
array
길이 0~64
획득처
sell
object
—
판매가 공식
chapter
integer
1~8
유물: 장
slot_no
integer
1~3
유물: 장 안 번호
effect
object
—
유물 효과
desc
string
—
설명(화면)
pity
integer
1~200
유물 확정 드롭 횟수
upgrade
string
—
유물 강화 규칙
kind
string
—
재료·소모품 종류
attr
enum
홍·녹·청·황·묵
정수 속성
tier
enum
하·중·상
하·중·상
use
string
—
쓰임
drop_enemies
array
길이 0~
드롭 적
drop_bosses
array
길이 0~
드롭 보스
boxes
array
길이 0~
들어 있는 상자
source
string
—
얻는 곳
loved_by
array
길이 0~
아주 좋아하는 동료
liked_by
array
길이 0~
좋아하는 동료
hours
enum
2·4·8
—
brush
integer
1~
—
entries
array
길이 1~20
무작위 상자 가중치(합 10000)
total
enum
10000
—
disclose
boolean
—
—
bruni21
bruni · 게임기술 명세서
09 / JSON Schema
데이터 스키마 — 이야기
본편 화 — story.json · episodes[]
필드
형
필수
제약(설계 범위)
뜻
id
string
●
^ep_[1-8]_(0[1-9]|10)$
ep_<장>_<화>
chapter
integer
●
1~8
장
no
integer
●
1~10
화
title
string
●
—
제목
unlock
object
●
—
해금 판
place
string
●
—
장소
bg
string
●
^bg/
배경
bgs
array
●
길이 0~
—
cast
array
●
길이 0~12
등장 동료
npc
array
●
길이 0~12
등장 NPC
synopsis
array
●
길이 3~5
줄거리 3~5줄
choice
null|object
●
null 허용
선택지(없으면 null)
joins
null|string
●
null 허용
합류 동료
events
array
●
길이 0~
화 안 사건 태그
mood
string
●
—
분위기
tension
integer
●
0~10
긴장 0~10
warmth
integer
●
0~10
온기 0~10
개인 이야기 — char_stories.json · episodes[]
필드
형
필수
제약(설계 범위)
뜻
id
string
●
^cs_c\d{3}_[123]$
cs__<1~3>
char
enum
●
id 목록 60개 (c001~c060)
동료 id
name
string
●
—
동료 이름
ep
integer
●
1~3
화 1~3
title
string
●
—
제목
synopsis
string
●
—
줄거리
cast
array
●
길이 0~8
등장
place
string
●
—
장소
unlock
object
●
—
인연 Lv·본편 조건·이전 화
reward
array<[enum, integer]>
●
길이 0~12
보상
content_wave
string
●
^W[0-6]\+?$
공개 차수
lines_target
[integer, integer]
●
칸별: 1~ / 1~
대사 줄 수 목표
choice
integer
●
0~3
선택지 수
bruni22
bruni · 게임기술 명세서
10 / JSON Schema
설정 파일과 남은 스키마
목록형이 아닌 설정 파일은 최상위 키만 적습니다. 세부 필드는 같은 폴더의 .schema.json 에 있습니다.
파일
최상위 키
담당 문서
growth.json
version · formulas · star_cap · companion_level · bruni_level · star · dup · awaken · skill · gear_enhance · pot · relic · relic_pay · relic_keys · relic_supply · gear_refine · drop_exchange · awaken_essence · bond · bond_sources · color · examples · checks
대본은 장 단위 파일 하나(script_ch<장>.json)에 화 → 장면 → 줄 3단으로 들어 있습니다. 1장 파일이 실제로 있어 그 모양을 그대로 기준으로 삼습니다. 2~8장 대본도 같은 모양으로 만듭니다.
필드 (episodes[] 아래)
형
제약(설계 범위)
id
string
^ep_1_(0[1-9]|10)$
chapter
enum
1
no
integer
1~10
title
object
—
title.ko
string
—
title.en
string
—
unlock
null|object
null 허용
event
string
—
cast
array
길이 0~
joins
array
길이 0~
has_choice
boolean
—
scenes
array
길이 1~12
scenes[].id
string
—
scenes[].bg
string
—
scenes[].fade
number
0~5
scenes[].place
string
—
scenes[].time
string
—
scenes[].lines
array
길이 1~
대본 줄(lines[])은 선택지 안에 다시 줄이 들어가는 재귀 구조라 스키마에서 $defs/line 하나로 정의했습니다 — type(say·nar·sys·dir·choice) · text{ko, en} · speaker · face · cmds[{op, args}] · options[2~3]{id, text, bond, lines[]}.
bruni24
bruni · 게임기술 명세서
10 / JSON Schema
대본 연출 명령
대본 줄의 cmds 는 아래 13종만 씁니다. 표는 script_ch1.json 의 commands 를 그대로 옮긴 것이고, 1장 대본에서 실제로 쓰인 횟수를 셌습니다. story_player.gd _exec() 는 이 표 밖의 명령을 받으면 디버그 빌드에서 멈춥니다.
명령
문법
동작
1장 사용
bg
bg <배경id> [fade=0~1]
배경 교체. fade 는 회색 셰이더 강도(0 원색, 1 완전 회색). 0.4초 디졸브
19
bgm
bgm <곡id>
배경음 교체. 1.0초 교차 페이드
18
se
se <효과음id>
효과음 1회
49
show
show <id> <L|C|R> [fade=0~1]
서 있는 그림 표시(동료·NPC). 적 e###·보스 b### 는 전투 시트 대기 1프레임을 정수배 확대. 이미 있으면 fade 값만 바꿈(0.6초)
19
hide
hide <id>
그림 퇴장 0.3초
8
shake
shake
화면 흔들림 0.3초, 진폭 8px
7
flash
flash <white|green|red>
전체 화면 섬광 0.25초
5
cutscene
cutscene battle_auto <초>
조작 없는 연출 전투. 대본 안 등장 그림으로 공격 동작만 재생
1
battle
battle <판id>
대본 종료 후 해당 판 전투로 바로 진입(튜토리얼 전용)
1
goto_stage
goto_stage <판id>
대본 종료 후 해당 판 준비 화면을 연다
1
join
join <동료id>
동료 지급. 이미 모집으로 보유했으면 바이블 9-2 중복 환원(기억 조각 + 무지개 조각) — gacha_roll.apply_result 를 그대로 호출
3
unlock
unlock <해금id>
기능 해금. 1장은 recruit(모집, 3화 끝 = s007 클리어)·color_red(브루니 색 전환 홍, 10화) 둘
2
chapter_end
chapter_end <장>
장 완료 연출(제목 카드 2초) 후 2장 1화 해금
1
대본 줄 type
1장 줄 수
화면 동작
say
268
이름표 + 표정 얼굴 + 대사
dir
93
연출 명령 실행 + 지문(화면에는 안 보임, 개발용)
nar
25
이름표 없는 내레이션
sys
11
시스템 안내 상자(해요체)
bruni25
bruni · 게임기술 명세서
11 / Data Validation
데이터 무결성 검사
tools/validate_data.py 가 빌드 전에 돌릴 검사를 지금 데이터에 실제로 돌린 결과입니다(2026-10-13, 이 스크립트 실행 시점). 35개 중 통과 35 · 실패 0입니다. 실패는 담당 문서가 고치고, 고칠 수 없으면 바이블 쪽을 고칩니다.
검사
결과
세부
동료 60·id 연속
통과
60명
동료 이름 = roster.json
통과
불일치 0
등급 16·20·24
통과
SSR 16 · SR 20 · R 24
속성 각 12
통과
녹 12 · 청 12 · 황 12 · 홍 12 · 묵 12
역할 10·12·12·10·8·8
통과
타격 12 · 사격 12 · 돌격 10 · 치유 8 · 술사 10 · 지원 8
스킬 S1·S2·S3
통과
어긋남 0
1차판 동료 15
통과
15명
적 80·id 연속
통과
80종
적 장 배치 e(10n−9)~e(10n)
통과
어긋남 0
보스 24·id 연속
통과
24종
아이템 150·id 연속
통과
150종
아이템 분류 84·24·24·18
통과
gear 84 · relic 24 · material 24 · consumable 18
판 300·id 연속
통과
300판
판 웨이브 → 적·보스 id 존재
통과
없는 id 0
보스 판 30 = stage_frame
통과
보스 판 30 · 어긋남 0
첫 클리어 빛구슬 합 6,900
통과
6,900
본편 80화
통과
80화
화 해금 판 = 바이블 7-1
통과
어긋남 0
화 등장인물 id 존재
통과
없는 id 0
개인 이야기 180화
통과
180화
개인 이야기 → 동료 id
통과
어긋남 0
보상 키 → 재화·아이템 존재
통과
알 수 없는 키 0건
적 드롭 → 아이템 존재
통과
어긋남 0
미션 120 (15·15·90)
통과
15·15·90
출석 28칸
통과
28칸
모집 개별 확률 합 100%
통과
표 28개 · 어긋남 0
항아리 30단계 가중치 합
통과
어긋남 0
무작위 상자 가중치 합 10000
통과
상자 3 · 어긋남 0
1장 대본 10화
통과
10화 · 줄 397
대본 명령 ∈ commands
통과
명령 13종 · 모르는 명령 0
대본 화자 ∈ speakers (단역은 mob + mob_name)
통과
모르는 화자 0
대본 bg·bgm·se id 목록에 있음
통과
없는 id 0
대본 bgm·se id ∈ sounds.json
통과
대본 소리 45개 · sounds.json 166개 · 없음 0
대본 얼굴 시트 ∈ 아트 목록
통과
시트 9장 · 없음 0
판·화·대본 배경 ∈ 아트 목록
통과
배경 112장 · 없음 0
bruni26
bruni · 게임기술 명세서
11 / Schema Validation
스키마 검사 결과 (설계 범위)
9·10장 스키마로 data/*.json 16개와 저장 샘플 3개를 실제로 검사한 결과입니다(gen/tech.py 실행 2026-10-13). 검사기는 외부 라이브러리 없이 gen/tech_schema.py validate() 가 하고, 이번 실행 환경에 jsonschema 라이브러리가 없어 교차 확인 칸은 비었습니다(자체 검사기 결과만). 데이터 위반 0건 · 저장 위반 0건입니다. 빌드 전 tools/validate_data.py 가 같은 검사를 합니다(23장 2단계).
파일
위반(자체)
위반(jsonschema)
처음 두 건
characters.json
0
—
—
enemies.json
0
—
—
bosses.json
0
—
—
items.json
0
—
—
stages.json
0
—
—
story.json
0
—
—
script_ch1.json
0
—
—
char_stories.json
0
—
—
growth.json
0
—
—
combat.json
0
—
—
gacha.json
0
—
—
shop.json
0
—
—
missions.json
0
—
—
attendance.json
0
—
—
events.json
0
—
—
economy.json
0
—
—
저장 샘플
위반(자체)
위반(jsonschema)
처음 두 건
새 계정 (튜토리얼 시작 전 · 브루니만)
0
—
—
1차판 30일차 (동료 15 · 가방 42/60 · 2장까지 · 모집 기록 30)
0
—
—
완성판 최대 (동료 60 · 가방 60 · 우편 100 · 이야기 전부 · 모집 기록 500)
0
—
—
음성 시험 — 틀린 값을 일부러 넣으면 잡히는가
넣은 오류
파일
잡힌 건수(자체)
잡힌 건수(jsonschema)
판 번호 301
stages.json
1
—
보상 수량 0
stages.json
1
—
보상 키 오타(gem_fre)
missions.json
1
—
웨이브에 없는 적 e099
stages.json
1
—
동료 스킬 2개
characters.json
1
—
장비 부위 cloth(옛 이름)
items.json
1
—
출석 27칸
attendance.json
1
—
가방 61칸
save
1
—
가방 개체에 enh(옛 구조)
save
1
—
칸 강화 +21
save
1
—
comp.c001 에 lv
save
1
—
자동 판매 g5
save
1
—
반복 보상 31판
save
1
—
튜토리얼 18
save
1
—
최고 판 301
save
1
—
bruni27
bruni · 게임기술 명세서
12 / Save Format
저장 구조
저장은 계정 상태 전체를 JSON 한 덩어리로 씁니다. 키 이름은 짧게 두되 뜻이 보이게 했습니다(저장 크기보다 디버깅이 중요). 형 칸의 범위는 save.schema.json 과 같은 설계값입니다. 실제 샘플이 game-design/data/schema/save.sample.json(1차판 30일차)이고, 11장에서 새 계정·1차판·완성판 최대 세 샘플을 스키마로 검사합니다. 장비 강화는 칸(bruni.slot_enh)에, 브루니 레벨은 bruni.lv 한 곳에만 둡니다.
완성판 최대 (동료 60 · 가방 60 · 우편 100 · 이야기 전부 · 모집 기록 500)
63,921
6,577
98%
가장 큰 몫완성판 최대 샘플 63,921B 중 모집 기록(gacha.log 500건 — 전부 1회 모집이라 가장 큰 경우)이 39,193B, 우편 100통이 11,391B 입니다. 64KB 기준에 98% 로 붙어 있어, 저장 항목을 늘리면 먼저 모집 기록을 「최근 500회」에서 「최근 200회 + 서버 조회」로 줄입니다(서버 gacha_pulls 가 원본). 서버 백업은 gzip 이라 6,577B 입니다.
쓰기 절차
순서
동작
이유
1
키를 정렬해 공백 없이 직렬화(sig 제외)
같은 상태 = 같은 문자열 → 서명이 흔들리지 않음
2
sig = HMAC-SHA256(derive_key(install_id), 본문)
숫자 하나만 바꿔도 sig 가 완전히 달라짐 (아래 예)
3
FileAccess.open_encrypted(save.tmp, WRITE, key32) 에 sig 포함 본문 쓰기
평문 편집 막기
4
save.json → save.bak 로 이름 바꾸기, save.tmp → save.json
쓰다 꺼져도 둘 중 하나는 온전함
5
Save.saved 시그널
백업 예약·UI
서명 예 (실제 계산값)같은 키로 {"version":1,"wallet":{"gold":100}} → b214257b373cc2d490f9bbda…, gold 만 900 으로 바꾸면 bc60ddae7fbb156c2645cf12…. 키는 SHA256(APP_PEPPER + install_id) 이고 APP_PEPPER 는 실행 파일 안 상수라 디컴파일하면 드러납니다 — 그래서 강도는 「낮음~중간」으로 봅니다(19장).
언제 쓰나
계기
방식
재화·진행 변화
Save.mark_dirty() → 2초 디바운스
백그라운드·포커스 잃음·종료 요청
즉시 write_now() (Android 는 백그라운드 뒤 강제 종료가 흔함 — godot-tech §2-1)
결제 지급·모집·장 완료
즉시 write_now() + backup_to_server()
서버 백업
하루 2회(첫 실행·저녁 첫 실행) + 위 계기
버전 이전
규칙
내용
규칙 1
저장 형식이 바뀌는 빌드는 반드시 version 을 1 올리고 core/save_migrate.gd 에 _v{n}_to_{n+1}(raw) 를 추가한다
규칙 2
이전 함수는 지우지 않는다 — v1 저장을 가진 사람이 v7 앱을 깔아도 1→2→…→7 이 차례로 돈다
규칙 3
필드를 지우지 말고 이름을 바꿀 땐 옮긴 뒤 옛 키를 지운다(한 함수 안에서)
규칙 4
새 필드 기본값은 이전 함수가 넣는다. 읽는 쪽에서 .get(k, 기본값) 으로 덮지 않는다 — 기본값이 두 곳에 생기면 어긋난다
규칙 5
이전 전 원본을 save.pre_v{n}.json 으로 1회 복사(다음 성공 저장 뒤 삭제)
규칙 6
이전 결과 version > 앱이 아는 CURRENT 이면(앱을 낮은 버전으로 되돌림) 읽기 전용으로 열고 업데이트 안내
규칙 7
테스트 — tests/save/ 에 버전마다 실제 저장 샘플 1개를 두고 CURRENT 까지 이전 후 sig 재계산·필수 키 검사
데이터 id 변경
동료·아이템 id 는 바꾸지 않는다(c###·i### 고정, 바이블 12-3). 꼭 필요하면 이전 함수에 id 대응표를 둔다
bruni29
bruni · 게임기술 명세서
14 / Server Decision
서버 필요 여부와 엔드포인트
싱글플레이 방치형이라 게임 판정은 기기에서 합니다. 서버는 돈과 시간이 걸린 것만 맡습니다 — 서버 시각, 영수증 검증, 유료 빛구슬 장부, 저장 백업, 이벤트 기록. PvP·길드·거래가 없어서(바이블 1장) 서버 권위 판정은 넣지 않습니다.
기능
서버 필요?
판단
방치 경과 시간
필요
기기 시계는 이용자가 바꿀 수 있음(Godot Time 문서 경고). Worker Date.now() 로 막음
결제 지급
필요
클라이언트만 믿으면 결제 우회 도구로 재화 생성, 환불 회수 불가
유료 빛구슬 장부
필요
7일 청약철회 판정은 미사용 유료분 기준 — 기기 장부만으로는 분쟁 때 근거가 없음
저장 백업·기기 변경
필요
iOS 에 Play 저장과 같은 수단이 없어 자체 백업으로 통일(godot-tech §2-2)
이벤트 기록
필요
리텐션·막힘 구간·결제 전환을 알 방법
원격 설정
필요(정적)
이벤트 달력·배너를 앱 업데이트 없이 바꿈 — Pages 정적 JSON 으로 충분
모집 판정
필요
유료 빛구슬이 걸리고 확률 준수 입증책임이 사업자에게 있음. 기기에서 굴리면 옛 저장 복구 뒤 다시 뽑기를 막을 수 없고, 기기가 보낸 결과는 입증 자료가 못 됨 → POST /v1/gacha/pull 이 굴리고 서명(24장 제안)
전투·항아리·드롭 판정
불필요
유료 재화가 직접 걸리지 않고 자기 계정만 망가짐. 붓은 빛구슬로 살 수 있으나 그 빛구슬 유료분은 gem_ledger 로 먼저 차감됨
gacha_pulls 1 · gacha_state 1 · 유료분 있으면 gem_ledger 1
POST /v1/gem/spend
모집 밖의 유료 빛구슬 사용 보고(상점 빛구슬 품목·빠른 방치)
{uid, items[{order_id, n, why, at}]}
{ok, paid_left}
gem_ledger n행
POST /v1/events
이벤트 기록 묶음
{uid, events[≤20]}
{ok, accepted}
events 1행(묶음째 JSON)
GET /v1/config
원격 설정
?ver=
{wave, min_version, flags{}, events[], banners[]}
없음(KV 아님, Worker 안 상수 + Pages 정적 JSON)
POST /v1/notify/play
Play 실시간 개발자 알림(RTDN) 수신 — 환불·취소
Pub/Sub push
204
purchases 상태 갱신 (완성판)
POST /v1/notify/apple
App Store Server Notifications V2 — 환불
signedPayload
200
purchases 상태 갱신 (완성판)
모집 흐름 (서버 판정)
순서
기기
서버
①
확인 창(청약철회 불가 문구) → POST /v1/gacha/pull {banner, n, pay, free_have, seq_have}
—
②
—
gacha_state.seq ≠ seq_have 면 409 + 빠진 결과 replay (기기가 옛 저장으로 돌아간 경우)
③
—
비용 = 무료(free_have 한도) 먼저 → 유료(gem_ledger 잔액). 유료가 모자라면 402
④
—
PCG32(서버 난수 시드, stream 2) 로 등급 → 개별. 천장·입문 50회·10번째 칸은 gacha_state 기준
⑤
—
gacha_pulls · gacha_state · gem_ledger 를 D1 batch 하나로 쓰기 → Ed25519 서명 응답
⑥
서명 확인 → GachaApply.apply → Wallet 에서 free_used·paid_used 차감 → 즉시 저장
—
복구
GET /v1/save → body + replay[] → apply 를 seq 순서로
gem_paid·lots 를 장부 잔액으로 덮고, body.gacha.seq 뒤의 결과를 replay 로
오프라인모집은 서버 연결이 있어야 합니다. 연결이 없으면 모집 버튼을 회색으로 두고 「모집은 인터넷 연결이 필요해요」를 띄웁니다. 전투·방치·항아리·성장은 오프라인에서도 됩니다.
bruni30
bruni · 게임기술 명세서
15 / Server Budget
D1 테이블과 무료 한도 예산
D1 테이블 (DDL: game-design/data/schema/d1.sql)
테이블
열
비고
accounts
uid TEXT PK · install_id TEXT · platform TEXT · token_hash TEXT · recovery_code_hash TEXT · created_ms INT · last_seen_ms INT · flags INT
계정 1명 1행
saves
uid TEXT PK · version INT · total_play_ms INT · body BLOB(gzip) · sig TEXT · updated_ms INT · prev_body BLOB
최신 1벌 + 직전 1벌
purchases
token TEXT PK · uid TEXT · platform TEXT · product_id TEXT · order_id TEXT · state TEXT(verified/delivered/refunded) · price_krw INT · at_ms INT · test INT
같은 token 재지급 차단
gem_ledger
id INT PK · uid TEXT · order_id TEXT · delta INT · why TEXT · at_ms INT
유료 빛구슬 들고 남 — 청약철회 7일 판정
events
id INT PK · uid TEXT · day TEXT · body TEXT(JSON 묶음) · at_ms INT
90일 보관 후 삭제(크론)
flags
id INT PK · uid TEXT · kind TEXT · detail TEXT · at_ms INT
이상치 기록 — 차단하지 않음
gacha_pulls
id INT PK · uid TEXT · seq INT · banner TEXT · n INT · pay TEXT · free_used INT · paid_used INT · ids TEXT · grades TEXT · rng_seed TEXT · sig TEXT · at_ms INT
서버가 굴린 모집 결과 전부 — 확률 준수 입증·결제 문의 대조. 삭제하지 않음(5년, 추정 — 전자상거래법 거래기록 보존 기간은 launch 문서 확인)
gacha_state
uid TEXT PK · seq INT · pulls INT · beginner INT · beginner_ssr INT · by_banner TEXT · stardust INT · updated_ms INT
계산 가정하루 접속 3회(바이블 1장) · 세션당 이벤트 35건(추정 — 판 10회 시작·끝 20 + 방치·항아리·성장·미션·열기/닫기) · 묶음 20건 → 세션당 전송 2회 · 모집 실행 하루 2번(추정 — 광고 무료 모집 1 + 빛구슬·모집권 1). 기록 묶음·모집 행 크기는 실제 JSON 을 만들어 잰 값. 유료 장부 쓰기는 모집마다 1행으로 잡은 상한 가정입니다. 한도 값은 Cloudflare 문서(2026-10-13 조회, godot-tech §1-4).
유료 서버 전환선 (기준값 — launch·plan 이 이 값을 씀)D1 저장 4GB(≈DAU 3,096, 출시 90일째 기준) 또는 하루 요청 7만(≈DAU 4,965) 중 먼저 닿는 쪽에서 Workers Paid $5/월로 바꿉니다. 저장이 먼저 닿습니다. 모집 기록은 지우지 않아 1년째에는 DAU 2,643 에서 4GB 에 닿습니다. 바이블 12-2 의 「DAU 1만」 은 두 한도보다 늦어 기준으로 쓸 수 없습니다 — 24장 바이블 추가 제안.
bruni31
bruni · 게임기술 명세서
16 / Time Integrity
시간 검증과 오프라인 보상
방치 보상은 「마지막 수령 시각부터 지금까지」입니다. 지금이 언제인지를 서버가 정합니다. 서버에 닿지 못하면 기기 시계로 임시 계산하고, 다음에 닿았을 때 맞춰 봅니다. 의심되는 경우에도 막지 않고 그 회차 보상만 0 으로 둡니다 — 1인 개발이 오탐 문의를 감당할 수 없기 때문입니다(godot-tech §1-3).
앱 시작·복귀Clock.sync()
GET /v1/time
서버 시각{now_ms, sig}
기준점
세션 중ticks 델타로만 진행
경우
상황
기대 결과
T1
온라인, 9시간 뒤 접속
9시간분 지급
T2
온라인, 20시간 뒤 접속
24시간분(정기권 32시간분) — capped 기록
T3
기기 시계 +24h 후 온라인 접속
서버 시각 기준이라 실제 경과만 지급
T4
비행기 모드 + 기기 시계 +24h
24시간분 임시 지급 · pending_verify — 다음 온라인 때 실제 경과로 재계산, 초과분은 다음 방치 보상에서 차감(자동 돌파한 판은 유지)
T5
기기 시계 −1일
그 회차 0분 · backward 기록
T6
앱 켠 채 시계 +2h
ticks 기준이라 영향 없음 · drift 기록
T7
04:00 경계 앞뒤 접속
일일 카운터 서버 시각 기준 1회만 초기화
T8
서버 응답 서명 위조(프록시)
서버가 백업 업로드 때 sig 재검증 → flags
T9
기기 재부팅 직후(ticks 0부터)
기준점은 세션마다 새로 잡으므로 영향 없음
오프라인 보상 계산 예 (바이블 8장 공식으로 계산)
경우
최고 판
경과 분
인정 분
골드
물감 방울
브루니 경험치
붓
T1 9시간 뒤
s060
540
540
17,370
2,895
8,685
72
T2 20시간 뒤
s060
1,200
720
23,160
3,860
11,580
96
T2 20시간 뒤 · 정기권
s060
1,200
960
37,056
6,176
18,528
153
T4 비행기 모드 시계 +24h
s060
1,440
720
23,160
3,860
11,580
96
T5 시계 −1일(역행)
s060
0
0
0
0
0
0
s150 · 8시간
s150
480
480
91,762
15,293
45,881
88
s300 · 24시간
s300
1,440
720
2,684,008
447,334
1,342,004
168
공식시간당 골드 600×1.02^(s−1) · 방울 100×1.02^(s−1) · 경험치 300×1.02^(s−1) · 붓 6+장 번호. 보상 = 시간당 × min(경과 분, 상한 분) ÷ 60, 분 단위 내림. 상한 24시간(정기권 32시간) — 앱을 켤 때 30분 칸으로 재생하며 자동 돌파(바이블 8장), 정기권 보상 +20%. 광고 2배·빠른 방치는 이 값에 곱하거나 2시간분을 더합니다. 하루 리셋은 04:00 KST(UTC 19:00) — Clock.day_key() 는 서버 ms 에 +9h −4h 를 더해 날짜를 자릅니다.
bruni32
bruni · 게임기술 명세서
17 / In-App Purchase
결제 연동과 영수증 검증
결제 상품은 스토어에 38개를 등록합니다. 가격은 바이블 9-3·뽑기·상점 명세서 값을 그대로 쓰고, 구성은 shop.json 이 정합니다. 지급은 언제나 서버 검증 뒤 우편으로 합니다.
상품 id
스토어 형식
가격(원)
내용
비고
gem_60
소모성
1,200
빛구슬 60 (첫 구매 +60)
상품별 첫 구매 1회 2배
gem_320
소모성
6,000
빛구슬 320 (첫 구매 +320)
상품별 첫 구매 1회 2배
gem_660
소모성
12,000
빛구슬 660 (첫 구매 +660)
상품별 첫 구매 1회 2배
gem_1880
소모성
33,000
빛구슬 1,880 (첫 구매 +1,880)
상품별 첫 구매 1회 2배
gem_3900
소모성
66,000
빛구슬 3,900 (첫 구매 +3,900)
상품별 첫 구매 1회 2배
gem_6000
소모성
99,000
빛구슬 6,000 (첫 구매 +6,000)
상품별 첫 구매 1회 2배
starter
소모성
1,200
첫 결제 꾸러미
계정 1회 — 서버 purchases 로 판정
growth_s030_1 ~ growth_s300_3 (24개)
소모성
4,400 / 9,900 / 33,000
단계 꾸러미
각 1회, 해당 판 도달 시 노출
event_pack_s
소모성
2,200
이벤트 꾸러미(구성은 원격 설정)
이벤트당 3회
event_pack_m
소모성
6,600
이벤트 꾸러미(구성은 원격 설정)
이벤트당 2회
event_pack_l
소모성
13,200
이벤트 꾸러미(구성은 원격 설정)
이벤트당 1회
event_pack_xl
소모성
33,000
이벤트 꾸러미(구성은 원격 설정)
이벤트당 1회
pass_month
소모성(구독 아님)
5,900
30일 · 즉시 300 + 매일 50
만료 전 재구매는 기간 연장
pass_season
소모성
9,900
성장 패스 유료 줄 (시즌별)
시즌마다 1회
no_ads
비소모성
12,000
광고 생략권
복원 대상
구매 흐름
순서
동작
세부
①
상점에서 상품 터치
iap_click 기록 · 확인 창(가격·구성·청약철회 안내)
②
스토어 결제 창
플러그인 purchase(product_id)
③
결제 성공 콜백
purchaseToken(Android) / jwsRepresentation(iOS) 을 저장의 pending_iap 에 먼저 기록
④
POST /v1/iap/verify
Worker → Google androidpublisher purchases.products.get / Apple JWS 서명 체인 검증
⑤
서버 판정
purchaseState==0 · 토큰 처음 · 상품 가격 일치 → purchases·gem_ledger 기록 → consume/acknowledge(서버)
⑥
응답 grant
우편 1통 생성(결제분은 만료 없음) · pending_iap 제거 · 즉시 저장 + 백업
⑦
실패·끊김
pending_iap 이 남아 있으면 다음 실행·상점 진입마다 ④ 재시도(최대 72시간), 이후 고객 문의 안내
⑧
수령
우편 「수령」 = 사용 시작. 유료 빛구슬은 gem_paid_lots 에 건별로 들어감
bruni33
bruni · 게임기술 명세서
17 / In-App Purchase
영수증 검증 세부와 오류 처리
서버 검증 세부
Android
iOS
보내는 것
purchaseToken · productId
StoreKit 2 jwsRepresentation
확인
androidpublisher v3 purchases.products.get — purchaseState==0, purchaseType==0 이면 테스트 구매로 표시
JWS 서명·인증서 체인을 Worker 에서 검증(Apple 루트 인증서). verifyReceipt 는 쓰지 않음(deprecated)
인증
서비스 계정 JWT(RS256) → OAuth 토큰. Google Node SDK 는 Workers 에서 안 돌아 WebCrypto 로 직접 서명, 토큰 55분 캐시
필요 시 App Store Server API JWT
마무리
서버에서 consume(소모성)·acknowledge(비소모성)
transaction finish 는 앱에서, 지급 확인 뒤
비밀값
서비스 계정 JSON → wrangler secret
인앱 키(.p8) → wrangler secret
오류
처리
같은 token 두 번
purchases PK 충돌 → 이미 지급됐다고 응답(grant 없음) · flags
가격·상품 불일치
지급 안 함 · flags · 문의 안내
Google/Apple API 시간 초과
502 → 앱이 재시도 큐(지수 대기), 최대 72시간
검증 성공·응답 유실
GET /v1/iap/pending 이 다음 실행 때 미지급분을 내려 줌
환불 알림(완성판)
미수령 우편이면 회수, 수령했으면 미사용 유료 빛구슬만 회수(잔액을 음수로 만들지 않음)
청약철회 판정 데이터유료 빛구슬은 결제 건마다 gem_paid_lots[{order, n, left, at}] 로 남기고, 쓸 때는 무료분 먼저 → 유료분은 오래된 건부터 차감합니다(바이블 5장). 7일 안 철회 요청이 오면 그 건의 left 와 서버 gem_ledger 를 대조합니다. 모집 실행 버튼 위에는 「모집 실행 후에는 청약철회가 되지 않아요」를 고정합니다(monetization-law 3-4).
bruni34
bruni · 게임기술 명세서
18 / Ads
광고 연동 구조
광고는 보상형 하나만 씁니다. 강제 전면 광고는 없습니다. 모든 호출은 ads.gd 를 거치므로 플러그인을 바꿔도 화면 코드는 그대로입니다.
자리 id
이름
하루
보상
ad_idle2x
방치 보상 2배
1
그 회차 방치 보상 ×2 (하루 1회까지, 3회 ×2 에서 줄임 — 바이블 추가 제안 18)
ad_quick
빠른 방치 추가
2
방치 2시간분
ad_recruit
무료 모집
1
상시 모집 1회 + 별가루 1 (상시 확률표 그대로)
ad_brush
붓 받기
5
붓 30
ad_dungeon
던전 추가 입장
3
일일 던전 1회 (던전별 1)
ad_shop
원정 상점 새로고침
2
무작위 6칸 새로 뽑기
합계
14
단계
동작
첫 실행
UMP 동의 양식(필요 지역만) → 결과를 settings 와 consent 이벤트에 저장
미리 받기
앱 시작 15초 뒤 보상형 1개 미리 로드, 보여 준 직후 다음 1개 로드
버튼
Ads.can_show(slot) 이 false 면 버튼 회색 + 「오늘 n/3」
보상
on_user_earned_reward 콜백에서만 지급. 닫기만 하면 지급 안 함
생략권
no_ads 보유면 SDK 호출 없이 바로 지급 — 하루 한도는 그대로 센다(바이블 9-3)
실패
no_fill·오류면 「지금은 광고가 없어요」 + 한도 차감 없음
어린이
대상 등급 전체이용가여도 「아동 대상 앱」으로 지정하지 않음 — 지정하면 광고 제약이 커진다. 판단은 launch 문서
플러그인
판
상태
판단
godot-admob (godot-sdk-integrations)
—
바이블·game-spec 기준 1순위. 4.7 지원 미확인
1주차 빈 프로젝트 시험
Poing Studios godot-admob-plugin
v5.0.0 (2026-07-22)
Android·iOS 통합, UMP·모의 광고·미디에이션. 4.7 명시 미확인
대체안
광고 단위
보상형 1개(Android) + 1개(iOS)
테스트 중엔 Google 공식 테스트 단위 id
실제 단위로 시험 클릭 금지
bruni35
bruni · 게임기술 명세서
19 / Anti-Cheat
시간 조작·저장 변조 방지
원칙은 조사 결론과 같습니다 — 돈이 오가는 곳(결제·서버 시각)만 서버로 막고 나머지는 지연책과 기록입니다. 저장을 고쳐 자기 판을 망가뜨리는 것은 막지 않습니다. 감지해도 계정을 막지 않고 flags 에 남깁니다.
위협
대응
서버
강도
범위
기기 시계 앞으로 돌리기
서버 시각 기준 경과 시간, 오프라인이면 기기 시계 + 상한 24h + 다음 접속 대조
/v1/time
높음
1차판
기기 시계 뒤로 돌리기
last_device_ms 보다 작으면 그 회차 0분, anti.backward +1
—
높음
1차판
세션 중 시계 변경
ticks 경과와 기기 시계 경과가 5분 넘게 다르면 drift 기록, 계산은 ticks 기준
—
높음
1차판
저장 파일 숫자 수정
본문 HMAC-SHA256 + open_encrypted(AES-256, install_id 유도 키). 틀리면 save.bak
body.gacha.seq > 서버 gacha_state.seq → 있을 수 없는 값, 서버 값으로 내려보냄
반복 보상
daily.repeat_n > 30
total_play_ms
백업 사이 실제 경과 시간보다 큼
유료 빛구슬
서버 gem_ledger 잔액보다 큼 → 서버 값으로 내려보냄(서버가 덮어쓰는 항목 ① — ② 는 모집 순번)
bruni36
bruni · 게임기술 명세서
20 / Event Log
이벤트 기록 항목 전부
이벤트 기록은 48종입니다. 모든 이벤트에 공통 필드(e · ts(서버 기준 ms) · s(세션 id) · uid · ver · data_ver · max_stage · platform)가 자동으로 붙습니다. 이름·나이·연락처·기기 고유 id·광고 id 는 기록하지 않습니다. uid 는 서버가 만든 익명 값입니다.
주 1회 등급·개별 분포 대조 — 표준오차 3배 밖이면 즉시 조사. 기기 recruit 기록은 보조
성능
perf fps_p5·mem_mb
21장 기준 밑도는 기기 모델
개인정보Play 「데이터 보안」 양식·개인정보 처리방침에는 앱 활동(게임 내 행동)·앱 정보 및 성능(오류·성능)·구매 내역을 수집한다고 적고, 광고 SDK 가 수집하는 기기 id 는 AdMob 항목으로 따로 적습니다. 처리방침 문안과 스토어 양식 작성은 출시·운영 사양서 범위입니다.
bruni38
bruni · 게임기술 명세서
21 / Performance
성능 기준
저사양 기준은 조사의 추정치에서 출발해 1주차 실기기 측정으로 고정합니다. 측정 수단이 없는 기준은 두지 않았습니다 — 표의 「측정」 칸이 그 수단입니다.
100ms 고정 틱(combat.json tick_ms) — 프레임과 분리, ×2 배속은 한 프레임 최대 2틱
골든 테스트가 같은 결과
부팅
콜드 스타트 → 홈 3.0초 이내(저사양), DB 적재 800ms 이내
boot.gd 단계별 ms 기록
화면 전환
탭 전환 300ms 이내 · 이야기 화 진입 1.0초 이내
Router 측정
메모리
상주 텍스처 300MB 이하 · 전체 RSS 600MB 이하(저사양) — 추정
perf mem_mb
드로우콜
전투 화면 200 미만
편집기 모니터 · 실기기 원격 디버그
텍스처
VRAM 압축 ETC2/ASTC · 아틀라스 2048×2048 이하 · 장마다 배경·적 아틀라스 분리
내보내기 설정
설치 크기
1차판 AAB 다운로드 150MB 이하 (Play 기본 다운로드 한도 미확인 → 출시 전 확인)
빌드마다 크기 기록
저장
write_now() 50ms 이내 · 저장 파일 64KB 이하(13장 계산)
단위 테스트
배터리
홈 방치 화면 1시간 화면 켜짐 소모 8% 이하 — 추정, 실측 후 고정
실기기
네트워크
DAU 1명당 하루 요청 14.1건 이하(15장 예산) · 실패해도 게임 진행 막지 않음(결제 제외)
Net 계수기
bruni39
bruni · 게임기술 명세서
22 / Test Plan
테스트 계획
테스트의 중심은 그리기 없는 계산입니다. 전투는 세 겹으로 확인합니다. ① 난수기가 같은가(PCG32 수열 1,000개 일치) ② 판정 규칙이 같은가(파이썬 참조 구현 gen/combat_sim.py 의 표준 키트를 Godot 테스트 키트로 옮겨 같은 시드에서 결과 전부 일치) ③ 실제 동료 스킬이 맞는가(효과 13종·special 24종 단위 시험). 파이썬 시뮬레이터는 실제 동료 스킬을 돌리지 않으므로 ③ 을 골든 비교로 대신할 수 없습니다.
구분
무엇
방법
통과 기준
언제
데이터
id 참조·개수·범위
tools/validate_data.py (11장 검사 그대로)
오류 0
데이터 바꿀 때마다 · 빌드 전
단위
core/formula · idle_calc · rng · effect_dsl
GUT — tests/unit
모두 통과
커밋 전
난수 벡터
PCG32 세 구현이 같은 수열인가
tests/golden/pcg32_vectors.json(gen 이 만듦) — 시드·stream 5쌍 × 앞 1,000개 u32 를 rng.gd · server/src/pcg32.ts 가 재현
1,000개 전부 일치(1개라도 다르면 실패)
rng 코드 바꿀 때 · 1주차
골든 전투(정확)
틱 순서·피해 공식·난수 호출 순서
tests/golden/golden_battle.json — 참조 편성(combat_sim 의 표준 키트 5종 + 보스 키트를 GDScript 테스트 키트로 옮긴 것) × 시드 10 × 2판, PCG32(seed, 1). battle.run_headless(ref_kits=true) 와 비교
결과·종료 틱·남은 체력 전부 일치
전투 코드 바꿀 때
골든 전투(분포)
정확 일치가 부동소수 순서 차이로 깨졌을 때 규칙이 같은가
참조 편성 2판 × 시드 1,000 — 승률·평균 종료 시각을 golden_battle.json dist 기준값과 비교
승률 차이 ≤ 두 표본 표준오차 × 3(판마다 win_tol_pp) · 평균 종료 ±5%
전투 코드 바꿀 때
special·효과 단위
characters.json 효과 DSL 13종 + special 24종
GUT — 효과마다 고정 상황 1개(시전자·대상 능력치 고정, 난수 고정)와 기대값. 기대값은 systems 문서 공식으로 손계산해 tests/unit/effects.json 에 적음
전부 일치
효과 코드 바꿀 때
모집 분포
확률표대로 나오는가
server/src/gacha.ts 를 vitest 에서 100만 회 → 등급·개별 비율
공개 확률과의 차이가 표준오차 3배 안 (SSR 3% 기준 ±0.051%p)
확률표 바꿀 때 · 출시 전
항아리 분포
레벨 30단계 가중치
레벨마다 10만 회
등급별 ±3 표준오차
출시 전
저장 이전
v1→CURRENT
tests/save 샘플
필수 키·sig 통과
version 올릴 때
저장 손상
save.json 잘림·0바이트·서명 틀림
파일을 일부러 망가뜨림
save.bak 로 복구, tamper 기록
출시 전
시계 조작
기기 시계 +12h·−1일·비행기 모드
실기기 수동 9경우(16장 표)
표의 기대 결과와 일치
출시 전 · 시간 코드 바꿀 때
결제
6종 빛구슬·꾸러미·정기권·생략권
Play 라이선스 테스터 + 테스트 카드
중복 지급 0 · 취소 시 미지급 · 앱 강제 종료 후 재시작 지급
내부 테스트 주
광고
6자리 한도·생략권
AdMob 테스트 광고 단위
한도 초과 버튼 비활성 · 실패 시 보상 없음 안내
내부 테스트 주
서버
Worker 엔드포인트 13개
vitest + wrangler dev(로컬 D1)
모두 통과 · 같은 token 2회 지급 거부 · 옛 저장 복구 후 gacha_pulls replay 와 유료 잔액 맞춤 확인
서버 바꿀 때
스키마
data/*.json · 저장 샘플
tools/validate_data.py 가 data/schema/*.schema.json 으로 검사(11장)
위반 0건
데이터 바꿀 때마다 · 빌드 전
밸런스
무과금 7일 s060 · 30일 s150
economy_sim.py 결과 대조 + 내부 테스터 기록
±20% 안
1차판 관문
화면
33개 화면 세로 720×1280 · 1080×2400 · 태블릿
편집기 미리보기 + 실기기 3대
잘림·겹침 0
화면 추가 때
성능
21장 기준
저사양 실기기 30분 연속 방치·전투
기준표 전부 통과
관문마다
이야기
80화(1차판 20화) 재생
story_player 전 화 자동 넘김
명령 오류 0 · 누락 그림 0
대본 추가 때
모집 분포 기준의 계산SSR 3.0%, 100만 회일 때 표준오차 = √(0.03×0.97/1,000,000) = 0.0171%p, 3배 = 0.051%p. 관측 SSR 비율이 2.949%~3.051% 밖이면 실패입니다.
bruni40
bruni · 게임기술 명세서
22 / Golden Tests
난수 벡터와 골든 전투 기준값
골든 시험이 지금 구조로 통과할 수 있게 고친 내용입니다. 예전 시뮬레이터는 파이썬 random.Random(메르센 트위스터)을, Godot 은 PCG32 를 써서 같은 시드라도 수열이 달랐습니다. 이제 gen/pcg32.py 가 PCG32 를 정의하고(공개 예제 seed 42·stream 54 의 첫 6개와 일치: 확인), combat_sim.Battle(rng=…) 에 넘겨 같은 수열로 돌립니다. 기본값은 그대로 메르센 트위스터라 전투·성장 명세서의 수치는 바뀌지 않습니다. 시험 파일은 game-design/data/schema/golden/ 에 있고 게임 저장소 tests/golden/ 으로 복사합니다.
파일
내용
pcg32_vectors.json
(seed, stream) 5쌍 × 앞 1,000개 u32 — rng.gd 와 server/src/pcg32.ts 가 그대로 재현
golden_battle.json · exact
참조 편성 2판 × 시드 10 = 20건의 결과·종료 ms·도달 웨이브·남은 체력 — 정확 일치
golden_battle.json · dist
참조 편성 2판 × 시드 1,000 승률·평균 종료 시각 — 승률 차이 ≤ 두 표본 표준오차 × 3 · 평균 종료 ±5%
정확 일치 기준값 (처음 3개 시드만 표시)
판
시드
결과
종료 ms
도달 웨이브
아군 남은 체력(칸 1~5)
일반 판 · 아군 배율 1.0
1
win
16,400
3
1,152 · 950 · 637 · 700 · 765
일반 판 · 아군 배율 1.0
2
win
16,700
3
1,152 · 950 · 637 · 700 · 765
일반 판 · 아군 배율 1.0
3
win
16,400
3
1,125 · 950 · 637 · 700 · 765
보스 판 · 아군 배율 0.72 (승률 경계)
1
lose
42,800
3
0 · 0 · 0 · 0 · 0
보스 판 · 아군 배율 0.72 (승률 경계)
2
win
35,200
3
0 · 0 · 0 · 16 · 10
보스 판 · 아군 배율 0.72 (승률 경계)
3
win
36,400
3
0 · 0 · 0 · 57 · 69
분포 기준값과 난수기 차이
판
승률(PCG32)
승률(메르센)
차이
허용(3σ)
평균 종료(PCG32)
평균 종료(메르센)
차이
일반 판 · 배율 0.635
75.2%
78.0%
-2.8%p
±5.8%p
27.03초
26.91초
+0.4%
보스 판 · 배율 0.72
67.4%
66.7%
+0.7%p
±6.3%p
36.54초
36.65초
-0.3%
읽는 법·기준승률 경계(이기고 지는 게 시드마다 갈리는 배율)를 골라 판정 규칙의 작은 차이도 승률로 드러나게 했습니다. 처음 검토 제안이던 「승률 ±2%p」는 시드 1,000개에서 표본 오차(두 표본 차이의 1σ 1.9%p)와 거의 같은 크기라 난수기만 바꿔도 넘을 수 있습니다 (위 일반 판 · 배율 0.635 줄). 그래서 허용 폭을 두 표본 차이의 표준오차 × 3(표의 「허용」)으로, 평균 종료 시각은 ±5% 로 정했습니다. 정확 일치 시험이 주 기준이고, 분포 시험은 부동소수 연산 순서 차이로 정확 일치가 깨졌을 때 규칙이 같은지를 가리는 보조 기준입니다. 숫자는 gen/tech.py 가 시드 1,000개로 실제로 돌린 값입니다.
못 한 것combat_sim.py 가 characters.json 효과 표기(13종 연산자 + special 24종)를 실행하도록 넓히는 일은 하지 않았습니다 — 전투·성장 명세서의 시뮬레이터 범위를 바꾸는 일이라 그 문서가 맡습니다. 그래서 실제 동료 스킬은 위 「special·효과 단위」 시험으로 확인하고, 실제 편성 전투의 골든 비교는 두지 않습니다.
bruni41
bruni · 게임기술 명세서
23 / Build & Deploy
빌드·배포 절차
빌드는 손 단계를 줄이려고 저장소의 스크립트로 묶습니다. 아래 순서를 건너뛰면 데이터와 확률 공개본이 어긋날 수 있습니다. bruni 저장소 규칙대로 업로드 전에 푸시합니다.
단계
명령·도구
확인
1 데이터 내보내기
python3 tools/export_data.py --wave W0
data/*.json 17개 생성, `_`로 시작하는 키·문서용 필드(tip·note·desc 중 문서 전용) 제거
2 데이터 검사
python3 tools/validate_data.py
오류 0이어야 다음 단계
3 확률 공개본 생성
python3 tools/prob_publish.py
게임 안 prob_info 가 읽는 gacha.json 과 누리집 텍스트 페이지가 같은 파일에서 나옴
4 버전 올리기
python3 tools/bump_version.py 1.0.3
export_presets.cfg 의 version/name 과 version/code(정수, 매 업로드 +1) 동시 갱신
게임 프로젝트는 새 비공개 저장소 `bruni-idle` 에 둔다. 설계 원본(game-design/)은 bruni-world 에 남기고 tools/export_data.py 가 복사한다. 이유: 바이너리 그림·빌드 산출물이 사이트 저장소를 키우지 않게
데이터 파일 수
res://data 에 들어가는 파일은 17개(characters·enemies·bosses·items·stages·story·script_ch1·char_stories·growth·combat·gacha·shop·missions·attendance·events·economy·sounds). economy_sim.json·plan.json·roster.json·stage_frame.json 은 문서 전용이라 넣지 않는다
광고 플러그인
바이블 9-4 는 godot-admob(godot-sdk-integrations) 우선이다. 조사(godot-tech §3-1)는 Poing Studios v5 를 「최신·UMP 포함」으로 적었다. 두 저장소의 4.7 지원이 모두 미확인이라 1주차에 둘 다 빈 프로젝트로 시험하고, 광고 호출을 ads.gd 한 파일에 가둬 바꿔 끼울 수 있게 한다
이벤트 꾸러미 상품 id
이벤트마다 스토어 상품을 새로 만들지 않고 event_pack_s·m·l·xl 4개를 재사용한다. 구성은 원격 설정이 정한다(가격 고정, 구성만 바뀜)
정기권 상품 형식
물빛 정기권(pass_month)은 스토어 「구독」이 아니라 30일짜리 소모성 상품으로 판다. 구독 API(subscriptionsv2)·갱신 알림을 1차판에서 빼기 위해서다. 만료 3일 전 알림으로 재구매 유도
저장 키 유도
저장 암호 키에 OS.get_unique_id()(조사 §5 제안) 대신 첫 실행 때 만든 install_id 를 쓴다. 기기 고유 id 수집을 피해 개인정보 처리 방침이 단순해진다
이벤트 기록 보관
events 원본은 D1 에 90일만 두고 날짜별 요약(DAU·판 분포·결제)만 남긴다. D1 무료 저장 5GB 를 넘지 않게
모집은 서버가 굴린다
모든 모집(빛구슬·모집권·무료·광고)을 POST /v1/gacha/pull 이 판정하고 결과를 서명해 돌려준다. 기기에서 굴리면 ① 옛 저장 복구 뒤 다시 뽑기가 막히지 않고 ② 기기가 보낸 결과는 확률 준수 입증 자료가 못 된다(입증책임이 사업자 — monetization-law 2-6). 대가: 모집은 인터넷 연결이 있어야 한다(오프라인이면 버튼 비활성 + 「모집은 인터넷 연결이 필요해요」)
가방 상한
장비 가방 60칸(items.json storage.gear_slots · 화면 J07). 꽉 차면 항아리 결과는 자동 판매 규칙과 상관없이 골드로 바뀌고 토스트로 알림 — 화면 문서 결과 처리 규칙과 맞출 것
튜토리얼 단계 수
tutorial.step 0~17 — 화면 문서 튜토리얼 T01~T17 과 1:1
우편 상한
우편함 100통. 넘치면 결제분이 아닌 가장 오래된 수령 완료 우편부터 지운다 — 바이블에 우편 상한이 없어 새로 정함
원정 상점 무작위 칸
오늘 품목 6칸은 daily(04:00 리셋)에 둔다. 검토 지적은 weekly.seal_slots 였으나 화면 I05 가 「오늘 품목 6」, 새로고침 한도가 하루 단위(shop.json gs_shopref 하루 3회 · ad_shop 하루 2회)라 daily 로 정함
유료 서버 전환선 (바이블 12-2 변경)
바이블 12-2 「DAU 1만 또는 하루 요청 7만」을 「D1 저장 4GB 또는 하루 요청 7만」으로 바꾼다. 15장 계산으로 DAU 1명당 하루 14.1건이라 요청 7만은 DAU 약 4,965, D1 저장 4GB(무료 5GB 의 80%)는 DAU 약 3,096 에서 닿는다 — 둘 다 「DAU 1만」 보다 먼저다. launch(운영 비용 표·R17)·plan(15장 ③·낙관 시나리오 비용)이 이 값으로 바뀌어야 한다
bruni43
bruni · 게임기술 명세서
25 / Open Items
미확인 목록과 산출물
미확인으로 남은 것
#
내용
1
GDScript int 64비트 곱셈의 랩어라운드 동작 — PCG32 를 GDScript 로 짤 때 필요. 1주차 난수 벡터 시험으로 확인, 어긋나면 32비트 반쪽 곱으로 나눠 계산
2
Workers 에서 Ed25519 서명(WebCrypto) 사용 가능 여부 — 안 되면 HMAC-SHA256 + 앱 내 검증 키(키가 바이너리에 들어가 강도 낮음)
3
모집 기록(gacha_pulls) 보존 기간 — 전자상거래법 거래기록 보존(5년으로 추정) 적용 여부는 launch 문서 범위
4
godot-sdk-integrations godot-admob 와 Poing Studios godot-admob-plugin v5 의 Godot 4.7.2 동작 (조사 §3-1 · §7)
5
godot-google-play-billing 3.2.0 의 Godot 4.7.2 동작 — README 4.2+ 표기만 확인 (조사 §3-2)
6
iOS 결제 플러그인 선택(godot-storekit2 「API 불안정」 / hrk4649 4.7 이상) — iOS 착수(2027-08) 전 시험
7
Play 에서 acknowledge 안 한 구매의 자동 환불 기한 (조사 §3-4, §7-4)
8
AdMob 서버측 검증(SSV)을 플러그인이 설정할 수 있는지 (조사 §3-1)
9
저사양 실기기에서 JSON 17파일 파싱 시간 — 1주차 실측 (조사 §4-4)
10
GUT(Godot Unit Test)의 Godot 4.7 호환 판 — 1주차 확인. 안 되면 gdUnit4
11
Play 기본 다운로드 크기 한도(AAB) — 출시 전 Play Console 도움말 확인
12
기준 저사양 기기 급(Adreno 610/Mali-G52, RAM 3GB)과 메모리·배터리 한도 — 추정값, 실측 후 고정
13
iOS 클라우드 저장(GKSavedGame) 플러그인 — 없으면 서버 백업만 (조사 §2-2)
이 문서가 만든 파일
파일
무엇
game-design/data/tech.json
이 문서의 표 전부(폴더·오토로드·씬·함수·저장·서버·이벤트·성능·테스트·빌드) + 계산값
game-design/data/schema/*.schema.json
런타임 데이터 16개 + save 의 JSON Schema(2020-12) — 설계 범위, gen/tech_schema.py 에서 생성