CatGunner CookbookBardCat 1.1.59 · L1 ANALYSIS · tài liệu học
Trang chủ › Cookbook › 11 — Monetization (IAA + IAP): quảng cáo là bản dùng thử của cửa hàng
Nguồn: 11-monetization.md · Cập nhật lần cuối: 2026-09-12 07:24 +07 · sha256 nguồn 49d67104aa47

11 — Monetization (IAA + IAP): quảng cáo là bản dùng thử của cửa hàng

Nguồn framework: docs/framework/10-monetization-iaa-iap.md §1–§10 (nguồn chính), docs/framework/07-progression-managers.md §2.4, §3.4, §3.6, docs/framework/09-prestige-rebirth.md §3.5, docs/framework/01-economy-money.md §3.2, §3.6, [liveops catalog: LIVEOPS_SURFACE.md] §2–§6, [liveops catalog: UNLOCK_GATES.md] §1, §3, [phân tích nội bộ] U2, U4. Cấp bằng chứng: L1 ANALYSIS — dựng lại clean-room từ disasm + metadata + scene + probe read-only trên bản Play Store chính hãng của user; không phải source gốc. Mỗi dòng bên dưới mang nhãn bằng chứng riêng. Ranh giới R1 / R9: SDK quảng cáo / IAP / attribution / offerwall không được tích hợp vào bản restore; chúng nằm sau interface fail-closed và không bao giờ cấp thưởng. Trang này chưng cất thiết kế kiếm tiền (placement, gate, offer, timing), không chưng cất nội tại SDK.

Mục tiêu

Bạn muốn biết một game idle thương mại bán gì, bán bằng quảng cáo hay bằng tiền, đặt offer ở đâu, khi nào, và điều gì quyết định một người chơi có thấy quảng cáo hay không — để thiết kế tầng IAA/IAP cho game của mình mà không phá vòng lặp (doc 01/03) và không "phạt" người chơi. CatGunner bán đúng 4 thứ, mỗi thứ có một bản quảng cáo (nếm thử) và một bản IAP (vĩnh viễn) — đó là toàn bộ ý đồ, và mọi con số bên dưới đọc được từ binary + scene + một phiên probe sống.

Game gốc làm gì

Bốn hàng hoá — hai cột xếp tầng (doc 10 §1)

Hàng hoá Bán bằng quảng cáo (IAA) Bán bằng tiền (IAP) Mức bằng chứng
Nhân thu nhập / sát thương tạm thời Buff ×3, 300 s/lần, trần 900 s buff_pack → ×3 vĩnh viễn cả hai buff disasm-proven
Nhân phần thưởng một lần Bonus sau Mine / Boss / Hunt / Challenge, Mission, Fish gacha PlusPack (×2 Monster, ×2 Boss, ×1.5 Mine) disasm-proven
Nhảy cóc tiến trình Rebirth tăng cường ×3 (1 lượt/ngày) catgunner_rebirth_single (thêm 1 lượt/ngày, mở ở Stage 100-1) disasm-proven
Bỏ ma sát FreeCash link → rewarded ad tự trao thưởng remove_forced_ads / remove_all_ads disasm-proven

Điểm nối kỹ thuật của hai cột: hai cờ non-consumable Shop_manager.Is_Remove_AD_All / Is_Buff_Pack được tầng quảng cáo đọc trực tiếp (doc 10 §1, §4.1).

Tầng quảng cáo (IAA)

Điều game làm Con số / hằng số Mức bằng chứng Nguồn
Lớp Admob không dùng Google AdMob — bọc SDK ADX (AdxRewardedAd, AdxInterstitialAd, AdxBannerAd); tên lớp là di sản 3 định dạng: rewarded · interstitial · banner metadata-proven doc 10 §2.1; dump.cs:585348-585700
enum AD_Type = 10 loại phần thưởng của rewarded ad Money_Buff=0 … AD_Reward_Gun=9 metadata-proven dump.cs:584959
Trao thưởng trong một coroutine của game, không trong callback SDK Admob.Reward_Get() → switch(ad_type) → đúng 1 manager disasm-proven RVA 0x2A5F0B0
Giãn cách giữa 2 rewarded ad AD_Cool = 20.0f, đếm ngược Time.deltaTime trong LateUpdate; bấm sớm ⇒ Alert("Try in {0} seconds") disasm-proven RVA 0x2A5F0B0, 0x2A58CC8, 0x2A59294
Đường tắt entitlement: đã mua remove_all_ads hoặc đã liên kết FreeCash ⇒ ShowRewardAd trao thưởng ngay, không hiện quảng cáo Is_Remove_AD_All \|\| FreeCash_manager.RewardAdRemoved disasm-proven RVA 0x2A59294
Cùng vị ngữ đó là Shop_manager.Is_RewardBonus_Auto — dùng để tắt interstitial và đổi nút "xem QC" thành "nhận luôn" — disasm-proven RVA 0x2B2D510
5 Admob.Inter_Place khớp 1-1 với 5 field inter_daily_cnt_* của ServerConfig Rebirth=0, Mine=1, Challenge=2, Boss=3, Hunt=4 metadata-proven + disasm-proven dump.cs:584976; Inter_DailyLimit 0x2A5CC04
Bộ đếm interstitial nằm ở PlayerPrefs, không phải SaveData; reset khi đổi ngày khoá inter_place_cnt_<place>, inter_place_cnt_whole, inter_place_date (yyyyMMdd) disasm-proven 0x2A5C784, 0x2A5C7F0, 0x2A5D644
Cổng interstitial = 7 điều kiện theo thứ tự (xem pseudocode) — disasm-proven Inter_Placement_Able 0x2A5CD34
Interstitial không bao giờ chặn luồng game: gate fail ⇒ gọi onClosed() ngay — disasm-proven ShowPlacementInterstitial 0x2A5D508
Chặn interstitial / banner Is_Interstitial_Blocked = is_Interstitial_Disable \|\| Is_Remove_AD_forced \|\| Is_Remove_AD_All; Is_Banner_Blocked = Is_Remove_AD_forced \|\| Is_Remove_AD_All disasm-proven 0x2A5B4D4, 0x2A5BC70
Banner chỉ chạy trong trận Boss / Hunt / Raid (show ở Enter_Cor, hide ở Game_Exit) BANNER_HEIGHT_DP = 50, BANNER_RETRY_DELAY = 30 s disasm-proven (xrefs) + metadata-proven 0x2A5BCF4, 0x2A5BD58; dump.cs:585392-585404
Đổi trạng thái IAP ⇒ huỷ ngay banner / ad đã nạp nếu bị chặn Handle_InAppPurchaseChanged disasm-proven 0x2A5C2CC
Hằng độ bền REWARD_RETRY_BASE 15 s, REWARD_RETRY_MAX 120 s, REWARD_AD_EXPIRE_MIN 50 phút, LOAD_HARD_RESET_SEC 60 s metadata-proven dump.cs:585374-585377
Giá trị serialize của Admob trong scene is_AD_Disable_Version 0, is_Interstitial_Disable 0, adLoadTimeout 12, interLoadingSeconds 1.2, interLoadMaxAttempts 1, interLoadWaitPerAttempt 3 scene-YAML Main.unity:20647245-20647268
28 điểm kích hoạt quảng cáo trong code rewarded 13 · interstitial 9 (5 place) · banner 3 show / 3 hide · OfferWall 2 · FreeCash 1 disasm-proven doc 10 §4
Súng thưởng quảng cáo weapon_02: trigger là hành vi (ghép súng lần đầu khi chưa có súng bậc ≥ 2) súng #14, đếm ngược 1800 s, một-lần-cho-đời-tài-khoản; xem QC ⇒ giữ vĩnh viễn; hết giờ ⇒ biến mất disasm-proven CheckAdGunEvent 0x2B0C74C, AddAdRewardGun 0x2B0C340, AdRewardGun_Acquire 0x2B0C508

Danh mục AD_Type (doc 10 §3, dispatch 0x2A5F0B0)

# AD_Type Điểm gọi ShowRewardAd Phần thưởng
0 Money_Buff Buff_Panel.AD_Watch 0x2AE4F38 moneyBuffTime = min(+300, 900) + Gamemanager.InvalidateMoneyCache()
1 Power_Buff Buff_Panel.AD_Watch powerBuffTime = min(+300, 900)
2 SpeedRun_Bonus SpeedRun_manager.Bonus_AD_Show 0x2B40E08 / SweepBonus_AD_Show 0x2B41A04 bonus chế độ Play/Challenge
3 Mine_Bonus Mine_manager.Bonus_AD_Show 0x2AF562C / 0x2AF5B28 bonus mỏ
4 Boss_Bonus Boss_manager.Bonus_AD_Show 0x2AF21A8 / 0x2AF2D94 bonus boss
5 Monster_Bonus Monster_manager.Bonus_AD_Show 0x2AFB094 / 0x2AFAEC4 bonus Hunt (PetCoin)
6 Mission_Reward Mission_manager.Reward_Get 0x2A71A34 nhận thưởng mission
7 Fish_Gacha Fish_manager.FreeGacha 0x2B00AB4 1 lượt gacha cá miễn phí
8 Upgrade_Rebirth Rebirth_manager.UpgradeRebirth_Start_By_Ad 0x2B233D0 bật is_UpgradeRebirth ⇒ thưởng Rebirth ×3
9 AD_Reward_Gun Gun_manager.AdRewardGun_TryWatch 0x2B0C484 giữ vĩnh viễn khẩu súng thưởng

IsBuffActive(type) trả true cho mọi type khi IsPermanentBuff (0x2AE4AF8) ⇒ buff_pack bật vĩnh viễn cả hai buff; GetBuffRemainingTime trả -1 khi vĩnh viễn (0x2AE4B4C) và Buff_Panel.Update (0x2AE4D4C) hiểu -1 là "ẩn nút xem QC, fill = 1.0" — disasm-proven, doc 10 §3, §5.

Phân khúc (segment) — công tắc tổng của IAA

