← 문서 묶음 · 기획서 · 전투·성장 · 화면 · 출시·운영

bruni · 게임기술 명세서
Godot / Data / Server

브루니 키우기: 바랜 섬
기술 명세서

만든 날 2026-10-13 · 고친 날 2026-10-15 — 깃 이력에서 자동으로 박혀요

Godot 4.7.2 · Android 먼저, iOS 다음 · Cloudflare Workers + D1

항목내용
문서 범위프로젝트 구조 · 씬 트리 · 오토로드 · 함수 · 데이터 스키마 · 저장 · 서버 · 시간 검증 · 결제·광고 · 치트 방지 · 이벤트 기록 · 성능 · 테스트 · 빌드
규모씬 33 · 오토로드 14 · 함수 127줄 · special 24종 · 데이터 17파일 · 엔드포인트 13 · D1 테이블 8 · 기록 이벤트 48
데이터 검사35개 중 통과 35 · 실패 0 · 스키마 위반 데이터 0건 · 저장 0건 (11장)
기준 문서game-design/bible.md (2026-10-13) · research/godot-tech.md
생성game-design/gen/tech.py — 표의 숫자는 실행값
bruni01
bruni · 게임기술 명세서
Overview / Contents

문서 개요 및 목차

개발자가 이 문서만 보고 Godot 프로젝트를 만들고, 데이터를 읽고, 저장하고, 서버와 주고받고, 스토어에 올릴 수 있게 쓰는 것이 목표입니다. 표의 숫자 중 파일 크기·저장 크기·요청 예산·보상 예시·검사 결과는 gen/tech.py 가 실제 데이터로 계산한 값입니다. 바깥 사실은 조사 문서를 따르고 확인 못 한 것은 「미확인」으로 적었습니다.

장영문
01 기술 고정값과 구성도Fixed Stack
02 프로젝트 폴더 구조Project Tree
03 자동 로드 목록과 부팅 순서Autoloads
04 씬 목록 (33개)Scenes
05 씬 트리Scene Trees
06 스크립트별 함수 — 자동 로드 스크립트Functions
06 미션 유형 38종 → 발신 위치Mission Hooks
07 스킬 효과 표기 해석Effect DSL
08 데이터 파일 목록과 적재Data Files
09 데이터 스키마 — 동료JSON Schema
10 설정 파일과 남은 스키마JSON Schema
11 데이터 무결성 검사Data Validation
11 스키마 검사 결과 (설계 범위)Schema Validation
12 저장 구조Save Format
13 저장 쓰기·버전 이전Save Write & Migration
14 서버 필요 여부와 엔드포인트Server Decision
15 D1 테이블과 무료 한도 예산Server Budget
16 시간 검증과 오프라인 보상Time Integrity
17 결제 연동과 영수증 검증In-App Purchase
18 광고 연동 구조Ads
19 시간 조작·저장 변조 방지Anti-Cheat
20 이벤트 기록 항목 전부Event Log
21 성능 기준Performance
22 테스트 계획Test Plan
22 난수 벡터와 골든 전투 기준값Golden Tests
23 빌드·배포 절차Build & Deploy
24 문서 경계와 바이블 추가 제안Boundaries
25 미확인 목록과 산출물Open Items
bruni02
bruni · 게임기술 명세서
01 / Fixed Stack

기술 고정값과 구성도