Điều game làm Con số / hằng số Mức bằng chứng Nguồn
ServerConfig_manager.ConfigResp có đúng 15 field, 12 là số điều khiển quảng cáo, 0 field kinh tế 15 metadata-proven + runtime-verified (live) dump.cs:589438; LIVEOPS_SURFACE §2.2; doc 10 §2.3
Payload gửi lên ConfigReq (13 field) ⇒ server phân khúc theo nguồn cài đặt (AppsFlyer campaign/media_source), quốc gia, ngày cài, số lần mua, số reward-ad đã xem 13 metadata-proven dump.cs:589413
EmbeddedDefaults(segment == 1) freecash_stage_min 15, freecash_stage_max_hud 35, offerwall_stage_min 10, inter_stage_min 12, inter_cooltime 0, inter_daily_cnt_rebirth 6, _mine 2, _challenge 0, _boss 0, _hunt 0, _whole 99999, weapon_02_ad_onoff 1 disasm-proven (.rodata) 0x2AC7B20; LIVEOPS_SURFACE §2.3
EmbeddedDefaults(segment ≠ 1) 4 ngưỡng stage = 99999, mọi cap = 0, ad-gun tắt ⇒ tắt sạch IAA disasm-proven như trên
Payload production thật cho user này: segment 0, rule_version "41", reason "ok", mọi ngưỡng 99999, mọi cap 0 ⇒ toàn bộ IAA tắt; user này kiếm tiền IAP-only — runtime-verified (live) doc 10 §2.3–§2.4
Server trả đúng 15 key đã suy ra tĩnh, không thừa không thiếu ⇒ liveops không chạm balance — runtime-verified (live) doc 10 §2.3; CONVERGENCE U4
Phân khúc gắn với package id: đổi bundle_id ⇒ server trả reason: default_rules_fallback, rule_version 0 — runtime-verified (live) doc 10 §2.3
Lộ diện = f(tiến trình) × f(segment): stageDisplay = Stage_Value_Max / 10 + 1 interstitial ≥ inter_stage_min (seg 1: 12); OfferWall ≥ offerwall_stage_min (10); FreeCash HUD trong [15, 35] disasm-proven 0x2A5CD34, 0x2B2B20C, 0x2A6CF08 + 0x2A6D1F8
Firebase Remote Config chỉ 5 key: version gate + timehack_pardon + offerwall_event_onoff 5 VERIFIED LIVEOPS_SURFACE §3
Có công tắc QA đổi phân khúc / ép lộ diện ngay trong build shipped — metadata-proven doc 10 §2.3; LIVEOPS_SURFACE §2.5
Nút QA đổi phân khúc AppsFlyerInit.Cheat_SetSegment(seg): ghi Segment (static 0x8) + cờ ưu tiên (0x21), lưu PlayerPrefs["af_segment"]/["af_segment_cheat"], đổi nhãn TMP, gọi ServerConfig_manager.Cheat_OnSegmentChanged() (0x2AC96D0) ⇒ phát OnConfigApplied + SaveSystem.Save — disasm-proven 0x2A600CC; 0x2AC96D0; doc 10 §2.3
Đường server đặt segment (ApplyServerSegment 0x2A6025C) thoát ở dòng đầu khi cờ QA đã bật (0x2a60320) ⇒ ưu tiên QA > server > mặc định 0 — disasm-proven 0x2A6025C; doc 10 §2.3

Ba tầng đừng lẫn (chỗ nhầm phổ biến nhất khi tự dựng lại hệ này):

Segment ServerConfig (ConfigResp) Placement
Là gì một con số dán lên người chơi (0/1/2) bộ 15 field ứng với con số đó một chỗ cụ thể trong game có thể chiếu quảng cáo
Trả lời bạn thuộc nhóm nào nhóm của bạn được đối xử ra sao ở đây, ngay bây giờ, có được chiếu không
Ai đặt server attribution (hoặc nút QA) server liveops; mất mạng ⇒ EmbeddedDefaults người làm game đặt sẵn trong code/scene
Đổi khi nào hiếm (lúc cài / lúc ép) mỗi lần khởi động, hoặc khi segment đổi không đổi — nó là địa điểm
Trong Cat Gunner 3 giá trị 15 field (12 số quảng cáo, 0 số kinh tế) 6 loại × 28 điểm kích hoạt

Đơn vị đo của mọi ngưỡng là stage hiển thị Stage_Value_Max / 10 + 1, không phải Stage_Value_Max thô (0x2A6D118, 0x2B2B12C) — nhầm chỗ này thì mọi ngưỡng lệch mười lần mà không có lỗi nào nổ.

Tầng IAP

Điều game làm Con số / hằng số Mức bằng chứng Nguồn
34 product id được TryApplyPurchase xử lý, ánh xạ sang 33 chỉ số offer (1..30, 36..38; vip_pass và vip_pass_2 chung chỉ số 12; 31..35 trống) 34 disasm-proven ItemIdxFromProductId 0x2B2DE44; doc 10 §6.1
Đính chính LIVEOPS_SURFACE §6.1: mine_ticket, fish_free, banner_enter, banner_reward, banner_weapon, sp_iap, reward_hub, f_cash không phải product id — là chuỗi place / khoá analytics 8 chuỗi disasm-proven (xrefs literal) doc 10 §2.2
Backend = Unity StoreController, có xác thực receipt phía server (2 Cloud Function) — metadata-proven dump.cs:600320-600321; ValidateAndComplete 0x2B33DAC
Chống trùng giao dịch _inflightTxns, ProcessedTxnIds (ProcessedTxnIdsMax = 50) metadata-proven + disasm-proven dump.cs:600334-600337
6 non-consumable là cờ static của Shop_manager Is_Remove_AD_forced@0x8, Is_Remove_AD_All@0x9, Is_AutoUpgrade_Pack@0xA, Is_Buff_Pack@0xB, Is_Dia_Pack@0xC, Is_Mine_Pack@0xD metadata-proven dump.cs:600306-600311
Dia kèm non-consumable autoupgrade_pack +500 · buff_pack +1000 · dia_pack +16000 · mine_pack +500 · remove_*_ads 0 dia nhưng mở DiaPig disasm-proven + runtime-verified (live) ApplyNonConsumable 0x2B311EC; doc 10 §2.2
5 bậc dia DiaRewards = [240, 600, 1500, 3500, 8000] — blob được chứng minh bằng SHA-256 của chính tên field (preimage không thể trùng); cấp trong static ctor cùng Dia_FirstBuy_Used = new bool[5] metadata-proven (blob SHA MATCH) doc 10 §2.2 (G350, G353)
Khuyến mãi mua-lần-đầu = dịch trái 1 bit (đúng gấp đôi, một lần mỗi bậc, có mùa) amount = DiaRewards[i] << (Is_Dia_FirstBuy(i) ? 1 : 0); DiaFirstBuy_Season_Current = 1 disasm-proven + runtime-verified (live) ApplyDiaReward 0x2B32D90; dump.cs:600315-600318
Core (tiền tệ Adventure) [100, 500, 1050, 3300, 750, 1750, 6000]; 3 gói có nhịp ngày/tuần (LastDailyCorePurchase_dateTime …) disasm-proven (.rodata) 0x2B30018, 0x2B304BC
VIP / Luxury pass vip_pass/vip_pass_2 → is_Vip = true + 500 dia; luxury_pass và luxury_upgrade → thân hàm giống hệt: is_Vip = is_Luxury = true + 1000 dia + Exp_Get(300) = 3 cấp pass (Exp_balance 100, Max_Lv 25) disasm-proven 0x2B32C50, 0x2B32AA4 ≡ 0x2B328F8
catgunner_rebirth_single → UpgradeRebirth_Start_By_IAP; nếu manager còn null thì hoãn, thử lại lần chạy sau — disasm-proven ApplyRebirthSingle 0x2B3280C
StepUp 5 bậc Gun_Rewards = [23, 28, 33, 39, 44] = 유령 저격총 (S, LASER) · 미사일 SS · 미사일 SSS · 폭발레이저 X · 폭발레이저 XX — thang S → XX; Dia_Rewards = [0, 200, 400, 600, 1000] metadata-proven (blob SHA MATCH) + derived (tên từ Gun_list scene) doc 10 §2.2, doc 11 §3.1 (G353)
DailyGun MaxCounts = [2, 2, 1], PetCoin_Rewards = [300, 700, 1500], GunNum_Base = [20, 30, 35], WeekEpoch = 2024-01-01 metadata-proven DailyGun.cctor 0x2B26894
DiaPig là phần thưởng đi kèm gói xoá quảng cáo is_DiaPig_Unlock = Is_Remove_AD_All \|\| Is_Remove_AD_forced; tick (forced?1:0)+(all?1:0) dia mỗi 60 s disasm-proven 0x2B27764, 0x2B2823C
Giá không hardcode — đọc từ Product.metadata của store, định dạng theo CurrencyFormat (23 tiền tệ → định dạng) / ZeroDecimalCurrency (KRW, JPY, VND, IDR, CLP, HUF — không thập phân) 6 overload Localized_Price_return; 2 bảng trong static ctor disasm-proven 0x2B367D4, 0x2B368B0; G399
Giá quan sát sống (VND, tại thời điểm chụp — không phải hằng trong build) Auto Upgrade 211.000đ · Buff 210.000đ · Bom Kim Cương 1.300.000đ · dia 52.000 / 131.000 / 263.000 / 526.000 / 1.300.000đ runtime-verified (live) doc 10 §2.4
Số lần mua được gửi lên server phân khúc InappCntForServer = max(PurchaseCount, PurchaseHistory.Count) → ConfigReq.inapp_cnt disasm-proven 0x2B36528

Rebirth tăng cường ×3 (doc 09 §3.5; doc 10 §7.4)

Điều game làm Con số / hằng số Mức bằng chứng Nguồn
Thưởng ×3 nếu is_UpgradeRebirth UpgradeRebirth_Multiplier = 3 disasm-proven + metadata-proven RVA 0x2B22F18; dump.cs:598987
Hai lối vào, mỗi lối 1 lượt/ngày, reset theo chuỗi ngày UpgradeRebirth_DailyLimit = 1, _IAP_DailyLimit = 1; DayCheck 0x2B23260 metadata-proven + disasm-proven doc 10 §7.5
Kênh IAP mở khi Stage_Value_Max > 988 ⇒ Stage 100-1 UpgradeRebirth_IAP_UnlockStageValue = 989 (HYPOTHESIS cũ đã được giải quyết) VERIFIED 0x2B232D0 @0x2b23394; UNLOCK_GATES §3
Mã "kind" log: 1 = xem QC, 2 = đã mua Remove-AD-All, 3 = IAP; cờ chọn Shop_manager.Is_Remove_AD_All 1/2/3 VERIFIED (2 phân tích hội tụ) CONVERGENCE U2
Interstitial sau Rebirth chỉ khi không boost và popup review không mở; Review_Panel_Open() trả true đúng một lần trong đời tài khoản — disasm-proven Rebirth() @0x2b22d9c; 0x2AC0DE8

Lịch offer (cố định trong binary — doc 10 §7.5)

Hệ Nhịp Hằng RVA
StepUp 7 ngày kể từ lần đầu banner được nhìn thấy, 5 bậc mua tuần tự; banner cần súng bậc ≥ 2 StepCount 5, Duration_Days 7, Banner_Unlock_Degree 2 0x2B3A328, 0x2B3A670, 0x2B3A4E0
DailyGun xoay theo tuần từ WeekEpoch, 3 món, giới hạn [2,2,1] ProductCount 3 0x2B25F34, 0x2B26080
PlusPack 7 ngày kể từ lúc mua, 3 loại độc lập; banner từ Banner_Stage_Min 190 (Stage 20-1) Duration_Days 7, PlusPack_Auto_Reduce_Hours 16 0x2B2B5EC, 0x2B2C778
WeeklyShop / VIP Stage_Value_Max > 189 ⇒ Stage 20-1 Unlock_Degree 3 0x2B3EA58
Core daily/weekly mốc LastDaily… / LastWeekly… — 0x2B24F9C
DiaPig tick 60 s, bù tối đa 3 tick TICK_SECONDS 60, CatchUpMaxPending 3 LIVEOPS_SURFACE §6
DailyCheck chuỗi 7 ngày lặp dia 70/200/400/600/800/1000/1500 — xem mục "7. Điểm danh 7 ngày" bên dưới (G378) doc 10 §7.6

Công thức / pseudocode

1. Rewarded — Admob.ShowRewardAd(AD_Type) — L1 ANALYSIS, RVA 0x2A59294 (doc 10 §4.1)

ShowRewardAd(type):
    if (Shop_manager.Is_Remove_AD_All || FreeCash_manager.RewardAdRemoved):
        ad_type = type; StartCoroutine(Reward_Get()); return          // ĐƯỜNG TẮT: không có quảng cáo nào
    if (is_AD_Disable_Version)        -> Alert("Try Later"); return
    if (_pendingShow || _isShowing)   -> Alert("Try Later"); return
    if (AD_Cool > 0)                  -> Alert("Try in {0} seconds"); return
    ad_type = type
    if (loaded && expired > 50 phút)  -> Discard + reload
    if (loaded)                       -> TryShowLoadedAd()
    else if (!_sdkReady)              -> Alert("Try Later")
    else if (no network)              -> Alert("Check Network Connection")
    else                              -> _pendingShow = true; loading panel; LoadRewardedAd()

Reward_Get():                                                          // RVA 0x2A5F0B0, sau khi SDK báo earned
    yield 1 frame; AD_Cool = 20.0f; Sound_manager.PlaySfx(7)
    if (!Is_Remove_AD_All) log analytics AF_CODE[ad_type]              // [5,6,1,2,3,4,7,8,9,10]
    switch (ad_type): 0,1 -> Buff_manager.AddBuff(ad_type); 2 -> SpeedRun.Bonus_Get(); ...   // ĐIỂM TRAO DUY NHẤT

1b. Quota ngày: "cho không mấy lượt trước, gắn quảng cáo mấy lượt sau" (doc 10 §4.1b)

Muốn làm một bề mặt kiểu "rút thưởng hằng ngày" vừa giữ chân người không xem quảng cáo vừa bán được lượt cho người có xem, thì đây là hình dạng đã chạy thật (đo trên máy, placement rút cá):

Draw():
    Quota_Reload()                                   // §7b: nạp lại nếu sang ngày mới
    if (kho đầy)          -> Alert("Kho đã đầy");  return     // TRẦN KHO chặn trước MỌI thứ
    if (used >= MAX)      -> Alert("Hết lượt");    return     // MAX = 5
    if (used >= FREE)     -> ShowRewarded(place);  return     // FREE = 3 -> nhánh Granted gọi lại Grant()
    Grant()                                                    // 3 lượt đầu: thưởng NGAY

Grant():                                             // Granted của quảng cáo cũng vào đúng đây
    if (kho đầy) -> Alert; return                    // kiểm LẠI: giữa lúc xem quảng cáo kho có thể đầy
    grade = Roll(baseGrade = 0); kho.Add(grade); vẽ lại chỉ số; popup "vật phẩm mới"; PlaySfx
    used++;  Btn_UI_Reload()

// Nhãn và biểu tượng
Btn_UI_Reload():
    count_txt   = "x" + (MAX - used)
    btn_color   = (used >= MAX) ? Color_Disable : Color.white
    ad_icon.SetActive((uint)(used - FREE) < (MAX - FREE))      // DẢI {3,4}, KHÔNG phải used >= 3

Ba con số cần chốt trước khi viết dòng nào: MAX (5), FREE (3), giá nút trả tiền (200 Dia). Đo được trên máy: x5 → x4 → x3 không quảng cáo; x2, x1 phải xem; x0 nút xám và biểu tượng quảng cáo biến mất.

Vì sao cho không trước, không phải gắn quảng cáo từ lượt đầu. Ba lượt đầu là dạy giá: người chơi thấy phần thưởng rơi ra và thấy hai ô chỉ số nhích lên ngay trên cùng màn hình. Tới lượt thứ tư, "xem quảng cáo chứ?" mới là một câu hỏi có giá đã niêm yết — trước đó nó chỉ là một lời mời mù. Phần cho không còn giữ lý do quay lại hằng ngày cho nhóm không bao giờ xem quảng cáo, và biên trên (5) giữ cho bề mặt không thành máy cày.

Nút trả tiền mua SÀN, không mua tỉ lệ. Cùng một bảng tỉ lệ cộng dồn cho cả hai nút; nút miễn phí trả base + i với base = 0, nút 200 Dia trả base = 1. Vì mỗi bậc mạnh gấp ~3, dịch một bậc đã là gấp ba. Một bảng duy nhất phục vụ mọi nút ⇒ thêm hạng nút cao hơn chỉ là Roll(base + 1), không có bảng thứ hai để lệch nhau.

Roll(base):                                # tỉ lệ CỘNG DỒN, tổng đúng 100
    r = Random(0, 100); acc = 0
    for i, p in enumerate([60, 30, 10, 0]):    # bảng THẬT, đọc từ scene — xem cảnh báo dưới
        acc += p                               # 60 -> 90 -> 100 -> 100
        if r < acc: return base + i            # base=0 -> hạng 0..2 ; base=1 -> hạng 1..3
    return base                                # không bao giờ chạy khi tổng = 100
Nút base Phân phối
miễn phí 0 hạng 0 (60%) · 1 (30%) · 2 (10%) · ~~3 (0%)~~
200 Dia 1 hạng 1 (60%) · 2 (30%) · 3 (10%) · ~~4 (0%)~~

Hai lựa chọn cân bằng đáng đọc: lệch mạnh về bậc nền và đuôi bị cắt cụt (gacha là máy rót nguyên liệu, không phải máy tạo bất ngờ — cảm giác tiến bộ được đẩy sang mạch hợp nhất); và giữ ô thứ tư trong mảng nhưng để 0 — bậc thưởng cao nhất có sẵn chỗ, bật lại chỉ là đổi một số trong dữ liệu chứ không phải sửa code.

⚠️ BẪY: đừng lấy tỉ lệ từ constructor. Mảng tỉ lệ ở đây là field public ⇒ được serialize, nên giá trị trong scene THẮNG giá trị ctor lúc chạy. Constructor của game gốc ghi {40, 30, 20, 10}; bản trước của trang này chép đúng con số đó và sai — bảng thật là {60, 30, 10, 0}. Cái bắt được lỗi là một phép thử tần suất: năm lượt trả tiền liên tiếp đều ra bậc thấp nhất — 0,6⁵ ≈ 7,8% (bình thường) so với 0,4⁵ ≈ 1% (đáng nghi). Quy tắc chung: public/[SerializeField] có trong scene ⇒ scene thắng; private không [SerializeField] ⇒ ctor thắng; const ⇒ ctor thắng. Kiểm loại field trước khi tin con số.

2. Interstitial — 7 cổng theo thứ tự — L1 ANALYSIS, RVA 0x2A5CD34 (doc 10 §4.2)

Inter_Placement_Able(place):
 1. is_AD_Disable_Version                                           -> false
 2. Is_Interstitial_Blocked()   (= disable || Remove_forced || Remove_All) -> false
 3. _interShowing || _interPlacementRunning                         -> false
 4. stageDisplay = Stage_Value_Max / 10 + 1;  stageDisplay < inter_stage_min -> false
 5. inter_cooltime >= 1 && (UtcNow - _lastInterShownUtc) < cooltime -> false
 6. Inter_DayCheck(); PlayerPrefs["inter_place_cnt_"+place] >= Inter_DailyLimit(place) -> false
 7. PlayerPrefs["inter_place_cnt_whole"] >= inter_daily_cnt_whole   -> false
    return true
ShowPlacementInterstitial(place, onClosed): !able ? onClosed() ngay : PlacementInterCo (1.2 s loading, 1 attempt, 3 s chờ, fail -> onClosed())

9 điểm gọi đều là "đóng màn kết quả" (Sweep_Close / Exit_Cor của Mine, Challenge, Boss, Hunt; đuôi Rebirth()), với điều kiện !_bonusAdWatched && !Is_RewardBonus_Auto — không bao giờ giữa trận (doc 10 §4.2).

3. Buff ×3 — doc 10 §5, doc 01 §3.2

AddBuff(Money_Buff):  moneyBuffTime = min(moneyBuffTime + 300, 900); Gamemanager.InvalidateMoneyCache()   // 0x2AE4510
IsBuffActive(t):      IsPermanentBuff || timer[t] > 0                 // IsPermanentBuff = Shop_manager.Is_Buff_Pack (0x2AE4A08)
MoneyValue_return(v): r = v × Upgrade% × Rebirth% × Skin% × Pet% × Relic% (mỗi /100); if IsBuffActive(Money_Buff): r *= 3

4. Lộ diện theo tiến trình × segment (doc 10 §4.4–§4.5, §7.2)

(khối mã nội bộ đã lược bỏ)

5. Giao dịch IAP — một điểm trao duy nhất (doc 10 §6.2)

UI -> Shop_manager.Purchase*(type, place) -> BeginPurchase(productId) 0x2B2E7D8 -> [Unity IAP + store]
   -> OnPurchasePending 0x2B31758 -> StartValidate 0x2B32144 -> ValidateAndComplete 0x2B33DAC (receipt -> server)
   -> CompletePurchaseSuccess 0x2B34164 -> TryApplyPurchase(id, type) 0x2B32204   // <== ĐIỂM TRAO DUY NHẤT
   -> MarkProcessed(txnId); SaveSystem.Save(); log
TryApplyPurchase: 6 non-consumable -> ApplyNonConsumable; rebirth_single; luxury_*; vip_pass*; core; rồi 4 nhánh theo PREFIX
                  (dia_, catgunner_stepup_, catgunner_dailygun_, catgunner_pluspack_); không khớp -> LogError, return false
                  manager đích còn null -> return false, KHÔNG finalize -> store phát lại lần chạy sau (không mất hàng)