이 문서는 「브루니 키우기: 바랜 섬」을 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장
AndroidtargetSdk 36 · minSdk 24 · AAB · 커스텀 빌드 템플릿godot-tech §4-2 (Play: 2026-08-31부터 API 36)
iOS1차판 출시 8주 뒤(2027-08-09) — 같은 프로젝트에서 내보냄바이블 12-1
전투 틱100ms 고정 · 웨이브 간격 1000msdata/combat.json
데이터res://data/*.json 17개 — 부팅 때 오토로드 DB 에 상주바이블 12-3
저장user://save.json — 원자적 쓰기 · HMAC-SHA256 · AES 암호화 · 서버 백업godot-tech §2
서버Cloudflare Workers + D1 (게임 전용 계정) — 시각·영수증·유료 장부·모집 판정·백업·기록godot-tech §1-4 · 바이블 12-2 · 14장
결제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개손으로 고치지 않고 편집기에서만
export_presets.cfgAndroid·iOS 내보내기 프리셋키스토어 비밀번호는 넣지 않음 — 환경변수 GODOT_ANDROID_KEYSTORE_RELEASE_*
addons/플러그인 — admob · GodotGooglePlayBilling · storekit2 · gut버전은 addons/VERSIONS.md 한 장에 적음
android/Android 커스텀 빌드 템플릿(gradle) — 결제·광고 플러그인이 요구편집기 「Android 빌드 템플릿 설치」로 생성, 깃에 포함
autoload/전역 싱글톤 스크립트 14개 (03장)노드 없는 .gd 만
core/노드에 붙지 않는 순수 계산 — 공식·방치·항아리·드롭·난수·효과 해석·아이템 사용·저장 이전class_name 정적 함수. 단위 테스트 대상의 중심. 모집 판정은 여기 없음 — 서버 server/src/gacha.ts
scenes/<화면>/화면마다 .tscn + .gd 한 쌍 (04장 33개)폴더 이름 = 화면 id
ui/공통 부품 — 재화 막대·보상 목록·확인 창·토스트·확률표·초상 칸scenes 가 인스턴스로 씀
data/게임 데이터 JSON 17개 + manifest.json + i18n/ui.csvtools/export_data.py 산출물. 직접 고치지 않음
assets/char · enemy · boss · item · bg · ui · fx그림 — 파일명은 바이블 12-4목록·크기는 /me/idle-rpg/art/
assets/font/픽셀 한글 글꼴 1종 + 폴백 1종OFL 라이선스 파일 같이 둠
assets/sfx · bgm효과음 .wav · 배경음 .ogg목록은 /me/idle-rpg/art/
shaders/palette_swap(브루니 5색) · invert_plus(강화판 보스) · fade_gray(바램 연출) · flash_hit(피격)셰이더 4개 고정
tests/unit · golden · saveGUT 테스트 — 공식·전투 골든·저장 이전내보내기에서 제외(export filter)
(저장소) tools/export_data.py · validate_data.py · prob_publish.py · build_android.sh · bump_version.py파이썬·셸. res:// 밖
(저장소) server/Cloudflare Worker — src/*.ts(time·account·save·iap·gacha·events) · src/pcg32.ts · migrations/*.sql · wrangler.jsonc게임용 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 시그널만 씁니다.

#이름파일책임의존
1Logautoload/log.gd로컬 로그 링버퍼(최근 500줄) · 오류 수집 · 디버그 빌드 화면 출력—
2DBautoload/db.gdres://data/*.json 을 부팅 때 한 번 읽어 Dictionary 로 상주 · id 조회 · 공개 차수 필터Log
3Clockautoload/clock.gd서버 시각 동기·세션 ticks 기준 현재 시각 · 04:00 KST 일일 리셋·월요일 주간 리셋 판정Log, Net(지연 연결)
4Saveautoload/save.gd계정 상태 Dictionary 보관 · 원자적 저장 · HMAC 서명 · 버전 이전 · 서버 백업DB, Clock, Log
5Walletautoload/wallet.gd재화 12종 증감의 유일한 입구 · 빛구슬 유료/무료 분리 장부 · 메모리 가림(XOR)Save, Bus
6Busautoload/bus.gd전역 시그널 모음 — 미션·이벤트 기록·화면 갱신이 구독—
7Netautoload/net.gdHTTPRequest 풀 · 재시도 큐(지수 대기 2·4·8·16·32초) · 요청 서명Log
8Remoteautoload/remote.gd원격 설정(이벤트 달력·배너·최소 버전·기능 스위치) 받아 캐시Net, Save
9Analyticsautoload/analytics.gd이벤트 기록 큐 · 30초 또는 20건마다 묶어 전송 · 오프라인 보관(최대 500건)Net, Clock, Bus
10Adsautoload/ads.gd보상형 광고 6자리 단일 입구 · 하루 한도 · 광고 생략권 분기 · UMP 동의Wallet, Save, Analytics
11IAPautoload/iap.gd결제 플러그인 감싸기(Android/iOS 한 인터페이스) · 서버 검증 · 우편 지급Net, Save, Analytics
12Missionsautoload/missions.gdBus 시그널을 받아 일일·주간·업적 120개 진행도 갱신Bus, Save, DB, Clock
13Audioautoload/audio.gd배경음 교차 재생 · 효과음 풀(동시 8) · 설정 음량Save
14Routerautoload/router.gd화면 전환 · 팝업 스택 · Android 뒤로 가기 · 로딩 가림막Audio

부팅 순서 (boot.gd)

단계하는 일실패하면목표 시간
1DB.load_all()오류 화면 + Log 전송 시도 (데이터 파일이 깨진 빌드)800ms 이하
2Save.load_or_new()save.bak → 새 계정 + 「복구 코드가 있으면 설정에서 복구」 안내100ms 이하
3Clock.sync() — 3초 제한오프라인 모드 — 기기 시계 + pending_verify3,000ms 상한
4Remote.fetch() — 비동기마지막 캐시기다리지 않음
5Missions.reset_if_needed()——
6IAP.init_store() · Ads 초기화 — 비동기상점 버튼만 비활성기다리지 않음
7첫 실행이면 title, 아니면 main(home)—합계 3.0초 이내
bruni05
bruni · 게임기술 명세서
04 / Scenes

씬 목록 (33개)

화면 하나 = 씬 하나입니다. 와이어프레임·문구·이동 지도는 화면 명세서가 정하고, 여기서는 파일과 루트 노드만 고정합니다. 화면 명세서의 화면 id 는 이 표의 id 와 같아야 합니다.

id파일루트무엇1차판
bootscenes/boot/boot.tscnControl로고 · DB 적재 · 저장 불러오기 · 서버 시각 · 원격 설정●
titlescenes/title/title.tscnControl첫 실행 약관·개인정보 동의 · UMP 동의 · 클라우드 복구 버튼●
mainscenes/main/main.tscnNode상주 뼈대 — 화면 칸(ScreenSlot) · 팝업 층 · 재화 막대 · 하단 탭●
homescenes/home/home.tscnControl방치 사냥 화면(전투 축소판) · 방치 보상 버튼 · 판 도전●
battlescenes/battle/battle.tscnNode2D판 전투 — 웨이브·보스·결과●
battle_resultscenes/battle_result/battle_result.tscnControl승패·보상·다음 판/재도전●
stage_mapscenes/stage_map/stage_map.tscnControl장 지도 · 판 목록 · 추천 전투력●
idle_rewardscenes/idle_reward/idle_reward.tscnControl방치 보상 팝업 · 광고 2배 · 빠른 방치●
potscenes/pot/pot.tscnControl물감 항아리 뽑기·항아리 레벨·자동 판매 규칙●
gearscenes/gear/gear.tscnControl브루니 장비 6부위 · 가방 · 강화 · 제작●
bruniscenes/bruni/bruni.tscnControl브루니 레벨·색 전환·능력치●
companionsscenes/companions/companions.tscnControl동료 목록 · 정렬·필터●
companion_detailscenes/companion_detail/companion_detail.tscnControl레벨업·승급·각성·스킬 강화·인연·선물●
partyscenes/party/party.tscnControl출전 5칸(전열 2·후열 3) 편성●
recruitscenes/recruit/recruit.tscnControl모집 배너·1회/10회·천장 교환·확률 보기 버튼●
recruit_resultscenes/recruit_result/recruit_result.tscnControl모집 연출·결과 카드●
prob_infoscenes/prob_info/prob_info.tscnControl확률 정보 — 동료·항아리·상자 개별 확률, 천장 횟수별 확률●
shopscenes/shop/shop.tscnControl빛구슬·꾸러미·정기권·원정 상점·빛구슬 상점●
passscenes/pass/pass.tscnControl성장 패스 30단계●
missionscenes/mission/mission.tscnControl일일·주간·업적 탭●
attendancescenes/attendance/attendance.tscnControl출석 28칸●
story_listscenes/story_list/story_list.tscnControl본편 장·화 목록 · 다시 보기●
story_playerscenes/story_player/story_player.tscnControl대본 재생 — 배경·서 있는 그림·대사·선택지·건너뛰기●
char_storyscenes/char_story/char_story.tscnControl개인 이야기 목록(동료별 3화)●
dungeonscenes/dungeon/dungeon.tscnControl일일 던전 3곳 입장●
towerscenes/tower/tower.tscnControl시련의 탑 100층—
raidscenes/raid/raid.tscnControl주간 토벌 요일 보스—
event_hubscenes/event_hub/event_hub.tscnControl진행 중 이벤트·이벤트 판·이벤트 상점●
mailscenes/mail/mail.tscnControl우편함 — 결제 지급·보상·「수령」●
relicscenes/relic/relic.tscnControl유물 24종 보유 효과·강화●
settingsscenes/settings/settings.tscnControl음량·배속·필살기 수동·언어·계정 연결·복구 코드·약관●
popup_confirmui/popup_confirm.tscnPanelContainer공통 확인 창(재화 소모 확인·청약철회 불가 문구)●
toastui/toast.tscnPanelContainer짧은 알림 2초●
bruni06
bruni · 게임기술 명세서
05 / Scene Trees

씬 트리

자주 바뀌지 않는 큰 씬의 노드 구성입니다. 들여쓰기 칸 수가 깊이입니다. 판정 코드는 그리기 노드에 넣지 않습니다 — unit.gd 는 그리기만, 판정은 battle.gd 입니다.

main.tscn — 상주 뼈대

노드타입역할
MainNodemain.gd — 부팅 끝나면 home 을 ScreenSlot 에 넣음
    BackgroundCanvasLayer (layer −10)장 배경 bg/ch?_??.png
    ScreenSlotControl (전체)Router 가 화면 씬 하나를 자식으로 바꿔 끼움
    HUDCanvasLayer (layer 10)항상 보이는 층
        CurrencyBarui/currency_bar.tscn골드·빛구슬(합계)·붓 — Wallet.changed 구독
        BottomTabsHBoxContainer홈·동료·모집·상점·더보기 5탭
    PopupLayerCanvasLayer (layer 20)Router.push_popup() 대상, 스택
    ToastLayerCanvasLayer (layer 30)toast 인스턴스
    BlockerColorRect전환 중 입력 막기(알파 0.4)

battle.tscn — 판 전투

노드타입역할
BattleNode2Dbattle.gd — 틱 루프 12단계(combat.json tick_order)
    FieldNode2D720×760 전투 영역
        AllySlotsNode2D칸 5개(Marker2D) — 전열 1·2, 후열 3·4·5
            Slot1~Slot5Marker2Dunit.tscn 인스턴스 자리
        EnemySlotsNode2D칸 최대 6개(Marker2D)
        FxLayerNode2D피해 숫자·타격 효과(풀 40개 재사용)
    UICanvasLayer
        WaveLabelLabel「웨이브 2/3」
        TimerBarProgressBar남은 시간 60/90초
        BossBarProgressBar보스 체력·페이즈 눈금
        PortraitsHBoxContainer동료 초상 5 — 필살기 게이지·수동 시전 터치
        SpeedButtonButton×1 / ×2 (×2 는 s010 클리어 후)
        AutoUltToggleCheckButton필살기 자동/수동
        PauseButtonButton일시정지 → 포기 확인
    TickTimer(없음)_physics_process 고정 100ms 누적 방식 — Timer 노드 안 씀

unit.tscn — 전투 유닛(아군·적 공용)

노드타입역할
UnitNode2Dunit.gd — 능력치·상태·게이지 보관, 그리기만 함(판정은 battle.gd)
    SpriteAnimatedSprite2DSpriteFrames: idle 4·walk 4·attack 6·ult 8·hit 2·down 4 (적은 ult·walk 없음)
    ShadowSprite2D발밑 그림자
    HpBarTextureProgressBar체력
    ShieldBarTextureProgressBar보호막 겹침
    StatusIconsHBoxContainer버프·디버프 아이콘 최대 6
    AttrBadgeTextureRect속성 5색 표시
bruni07
bruni · 게임기술 명세서
05 / Scene Trees

씬 트리 (이어서)

story_player.tscn — 대본 재생

노드타입역할
StoryPlayerControlstory_player.gd — script_ch1~8.json 대본을 줄 단위로 재생
    BgTextureRectbg/ch?_??.png, 페이드
    StandsControl서 있는 그림 자리 L·C·R (256×384)
    DialogBoxPanelContainer이름표 + RichTextLabel(글자 단위 출력 30자/초)
    ChoiceBoxVBoxContainer선택지 버튼 2~3
    TopBarHBoxContainer자동 넘김·기록(백로그)·건너뛰기
    BacklogPanel지난 대사 200줄

recruit.tscn — 모집

노드타입역할
RecruitControlrecruit.gd
    BannerPagerScrollContainer배너 카드(입문·상시·픽업·색 축제)
    PickupArtTextureRect픽업 동료 모집 카드 256×384
    PullButtonsHBoxContainer1회 150 / 10회 1,500 / 모집권
    NoRefundLabelLabel「모집 실행 후에는 청약철회가 되지 않아요」— 버튼 바로 위 고정
    ProbButtonButton확률 정보 → prob_info (같은 배너 id 전달)
    StardustHBoxContainer별가루 n/200 · 교환 버튼
bruni08
bruni · 게임기술 명세서
06 / Functions

스크립트별 함수 — 자동 로드 스크립트

함수 127줄(한 줄에 여럿 묶은 것 포함)입니다. 시그니처는 GDScript 4 정적 타입 그대로 적었습니다. 반환이 await 가능한 함수는 설명에 적었습니다. 여기 없는 함수를 새로 만들 땐 이 표에 먼저 한 줄 넣습니다.

스크립트시그니처설명
db.gdload_all() -> Errordata/ 17파일 읽기·파싱·id 색인 생성 + 참조 검사(대본 bg 는 대본 backgrounds, 대본 bgm·se 는 sounds.json id, 판·화 bg 는 배경 파일 목록). 실패하면 boot 가 오류 화면
db.gdchar(id: String) -> Dictionarycharacters.json 한 명. 없으면 {} + Log.error
db.gdstage(id: String) -> Dictionarystages.json 한 판
db.gdenemy(id: String) -> Dictionarye### 또는 b### — 보스는 bosses 에서
db.gditem(id: String) -> Dictionaryi001~i150
db.gdepisode(id: String) -> Dictionaryep_<장>_<화> 또는 cs_<c###>_<n>
db.gdreleased(wave: String) -> bool공개 차수(W0~W6)가 현재 Remote.wave 이하인지
db.gdchars_in_pool(banner_id: String) -> Array[String]그 시점 모집 풀
clock.gdnow_ms() -> int서버 기준 현재 시각 = server_ms_at_sync + (ticks_now − ticks_at_sync)
clock.gdsync() -> voidGET /v1/time, 성공 시 기준점 갱신·synced 시그널
clock.gdis_synced() -> bool이번 세션에 서버 시각을 받았는지
clock.gdday_key(ms: int) -> String04:00 KST 기준 날짜 문자열 "2027-06-14"
clock.gdweek_key(ms: int) -> String월요일 04:00 KST 기준 주 "2027-W24"
clock.gdcheck_drift() -> void기기 시계 − now_ms() 가 5분 넘으면 anti.drift +1, 기록
save.gdload_or_new() -> voidsave.json → 실패 시 save.bak → 둘 다 실패면 새 계정 + 서버 복구 안내
save.gdmark_dirty() -> void2초 디바운스 후 write_now()
save.gdwrite_now() -> Error직렬화 → HMAC → 암호화 → save.tmp → rename. 직전본 save.bak
save.gdverify(raw: Dictionary) -> boolsig 필드와 HMAC-SHA256 재계산 비교
save.gdmigrate(raw: Dictionary) -> Dictionarycore/save_migrate.gd 체인 호출, 버전을 현재까지 올림
save.gdbackup_to_server() -> voidPUT /v1/save (하루 2회 + 결제 직후 + 장 완료 직후)
save.gdrestore_from_server(code: String) -> Error복구 코드로 GET /v1/save, 충돌 규칙 적용
save.gd_notification(what: int) -> voidAPPLICATION_PAUSED·FOCUS_OUT·WM_CLOSE_REQUEST 에서 즉시 write_now()
wallet.gdget_amount(cur: String) -> intgem 이면 유료+무료 합
wallet.gdadd(cur: String, n: int, src: String) -> voidn>0, src 는 기록용 출처 id(예 stage_first:s010)
wallet.gdspend(cur: String, n: int, why: String) -> bool모자라면 false. gem 은 무료분 먼저, 유료 차감분은 gem_spend 이벤트로 남김
wallet.gdadd_paid_gem(n: int, order_id: String) -> voidIAP 검증 성공 시에만 호출되는 유일한 유료분 입구
wallet.gd_mask(v: int) -> int / _unmask(v: int) -> int세션 난수 XOR + 체크섬(메모리 변조 지연책)
net.gdrequest(method: String, path: String, body: Dictionary = {}) -> Dictionaryawait 가능. {ok, status, json}. 실패 시 재시도 큐
net.gd_sign(body: String, ts: int) -> String요청 무결성용 HMAC(기기 토큰 키) — 결제·저장 요청에만
remote.gdfetch() -> voidGET /v1/config, ETag 캐시. 실패 시 마지막 캐시 사용
remote.gdget_flag(key: String, default: Variant) -> Variant기능 스위치(예 raid_enabled)
remote.gdmin_version_ok() -> bool앱 버전 < min_version 이면 업데이트 강제 창
analytics.gdlog(event: String, params: Dictionary = {}) -> void큐에 넣음. 공통 필드(uid·ts·session·ver·max_stage) 자동
analytics.gdflush() -> voidPOST /v1/events 묶음 전송, 성공분만 큐에서 제거
ads.gdcan_show(slot: String) -> bool하루 한도·광고 준비·UMP 상태 확인
ads.gdshow_rewarded(slot: String) -> boolawait. 생략권 있으면 바로 true. 보상 지급은 호출한 쪽
ads.gdremaining(slot: String) -> int그 자리 오늘 남은 횟수
iap.gdinit_store() -> void플랫폼별 플러그인 연결, 상품 정보 질의, 미완료 구매 sync
iap.gdbuy(product_id: String) -> void결제 창 → purchase_succeeded 에서 verify() 호출
iap.gdverify(p: Dictionary) -> voidPOST /v1/iap/verify → 성공이면 우편 지급, 실패면 재시도 큐(최대 72시간)
iap.gdrestore() -> void광고 생략권(비소모성) 복원 + 서버 미지급분 GET /v1/iap/pending
missions.gdon_event(type: String, amount: int, ctx: Dictionary) -> void120개 중 type 일치하는 미션 갱신. 집계 방식(add 누적 · max 현재값 · has 조건 일치)은 07장 「미션 유형 → 발신 위치」 표
missions.gdrecheck_all() -> void부팅·저장 복구 직후 1회 — max·has 유형을 저장 상태(최고 판·보유 동료·각성 합…)에서 다시 계산. 이벤트를 놓친 업적을 메움
missions.gdclaim(mission_id: String) -> Array보상 지급, 받은 목록 반환
missions.gdreset_if_needed() -> voidClock.day_key/week_key 가 바뀌었으면 일일·주간 초기화
audio.gdplay_bgm(id: String, fade_s: float = 0.6) -> voidsounds.json 으로 id → file(alias 면 같은 파일) 뒤 교차 재생
audio.gdplay_se(id: String) -> voidsounds.json 으로 id → file(alias 면 같은 파일). 풀 8개 중 빈 것, 같은 id 50ms 안 중복 무시. 없는 id 는 Log.error 후 무시
router.gdgo(screen_id: String, args: Dictionary = {}) -> voidScreenSlot 교체, 이전 화면은 queue_free
router.gdpush_popup(scene: PackedScene, args: Dictionary = {}) -> NodePopupLayer 스택
router.gdback() -> bool팝업 먼저 닫고, 없으면 이전 화면, home 이면 종료 확인
bruni09
bruni · 게임기술 명세서
06 / Functions

스크립트별 함수 — core — 순수 계산 (정적 함수)

스크립트시그니처설명
core/formula.gdstat(c: Dictionary, st: Dictionary, key: String) -> floatLv1 값 × 레벨 × ★ × 각성 × (장비·유물·인연) — 순서는 growth.json stat_order
core/formula.gdpower(c: Dictionary, st: Dictionary) -> int공격×5 + 체력×0.5 + 방어×3 × (1 + 스킬 레벨 합 × 0.01), 반올림
core/formula.gdparty_power(party: Array) -> int출전 5명 합 + 유물 보정
core/formula.gddamage(atk: float, df: float, coef: float, el: float, crit: bool, rnd: float) -> int공격 × 계수 × 상성 × 치명 × atk/(atk+df) × 난수
core/formula.gdelement_mul(a: String, d: String) -> floatcombat.json element.matrix 조회 (1.30·0.80·1.00)
core/idle_calc.gdreward(stage_no: int, chapter: int, elapsed_min: int, cap_min: int, pass_on: bool) -> Dictionary골드·방울·경험치·붓. min(경과, 상한) 분 단위 내림
core/idle_calc.gdoffline_minutes(last_ms: int, now_ms: int, device_backward: bool) -> int역행이면 0, 음수 0
core/gacha_apply.gdapply(pull: Dictionary, save: Dictionary) -> Dictionary서버가 돌려준 모집 결과 1건 적용 — pull.seq ≤ save.gacha.seq 면 건너뜀(같은 결과 두 번 적용 방지). 신규면 획득, 중복이면 기억 조각·무지개 조각 환원(gacha.json duplicate), gacha.log 앞에 넣고 500건 넘으면 뒤에서 버림
core/gacha_apply.gdverify_sig(pull: Dictionary) -> bool응답 서명(Ed25519, 공개 키는 앱 상수) 확인 — 틀리면 적용하지 않고 Log + save_tamper 기록
core/pot_roll.gddraw(pot_lv: int, rng: Rng) -> Stringgacha.json pot.levels 가중치 → 등급 → 그 등급 장비 12종 균등
core/rng.gdRng.new(seed: int, stream: int)PCG32(pcg_basic) — gen/pcg32.py 와 같은 정의. stream 1 전투 · 3 항아리 · 4 드롭 · 5 상점 진열(2 모집은 서버). Godot RandomNumberGenerator 는 randf() 가 u32 를 여러 개 쓸 수 있어 쓰지 않음
core/rng.gdnext_u32() -> int / next_float() -> float / uniform(a: float, b: float) -> float / pick_weighted(w: Dictionary) -> Stringnext_float = u32 ÷ 2^32(u32 하나). uniform = a+(b−a)×next_float. 64비트 곱은 GDScript int(부호 있는 64비트) 랩어라운드에 기대고, >> 뒤 마스크로 부호 확장을 지움 — 1주차 벡터 시험으로 확인(미확인). 가중치는 누적 합 이분 탐색
core/drop_roll.gdstage_drops(stage: Dictionary, rng: Rng) -> Array반복 보상 판정 — 등장 적 종류마다 enemies.json drops 1회씩. daily.repeat_n ≥ 30 이면 호출 안 함
core/drop_roll.gdboss_relic(boss_id: String, save: Dictionary, rng: Rng) -> Stringbosses.json reward_repeat 의 유물 % 판정. 안 나오면 relic_pity[유물] +1, 49 에서 다음 처치는 확정(enemies.json rules.relic_pity 50) → 0 으로
core/stage_reward.gdfirst_clear(stage_id: String, save: Dictionary) -> Arrayprog.first 비트가 0 일 때만 stages.json first_reward + first_reward_items. 비트 세움
core/stage_reward.gdrepeat(stage_id: String, save: Dictionary, rng: Rng) -> Arraydaily.repeat_n < 30 이면 repeat_reward + stage_drops, repeat_n +1. 30 이면 [] 와 「오늘 반복 보상 끝」 표시(stages.json rules.repeat_reward)
core/item_use.gduse(item_id: String, n: int, save: Dictionary, rng: Rng) -> Arrayitems.json kind 로 분기 — quick_idle(i143·i144·i145 = hours 2·4·8 → IdleCalc.reward) · brush_pack(i146·i147 → brush) · random_box(i148~i150 → gacha.json boxes 가중치 합 10000, n 번) · gift 는 거부(선물은 give_gift 로만). 받은 목록 반환
core/item_use.gdmerge_essence(item_id: String, times: int) -> boolitems.json essence_merge — 하 5 + 골드 2,000 → 중 1, 중 4 + 골드 10,000 → 상 1. 같은 속성끼리만, 상은 합성 대상 아님
core/effect_dsl.gdrun(effect: Array, src: Unit, tgt_spec: String, ctx: BattleCtx) -> voiddmg·buff·debuff·heal·shield·gauge·taunt·regen·stain·cleanse·dispel·revive·special 13종 분기
core/effect_dsl.gdresolve_targets(spec: String, src: Unit, ctx: BattleCtx) -> Array[Unit]front·back·lowest·all·rand2·a_all·a_low… 표기 해석
core/special_handlers.gdhandle(id: String, params: Dictionary, src: Unit, ctx: BattleCtx) -> voidspecial 효과 레지스트리 — 08장 표의 id 마다 함수 1개
core/save_migrate.gdrun(raw: Dictionary) -> Dictionaryraw.version 부터 CURRENT 까지 _v{n}_to_{n+1} 차례 호출
core/crypto_util.gdhmac_hex(key: PackedByteArray, msg: String) -> StringCrypto.hmac_digest(HASH_SHA256)
core/crypto_util.gdderive_key(install_id: String) -> PackedByteArraySHA256(APP_PEPPER + install_id) 32바이트
bruni10
bruni · 게임기술 명세서
06 / Functions

스크립트별 함수 — 전투

전투 틱 12단계 함수 이름은 data/combat.json 의 tick_order[].fn 과 같습니다. 판정 순서의 뜻은 전투·성장 명세서가 정합니다.

스크립트시그니처설명
scenes/battle/battle.gdsetup(stage_id: String, party: Array, seed: int) -> void웨이브 구성·유닛 생성·첫 행동 = 간격×0.5
scenes/battle/battle.gd_physics_process(delta: float) -> void누적 시간 ≥ 100ms 마다 _tick() (배속이면 한 프레임 최대 2틱)
scenes/battle/battle.gd_tick() -> void아래 12단계를 순서대로
scenes/battle/battle.gd_poll_input() · _advance_clock() · _tick_periodic() · _tick_durations()① 입력 ② 시간 ③ 지속 효과(화상·중독·재생) ④ 지속시간 감소
scenes/battle/battle.gd_resolve_deaths() · _tick_timers() · _sort_ready()⑤ 쓰러짐 ⑥ 행동 타이머 ⑦ 같은 틱 정렬(속도→아군→칸)
scenes/battle/battle.gd_cast_ultimates() · _run_actions() · _apply_hit(hit: Hit)⑧-1 필살기 ⑧-2 기본기 ⑧-3 피격 반응(게이지 +5·반사·흡혈)
scenes/battle/battle.gd_check_wave() · _check_timeout()⑨ 웨이브 전멸·다음 웨이브 1초 뒤 ⑩ 60/90초 초과 실패
scenes/battle/battle.gdfinish(win: bool) -> voidbattle_end 시그널, 결과 화면. 이기면 StageReward.first_clear → 없으면 StageReward.repeat, 보스 판이면 DropRoll.boss_relic, 화 해금
scenes/battle/battle.gdrun_headless(stage_id: String, party: Array, seed: int) -> Dictionary그리기 없이 끝까지 — 골든 테스트·방치 사냥 판정에 씀
scenes/battle/unit.gdinit_from(data: Dictionary, st: Dictionary, side: int, slot: int) -> void능력치 계산 결과 주입
scenes/battle/unit.gdadd_status(id: String, value: float, dur_s: float, src: Unit) -> voidcombat.json stack_rules(refresh·stack·per_src…) 적용
scenes/battle/unit.gdtake_damage(n: int, hit: Hit) -> void보호막 먼저, 체력 0 이면 down 대기
scenes/battle/unit.gdplay(anim: String) -> void그리기만. 판정과 분리
scenes/battle/boss_unit.gdon_hp_ratio(r: float) -> voidbosses.json phases hp_from/hp_to 경계에서 페이즈 전환·속성 전환
bruni11
bruni · 게임기술 명세서
06 / Functions

스크립트별 함수 — 화면 스크립트

스크립트시그니처설명
scenes/home/home.gd_on_idle_tick() -> void방치 사냥 연출(헤드리스 판정 결과를 3초마다 재생)
scenes/home/home.gdopen_idle_reward() -> voididle_reward 팝업, 경과 분 전달
scenes/idle_reward/idle_reward.gdclaim(double_by_ad: bool) -> voidIdleCalc.reward → Wallet.add, last_claim_ms 갱신
scenes/idle_reward/idle_reward.gdquick(kind: String) -> voidfree·ad·gem — 하루 1·2·3회, 빛구슬 50→100→150
scenes/pot/pot.gddraw(n: int) -> void붓 n 소모 → PotRoll → 자동 판매(pot.auto_sell 등급 이하, keep_if_up 이면 전투력 오르는 건 남김) → auto_equip → 남은 것 가방
scenes/pot/pot.gdauto_open_tick() -> bool자동 열기 1회. 멈춤 조건 pot.auto_stop 3개 — g4up(영웅 이상 나옴) · brush_min(붓이 이 수 이하) · cp_up(전투력 오르는 장비) 중 켜진 것 하나라도 맞으면 false
scenes/pot/pot.gdupgrade() -> void골드 소모, upgrading_until = now + wait_min
scenes/gear/gear.gdequip(gear_uid: int) -> void부위 교체, 이전 장비 가방으로(가방 60칸 꽉 차면 거부). 강화 수치는 칸(bruni.slot_enh)에 있어 그대로
scenes/gear/gear.gdenhance(slot: String) -> boolbruni.slot_enh[slot] +1 (0~20). growth.json gear_enhance 비용·need_bruni_lv, 성공률 100%(확률 없음)
scenes/gear/gear.gdauto_equip_if_better(gear_uid: int) -> boolpot.auto_equip 이 켜져 있을 때 항아리 결과마다 — 같은 부위 cp 비교, 높으면 equip
scenes/gear/gear.gdcraft(item_id: String) -> boolitems.json craft 재료 확인·소모
scenes/gear/gear.gdsell(uids: Array) -> void골드 + 브루니 경험치(최고 판 배율)
scenes/companion_detail/companion_detail.gdlevel_up(n: int) -> void · star_up() · awaken() · skill_up(slot: int) · give_gift(item_id: String)각 비용은 growth.json 표 그대로
scenes/party/party.gdset_slot(slot: int, char_id: String) -> void브루니 필수 검사, 전열 2·후열 3
scenes/recruit/recruit.gdpull(banner_id: String, n: int, pay: String) -> voidpay = gem·ticket·free·ad. 실행 전 확인 창(청약철회 불가 문구). 서버 연결 없으면 버튼 비활성. await Net POST /v1/gacha/pull → GachaApply.verify_sig → apply → Wallet 차감(서버가 정한 무료·유료 분할 그대로) → 즉시 write_now
scenes/recruit/recruit.gdexchange_stardust(char_id: String) -> void별가루 200 → 픽업(또는 상시 SSR) 1명
scenes/prob_info/prob_info.gdrender(table_id: String) -> voidDB 의 gacha.json 표를 그대로 그림 — 숫자를 코드에 적지 않음
scenes/story_player/story_player.gdplay(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
scenes/story_player/story_player.gdchoose(i: int) -> void선택 기록 + 인연 +1(bond 대상)
scenes/dungeon/dungeon.gdenter(dg_id: String, by_ad: bool) -> void열쇠 1 소모 또는 광고
scenes/raid/raid.gdenter(boss_id: String) -> void · finish(dmg: int) -> Arrayweekly.raid[b].tries < 3 확인·+1 → 전투(bosses.json raid_tiers 단계 능력치) → 피해 구간(raid_reward.bands 25·50·75·100%) 보상, best 갱신. 100% 면 다음 단계
scenes/tower/tower.gdenter(floor: int) -> void · finish(win: bool) -> Arrayfloor = tower.floor + 1 만 도전. 이기면 stages.json tower[floor].reward, tower.floor +1
scenes/stage_map/stage_map.gdset_difficulty(hard: bool) -> void어려움(W6) 전환 — 해금 = 보통 같은 장 마지막 판 클리어. 어려움 판은 stages[].hard 의 배율·req_cp·first_reward, 진행은 prog.hard_max
scenes/bruni/bruni.gdset_color(color_id: String) -> boolgrowth.json color 의 stage 를 넘었는지(prog.max_stage) 확인 → bruni.color 교체·palette_swap 셰이더 파라미터·bruni_color 기록. 처음 여는 색이면 bruni.colors 에 추가
scenes/relic/relic.gdlevel_up(relic_id: String) -> boolgrowth.json relic 표 — 복사본(inv 의 같은 유물 id 수량 — 첫 1개는 relic 으로 옮겨 장착, 그 뒤 중복은 inv 에 쌓임)·유물 숫돌 i132·골드 확인 후 relic[id].lv +1 (1~10)
scenes/attendance/attendance.gdclaim() -> Arrayattend.last_day == Clock.day_key(now) 면 거부. 아니면 attend.n = n % 28 + 1, attendance.json days[n].reward 지급, last_day 갱신
scenes/pass/pass.gdclaim(lv: int, paid_line: bool) -> Array · claim_all() -> Array성장 패스 — pts ≥ levels[lv].points_cum 이고 아직 안 받은 단계만. paid_line 은 pass.paid 일 때만
scenes/pass/pass.gdgrant_month_daily(day_key: String) -> void물빛 정기권 매일분 — Missions.reset_if_needed 가 날이 바뀔 때 부름. month_until_ms 안이면 daily_gem 을 우편(7일 보관)으로, month_last_day 갱신. 못 들어온 날도 우편으로
scenes/event_hub/event_hub.gdbuy(ev_id: String, idx: int, qty: int) -> boolevents.json shop[idx] — 이벤트 주화 차감, events[ev].shop[idx] 구매 수 ≤ limit. 이벤트 상점은 새로고침 없음(고정 진열)
scenes/shop/shop.gdbuy_iap(product_id: String) · buy_gem(item_id: String) · buy_seal(slot: int)결제 상품은 IAP, 나머지는 Wallet.spend. buy_seal: 0~5 고정(weekly.seal_shop 주간 한도) · 6~11 오늘 칸(daily.seal_slots, 칸마다 1회 — daily.seal_bought)
scenes/shop/shop.gdroll_seal_slots(rng: Rng) -> Array[int]shop.json seal_shop_random 12종 중 6개를 비복원 균등 추출(진열 — 확률형 아님). 04:00 리셋 때 1회 + refresh_seal
scenes/shop/shop.gdrefresh_seal(pay: String) -> boolpay = free(하루 1) → ad(하루 2, ad_shop) → gem(30, 하루 3 — gs_shopref). daily.seal_ref 카운터, 새 6칸이면 seal_bought 초기화
scenes/mail/mail.gdclaim(mail_id: String) -> void · claim_all() -> void수령 = 사용 시작(청약철회 판정 기준 시각 기록)
scenes/settings/settings.gdissue_recovery_code() -> StringPOST /v1/account/code — 12자 복구 코드
bruni12
bruni · 게임기술 명세서
06 / Functions

스크립트별 함수 — 서버(Worker) — 모집·복구

스크립트시그니처설명
server/src/gacha.tspull(uid: string, banner: string, n: 1|10, pay: Pay, freeHave: number): Promise<PullRes>모집 판정의 유일한 자리. 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.tssign(res: PullRes): stringEd25519(비밀 키 wrangler secret) — 앱은 공개 키로 확인
server/src/save.tsrestore(uid: string): Promise<SaveRes>GET /v1/save — 백업 본문의 wallet.gem_paid·gem_paid_lots 를 gem_ledger 잔액으로 맞추고, gacha.seq 보다 뒤인 gacha_pulls 를 replay[] 로 같이 보냄(되돌린 저장에서도 서버가 굴린 결과는 사라지지 않음)
server/src/save.tscheck(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 뜻
login1m_d01missions.gd reset_if_needed()add1 — 그날 첫 실행(날이 바뀐 직후 1회)
idle_claim1m_d02idle_reward.gd claim()add1 — 수령 1회(광고 2배도 1회)
stage_clear1m_d03battle.gd finish(true)add1 — 이긴 판 1개(첫 클리어·반복·반복 보상 없는 연습 포함)
pot_open2m_d04~m_w03pot.gd draw(n)addn — 연 횟수(자동 열기 포함)
gear_enh2m_d05~m_w09gear.gd enhance(slot)add1 — 강화 1단계
unit_lvup2m_d06~m_w08companion_detail.gd level_up(n)addn — 올린 레벨 수
recruit2m_d07~m_w04recruit.gd pull() — 서버 응답 적용 뒤addn — 모집 횟수(10회 묶음 = 10, 광고 무료 1)
dungeon3m_d08~m_w05dungeon.gd enter() 의 전투 끝add1 — 입장 1회(승패 무관)
quick_idle2m_d10~m_w12idle_reward.gd quick(kind)add1 — free·ad·gem 모두
gift2m_d11~m_w11companion_detail.gd give_gift(item)add1 — 선물 1개
skill_up2m_d12~m_w10companion_detail.gd skill_up(slot)add1 — 스킬 1단계
ad_slot1m_d13ads.gd show_rewarded(slot) 가 trueadd1 — 보상 받은 1회(생략권 즉시 지급 포함)
gear_sell1m_d14gear.gd sell(uids) · pot.gd 자동 판매add판매한 장비 수
daily_done1m_d15missions.gd claim(m_d##)add1 — 일일 미션 완료(받기 기준) 1개
login_days1m_w01missions.gd reset_if_needed() 날 바뀜add1 — 그 주 접속한 날
stage_first1m_w02stage_reward.gd first_clear()add1 — 첫 클리어 판 1개
raid1m_w06raid.gd enter(boss)add1 — 도전 1회
raid_kill1m_w07raid.gd finish(dmg) 100% 구간add1 — 처치(100% 구간) 1회
tower_floor1m_w13tower.gd finish(true)add1 — 오른 층 1개
daily_done_days1m_w14missions.gd claim() 으로 그날 daily_done 10 도달add1 — 그날 처음 10개가 된 순간 1회
weekly_done1m_w15missions.gd claim(m_w##)add1 — 주간 미션 완료 1개
stage_reach30m_a01~m_a30stage_reward.gd first_clear() · recheck_all()maxprog.max_stage (판 번호)
episode_read8m_a31~m_a38story_player.gd 화 끝 · recheck_all()hasctx.ep == target(ep_n_10) 이면 1
unit_count8m_a39~m_a46gacha_apply.gd apply() 신규 · story join · recheck_all()maxcomp 키 수(브루니 포함)
unit_star53m_a47~m_a49companion_detail.gd star_up() · recheck_all()max★5 이상 동료 수
awaken51m_a50companion_detail.gd awaken() · recheck_all()max각성 5 동료 수
awaken_sum1m_a51companion_detail.gd awaken() · recheck_all()max전 동료 aw 합
bruni_lv7m_a52~m_a58wallet.gd add(bruni_exp) 레벨업 · recheck_all()maxbruni.lv
pot_lv6m_a59~m_a64pot.gd 업그레이드 완료 · recheck_all()maxpot.lv
gear_enh_max3m_a65~m_a67gear.gd enhance(slot) · recheck_all()maxmax(bruni.slot_enh 6칸)
gear_set4m_a68~m_a71gear.gd equip() · recheck_all()has장착 6부위의 최저 등급 ≥ target(g4~g7) 이면 1
recruit_total4m_a72~m_a75gacha_apply.gd apply() · recheck_all()maxgacha.pulls
bond_lv2m_a76~m_a77companion_detail.gd give_gift() · story_player.gd choose() · recheck_all()max동료 중 최고 인연 Lv
char_story3m_a78~m_a80char_story 화 끝 · recheck_all()max읽은 개인 이야기 화 수(comp.*.cs 합)
tower_floor_max5m_a81~m_a85tower.gd finish(true) · recheck_all()maxtower.floor
dungeon_total2m_a86~m_a87dungeon.gd 전투 끝add1 — 누적(업적이라 초기화 없음)
raid_tier2m_a88~m_a89raid.gd finish(dmg) 100% · recheck_all()max한 보스의 처치한 최고 단계
color_all1m_a90bruni.gd set_color() 새 색 · recheck_all()maxbruni.colors 길이
bruni14
bruni · 게임기술 명세서
07 / Effect DSL

스킬 효과 표기 해석

동료 스킬은 코드가 아니라 데이터의 짧은 배열로 적혀 있습니다(["dmg", "front", 1.1]). core/effect_dsl.gd 가 첫 칸(연산자)으로 분기합니다. 아래 표는 characters.json 을 실제로 훑어 센 값입니다 — 새 연산자가 데이터에 나타나면 이 표와 분기를 같이 늘립니다.

연산자칸 수인자쓰인 횟수데이터 예
dmg3대상, 계수86c001_s1 ["dmg", "front", 1.1]
special4id, 매개변수 객체, 설명문 → special_handlers.gd26c001_s2 ["special", "bruni_color", {}, "브루니의 현재 색에 따라
heal3대상, 치유 계수19c001_s2 ["heal", "self", 1.0]
stain5대상, 계수/초, 지속초, 확률 (얼룩 — 지속 피해)5c001_s2 ["stain", "target", 0.3, 5, 1.0]
shield4대상, 최대 체력 %, 지속초14c001_s2 ["shield", "a_all", 8, 6]
gauge3대상, 게이지 +N11c001_s2 ["gauge", "a_others", 15]
debuff6대상, 상태 키, %, 지속초, 확률33c001_s2 ["debuff", "target", "d_def_down", 20, 6
taunt2지속초10c003_s2 ["taunt", 5]
buff5대상, 능력치 키, %, 지속초(null=전투 끝까지)44c003_s3 ["buff", "self", "b_dmg_reduce", 8, null
cleanse3대상, 해제 개수3c004_s2 ["cleanse", "a_all", 1]
dispel4대상, 해제 개수, 확률2c009_s2 ["dispel", "target", 1, 1.0]
regen4대상, 계수/초, 지속초4c010_s2 ["regen", "a_all", 0.2, 6]
dmg4대상, 계수, 타수3c037_s2 ["dmg", "front", 0.6, 5]
revive3대상, 부활 체력 %2c044_s3 ["revive", "self", 15]

패시브(S3) 발동 조건은 22종입니다: always 24 · battle_start 8 · on_hit 4 · on_self_down 3 · hp_state 2 · every_n 2 · on_ally_down 2 · on_crit 1 · ally_low 1 · on_kill 1 · enemy_ult 1 · ally_ult 1 · cond_front_alive 1 · every_sec 1 · hp_below 1 · after_sec 1 · ally_debuffed 1 · ally_lethal 1 · cond_attr3 1 · on_act 1 · on_attack 1 · cond_blue2 1.

적·보스 스킬 효과는 상태 키를 쓰는 다른 표기입니다(["slow", 25, 4] = 상태, 수치, 지속초). 실제 쓰인 키: d_stun 19 · d_spd_down 13 · d_burn 11 · d_poison 9 · d_silence 8 · d_shock 8 · d_atk_down 7 · d_fade 7 · d_def_down 6 · b_def_up 6 · x_gauge_down 6 · b_shield 5 · x_heal 5 · b_taunt 4 · b_regen 4 · b_atk_up 3 · b_stealth 3 · b_spd_up 2 · b_reflect 1 · x_cleanse 1.

bruni15
bruni · 게임기술 명세서
07 / Special Handlers

special 처리 함수 24종

special 효과는 일반 연산자로 표현하기 어려운 고유 규칙입니다. core/special_handlers.gd 에 id 마다 함수 하나(_sp_<id>(params, src, ctx))를 둡니다. 데이터에 있는 special 은 24종입니다. 이 중 하나라도 처리 함수가 없으면 DB.load_all() 이 디버그 빌드에서 멈춥니다.

id횟수스킬매개변수규칙(데이터 설명문)
bruni_color1c001_s2{}브루니의 현재 색에 따라 추가 효과 (08장 표)
color_variety1c001_s3{"per_attr": 2, "max": 10}출전 동료 속성 1종마다 아군 전체 공격력 +2% (최대 +10%)
execute1c006_s2{"hp_below": 30, "mul": 1.5}대상 체력 30% 이하이면 이 피해 ×1.5
same_target_stack1c019_s3{"per": 5, "max_stack": 5}같은 대상을 연속으로 공격할 때마다 피해 +5% (최대 5회, 대상이 바뀌면 초기화)
gauge_steal1c020_s1{"n": 5}대상 게이지 −5, 자신 게이지 +5
self_heal_pct2c021_s3, c024_s3{"pct": 2}자신 최대 체력 2% 회복
vs_stained1c022_s3{"pct": 12}얼룩 상태의 적에게 주는 피해 +12%
heal_low_bonus1c025_s3{"hp_below": 50, "pct": 25}체력 50% 이하 아군을 회복할 때 회복량 +25%
double_s11c027_s3{}S1 을 한 번 더 쓴다 (게이지 +20 도 한 번 더)
crit_bonus1c029_s2{"pct": 30}이 공격만 치명률 +30%p
first_act1c032_s3{}전투 첫 행동은 아군·적 통틀어 자신이 먼저
sure_crit2c033_s2, c047_s3{}반드시 치명
spd_ramp1c037_s3{"per": 2, "max_stack": 10}행동마다 속도 +2% (최대 10회, +20%)
refund_on_kill1c039_s2{"gauge": 50}이 공격으로 쓰러뜨리면 게이지 50 반환
stack_atk1c039_s3{"per": 12, "max_stack": 3}자신 공격력 +12% (최대 3회, 전투 끝까지)
wait_then_buff1c043_s3{"wait": 5, "pct": 30}처음 5초 동안 행동하지 않고, 그 뒤 전투 끝까지 공격력 +30%
resist_attr1c045_s3{"attr": "홍", "pct": 15}홍 속성 적에게 받는 피해 −15%
cover1c048_s3{}쓰러질 피해를 받는 아군 대신 그 피해를 받는다
start_gauge1c049_s3{"n": 20}전투 시작 게이지 +20
s1_spill1c052_s3{"chance": 20}20% 확률로 S1 이 적 전체로 번진다 (계수 그대로)
random_debuff1c054_s2{"pool": ["atk_down", "def_down&quo적마다 공격력↓·방어력↓·속도↓ 중 1개 20% 6초 (무작위)
third_hit1c056_s3{"pct": 40}같은 대상 3번째 공격마다 피해 +40%
lifeshare1c057_s3{"pct": 10}준 피해의 10% 만큼 체력 비율 최저 아군 회복
role_count1c058_s3{"role": "사격", "per": 4}출전한 사격 동료 1명마다 자신 공격력 +4% (자신 포함)
bruni16
bruni · 게임기술 명세서
08 / Data Files

데이터 파일 목록과 적재

런타임에 들어가는 데이터는 17개 파일입니다. 크기는 지금 저장소에 있는 파일을 그대로 잰 값이고, 「런타임」은 _ 로 시작하는 문서용 키를 빼고 공백 없이 다시 쓴 크기입니다. 데이터 원본은 각 문서가 만들고 이 문서는 모양만 고정합니다.

파일레코드원본 KB런타임 KBgzip KB
characters60107.771.513.5
enemies8099.866.711.5
bosses2468.939.27.7
items150197.0133.615.4
stages300496.4268.631.2
story80113.791.525.7
script_ch110143.290.720.7
char_stories180119.487.219.1
growth—66.746.112.0
combat—33.627.37.7
gacha599.359.95.9
shop—20.414.53.1
missions15+15+9026.617.83.0
attendance283.31.80.6
events1832.327.34.5
economy—7.33.21.7
sounds—45.236.06.4
합계1,680.81,082.8189.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[]

필드형필수제약(설계 범위)뜻
idenum●id 목록 60개 (c001~c060)c001~c060 고정
namestring●—한국어 이름(화면)
name_enstring●—영문 이름
speciesstring●—종
gradeenum●SSR·SR·RSSR·SR·R
star_startinteger●1~5태생 ★
attrenum●홍·녹·청·황·묵속성(한글)
attr_idenum●red·green·blue·yellow·inkred·green·blue·yellow·ink
roleenum●돌격·타격·사격·술사·치유·지원역할(한글)
role_idenum●tank·striker·shooter·caster·healer·supporttank·striker·shooter·caster·healer·support
positionenum●전열·후열전열·후열
factionstring●—소속 세력
personalitystring●—성격 한 줄
verbal_ticstring●—말버릇(대본용)
indivobject●—개성 보정 ±%
stats_lv1object●—Lv1 ★태생 능력치(역할·등급 배율 반영 후)
curveenum●early·std·late·bruni성장 곡선 id → meta.curves
skills[object, object, object]●칸별: — / — / —S1·S2·S3 3개
artobject●—그림 4종 경로
acquirestring●—획득 경로 한 줄(화면)
acquire_detailobject●—획득 경로
wavestring●^W[0-6]\+?$공개 차수 W0~W6
first_releaseboolean●—1차판 포함
bruni18
bruni · 게임기술 명세서
09 / JSON Schema

데이터 스키마 — 판

판 — stages.json · stages[]

필드형필수제약(설계 범위)뜻
idenum●id 목록 300개 (s001~s300)s001~s300
nointeger●1~300판 번호
chapterinteger●1~8장
wave_releasestring●^W[0-6]\+?$공개 차수
areastring●—구역 이름
area_nointeger●1~10장 안 구역 번호
namestring●—판 이름(화면)
kindenum●normal·bossnormal·boss
bossnull|enum●— 또는 id 목록 48개 (b001~b024+)보스 id(+ 강화판)
chapter_bossboolean●—장 보스 판
time_limit_senum●60·9060·90
wavesarray>●길이 1~3웨이브별 [적 id, 수]
enemy_countinteger●1~15적 총수
main_attrenum●홍·녹·청·황·묵적 주 속성
advice_attrenum●홍·녹·청·황·묵권장 속성(화면 힌트)
enemy_hp_totalinteger●1~적 체력 합(검산용)
mnumber●1~판 배율 m(s) = 1 + 0.125 × (s − 1) + 0.00005 × (s − 1)² (curve.py)
req_cpinteger●1~추천 전투력
first_rewardarray<[enum, integer]>●길이 0~12첫 클리어 보상 [재화, 수]
first_reward_itemsarray<[enum, integer]>●길이 0~12첫 클리어 아이템
repeat_rewardarray<[enum, integer]>●길이 0~12반복 보상
repeat_drop_rollboolean●—반복 시 적 드롭 굴림
episode_unlocknull|string●null 허용이 판 클리어로 열리는 화
unlocksarray●길이 0~6기능 해금 id(던전·색 등)
bgstring●^bg/ch[1-8]_[a-z0-9_]+\.png$배경 파일
hardobject●—어려움 난이도(완성판)
bruni19
bruni · 게임기술 명세서
09 / JSON Schema

데이터 스키마 — 적·보스

적 — enemies.json · enemies[]

필드형필수제약(설계 범위)뜻
idenum●id 목록 80개 (e001~e080)e001~e080
namestring●—이름
speciesstring●—본래 동물
chapterinteger●1~8장
rankenum●normal·elitenormal·elite
eliteboolean●—정예 여부
typeenum●tank·striker·shooter·caster·healer·support역할형 6종 → types
attrenum●홍·녹·청·황·묵속성
attr_idenum●red·green·blue·yellow·ink속성 id
tweakobject●키 ^(atk|hp|def|spd)$개별 배율 조정
stagesobject●—등장 판 처음·끝
basicobject●—기본 공격 계수·대상
specialobject●—특수기 1개
passivenull|object●null 허용패시브(없으면 null)
weakobject●—약점 속성·공략
artstring●^enemy/e\d{3}_battle\.png$전투 시트
first_releaseboolean●—1차판 포함
dropsarray●길이 0~8드롭 [{item, pct}]
stat_firstobject●—첫 등장 판 능력치
stat_lastobject●—마지막 등장 판 능력치

보스 — bosses.json · bosses[]

필드형필수제약(설계 범위)뜻
idenum●id 목록 24개 (b001~b024)b001~b024
namestring●—이름
speciesstring●—본모습
kindstring●—장·중간·콘텐츠 보스
attrenum●홍·녹·청·황·묵·이벤트마다속성
attr_idenum●red·green·blue·yellow·ink·event속성 id
mulobject●—보스 배율
phasesarray●길이 1~5페이즈(체력 구간·속성·스킬)
plus_skillnull|object●null 허용강화판 추가 패턴
gimmickstring●—기믹 설명(화면)
tipstring●—공략 힌트(화면)
releasestring●^W[0-6]\+?$공개 차수
weak_attrnull|string●null 허용약점 속성
artstring●^boss/b\d{3}_battle\.png$전투 시트
canvasenum●128·160·192·256그림 캔버스(px)
appeararray●길이 0~6등장 판과 그 판 능력치
reward_firstarray<[enum, integer]>길이 0~12첫 처치 보상
reward_repeatarray<[enum, number]>길이 0~8반복 처치 보상
reward_first_plusnull|array<[enum, integer]>null 허용 · 길이 0~12—
chapterinteger0~8장(콘텐츠 보스는 0)
daystring——
raid_tiersarray길이 1~30—
raid_rewardobject——
event_levelsarray——
reward_notestring——
bruni20
bruni · 게임기술 명세서
09 / JSON Schema

데이터 스키마 — 아이템

아이템은 분류(cat)마다 쓰는 필드가 달라 필수 표시가 id·name·cat 정도에만 붙습니다. 장비(gear)는 slot·line·grade·main·cp, 유물(relic)은 effect·pity, 재료·소모품은 kind·stack 을 씁니다.

아이템 — items.json · items[]

필드형필수제약(설계 범위)뜻
idenum●id 목록 150개 (i001~i150)i001~i150
namestring●—이름
catenum●gear·relic·material·consumablegear·relic·material·consumable
iconstring●^item/i\d{3}\.png$아이콘 32×32
stackinteger●1~999,999겹침 상한
releasestring●^W[0-6]\+?$공개 차수
slotenumweapon·hat·body·hands·feet·acc부위 id
slot_kostring—부위
lineenumblade·guard계열 blade·guard
line_kostring—계열(날·결)
gradeenumg1·g2·g3·g4·g5·g6·g7등급 g1~g7
grade_kostring—등급
mainobject키 ^(atk|hp|def)$ 최대 3개주 능력치
cpinteger1~전투력 기여
subsarray길이 0~4부 능력치
setstring^set_(blade|guard)_g[1-7]$세트 id
craftnull|objectnull 허용제작 재료(g4 이상)
acquirearray길이 0~64획득처
sellobject—판매가 공식
chapterinteger1~8유물: 장
slot_nointeger1~3유물: 장 안 번호
effectobject—유물 효과
descstring—설명(화면)
pityinteger1~200유물 확정 드롭 횟수
upgradestring—유물 강화 규칙
kindstring—재료·소모품 종류
attrenum홍·녹·청·황·묵정수 속성
tierenum하·중·상하·중·상
usestring—쓰임
drop_enemiesarray길이 0~드롭 적
drop_bossesarray길이 0~드롭 보스
boxesarray길이 0~들어 있는 상자
sourcestring—얻는 곳
loved_byarray길이 0~아주 좋아하는 동료
liked_byarray길이 0~좋아하는 동료
hoursenum2·4·8—
brushinteger1~—
entriesarray길이 1~20무작위 상자 가중치(합 10000)
totalenum10000—
discloseboolean——
bruni21
bruni · 게임기술 명세서
09 / JSON Schema

데이터 스키마 — 이야기

본편 화 — story.json · episodes[]

필드형필수제약(설계 범위)뜻
idstring●^ep_[1-8]_(0[1-9]|10)$ep_<장>_<화>
chapterinteger●1~8장
nointeger●1~10화
titlestring●—제목
unlockobject●—해금 판
placestring●—장소
bgstring●^bg/배경
bgsarray●길이 0~—
castarray●길이 0~12등장 동료
npcarray●길이 0~12등장 NPC
synopsisarray●길이 3~5줄거리 3~5줄
choicenull|object●null 허용선택지(없으면 null)
joinsnull|string●null 허용합류 동료
eventsarray●길이 0~화 안 사건 태그
moodstring●—분위기
tensioninteger●0~10긴장 0~10
warmthinteger●0~10온기 0~10

개인 이야기 — char_stories.json · episodes[]

필드형필수제약(설계 범위)뜻
idstring●^cs_c\d{3}_[123]$cs__<1~3>
charenum●id 목록 60개 (c001~c060)동료 id
namestring●—동료 이름
epinteger●1~3화 1~3
titlestring●—제목
synopsisstring●—줄거리
castarray●길이 0~8등장
placestring●—장소
unlockobject●—인연 Lv·본편 조건·이전 화
rewardarray<[enum, integer]>●길이 0~12보상
content_wavestring●^W[0-6]\+?$공개 차수
lines_target[integer, integer]●칸별: 1~ / 1~대사 줄 수 목표
choiceinteger●0~3선택지 수
bruni22
bruni · 게임기술 명세서
10 / JSON Schema

설정 파일과 남은 스키마

목록형이 아닌 설정 파일은 최상위 키만 적습니다. 세부 필드는 같은 폴더의 .schema.json 에 있습니다.

파일최상위 키담당 문서
growth.jsonversion · 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 · checkssystems
combat.jsontick_ms · wave_gap_ms · time_limit_ms · gauge · crit · random · first_action · clamp · element · tick_order · statuses · status_alias · status_unified · stack_rules · instant · targeting · target_alias · target_rules · simultaneous · cc_rules · simsystems
gacha.jsonversion · banners · slot10_weight · fest_slot10_weight · ceiling · duplicate · max_star_shard_to_rainbow · wave_tables · gated_pool · wave_tables_gated · pot · boxesgacha
shop.jsonversion · fee_rate · value_table · names · base_krw_per_gem · gem_products · packs · event_pack_templates · pass_month · pass_season · no_ads · gem_shop · seal_shop_fixed · seal_shop_random · stardust_exchange · ad_slotsgacha
missions.jsonversion · rules · daily · weekly · achievementstages
attendance.jsonversion · rules · daysstages
events.jsonversion · rules · templates · shop · shop_total · coin_sim · updates · events · festsstages
economy.jsonversion · date · fixed · assume · profileseconomy

screens.json — 파일 있음 · 아래는 설계 기준 (담당 screens)

필드형뜻
screens[].idstring04장 씬 id 와 같은 값
screens[].zones[] · buttons[] · texts[]array와이어프레임 구역·버튼·문구
screens[].to[]array이동 대상 화면 id — 화면 이동 지도
bruni23
bruni · 게임기술 명세서
10 / JSON Schema

데이터 스키마 — 대본

대본은 장 단위 파일 하나(script_ch<장>.json)에 화 → 장면 → 줄 3단으로 들어 있습니다. 1장 파일이 실제로 있어 그 모양을 그대로 기준으로 삼습니다. 2~8장 대본도 같은 모양으로 만듭니다.

필드 (episodes[] 아래)형제약(설계 범위)
idstring^ep_1_(0[1-9]|10)$
chapterenum1
nointeger1~10
titleobject—
title.kostring—
title.enstring—
unlocknull|objectnull 허용
eventstring—
castarray길이 0~
joinsarray길이 0~
has_choiceboolean—
scenesarray길이 1~12
scenes[].idstring—
scenes[].bgstring—
scenes[].fadenumber0~5
scenes[].placestring—
scenes[].timestring—
scenes[].linesarray길이 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장 사용
bgbg <배경id> [fade=0~1]배경 교체. fade 는 회색 셰이더 강도(0 원색, 1 완전 회색). 0.4초 디졸브19
bgmbgm <곡id>배경음 교체. 1.0초 교차 페이드18
sese <효과음id>효과음 1회49
showshow <id> <L|C|R> [fade=0~1]서 있는 그림 표시(동료·NPC). 적 e###·보스 b### 는 전투 시트 대기 1프레임을 정수배 확대. 이미 있으면 fade 값만 바꿈(0.6초)19
hidehide <id>그림 퇴장 0.3초8
shakeshake화면 흔들림 0.3초, 진폭 8px7
flashflash <white|green|red>전체 화면 섬광 0.25초5
cutscenecutscene battle_auto <초>조작 없는 연출 전투. 대본 안 등장 그림으로 공격 동작만 재생1
battlebattle <판id>대본 종료 후 해당 판 전투로 바로 진입(튜토리얼 전용)1
goto_stagegoto_stage <판id>대본 종료 후 해당 판 준비 화면을 연다1
joinjoin <동료id>동료 지급. 이미 모집으로 보유했으면 바이블 9-2 중복 환원(기억 조각 + 무지개 조각) — gacha_roll.apply_result 를 그대로 호출3
unlockunlock <해금id>기능 해금. 1장은 recruit(모집, 3화 끝 = s007 클리어)·color_red(브루니 색 전환 홍, 10화) 둘2
chapter_endchapter_end <장>장 완료 연출(제목 카드 2초) 후 2장 1화 해금1
대본 줄 type1장 줄 수화면 동작
say268이름표 + 표정 얼굴 + 대사
dir93연출 명령 실행 + 지문(화면에는 안 보임, 개발용)
nar25이름표 없는 내레이션
sys11시스템 안내 상자(해요체)
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.json0——
enemies.json0——
bosses.json0——
items.json0——
stages.json0——
story.json0——
script_ch1.json0——
char_stories.json0——
growth.json0——
combat.json0——
gacha.json0——
shop.json0——
missions.json0——
attendance.json0——
events.json0——
economy.json0——
저장 샘플위반(자체)위반(jsonschema)처음 두 건
새 계정 (튜토리얼 시작 전 · 브루니만)0——
1차판 30일차 (동료 15 · 가방 42/60 · 2장까지 · 모집 기록 30)0——
완성판 최대 (동료 60 · 가방 60 · 우편 100 · 이야기 전부 · 모집 기록 500)0——

음성 시험 — 틀린 값을 일부러 넣으면 잡히는가

넣은 오류파일잡힌 건수(자체)잡힌 건수(jsonschema)
판 번호 301stages.json1—
보상 수량 0stages.json1—
보상 키 오타(gem_fre)missions.json1—
웨이브에 없는 적 e099stages.json1—
동료 스킬 2개characters.json1—
장비 부위 cloth(옛 이름)items.json1—
출석 27칸attendance.json1—
가방 61칸save1—
가방 개체에 enh(옛 구조)save1—
칸 강화 +21save1—
comp.c001 에 lvsave1—
자동 판매 g5save1—
반복 보상 31판save1—
튜토리얼 18save1—
최고 판 301save1—
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 한 곳에만 둡니다.

경로형설명
versionint ≥1저장 형식 버전. 출시 = 1. 이전 함수 체인 기준
app_verString마지막으로 쓴 앱 버전 "1.0.0"
install_idString(36)첫 실행 때 만든 UUIDv4 — 키 유도에 씀. 개인정보 아님
uidString서버 익명 계정 id(첫 온라인 때 발급, 그 전엔 "")
created_ms · total_play_msint계정 생성 시각 · 누적 플레이 ms(충돌 규칙 1순위)
time.last_server_ms · last_claim_ms · last_device_msint마지막 서버 시각·방치 수령 시각·기기 시각
time.sig · pending_verifyString · bool서버 시각 서명 · 오프라인 계산을 다음 접속에 대조할지
wallet.{gold, gem_free, drop, brush, ticket, stardust, rainbow, wakestone, seal, key}int ≥0재화 — 바이블 5장 12종 중 10개
wallet.gem_paid · gem_paid_lots[≤500]int · Array유료 빛구슬 합 + 결제 건별 {order, n, left, at} — 7일 청약철회 판정. 서버 gem_ledger 가 원본: 복구·백업 때 서버 값으로 맞춤(14장)
wallet.evDictionary이벤트 주화 {ev_03: 1200}. 종료 7일 뒤 골드 환전 후 키 삭제
bruni.{lv 1~150, exp, color, colors[1~5]}int · String · Array브루니 레벨(유일한 자리 — comp.c001 에는 lv 없음)·현재 색·해금 색
bruni.gear.{weapon, hat, body, hands, feet, acc}int장착한 장비 uid (0 = 없음). 부위 id 는 items.json slots[].id
bruni.slot_enh.{weapon, hat, body, hands, feet, acc}int 0~20칸 강화 수치 — 장비를 바꿔도 칸에 남음(items.json rules.enhance). gear.enhance(slot) 이 올림
gear_bag[0~60] → {uid, item, lock}Array장비 개체(강화 수치 없음 — 칸에 있음). 상한 60칸 = items.json storage.gear_slots · 화면 J07
comp.c###.{star, aw, sk[3], shard, bond, bexp, cs[3], lv}Dictionary(1~60명)동료별 ★·각성·스킬 3·기억 조각·인연·개인 이야기 읽음. lv 는 c002~c060 만(1~100). c001(브루니)은 lv 없이 ★·스킬·각성만
party[5]Array[String|null]칸 1~5 동료 id. 칸 1·2 = 전열. 빈 칸 null
inv.i###int 0~999,999유물 중복·재료·소모품 수량 i085~i150 (장비 제외)
relic.i###.lvint 1~10장착 유물 레벨
relic_pity.i###int 0~49유물 확정 드롭까지 센 반복 처치 수 — 50번째 처치는 확정(enemies.json rules.relic_pity · items pity) 후 0
pot.{lv 1~30, until_ms}int항아리 레벨·업그레이드 끝 시각
pot.auto_sell"g0"~"g4"이 등급 이하 자동 판매. g0 = 끔 · g1 일반 … g4 영웅(화면 C03 — 전설 g5 이상은 고를 수 없음)
pot.keep_if_up · auto_equipbool · bool전투력이 오르면 판매하지 않기(기본 켬) · 더 좋은 장비 자동 장착(기본 끔)
pot.auto_stop.{g4up, brush_min, cp_up}bool · int · bool자동 열기 멈춤 조건 3개 — 영웅 이상 나오면(기본 켬) · 붓이 n개 이하(기본 0) · 전투력 오르는 장비(기본 끔)
prog.{max_stage 0~300, first, hard_max 0~300}int · String · int최고 판 번호 · 첫 클리어 비트열(300비트 → 76자 hex) · 어려움 최고
story.{read[≤80], choice{}}Array · Dictionary본 화 id · 선택 기록 {ep_1_04: 0}
bond_pending.c###int ≥1아직 합류 안 한 동료에게 쌓인 인연 경험치 — 합류하는 순간 comp 로 옮기고 키 삭제(script_ch1 meta.rules.bond_before_join · story 18장)
gacha.{seq, pulls, beginner 0~50, beginner_ssr, by_banner{}}int · int · int · bool · Dictionary서버가 마지막으로 돌려준 모집 순번 · 누적 모집 · 입문 횟수 · 입문 SSR 확정 소진 · 배너별 횟수(서버 gacha_state 의 사본)
gacha.log[0~500] → {seq, at, banner, ids[1~10], pay}Array모집 기록 최근 500건(화면 E06) — 새 것이 앞, 넘치면 뒤에서 버림. 서버 gacha_pulls 와 seq 로 대조
daily.{day, ads{}, quick 0~3, keys{}, shop{}}Dictionary04:00 기준 일일 카운터 — 날이 바뀌면 통째로 새로
daily.repeat_nint 0~30오늘 반복 보상 받은 판 수 — 30 이면 반복 보상 없음(stages.json rules.repeat_reward)
daily.seal_slots[6] · seal_bought[6] · seal_ref{free, ad, gem}Array · Array · Dictionary원정 상점 오늘 품목 6칸(seal_shop_random 12종의 번호 0~11, 중복 없음) · 칸별 구매 여부 · 새로고침 횟수(무료 1·광고 2·빛구슬 3)
weekly.{week, raid{b###: {tries 0~3, best}}, seal_shop{}}Dictionary월요일 04:00 기준 — 토벌 도전·최고 피해 · 원정 상점 고정 6칸 주간 구매 수
mission.{d{}, w{}, a{}, claimed[]}Dictionary진행도 {m_d03: 2}·받은 id
attend.{n 0~28, last_day}int · String|null출석 칸 · 마지막 받은 날
pass.{month_until_ms, month_last_day, season_id, pts, free_lv, paid_lv, paid}Dictionary정기권 만료·매일분 마지막 지급일 · 성장 패스 진행
shop.{bought{}, first_bonus[]}Dictionary · Array한정 상품 구매 수 · 첫 구매 2배 쓴 상품 id
mail[0~100] → {id, kind, items[], at_ms, exp_ms, claimed_ms}Array우편. 결제분은 exp 없음(0). 100통 넘으면 결제분이 아닌 가장 오래된 수령 완료 우편부터 지움 — 바이블 추가 제안
pending_iap[0~20] → {product, token, at_ms, tries}Array결제 성공 콜백 직후 기록, 서버 검증·지급 뒤 제거 (17장). token 은 Android purchaseToken 또는 iOS JWS
tower.floor · events.ev_##{pts, claimed[], shop{}}int 0~100 · Dictionary탑 층 · 이벤트별 진행·이벤트 상점 구매 수
settings.{bgm, se, speed, auto_ult, lang, push}Dictionary설정
tutorial.stepint 0~17튜토리얼 진행 — 0 = 시작 전, n = Tn 까지 끝냄(화면 문서 T01~T17)
anti.{drift, backward, tamper, last_ticks}Dictionary의심 카운터(차단 안 함, 기록만)
sigString(64)위 전체(정렬된 키 JSON, sig 제외)의 HMAC-SHA256 hex
bruni28
bruni · 게임기술 명세서
13 / Save Write & Migration

저장 쓰기·버전 이전

저장 크기 (샘플을 실제로 만들어 잰 값)

경우JSON 바이트gzip 바이트기준 64KB 대비
새 계정 (튜토리얼 시작 전 · 브루니만)2,1089913%
1차판 30일차 (동료 15 · 가방 42/60 · 2장까지 · 모집 기록 30)10,7802,46516%
완성판 최대 (동료 60 · 가방 60 · 우편 100 · 이야기 전부 · 모집 기록 500)63,9216,57798%
가장 큰 몫완성판 최대 샘플 63,921B 중 모집 기록(gacha.log 500건 — 전부 1회 모집이라 가장 큰 경우)이 39,193B, 우편 100통이 11,391B 입니다. 64KB 기준에 98% 로 붙어 있어, 저장 항목을 늘리면 먼저 모집 기록을 「최근 500회」에서 「최근 200회 + 서버 조회」로 줄입니다(서버 gacha_pulls 가 원본). 서버 백업은 gzip 이라 6,577B 입니다.

쓰기 절차

순서동작이유
1키를 정렬해 공백 없이 직렬화(sig 제외)같은 상태 = 같은 문자열 → 서명이 흔들리지 않음
2sig = HMAC-SHA256(derive_key(install_id), 본문)숫자 하나만 바꿔도 sig 가 완전히 달라짐 (아래 예)
3FileAccess.open_encrypted(save.tmp, WRITE, key32) 에 sig 포함 본문 쓰기평문 편집 막기
4save.json → save.bak 로 이름 바꾸기, save.tmp → save.json쓰다 꺼져도 둘 중 하나는 온전함
5Save.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 로 먼저 차감됨
랭킹·친구없음범위 밖(바이블 1장)

엔드포인트 13개

경로하는 일요청응답D1
GET /v1/time서버 시각—{now_ms, sig}없음
POST /v1/account익명 계정 발급(첫 온라인){install_id, platform, app_ver}{uid, token}accounts 1행 쓰기
POST /v1/account/code복구 코드 발급(설정 화면){uid} + 서명{code}accounts 갱신
PUT /v1/save저장 백업 업로드 + 이상치 검사{uid, version, total_play_ms, body(gzip+base64), sig}{ok, flags[]}saves 덮어쓰기 1행 + 이상치면 flags
GET /v1/save저장 내려받기(복구·기기 변경) — 유료 잔액·모집 결과를 서버 기준으로 맞춤?code= 또는 uid+token{body(gem_paid·lots 를 gem_ledger 로 덮음), replay[gacha_pulls seq > body.gacha.seq], updated_ms}읽기 3회(saves·gem_ledger 합·gacha_pulls)
POST /v1/iap/verify영수증 검증·지급 결정{uid, platform, product_id, token|jws}{ok, grant{}, order_id}purchases 1행·gem_ledger 1행
GET /v1/iap/pending검증됐는데 아직 우편 안 받은 지급uid+token{grants[]}읽기
POST /v1/gacha/pull모집 실행 — 서버가 굴림(모든 모집, 무료·광고 포함){uid, banner, n(1/10), pay(gem/ticket/free/ad), free_have, seq_have} + 서명{seq, ids[], free_used, paid_used, stardust, state, sig} · 유료 부족 402 · seq 어긋남 409(replay 동봉)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/playPlay 실시간 개발자 알림(RTDN) 수신 — 환불·취소Pub/Sub push204purchases 상태 갱신 (완성판)
POST /v1/notify/appleApp Store Server Notifications V2 — 환불signedPayload200purchases 상태 갱신 (완성판)

모집 흐름 (서버 판정)

순서기기서버
①확인 창(청약철회 불가 문구) → 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)

테이블열비고
accountsuid 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행
savesuid TEXT PK · version INT · total_play_ms INT · body BLOB(gzip) · sig TEXT · updated_ms INT · prev_body BLOB최신 1벌 + 직전 1벌
purchasestoken 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_ledgerid INT PK · uid TEXT · order_id TEXT · delta INT · why TEXT · at_ms INT유료 빛구슬 들고 남 — 청약철회 7일 판정
eventsid INT PK · uid TEXT · day TEXT · body TEXT(JSON 묶음) · at_ms INT90일 보관 후 삭제(크론)
flagsid INT PK · uid TEXT · kind TEXT · detail TEXT · at_ms INT이상치 기록 — 차단하지 않음
gacha_pullsid 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_stateuid TEXT PK · seq INT · pulls INT · beginner INT · beginner_ssr INT · by_banner TEXT · stardust INT · updated_ms INT천장·입문·10번째 칸 판정의 원본(앱 저장 gacha.* 는 사본)

요청·쓰기 예산 (계산)

항목DAU 1명당 하루
GET /v1/time3
GET /v1/config (24시간 캐시)1
POST /v1/events6
PUT /v1/save2
POST /v1/gacha/pull (추정 하루 2번)2
POST /v1/gem/spend · iap (평균, 추정)0.1
합계 요청14.1
한도값그 한도에 닿는 DAU
Workers 무료 요청하루 100,000 (계정 전체)7,092
전환선 ① 하루 요청 7만 (바이블 12-2)하루 70,000 요청4,965
D1 무료 쓰기하루 100,000행 · DAU당 15행6,667
D1 무료 저장 5GB · 출시 90일째기록 묶음 2,249B × 6회/일 × 90일 + 모집 행 403B × 2회/일 × 90일 + 백업 2벌3,870
전환선 ② D1 저장 4GB · 출시 90일째같음 (5GB 의 80%)3,096
D1 저장 4GB · 출시 1년째모집 행은 지우지 않아 365일치2,643
D1 무료 저장 5GB · 기록 30일 보관기록만 30일로 줄였을 때10,367
쓰기DAU 1명당 하루 행
saves (백업 덮어쓰기)2
events (묶음 1행)6
gacha_pulls (모집 1번 = 1행)2
gacha_state (모집 1번 = 1행 갱신)2
gem_ledger (모집 유료분, 상한 가정 — 모집마다 1행)2
accounts.last_seen_ms1
합계 쓰기15
계산 가정하루 접속 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비행기 모드 + 기기 시계 +24h24시간분 임시 지급 · pending_verify — 다음 온라인 때 실제 경과로 재계산, 초과분은 다음 방치 보상에서 차감(자동 돌파한 판은 유지)
T5기기 시계 −1일그 회차 0분 · backward 기록
T6앱 켠 채 시계 +2hticks 기준이라 영향 없음 · drift 기록
T704:00 경계 앞뒤 접속일일 카운터 서버 시각 기준 1회만 초기화
T8서버 응답 서명 위조(프록시)서버가 백업 업로드 때 sig 재검증 → flags
T9기기 재부팅 직후(ticks 0부터)기준점은 세션마다 새로 잡으므로 영향 없음

오프라인 보상 계산 예 (바이블 8장 공식으로 계산)

경우최고 판경과 분인정 분골드물감 방울브루니 경험치붓
T1 9시간 뒤s06054054017,3702,8958,68572
T2 20시간 뒤s0601,20072023,1603,86011,58096
T2 20시간 뒤 · 정기권s0601,20096037,0566,17618,528153
T4 비행기 모드 시계 +24hs0601,44072023,1603,86011,58096
T5 시계 −1일(역행)s060000000
s150 · 8시간s15048048091,76215,29345,88188
s300 · 24시간s3001,4407202,684,008447,3341,342,004168
공식시간당 골드 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,90030일 · 즉시 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/verifyWorker → 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

영수증 검증 세부와 오류 처리

서버 검증 세부

AndroidiOS
보내는 것purchaseToken · productIdStoreKit 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-pluginv5.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—낮음~중간 (키가 바이너리 안)1차판
저장 파일 다른 기기로 복사키를 install_id(별도 파일)로 유도 — 저장만 옮기면 안 열림—중간1차판
메모리 수정 도구재화 XOR 가림 + 체크섬, 불일치면 마지막 저장값으로 되돌리고 기록—낮음(지연책)1차판
결제 우회 앱지급은 서버 검증 성공 뒤에만, 우편으로/v1/iap/verify높음1차판
영수증 재사용purchases.token PRIMARY KEYD1높음1차판
환불 뒤 재화 유지RTDN·ASSN V2 수신 → 미사용 유료분 회수, 사용분은 기록만(음수 잔액 만들지 않음)/v1/notify/*중간완성판
백업 업로드 변조서버가 하루 증가량 검사: 빛구슬 무료분 증가가 그날 이론 최대치 × 3 초과면 flagsPUT /v1/save중간1차판
모집 되돌리기(저장 복구 후 다시 뽑기)모집은 서버만 굴림 — 결과·천장·유료 차감이 서버 gacha_pulls·gacha_state·gem_ledger 에 먼저 남음. 옛 저장을 복구하면 replay 로 결과가 다시 붙고 유료 잔액은 장부 값으로 내려감/v1/gacha/pull · GET /v1/save높음1차판
모집 결과 위조(응답 바꾸기)응답 Ed25519 서명 — 앱이 공개 키로 확인. 틀리면 적용 안 함/v1/gacha/pull높음1차판
광고 보상 반복자리별 하루 한도를 저장 + 서버 이벤트 대조(사후)events낮음1차판
PCK 디컴파일PCK 암호화(커스텀 내보내기 템플릿 필요) — 1차판은 안 함—낮음완성판 검토
루팅·변조 앱Play Integrity API — 랭킹이 없으니 도입 안 함——도입 안 함
서버 이상치 검사 (PUT /v1/save)기준
무료 빛구슬 하루 증가그날 이론 최대치 × 3 초과 — 이론 최대치는 economy 문서의 일일 획득 상한(첫 클리어·미션·출석·이벤트 합)
최고 판 하루 증가직전 백업 대비 +60판 초과(1차판 전체 60판)
칸 강화bruni.slot_enh 6칸 중 하나라도 20 초과, 또는 growth.json gear_enhance 의 need_bruni_lv 를 bruni.lv 가 못 넘었는데 그 단계 이상
가방·동료·우편 개수gear_bag > 60 · comp > 60명 · mail > 100 — 스키마 위반이면 백업은 받되 flags(schema)
모집 순번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 는 서버가 만든 익명 값입니다.

이벤트언제파라미터
app_open앱 시작·복귀cold(bool) · from_bg_s
session_end백그라운드 진입len_s · screens(int)
tutorial튜토리얼 단계 통과step(1~17 = T01~T17)
consent약관·UMP 응답terms · privacy · ads_personalized
stage_start판 도전stage · cp · req_cp · party[5]
stage_end판 종료stage · win · time_s · reason(win/wipe/timeout/quit) · wave
stage_first_clear첫 클리어stage · gem · tries
episode_unlock화 해금ep
story_view화 재생 끝ep · skipped · read_s
story_choice선택지ep · option · bond_to
char_story_view개인 이야기cs · skipped
idle_claim방치 보상 수령min · capped(bool) · x2_ad · gold · drop · brush
quick_idle빠른 방치kind(free/ad/gem) · gem
pot_draw항아리 뽑기n · pot_lv · grades{g1:n…} · sold
pot_upgrade항아리 레벨업 시작to_lv · gold · skip_gem
gear_enhance장비 강화slot · to · gold · stones
gear_craft장비 제작item · grade
comp_level동료 레벨업char · to · drop · gold
comp_star승급char · to · shards · rainbow
comp_awaken각성char · to
skill_up스킬 강화char · slot · to
bond_gift선물char · item · exp · to_lv
bruni_color브루니 색 전환from · to
recruit모집 결과 적용seq · banner · n · pay(gem/ticket/free/ad) · paid_used · results[c###] · stardust (원본은 서버 gacha_pulls)
relic_up유물 강화relic · to · gold
item_use소모품 사용item · n · got[]
essence_merge정수 합성from · times
repeat_cap반복 보상 30판 도달stage
seal_refresh원정 상점 새로고침pay(free/ad/gem)
stardust_exchange천장 교환banner · char
dungeon_run일일 던전dg · win · ratio · by_ad
raid_run · tower_run토벌·탑boss/floor · win · dmg
mission_claim미션 보상id
attend_claim출석n
event_progress이벤트 판·상점ev · kind · coin
shop_view상점 진입tab
iap_click결제 버튼product
iap_result스토어 결과product · result(ok/cancel/error) · code
iap_verify서버 검증 결과product · ok · reason · test
mail_claim우편 수령kind · paid(bool)
ad_offer광고 버튼 노출slot
ad_result광고 끝slot · result(reward/skip/fail/no_fill) · by_pass(생략권)
save_conflict저장 충돌 해결local_ms · server_ms · chosen
time_flag시계 의심kind(drift/backward/over_cap) · delta_s
save_tamper서명 불일치fallback(bak/new/server)
perf5분마다 1회fps_avg · fps_p5 · mem_mb · screen
error스크립트 오류·네트워크 실패where · code · msg(120자)
settings설정 변경key · value
bruni37
bruni · 게임기술 명세서
20 / Event Log

기록 활용과 개인정보

이 기록으로 답할 질문

질문쓰는 이벤트판정
어디서 막히나stage_end(reason=timeout/wipe) 판별 실패율실패율 50% 넘는 판 → stages 문서 추천 전투력 재검토
이야기를 읽나story_view skipped 비율건너뛰기 70% 넘는 장 → story 문서 검토
D1·D7 리텐션app_open 첫 날짜 기준 재방문조사 중앙값 D1 22% · D7 4% 미만(plan 문서 S7)과 비교
결제 전환iap_verify ok=true 최초 / DAURPG 약 1.5%(monetization-law 1-2, 2차 자료)와 비교
광고 소진ad_result reward / 자리별 하루 한도자리별 소진율
확률이 공개값대로인가서버 gacha_pulls(서버가 굴린 원본)주 1회 등급·개별 분포 대조 — 표준오차 3배 밖이면 즉시 조사. 기기 recruit 기록은 보조
성능perf fps_p5·mem_mb21장 기준 밑도는 기기 모델
개인정보Play 「데이터 보안」 양식·개인정보 처리방침에는 앱 활동(게임 내 행동)·앱 정보 및 성능(오류·성능)·구매 내역을 수집한다고 적고, 광고 SDK 가 수집하는 기기 id 는 AdMob 항목으로 따로 적습니다. 처리방침 문안과 스토어 양식 작성은 출시·운영 사양서 범위입니다.
bruni38
bruni · 게임기술 명세서
21 / Performance

성능 기준

저사양 기준은 조사의 추정치에서 출발해 1주차 실기기 측정으로 고정합니다. 측정 수단이 없는 기준은 두지 않았습니다 — 표의 「측정」 칸이 그 수단입니다.

항목기준측정
기준 기기(저사양)Android 7.0(API 24) · GPU Adreno 610/Mali-G52 급 · RAM 3GB — 추정(godot-tech §4-3)실기기 1대 확보 — 1주차
기준 기기(보통)Galaxy A 시리즈 2023년 이후 1대출시 전 실기기
프레임전투 30fps 고정 · UI 화면 60fps 상한 · 저사양 5퍼센타일 fps ≥ 25perf 이벤트 fps_p5
전투 틱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_dslGUT — 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→CURRENTtests/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일 s150economy_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.01win16,40031,152 · 950 · 637 · 700 · 765
일반 판 · 아군 배율 1.02win16,70031,152 · 950 · 637 · 700 · 765
일반 판 · 아군 배율 1.03win16,40031,125 · 950 · 637 · 700 · 765
보스 판 · 아군 배율 0.72 (승률 경계)1lose42,80030 · 0 · 0 · 0 · 0
보스 판 · 아군 배율 0.72 (승률 경계)2win35,20030 · 0 · 0 · 16 · 10
보스 판 · 아군 배율 0.72 (승률 경계)3win36,40030 · 0 · 0 · 57 · 69

분포 기준값과 난수기 차이

판승률(PCG32)승률(메르센)차이허용(3σ)평균 종료(PCG32)평균 종료(메르센)차이
일반 판 · 배율 0.63575.2%78.0%-2.8%p±5.8%p27.03초26.91초+0.4%
보스 판 · 배율 0.7267.4%66.7%+0.7%p±6.3%p36.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 W0data/*.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.3export_presets.cfg 의 version/name 과 version/code(정수, 매 업로드 +1) 동시 갱신
5 테스트godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests실패 0
6 Android 빌드godot --headless --export-release "Android" build/bruni-idle-1.0.3.aabAAB 크기 기록, 서명은 업로드 키(Play 앱 서명 사용)
7 서버 배포cd server && npx wrangler d1 migrations apply bruni-idle && npx wrangler deploy/v1/time 실제 호출로 확인
8 업로드Play Console → 내부 테스트 트랙(→ 비공개 → 프로덕션 단계 출시 20%→50%→100%)사전 출시 보고서 경고 확인
9 커밋·태그git tag v1.0.3 && git push --tags빌드한 커밋 = 태그
10 실제 확인내부 테스트 설치 → 결제 1건·광고 1건·저장 백업 1건D1 에 각 1행
iOS (43주차~)godot --headless --export-release "iOS" build/ios/ → Xcode 보관·서명 → TestFlight/me/game-spec 04장 절차

버전 규칙

항목규칙
앱 버전 이름주.부.수 — 1.0.0 = 1차판 출시(W0). W1~W6 업데이트는 부 버전(1.1.0~1.6.0), 수정 배포는 수 버전
version code정수, 업로드마다 +1. 되돌리지 않음
저장 version앱 버전과 별개의 정수(12장)
데이터 버전data/manifest.json 의 data_ver = 내보낸 시각 + 해시 앞 8자. 이벤트 기록 공통 필드
서버 API/v1/ 고정. 깨지는 변경은 /v2/ 를 새로 열고 /v1 은 min_version 이 올라간 뒤 30일 유지
비밀값어디에깃에
업로드 키스토어(.jks)·비밀번호개발 맥 + 오프라인 백업 1벌 (Play 앱 서명이라 분실 시 업로드 키 재설정 가능)넣지 않음
Google 서비스 계정 JSONwrangler secret넣지 않음
Apple 인앱 키 .p8wrangler secret넣지 않음
APP_PEPPER(저장 키 재료)core/crypto_util.gd 상수 — 바이너리에 들어감을 전제로 설계넣음(비공개 저장소)
Worker 응답 서명 키wrangler secret넣지 않음
bruni42
bruni · 게임기술 명세서
24 / Boundaries

문서 경계와 바이블 추가 제안

다른 문서와의 경계

주제여기서정하는 문서
판정 순서·공식·상태 29종 수치함수 이름·틱 구조전투·성장 명세서
재화 흐름·시뮬레이션Wallet 구조·유료 분리 장부재화·성장 속도 명세서
확률표·상품 구성·가격확률 표시 화면이 데이터를 그대로 읽는 구조·스토어 등록 형식뽑기·상점 명세서
화면 와이어프레임·이동 지도씬 파일·루트 노드화면 명세서
그림·소리 목록폴더·셰이더아트·사운드 명세서
일정·비용·개인정보 처리방침서버 무료 한도 예산출시·운영 사양서
대본 문장대본 JSON 형식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

미확인 목록과 산출물

미확인으로 남은 것

#내용
1GDScript int 64비트 곱셈의 랩어라운드 동작 — PCG32 를 GDScript 로 짤 때 필요. 1주차 난수 벡터 시험으로 확인, 어긋나면 32비트 반쪽 곱으로 나눠 계산
2Workers 에서 Ed25519 서명(WebCrypto) 사용 가능 여부 — 안 되면 HMAC-SHA256 + 앱 내 검증 키(키가 바이너리에 들어가 강도 낮음)
3모집 기록(gacha_pulls) 보존 기간 — 전자상거래법 거래기록 보존(5년으로 추정) 적용 여부는 launch 문서 범위
4godot-sdk-integrations godot-admob 와 Poing Studios godot-admob-plugin v5 의 Godot 4.7.2 동작 (조사 §3-1 · §7)
5godot-google-play-billing 3.2.0 의 Godot 4.7.2 동작 — README 4.2+ 표기만 확인 (조사 §3-2)
6iOS 결제 플러그인 선택(godot-storekit2 「API 불안정」 / hrk4649 4.7 이상) — iOS 착수(2027-08) 전 시험
7Play 에서 acknowledge 안 한 구매의 자동 환불 기한 (조사 §3-4, §7-4)
8AdMob 서버측 검증(SSV)을 플러그인이 설정할 수 있는지 (조사 §3-1)
9저사양 실기기에서 JSON 17파일 파싱 시간 — 1주차 실측 (조사 §4-4)
10GUT(Godot Unit Test)의 Godot 4.7 호환 판 — 1주차 확인. 안 되면 gdUnit4
11Play 기본 다운로드 크기 한도(AAB) — 출시 전 Play Console 도움말 확인
12기준 저사양 기기 급(Adreno 610/Mali-G52, RAM 3GB)과 메모리·배터리 한도 — 추정값, 실측 후 고정
13iOS 클라우드 저장(GKSavedGame) 플러그인 — 없으면 서버 백업만 (조사 §2-2)

이 문서가 만든 파일

파일무엇
game-design/data/tech.json이 문서의 표 전부(폴더·오토로드·씬·함수·저장·서버·이벤트·성능·테스트·빌드) + 계산값
game-design/data/schema/*.schema.json런타임 데이터 16개 + save 의 JSON Schema(2020-12) — 설계 범위, gen/tech_schema.py 에서 생성
game-design/data/schema/save.sample.json1차판 30일차 저장 샘플 (스키마 통과)
game-design/data/schema/golden/pcg32_vectors.json · golden_battle.json난수 벡터 · 골든 전투 기준값 (22장)
game-design/data/schema/d1.sqlD1 테이블 8개 DDL
game-design/gen/tech.py · tech_spec.py · tech_schema.py · pcg32.py생성기 · 손으로 정한 설계값 · 스키마와 검사기 · PCG32 참조 구현
근거 (모두 2026-10-13 조회, game-design/research/godot-tech.md 경유): Godot 4.7 릴리스 공지(2026-06-18)·4.7.2 다운로드(2026-08-18) · Godot 문서 Time·FileAccess·렌더러 개요·Android 내보내기 4.7·i18n · Google Play 타깃 API 요건(2026-08-31부터 API 36) · godot-google-play-billing(3.2.0 2026-03-16 / 3.3.0 2026-07-27 unstable) · godot-admob-plugin v5.0.0(2026-07-22) · godot-storekit2 · hrk4649 iOS IAP(2026-06-27) · androidpublisher purchases.products.get · Apple verifyReceipt 이전 안내 · Cloudflare Workers 가격·한도(무료 하루 10만 요청, CPU 10ms) · D1 무료 한도 강제(2026-09-01 changelog: 읽기 500만·쓰기 10만/일·5GB) · monetization-law.md(확률 공개 시행령 별표 3의2, 전자상거래법 제17조, 모바일게임 표준약관). 저장 크기·데이터 크기·요청 예산·보상 예시·검사 결과는 gen/tech.py 실행값(2026-10-13).
44