6. Gợi ý framework (doc 10 §9 — không phải SOT)

interface IPlacement { string Id; bool CanShow(IGateContext ctx); void Show(Action onClosed); }  // not-able -> onClosed() ngay
interface IGate      { bool Pass(IGateContext ctx, out string reason); }
// thứ tự RẺ -> ĐẮT: BuildFlagGate, EntitlementGate, ReentrancyGate, ProgressGate(min & max), CooldownGate,
//                   PerPlaceCapGate, GlobalCapGate, CourtesyGate (!justRewarded && !justPaid)
interface IOffer     { bool IsActive(DateTime now, IPlayerState s); IReadOnlyList<Reward> Preview { get; } }
interface IRemoteAdConfig { int Segment; int MinStage(id); int MaxStage(id); int CooldownSec; int DailyCap(id); int DailyCapGlobal; bool FeatureOn(id); }
// R1 — binding mặc định của bản restore:
public sealed class NullAdService : IAdService {
    public bool IsRewardedReady => false;
    public void ShowRewarded(string p, Action<AdResult> d) => d?.Invoke(AdResult.Failed);   // KHÔNG BAO GIỜ Rewarded
    public void ShowInterstitial(string p, Action d) => d?.Invoke();                         // no-op, không chặn
}
// NullPurchaseService.IsOwned luôn false; NullRemoteConfig trả bản nhúng "tắt" (99999 / 0) — đúng như SOT cho segment ≠ 1.

7. Điểm danh 7 ngày — hệ retention (doc 10 §7.6, G378)

Vòng lặp quay lại mỗi ngày — thứ nuôi số ngày hoạt động mà mọi offer khác đứng trên. Đọc trọn từ binary (13 method).

Điều game làm Con số / hằng số Mức bằng chứng Nguồn
Bảng thưởng ngày 1–7 70 / 200 / 400 / 600 / 800 / 1000 / 1500 dia (tổng 4 570 / chu kỳ; nhảy vọt ở ngày 7) scene-YAML doc 10 §7.6 (G378)
Sang ngày mới = đồng hồ tin cậy TRƯỚC, rồi mới so ngày CanRolloverDaily("daily", tối đa 1 đang chờ) và LastCheck_Date < Today disasm-proven G378
Chuỗi 7 ngày lặp vô hạn, không phạt khi bỏ lỡ ngày 7 + đổi ngày ⇒ về ngày 1; bỏ lỡ chỉ chờ, không reset disasm-proven G378
Người mới được lùi ngày một hôm ⇒ ngày 1 nhận ngay lần mở đầu NewUser_Reload disasm-proven G378
Nhận thưởng kiểm lại điều kiện; từ chối kèm sfx Dont; hợp lệ ⇒ ghi sổ cái (RecordGain("daily")) TRƯỚC khi cấp dia; rồi CheckNum++ + TotalCheckNum++ (cặp), LastCheck_Date = Today, dia bay về ví, sfx Purchase 2 bộ đếm disasm-proven G378
Mở panel ép kiểm lại giờ (chống chỉnh giờ) Open_Btn → Time_Hacker_ForceCheck disasm-proven G378
Nút lùi ngày debug tắt trong bản phát hành Debug.isDebugBuild disasm-proven G378
Panel chỉ hiện sau khi chờ đồng bộ giờ Show_WhenReady: chờ 4 s kiểm giờ rồi Try_Show disasm-proven G359, G378
# Điểm danh — L1 ANALYSIS (doc 10 §7.6)
is_DateChange  = CanRolloverDaily("daily", maxPending 1) && LastCheck_Date.Date < Today   # giờ tin cậy trước
if CheckNum == 7 and is_DateChange: CheckNum = 0                                          # chuỗi lặp
NewUser: LastCheck_Date = Today - 1 day                                                   # ngày 1 nhận ngay
Get_Reward:
    if !is_GetAble(): PlaySfx(Dont); return
    Time_manager.RecordGain("daily")            # ghi sổ TRƯỚC
    Get_Dia(DiaReward_list[CheckNum]); CheckNum++; TotalCheckNum++; LastCheck_Date = Today
    Dia_Attraction.Dia_Spawn(); PlaySfx(Purchase)
Open_Btn: Time_Hacker_ForceCheck()

Bốn quyết định đáng chép: thưởng đầu ngay lập tức (lùi ngày người mới); chuỗi lặp, không phạt; đồng hồ tin cậy là điều kiện tiên quyết chứ không phải kiểm tra sau; ghi sổ trước khi cấp — mọi thưởng theo thời gian đi qua một sổ cái (xem 14).

7b. Reset quota theo ngày — khuôn dùng chung cho MỌI thứ "mỗi ngày N lần" (doc 07 §3.7)

Điểm danh ở §7 và quota rút thưởng ở §1b là cùng một khuôn. Viết một lần, dùng lại khắp nơi:

Quota_Reload(key, maxPending):
    if savedDate.Date >= Today: return                  # chưa sang ngày mới  (Today = giờ ĐÃ HIỆU CHỈNH)
    if !CanRolloverDaily(key, maxPending): return       # tầng trust chưa đồng ý -> KHÔNG ghi ngày mới
    RecordGain(key)                                     # ghi sổ TRƯỚC
    used = 0                                            # rồi mới phát
    savedDate = Today                                   # và mới đánh dấu

OnClockSynced():                                        # khi đo được lệch giờ với máy chủ
    savedDate = ClampFutureLocalDate(savedDate)         # kéo ngày ở TƯƠNG LAI về, trần = Today + 1 ngày

Bốn điều dễ làm sai nhất, theo thứ tự:

  1. Đừng dùng DateTime.Today của hệ thống. Cả game phải hỏi một nguồn giờ đã hiệu chỉnh. Hai nguồn giờ = hai lịch, và chúng lệch nhau đúng lúc người chơi đổi múi giờ.
  2. Cổng chống chỉnh giờ là một sổ nợ có TRẦN, không phải một cờ. PendingCount(key) < 3: cho phép "ứng trước" tối đa 3 lần rollover chưa xác thực. Người chơi offline dài ngày vẫn nhận được; chỉ gian lận lặp lại mới bị chặn. Mỗi bề mặt một khoá riêng ("fish_free", "daily"…) nên một hệ bị khoá không kéo theo hệ khác.
  3. Bị chặn thì GIỮ NGUYÊN ngày cũ. Vì savedDate chưa đổi, điều kiện savedDate < Today vẫn đúng ở lần gọi sau ⇒ hệ thử lại. Ghi ngày trước khi qua cổng = người chơi mất trắng quota hôm đó, âm thầm. Quy tắc: đừng đánh dấu "đã phát" trước khi phát.
  4. Phải kẹp ngày lưu nằm ở tương lai. Không có bước ClampFutureLocalDate, người chơi đẩy giờ máy tới năm sau rồi trả về là quota đóng băng vĩnh viễn.

Gọi Quota_Reload ở cả hai đường: mỗi lần vẽ lại UI của bề mặt đó, và khi đồng hồ vừa đồng bộ lúc UI đang đóng. Chỉ gọi một chỗ là có một cửa sổ dùng bộ đếm cũ để quyết định.

7c. Công tắc tiện lợi bị khoá — cái nút vừa là TÍNH NĂNG vừa là ĐIỂM BÁN (đo trên máy 2026-09-07)

Khuôn của "hợp nhất tự động" (Pet / Cá / Súng) — mẫu đáng chép nguyên vẹn cho mọi gói tiện lợi:

UI_Reload():
    if (!able && flag) flag = false        // ① mất quyền thì DỌN cờ, không chỉ ẩn
    on = able && flag                      // ② trạng thái hiển thị
    Toggle.SetOn(on)
    Explain_On.SetActive(on)               // hai dòng mẹo, bật/tắt nghịch nhau
    Explain_Off.SetActive(!on)
    Toggle_Root.SetActive(TIER_UNLOCKED)   // ③ cổng KHÁC HẲN: mốc tiến trình, không phải gói

Change():                                  // người chơi bấm công tắc
    if (able) { SetOn(!flag); return; }
    flag = false; UI_Reload();
    Shop.Open_Auto(place)                  // chưa có gói ⇒ nút biến thành cửa bán

Bốn điều rút ra, cái nào cũng là quyết định thiết kế chứ không phải chi tiết code:

  1. Hai cổng chồng nhau, cố ý. Cổng ③ (mốc tiến trình — ở game gốc là Stage_Value_Max > 189) quyết định nút có HIỆN; cổng ① (gói đã mua — PlusPack.Is_Active(Auto)) quyết định nút có BẬT ĐƯỢC. Người mới không thấy gì (không nhiễu tutorial); người đã đi xa thấy nút mình chưa dùng được — đúng lúc họ đã đủ vật phẩm để thấy thao tác tay phiền.
  2. Nút bị khoá không hiện popup "hãy mua", nó MỞ THẲNG cửa hàng đúng tab và mang theo chuỗi place (auto_pet / auto_fish / auto_gun) để đo người chơi bị chặn ở màn nào. Một chuỗi, không có nó thì cả hệ đo phễu mất một tầng.
  3. Hai dòng giải thích thay nhau ("Kéo … để hợp nhất" ⇄ "… tự động hợp nhất!") — trạng thái bật/tắt được nói bằng CHỮ, không chỉ bằng màu công tắc. Rẻ, và là thứ người chơi thật sự đọc.
  4. Dọn cờ LƯỜI, không polling. Khi gói hết hạn giữa phiên, game không hề đi kiểm tra: cờ vẫn true và UI vẫn vẽ trạng thái cũ cho tới lần UI_Reload kế tiếp (đo được: mở panel lại thì cờ mới về false). Rẻ về CPU, và an toàn vì thứ tự dòng ① trước dòng ② — nếu viết ngược lại, người chơi hết hạn giữa chừng vẫn được hưởng tiện ích tới hết phiên.

Cái nút bị khoá dẫn thẳng tới gói: Open_Auto mở cửa hàng ở tab VIP rồi kéo thanh cuộn xuống ĐÁY (PlusPack_Popup.Panel_View_Down, đợi một khung cho ScrollRect tính xong layout), nên người chơi thấy ngay ô gói cần mua thay vì phải tự cuộn. Chi tiết đo đạc, bảng chuỗi và cách nghiệm thu: docs/functions/cong-tac-hop-nhat-tu-dong.md và docs/functions/goi-pluspack.md.

Một con số nữa đáng chép: gói tiện lợi ở game gốc là consumable 7 ngày, không phải non-consumable vĩnh viễn như remove_all_ads / buff_pack. Hết hạn thì tiện ích biến mất và cái nút xám quay lại — tức nó được thiết kế để mua LẠI, và cái công tắc trên màn chơi làm nhiệm vụ nhắc mua mà không cần một popup nào.

7d. Thang giá "step-up" — bán một CON ĐƯỜNG chứ không bán một món (đo trên máy 2026-09-07)

Năm gói xếp hàng, phải mua tuần tự, trong một cửa sổ 7 ngày. Ba cơ chế cùng đẩy về một phía và cái nào cũng gọn tới mức đáng chép nguyên:

Is_Purchasable(step):                       // toàn bộ luật tuần tự, không có bảng nào khác
    if (step > 4) return false
    if (Purchased[step]) return false
    if (step == 0) return true
    return Purchased[step - 1]

Hud.Reload():                               // ĐỒNG HỒ BẮT ĐẦU Ở ĐÂY
    if (!ShouldShow()) { hide(); return }
    Manager.Start()                         // chỉ đóng dấu MỘT LẦN, rồi Save() ngay
    show(); Time_Reload(); restart timer coroutine
  1. Đồng hồ chạy từ lần ĐẦU TIÊN người chơi nhìn thấy offer, không phải từ lúc cài game hay một mốc chung. Mỗi người một cửa sổ riêng — và nó được lưu ngay tại chỗ, nên không mất khi app bị giết. Hệ quả nhỏ nhưng quan trọng: chưa bắt đầu thì "thời gian còn lại" trả TRỌN 7 ngày, không phải 0, để lần đầu nhìn thấy là một cửa sổ đầy.
  2. Mồi giá ở bậc đầu: nhãn giảm giá là 12 cho bậc 1 và 5 cho bốn bậc sau (step != 0 ? 5 : 12), và bậc 1 ở bản gốc bán $0.99. Rào cản vào chuỗi gần bằng 0; giá thật nằm ở bốn bậc sau (26k → 1.050k VND).
  3. Nút HUD đổi mặt sau lần mua đầu: hiện GIÁ khi chưa mua bậc 1, hiện ĐỒNG HỒ ĐẾM NGƯỢC sau đó. Người chơi thấy giá trước, thấy sức ép thời gian sau — chỉ sau khi đã bỏ tiền một lần. Đồng hồ vẫn chạy suốt, chỉ là chưa cho xem.

Phần thưởng cũng xếp thang: năm khẩu S → SS → SSS → X → XX, kèm kim cương {0, 200, 400, 600, 1000} — bậc 1 không kèm kim cương, tiền chỉ đổi lấy súng.

Chi tiết đo đạc, ba định dạng đồng hồ và ba trạng thái của một ô: docs/functions/stepup-offer.md.

7e. Nhãn giá — tự dựng chuỗi thay vì tin cửa hàng (đo trên máy 2026-09-07)

Vấn đề thật: ProductMetadata.localizedPriceString mà store trả về không đồng nhất — cùng một giá, hai máy hai vùng ra hai kiểu chữ khác nhau, và ô UI của bạn thì rộng cố định. Cat Gunner giải bằng cách bỏ hẳn chuỗi của store, chỉ lấy localizedPrice (Decimal) + isoCurrencyCode rồi tự định dạng.

FormatPrice_Display(decimal price, string iso):
    if iso == null: iso = ""
    s = price.ToString( ZERO_DECIMAL.Contains(iso) ? "#,0" : "#,0.00", InvariantCulture )
    if FORMAT.TryGetValue(iso, out fmt): return string.Format(fmt, s)   # "${0}" / "{0}₫" / "{0} zł"
    if iso != "":                        return iso + " " + s           # mã lạ: "CLP 4,990"
    return s

Ba điều đáng chép lại:

  1. Hai bảng RỜI NHAU. Bảng mẫu định dạng (23 mã) và bảng "tiền không có đơn vị lẻ" (6 mã: KRW JPY VND IDR CLP HUF) không trùng nhau: CLP/HUF chỉ có ở bảng sau. Gộp làm một là sai ngay hai đồng tiền. Nếu bạn viết test cho phần này, CLP 4990 → "CLP 4,990" là ca phân biệt.
  2. Ký hiệu trước hay sau là do MẪU, không do luật vùng. ${0} ₩{0} €{0} Rp{0} đứng trước; {0}₫ {0} zł {0} Kč {0} kr đứng sau. Thêm một đồng tiền = thêm một dòng, không phải thêm một nhánh if.
  3. Số luôn in theo InvariantCulture. Dấu phân cách nghìn là , kể cả trên máy tiếng Việt — cố ý, để chuỗi giá không đổi hình theo ngôn ngữ hệ thống.

Và một mẹo UI đáng giá hơn cả ba điều trên: component vẽ giá chỉ ghi đè text khi có giá; không có thì để nguyên. Nhờ vậy chuỗi đặt sẵn trong prefab ($4.99) trở thành fallback tự nhiên khi máy chưa nối được cửa hàng — không ô nào trống, không cần cờ "đang tải", không cần skeleton. Giá phải trả: khi làm QA bạn không phân biệt được bản có giá thật với bản chưa nối store bằng mắt — phải kiểm bằng test gọi thẳng hàm.

Chi tiết bảng, 33 nhãn và cách đo: docs/functions/gia-hien-thi-iap.md.

7f. Hai cách làm mới offer — nhịp CÁ NHÂN vs lịch CHUNG (đo trên máy 2026-09-07)

Cùng một game dùng cả hai, và chúng phục vụ hai mục đích khác nhau:

nhịp cá nhân (gói Core) lịch chung (DailyGun)
mốc Now − LầnMuaTrước >= 24h / 7 ngày khoá tuần yyyyMMdd của thứ hai
lưu gì một DateTime mỗi gói một chuỗi khoá + mảng lượt đã mua
người chơi thấy đồng hồ riêng của mình "cả làng đổi hàng sáng thứ hai"
hợp với gói tiêu hao mua đi mua lại danh mục xoay vòng, sự kiện đồng loạt

Nhịp cá nhân giữ người chơi quay lại đúng chu kỳ của họ và không cần server; cái giá là mỗi gói tốn một trường DateTime trong save, và phải Save() ngay lúc trao hàng — mất mốc là bán lại gói sớm hơn ý đồ. Lịch chung rẻ hơn về lưu trữ (một chuỗi khoá cho cả nhóm) và tạo được cảm giác sự kiện, nhưng phải xử lý ca "app mở qua nửa đêm" (Cat Gunner cho popup so lại khoá tuần trong coroutine đếm giờ và vẽ lại cả danh mục).

Ba mẹo cài đặt lấy nguyên từ bản gốc:

  1. "Đặc biệt" định nghĩa bằng THỨ TỰ ENUM, không bằng cờ: (uint)(type - Daily) < 3. Rẻ và không lệch dữ liệu — nhưng ràng buộc rằng gói mới phải chèn đúng chỗ.
  2. Hàm đếm ngược tự KẸP về 0 thay vì trả số âm, để mọi nơi gọi chỉ cần hỏi > Zero.
  3. Một cờ "mua được không" lái BỐN thứ trong ô hàng — tấm SOLD OUT, interactable của nút, nhãn 1/1 ↔ 0/1, và chuỗi đồng hồ. Đừng để bốn nhánh if rải rác đọc lại điều kiện: gọi một lần, giữ vào biến, lái cả bốn. (Phép thử QA phân biệt: bấm ô đang khoá phải không làm gì — nếu chỉ có ảnh mờ mà nút vẫn bấm được thì bạn đang thiếu interactable.)

Và một bài học sản phẩm: hai gói "xoá quảng cáo" của game này còn mở heo đất kim cương, và mua CẢ HAI thì heo chạy gấp đôi — thưởng chồng gói nằm trong code, không viết ở bảng giá nào. Đó là cách bán "tiện lợi" mà vẫn có giá trị dài hạn, nhưng nó cũng là thứ người chơi chỉ biết sau khi mua; nếu bạn chép mô hình này, hãy cân nhắc nói ra.

Chi tiết bảng SKU, bốn định dạng đồng hồ và cách đo: docs/functions/cua-hang-core.md.

7g. place — cái nhãn một chữ biến cửa hàng thành phễu đo được (đọc từ bản ship 2026-09-07)

Cat Gunner có 12 hàm Purchase_* cho chưa tới 10 SKU. Chúng gần như chỉ khác nhau một chuỗi:

Purchase_Banner(type)        → Purchase(type, "banner")
Purchase_BannerReward(type)  → Purchase(type, "banner_reward")
Purchase_RemoveBanner(type)  → Purchase(type, "popup")
Purchase(type, place = "")   → place rỗng thì "store"  (riêng Mine_Pack: "popup")
Purchase_StepUp(step, null)  → lấy StepUp.Open_Place, rỗng thì "banner_weapon"

Đó là cửa vào tự khai báo mình là ai. Người dựng UI không phải nhớ truyền tham số: gắn nút vào đúng hàm là xong, và mọi giao dịch đi kèm nhãn "người chơi bấm mua TỪ ĐÂU". Với dữ liệu ấy bạn trả lời được câu đắt nhất của monetization: banner trong màn chơi có bán được không, hay chỉ ai vào thẳng cửa hàng mới mua?

Ba chi tiết đáng chép:

  1. Phễu nối qua một biến trung gian, không truyền tay: công tắc tiện lợi bị khoá ghi WeeklyShop.Open_Place rồi mở cửa hàng; lúc bấm mua, Purchase_PlusPack mới ĐỌC lại biến đó. Nhờ vậy chuỗi place không phải xâu qua năm lớp UI. (StepUp làm y hệt với StepUp.Open_Place.)
  2. Mặc định phải nghĩ theo TỪNG SKU, không phải một hằng cho cả hệ: gói Mỏ mặc định "popup" vì nó không có ô nào trong cửa hàng — đặt "store" cho nó là tự làm bẩn số liệu.
  3. Đừng để hai đường tính cùng một chỉ số. Bản gốc có cả bảng tra ItemIdxFromProductId lẫn hằng viết thẳng trong hàm; hai đường khớp nhau ở mọi SKU trừ một: mua Luxury Pass qua cửa VIP ghi chỉ số 12, qua cửa Luxury ghi 19. Cùng một món, hai con số — báo cáo doanh thu theo chỉ số sẽ chia đôi mà không ai biết.

Mẹo QA đi kèm (bài học đắt của lô này): một nút "không phản ứng" có hai tầng nguyên nhân — handler của chính nút còn rỗng, hoặc handler gọi tới một hàm rỗng ở tầng dưới. Sửa tầng dưới mà nút vẫn im thì đừng nghi mã vừa sửa; đi ngược lên tầng handler trước.

7h. Trao thưởng IAP sao cho khôi phục giao dịch không cấp hai lần (đọc từ bản ship 2026-09-07)

Mọi hệ IAP đều phải chịu được việc cùng một giao dịch được gửi lại: người chơi cài lại máy, bấm "Restore purchases", hoặc store gửi lại một đơn chưa được consume. Cat Gunner giải bằng một khuôn hai dòng, đáng chép nguyên:

if (!Is_Buff_Pack)                 // phần THƯỞNG nằm trong guard
{
    Get_Dia(1000);
    SpawnDiaFx(...);
}
Is_Buff_Pack = true;               // phần QUYỀN nằm ngoài guard

Chạy lại lần thứ mười vẫn không cấp thêm kim cương, mà quyền vẫn được bật đúng. Sai lầm thường gặp là gói cả hai vào trong if (khôi phục xong mất quyền) hoặc cả hai ra ngoài (mỗi lần restore lại tặng tiền).

Ba mẹo nữa từ cùng bản ship:

  1. Trả false để HOÃN, đừng nuốt. Khi hệ thống nhận thưởng chưa sẵn sàng (Rebirth_manager chưa có, kho súng đầy…), hàm trao thưởng trả false + một LogWarning — nghĩa là chưa consume, store sẽ gửi lại lần chạy sau. Người chơi không mất tiền, bạn không phải viết hàng đợi riêng.
  2. Ưu đãi "mua lần đầu" là một phép dịch bit: reward[i] << (firstBuy ? 1 : 0). Rẻ, không cần bảng thứ hai, và cờ firstBuy chỉ được đặt trong nhánh đã trả thưởng.
  3. Gói "nâng cấp" có thể dùng LẠI nguyên hàm trao thưởng của gói gốc. Ở bản này luxury_upgrade và luxury_pass giống nhau từng lệnh — khác biệt chỉ ở giá và chỗ chào bán. Đừng nhân đôi mã trao thưởng chỉ vì SKU khác nhau.

Bảng thưởng đầy đủ của từng SKU: docs/functions/cua-hang-core.md (Core) và mục T11 của [F001: F014].

7i. Hạ tầng IAP: hàng đợi tới khi save sẵn sàng, và hai cuốn sổ (đọc từ bản ship 2026-09-07)

Ba lỗi kinh điển của một hệ IAP tự viết, và cách bản ship này chặn từng cái:

(a) Store trả đơn TRƯỚC khi save nạp xong. Trao thưởng lúc đó là ghi lên dữ liệu chưa tồn tại. Giải pháp không phải là "chờ bằng while", mà là đệm rồi xả:

_saveLoaded = false;                      // lúc khởi động
OnPurchasePending(order):
    if (!_saveLoaded) { _deferredOrders.Add(order); return; }
    ...
MarkSaveLoaded():                          // save nạp xong gọi đúng một lần
    _saveLoaded = true;                    // ĐẶT CỜ TRƯỚC
    var copy = new List<...>(_deferredOrders);
    _deferredOrders.Clear();               // XOÁ GỐC trước khi lặp
    foreach (var o in copy) OnPurchasePending(o);

Hai chi tiết nhỏ mà thiếu là hỏng: đặt cờ trước (callback đến giữa lúc xả sẽ đi thẳng, không lại vào đệm), và sao chép + xoá gốc trước khi lặp (nếu không, callback đẩy thêm vào chính danh sách đang duyệt).

(b) Xử lý một giao dịch hai lần. Cần HAI cuốn sổ, không phải một: _inflightTxns (HashSet — "đang xử lý", chặn chồng lệnh khi người dùng bấm nhanh hoặc callback tới hai lần) và ProcessedTxnIds (List — "đã xong", chặn store gửi lại đơn cũ sau khi cài lại). Gộp làm một là mất một trong hai bảo vệ.

(c) Sổ phình vô hạn. ProcessedTxnIds là hàng đợi có trần 50: thêm nếu chưa có, rồi cắt từ đầu. Đủ để chặn gửi-lại (store chỉ gửi lại đơn gần đây), mà save không lớn dần theo đời máy.

Thêm một luật bán hàng đáng chép: gói lớn bao trùm gói nhỏ. AlreadyOwned("remove_forced_ads") trả true khi người chơi đã có remove_all_ads — nên ô hàng nhỏ tự ẩn, không ai bị bán chồng. Chiều ngược lại không đúng, và đó cũng là chủ ý.

8. Segment — dựng tầng A/B test kiếm tiền (doc 10 §2.3, §7.1; bản dân dã: docs/functions/segment-lieu-ops.md)

Vì sao cần: không ai đoán đúng "thêm một quảng cáo ép thì lãi hay lỗ" — thêm quảng cáo tăng doanh thu hôm nay nhưng vay của ngày mai, và dấu của phép trừ đó khác nhau theo game, theo nước, theo nguồn cài. Cả thế giới tải một file APK, nên cách duy nhất để thử hai kiểu là dán nhãn lên người chơi rồi cho game tự đọc nhãn.

// L1 ANALYSIS — chuỗi thật (doc 10 §2.3):
Cheat_SetSegment(seg)        0x2A600CC   // nút QA: ép nhãn
    Segment = seg; SegmentCheatOverride = true            // static +0x8, +0x21
    PlayerPrefs "af_segment" = seg; "af_segment_cheat" = 1; PlayerPrefs.Save()
    segmentText.text = seg.ToString()
    ServerConfig_manager.Cheat_OnSegmentChanged()  0x2AC96D0
        OnConfigApplied?.Invoke()                          // UI liveops vẽ lại NGAY, không cần restart
        SaveSystem.Save(...)                               // nhãn không được mất vì app chết
ApplyServerSegment(seg)      0x2A6025C   // đường server
    if (SegmentCheatOverride) return;                      // 0x2a60320 — QA thắng server
    if (Segment == seg) return;                            // 0x2a60374
    // KHÔNG bật cờ QA, KHÔNG gọi Cheat_OnSegmentChanged

// Gợi ý framework (KHÔNG phải SOT) — hình dạng tối thiểu cho game của bạn:
AdConfig EmbeddedDefaults(int segment);   // luôn có, không gọi mạng, không ném lỗi; nhánh mặc định = TẮT
AdConfig Current { get; }                 // MỘT cửa duy nhất; đến từ server hay bản nhúng đều qua đây
event Action OnConfigApplied;             // phát mỗi khi Current đổi
string Current.reason;                    // "ok" | "client_embedded_default" | "default_rules_fallback"

Vòng đời một người chơi, đọc từ trên xuống — mỗi mũi tên là một chỗ có thể hỏng:

cài (từ chiến dịch X) -> SDK đo lường trả về SEGMENT -> ConfigReq(13 field) -> ConfigResp(15 field)
   -> [mất mạng? => EmbeddedDefaults(segment)] -> chơi: mỗi PLACEMENT hỏi bộ số "được chiếu không?"
   -> log mỗi lần chiếu/mua KÈM segment + rule_version -> sau 2 tuần so doanh thu/người & giữ chân D7
   -> đổi quy tắc phân khúc TRÊN SERVER (đợt quy tắc mới = rule_version mới), không build lại, không bắt cập nhật

Vì sao segment 0 gần như không có quảng cáo ép là quyết định đáng chép, không phải sơ suất:

  • hạ tầng ship đủ (6 loại placement, 28 điểm kích hoạt, OfferWall + FreeCash + súng thưởng) — không ai xây từng ấy rồi quên bật;
  • khoá bằng dữ liệu (99999) chứ không bằng cách gỡ code ⇒ mở lại từ xa trong một giây;
  • rewarded vẫn chạy, và không một field nào trong 12 số cấu hình khoá được rewarded (derived từ bảng field doc 10 §2.3a) ⇒ đây là chọn loại quảng cáo (giữ loại tự bấm, bỏ loại ép xem), không phải tắt quảng cáo;
  • interstitial còn miễn 4 lần cho người vừa xem/vừa boost/vừa mua (doc 10 §9.2) — cùng một tinh thần.

Vì sao họ chọn segment 0 cho người dùng này thì UNKNOWN — bằng chứng chỉ nói họ đã chọn thế, không nói lý do.

§7j — Cửa sổ phơi bày: mời trong MỘT KHOẢNG, không phải "từ màn X trở đi"

Offer kiếm-tiền-phụ (FreeCash / offerwall) của Cat Gunner không bật theo một mốc, mà theo một khoảng màn có sàn và có trần:

  • sàn freecash_stage_min — người mới chưa hiểu game thì chưa mời;
  • trần freecash_stage_max_hud — người chơi đã sâu thì thôi hiện HUD ngoài màn chính…
  • …nhưng banner trong Cửa hàng KHÔNG có trần: ai vào cửa hàng vẫn thấy.

Bài học chung: cùng một offer nên có ngưỡng KHÁC NHAU cho từng chỗ đặt. HUD là chỗ chen ngang nên phải biết rút lui; cửa hàng là chỗ người chơi tự tìm tới nên cứ để đó. Một cờ on/off duy nhất cho cả hai là đánh mất tầng điều tiết này.

§7k — Khoá bằng ngưỡng-không-thể-đạt thay vì cờ boolean

Bảng luật nhúng sẵn trong client dùng 99999 cho mọi ngưỡng của phân khúc bị tắt, thay vì một cờ enabled=false:

segment 1 (bật) segment 0 & 2 (tắt)
freecash_stage_min 15 99999
freecash_stage_max_hud 35 99999
offerwall_stage_min 10 99999
inter_stage_min 12 99999

Vì sao đáng học: cùng một đoạn mã phục vụ cả hai trạng thái. Không có nhánh if (disabled) nào để quên, không có tổ hợp "bật nhưng ngưỡng vô lý", và bật lại một phân khúc chỉ là đổi số — không phải đổi luồng. Đổi lại, phải nhớ rằng "99999" là ngôn ngữ chung của cả hệ, đọc log thấy 99999 là biết "tắt", không phải "cấu hình hỏng".

§7l — <= 0 từ máy chủ nghĩa là "không nói gì", KHÔNG phải "cấm"

int v = Config.freecash_stage_max_hud;
if (v > 0) return v;
return EmbeddedDefaults(Config.segment).freecash_stage_max_hud;   // rơi về bảng nhúng

Trần HUD có nhánh rơi-về, sàn thì không. Nếu getter trả thẳng 0 khi máy chủ im lặng thì tính năng chết câm vĩnh viễn (mọi màn đều > 0) — biên dịch sạch, không exception, không ai biết. Đây là bẫy R13 dạng cổng-chặn. Luật: mỗi giá trị cấu hình phải trả lời được "im lặng thì lấy gì?", và câu trả lời phải là một hằng có thật trong client, không phải default(T).

§7m — Nhãn phễu place: ghi TRƯỚC hành động, lưu xuống đĩa ngay

Mỗi banner mang Place_str riêng; khi bấm, game ghi nhãn vào PlayerPrefs trước khi mở panel, rồi mới khởi tạo SDK. Nhờ thứ tự đó, người chơi thoát giữa chừng và quay lại phiên sau thì lần chuyển đổi vẫn quy đúng về chỗ đã bấm. Không nhớ được thì quy về "auto" — có nhãn "không biết" tốt hơn để trống, vì báo cáo sẽ cộng đủ 100% thay vì rơi mất một phần.

§7n — "Hỏi cũng là ghi nhận": log lần-đầu-thấy nằm trong hàm điều kiện

TrySendPopLog() được gọi từ bên trong Hud_ShouldShow()/Banner_ShouldShow(), tức mỗi lần hệ thống hỏi "có nên hiện không" và câu trả lời là CÓ thì đồng thời ghi nhận "người này đã thấy". Gọn, nhưng có hai điều kiện sống còn: (a) cờ isPopLogSent nằm trong save nên chỉ gửi một lần cả đời máy; (b) có cổng IsSaveAllowed — bật cờ rồi Save() khi save chưa nạp xong sẽ ghi đè tiến trình bằng dữ liệu rỗng. Nếu bắt chước mẫu này, đừng bỏ cổng (b).

§7o — Thứ tự nhánh của cổng phơi bày là thiết kế, phải test được

(khối mã nội bộ đã lược bỏ) Cờ debug đặt sau chốt nghiệp vụ, nên bật cheat cũng không mời lại người đã nhận thưởng. Đây là loại lỗi mà một bản dựng sai vẫn chạy "trông đúng" — chỉ phát hiện bằng phép thử đảo hai chiều: giữ cheat bật, bật/tắt cờ đã-nhận, và kỳ vọng kết quả đổi theo cờ đã-nhận.

Bẫy đã gặp

  • Stub trả "thành công" = game tự trao thưởng cho chính nó. 10 AD_Type + 34 product id đan khắp tiến trình; một stub sai làm mọi phép đo parity vô nghĩa một cách âm thầm. Fail-closed = sai theo hướng an toàn và quan sát được (doc 07 §3.6; doc 10 §9.3; R1).
  • Segment mặc định tắt ads. EmbeddedDefaults(segment ≠ 1) = 99999/0, và production trả segment 0 cho user thật ⇒ toàn bộ hạ tầng IAA ship nhưng chạy 0 quảng cáo. Một bản restore không có server sẽ không bao giờ hiện ads dù có SDK (doc 10 §7.1).
  • Quên bản nhúng ⇒ mất mạng là game hỏng. Không có EmbeddedDefaults, server im lặng 5 phút là màn loading treo — hoặc tệ hơn: ngưỡng thiếu bị hiểu là 0 nên quảng cáo nổ khắp nơi từ stage 1. Quy tắc: thiếu ngưỡng = 99999 (khoá), thiếu trần ngày = 0; nhánh mặc định của bản nhúng phải là nhánh tắt sạch (doc 10 §2.3).
  • Đổi segment giữa phiên mà không phát sự kiện ⇒ UI sai. Ép sang segment 1 xong nút OfferWall vẫn chưa hiện, phải tắt app mở lại mới thấy — rồi đi sửa nhầm chỗ. SOT phát OnConfigApplied ngay trong Cheat_OnSegmentChanged (0x2AC96D0) đúng vì lý do này.
  • Cờ ép không thắng server ⇒ QA test một đằng ghi kết quả một nẻo. ApplyServerSegment return ở dòng đầu khi cờ bật (0x2a60320). Kèm theo: phải có đường về (xoá PlayerPrefs["af_segment_cheat"]), nếu không máy QA vĩnh viễn không nhận cấu hình thật nữa.
  • Nhãn segment nằm trong file save ⇒ nhảy nhóm. Reset tiến trình / khôi phục save cũ là đổi nhóm giữa chừng, phép đo A/B hỏng âm thầm. SOT để nhãn ở PlayerPrefs, tách khỏi save, đọc lại mỗi Awake (0x2A5FD78).
  • Không biết config đang đến từ đâu. Ba giá trị reason (ok / client_embedded_default / default_rules_fallback) là thứ tiết kiệm hàng giờ debug — nhìn một chữ biết ngay server trả hay bản nhúng chạy (doc 10 §2.3).
  • Early-game oracle không thấy monetization. Mọi nút liveops (StepUp / WeeklyShop / DailyGun / OfferWall / FreeCash) là active = 0 trên save thật; chúng bật bởi tiến trình × segment × hành vi × lịch (doc 10 §2.4, §7).
  • Đếm nhầm product id từ string literal. LIVEOPS_SURFACE §6.1 liệt kê 24 id nhưng 8 chuỗi trong đó là place/khoá analytics; danh sách thật là 34 id từ ItemIdxFromProductId (doc 10 §2.2). Xref literal, đừng đếm chuỗi.
  • Không suy ngưỡng từ tên key. freecash_stage_min = 15 là stage hiển thị (Stage_Value_Max/10 + 1), không phải Stage_Value (UNLOCK_GATES §1).
  • Giá không nằm trong build. Mọi con số đồng là giá store VN lúc chụp (Localized_Price_return); đừng hardcode giá vào tài liệu như hằng.
  • Buff không phải danh sách instance. Cộng dồn có trần (300/900); List<ActiveBuff> sẽ cho tích trữ vô hạn (doc 07 §3.4).
  • Interstitial ngay sau người vừa trả tiền. SOT miễn interstitial 4 lần: !_bonusAdWatched, !Is_RewardBonus_Auto, !boosted, !reviewOpened — cùng một tinh thần "lịch sự với người vừa trả" (doc 10 §9.2).
  • Hai nhánh code không thể đạt tới (đặt cờ rồi mới kiểm !cờ): ShowRewardAd(8) trong UpgradeRebirth_Start_By_IAP, interstitial trong Monster_manager.SweepBonus_Get — đọc disasm mà không thấy điều này sẽ đếm dư placement (doc 10 §10).
  • Biểu tượng quảng cáo hiện theo NGƯỠNG thay vì theo DẢI. used >= FREE để lại một biểu tượng chết sau khi hết quota: nút hứa quảng cáo nhưng chỉ trả về thông báo lỗi. Đúng là (uint)(used − FREE) < adSlots — một phép trừ + một so sánh unsigned bắt trọn cả hai biên (§1b).
  • Kiểm tiền trước khi kiểm kho đầy. Nhánh trả tiền phải kiểm kho đầy TRƯỚC, nếu không người chơi mất tiền mà không nhận được gì. Đây là thứ tự thật trong SOT, không phải tuỳ chọn.
  • Không kiểm lại điều kiện trong nhánh Granted. Giữa lúc xem quảng cáo 30 giây, kho có thể đã đầy vì một đường khác. Hàm cấp thưởng phải kiểm lại trần kho ở dòng đầu (§1b), dù người gọi đã kiểm rồi.
  • Đánh dấu "đã phát" trước khi phát. Ghi savedDate = Today trước khi qua cổng chống chỉnh giờ ⇒ người chơi mất trắng quota hôm đó, không có lỗi nào hiện ra (§7b).
  • Lấy bảng tỉ lệ gacha từ constructor thay vì từ scene. Field serialize thì scene thắng; trang này từng publish bảng {40,30,20,10} của ctor trong khi bản ship dùng {60,30,10,0} (§1b). Kiểm loại field trước khi tin con số, và đặt cạnh mỗi bảng tỉ lệ một phép thử tần suất rẻ (N lượt liên tiếp ra cùng bậc: xác suất bao nhiêu với mỗi bảng?) — đó là thứ bắt được sai lệch khi không đọc được dữ liệu gốc.

Mặt hàng phải tự biết mình đã được mua (2026-09-12)

Mọi ô chào bán trong bản gốc đều theo đúng MỘT khuôn, và khuôn này đáng chép nguyên:

OnEnable:   VeLai();  Shop.OnInAppPurchaseChanged += VeLai;
OnDisable:  Shop.OnInAppPurchaseChanged -= VeLai;
VeLai():    bool daCo = Shop.DaMua(loaiHang);
            oDaMua.SetActive(daCo);
            nutMua.SetActive(!daCo);

Ba điểm cốt lõi:

  • Một event TĨNH duy nhất cho cả game. Mua ở đâu cũng phát; mọi mặt hàng đang mở tự vẽ lại. Không cần ai đi gọi tay từng panel, và không panel nào bị bỏ sót.
  • Vẽ ngay trong OnEnable rồi mới đăng ký. Panel mở sau khi mua vẫn đúng, vì nó tự hỏi lại trạng thái lúc bật chứ không chờ event kế tiếp.
  • Gói có hai giá thì để HAI object giá, bật/tắt theo điều kiện nâng cấp (ví dụ: đã có gói thường thì hiện giá NÂNG CẤP rẻ hơn), đừng ghi đè chữ vào một ô.

Bẫy nặng, rất dễ mắc: nếu hàm vẽ lại chưa được viết, ô chào bán giữ nguyên trạng thái vẽ sẵn trong scene. Trong bản gốc, trạng thái vẽ sẵn là "đã mua" bật + nút mua bật ⇒ dấu tick "đã mua" nằm ĐÈ lên giá, và game bày bán gói mà người chơi đã sở hữu. Hãy đặt mặc định trong scene là trạng thái CHƯA mua, và thêm một phép thử "bật cờ đã-mua rồi mở panel" vào danh sách nghiệm thu.

Checklist khi tự làm

  • [ ] Một enum loại phần thưởng quảng cáo (như AD_Type); buff theo thời gian tách khỏi phần thưởng một lần.
  • [ ] Mỗi thứ IAA trao đều có bản IAP vĩnh viễn tương ứng — quảng cáo là bản dùng thử của cửa hàng.
  • [ ] Buff = thời gian còn lại có trần (+300 s, max 900 s) + IsPermanent cho IAP; buff nằm trong chuỗi nhân có cache ⇒ invalidate khi đổi.
  • [ ] Rewarded: một điểm trao thưởng duy nhất trong coroutine của game; đường "đã mua miễn QC" tái dùng đúng điểm đó.
  • [ ] Interstitial: chuỗi gate rẻ → đắt (build flag, entitlement, reentrancy, stage min, cooldown, cap theo place, cap toàn cục, lịch sự); gate fail ⇒ onClosed() ngay, không bao giờ chặn luồng.
  • [ ] Chỉ đặt interstitial ở "đóng màn kết quả", không giữa trận; miễn cho người vừa xem rewarded / vừa boost / vừa thấy popup review.
  • [ ] Bộ đếm quảng cáo ở PlayerPrefs, tách khỏi save; reset theo chuỗi ngày.
  • [ ] Quota ngày kiểu "rút thưởng": chốt 3 con số (MAX lượt, FREE lượt không quảng cáo, giá nút trả tiền) trước khi viết; một bộ đếm cho cả hai đoạn; biểu tượng quảng cáo hiện theo dải (uint)(used − FREE) < MAX − FREE (§1b).
  • [ ] Nút trả tiền của cùng bề mặt mua sàn cao hơn trên cùng một bảng tỉ lệ, không phải một bảng tỉ lệ riêng — thêm hạng nút = Roll(base + 1).
  • [ ] Bảng tỉ lệ sống ở dữ liệu (scene/asset/remote), không ở constructor; ghi rõ nguồn ngay cạnh bảng, và có một phép thử tần suất để phát hiện bảng tài liệu trôi khỏi bảng chạy thật (§1b).
  • [ ] Mọi reset theo ngày đi qua một khuôn: giờ đã hiệu chỉnh + sổ nợ có trần + giữ nguyên ngày cũ khi bị chặn + kẹp ngày ở tương lai (§7b).
  • [ ] Remote config chỉ chứa số placement (15 số kiểu ConfigResp), có bản nhúng fail-safe mặc định tắt; không bao giờ chứa số kinh tế.
  • [ ] Lộ diện = f(tiến trình) × f(segment), có cả trần trên cho offer chuyển đổi kém ở late-game.
  • [ ] IAP: một điểm trao idempotent, hoãn được khi hệ đích chưa sẵn sàng; prefix hoá sản phẩm có chỉ số; giá đọc từ store.
  • [ ] Non-consumable là cờ; entitlement chảy ngược vào tầng quảng cáo qua một vị ngữ (Is_RewardBonus_Auto).
  • [ ] Offer theo hành vi (ghép súng lần đầu ⇒ súng thử 30 phút) bên cạnh offer theo stage và theo lịch.
  • [ ] IAdService / IPurchaseService / IRemoteConfigService với NullService mặc định: không bao giờ trả thành công / owned / bật ads.
  • [ ] Liệt kê hết placement trước khi viết code; mỗi placement cần đúng 3 số (ngưỡng tiến trình, cooldown, trần ngày) + ngưỡng tối đa nếu có cửa sổ.
  • [ ] Viết EmbeddedDefaults(segment) trước code gọi mạng; kiểm bằng chế độ máy bay: cài mới, chơi 10 phút, không quảng cáo ép nào, không treo.
  • [ ] Một cửa duy nhất đọc cấu hình (Current) + một sự kiện OnConfigApplied; đổi segment lúc đang chơi ⇒ UI liveops vẽ lại trong cùng khung hình.
  • [ ] Nhãn segment ở kho riêng (không trong save), đọc lại lúc khởi động, mặc định 0; ghi xuống đĩa ngay trong hàm đổi.
  • [ ] Nút ép segment cho QA + cờ ưu tiên thắng server, và một đường về xoá cờ.
  • [ ] Mọi payload mang reason + rule_version; mọi log chiếu/mua kèm segment — không có hai trường này thì dữ liệu không chia nhóm được.
  • [ ] Mỗi lần thử chỉ đổi một con số giữa hai nhóm; chốt thời gian chạy và ngưỡng quyết định (doanh thu/người và giữ chân D7) trước khi nhìn số.
  • [ ] Không A/B test giá — chỉ test tần suất mời. Cat Gunner không có field giá trong 15 key config.

Điều chưa biết (UNKNOWN)

Chép từ doc 10 §10 (kèm cách đóng):

  • Toàn bộ hành vi IAA lúc chạy (cooldown thật, cap ngày thật, thứ tự popup): UNKNOWN-pending-live — user thật ở segment 0 nên chưa từng thấy một quảng cáo nào. Đóng bằng phiên probe ở segment 1, hook Inter_Placement_Able 0x2A5CD34 + Inter_CountUp 0x2A5D644 + ShowRewardAd 0x2A59294 (G178).
  • Giá VND của 25/34 product id: UNKNOWN (chỉ 9 giá quan sát được). Đóng: mở đủ panel shop trên bản store hoặc hook Localized_Price_return 0x2B367D4.
  • Cửa sổ hiện thật của StepUp / WeeklyShop / DailyGun / PlusPack: UNKNOWN — 5 nút HUD đều active = 0 trên save user. Đóng: segment 1 + đẩy Stage_Value_Max qua 190 và 988.
  • PlusPack.Is_Banner_Stage_OK (0x2B2C6AC) so sánh chính xác cái gì: UNKNOWN (IDA gộp biên hàm; chỉ hằng Banner_Stage_Min = 190 là metadata-proven).
  • "Core" (Adventure_manager.Core_Get): ĐÃ ĐÓNG phần lớn (doc 10 §6.1a) — tiền tệ Trip, 4 ô Forest/Desert/Tundra/ALL, kiếm 20/giờ đi Trip, tiêu vào cây Ability; IAP core_* nạp ô ALL.
  • ~~StepUp.Gun_Rewards là khẩu nào~~: ĐÃ ĐÓNG (derived từ Gun_list scene — doc 11 §3.1): 5 bậc StepUp = 5 khẩu S → XX.
  • catgunner_luxury_pass vs catgunner_luxury_upgrade: bằng chứng phủ định — thân hàm giống hệt; khác biệt (nếu có) nằm ở giá / điều kiện hiển thị.
  • vip_pass vs vip_pass_2: cùng chỉ số 12, cùng ApplyVipPass — nhiều khả năng đổi giá/SKU, chưa kiểm store listing.
  • Chỉ số offer 31–35: bằng chứng phủ định — không product id nào trả về.
  • Admob.ShowRewardAd(int) (0x2A59C04) nút nào gọi: UNKNOWN — 0 code-xref ⇒ nối từ scene bằng UnityEvent.
  • Cơ chế kích hoạt Season reset và backend Redeem code: UNKNOWN (kế thừa LIVEOPS_SURFACE §7).
  • Nội dung config cho segment 1 và 2 thật trên production: UNKNOWN — chỉ mới thấy segment 0. Cái đã biết là bản nhúng: EmbeddedDefaults chỉ có hai nhánh (== 1 và ≠ 1) nên bản nhúng của segment 2 trùng segment 0 (disasm, doc 10 §2.3a); cái server trả cho segment 2 thì chưa ai thấy. Đóng: ép segment 2 rồi đọc lại ConfigResp.
  • Quy tắc phân khúc phía server (ai được gán 0, ai được gán 1): UNKNOWN và nhiều khả năng không đo được từ phía client — nó sống trên máy chủ nhà phát hành. Cái đo được là 13 field ConfigReq client gửi lên, tức danh sách thứ có thể đã dùng để chia nhóm, không phải công thức.
  • Tỉ lệ rơi gacha (Gun_manager.DrawRandomGun 0x2B0A278): UNKNOWN, chưa hề được đọc (doc 07 §5).
  • Đã đóng (ghi để khỏi tìm lại): G144 — Gun_manager.Balance_Reload / Request_Sheet 0 lần fire qua 3 phiên ⇒ bảng súng không remote-driven (doc 10 §10); PlayFab GetTitleData 0 caller; không field kinh tế nào trong 15 key ConfigResp (tĩnh và payload production).

Đọc thêm

  • docs/framework/10-monetization-iaa-iap.md — toàn bộ, đặc biệt §4 (28 điểm kích hoạt), §6.1 (34 product id), §8 (phễu), §9 (framework)
  • docs/functions/segment-lieu-ops.md — segment giải thích dân dã: ví dụ đời thường, vòng đời một người chơi (cài → attribution → cấu hình → placement → đo → chọn), ba tầng segment/ServerConfig/placement, 12 cạm bẫy, 13 bước "nếu bạn muốn tự làm"
  • docs/framework/07-progression-managers.md §2.4, §3.4, §3.6 — AD_Type, Buff_manager, ranh giới R1
  • docs/framework/09-prestige-rebirth.md §3.5 — rebirth tăng cường và call-site R1
  • docs/framework/01-economy-money.md §3.2, §3.6 — nhánh ×3 và quy tắc fail-closed
  • [liveops catalog: LIVEOPS_SURFACE.md] §2–§6 — 15 key ConfigResp, EmbeddedDefaults, Firebase RC, lịch offer
  • [liveops catalog: UNLOCK_GATES.md] §1, §3 — stageDisplay, cổng Rebirth-IAP, PlusPack, StepUp, WeeklyShop
  • [phân tích nội bộ] — U2 (boost kind), U4 (liveops không ghi đè kinh tế)
  • 03 Prestige / Rebirth · 10 Tiến trình & mở khoá · 02 Kinh tế & số lớn
  • gap list (nội bộ) — G008, G141, G143, G144, G145, G149, G178

§7p — Nguồn thụ động theo thời gian: hai đồng hồ, và "đầy thì ngừng đếm"

Heo đất kim cương (DiaPig) là mẫu chuẩn cho mọi thứ "đẻ tài nguyên mỗi N giây, có trần": 1. Đo thời gian bằng HAI đồng hồ và tin cái nhỏ hơn khi chúng đồng ý (lệch ≤ 300 s). Giờ tường sống qua tắt app nhưng vặn được; giờ đơn điệu không vặn được nhưng chết theo tiến trình. Lệch lớn ⇒ chỉ tin đơn điệu, trừ khi có cơ chế catch-up offline có hạn mức (PendingCount ≤ 2) — người chơi offline thật vẫn được bù, kẻ vặn giờ thì không. 2. Giữ phần dư giây khi cộng tick (mono_anchor = now − (elapsed − 60·ticks)); làm tròn mất là mỗi lần mở app người chơi thiệt vài giây, tích lại thành cảm giác "heo này lười". 3. Trần ⇒ ngừng đếm ⇒ lấy từ heo ĐẦY mới neo lại đồng hồ. Neo mỗi lần lấy là mất phần dư; không neo khi đầy là tick kế tới tức thì. Hai lỗi ngược nhau, cùng một dòng if (n >= Max) SetLastTick(now). 4. Chồng gói = gấp đôi tốc độ (TickAmount = forced + all, 0/1/2) — phần thưởng nằm cứng trong binary, không có ở bảng giá nào; đó là lý do thật để mua gói thứ hai.