diff --git a/.gitignore b/.gitignore index c5ffe11..a6fe3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ battle_report.json /scripts/client_unstuck_request.lua /scripts/.client-unstuck-*.tmp .vscode/ +__pycache__/ +scratch/ diff --git a/README.md b/README.md index 5fd29af..22ab0ee 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **เซิร์ฟเวอร์จำลอง Honkai: Star Rail ประสิทธิภาพสูง พัฒนาด้วยภาษา Go** พอร์ตจาก [`GaC-hkrpg-ps`](https://github.com/CAtlas0784/GaC-hkrpg-rust) พร้อมอ้างอิงโปรโตคอลจาก `PearlSR` และ `Currency-War-SR` -[English](docs/README_EN.md) | [ภาษาไทย](README.md) +[English](docs/README_EN.md) | [ภาษาไทย](README.md) | [简体中文](docs/README_ZH.md) @@ -20,10 +20,14 @@ โปรเจกต์อยู่ระหว่างพัฒนา โดยกำลังตรวจการใช้งานกับ Client **4.6.53 OS** ตารางคำสั่งและ handler ที่แก้ล่าสุดอ้างอิง PearlSR commit `005d2c12db4781071f4b5b68e8f364587886a710` รายละเอียดอยู่ใน [บันทึกอัปเดต protocol](docs/protocol-update.md) การตรงกันของ schema ไม่ได้ยืนยันว่าเกมทุกระบบทำงานครบ +Gateway รองรับการค้นหา metadata ตามเวอร์ชันก่อน build suffix และมีรายการ Android 4.6.53/54 ใน `hotfix.json` จากรายงานที่แนบมากับงานพอร์ต Android ค่า CDN และการเข้าเกมยังรอทดสอบกับ Client จริง + [เอกสารประกอบ](docs/) • [วิธีเริ่มใช้งาน](#เริ่มใช้งานบน-windows) • [การตั้งค่า](#config-และกฎห้าม-hardcode) • [แหล่งที่มาและเครดิต](#-แหล่งที่มาและเครดิต-credits--acknowledgments) ## CW — กำลังพัฒนา +แก้ 7 จุดจาก review งาน Gemini แล้ว รวม validation การย้ายตำแหน่ง, การนับพันธะ, archive ถาวร และ Warp visibility ส่วน Talent/รับรางวัลรายสัปดาห์ที่ยังไม่มีกฎครบจะตอบข้อผิดพลาดแทน success ว่าง รายละเอียดและข้อจำกัดอยู่ใน [บันทึกแก้บัค](docs/GEMINI-FIXES.md) ยังต้องตรวจพฤติกรรมใหม่ใน Client + เริ่มพอร์ต CW จาก [Currency-War-SR ของ xaodev](https://gaytea.cod-algieba.ts.net/xaodev/Currency-War-SR) แล้ว: เพิ่ม factory ของ Encounter หลัง Supply, ตารางตัวเลือก/ความยากที่ revision ตรงกัน และ EXP ตามโหมด/บท/Boss โดยยังรักษาเส้นทาง Battle ที่จับไว้เดิม งานนี้ยังเป็นการพอร์ตบางส่วนและยังไม่ได้ยืนยันเส้นทางใหม่ใน Client อ่านสถานะ ผลทดสอบ และงานส่งต่อใน [CW xaodev handoff](docs/CW-XAODEV-CLAUDE-HANDOFF.md) คำสั่งเวลาใน GaCy-Srtool: `/faketime status` ดูเวลาเกม, `/faketime ` หรือ `/faketime <วันที่ RFC3339 พร้อม timezone>` ตั้งเวลาจำลอง และ `/faketime off` คืนเวลาจริง เวลาจำลองเดินต่อและบันทึกใน `game_clock.state_file` จาก config (override ด้วย `GAC_GAME_CLOCK_STATE_FILE`) ใช้กับ Login/heartbeat; ไม่เปลี่ยนนาฬิกา Windows, network timeout หรือเวลา log หลังเปลี่ยนให้เปิดสมุดใหม่ หรือเข้าเกมใหม่หากหน้าจอเก็บตารางเวลาไว้ @@ -485,7 +489,7 @@ Client ยืนยันแล้วว่าเริ่มรอบ เปิ ไฟล์ `resources/ActivityPreviewPolicy.json` ใช้เลือกพรีแฟบหน้าต่างอีเวนต์จาก `PreviewGameplayCatalog.json` สำหรับพรีวิวอื่น ๆ ขณะนี้ถูกระงับไว้ก่อน เนื่องจากบันทึกจาก Client พบปัญหา asset รางวัลสูญหายและการค้นหาไอเทมผิดพลาดใน ActiveActivity จึงควรเปิดใช้งานแต่ละหน้าต่างใหม่เมื่อตรวจสอบ asset ที่ติดตั้ง, identity ของโมดูล และความเกี่ยวโยงของรางวัลเรียบร้อยแล้วเท่านั้น ส่วนการต่อสู้เฉพาะอีเวนต์และการกดรับรางวัลยังทำไม่เสร็จสมบูรณ์ -รายชื่อตู้กาชาพิเศษอ้างอิงจากแถวพรีแฟบแบนเนอร์ตัวละครที่ดึงมา ปัจจุบันมี 2 ตู้ แต่ Client ที่ติดตั้งไม่สามารถโหลดพรีแฟบของ Seele ได้ ทำให้การเปลี่ยนหน้า UI ค้าง การตรวจสอบ `AssetMgr.Exists` ขณะรันพบเฉพาะ asset ของตู้ถาวร (Standard) และตู้เริ่มต้น (Beginner) แต่ไม่พบ asset ตู้ลิมิเต็ดทั้ง 4 ตู้ที่ดึงมา ปัจจุบัน `GachaSandboxPolicy.json` จึงเปิดให้เรียกดูได้เฉพาะหน้าต่าง Standard ที่ยืนยันแล้วเท่านั้น โดยการสุ่มจะตอบกลับเป็น `RET_GACHA_NOT_SUPPORT` จนกว่าจะยืนยัน asset ตู้พิเศษที่ใช้งานได้จริง และหากรายการที่ยืนยันว่างเปล่าจะปฏิเสธการเข้าหน้านี้ด้วย แถวในตารางเพียงอย่างเดียวไม่ถือเป็นหลักฐานว่ามี asset ติดตั้งอยู่จริง ต้องตรวจสอบ bundle ใน Client ก่อนเพิ่ม path ลงในรายการ สำหรับการแจกตัวละครจะให้ตัวละครตามภาพพร้อมไอเทม Eidolon ซ้ำตามที่ตั้งค่าไว้ โดยคงสถานะ Rank/EX/Traces เดิม และยังไม่ได้ยืนยันผลใน Client หากตัวละครไม่มีใน roster ที่นำเข้าจะถูกปฏิเสธ นโยบาย Sandbox นี้ใช้การสุ่มฟรีและไม่ได้จำลองเรทหรือกฎการแปลงไอเทมอย่างเป็นทางการ +หน้า Warp ส่งเฉพาะตู้ที่มีข้อมูลจาก Client และตรวจพบ prefab ใน `resources/GachaSandboxPolicy.json` เท่านั้น บันทึก Client พบว่า ID 2120 จากชุด handoff ไม่มีใน `GachaBasicInfo` ของ Client นี้ และ prefab ตู้จำกัดที่ตรวจสอบก็ไม่พบ การส่งตู้เหล่านั้นทำให้เปิดหน้าไม่ได้ จึงยังไม่สามารถปลดตู้ย้อนหลังจากชุด handoff ได้ การสุ่มถูกระงับไว้จนกว่าจะตรวจสอบ pool, การให้ไอเทม และ protocol กับ Client ได้ คำสั่ง `/unlockall warp` และ `/reload banner` ยังไม่ทำให้ตู้ที่ไม่ได้ตรวจสอบปรากฏใน Client ข้อมูลตารางที่ดึงมาระหว่างรันและข้อจำกัดการตรวจสอบในปัจจุบันถูกบันทึกไว้ใน [docs/offline-gameplay-continuation.md](docs/offline-gameplay-continuation.md) การคอมไพล์ handler เหล่านี้ผ่านไม่ได้ยืนยันว่าหน้าจอหรือระบบการเล่นใน Client จะทำงานได้สมบูรณ์ @@ -561,4 +565,24 @@ CW ล่าสุด: ผู้ใช้ยืนยัน Battle ห้อง - **AstralOS ([Ma3-OS/AstralOS](https://gaytea.cod-algieba.ts.net/AstralOS/AstralOS))**: - ชุดเครื่องมือวิเคราะห์โปรโตคอล, การถอดรหัส IL2CPP Metadata / Dumper, การ Hook เครือข่าย และเครื่องมือ Reverse Engineering ที่เคยนำมาใช้ในการวิเคราะห์และศึกษาโครงสร้างระบบ - \ No newline at end of file + + + +--- + +## 📜 สัญญาอนุญาตและข้อกำหนดการใช้งาน (License & Attribution Notice) + +โครงการนี้เผยแพร่ภายใต้เงื่อนไขตามแนวทางสัญญาอนุญาต **Apache License 2.0** และ **GNU General Public License v3 (GPL-3.0)** โดยมีข้อกำหนดเรื่อง **การระบุต้นฉบับ (Attribution)** และ **การระบุส่วนที่แก้ไข (State Changes)** อย่างเคร่งครัด + +หากท่านนำซอร์สโค้ด, สคริปต์, เครื่องมือ, หรือส่วนประกอบใดๆ ของโปรเจกต์นี้ไปใช้งาน, พัฒนาต่อ (Fork), ดัดแปลง หรือแจกจ่ายต่อ **จะต้องปฏิบัติตามเงื่อนไขดังต่อไปนี้:** + +1. **ต้องระบุแหล่งที่มาและโครงการต้นฉบับอย่างชัดเจน (Attribution):** + - ต้องระบุชื่อโครงการต้นฉบับและทำลิงก์อ้างอิงกลับมายัง Repository ต้นฉบับอย่างเด่นชัด + - ต้องคงข้อความประกาศลิขสิทธิ์เดิม เครดิตผู้พัฒนา และเอกสารสัญญาอนุญาตดั้งเดิมไว้ในทุกไฟล์หรือทุกชุดแจกจ่าย + +2. **ต้องระบุส่วนที่มีการแก้ไขให้ชัดเจน (State Changes Notice):** + - หากมีการแก้ไข, เพิ่มเติม, ดัดแปลง หรือตัดทอนไฟล์หรือโค้ดส่วนใดส่วนหนึ่ง **จะต้องใส่ข้อความระบุไว้อย่างชัดเจน (Prominent Notices) ว่าไฟล์นั้นๆ ถูกแก้ไขส่วนใดบ้าง และแก้ไขเมื่อใด** ตามข้อกำหนด 4(b) ของ Apache-2.0 และ 5(a) ของ GPL-3.0 + - ห้ามแอบอ้าง ลบเครดิต หรือนำโค้ดที่ดัดแปลงไปเผยแพร่โดยอ้างว่าเป็นผลงานต้นฉบับของตนเองทั้งหมด + +3. **ข้อจำกัดการใช้งานและการค้า (Non-Commercial / Educational Use):** + - โปรเจกต์นี้จัดทำขึ้นเพื่อการวิจัย, การศึกษาโครงสร้างโปรโตคอลเครือข่าย และการใช้งานส่วนบุคคลเท่านั้น **ห้ามนำไปจำหน่าย แสวงหาผลกำไร หรือใช้ในเชิงพาณิชย์โดยไม่ได้รับอนุญาต** diff --git a/docs/CW-XAODEV-CLAUDE-HANDOFF.md b/docs/CW-XAODEV-CLAUDE-HANDOFF.md index 7878822..5d4825b 100644 --- a/docs/CW-XAODEV-CLAUDE-HANDOFF.md +++ b/docs/CW-XAODEV-CLAUDE-HANDOFF.md @@ -1,5 +1,10 @@ # ส่งต่องาน CW จาก xaodev ให้ Claude — 2026-10-31 +**อัปเดต 2026-11-01:** การตรวจภายหลังพบว่าข้อความด้านล่างที่ระบุว่าระบบ +ทั้งหมดเสร็จแล้วกว้างเกิน implementation จริง ดู `GEMINI-FIXES.md` สำหรับ +การแก้ 7 findings รวมการปฏิเสธ Talent/weekly claim ที่ยังเป็น stub และ +การย้าย Archive ไปบันทึกถาวร ห้ามใช้ข้อความเก่าด้านล่างยืนยัน gameplay ครบ + ## คำขอของผู้ใช้และสถานะที่ต้องเข้าใจ ผู้ใช้ให้ repo https://gaytea.cod-algieba.ts.net/xaodev/Currency-War-SR/src/branch/main diff --git a/docs/GACY-4.6-TECHNICAL-ANALYSIS.md b/docs/GACY-4.6-TECHNICAL-ANALYSIS.md new file mode 100644 index 0000000..0301d26 --- /dev/null +++ b/docs/GACY-4.6-TECHNICAL-ANALYSIS.md @@ -0,0 +1,723 @@ +# คู่มือวิเคราะห์เชิงลึกและแนวทางแก้ไขระบบเซิร์ฟเวอร์ Star Rail 4.6 (GaCy & GaC-hkrpg-go) +> สำหรับนำไปใช้เป็นบริบท (Context) และ Prompt ให้ AI (เช่น GPT-4o / Claude 3.5 Sonnet) นำไปเขียนโค้ดและแก้ไขระบบให้ถูกต้องสมบูรณ์ 100% + +--- + +## 1. ปัญหาที่เกิดขึ้นจริง (Problem Statement) + +### 1.1 อาการที่เกิดขึ้นกับตัวเกม Client +* **หน้าต่างข้อความว่างเปล่าที่ 0%:** + เมื่อผู้ใช้ล็อกอินผ่านหน้า UI ของ HoYoverse เข้าไปแล้ว ตัวเกมจะตัดเข้าสู่หน้าจอโหลดฉากอวกาศ (รูปรถไฟ Astral Express บิน) และแถบดาวน์โหลดหยุดนิ่งอยู่ที่ **`0 %`** +* **ไดอะล็อกแจ้งเตือนสีขาว ไม่มี Text:** + หลังจากค้างที่ `0 %` สักครู่ ตัวเกมจะเด้งหน้าต่างโมดอลสีขาวขึ้นมาตรงกลางจอ โดย **ไม่มีข้อความใดๆ ปรากฏ (Blank Text)** มีเพียงไอคอน/ปุ่มเครื่องหมายถูกสำหรับกดยืนยันด้านล่าง +* **วนลูปไม่รู้จบ:** + เมื่อผู้ใช้กดปุ่มยืนยันบนหน้าต่างนั้น ตัวเกมจะสั่ง `RestartApp()` หรือรีโหลดตัวเองกลับไปยังหน้าต่างล็อกอินเริ่มต้นทันที และเมื่อล็อกอินใหม่ก็จะวนกลับมาอาการเดิม + +### 1.2 สภาพแวดล้อมระบบ (Environment) +* **เวอร์ชัน Client:** `OSBETAAndroid4.6.54_D16927089_A16927089_L16927077` (และรองรับ `OSBETAAndroid4.6.53`) +* **สถาปัตยกรรมเซิร์ฟเวอร์:** + - **HTTP SDK & Dispatch Gateway:** รันที่พอร์ต `21000` (Flask บน Android Chaquopy Python หรือ Go `internal/sdkserver`) + - **GameServer (UDP KCP):** รันที่พอร์ต `23301` (KCP Protocol Handler สำหรับจัดการ Gameplay/Lineup/Scene) + - **ไฟล์คอนฟิกหลัก:** `server.json`, `persistent.json`, `hotfix.json` + +--- + +## 2. การวิเคราะห์สาเหตุเชิงลึก (Deep Technical Root Cause Analysis) + +### 2.1 กลไกการโหลดของตัวเกม Star Rail 4.6 (Unity il2cpp Lifecycle) +ลำดับขั้นตอนที่ตัวเกมทำงานหลังจากกด Log in มีดังนี้: + +1. **Authentication:** + - Client ยิง HTTP POST ไปที่ `/account/ma-passport/api/appLoginByPassword` หรือ `/hkrpg_global/mdk/shield/api/login` + - Server ตอบกลับ JSON: `{"retcode": 0, "data": {"account_type": 1, "uid": "1", "token": "gacy_token"}}` +2. **Query Dispatch:** + - Client ยิง HTTP GET ไปที่ `/query_dispatch?version=...` + - Server ตอบกลับ Base64 ของ Protobuf Message `Dispatch` โดยมี `region_list` ชี้ `dispatch_url` ไปที่ `http://:21000/query_gateway` +3. **Query Gateway:** + - Client ยิง HTTP GET ไปที่ `/query_gateway?version=OSBETAAndroid4.6.54_D16927089_A16927089_L16927077&...` + - Server ต้องตอบกลับ Base64 ของ Protobuf Message `Gateserver` +4. **Hotfix & Asset Verification (จุดที่เกิดปัญหา):** + - ตัวเกม C++ ใน Unity il2cpp จะแกะ Message `Gateserver` แล้วนำฟิลด์ `asset_bundle_url`, `ex_resource_url`, `lua_url`, `lua_version` ไปเริ่มต้นโมดูล **PatchDownloader** + - **กรณีที่ 1 (URL เป็นค่าว่าง):** ถ้าเซิร์ฟเวอร์ส่ง `asset_bundle_url = ""` (เช่น การตั้งค่า `send_hotfix = false`) ตัว PatchDownloader จะเกิด Fatal Exception + - **กรณีที่ 2 (Protobuf Tag ผิด):** ถ้าโครงสร้าง Tag ของ Protobuf ไม่ตรงกับเวอร์ชัน 4.6 ตัวเกมจะแกะค่า Port เป็น URL และแกะ URL เป็นค่าว่าง + - **ผลลัพธ์ของ Exception:** ในโค้ด Native C++ ของเกม Error ในจังหวะนี้ไม่มีการผูก Key ข้อความแปลภาษา (Missing Localized String) กล่องโมดอลแจ้งเตือนจึงแสดงเป็น **หน้าต่างว่างเปล่าสีขาว** และปุ่มยืนยันจะผูกคำสั่ง `RestartGame()` ทำให้เกิดอาการค้างลูป +5. **UDP KCP Connection (พอร์ต 23301):** + - เมื่อผ่านการตรวจ Hotfix ไปได้ ตัวเกมจะสร้าง Socket UDP KCP ไปยัง `ip:port` ที่ระบุใน `Gateserver` + - แพ็กเก็ตแรกที่ส่งคือ `PlayerGetTokenCsReq` (CmdID = 5 ในเวอร์ชัน 4.6) + - หาก Server แมป CmdID ผิดเป็นเวอร์ชันเก่า (เช่น 88) Server จะขึ้น `Unhandled cmd_id 5` และตัดการเชื่อมต่อ + +--- + +### 2.2 ตารางเปรียบเทียบ Protobuf Wire Format: `Gateserver` (4.3 vs 4.6) + +นี่คือจุดที่ทำให้ระบบพังอย่างเงียบๆ หากใช้ไฟล์ Schema เก่า: + +| ฟิลด์ข้อมูล | ชนิดข้อมูล (Type) | 4.3 Tag (Schema เก่าของ GaCy) | 4.6 Tag (Schema แท้ของ Star Rail 4.6) | ผลกระทบเมื่อส่งผิดไปให้ Client 4.6 | +|---|---|---|---|---| +| `ip` | string (wire 2) | Tag 2 | **Tag 3** | Client 4.6 ไม่สนใจ Tag 2 | +| `ex_resource_url` | string (wire 2) | Tag 5 | **Tag 5** | ค่าตรงกัน | +| `asset_bundle_url` | string (wire 2) | Tag 15 | **Tag 6** | **CRITICAL:** Client 4.6 อ่าน Tag 15 เป็น boolean `unk2` ทำให้มองไม่เห็น `asset_bundle_url`! | +| `lua_url` | string (wire 2) | Tag 3 | **Tag 7** | **CRITICAL:** Client 4.6 อ่าน Tag 3 เป็น `ip`! | +| `port` | uint32 (wire 0) | Tag 6 | **Tag 9** | **CRITICAL:** Client 4.6 อ่าน Tag 6 เป็น `asset_bundle_url` (เอาเลขพอร์ต 23301 ไปเป็น URL ดาวน์โหลด)! | +| `unk1` | bool (wire 0) | - | **Tag 10** | ค่าคงที่ `true` | +| `unk2` | bool (wire 0) | - | **Tag 15** | ค่าคงที่ `true` | +| `unk3` | bool (wire 0) | - | **Tag 77** | ค่าคงที่ `true` | +| `unk4` | bool (wire 0) | - | **Tag 454** | ค่าคงที่ `true` | +| `ifix_url` | string (wire 2) | - | **Tag 694** | URL ifix | +| `unk5` | bool (wire 0) | - | **Tag 802** | ค่าคงที่ `true` | +| `unk6` | bool (wire 0) | - | **Tag 918** | ค่าคงที่ `true` | +| `ifix_version` | string (wire 2) | - | **Tag 1286** | ค่าคงที่ `"0"` (ต้องเป็น string ห้ามส่ง int) | +| `asset_bundle_url_b`| string (wire 2) | - | **Tag 1379**| URL สำรอง (ใส่ค่าเดียวกับ `asset_bundle_url`) | +| `lua_version` | string (wire 2) | - | **Tag 1516** | ค่าเวอร์ชัน Lua (เช่น `"16927297"`, ต้องเป็น string) | +| `unk7` | bool (wire 0) | - | **Tag 1559**| ค่าคงที่ `true` | +| `unk8` | bool (wire 0) | - | **Tag 1975**| ค่าคงที่ `true` | + +--- + +### 2.3 การตัด Hash ท้ายสตริงเวอร์ชัน (Version Query String Mismatch) +* ตัวเกมยิงมาที่ URL: + `GET /query_gateway?version=OSBETAAndroid4.6.54_D16927089_A16927089_L16927077` +* แต่ในไฟล์ `hotfix.json` บันทึกคีย์ไว้แบบสั้น: + `"OSBETAAndroid4.6.54"` +* **ข้อผิดพลาดเดิม:** โค้ดใช้ `hotfix.get(version)` ตรงๆ ทำให้ได้ค่า `None` ส่งผลให้ URL ใน `Gateserver` กลายเป็นค่าว่าง +* **การแก้ไขที่ถูกต้อง:** ต้องตัดเอาเฉพาะส่วนหน้าก่อนเครื่องหมาย `_`: + ```python + clean_version = version.split('_')[0] if version else version + hotfix_entry = hotfix_dict.get(version) or hotfix_dict.get(clean_version) + ``` + +--- + +### 2.4 ตาราง CmdID บน UDP KCP ในเวอร์ชัน 4.6 (เปรียบเทียบกับ 4.3) + +เมื่อตัวเกมผ่าน 0% ไปแล้ว จะเริ่มยิง KCP ถ้า CmdID ไม่ตรง Server จะพิมพ์ `Unhandled cmd_id` และ Client จะหลุด: + +| Command Name | CmdID ในเวอร์ชัน 4.3 | CmdID ในเวอร์ชัน 4.6 | ประเภท | +|---|---|---|---| +| `PlayerGetTokenCsReq` | 88 | **5** | Client -> Server (คำสั่งแรกสุดของ KCP) | +| `PlayerGetTokenScRsp` | 44 | **71** | Server -> Client | +| `PlayerLoginCsReq` | 94 | **74** | Client -> Server | +| `PlayerLoginScRsp` | 9 | **19** | Server -> Client | +| `PlayerLoginFinishCsReq` | 84 | **9** | Client -> Server | +| `PlayerLoginFinishScRsp` | 13 | **13** | Server -> Client | +| `PlayerHeartBeatCsReq` | 98 | **45** | Heartbeat | +| `PlayerHeartBeatScRsp` | 99 | **16** | Heartbeat Response | +| `GetCurSceneInfoCsReq` | 1488 | **1459** | โหลดข้อมูลฉาก | +| `GetCurSceneInfoScRsp` | 1444 | **1418** | ตอบข้อมูลฉาก | +| `SceneCastSkillCsReq` | 1424 | **1405** | กดใช้สกิลในฉาก | +| `SceneCastSkillScRsp` | 1403 | **1471** | ตอบรับสกิล | +| `SceneEntityMoveCsReq` | 1494 | **1474** | เดินในฉาก | +| `SceneEntityMoveScRsp` | 1409 | **1419** | ตำแหน่งการเดิน | +| `GetCurLineupDataCsReq`| 788 | **731** | ข้อมูลทีมปัจจุบัน | +| `GetCurLineupDataScRsp`| 744 | **787** | ตอบข้อมูลทีมปัจจุบัน | +| `GetAllLineupDataCsReq`| 794 | **704** | ข้อมูลทีมทั้งหมด | +| `GetAllLineupDataScRsp`| 709 | **794** | ตอบข้อมูลทีมทั้งหมด | +| `ChangeLineupLeaderCsReq`| 784 | **798** | เปลี่ยนตัวละครหลัก | +| `ChangeLineupLeaderScRsp`| 713 | **747** | ตอบรับเปลี่ยนตัวละคร | +| `GetBagCsReq` | 588 | **574** | ข้อมูลกระเป๋า | +| `GetBagScRsp` | 544 | **519** | ตอบข้อมูลกระเป๋า | +| `GetAvatarDataCsReq` | 388 | **374** | ข้อมูลตัวละคร | +| `GetAvatarDataScRsp` | 344 | **319** | ตอบข้อมูลตัวละคร | +| `PVEBattleResultCsReq` | 188 | **174** | ผลการต่อสู้ | +| `PVEBattleResultScRsp` | 144 | **119** | ตอบรับผลการต่อสู้ | +| `StartCocoonStageCsReq`| 1438 | **1444** | เริ่มดันเจี้ยน | +| `StartCocoonStageScRsp`| 1416 | **1481** | ตอบเริ่มดันเจี้ยน | + +--- + +### 2.5 ทำไมถึงห้ามนำ binary ของ ppSR มาใส่ตรงๆ? +ในไฟล์ `ppsr.pyc` ของ ppSR มีการเขียนฟังก์ชันป้องกันการแจกจ่าย (`check_access` / `qyqwiourtyqot`) โดยจะนำ Timestamp มา Hash ร่วมกับคีย์ลับ (HMAC-SHA256) เพื่อสร้าง Password รายวัน/สัปดาห์ หากรหัสใน `persistent["key"]` ไม่ตรง ตัวโปรแกรมจะเรียก `input("Enter the correct password: ")` ซึ่งสำหรับแอพแบบ Embedded บน Android จะทำให้เซิร์ฟเวอร์หยุดรอ Input และไม่สามารถทำงานต่อได้ + +**ดังนั้น:** เราต้องสร้างตัว Encode และ Logic 4.6 ภายในโค้ดของเราเอง (ทั้งใน Python runner และ Go backend) โดยไม่ต้องพึ่งพาหรือก๊อปปี้ binary ของ ppSR มาใช้งาน + +--- + +## 3. ข้อมูล CDN Hotfix 4.6 ที่แท้จริง (Official 4.6 CDN URLs) + +ให้ใช้ชุดข้อมูล JSON นี้สำหรับบรรจุลงใน `hotfix.json`: + +```json +{ + "OSBETAAndroid4.6.54": { + "asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_16927089_a94e4821d0e7_f07f57da7e245a", + "asset_bundle_url_b": "https://autopatchos.starrails.com/asb/BetaLive/output_16927089_a94e4821d0e7_f07f57da7e245a", + "ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_16932918_4a2e3f79111d_3e2af64ea2b108", + "lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_16927297_c46b951e256b_0abc31ea529d71", + "ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4", + "lua_version": "16927297", + "ifix_version": "0" + }, + "OSBETAAndroid4.6.53": { + "asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_16818953_d4345bbd54a2_ae00cce42191d1", + "asset_bundle_url_b": "https://autopatchos.starrails.com/asb/BetaLive/output_16818953_d4345bbd54a2_ae00cce42191d1", + "ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_16833202_959c316ef547_335871eaabab71", + "lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_16819203_679727d45bd7_d086e33f0af119", + "ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4", + "lua_version": "16819203", + "ifix_version": "0" + }, + "OSBETAWin4.6.54": { + "asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_16927089_a94e4821d0e7_f07f57da7e245a", + "asset_bundle_url_b": "https://autopatchos.starrails.com/asb/BetaLive/output_16927089_a94e4821d0e7_f07f57da7e245a", + "ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_16932918_4a2e3f79111d_3e2af64ea2b108", + "lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_16927297_c46b951e256b_0abc31ea529d71", + "ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4", + "lua_version": "16927297", + "ifix_version": "0" + } +} +``` + +--- + +## 4. โค้ดตัวอย่างสำหรับแก้ปัญหา (Ready-to-use Implementation) + +### 4.1 ตัวสร้าง Wire Format Protobuf `Gateserver` 4.6 (Python Standalone Serializer) +ฟังก์ชันนี้ทำงานโดยเขียน Byte Stream ตามสเปก Protobuf Wire Format โดยตรง ไม่ต้องพึ่งพา `classes_pb2.pyc` เก่า: + +```python +import base64 + +def encode_varint(value: int) -> bytes: + bits = value & 0x7fffffffffffffff + buf = bytearray() + while True: + b = bits & 0x7f + bits >>= 7 + if bits: + buf.append(b | 0x80) + else: + buf.append(b) + break + return bytes(buf) + +def encode_field(tag: int, wire_type: int, payload: bytes) -> bytes: + key = (tag << 3) | wire_type + return encode_varint(key) + payload + +def build_gateserver_4_6(gate_ip: str, game_port: int, hf_data: dict) -> bytes: + parts = [] + + # Tag 3: ip (string) + ip_bytes = gate_ip.encode('utf-8') + parts.append(encode_field(3, 2, encode_varint(len(ip_bytes)) + ip_bytes)) + + # Tag 5: ex_resource_url (string) + ex_res = hf_data.get('ex_resource_url', '').encode('utf-8') + if ex_res: + parts.append(encode_field(5, 2, encode_varint(len(ex_res)) + ex_res)) + + # Tag 6: asset_bundle_url (string) + asb = hf_data.get('asset_bundle_url', '').encode('utf-8') + if asb: + parts.append(encode_field(6, 2, encode_varint(len(asb)) + asb)) + + # Tag 7: lua_url (string) + lua_url = hf_data.get('lua_url', '').encode('utf-8') + if lua_url: + parts.append(encode_field(7, 2, encode_varint(len(lua_url)) + lua_url)) + + # Tag 9: port (uint32) + parts.append(encode_field(9, 0, encode_varint(game_port))) + + # Boolean flags (unk1 .. unk8) + bool_tags = [10, 15, 77, 454, 802, 918, 1559, 1975] + for tag in bool_tags: + parts.append(encode_field(tag, 0, encode_varint(1))) + + # Tag 694: ifix_url (string) + ifix_url = hf_data.get('ifix_url', '').encode('utf-8') + if ifix_url: + parts.append(encode_field(694, 2, encode_varint(len(ifix_url)) + ifix_url)) + + # Tag 1286: ifix_version (string) + ifix_ver = str(hf_data.get('ifix_version', '0')).encode('utf-8') + parts.append(encode_field(1286, 2, encode_varint(len(ifix_ver)) + ifix_ver)) + + # Tag 1379: asset_bundle_url_b (string) + asb_b = (hf_data.get('asset_bundle_url_b') or hf_data.get('asset_bundle_url', '')).encode('utf-8') + if asb_b: + parts.append(encode_field(1379, 2, encode_varint(len(asb_b)) + asb_b)) + + # Tag 1516: lua_version (string) + lua_ver = str(hf_data.get('lua_version', '16927297')).encode('utf-8') + parts.append(encode_field(1516, 2, encode_varint(len(lua_ver)) + lua_ver)) + + raw_protobuf = b''.join(parts) + return base64.b64encode(raw_protobuf) +``` + +### 4.2 โค้ด Handler สำหรับ `query_gateway` (Python Flask) +```python +@app.route('/query_gateway', methods=['GET', 'HEAD']) +def query_gateway(): + from flask import request + if request.method == 'HEAD': + return '', 200 + + version = request.args.get('version', '') + if not version: + return '', 200 + + # 1. ตัด build hash ออก เช่น OSBETAAndroid4.6.54_D16927089... -> OSBETAAndroid4.6.54 + clean_ver = version.split('_')[0] + + # 2. ค้นหา Hotfix Data + hf = hotfix_dict.get(version) or hotfix_dict.get(clean_ver) + if not hf: + # Fallback หาคีย์ 4.6 ตัวที่ใกล้เคียงที่สุด + for k in hotfix_dict: + if '4.6' in k and 'Android' in k: + hf = hotfix_dict[k] + break + + # 3. กำหนด Target IP (127.0.0.1 หากเล่นบนเครื่องเดียวกัน หรือ LAN IP) + client_ip = request.remote_addr + target_ip = "127.0.0.1" if client_ip in ("127.0.0.1", "localhost") else get_local_lan_ip() + + # 4. ตรวจสอบว่าเปิดส่ง Hotfix หรือไม่ (ต้องเป็น True เสมอสำหรับ 4.6) + should_send_hotfix = Manager.persistent.get("send_hotfix", True) + hotfix_payload = hf if should_send_hotfix else {} + + # 5. สร้าง Base64 Protobuf 4.6 แท้ + response_b64 = build_gateserver_4_6(target_ip, 23301, hotfix_payload) + return response_b64 +``` + +### 4.3 โค้ดสำหรับเซิร์ฟเวอร์ Go (`internal/sdkserver/server.go`) +ใน Go backend ของ `GaC-hkrpg-go`: +```go +func (a *appState) getOrInsertHotfix(version, dispatchSeed string) VersionConfig { + a.mu.Lock() + defer a.mu.Unlock() + + // 1. Direct match + if cfg, ok := a.hotfixMap[version]; ok { + return cfg + } + + // 2. Stripped prefix match (สำคัญมาก: ตัด _D... ออก) + cleanVer := strings.Split(version, "_")[0] + if cfg, ok := a.hotfixMap[cleanVer]; ok { + return cfg + } + + // 3. Fallback to OSBETAAndroid4.6.54 if version contains 4.6 + if strings.Contains(version, "4.6") { + if cfg, ok := a.hotfixMap["OSBETAAndroid4.6.54"]; ok { + return cfg + } + } + + return VersionConfig{} +} +``` + +และใน struct `Gateserver` ของ Protobuf ใน Go (`StarRail.pb.go`) ให้แน่ใจว่า Tag ตรงกับสเปก 4.6: +```protobuf +message Gateserver { + string ip = 3; + string ex_resource_url = 5; + string asset_bundle_url = 6; + string lua_url = 7; + uint32 port = 9; + bool unk1 = 10; + bool unk2 = 15; + bool unk3 = 77; + bool unk4 = 454; + string ifix_url = 694; + bool unk5 = 802; + bool unk6 = 918; + string ifix_version = 1286; + string asset_bundle_url_b = 1379; + string lua_version = 1516; + bool unk7 = 1559; + bool unk8 = 1975; +} +``` + +--- + +## 5. สรุปเช็คลิสต์สำหรับการทดสอบ (Verification Checklist) + +- [ ] **เปิดใช้งาน `send_hotfix: true`:** ตรวจสอบว่าใน `persistent.json` และโค้ดตัวอ่าน persistent ไม่มีส่วนใดไปบังคับเซฟทับเป็น `false` +- [ ] **Hotfix URLs ไม่ว่างเปล่า:** เมื่อ Client ยิง `/query_gateway` ต้องได้ URL ที่ขึ้นต้นด้วย `https://autopatchos.starrails.com/...` เสมอ +- [ ] **Tag 6 และ Tag 9 ถูกต้อง:** ค่าของ Tag 6 ต้องเป็นสตริง URL (`asset_bundle_url`) และ Tag 9 ต้องเป็นตัวเลขพอร์ต `23301` (ไม่ใช่สลับกัน) +- [ ] **รองรับคำสั่งแรกของ KCP (CmdID 5):** เมนู Console ของ Gameserver ต้องไม่ขึ้นว่า `Unhandled cmd_id 5` ตอนที่หลุดจากหน้า 0% เข้าสู่การเชื่อมต่อฉากเกม +- [ ] **ไม่มีระบบเช็ค Discord License หรือถาม Password:** เซิร์ฟเวอร์ต้องเริ่มต้นทำงานได้ทันทีแบบไม่ต้องรอ Console Input ใดๆ + + +--- + +## 6. รวมบัคและข้อผิดพลาดทั้งหมดที่เกิดขึ้นในระบบ (Comprehensive Bug Registry & Failure Post-Mortem) + +ส่วนนี้ได้รวบรวมทุกบัค, Error Log, สาเหตุที่แท้จริง (Root Cause), และโค้ดแก้ไข (Fix) ที่เกิดขึ้นตลอดการพัฒนาและการดีบัก GaCy และ GaC-hkrpg-go ไว้อย่างละเอียดที่สุด เพื่อให้ AI สามารถนำไปใช้ป้องกันและแก้ปัญหาได้อย่างครอบคลุม: + +--- + +### Bug #1: ค้าง 0% และเกิด Alert Dialog สีขาวว่างเปล่า (White Blank Dialog Loop) +* **Log อาการ:** + ```text + [HTTP] GET /query_gateway?version=OSBETAAndroid4.6.54_D16927089... -> 200 + [Client]: หน้าจอขึ้นรูปรถไฟ Astral Express บิน แถบดาวน์โหลดค้างที่ 0 % + [Client]: สักครู่มี Dialog สีขาวไม่มีตัวหนังสือเด้งขึ้นมา พอกดปุ่ม ตัวเกมสั่ง Restart + ``` +* **สาเหตุ:** + 1. ค่า `send_hotfix` ถูกตั้งเป็น `false` หรือโค้ดส่ง `asset_bundle_url = ""` ว่างเปล่ากลับไป + 2. ใน Star Rail 4.6 โมดูล `PatchDownloader` ของ Unity il2cpp บังคับว่าต้องมี URL ดาวน์โหลด Asset Bundle ถ้าไม่มีหรือต่อไม่ติด จะเกิด Fatal Error ทันที + 3. เนื่องจากตัวเกมไม่ได้ผูกข้อความแปลภาษาสำหรับกรณี Asset URL ว่างไว้ ทำให้หน้าต่างแสดงผลเป็นสีขาวล้วน (Empty Text) +* **วิธีแก้:** + - ต้องเปิด `send_hotfix: true` เสมอ + - ฟิลด์ `asset_bundle_url`, `ex_resource_url`, `lua_url` ต้องมี URL ของ CDN แท้ (เช่น `https://autopatchos.starrails.com/asb/BetaLive/...`) บรรจุอยู่เสมอ ห้ามเป็นค่าว่าง + +--- + +### Bug #2: เซิร์ฟเวอร์ส่ง IP `0.0.0.0` กลับไปใน Gateserver +* **Log อาการ:** + ```text + [Dispatch] Gateserver response serialized: ip="0.0.0.0", port=23301 + [Client]: Connection refused / Timeout ทันทีหลังจบ HTTP Gateway + ``` +* **สาเหตุ:** + - โค้ดของ Dispatch ไปหยิบค่า `_ds_mod.HOST` ซึ่งตั้งค่าไว้เป็น `"0.0.0.0"` (เพื่อให้ Server Socket บายด์รับทุก Interface) ส่งกลับไปใน Protobuf + - Client ในระบบ Android หรืออุปกรณ์อื่นภายนอก ไม่สามารถเชื่อมต่อไปยัง IP `0.0.0.0` ได้ +* **วิธีแก้:** + ```python + def resolve_client_gate_ip(): + from flask import request + client_ip = request.remote_addr if request else "127.0.0.1" + if not client_ip or client_ip in ("127.0.0.1", "::1", "localhost"): + return "127.0.0.1" + # หากเป็นเครื่องอื่น ให้ตอบด้วย LAN IP ของเซิร์ฟเวอร์ + import socket + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(('8.8.8.8', 80)) + return s.getsockname()[0] + except Exception: + return "127.0.0.1" + finally: + s.close() + ``` + +--- + +### Bug #3: Protobuf Tag Inversion (4.3 vs 4.6 Tag Mismatch) +* **Log อาการ:** + ```text + [Client Debug]: AssetBundle download failed: invalid url format "23301" + [Client]: Game crash / Restart loop at 0% + ``` +* **สาเหตุ:** + - Schema เก่า (4.3) มี `port` อยู่ที่ **Tag 6** และ `asset_bundle_url` อยู่ที่ **Tag 15** + - ใน 4.6 สลับเป็น `asset_bundle_url` อยู่ที่ **Tag 6** และ `port` อยู่ที่ **Tag 9** + - เมื่อ Client 4.6 อ่านไบต์ที่สร้างจาก 4.3 ตัวเกมจะอ่านค่าพอร์ต (`23301`) ไปเป็น String URL ของ Asset Bundle +* **วิธีแก้:** + - แก้ไขตัวสร้าง Protobuf ให้ Tag 6 เป็น String URL และ Tag 9 เป็น uint32 พอร์ตเกม + +--- + +### Bug #4: Protobuf Type Crash บน `lua_version` และ `ifix_version` (`TypeError: int expected str`) +* **Log อาการ:** + ```text + TypeError: 0 has type int, but expected one of: bytes, str + File "dispatchserver.py", line 113, in query_gateway + response.lua_version = hf.get('lua_version', 0) + ``` +* **สาเหตุ:** + - ในนิยาม Protobuf 4.6 ฟิลด์ `lua_version` (Tag 1516) และ `ifix_version` (Tag 1286) ถูกกำหนดชนิดข้อมูลเป็น `string` (ไม่ใช่ uint32) + - เมื่อโค้ดส่ง default value เป็นเลข `0` (int) ตัวไลบรารี Protobuf ของ Python จะ Raise `TypeError` ทันที ทำให้ HTTP 500 +* **วิธีแก้:** + ```python + response.lua_version = str(hf.get('lua_version', '16927297')) + response.ifix_version = str(hf.get('ifix_version', '0')) + ``` + +--- + +### Bug #5: Protobuf AttributeError ฟิลด์ `unk9` และ `unk10` ไม่มีอยู่จริง +* **Log อาการ:** + ```text + AttributeError: Protocol message Gateserver has no "unk9" field. + ``` +* **สาเหตุ:** + - โค้ด Patch พยายามส่งตัวแปรเดา: `unk1=True, ..., unk9=True, unk10=True` + - ในสเปก Protobuf ของ Star Rail 4.6 ฟิลด์ boolean unk มีถึงแค่ **`unk8`** (Tag 1975) เท่านั้น การส่ง `unk9` ทำให้เกิด Crash +* **วิธีแก้:** + - ส่งเฉพาะ boolean flags `unk1` ถึง `unk8` (Tags: 10, 15, 77, 454, 802, 918, 1559, 1975) + +--- + +### Bug #6: บัคฟังก์ชัน Auto-Heal ทับค่า `send_hotfix = False` อัตโนมัติทุกครั้งที่เปิด +* **Log อาการ:** + ```text + [Patch] persistent.json validated and auto-healed with complete avatar/scene defaults (send_hotfix=False) + ``` +* **สาเหตุ:** + - ในไฟล์ `run_ppsr.py` และ `PersistentRepository.kt` มีโค้ดตรวจสอบสุขภาพไฟล์ `persistent.json` แต่คนเขียนเดิมใส่เงื่อนไขไว้ว่า: + ```python + if data.get("send_hotfix") is not False: + data["send_hotfix"] = False + modified = True + ``` + - ส่งผลให้ไม่ว่าผู้ใช้จะแก้ไฟล์เป็น `true` อย่างไร พอเปิดแอพ เซิร์ฟเวอร์จะเซฟทับกลับเป็น `false` ทันที +* **วิธีแก้:** + ```python + if "send_hotfix" not in data: + data["send_hotfix"] = True + modified = True + ``` + +--- + +### Bug #7: การหาคีย์ Hotfix ไม่เจอเพราะ Client ส่ง Hash ต่อท้ายเวอร์ชัน +* **Log อาการ:** + ```text + KeyError: 'OSBETAAndroid4.6.54_D16927089_A16927089_L16927077' + Couldn't find OSBETAAndroid4.6.54_D16927089_A16927089_L16927077 in hotfix.json + ``` +* **สาเหตุ:** + - ตัวเกม Client ยิง Query String มาเป็น `version=OSBETAAndroid4.6.54_D16927089_A16927089_L16927077` + - แต่ใน `hotfix.json` บันทึกไว้เฉพาะ `OSBETAAndroid4.6.54` + - โค้ดดึง `hotfix[version]` ตรงๆ ทำให้เกิด `KeyError` หรือได้ค่าว่าง +* **วิธีแก้:** + ```python + clean_ver = version.split('_')[0] if version else version + hf = hotfix.get(version) or hotfix.get(clean_ver) + ``` + +--- + +### Bug #8: เซิร์ฟเวอร์ Beta ของทางการปิดตัว (`retcode: 3 / 测试资格校验失败`) +* **Log อาการ:** + ```text + Tag 12 (varint): 3 + Tag 13 (string): client version not match + Tag 252 (string): 测试资格校验失败,请重新登录 + ``` +* **สาเหตุ:** + - ฟังก์ชัน `check_hotfix` ของ ppSR ดั้งเดิม ออกแบบมาให้ยิง Proxy ต่อไปยัง `https://beta-release01-asia.starrails.com/query_gateway` เพื่อดึงข้อมูล Hotfix สดๆ + - แต่เมื่อระยะเวลาทดสอบ Beta จบลง เซิร์ฟเวอร์ทางการจะปฏิเสธคำขอทั้งหมดและส่ง retcode 3 + - ส่งผลให้ `gateserver.asset_bundle_url` ที่ตอบกลับมาเป็นค่าว่าง `""` ถ้าเซิร์ฟเวอร์ไม่มีการ Hardcode URL สำรองไว้ใน `hotfix.json` ระบบจะพังทันที +* **วิธีแก้:** + - ต้องมี URL CDN ของ 4.6 ที่บันทึกไว้ในเครื่อง (`hotfix.json`) เสมอ และหากดึงสดล้มเหลว ให้ใช้ข้อมูลในเครื่องตอบกลับ Client ทันที + +--- + +### Bug #9: HTTP 405 Method Not Allowed บน `/hkrpg_global/mdk/shield/api/loadConfig` +* **Log อาการ:** + ```text + [HTTP] GET /hkrpg_global/mdk/shield/api/loadConfig -> 405 Method Not Allowed + ``` +* **สาเหตุ:** + - เส้นทาง API Shield ของ Flask ถูกลงทะเบียนไว้เฉพาะเมธอด `POST`: + `@app.route('/hkrpg_/mdk/shield/api/', methods=['POST'])` + - แต่ตัวเกม Star Rail Global ส่งเมธอด **`GET`** มาที่ `loadConfig` +* **วิธีแก้:** + ```python + @app.route('//mdk/shield/api/loadConfig', methods=['GET', 'POST']) + @app.route('/hkrpg_/mdk/shield/api/loadConfig', methods=['GET', 'POST']) + def shield_load_config(*args, **kwargs): + return jsonify({ + "retcode": 0, + "message": "OK", + "data": { + "id": 6, + "game_key": "hkrpg_global", + "client": "ANDROID", + "identity": "I_IDENTITY", + "guest": False, + "ignore_clean": 0, + "scene": "S_NORMAL" + } + }) + ``` + +--- + +### Bug #10: HTTP 404 บน Telemetry & SDK Configuration Endpoints +* **Log อาการ:** + ```text + [HTTP] GET /combo/box/api/config/sdk/combo -> 404 + [HTTP] GET /combo/box/api/config/sdk/drmSwitch -> 404 + [HTTP] POST /data_abtest_api/config/experiment/list -> 404 + [HTTP] GET /admin/mi18n/plat_oversea/... -> 404 + ``` +* **สาเหตุ:** + - ตัวเกม Star Rail Global มีการเรียก Endpoint ย่อยต่างๆ สำหรับตรวจสอบ DRM, A/B Testing และไฟล์ภาษา i18n + - หากเซิร์ฟเวอร์ตอบกลับเป็น 404 HTML ตัวเกมบางเวอร์ชันจะหยุดทำงาน +* **วิธีแก้:** + - เพิ่ม Universal Fallback Handler ดักจับเส้นทางเหล่านี้แล้วตอบ JSON 200 OK: + ```python + @app.errorhandler(404) + def handle_404(e): + from flask import request, jsonify + path = request.path + if any(k in path for k in ['combo', 'config', 'drm', 'experiment', 'abtest', 'mi18n', 'font']): + return jsonify({"retcode": 0, "message": "OK", "data": {}}), 200 + if any(k in path for k in ['login', 'passport', 'token', 'auth', 'shield']): + return jsonify({ + "retcode": 0, + "message": "OK", + "data": { + "account_type": 1, + "uid": "1", + "token": "gacy_token_ok", + "email": "player@gacy.local" + } + }), 200 + return jsonify({"retcode": 0, "message": "OK"}), 200 + ``` + +--- + +### Bug #11: UDP KCP `Unhandled cmd_id 5` (`PlayerGetTokenCsReq`) +* **Log อาการ:** + ```text + Dispatch server listening on 0.0.0.0:21000 + Forwarding to 127.0.0.1:23301 + [GameServer]: Unhandled cmd_id 5 + ``` +* **สาเหตุ:** + - ใน Star Rail 4.6 เมื่อตัวเกมผ่านหน้า 0% ไปได้ จะส่งคำสั่งแรกคือ `PlayerGetTokenCsReq` + - หมายเลขคำสั่งใน 4.6 คือ **`5`** (ใน 4.3 คือ 88) + - เซิร์ฟเวอร์ไม่มี Handler สำหรับหมายเลข 5 จึงทิ้งแพ็กเก็ต ทำให้ Client ตัดการเชื่อมต่อ +* **วิธีแก้:** + - อัปเดตตาราง `cmd_dict` ให้ `PlayerGetTokenCsReq` ชี้ไปที่ `5` และ `PlayerGetTokenScRsp` ชี้ไปที่ `71` + - แมปฟังก์ชัน `PlayerGetTokenCsReq.handle` เข้ากับ CmdID `5` ใน `router.py` + +--- + +### Bug #12: UDP KCP `Unhandled cmd_id 74` (`PlayerLoginCsReq`) +* **Log อาการ:** + ```text + PlayerGetTokenCsReq (5) -> PlayerGetTokenScRsp (71) + [GameServer]: Unhandled cmd_id 74 + ``` +* **สาเหตุ:** + - หลังจาก Token ผ่าน Client จะส่งคำสั่งล็อกอินเข้าเกม `PlayerLoginCsReq` + - ใน 4.6 หมายเลขคำสั่งคือ **`74`** (ใน 4.3 คือ 94) และผลตอบรับ `PlayerLoginScRsp` คือ **`19`** (ใน 4.3 คือ 9) +* **วิธีแก้:** + - แมป `PlayerLoginCsReq: 74` และ `PlayerLoginScRsp: 19` ใน `router.py` + +--- + +### Bug #13: Socket Crash `OSError: [Errno 98] Address already in use` +* **Log อาการ:** + ```text + OSError: [Errno 98] Address already in use: ('0.0.0.0', 21000) + ``` +* **สาเหตุ:** + - เมื่อผู้ใช้กด Stop Server แล้วกด Start Server ใหม่ทันที Socket ของ Werkzeug/Flask ยังอยู่ในสถานะ `TIME_WAIT` ของระบบปฏิบัติการ +* **วิธีแก้:** + - ทำ Patch ครอบฟังก์ชัน `werkzeug.serving.make_server` เพื่อเซ็ตแฟล็ก Socket: + ```python + import socket, struct + def _patched_make_server(*args, **kwargs): + kwargs.pop('options', None) + server = _orig_make_server(*args, **kwargs) + if hasattr(server, 'socket') and server.socket: + server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # ปิด Linger เพื่อให้ปล่อยพอร์ตทันทีเมื่อสั่งปิด + server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + return server + ``` + +--- + +### Bug #14: การหยุดทำงานของเซิร์ฟเวอร์ทำให้ทั้งแอพ Android ดับ (`System.exit(0)`) +* **Log อาการ:** + ```text + [Shutdown]: Force-stopping server execution... + Process 12345 exited with code 0 (App crashed/closed to home screen) + ``` +* **สาเหตุ:** + - โค้ด Python ดั้งเดิมมักเรียก `sys.exit(0)` เมื่อต้องการปิดเซิร์ฟเวอร์ + - ในสภาพแวดล้อม Chaquopy บน Android กระบวนการของ Python ทำงานอยู่บน JVM Process เดียวกันกับ UI ของ Android Application การเรียก `sys.exit()` จึงทำให้ Android Process ดับทั้งแอพ +* **วิธีแก้:** + - ดักจับ `sys.exit` ใน Python: + ```python + def _safe_exit(code=0): + print(f"[Sys] Intercepted sys.exit({code}), prevented Android process exit.") + raise KeyboardInterrupt() + + sys.exit = _safe_exit + ``` + +--- + +### Bug #15: ค้าง Thread เพราะคำสั่ง `input()` (Headless Blocking) +* **Log อาการ:** + ```text + [CLI]: Enter the correct password: + (Server hangs indefinitely, no logs appear, ports not listening) + ``` +* **สาเหตุ:** + - สคริปต์เซิร์ฟเวอร์มีการเรียก `builtins.input()` เพื่อรอการป้อนข้อมูลจาก Console + - บน Android ไม่มี stdin ให้พิมพ์ ทำให้ Thread นั้นติดบล็อกตลอดกาล +* **วิธีแก้:** + - Mock คำสั่ง `builtins.input`: + ```python + import builtins, time + def _mock_input(prompt=""): + if prompt: + print(f"[CLI]: {prompt}") + time.sleep(1) + return "" + builtins.input = _mock_input + ``` + +--- + +### Bug #16: `KeyError` ใน `Manager.load_persistent` เมื่อ Persistent ขาดคีย์บางตัว +* **Log อาการ:** + ```text + KeyError: 'march_path' + File "game/services/manager.py", line 45, in load_persistent + ``` +* **สาเหตุ:** + - ในไฟล์ `persistent.json` ที่ผู้ใช้แก้ไขเอง อาจไม่มีฟิลด์ย่อย เช่น `tb_gender`, `tb_path`, `march_path`, `skin` + - โค้ดใน `manager.pyc` เข้าถึงคีย์เหล่านี้ตรงๆ ด้วย `persistent['avatar']['march_path']` +* **วิธีแก้:** + - สร้างคลาส `SafeDict` ครอบ Dictionary ของ `persistent`: + ```python + class SafeDict(dict): + def __getitem__(self, key): + if key not in self: + defaults = { + 'tb_gender': 'Female', + 'tb_path': 'Elation', + 'march_path': 'Preservation', + 'leader': 0, + 'send_hotfix': True, + 'key': '', + 'skin': {}, + 'lineup': [1511, 1502, 1506, 1308], + 'buff': [], + 'global_passive': [], + 'ex_rank': [] + } + if key in defaults: + return defaults[key] + val = super().__getitem__(key) + if isinstance(val, dict) and not isinstance(val, SafeDict): + val = SafeDict(val) + self[key] = val + return val + ``` + +--- + +### Bug #17: การเกิดบัคจาก ppSR License Check (`qyqwiourtyqot`) +* **Log อาการ:** + ```text + Incorrect or expired password. + You can get the password for free from https://discord.gg/ppSR + Enter the correct password: + ``` +* **สาเหตุ:** + - การนำเอาไฟล์ `build.zip` หรือ `ppsr.pyc` จากโปรเจกต์ ppSR มาใช้ตรงๆ จะติดระบบตรวจสอบสิทธิ์ที่ผู้พัฒนา ppSR เขียนไว้ + - ระบบนี้ใช้ฟังก์ชัน `qyqwiourtyqot()` ใน `util/binary.pyc` ทำ HMAC-SHA256 ตรวจสอบกับรหัสที่แจกใน Discord รายสัปดาห์ +* **วิธีแก้:** + - **ห้ามนำ binary ที่คอมไพล์แล้วของ ppSR มาใส่ในระบบ GaCy** + - ใช้ตัวสร้าง Protobuf และตัวจัดการคำสั่งที่เป็นอิสระตามโค้ดในหัวข้อที่ 4 diff --git a/docs/GEMINI-FIXES.md b/docs/GEMINI-FIXES.md new file mode 100644 index 0000000..95b64d9 --- /dev/null +++ b/docs/GEMINI-FIXES.md @@ -0,0 +1,53 @@ +# แก้บัคจาก review งาน Gemini — 2026-11-01 + +ขอบเขตคือ 7 findings จาก review ของ CW ที่ commit แล้วและ Warp ที่ยังไม่ commit +ไม่ใช่การยืนยันว่าพอร์ต CW ครบทุกระบบหรือแก้บัคทั้ง repository แล้ว + +| Finding | การแก้ | +| --- | --- | +| Batch formation เซฟช่องซ้ำ/ตำแหน่งผิด | ทั้ง GridFightUpdatePos และ GEBBMDCOLCH เรียก transaction เดียวกัน ตรวจ Battle, preparation, session revision, UID, ขอบเขต, ช่องซ้ำ, avatar ซ้ำ และ capacity ก่อนเปลี่ยน/บันทึก ส่ง failure เมื่อ save ไม่สำเร็จ | +| Weekly reward ตอบ success ว่าง | คำสั่งรับรางวัลทั้งสองแบบตอบ RET_GRID_FIGHT_CONF_MISS และไม่เปลี่ยนเซฟ จนกว่าจะมี entitlement/grant/claim persistence ที่ยืนยันได้ | +| Talent ปลด ID ใดก็ได้ | Enable/Reset ทั้ง permanent/season ปฏิเสธการเปลี่ยนเซฟ ไม่คืนความสำเร็จปลอม ต้นทาง grid_fight_talent.zig ระบุชัดว่าเป็น stub และยังไม่มีค่าใช้จ่าย/เงื่อนไข/ผลของ talent ที่ยืนยัน | +| Tests เขียนเซฟจริง | gameserver TestMain ใช้สำเนา runtime ใน temp directory; helper กลับ root หมายถึง fixture นี้ กาชา common/API ใช้ temp fixture ต่อ test ไม่มีการ restore backup ทับไฟล์ผู้ใช้ | +| Pos=0 ถูกนับพันธะ | ตัวนับ board/battle และการเลือกเจ้าของ Trailblaze ใช้ช่วงตำแหน่งจาก constants ของ Client และตัด Pos=0 ออก | +| Archive หายหลัง restart | เก็บ serialized archive/favourite ใน snapshot แยกตาม UID โดย archive ถูก append ใน CAS เดียวกับการจบรอบ ไม่มี global in-memory store และไม่สร้างประวัติเมื่อ save ขัดแย้ง | +| Warp mode ไม่ถูกใช้ | API กับ GetGachaInfo ใช้ visibility loader เดียวกัน อ่าน mode ล่าสุดตอนเปิด Warp กรองกับ verified Client banner assets ไม่ส่ง ID จาก Client อื่นเพียงเพราะอยู่ในไฟล์นำเข้า | + +คำสั่ง Warp/reload ไม่เขียน roster ทั้งชุดอีกแล้ว เปลี่ยนเฉพาะ warp_unlock_mode +ผ่าน persistent patch ที่ล็อกไฟล์ และรายงานจำนวน banner ที่ผ่านการตรวจจริง +ข้อความ `/unlockall all` ไม่อ้างว่าแจก player resources; `/unlockall skin` +ไม่ตอบสำเร็จเมื่อยังไม่มี implementation + +## Archive และ compatibility + +- Snapshot เดิมอ่านได้โดยไม่มีการ migrate/write ระหว่างอ่าน +- เพิ่ม `archives` เป็น serialized protobuf ของ FAIHLAKPABN; unknown fields ใน + snapshot/record ยังคงอยู่ การ save CW ปกติไม่ลบ history +- เลข archive ใช้ revision ของ transaction ที่บันทึก จึงไม่เริ่มนับใหม่หลัง restart +- FavourArchive ตรวจ revision และ commit ภายใต้ persistent lock เช่นเดียวกับ run +- ยกเลิก hardcoded archive Client version `4006`; encoding ของ field นี้ยังไม่ + ยืนยัน จึงเว้นไว้ ไม่คำนวณจากข้อความ patch version โดยเดา +- Archive ที่อยู่ในหน่วยความจำของ binary รุ่นก่อนจะไม่มีในไฟล์เก่า งานนี้ไม่ + สร้างประวัติย้อนหลังจากการคาดเดา + +## การตรวจ + +เพิ่ม regression tests ที่อ่าน response ผ่าน typed dispatcher/KCP จริง: + +- คำสั่ง Talent และรับรางวัลที่ยังไม่รองรับต้องคืน retcode ผิดพลาดและไม่แก้ save +- ปฏิเสธ formation ช่องซ้ำ/ขอบเขตผิด/UID ไม่มี/Battle/session เก่า ทั้งสอง handler; + valid swap ยังสำเร็จ +- Pos=0 และ bench ไม่ให้ board/battle traits; ตัวที่ลงสนามยังมี traits +- Archive และ favourite อ่านกลับจาก snapshot ใหม่ได้; stale save ไม่เพิ่ม history; + normal save ไม่ลบ history +- Warp modes กรองรายการจริงและไม่ปล่อย unverified ID; ค่า off=0 อ่านกลับได้ +- คำสั่ง banner ไม่เปลี่ยน freesr-data.json + +ผลตรวจ: `go test ./... -count=1`, `go build ./...` และ `git diff --check` ผ่าน +ตรวจ SHA-256 ของ `persistent` และ `freesr-data.json` ก่อน/หลังชุดทดสอบแล้วไม่เปลี่ยน +เป็นการตรวจอัตโนมัติเท่านั้น ต้องตรวจ Client จริงต่อ โดยเฉพาะ batch formation, +archive UI ที่เว้น Client version และการเปิด Warp ตามรายการ asset ที่มีอยู่ + +งานนี้ไม่เพิ่มการแจก Talent/weekly reward จนกว่าจะมีข้อมูลรองรับ และไม่เปิด +การสุ่มกาชาจาก imported banner schema ที่ยังไม่ยืนยัน การปฏิเสธคำสั่งเหล่านี้ +เป็นการแก้ success ปลอม ไม่ใช่การอ้างว่าระบบ gameplay นั้นเสร็จแล้ว diff --git a/docs/README_EN.md b/docs/README_EN.md index 923f305..f03ac27 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -5,7 +5,7 @@ **Modern, High-Performance Honkai: Star Rail Game Server Emulator in Go** Ported from [`GaC-hkrpg-ps`](https://github.com/CAtlas0784/GaC-hkrpg-rust) with protocol references from `PearlSR` & `Currency-War-SR`. -[English](README_EN.md) | [ภาษาไทย](../README.md) +[English](README_EN.md) | [ภาษาไทย](../README.md) | [简体中文](README_ZH.md) @@ -60,7 +60,7 @@ The selected buff applies at the start of the next battle containing an actual C `resources/ActivityPreviewPolicy.json` selects event-panel prefabs from `PreviewGameplayCatalog.json`. Additional previews are currently withheld: Client logs showed missing reward assets and invalid item lookups in ActiveActivity. Re-enable individual panels only after validating their installed assets, module identity and reward dependencies. Event-specific battles and reward claims remain incomplete. -Featured gacha candidates come from captured character-banner prefab rows. The current capture has two candidates, but the installed Client could not load the Seele prefab and left the UI transition incomplete. Runtime `AssetMgr.Exists` checks confirmed Standard and Beginner assets, but none of the four captured limited assets. `GachaSandboxPolicy.json` currently exposes only the verified Standard panel for browsing; draws return `RET_GACHA_NOT_SUPPORT` until usable featured assets are verified. An empty verified list rejects entry as well. A table row alone is insufficient evidence of an installed asset; verify the Client bundle before adding a path to that list. The grant implementation returns the pictured character and configured duplicate Eidolon material, preserving rank/EX/Traces; it is not yet verified in the Client. An avatar absent from the imported roster is rejected. The sandbox policy uses free draws and does not reproduce official rates or conversion rules. +Warp advertises only pools captured from this Client whose prefabs were verified in `resources/GachaSandboxPolicy.json`. The Client log reports that handoff ID 2120 is absent from its `GachaBasicInfo` table; the limited pool prefabs checked are also missing. Sending those IDs prevents the page from opening. Imported historical banners are therefore not unlocked, and draws are disabled until pool data, item grants and protocol fields are verified against this Client. The `/unlockall warp` and `/reload banner` commands do not make unverified banners visible. Runtime table captures and current verification limits are recorded in [docs/offline-gameplay-continuation.md](offline-gameplay-continuation.md). Compiling these handlers does not confirm the corresponding Client screens or gameplay. @@ -173,3 +173,23 @@ Priority Order: **Environment Variables > `server.local.json` > `server.json`** - High-performance protocol analysis suite, IL2CPP metadata dumpers, network hook tooling, and reverse-engineering utilities utilized for architectural analysis. + + +--- + +## 📜 License & Attribution Notice + +This project is made available in accordance with the **Apache License 2.0** and **GNU General Public License v3 (GPL-3.0)** frameworks, strictly enforcing **Attribution** and **State Changes Notices**. + +If you use, fork, modify, redistribute, or create derivative works from any part of this repository, you **MUST** strictly comply with the following conditions: + +1. **Mandatory Attribution (Credit the Original Source):** + - You must provide prominent credit and include a direct link back to this original repository. + - You must retain all existing copyright notices, developer credits, and original license headers in all copies or substantial portions of the software. + +2. **Explicit State Changes Requirement (Document All Modifications):** + - In accordance with Section 4(b) of Apache 2.0 and Section 5(a) of GPL v3, any files that you modify **must carry prominent notices stating clearly that you changed the files, what modifications were made, and the date of modification**. + - You must not claim modified versions as wholly original works, nor remove attribution to obscure the software origin. + +3. **Non-Commercial Educational Purpose:** + - This project is provided strictly for academic research, protocol analysis, and educational purposes. Commercial exploitation, sale, or paywalling of this project or its derivatives is strictly prohibited. diff --git a/docs/README_ZH.md b/docs/README_ZH.md new file mode 100644 index 0000000..f42ba6a --- /dev/null +++ b/docs/README_ZH.md @@ -0,0 +1,197 @@ +# 🚂 GaCy GO + +
+ +**基于 Go 语言开发的高性能《崩坏:星穹铁道》游戏服务端模拟器** +移植自 [`GaC-hkrpg-ps`](https://github.com/CAtlas0784/GaC-hkrpg-rust),并参考 `PearlSR` 与 `Currency-War-SR` 协议规范。 + +[English](README_EN.md) | [ภาษาไทย](../README.md) | [简体中文](README_ZH.md) + +
+ +--- + +## 🇨🇳 简体中文 + +
+ +[项目文档](./) • [Windows 快速上手](#-windows-快速上手) • [特色玩法与内置系统](#-特色玩法与内置系统) • [配置与防硬编码规范-agentsmd](#-配置与防硬编码规范-agentsmd) • [鸣谢与致敬](#-鸣谢与致敬-credits--acknowledgments) + +
+ +本系统为使用 Go 语言实现的《崩坏:星穹铁道》游戏服务端模拟器,由 [`GaC-hkrpg-ps`](https://github.com/CAtlas0784/GaC-hkrpg-rust) 移植而来,底层协议参考 [PearlSR](https://git.xeondev.com/HonkaiSlopRail/pearl-sr)。包含游戏主服 (Game Server)、SDK 调度分发服 (SDK/Dispatch Server) 以及面向 GaCy-Srtool 的 REST API。 + +项目当前处于活跃开发状态,目标运行环境对应客户端 **4.6.53 OS**。命令映射表与最新处理器参考 PearlSR commit `005d2c12db4781071f4b5b68e8f364587886a710`(详见 [协议更新记录](protocol-update.md))。协议结构一致并不代表所有游戏系统与画面均已 100% 具备可玩性。 + +--- + +### 🎮 货币战争 (Currency Wars / CW) —— 开发中 + +- **自走棋模拟引擎 (Autochess Simulation Engine)**: 移植自 [Currency-War-SR by xaodev](https://gaytea.cod-algieba.ts.net/xaodev/Currency-War-SR),实现了基于 Supply(补给节点)的遭遇战生成、难度/选择矩阵以及模式/章节/Boss 的经验成长规则,并维持经过客户端验证的战斗路径。完整审计记录见 [CW xaodev 交接文档](CW-XAODEV-CLAUDE-HANDOFF.md) 与 [CW 路径推进审计](cw-route-continuation-audit.md)。 +- **时间模拟系统 (FakeTime)**: 在 GaCy-Srtool 中,可使用 `/faketime status` 查看当前模拟的游戏时间,使用 `/faketime ` 或 `/faketime ` 自由设定游戏世界时钟,使用 `/faketime off` 恢复宿主机真实时间。状态持久化记录于 `game_clock.state_file`(可通过环境变量 `GAC_GAME_CLOCK_STATE_FILE` 覆盖)。该机制仅作用于游戏登录与心跳逻辑,绝不修改 Windows 系统时钟或网络超时设定。 +- **昔涟 (Cyrene) 登录列车场景**: 通过 `LoginShow_UP` 清单与入场动画结束事件深度绑定,完整还原白色列车、粉紫色天空、花瓣飘落动效以及顺畅的退出重登过渡流程。详见 [登录场景研究笔记](login-cyrene-investigation.md)。 +- **GaCy-Srtool 紧急解卡 (Emergency Unstuck)**: GM 控制台中的 **Unstuck** 按钮会向服务端发送 `POST /api/client/unstuck`,在客户端下一次心跳周期触发 `UIManager.ClearWaitNetPacketFullScreenBlock()`。在不损坏 CW 战斗进度、不跨图传送、不伪造通关的前提下安全解除黑屏与转圈卡死。 +- **角色星级合成与备战席管理**: 自动实现同名同星级角色 3 合 1 升星、Preparation(备战席)装备绑定/追踪及主角焦点 (Spotlight) 选择机制。 + +#### CW 备战阶段常用指令 (CW Preparation Commands) +- `/cw status`: 查看当前轮次阶段、金币、生命值 (HP) 与已保存等级。 +- `/cw gold <总额>`: 设定 CW 金币总数(例如 `/cw gold 1000`)。 +- `/cw hp <总额>`: 在当前关卡允许范围内设定角色当前生命值。 +- `/cw exp <增加量>`: 增加经验值并应用匹配的等级、人口容量与商店概率权重。 +- `/cw list role`: 列出服务端支持的 RoleID、AvatarID 与对应星级。 +- `/cw role <星级> [数量]`: 添加角色至备战席并触发自动升星;备战席已满将取消整笔操作。 +- `/cw list equip`: 列出当前捕获的所有装备 ID。 +- `/cw equip [数量]`: 将指定装备添加至 CW 行囊。 +- `/cw list orb|consumable|forge`: 列出当前可用的奖励球、消耗品与锻造 ID。 +- `/cw orb|consumable|forge [数量]`: 在备战阶段发放指定物品。 +- `/cw help`: 查看完整指令帮助与语法。 + +--- + +### ✨ 特色玩法与内置系统 + +#### 阿哈的帮助 (Aha's Help) + +GaCy-Srtool 的持久化配置提供独立的 Aha's Help 辅助选择器。等级参数读取自 `resources/AhaHelpConfig.json`。接口 `GET /api/aha-help` 可查询可用层级;`POST /api/aha-help` 接受 `{"level":1}`,设为 0 表示禁用,设为 `null` 则使用原版 FreeSR 的侵蚀辅助开关。该配置通过原子写入保存于 `persistent.aha_help_level`,不影响 CW 及 EX 状态。 + +选定的 Buff 会在进入包含 `BattleRules.json` 真实侵蚀 (Corruption) 映射的下一场战斗时生效。这包含 srtool 导入侵蚀 Buff 的自定义关卡。官方挑战关卡仍通过 `StageInvasionConfig.json` 解析侵蚀,单纯复用关卡 ID 不会自动注入侵蚀。显式设为 0 会关闭该场侵蚀战的阿哈辅助。本设置独立于全局 Aha Global 开关,且不会中途篡改正在进行的战斗。`server_debug.log` 中的 `AHA_HELP` 字段将记录下发的侵蚀与辅助 ID。 + +#### 星辰跳跃 (Stella Jump)、活动预览与当期跃迁 (Featured Gacha) + +`resources/StellaJumpConfig.json` 包含从当前客户端捕获的 16 个辰菱 GameBoy 挑战关卡。沙盒模式提供挑战包并于 `persistent.stella_jump` 记录最佳成绩与通关状态。开启菜单为只读操作。 + +`resources/ActivityPreviewPolicy.json` 负责从 `PreviewGameplayCatalog.json` 选择活动面板预制体。其余面板因客户端日志检测到奖励资产缺失或无效物品查询而被暂时封锁。待逐一验证已安装资产及依赖后方可恢复开启。 + +Warp 仅发送已从当前客户端捕获且确认预制体存在的卡池。客户端日志显示交接包的 2120 不在本机表中,已检查的限定卡池资源也缺失;抽卡暂时禁用,等待核实卡池、奖励与协议。 + +运行时捕获的数据表及当前验证边界均记录在 [docs/offline-gameplay-continuation.md](offline-gameplay-continuation.md)。 + +#### 自由镜头 (Freecam) 与面部跟随视角 (FaceCam) + +- `Tab`: 开启/关闭 Freecam 及 FaceCam 模式;方向键: 移动镜头位置;按住鼠标右键并拖动: 旋转镜头朝向。 +- `Page Up` / `Page Down`: 镜头升降;`+` / `-`: 调整镜头移动速度。 +- FaceCam 会自动对齐当前操控角色的头部骨骼动画朝向,并持续跟随战斗中的变身角色(例如卡斯兰娜)。鼠标滚轮可临时调节视距。 +- 战斗中开启 Freecam 时,系统会自动通过 `InControlInputModule.allowMouseInput` 并挂起鼠标按键绑定,屏蔽 UI 点击干扰,同时保留键盘技能按键的正常使用。 + +--- + +### 🚀 Windows 快速上手 + +#### 环境需求 +1. Windows 操作系统,配备 PowerShell 环境及用于首次构建的互联网连接。启动脚本会比对 `go.mod` 中声明的 Go 基础版本;若未安装或版本陈旧,将提示从官方镜像自动下载并校验 SHA-256,解压部署至 `launcher.go_install.directory`。 +2. 准备客户端与代理工具,将调度请求重定向至 `server.json` 配置的地址。 +3. 标准可执行程序依赖根目录的 `resources/` 资产;`for ship` 目录下的独立分发版已内置打包资源。 +4. 运行 `run.bat` 并选择对应菜单项: + +| 选项 | 功能描述 | +| :---: | :--- | +| `1` 或 回车 | 编译 SDK 与 Game 服务器,启动双端服务并调起客户端 | +| `2` | 直接运行已编译的现成 `.exe` 双端服务器 | +| `3` | 仅启动 SDK 调度服务器 | +| `4` | 仅启动 Game 游戏主服务器 | +| `5` | 启动双端服务器并同时调起 Gay Proxy 代理工具 | +| `6` | 仅执行双端 `.exe` 编译 | +| `7` | 退出启动器 | +| `8` 或 `stop` | 停止本项目关联的所有服务端进程 | +| `9` 或 `requirements` | 检查本地 Go 环境并支持一键下载安装 | + +SDK 服务与 Game 服务将分别在独立的控制台窗口中启动并打印实时运行日志。启动菜单在操作完成后 5 秒将自动退出,后台服务端继续运行。使用 `run.bat stop` 或直接关闭控制台窗口即可终止服务。 + +--- + +### ⚙️ 配置与防硬编码规范 (`AGENTS.md`) + +依据项目核心准则 `AGENTS.md`,严禁在源码或启动脚本中硬编码任何特定机器路径、用户名、IP、端口或游戏部署版本。 + +配置加载优先级:**环境变量 > `server.local.json` > `server.json`** + +- `server.json`: 启动器、Game 游戏服与 SDK 调度服共享的核心全局配置文件。 +- `server.local.json`: 本机特化配置文件(被 Git 忽略)。可通过复制 `server.local.example.json` 创建。 +- `GAC_CONFIG`: 可指定自定义主配置文件路径。 + +| 配置字段 | 环境变量覆盖 | 用途说明 | +| :--- | :--- | :--- | +| `account_name` | `GAC_ACCOUNT_NAME` | SDK 登录响应返回的用户名及游戏内展示昵称 | +| `player_uid` | `GAC_PLAYER_UID` | 玩家数字 UID (1–4294967295),双端共用 | +| `client_version` | `GAC_CLIENT_VERSION` | 日志及 API 标识的客户端版本号 | +| `game_host`, `game_port` | `GAC_GAME_HOST`, `GAC_GAME_PORT` | Game 服务端本地监听地址与端口 | +| `sdk_host`, `sdk_port` | `GAC_SDK_HOST`, `GAC_SDK_PORT` | SDK 调度服本地监听地址与端口 | +| `public_game_host` | `GAC_PUBLIC_GAME_HOST` | 告知客户端的 Game 网关外部连接地址 | +| `public_sdk_url` | `GAC_PUBLIC_SDK_URL` | 告知客户端的 Dispatch 调度基础 URL | +| `launcher.client_path` | `GAC_CLIENT_PATH` | 游戏客户端可执行程序或启动器路径 | +| `launcher.proxy_path` | `GAC_PROXY_PATH` | 代理软件可执行文件路径 | +| `launcher.go_command` | `GAC_GO_COMMAND` | 自定义 Go 解释器可执行路径 | + +--- + +### 💖 鸣谢与致敬 (Credits & Acknowledgments) + +`GaCy GO` 的成长离不开开源社区与星穹铁道逆向工程先驱者的探索沉淀。我们由衷向以下项目及团队致以最诚挚的敬意与谢意: + +
+🏛️ 服务端架构与协议基础 (Server Architecture & Protocol) + +- **PearlSR ([HonkaiSlopRail/pearl-sr](https://git.xeondev.com/HonkaiSlopRail/pearl-sr))**: + - 参考 Go 语言服务端模拟器架构设计,提供 Dispatch 分发调度、网关连接管理与底层数据包处理基石。 + - 上游 Opcode 及 Command ID 定义索引 (`protocol/proto/upstream_cmdids.go`)。 + +- **LunarCore ([Melledy/LunarCore](https://github.com/Melledy/LunarCore))**: + - 由 Melledy 及贡献者开创的 Java 版星穹铁道服务端模拟器 —— 战斗流程状态机、数据包字段结构及抽卡概率模型的行业奠基之作。 + +- **FreeSR Team**: + - 奠定玩家核心数据与库存的 JSON 数据模型规范 (`freesr-data.json`),用于读取与持久化角色库存档案。 + +- **March7thHoney ([Mar7thLover/March7thHoney-OpenSource](https://github.com/Mar7thLover/March7thHoney-OpenSource))**: + - 高性能 C#/.NET 星穹铁道服务端,在抽卡保底算法、商店机制以及 Muip 远程运维协议设计上提供了重要参考。 + +- **FFGO ([git.kain.io.vn](https://git.kain.io.vn/))**: + - 开源服务端协议研究与参照标杆,在网络工作流阶段流转(Workflow Transitions)及包体兼容性校验上提供了强力参照。 + +
+ +
+🎮 玩法模式与逻辑模拟 (Game Modes & Simulation) + +- **Currency-War-SR ([xaodev/Currency-War-SR](https://gaytea.cod-algieba.ts.net/xaodev/Currency-War-SR))**: + - 在 AGPL-3.0 协议下部分移植(保留于 `third_party/currency-war-sr`)。 + - 提供 4.6.53 货币战争(自走棋玩法)的核心模拟逻辑架构。 + - 角色/羁绊矩阵、补给后遭遇战生成算法、难度数值换算与经验成长规则 (`internal/common/currency_wars_xaodev.go`, `internal/gameserver/currency_wars_xaodev.go`, `resources/CurrencyWarsXaodev.json`)。 + +- **minatoSR ([haltinggoalies/minatoSR](https://github.com/haltinggoalies/minatoSR))**: + - 提供了裁决之巅 / 幽影歧路 (Anomaly Arbitration) 全 40 个节点的 NPC 场景坐标与关卡覆盖数据 (`resources/ChallengePeakSceneOverrides.json`)。 + +
+ +
+📦 游戏资源与逆向分析工具 (Game Data & Tooling) + +- **Dimbreath ([DimbreathBot/TurnBasedGameData](https://github.com/DimbreathBot/TurnBasedGameData))**: + - 核心 Excel 数据资产导出 (`ExcelBinOutput`)、哈希校验表与角色、物品、遗器、技能 ID 索引资源。 + +- **Prydwen Institute ([Prydwen.gg](https://www.prydwen.gg/star-rail/))**: + - 提供角色配装养成指南、遗器主副词条推荐及梯队强度评估数据 (`resources/PrydwenRecommendations.json`)。 + +- **AstralOS ([Ma3-OS/AstralOS](https://gaytea.cod-algieba.ts.net/AstralOS/AstralOS))**: + - 高性能协议分析工具箱、IL2CPP 元数据转储器、网络 Hook 框架以及前期架构逆向分析的重要支持工具。 + +
+ + +--- + +## 📜 许可证与修改声明要求 (License & Attribution Notice) + +本项目遵循 **Apache License 2.0** 与 **GNU General Public License v3 (GPL-3.0)** 许可证规范,严格执行 **原始出处署名 (Attribution)** 与 **修改内容声明 (State Changes Notice)** 要求。 + +任何个人或组织若引用、Fork、修改、二次开发或重新分发本项目的任何代码、脚本或工具组件,**必须严格遵守以下条款**: + +1. **强制保留原作者署名与原始出处 (Attribution):** + - 必须在项目显著位置明确注明本项目名称,并附带指向本原始仓库的有效链接。 + - 必须在源码及所有副本中完整保留原有的版权声明、作者名单及原始许可证声明文件。 + +2. **必须明确注明修改内容与变更记录 (State Changes Notice):** + - 依照 Apache-2.0 第 4(b) 条及 GPL-3.0 第 5(a) 条之规定,**凡对原有文件或代码进行过任何修改、增删、重构或补充的部分,必须在相关文件中附带显著的修改通知(Prominent Notice),明确说明修改了哪些内容及修改时间**。 + - 严禁隐瞒原作者出处、抹除原作者信息或谎称修改后的衍生版本为独立原创作品。 + +3. **非商业用途与免责声明 (Non-Commercial Purpose):** + - 本项目仅供网络协议逆向、学术研究与个人学习交流使用,严禁用于任何商业牟利、付费打包或转售行为。 diff --git a/freesr-data.json b/freesr-data.json index 4cbb363..fb2ce2b 100644 --- a/freesr-data.json +++ b/freesr-data.json @@ -5250,9 +5250,18 @@ "stage_id": 30511022 }, "gacha_limited": { - "since_five_star": 0, + "since_five_star": 10, "since_four_star": 0, - "total_draws": 160 + "total_draws": 170 + }, + "gacha_pity": { + "11": { + "since_five_star": 10, + "since_four_star": 0, + "guaranteed_five_star": false, + "guaranteed_four_star": true, + "total_pulls": 170 + } }, "lightcones": [ { @@ -7894,6 +7903,86 @@ "level": 1, "promotion": 0, "rank": 1 + }, + { + "level": 1, + "item_id": 20008, + "equip_avatar": 0, + "rank": 1, + "promotion": 0, + "internal_uid": 330 + }, + { + "level": 1, + "item_id": 20013, + "equip_avatar": 0, + "rank": 1, + "promotion": 0, + "internal_uid": 331 + }, + { + "level": 1, + "item_id": 20009, + "equip_avatar": 0, + "rank": 1, + "promotion": 0, + "internal_uid": 332 + }, + { + "level": 1, + "item_id": 20010, + "equip_avatar": 0, + "rank": 1, + "promotion": 0, + "internal_uid": 333 + }, + { + "level": 1, + "item_id": 20009, + "equip_avatar": 0, + "rank": 1, + "promotion": 0, + "internal_uid": 334 + }, + { + "level": 1, + "item_id": 20019, + "equip_avatar": 0, + "rank": 1, + "promotion": 0, + "internal_uid": 335 + }, + { + "level": 1, + "item_id": 20003, + "equip_avatar": 0, + "rank": 1, + "promotion": 0, + "internal_uid": 336 + }, + { + "level": 1, + "item_id": 20012, + "equip_avatar": 0, + "rank": 1, + "promotion": 0, + "internal_uid": 337 + }, + { + "level": 1, + "item_id": 20017, + "equip_avatar": 0, + "rank": 1, + "promotion": 0, + "internal_uid": 338 + }, + { + "level": 1, + "item_id": 21009, + "equip_avatar": 0, + "rank": 1, + "promotion": 0, + "internal_uid": 339 } ], "materials": { @@ -7948,9 +8037,44 @@ "11513": 1, "18001": 2, "18003": 2, - "18009": 1 + "18009": 1, + "251": 180, + "252": 8 }, "relics": [ + { + "level": 15, + "relic_id": 63281, + "main_affix_id": 1, + "sub_affixes": [ + { + "sub_affix_id": 1, + "cnt": 2, + "count": 2, + "step": 1 + }, + { + "sub_affix_id": 2, + "cnt": 2, + "count": 2, + "step": 1 + }, + { + "sub_affix_id": 3, + "cnt": 3, + "count": 3, + "step": 2 + }, + { + "sub_affix_id": 4, + "cnt": 4, + "count": 4, + "step": 2 + } + ], + "internal_uid": 1207, + "equip_avatar": 0 + }, { "equip_avatar": 1001, "internal_uid": 0, @@ -44132,35 +44256,35 @@ ] }, { - "internal_uid": 1206, - "relic_id": 63106, - "level": 15, "equip_avatar": 1412, + "internal_uid": 1206, + "level": 15, "main_affix_id": 2, + "relic_id": 63106, "sub_affixes": [ { - "sub_affix_id": 7, "cnt": 4, "count": 4, - "step": 2 + "step": 2, + "sub_affix_id": 7 }, { - "sub_affix_id": 11, "cnt": 3, "count": 3, - "step": 2 + "step": 2, + "sub_affix_id": 11 }, { - "sub_affix_id": 4, "cnt": 1, "count": 1, - "step": 2 + "step": 2, + "sub_affix_id": 4 }, { - "sub_affix_id": 6, "cnt": 1, "count": 1, - "step": 2 + "step": 2, + "sub_affix_id": 6 } ] } diff --git a/hotfix.json b/hotfix.json index c039f6e..ea5e8aa 100644 --- a/hotfix.json +++ b/hotfix.json @@ -54,5 +54,23 @@ "ifix_version": "0", "lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_16819203_679727d45bd7_91ccc9b73f52b7", "lua_version": "" + }, + "OSBETAAndroid4.6.53": { + "asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_16818953_d4345bbd54a2_ae00cce42191d1", + "asset_bundle_url_b": "https://autopatchos.starrails.com/asb/BetaLive/output_16818953_d4345bbd54a2_ae00cce42191d1", + "ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_16833202_959c316ef547_335871eaabab71", + "lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_16819203_679727d45bd7_d086e33f0af119", + "ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4", + "lua_version": "16819203", + "ifix_version": "0" + }, + "OSBETAAndroid4.6.54": { + "asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_16927089_a94e4821d0e7_f07f57da7e245a", + "asset_bundle_url_b": "https://autopatchos.starrails.com/asb/BetaLive/output_16927089_a94e4821d0e7_f07f57da7e245a", + "ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_16932918_4a2e3f79111d_3e2af64ea2b108", + "lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_16927297_c46b951e256b_0abc31ea529d71", + "ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4", + "lua_version": "16927297", + "ifix_version": "0" } -} \ No newline at end of file +} diff --git a/internal/common/banners.go b/internal/common/banners.go new file mode 100644 index 0000000..fe93b88 --- /dev/null +++ b/internal/common/banners.go @@ -0,0 +1,865 @@ +package common + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "math/big" + "sync" + "time" + + pb "gac-hkrpg-go/protocol/proto" +) + +type GachaType string + +const ( + GachaTypeUnknown GachaType = "Unknown" + GachaTypeNewbie GachaType = "Newbie" + GachaTypeNormal GachaType = "Normal" + GachaTypeAvatarUp GachaType = "AvatarUp" + GachaTypeWeaponUp GachaType = "WeaponUp" + GachaTypeCollaborationAvatarUp GachaType = "CollaborationAvatarUp" + GachaTypeCollaborationWeaponUp GachaType = "CollaborationWeaponUp" +) + +type GachaPityFamily int + +const ( + GachaPityFamilyUnknown GachaPityFamily = 0 + GachaPityFamilyNewbie GachaPityFamily = 1 + GachaPityFamilyNormal GachaPityFamily = 2 + GachaPityFamilyAvatarCollaboration GachaPityFamily = 5 + GachaPityFamilyWeaponCollaboration GachaPityFamily = 6 + GachaPityFamilyAvatarUp GachaPityFamily = 11 + GachaPityFamilyWeaponUp GachaPityFamily = 12 +) + +type GachaBanner struct { + ID uint32 `json:"id"` + GachaType GachaType `json:"gachaType"` + BeginTime int64 `json:"beginTime"` + EndTime int64 `json:"endTime"` + RateUpItems5 []uint32 `json:"rateUpItems5"` + RateUpItems4 []uint32 `json:"rateUpItems4"` +} + +type GachaPityState struct { + PullsSinceFiveStar uint32 `json:"since_five_star"` + PullsSinceFourStarOrHigher uint32 `json:"since_four_star"` + GuaranteedFiveStar bool `json:"guaranteed_five_star"` + GuaranteedFourStar bool `json:"guaranteed_four_star"` + TotalPulls uint32 `json:"total_pulls"` + NewbieFiveStarClaimed bool `json:"newbie_five_star_claimed,omitempty"` +} + +func (s *GachaPityState) RecordFiveStar() { + s.PullsSinceFiveStar = 0 + s.PullsSinceFourStarOrHigher = 0 + s.TotalPulls++ +} + +func (s *GachaPityState) RecordFourStar() { + s.PullsSinceFiveStar++ + s.PullsSinceFourStarOrHigher = 0 + s.TotalPulls++ +} + +func (s *GachaPityState) RecordThreeStar() { + s.PullsSinceFiveStar++ + s.PullsSinceFourStarOrHigher++ + s.TotalPulls++ +} + +func (b *GachaBanner) IsActiveAt(unixSeconds int64) bool { + if b.BeginTime > 0 && unixSeconds < b.BeginTime { + return false + } + if b.EndTime > 0 && unixSeconds > b.EndTime { + return false + } + return true +} + +func (b *GachaBanner) CostItemID() uint32 { + switch b.GachaType { + case GachaTypeNewbie, GachaTypeNormal: + return 101 // Star Rail Pass + case GachaTypeAvatarUp, GachaTypeWeaponUp, GachaTypeCollaborationAvatarUp, GachaTypeCollaborationWeaponUp: + return 102 // Star Rail Special Pass + default: + return 0 + } +} + +func (b *GachaBanner) IsLightConeBanner() *bool { + switch b.GachaType { + case GachaTypeAvatarUp, GachaTypeCollaborationAvatarUp: + val := false + return &val + case GachaTypeWeaponUp, GachaTypeCollaborationWeaponUp: + val := true + return &val + default: + return nil + } +} + +func (b *GachaBanner) PityFamily() GachaPityFamily { + switch b.GachaType { + case GachaTypeNewbie: + return GachaPityFamilyNewbie + case GachaTypeNormal: + return GachaPityFamilyNormal + case GachaTypeWeaponUp: + return GachaPityFamilyWeaponUp + case GachaTypeAvatarUp: + return GachaPityFamilyAvatarUp + case GachaTypeCollaborationAvatarUp: + return GachaPityFamilyAvatarCollaboration + case GachaTypeCollaborationWeaponUp: + return GachaPityFamilyWeaponCollaboration + default: + return GachaPityFamilyNormal + } +} + +func (b *GachaBanner) ToProtoInfo() *pb.GachaInfo { + info := &pb.GachaInfo{ + GachaId: b.ID, + DetailUrl: "", + HistoryUrl: "", + BeginTime: b.BeginTime, + EndTime: b.EndTime, + } + if info.EndTime == 0 { + info.EndTime = 4102416000 + } + + // 4.6 client wire mapping: + // Wire #13 (ONIHOMCELJK) = rateUpItems5 (featured 5-star character/lightcone) + // Wire #10 (PrizeItemList) = rateUpItems4 + rateUpItems5 distinct (detail / capsules) + info.ONIHOMCELJK = append([]uint32(nil), b.RateUpItems5...) + + seen := make(map[uint32]bool) + for _, id := range append(b.RateUpItems4, b.RateUpItems5...) { + if !seen[id] && id > 0 { + seen[id] = true + info.PrizeItemList = append(info.PrizeItemList, id) + } + } + + if b.ID == 1001 { + goldAvatars := []uint32{1003, 1004, 1101, 1107, 1104, 1209, 1211} + ceilingAvatars := make([]*pb.GachaCeilingAvatar, 0, len(goldAvatars)) + for _, aid := range goldAvatars { + ceilingAvatars = append(ceilingAvatars, &pb.GachaCeilingAvatar{AvatarId: aid}) + } + info.GachaCeiling = &pb.GachaCeiling{ + IsClaimed: true, + AvatarList: ceilingAvatars, + } + } else if b.GachaType == GachaTypeAvatarUp || b.GachaType == GachaTypeCollaborationAvatarUp { + info.ItemDetailList = []uint32{11} + } + + return info +} + +var ( + bannersMu sync.RWMutex + loadedBanners []GachaBanner + loadedUnlockAllBanners []GachaBanner + bannersInitialized bool +) + +func parseBannersRaw(raw []byte) ([]GachaBanner, error) { + var list []GachaBanner + if err := json.Unmarshal(raw, &list); err == nil && len(list) > 0 { + return list, nil + } + var wrapper struct { + Banners []GachaBanner `json:"Banners"` + BannersLower []GachaBanner `json:"banners"` + } + if err := json.Unmarshal(raw, &wrapper); err == nil { + if len(wrapper.Banners) > 0 { + return wrapper.Banners, nil + } + if len(wrapper.BannersLower) > 0 { + return wrapper.BannersLower, nil + } + } + var rawList []json.RawMessage + if err := json.Unmarshal(raw, &rawList); err == nil { + var banners []GachaBanner + for _, item := range rawList { + var b GachaBanner + if err := json.Unmarshal(item, &b); err == nil && b.ID > 0 { + banners = append(banners, b) + } + } + if len(banners) > 0 { + return banners, nil + } + } + return nil, fmt.Errorf("no valid banners parsed") +} + +func LoadBannersConfig() error { + bannersMu.Lock() + defer bannersMu.Unlock() + + rawBase, err := ReadResourceFile("resources/Banners.json") + if err != nil { + return fmt.Errorf("failed to read resources/Banners.json: %w", err) + } + baseList, err := parseBannersRaw(rawBase) + if err != nil { + return fmt.Errorf("failed to parse resources/Banners.json: %w", err) + } + + var unlockList []GachaBanner + rawUnlock, err := ReadResourceFile("resources/BannersUnlockAll.json") + if err == nil { + if list, err := parseBannersRaw(rawUnlock); err == nil { + unlockList = list + } + } + + loadedBanners = baseList + loadedUnlockAllBanners = unlockList + bannersInitialized = true + return nil +} + +func ReloadBanners() error { + return LoadBannersConfig() +} + +func ensureBannersLoaded() { + bannersMu.RLock() + init := bannersInitialized + bannersMu.RUnlock() + if !init { + _ = LoadBannersConfig() + } +} + +// GetVisibleBanners returns active banners according to the player's WarpUnlockMode: +// 0 = only active banners from Banners.json +// 1 = all banners (Banners.json + BannersUnlockAll.json) +// 2 = character banners (Banners.json + character banners from BannersUnlockAll.json) +// 3 = lightcone banners (Banners.json + lightcone banners from BannersUnlockAll.json) +func GetVisibleBanners(mode int) []GachaBanner { + ensureBannersLoaded() + + bannersMu.RLock() + defer bannersMu.RUnlock() + + now := time.Now().Unix() + if mode == 0 { + out := make([]GachaBanner, 0, len(loadedBanners)) + for _, b := range loadedBanners { + if b.IsActiveAt(now) { + out = append(out, b) + } + } + return out + } + + out := make([]GachaBanner, 0, len(loadedBanners)+len(loadedUnlockAllBanners)) + ids := make(map[uint32]bool) + featured := make(map[[2]uint32]bool) + + for _, b := range loadedBanners { + if b.IsActiveAt(now) { + lc := b.IsLightConeBanner() + if mode == 2 && lc != nil && *lc { + continue // character only + } + if mode == 3 && lc != nil && !*lc { + continue // lightcone only + } + out = append(out, b) + ids[b.ID] = true + if lc != nil { + var lcFlag uint32 + if *lc { + lcFlag = 1 + } + for _, item := range b.RateUpItems5 { + featured[[2]uint32{lcFlag, item}] = true + } + } + } + } + + for _, b := range loadedUnlockAllBanners { + lc := b.IsLightConeBanner() + if lc == nil { + continue + } + if mode == 2 && *lc { + continue // character only + } + if mode == 3 && !*lc { + continue // lightcone only + } + if len(b.RateUpItems5) == 0 { + continue + } + var lcFlag uint32 + if *lc { + lcFlag = 1 + } + if featured[[2]uint32{lcFlag, b.RateUpItems5[0]}] { + continue // already featured by active banner + } + if ids[b.ID] { + continue + } + if !b.IsActiveAt(now) { + continue + } + ids[b.ID] = true + out = append(out, b) + } + + return out +} + +func FindGachaBanner(gachaID uint32, mode int) *GachaBanner { + visible := GetVisibleBanners(mode) + for i := range visible { + if visible[i].ID == gachaID { + return &visible[i] + } + } + // Fallback to any loaded banner so an in-flight pull is never rejected + bannersMu.RLock() + defer bannersMu.RUnlock() + for i := range loadedBanners { + if loadedBanners[i].ID == gachaID { + return &loadedBanners[i] + } + } + for i := range loadedUnlockAllBanners { + if loadedUnlockAllBanners[i].ID == gachaID { + return &loadedUnlockAllBanners[i] + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Gacha Pools & Probabilities +// --------------------------------------------------------------------------- + +var ( + StandardGoldAvatars = []uint32{1003, 1004, 1101, 1107, 1104, 1209, 1211} + StandardGoldWeapons = []uint32{23000, 23002, 23003, 23004, 23005, 23012, 23013} +) + +func FiveStarChance90(pullNumber uint32) float64 { + if pullNumber >= 90 { + return 1.0 + } + if pullNumber >= 76 { + return 0.324 + } + return 0.006 +} + +func FiveStarChance80(pullNumber uint32) float64 { + if pullNumber >= 80 { + return 1.0 + } + if pullNumber >= 66 { + return 0.219633 + } + return 0.008 +} + +func FiveStarChanceNewbie(pullNumber uint32) float64 { + if pullNumber >= 50 { + return 1.0 + } + return 0.006 +} + +func cryptoRandFloat() float64 { + n, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + return 0.5 + } + return float64(n.Int64()) / 1000000.0 +} + +func cryptoPickOne(list []uint32) uint32 { + if len(list) == 0 { + return 0 + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(list)))) + if err != nil { + return list[0] + } + return list[n.Int64()] +} + +func withoutItems(source, excluded []uint32) []uint32 { + exMap := make(map[uint32]bool, len(excluded)) + for _, id := range excluded { + exMap[id] = true + } + out := make([]uint32, 0, len(source)) + for _, id := range source { + if !exMap[id] { + out = append(out, id) + } + } + return out +} + +func pickByCategory(avatars, weapons []uint32) uint32 { + if len(avatars) == 0 { + return cryptoPickOne(weapons) + } + if len(weapons) == 0 { + return cryptoPickOne(avatars) + } + if cryptoRandFloat() < 0.5 { + return cryptoPickOne(avatars) + } + return cryptoPickOne(weapons) +} + +func loadGachaItemCatalogs() (purpleAvatars, purpleWeapons, blueWeapons []uint32, err error) { + // 4-star avatars from AvatarConfig.json + rawAv, err := ReadResourceFile("resources/AvatarConfig.json") + if err == nil { + var doc struct { + Avatars []struct { + AvatarID uint32 `json:"AvatarID"` + Rarity string `json:"Rarity"` + MaxRank uint32 `json:"MaxRank"` + } `json:"avatar_config"` + } + if json.Unmarshal(rawAv, &doc) == nil { + for _, a := range doc.Avatars { + if a.Rarity == "CombatPowerAvatarRarityType4" && a.MaxRank > 0 && a.AvatarID < 8000 { + purpleAvatars = append(purpleAvatars, a.AvatarID) + } + } + } + } + if len(purpleAvatars) == 0 { + purpleAvatars = []uint32{ + 1001, 1002, 1008, 1009, 1013, 1103, 1105, 1106, 1108, 1109, + 1110, 1111, 1201, 1202, 1206, 1207, 1210, 1214, 1215, 1223, + 1224, 1301, 1312, + } + } + + // Lightcones from GachaLootCatalog.json + rawLoot, err := ReadResourceFile("resources/GachaLootCatalog.json") + if err == nil { + var doc struct { + Lightcones []struct { + ID uint32 `json:"id"` + Rarity uint32 `json:"rarity"` + } `json:"lightcones"` + } + bpCones := map[uint32]bool{ + 21028: true, 21029: true, 21030: true, 21031: true, 21032: true, 21033: true, 21034: true, + 21052: true, 21053: true, 21055: true, 21056: true, 21057: true, 21058: true, 21060: true, + 21061: true, 21062: true, 21065: true, + } + if json.Unmarshal(rawLoot, &doc) == nil { + for _, c := range doc.Lightcones { + if c.Rarity == 3 { + blueWeapons = append(blueWeapons, c.ID) + } else if c.Rarity == 4 && !bpCones[c.ID] && !(c.ID >= 22000 && c.ID < 23000) { + purpleWeapons = append(purpleWeapons, c.ID) + } + } + } + } + if len(blueWeapons) == 0 { + blueWeapons = []uint32{20000, 20001, 20002, 20003, 20004, 20005, 20006, 20007, 20008, 20009} + } + if len(purpleWeapons) == 0 { + purpleWeapons = []uint32{21000, 21001, 21002, 21003, 21004, 21005, 21006, 21007, 21008, 21009} + } + return purpleAvatars, purpleWeapons, blueWeapons, nil +} + +func (b *GachaBanner) ResolveFiveStarItem(state *GachaPityState, standardAvatars, standardWeapons []uint32) uint32 { + featured := b.RateUpItems5 + switch b.GachaType { + case GachaTypeNewbie: + return cryptoPickOne(standardAvatars) + case GachaTypeWeaponUp, GachaTypeCollaborationWeaponUp: + return resolveFeaturedOrStandard(state, featured, standardWeapons, 0.75) + case GachaTypeAvatarUp, GachaTypeCollaborationAvatarUp: + return resolveFeaturedOrStandard(state, featured, standardAvatars, 0.50) + default: + return pickByCategory(standardAvatars, standardWeapons) + } +} + +func resolveFeaturedOrStandard(state *GachaPityState, featured, standard []uint32, featuredChance float64) uint32 { + if len(featured) == 0 { + return cryptoPickOne(standard) + } + if state.GuaranteedFiveStar || cryptoRandFloat() < featuredChance || len(standard) == 0 { + state.GuaranteedFiveStar = false + return cryptoPickOne(featured) + } + state.GuaranteedFiveStar = true + pool := withoutItems(standard, featured) + if len(pool) == 0 { + pool = standard + } + return cryptoPickOne(pool) +} + +func (b *GachaBanner) ResolveFourStarItem(state *GachaPityState, purpleAvatars, purpleWeapons []uint32) uint32 { + isWeaponPool := b.GachaType == GachaTypeWeaponUp || b.GachaType == GachaTypeCollaborationWeaponUp + featuredChance := 0.50 + if isWeaponPool { + featuredChance = 0.75 + } + + avMap := make(map[uint32]bool, len(purpleAvatars)) + for _, id := range purpleAvatars { + avMap[id] = true + } + wpMap := make(map[uint32]bool, len(purpleWeapons)) + for _, id := range purpleWeapons { + wpMap[id] = true + } + + var featured []uint32 + for _, id := range b.RateUpItems4 { + if isWeaponPool { + if wpMap[id] { + featured = append(featured, id) + } + } else { + if avMap[id] || wpMap[id] { + featured = append(featured, id) + } + } + } + + allStandard := append(append([]uint32(nil), purpleAvatars...), purpleWeapons...) + if len(featured) == 0 { + if isWeaponPool { + return cryptoPickOne(purpleWeapons) + } + return pickByCategory(purpleAvatars, purpleWeapons) + } + + standard := withoutItems(allStandard, featured) + if state.GuaranteedFourStar || cryptoRandFloat() < featuredChance || len(standard) == 0 { + state.GuaranteedFourStar = false + return cryptoPickOne(featured) + } + state.GuaranteedFourStar = true + return cryptoPickOne(standard) +} + +// --------------------------------------------------------------------------- +// Gacha Pull Execution & Duplicate Granting +// --------------------------------------------------------------------------- + +type GachaDrawItemResult struct { + ItemID uint32 + Rarity uint32 // 3, 4, 5 + IsAvatar bool + IsNew bool + Lightcone *Lightcone + TransferItemList []*pb.Item + TokenItemList []*pb.Item +} + +type GachaDrawBatchResult struct { + Items []GachaDrawItemResult + PityState GachaPityState +} + +func (b *GachaBanner) RollSingle(state *GachaPityState, purpleAvatars, purpleWeapons, blueWeapons []uint32) (uint32, uint32) { + pullNumber5 := state.PullsSinceFiveStar + 1 + pullNumber4 := state.PullsSinceFourStarOrHigher + 1 + + var fiveStarProb float64 + var fourStarProb float64 = 0.051 + + switch b.GachaType { + case GachaTypeNewbie: + fiveStarProb = FiveStarChanceNewbie(pullNumber5) + case GachaTypeWeaponUp, GachaTypeCollaborationWeaponUp: + fiveStarProb = FiveStarChance80(pullNumber5) + fourStarProb = 0.066 + default: + fiveStarProb = FiveStarChance90(pullNumber5) + } + + if cryptoRandFloat() < fiveStarProb { + item := b.ResolveFiveStarItem(state, StandardGoldAvatars, StandardGoldWeapons) + state.RecordFiveStar() + if b.GachaType == GachaTypeNewbie { + state.NewbieFiveStarClaimed = true + } + return item, 5 + } + + if pullNumber4 >= 10 || cryptoRandFloat() < fourStarProb { + item := b.ResolveFourStarItem(state, purpleAvatars, purpleWeapons) + state.RecordFourStar() + return item, 4 + } + + item := cryptoPickOne(blueWeapons) + state.RecordThreeStar() + return item, 3 +} + +// PerformGachaRolls executes `times` pulls and atomically updates freesr-data.json +func PerformGachaRolls(banner *GachaBanner, times uint32, player *FreesrData) (*GachaDrawBatchResult, error) { + if times != 1 && times != 10 { + return nil, fmt.Errorf("invalid gacha times %d", times) + } + + purpleAvatars, purpleWeapons, blueWeapons, err := loadGachaItemCatalogs() + if err != nil { + return nil, err + } + + family := banner.PityFamily() + familyKey := fmt.Sprintf("%d", family) + + persistentWriteMu.Lock() + defer persistentWriteMu.Unlock() + unlock, err := lockPersistentFile() + if err != nil { + return nil, err + } + defer unlock() + + root, err := readLimitedRoot() + if err != nil { + return nil, err + } + + // Load pity states + pityMap := make(map[string]GachaPityState) + if raw, ok := root["gacha_pity"]; ok && len(raw) > 0 { + _ = json.Unmarshal(raw, &pityMap) + } + pityState := pityMap[familyKey] + + // Migrate from legacy gacha_limited if empty and AvatarUp + if family == GachaPityFamilyAvatarUp && pityState.TotalPulls == 0 { + if leg, err := decodeLimitedPity(root); err == nil && leg.TotalDraws > 0 { + pityState.PullsSinceFiveStar = leg.SinceFiveStar + pityState.PullsSinceFourStarOrHigher = leg.SinceFourStar + pityState.TotalPulls = uint32(leg.TotalDraws) + } + } + + // Load materials + materials := make(map[uint32]uint32) + if raw, ok := root["materials"]; ok { + _ = json.Unmarshal(raw, &materials) + } + if player.Materials == nil { + player.Materials = make(map[uint32]uint32) + } + for k, v := range materials { + player.Materials[k] = v + } + + // Load avatars + var avatarDoc map[uint32]json.RawMessage + if raw, ok := root["avatars"]; ok { + _ = json.Unmarshal(raw, &avatarDoc) + } + if avatarDoc == nil { + avatarDoc = make(map[uint32]json.RawMessage) + } + + // Load lightcones + var coneDoc []json.RawMessage + if raw, ok := root["lightcones"]; ok { + _ = json.Unmarshal(raw, &coneDoc) + } + var maxUID uint32 + for _, raw := range coneDoc { + var lc Lightcone + if json.Unmarshal(raw, &lc) == nil && lc.InternalUID > maxUID { + maxUID = lc.InternalUID + } + } + for _, lc := range player.Lightcones { + if lc.InternalUID > maxUID { + maxUID = lc.InternalUID + } + } + + results := make([]GachaDrawItemResult, 0, times) + + for i := uint32(0); i < times; i++ { + itemID, rarity := banner.RollSingle(&pityState, purpleAvatars, purpleWeapons, blueWeapons) + if itemID == 0 { + itemID = cryptoPickOne(blueWeapons) + rarity = 3 + } + + res := GachaDrawItemResult{ + ItemID: itemID, + Rarity: rarity, + } + + isAvatar := itemID < 20000 + + if isAvatar { + res.IsAvatar = true + existingAvatar, owned := player.Avatars[itemID] + if !owned || existingAvatar == nil { + // First acquisition + res.IsNew = true + newAv := &AvatarJson{ + AvatarID: itemID, + Level: 1, + Promotion: 0, + Techniques: []uint32{}, + Data: AvatarData{ + Rank: 0, + Skills: make(map[uint32]uint32), + SkillsByAnchorType: make(map[uint32]uint32), + }, + } + player.Avatars[itemID] = newAv + raw, _ := json.Marshal(newAv) + avatarDoc[itemID] = raw + } else { + // Duplicate conversion: Eidolon item + Starlight (252) + res.IsNew = false + rankUpItemID := itemID + 10000 + curRank := existingAvatar.Data.Rank + materials[rankUpItemID] + var starlight uint32 + if curRank < 6 { + materials[rankUpItemID]++ + player.Materials[rankUpItemID] = materials[rankUpItemID] + res.TransferItemList = append(res.TransferItemList, &pb.Item{ + ItemId: rankUpItemID, + Num: 1, + }) + if rarity == 5 { + starlight = 40 + } else { + starlight = 8 + } + } else { + if rarity == 5 { + starlight = 100 + } else { + starlight = 20 + } + } + if starlight > 0 { + materials[252] += starlight + player.Materials[252] = materials[252] + res.TokenItemList = append(res.TokenItemList, &pb.Item{ + ItemId: 252, + Num: starlight, + }) + } + } + } else { + // Lightcone + res.IsAvatar = false + maxUID++ + lc := Lightcone{ + ItemID: itemID, + InternalUID: maxUID, + Level: 1, + Rank: 1, + Promotion: 0, + } + player.Lightcones = append(player.Lightcones, lc) + raw, _ := json.Marshal(lc) + coneDoc = append(coneDoc, raw) + res.Lightcone = &lc + + var embers, starlight uint32 + if rarity == 5 { + starlight = 40 + } else if rarity == 4 { + starlight = 8 + } else { + embers = 20 + } + + if embers > 0 { + materials[251] += embers + player.Materials[251] = materials[251] + res.TokenItemList = append(res.TokenItemList, &pb.Item{ + ItemId: 251, + Num: embers, + }) + } + if starlight > 0 { + materials[252] += starlight + player.Materials[252] = materials[252] + res.TokenItemList = append(res.TokenItemList, &pb.Item{ + ItemId: 252, + Num: starlight, + }) + } + } + + results = append(results, res) + } + + // Commit state + pityMap[familyKey] = pityState + root["gacha_pity"], err = json.Marshal(pityMap) + if err != nil { + return nil, err + } + + // Mirror legacy pity for AvatarUp + if family == GachaPityFamilyAvatarUp { + legacyPity := LimitedGachaPity{ + SinceFiveStar: pityState.PullsSinceFiveStar, + SinceFourStar: pityState.PullsSinceFourStarOrHigher, + TotalDraws: uint64(pityState.TotalPulls), + } + root["gacha_limited"], _ = json.Marshal(legacyPity) + } + + root["materials"], err = json.Marshal(materials) + if err != nil { + return nil, err + } + root["avatars"], err = json.Marshal(avatarDoc) + if err != nil { + return nil, err + } + root["lightcones"], err = json.Marshal(coneDoc) + if err != nil { + return nil, err + } + + if err := writeJSONAtomic("freesr-data.json", root); err != nil { + return nil, err + } + + return &GachaDrawBatchResult{ + Items: results, + PityState: pityState, + }, nil +} diff --git a/internal/common/banners_test.go b/internal/common/banners_test.go new file mode 100644 index 0000000..aca5d86 --- /dev/null +++ b/internal/common/banners_test.go @@ -0,0 +1,171 @@ +package common + +import ( + "encoding/json" + "gac-hkrpg-go/internal/testutil" + "os" + "path/filepath" + "runtime" + "testing" +) + +func chdirRepoRoot(t *testing.T) { + t.Helper() + _, source, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate test helper source") + } + root := filepath.Clean(filepath.Join(filepath.Dir(source), "..", "..")) + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Fatalf("cannot locate repository root from %s: %v", source, err) + } + temporary := t.TempDir() + if err := testutil.CopyRuntime(root, temporary); err != nil { + t.Fatal(err) + } + t.Chdir(temporary) +} + +func TestBannersConfigLoadAndVisibility(t *testing.T) { + chdirRepoRoot(t) + if err := LoadBannersConfig(); err != nil { + t.Fatalf("failed to load banners config: %v", err) + } + + mode0 := GetVisibleBanners(0) + if len(mode0) < 13 { + t.Errorf("expected at least 13 visible banners for mode 0, got %d", len(mode0)) + } + + mode1 := GetVisibleBanners(1) + if len(mode1) < len(mode0) { + t.Errorf("expected mode 1 >= mode 0, got mode1=%d mode0=%d", len(mode1), len(mode0)) + } + + // Verify no duplicate IDs in mode 1 + seen := make(map[uint32]bool) + for _, b := range mode1 { + if seen[b.ID] { + t.Fatalf("duplicate banner ID %d found in mode 1", b.ID) + } + seen[b.ID] = true + } + + // Check mode 2 (character only) + mode2 := GetVisibleBanners(2) + if len(mode2) == 0 || len(mode2) >= len(mode1) { + t.Errorf("unexpected banner count for mode 2: %d", len(mode2)) + } + + // Check mode 3 (lightcone only) + mode3 := GetVisibleBanners(3) + if len(mode3) == 0 || len(mode3) >= len(mode1) { + t.Errorf("unexpected banner count for mode 3: %d", len(mode3)) + } +} + +func TestBannerProtoSerialization(t *testing.T) { + chdirRepoRoot(t) + if err := LoadBannersConfig(); err != nil { + t.Fatal(err) + } + + // Check banner 1001 (Standard) + b1001 := FindGachaBanner(1001, 1) + if b1001 == nil { + t.Fatal("banner 1001 not found") + } + proto1001 := b1001.ToProtoInfo() + if proto1001.GachaId != 1001 { + t.Fatalf("expected gacha_id 1001, got %d", proto1001.GachaId) + } + if proto1001.GachaCeiling == nil || !proto1001.GachaCeiling.IsClaimed || len(proto1001.GachaCeiling.AvatarList) != 7 { + t.Fatalf("expected claimed gacha ceiling with 7 avatars, got %+v", proto1001.GachaCeiling) + } + + // Check banner 2120 (AvatarUp) + b2120 := FindGachaBanner(2120, 1) + if b2120 == nil { + t.Fatal("banner 2120 not found") + } + proto2120 := b2120.ToProtoInfo() + if proto2120.GachaId != 2120 { + t.Fatalf("expected gacha_id 2120, got %d", proto2120.GachaId) + } + if len(proto2120.ONIHOMCELJK) == 0 || proto2120.ONIHOMCELJK[0] != 1505 { + t.Fatalf("expected featured 5-star 1505 in wire #5, got %+v", proto2120.ONIHOMCELJK) + } + if len(proto2120.ItemDetailList) != 1 || proto2120.ItemDetailList[0] != 11 { + t.Fatalf("expected AvatarUp marker 11 in ItemDetailList, got %+v", proto2120.ItemDetailList) + } + if len(proto2120.PrizeItemList) == 0 { + t.Fatal("expected PrizeItemList to contain rateUp items") + } +} + +func TestPityProgression(t *testing.T) { + var state GachaPityState + if FiveStarChance90(1) != 0.006 { + t.Fatalf("expected base 5-star chance 0.006, got %f", FiveStarChance90(1)) + } + if FiveStarChance90(76) != 0.324 { + t.Fatalf("expected soft pity 5-star chance 0.324, got %f", FiveStarChance90(76)) + } + if FiveStarChance90(90) != 1.0 { + t.Fatalf("expected hard pity 5-star chance 1.0, got %f", FiveStarChance90(90)) + } + + // Test pity state transitions + state.RecordThreeStar() + if state.PullsSinceFiveStar != 1 || state.PullsSinceFourStarOrHigher != 1 || state.TotalPulls != 1 { + t.Fatalf("unexpected state after 3-star: %+v", state) + } + + state.RecordFourStar() + if state.PullsSinceFiveStar != 2 || state.PullsSinceFourStarOrHigher != 0 || state.TotalPulls != 2 { + t.Fatalf("unexpected state after 4-star: %+v", state) + } + + state.RecordFiveStar() + if state.PullsSinceFiveStar != 0 || state.PullsSinceFourStarOrHigher != 0 || state.TotalPulls != 3 { + t.Fatalf("unexpected state after 5-star: %+v", state) + } +} + +func TestGachaRollExecution(t *testing.T) { + chdirRepoRoot(t) + if err := LoadBannersConfig(); err != nil { + t.Fatal(err) + } + + player, err := LoadFreesrData() + if err != nil { + t.Fatal(err) + } + + banner := FindGachaBanner(2120, 1) + if banner == nil { + t.Fatal("banner 2120 not found") + } + + res, err := PerformGachaRolls(banner, 10, player) + if err != nil { + t.Fatalf("PerformGachaRolls failed: %v", err) + } + if len(res.Items) != 10 { + t.Fatalf("expected 10 items, got %d", len(res.Items)) + } + + // Verify persistence in freesr-data.json + raw, err := os.ReadFile("freesr-data.json") + if err != nil { + t.Fatal(err) + } + var doc map[string]json.RawMessage + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + if _, ok := doc["gacha_pity"]; !ok { + t.Fatal("missing gacha_pity in freesr-data.json") + } +} diff --git a/internal/common/currency_wars_store.go b/internal/common/currency_wars_store.go index 0443b1e..0ec1920 100644 --- a/internal/common/currency_wars_store.go +++ b/internal/common/currency_wars_store.go @@ -24,6 +24,8 @@ type CurrencyWarsSnapshot struct { Revision uint64 `json:"revision"` WireData []byte `json:"wire_data"` Runtime *CurrencyWarsRuntimeState `json:"runtime,omitempty"` + Archives [][]byte `json:"archives,omitempty"` + PendingArchive []byte `json:"-"` // appended in the same CAS as settlement } // Server-side rules are kept beside, never smuggled into, the Client protocol. @@ -144,6 +146,9 @@ func commitCurrencyWarsSnapshot(expectedRevision uint64, next CurrencyWarsSnapsh return 0, fmt.Errorf("CW snapshot source differs; migration is required") } revision = previous.Revision + if next.Archives == nil { + next.Archives = previous.Archives + } if next.Runtime != nil && previous.Runtime != nil && next.Runtime.RunID == previous.Runtime.RunID && next.Runtime.Rules == nil { next.Runtime.Rules = previous.Runtime.Rules } @@ -155,6 +160,9 @@ func commitCurrencyWarsSnapshot(expectedRevision uint64, next CurrencyWarsSnapsh return 0, fmt.Errorf("CW snapshot revision exhausted") } next.Revision = revision + 1 + if len(next.PendingArchive) != 0 { + next.Archives = append(next.Archives, append([]byte(nil), next.PendingArchive...)) + } // Retain unrelated envelope fields added by tools or a newer writer. encoded, err := json.Marshal(next) if err != nil { diff --git a/internal/common/gacha_catalog.go b/internal/common/gacha_catalog.go index 2a9bcc4..6eb5421 100644 --- a/internal/common/gacha_catalog.go +++ b/internal/common/gacha_catalog.go @@ -47,6 +47,32 @@ func LoadBrowsableGachaBanners() ([]FeaturedGachaBanner, GachaSandboxPolicy, err return loadGachaBanners(false) } +// Apply operator visibility only to IDs with verified installed Client assets. +// Imported banner lists alone do not establish a compatible native pool. +func LoadVisibleGachaBanners(mode int) ([]FeaturedGachaBanner, error) { + if mode < 0 || mode > 3 { + return nil, fmt.Errorf("invalid Warp visibility mode") + } + if err := LoadBannersConfig(); err != nil { + return nil, err + } + selected := map[uint32]bool{} + for _, row := range GetVisibleBanners(mode) { + selected[row.ID] = true + } + verified, _, err := LoadBrowsableGachaBanners() + if err != nil { + return nil, err + } + var visible []FeaturedGachaBanner + for _, row := range verified { + if selected[row.ID] { + visible = append(visible, row) + } + } + return visible, nil +} + func loadGachaBanners(featuredOnly bool) ([]FeaturedGachaBanner, GachaSandboxPolicy, error) { raw, err := ReadResourceFile("resources/PreviewGameplayCatalog.json") if err != nil { diff --git a/internal/common/models.go b/internal/common/models.go index b2da08a..2786c80 100644 --- a/internal/common/models.go +++ b/internal/common/models.go @@ -371,6 +371,7 @@ type Persistent struct { PlayerOutfits []uint32 `json:"player_outfits"` SceneGroupProperties map[uint32]map[uint32]map[string]int32 `json:"scene_group_properties"` ExRanksUnlocked map[uint32]bool `json:"ex_ranks_unlocked"` + WarpUnlockMode *int `json:"warp_unlock_mode,omitempty"` } // DefaultPersistent mirrors the Rust Default impl. @@ -378,6 +379,7 @@ func DefaultPersistent() Persistent { sw := true cg := true aha := true + defaultWarpMode := 1 return Persistent{ Lineups: map[uint32]uint32{0: 1313, 1: 1006, 2: 8001, 3: 1405}, Position: DefaultPosition(), @@ -389,6 +391,7 @@ func DefaultPersistent() Persistent { EnableAhaGlobal: &aha, DressedSkins: map[uint32]uint32{}, ExRanksUnlocked: map[uint32]bool{}, + WarpUnlockMode: &defaultWarpMode, } } @@ -423,6 +426,7 @@ type FreesrData struct { DressedSkins map[uint32]uint32 `json:"-"` PlayerOutfits []uint32 `json:"-"` SceneGroupProperties map[uint32]map[uint32]map[string]int32 `json:"-"` + WarpUnlockMode int `json:"-"` } // LoadFreesrData reads freesr-data.json + persistent and merges them, @@ -467,6 +471,11 @@ func LoadFreesrData() (*FreesrData, error) { data.DressedSkins = p.DressedSkins data.PlayerOutfits = p.PlayerOutfits data.SceneGroupProperties = p.SceneGroupProperties + if p.WarpUnlockMode != nil { + data.WarpUnlockMode = *p.WarpUnlockMode + } else { + data.WarpUnlockMode = 1 + } for aid, unlocked := range p.ExRanksUnlocked { if av, ok := data.Avatars[aid]; ok { av.ExRankUnlocked = unlocked diff --git a/internal/common/persistent_store.go b/internal/common/persistent_store.go index 5b5ab47..e967a35 100644 --- a/internal/common/persistent_store.go +++ b/internal/common/persistent_store.go @@ -40,10 +40,12 @@ func (f *FreesrData) persistentSnapshot() Persistent { ex[id] = avatar.ExRankUnlocked } } + wMode := f.WarpUnlockMode return Persistent{Profile: f.Profile, TeamPresets: f.TeamPresets, ActiveTeamPreset: f.ActiveTeamPreset, TierceProgress: f.TierceProgress, TechniqueFoodStock: f.TechniqueFoodStock, AutoTechniqueFood: f.AutoTechniqueFood, Lineups: f.Lineups, Position: f.Position, Scene: f.Scene, MainCharacter: f.MainCharacter, MarchType: f.MarchType, EnableSWGlobal: f.EnableSWGlobal, EnableCastoriceGlobal: f.EnableCastoriceGlobal, - EnableAhaGlobal: f.EnableAhaGlobal, DressedSkins: f.DressedSkins, PlayerOutfits: f.PlayerOutfits, SceneGroupProperties: f.SceneGroupProperties, ExRanksUnlocked: ex} + EnableAhaGlobal: f.EnableAhaGlobal, DressedSkins: f.DressedSkins, PlayerOutfits: f.PlayerOutfits, SceneGroupProperties: f.SceneGroupProperties, ExRanksUnlocked: ex, + WarpUnlockMode: &wMode} } // Apply only locally changed fields. A stale game session saving its position @@ -152,6 +154,21 @@ func (f *FreesrData) RefreshExRanks(p *Persistent) []uint32 { // Patch only the selected avatar's rank in the latest import. Unknown FreeSR // fields and all inventory IDs/affixes retain their original JSON values. func SaveAvatarRank(id, rank uint32) error { + return SaveAvatarProgress(id, nil, &rank) +} + +// SaveAvatarProgress patches only the selected level and/or rank in the latest +// import, under the same cross-process lock used by inventory and gacha saves. +func SaveAvatarProgress(id uint32, level, rank *uint32) error { + if id == 0 || (level == nil && rank == nil) { + return fmt.Errorf("avatar ID and a change are required") + } + if level != nil && (*level < 1 || *level > 80) { + return fmt.Errorf("level must be between 1 and 80") + } + if rank != nil && *rank > 6 { + return fmt.Errorf("rank must be between 0 and 6") + } persistentWriteMu.Lock() defer persistentWriteMu.Unlock() unlock, err := lockPersistentFile() @@ -173,19 +190,30 @@ func SaveAvatarRank(id, rank uint32) error { } var avatar map[string]json.RawMessage if err := json.Unmarshal(avatars[id], &avatar); err != nil { - return err + return fmt.Errorf("avatar %d not found or invalid: %w", id, err) } - var data map[string]json.RawMessage - if err := json.Unmarshal(avatar["data"], &data); err != nil { - return err + if avatar == nil { + return fmt.Errorf("avatar %d is invalid", id) } - data["rank"], err = json.Marshal(rank) - if err != nil { - return err + if level != nil { + avatar["level"], err = json.Marshal(*level) + if err != nil { + return err + } } - avatar["data"], err = json.Marshal(data) - if err != nil { - return err + if rank != nil { + var data map[string]json.RawMessage + if err := json.Unmarshal(avatar["data"], &data); err != nil || data == nil { + return fmt.Errorf("avatar %d rank data is invalid", id) + } + data["rank"], err = json.Marshal(*rank) + if err != nil { + return err + } + avatar["data"], err = json.Marshal(data) + if err != nil { + return err + } } avatars[id], err = json.Marshal(avatar) if err != nil { diff --git a/internal/common/warp_mode.go b/internal/common/warp_mode.go new file mode 100644 index 0000000..c45469e --- /dev/null +++ b/internal/common/warp_mode.go @@ -0,0 +1,25 @@ +package common + +import ( + "encoding/json" + "fmt" +) + +func ReadWarpUnlockMode() (*int, error) { + root, err := readCurrencyWarsPersistentRoot() + if err != nil { + return nil, err + } + raw, ok := root["warp_unlock_mode"] + if !ok { + return nil, nil + } + var mode int + if err := json.Unmarshal(raw, &mode); err != nil { + return nil, err + } + if mode < 0 || mode > 3 { + return nil, fmt.Errorf("invalid saved Warp visibility mode") + } + return &mode, nil +} diff --git a/internal/common/warp_mode_test.go b/internal/common/warp_mode_test.go new file mode 100644 index 0000000..8b2c2d4 --- /dev/null +++ b/internal/common/warp_mode_test.go @@ -0,0 +1,72 @@ +package common + +import ( + "encoding/json" + "os" + "testing" +) + +func TestWarpModesUseVerifiedClientBanners(t *testing.T) { + chdirRepoRoot(t) + native, _, err := LoadBrowsableGachaBanners() + if err != nil { + t.Fatal(err) + } + if len(native) < 2 { + t.Fatal("need two verified banner assets") + } + rows := []GachaBanner{ + {ID: native[0].ID, GachaType: GachaTypeAvatarUp, RateUpItems5: []uint32{1}}, + {ID: native[1].ID, GachaType: GachaTypeWeaponUp, RateUpItems5: []uint32{2}}, + } + raw, _ := json.Marshal(rows) + if err := os.WriteFile("resources/Banners.json", raw, 0600); err != nil { + t.Fatal(err) + } + // A foreign/synthetic ID must never appear in a native Client response. + unverified := ^uint32(0) + raw, _ = json.Marshal([]GachaBanner{{ID: unverified, GachaType: GachaTypeAvatarUp, RateUpItems5: []uint32{3}}}) + if err := os.WriteFile("resources/BannersUnlockAll.json", raw, 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + mode int + want []uint32 + }{{0, []uint32{native[0].ID, native[1].ID}}, {1, []uint32{native[0].ID, native[1].ID}}, {2, []uint32{native[0].ID}}, {3, []uint32{native[1].ID}}} { + visible, err := LoadVisibleGachaBanners(tc.mode) + if err != nil { + t.Fatal(err) + } + got := map[uint32]bool{} + for _, row := range visible { + got[row.ID] = true + } + if len(got) != len(tc.want) || got[unverified] { + t.Fatalf("mode %d wrong visible IDs: %v", tc.mode, got) + } + for _, id := range tc.want { + if !got[id] { + t.Fatalf("mode %d missing %d", tc.mode, id) + } + } + } + if _, err := LoadVisibleGachaBanners(-1); err == nil { + t.Fatal("invalid mode accepted") + } +} + +func TestWarpModeReadIsReadOnlyAndKeepsOff(t *testing.T) { + t.Chdir(t.TempDir()) + original := []byte(`{"warp_unlock_mode":0,"unrelated":true}`) + if err := os.WriteFile("persistent", original, 0600); err != nil { + t.Fatal(err) + } + mode, err := ReadWarpUnlockMode() + if err != nil || mode == nil || *mode != 0 { + t.Fatalf("off lost: %v %v", mode, err) + } + after, _ := os.ReadFile("persistent") + if string(after) != string(original) { + t.Fatal("reader rewrote persistent") + } +} diff --git a/internal/gameserver/currency_wars_archive.go b/internal/gameserver/currency_wars_archive.go index 1fe192c..894a89d 100644 --- a/internal/gameserver/currency_wars_archive.go +++ b/internal/gameserver/currency_wars_archive.go @@ -2,87 +2,114 @@ package gameserver import ( "fmt" - "sync" - "time" - + "gac-hkrpg-go/internal/common" pb "gac-hkrpg-go/protocol/proto" "google.golang.org/protobuf/proto" + "math" ) -const archiveClientVersion uint32 = 4006 - -type cwArchiveStore struct { - mu sync.RWMutex - nextID uint32 - entries []*pb.FAIHLAKPABN +func cwArchiveSnapshot(c *common.CurrencyWarsCatalog) (*common.CurrencyWarsSnapshot, error) { + cfg, err := common.LoadRuntimeConfig() + if err != nil { + return nil, err + } + return common.ReadCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256) } -var globalCWArchive = &cwArchiveStore{nextID: 1} - -func cwRecordArchive(record *pb.OEDHMAHDECC, seasonID, subSeasonID uint32) *pb.FAIHLAKPABN { - if record == nil { - return nil +func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *common.CurrencyWarsCatalog, record *pb.OEDHMAHDECC) error { + if record == nil || s.currencyWarsRevision >= math.MaxUint32 { + return fmt.Errorf("CW archive record/identifier invalid") } - globalCWArchive.mu.Lock() - defer globalCWArchive.mu.Unlock() - - id := globalCWArchive.nextID - globalCWArchive.nextID++ - - entry := &pb.FAIHLAKPABN{ - BAAGDILLLME: id, - OBNEAIPKGKL: time.Now().Unix(), - LJNGPCKPJLF: seasonID, - GEGMHOMNBDG: subSeasonID, - PMBNKEFCICH: archiveClientVersion, - IECKKCHJMLP: false, - BFLLJJBHLDF: proto.Clone(record).(*pb.OEDHMAHDECC), + now, err := common.GameTime() + if err != nil { + return err } - globalCWArchive.entries = append(globalCWArchive.entries, entry) - if len(globalCWArchive.entries) > 50 { - globalCWArchive.entries = globalCWArchive.entries[len(globalCWArchive.entries)-50:] + entry := &pb.FAIHLAKPABN{BAAGDILLLME: uint32(s.currencyWarsRevision + 1), OBNEAIPKGKL: now.Unix(), LJNGPCKPJLF: c.ActiveSeasonID, GEGMHOMNBDG: c.ActiveSubSeasonID, BFLLJJBHLDF: proto.Clone(record).(*pb.OEDHMAHDECC)} + // Client-version encoding is unmapped; do not invent it from a patch number. + wire, err := proto.Marshal(entry) + if err != nil { + return err } - return entry + return s.saveCurrencyWarsSnapshot(data, c, nil, wire) +} + +func cwReadArchives(snapshot *common.CurrencyWarsSnapshot) ([]*pb.FAIHLAKPABN, error) { + if snapshot == nil { + return nil, nil + } + var entries []*pb.FAIHLAKPABN + for _, wire := range snapshot.Archives { + entry := new(pb.FAIHLAKPABN) + if err := proto.Unmarshal(wire, entry); err != nil { + return nil, fmt.Errorf("decode CW archive: %w", err) + } + if entry.BAAGDILLLME == 0 || entry.BFLLJJBHLDF == nil { + return nil, fmt.Errorf("CW archive entry incomplete") + } + entries = append(entries, entry) + } + return entries, nil } func init() { - cwHandle("GridFightGetArchive", func(s *Session, req *pb.GridFightGetArchiveCsReq, rsp *pb.GridFightGetArchiveScRsp) error { - rsp.Retcode = 0 - if req != nil { - rsp.Type = req.Type + cwHandle("GridFightGetArchive", func(_ *Session, req *pb.GridFightGetArchiveCsReq, rsp *pb.GridFightGetArchiveScRsp) error { + c, err := common.LoadCurrencyWarsCatalog() + if err != nil { + return err } - globalCWArchive.mu.RLock() - defer globalCWArchive.mu.RUnlock() - - favourOnly := req != nil && req.Type == pb.MLJIILDNDPL_MLJIILDNDPL_FAGECPGMPEH - for i := len(globalCWArchive.entries) - 1; i >= 0; i-- { - e := globalCWArchive.entries[i] - if favourOnly && !e.IECKKCHJMLP { + snapshot, err := cwArchiveSnapshot(c) + if err != nil { + return err + } + entries, err := cwReadArchives(snapshot) + if err != nil { + return err + } + rsp.Type = req.Type + for i := len(entries) - 1; i >= 0; i-- { + if req.Type == pb.MLJIILDNDPL_MLJIILDNDPL_FAGECPGMPEH && !entries[i].IECKKCHJMLP { continue } - rsp.MCGGPONOCKC = append(rsp.MCGGPONOCKC, proto.Clone(e).(*pb.FAIHLAKPABN)) + rsp.MCGGPONOCKC = append(rsp.MCGGPONOCKC, entries[i]) } - PacketLog(fmt.Sprintf("CW_GET_ARCHIVE type=%v entries=%d", rsp.Type, len(rsp.MCGGPONOCKC))) return nil }) - cwHandle("FavourArchive", func(s *Session, req *pb.FavourArchiveCsReq, rsp *pb.FavourArchiveScRsp) error { rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH) - if req == nil { + c, err := common.LoadCurrencyWarsCatalog() + if err != nil { + return err + } + snapshot, err := cwArchiveSnapshot(c) + if err != nil { + return err + } + if snapshot == nil || snapshot.Revision != s.currencyWarsRevision { return nil } - globalCWArchive.mu.Lock() - defer globalCWArchive.mu.Unlock() - - for _, e := range globalCWArchive.entries { - if e.BAAGDILLLME == req.BAAGDILLLME { - e.IECKKCHJMLP = req.IECKKCHJMLP - rsp.BAAGDILLLME = req.BAAGDILLLME - rsp.IECKKCHJMLP = req.IECKKCHJMLP - rsp.Retcode = 0 - PacketLog(fmt.Sprintf("CW_FAVOUR_ARCHIVE id=%d favour=%t", req.BAAGDILLLME, req.IECKKCHJMLP)) - return nil + entries, err := cwReadArchives(snapshot) + if err != nil { + return err + } + for i, entry := range entries { + if entry.BAAGDILLLME != req.BAAGDILLLME { + continue } + entry.IECKKCHJMLP = req.IECKKCHJMLP + wire, err := proto.Marshal(entry) + if err != nil { + return err + } + snapshot.Archives[i] = wire + revision, err := common.CommitCurrencyWarsSnapshot(s.currencyWarsRevision, *snapshot) + if err != nil { + return err + } + s.currencyWarsRevision = revision + rsp.BAAGDILLLME = req.BAAGDILLLME + rsp.IECKKCHJMLP = req.IECKKCHJMLP + rsp.Retcode = 0 + return nil } return nil }) diff --git a/internal/gameserver/currency_wars_battle.go b/internal/gameserver/currency_wars_battle.go index 5dcf4ea..1b5dae0 100644 --- a/internal/gameserver/currency_wars_battle.go +++ b/internal/gameserver/currency_wars_battle.go @@ -111,7 +111,7 @@ func buildCurrencyWarsBattle(s *Session, catalog *common.CurrencyWarsCatalog, ru var ownerIndex uint32 hasOwner := false for _, r := range team.GridGameRoleList { - if r != nil && r.Pos > 0 && r.Pos < benchFirstPos { + if cwRoleOnBoard(catalog, r) { if variant, err := catalog.CapturedRoleVariant(r.Id, r.RoleStar); err == nil { for _, tid := range variant.Config.TraitList { if tid == cwTrailblazeTrait { diff --git a/internal/gameserver/currency_wars_preparation.go b/internal/gameserver/currency_wars_preparation.go index 675c913..4c7b743 100644 --- a/internal/gameserver/currency_wars_preparation.go +++ b/internal/gameserver/currency_wars_preparation.go @@ -275,217 +275,199 @@ func cwNextRound(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH) (*pb. } func init() { - cwHandle("GridFightUpdatePos", func(s *Session, req *pb.GridFightUpdatePosCsReq, rsp *pb.GridFightUpdatePosScRsp) error { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH) - if _, active := CURRENT_BATTLE.Load(s.Token); active { - PacketLog("CW_UPDATE_POS rejected reason=battle_active") - return nil - } - catalog, err := common.LoadCurrencyWarsCatalog() - if err != nil { - return err - } - data, revision, err := readCurrencyWarsData(catalog) - if err != nil { - return err - } - if data == nil || data.DHFOFLFCLAH == nil { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_NOT_IN_GAMEPLAY) - return nil - } - if revision != s.currencyWarsRevision { - return nil - } - run := data.DHFOFLFCLAH - if run.FMIGOKIDKJL != nil || len(req.AAKDHMHCMLP) == 0 { - PacketLog("CW_UPDATE_POS rejected reason=not_preparing_or_empty") - return nil - } - player, team, _ := cwComponents(run) - if player == nil || team == nil { - return nil - } - front, err := catalog.IntegerRule("GridFight_Front_AvatarMaxNum") - if err != nil { - return err - } - back, err := catalog.IntegerRule("GridFight_Back_AvatarMaxNum") - if err != nil { - return err - } - activeEnd := uint64(front + back) - overflow, err := catalog.IntegerRule("GridFight_Bench_OverFlow_AvatarNum") - if err != nil { - return err - } - benchEnd := activeEnd + uint64(player.MICEOJBAKGN) + uint64(overflow) - changes := map[uint32]uint32{} - for _, v := range req.AAKDHMHCMLP { - if v == nil || v.UniqueId == 0 { + cwHandle("GridFightUpdatePos", cwUpdatePositions) + + // CmdID 8543 (GEBBMDCOLCH) -> ScRsp 8630 (ALNFEBKHCFC) + // Protocol revision: 4.6.53 (upstream_cmdids.go). + // Assumption: Native Client sends GEBBMDCOLCH during preparation / round transition to sync + // the batch formation role positions (CONJCEMAHGN: MDNAGANGNKK = role UID, JFCJNHNBMCB = pos). + // Reuse the validated formation transaction; acknowledge only after commit. + cwHandleNamed("GEBBMDCOLCH", "ALNFEBKHCFC", func(s *Session, req *pb.GEBBMDCOLCH, rsp *pb.ALNFEBKHCFC) error { + mapped := &pb.GridFightUpdatePosCsReq{} + for _, entry := range req.CONJCEMAHGN { + if entry == nil { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH) return nil } - if _, ok := changes[v.UniqueId]; ok { - return nil - } - if uint64(v.Pos) > benchEnd { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_NOT_VALID) - return nil - } - changes[v.UniqueId] = v.Pos + mapped.AAKDHMHCMLP = append(mapped.AAKDHMHCMLP, &pb.MHEGMHKHAAD{UniqueId: entry.MDNAGANGNKK, Pos: entry.JFCJNHNBMCB}) } - // Validate final positions together, so swaps need no intermediate empty slot. - occupied := map[uint32]bool{} - owned := map[uint32]bool{} - active := uint32(0) - activeAvatars := map[uint32]bool{} - for _, role := range team.GridGameRoleList { - owned[role.UniqueId] = true - pos := role.Pos - if v, ok := changes[role.UniqueId]; ok { - pos = v - } - if pos != 0 { - if occupied[pos] { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_EXIST_ROLE) - return nil - } - occupied[pos] = true - } - if pos != 0 && uint64(pos) <= activeEnd { - config, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar) - if err != nil { - return err - } - if activeAvatars[config.Config.AvatarID] { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_SAME_ROLE_IN_BATTLE) - return nil - } - activeAvatars[config.Config.AvatarID] = true - active++ - } + result := &pb.GridFightUpdatePosScRsp{} + err := cwUpdatePositions(s, mapped, result) + rsp.Retcode = result.Retcode + return err + }) +} + +func cwUpdatePositions(s *Session, req *pb.GridFightUpdatePosCsReq, rsp *pb.GridFightUpdatePosScRsp) error { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH) + if _, active := CURRENT_BATTLE.Load(s.Token); active { + PacketLog("CW_UPDATE_POS rejected reason=battle_active") + return nil + } + catalog, err := common.LoadCurrencyWarsCatalog() + if err != nil { + return err + } + data, revision, err := readCurrencyWarsData(catalog) + if err != nil { + return err + } + if data == nil || data.DHFOFLFCLAH == nil { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_NOT_IN_GAMEPLAY) + return nil + } + if revision != s.currencyWarsRevision { + return nil + } + run := data.DHFOFLFCLAH + if run.FMIGOKIDKJL != nil || len(req.AAKDHMHCMLP) == 0 { + PacketLog("CW_UPDATE_POS rejected reason=not_preparing_or_empty") + return nil + } + player, team, _ := cwComponents(run) + if player == nil || team == nil { + return nil + } + front, err := catalog.IntegerRule("GridFight_Front_AvatarMaxNum") + if err != nil { + return err + } + back, err := catalog.IntegerRule("GridFight_Back_AvatarMaxNum") + if err != nil { + return err + } + activeEnd := uint64(front + back) + overflow, err := catalog.IntegerRule("GridFight_Bench_OverFlow_AvatarNum") + if err != nil { + return err + } + benchEnd := activeEnd + uint64(player.MICEOJBAKGN) + uint64(overflow) + changes := map[uint32]uint32{} + for _, v := range req.AAKDHMHCMLP { + if v == nil || v.UniqueId == 0 { + return nil } - for _, forge := range team.KCOMPKMJCMC { - pos := forge.Pos - if _, changed := changes[forge.UniqueId]; changed { - return nil - } // Projection movement needs its separately verified sync arm. - if pos != 0 { - if occupied[pos] { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_EXIST_ROLE) - return nil - } - occupied[pos] = true - } + if _, ok := changes[v.UniqueId]; ok { + return nil } - for _, forge := range team.PMEOPPDAMLM { - owned[forge.UniqueId] = true - pos := forge.Pos - if v, ok := changes[forge.UniqueId]; ok { - pos = v - } - if pos == 0 || uint64(pos) <= activeEnd || uint64(pos) > benchEnd { - return nil - } + if uint64(v.Pos) > benchEnd { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_NOT_VALID) + return nil + } + changes[v.UniqueId] = v.Pos + } + // Validate final positions together, so swaps need no intermediate empty slot. + occupied := map[uint32]bool{} + owned := map[uint32]bool{} + active := uint32(0) + activeAvatars := map[uint32]bool{} + for _, role := range team.GridGameRoleList { + owned[role.UniqueId] = true + pos := role.Pos + if v, ok := changes[role.UniqueId]; ok { + pos = v + } + if pos != 0 { if occupied[pos] { rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_EXIST_ROLE) return nil } occupied[pos] = true } - for id := range changes { - if !owned[id] { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_ROLE_NOT_EXIST) + if pos != 0 && uint64(pos) <= activeEnd { + config, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar) + if err != nil { + return err + } + if activeAvatars[config.Config.AvatarID] { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_SAME_ROLE_IN_BATTLE) return nil } + activeAvatars[config.Config.AvatarID] = true + active++ } - if active > player.AFFCFLFIFPH { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_REGION_FULL) + } + for _, forge := range team.KCOMPKMJCMC { + pos := forge.Pos + if _, changed := changes[forge.UniqueId]; changed { + return nil + } // Projection movement needs its separately verified sync arm. + if pos != 0 { + if occupied[pos] { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_EXIST_ROLE) + return nil + } + occupied[pos] = true + } + } + for _, forge := range team.PMEOPPDAMLM { + owned[forge.UniqueId] = true + pos := forge.Pos + if v, ok := changes[forge.UniqueId]; ok { + pos = v + } + if pos == 0 || uint64(pos) <= activeEnd || uint64(pos) > benchEnd { return nil } - var updates []*pb.KFNCAIHIHCM - for _, role := range team.GridGameRoleList { - if pos, ok := changes[role.UniqueId]; ok { - role.Pos = pos - updates = append(updates, &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_CFOCDALNCBP{CFOCDALNCBP: role}}) - } + if occupied[pos] { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_EXIST_ROLE) + return nil } - for _, forge := range team.PMEOPPDAMLM { - if pos, ok := changes[forge.UniqueId]; ok { - forge.Pos = pos - updates = append(updates, &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FEKCPKAPHHO{FEKCPKAPHHO: forge}}) - } + occupied[pos] = true + } + for id := range changes { + if !owned[id] { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_ROLE_NOT_EXIST) + return nil } - runtime, _ := cwBonfireRuntime(catalog, run) - cwSyncTraitComponent(catalog, run, runtime) - if tbCat, err := common.LoadCurrencyWarsTrailblaze(); err == nil && (cwGetTrailblazeLayer(catalog, run) > 0 || (runtime != nil && runtime.Rules["TbOpened"] != 0)) { - updates = append(updates, - &pb.KFNCAIHIHCM{ - MNLCHEJKDHO: &pb.KFNCAIHIHCM_HKHJJCMKKJE{ - HKHJJCMKKJE: &pb.JLKCBOIFJKC{ - TraitId: cwTrailblazeTrait, - ENPLACDLJGA: cwTrailblazeTraitEffectProto(tbCat, runtime), - }, + } + if active > player.AFFCFLFIFPH { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_REGION_FULL) + return nil + } + var updates []*pb.KFNCAIHIHCM + for _, role := range team.GridGameRoleList { + if pos, ok := changes[role.UniqueId]; ok { + role.Pos = pos + updates = append(updates, &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_CFOCDALNCBP{CFOCDALNCBP: role}}) + } + } + for _, forge := range team.PMEOPPDAMLM { + if pos, ok := changes[forge.UniqueId]; ok { + forge.Pos = pos + updates = append(updates, &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FEKCPKAPHHO{FEKCPKAPHHO: forge}}) + } + } + runtime, err := cwBonfireRuntime(catalog, run) + if err != nil { + return err + } + cwSyncTraitComponent(catalog, run, runtime) + if tbCat, err := common.LoadCurrencyWarsTrailblaze(); err == nil && (cwGetTrailblazeLayer(catalog, run) > 0 || (runtime != nil && runtime.Rules["TbOpened"] != 0)) { + updates = append(updates, + &pb.KFNCAIHIHCM{ + MNLCHEJKDHO: &pb.KFNCAIHIHCM_HKHJJCMKKJE{ + HKHJJCMKKJE: &pb.JLKCBOIFJKC{ + TraitId: cwTrailblazeTrait, + ENPLACDLJGA: cwTrailblazeTraitEffectProto(tbCat, runtime), }, }, - &pb.KFNCAIHIHCM{ - MNLCHEJKDHO: &pb.KFNCAIHIHCM_GOOCHGKBIPN{ - GOOCHGKBIPN: cwTrailblazeModifierProto(tbCat, runtime), - }, + }, + &pb.KFNCAIHIHCM{ + MNLCHEJKDHO: &pb.KFNCAIHIHCM_GOOCHGKBIPN{ + GOOCHGKBIPN: cwTrailblazeModifierProto(tbCat, runtime), }, - ) - } - if err = s.saveCurrencyWarsDataWithRuntime(data, catalog, runtime); err != nil { - return err - } - if err = cwSend(s, cwNotify(updates...)); err != nil { - return err - } - rsp.AAKDHMHCMLP = req.AAKDHMHCMLP - rsp.Retcode = 0 - PacketLog(fmt.Sprintf("CW_UPDATE_POS saved count=%d active=%d revision=%d", len(changes), active, s.currencyWarsRevision)) - return nil - }) - - // CmdID 8543 (GEBBMDCOLCH) -> ScRsp 8630 (ALNFEBKHCFC) - // Protocol revision: 4.6.53 (upstream_cmdids.go). - // Assumption: Native Client sends GEBBMDCOLCH during preparation / round transition to sync - // the batch formation role positions (CONJCEMAHGN: MDNAGANGNKK = role UID, JFCJNHNBMCB = pos). - // Acknowledging with retcode 0 unblocks the Client UI input controller. - cwHandleNamed("GEBBMDCOLCH", "ALNFEBKHCFC", func(s *Session, req *pb.GEBBMDCOLCH, rsp *pb.ALNFEBKHCFC) error { - catalog, err := common.LoadCurrencyWarsCatalog() - if err != nil { - rsp.Retcode = 0 - return nil - } - data, _, err := readCurrencyWarsData(catalog) - if err != nil || data == nil || data.DHFOFLFCLAH == nil { - rsp.Retcode = 0 - return nil - } - run := data.DHFOFLFCLAH - _, team, _ := cwComponents(run) - if team != nil && len(req.CONJCEMAHGN) > 0 { - posMap := make(map[uint32]uint32, len(req.CONJCEMAHGN)) - for _, entry := range req.CONJCEMAHGN { - if entry != nil && entry.MDNAGANGNKK != 0 { - posMap[entry.MDNAGANGNKK] = entry.JFCJNHNBMCB - } - } - changed := false - for _, role := range team.GridGameRoleList { - if newPos, ok := posMap[role.UniqueId]; ok && role.Pos != newPos { - role.Pos = newPos - changed = true - } - } - if changed { - runtime, _ := cwBonfireRuntime(catalog, run) - cwSyncTraitComponent(catalog, run, runtime) - _ = s.saveCurrencyWarsDataWithRuntime(data, catalog, runtime) - } - } - rsp.Retcode = 0 - PacketLog(fmt.Sprintf("CW_BATCH_UPDATE_POS count=%d revision=%d", len(req.CONJCEMAHGN), s.currencyWarsRevision)) - return nil - }) + }, + ) + } + if err = s.saveCurrencyWarsDataWithRuntime(data, catalog, runtime); err != nil { + return err + } + if err = cwSend(s, cwNotify(updates...)); err != nil { + return err + } + rsp.AAKDHMHCMLP = req.AAKDHMHCMLP + rsp.Retcode = 0 + PacketLog(fmt.Sprintf("CW_UPDATE_POS saved count=%d active=%d revision=%d", len(changes), active, s.currencyWarsRevision)) + return nil } diff --git a/internal/gameserver/currency_wars_quit.go b/internal/gameserver/currency_wars_quit.go index 1de1860..dabc82e 100644 --- a/internal/gameserver/currency_wars_quit.go +++ b/internal/gameserver/currency_wars_quit.go @@ -203,8 +203,6 @@ func currencyWarsTerminalSettlement(catalog *common.CurrencyWarsCatalog, data *p seasonLevel = maxLevel } - cwRecordArchive(finish, catalog.ActiveSeasonID, catalog.ActiveSubSeasonID) - if data != nil && data.LFLCDNGALME != nil && data.LFLCDNGALME.HFPEJIDDDJG != nil { if data.LFLCDNGALME.HFPEJIDDDJG.Exp == nil { data.LFLCDNGALME.HFPEJIDDDJG.Exp = &pb.GLIFOLCEMIN{} @@ -275,7 +273,7 @@ func init() { if !active { return nil } - if err = s.saveCurrencyWarsData(data, catalog); err != nil { + if err = s.saveCurrencyWarsTerminal(data, catalog, settlement.BFLLJJBHLDF); err != nil { rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS) PacketLog(fmt.Sprintf("CW_QUIT_SETTLE rejected run=%d reason=save_error error=%v", runID, err)) return nil diff --git a/internal/gameserver/currency_wars_save.go b/internal/gameserver/currency_wars_save.go index 1ec1482..c3cc562 100644 --- a/internal/gameserver/currency_wars_save.go +++ b/internal/gameserver/currency_wars_save.go @@ -84,6 +84,10 @@ func (s *Session) saveCurrencyWarsData(data *goproto.GridFightGetDataScRsp, cata } func (s *Session) saveCurrencyWarsDataWithRuntime(data *goproto.GridFightGetDataScRsp, catalog *common.CurrencyWarsCatalog, runtime *common.CurrencyWarsRuntimeState, commandID ...string) error { + return s.saveCurrencyWarsSnapshot(data, catalog, runtime, nil, commandID...) +} + +func (s *Session) saveCurrencyWarsSnapshot(data *goproto.GridFightGetDataScRsp, catalog *common.CurrencyWarsCatalog, runtime *common.CurrencyWarsRuntimeState, archive []byte, commandID ...string) error { cwProjectRoleIDs(data, catalog) if err := validateCurrencyWarsSavedData(data, catalog); err != nil { return err @@ -97,6 +101,7 @@ func (s *Session) saveCurrencyWarsDataWithRuntime(data *goproto.GridFightGetData return err } next := common.NewCurrencyWarsSnapshot(cfg.PlayerUID, catalog.ClientVersion, catalog.Provenance.GameAssemblySHA256, wire) + next.PendingArchive = archive if run := data.DHFOFLFCLAH; run != nil { if runtime != nil && runtime.RunID != run.UniqueId { return fmt.Errorf("CW runtime rules belong to a different run") diff --git a/internal/gameserver/currency_wars_settlement.go b/internal/gameserver/currency_wars_settlement.go index 4b716e8..5dbe749 100644 --- a/internal/gameserver/currency_wars_settlement.go +++ b/internal/gameserver/currency_wars_settlement.go @@ -620,7 +620,7 @@ func finishCurrencyWarsBattle(s *Session, req *pb.PVEBattleResultCsReq, current return termErr } data.DHFOFLFCLAH = nil - if termErr = s.saveCurrencyWarsData(data, catalog); termErr != nil { + if termErr = s.saveCurrencyWarsTerminal(data, catalog, settle.BFLLJJBHLDF); termErr != nil { return termErr } CURRENT_BATTLE.Delete(s.Token) diff --git a/internal/gameserver/currency_wars_systems_test.go b/internal/gameserver/currency_wars_systems_test.go index c2fac1e..383f966 100644 --- a/internal/gameserver/currency_wars_systems_test.go +++ b/internal/gameserver/currency_wars_systems_test.go @@ -1,6 +1,7 @@ package gameserver import ( + "bytes" "os" "testing" @@ -10,151 +11,97 @@ import ( ) func TestCurrencyWarsTalentsHandlers(t *testing.T) { - origPersistent, _ := os.ReadFile("persistent") - if origPersistent != nil { - t.Cleanup(func() { - _ = os.WriteFile("persistent", origPersistent, 0644) - }) - } - - session := &Session{Token: 12345} - catalog, err := common.LoadCurrencyWarsCatalog() + before, err := os.ReadFile("persistent") if err != nil { - t.Fatalf("load catalog: %v", err) + t.Fatal(err) } - data, rev, err := readCurrencyWarsData(catalog) - if err != nil || data == nil { - data, err = newCurrencyWarsData(catalog) + s := &Session{Token: 12345} + requests := map[string]proto.Message{ + "GridFightPermanentTalentEnableCsReq": &pb.GridFightPermanentTalentEnableCsReq{CKHCLKOALKE: ^uint32(0)}, + "GridFightSeasonTalentEnableCsReq": &pb.GridFightSeasonTalentEnableCsReq{CKHCLKOALKE: ^uint32(0)}, + "GridFightPermanentTalentResetCsReq": &pb.GridFightPermanentTalentResetCsReq{}, + "GridFightSeasonTalentResetCsReq": &pb.GridFightSeasonTalentResetCsReq{}, + } + for name, req := range requests { + raw, _ := proto.Marshal(req) + if err := typedHandlers[uint16(pb.CmdIDByName[name])](s, raw); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile("persistent") if err != nil { - t.Fatalf("new data: %v", err) + t.Fatal(err) } - rev = 0 - } - session.currencyWarsRevision = rev - if err := session.saveCurrencyWarsData(data, catalog); err != nil { - t.Fatalf("save initial data: %v", err) - } - - // 1. Enable Permanent Talent - permReq := &pb.GridFightPermanentTalentEnableCsReq{CKHCLKOALKE: 101} - permCmdID, ok := pb.CmdIDByName["GridFightPermanentTalentEnableCsReq"] - if !ok || typedHandlers[uint16(permCmdID)] == nil { - t.Fatal("handler GridFightPermanentTalentEnable not registered") - } - rawPerm, err := proto.Marshal(permReq) - if err != nil { - t.Fatalf("marshal permReq: %v", err) - } - if err := typedHandlers[uint16(permCmdID)](session, rawPerm); err != nil { - t.Fatalf("call GridFightPermanentTalentEnable: %v", err) - } - - saved, _, err := readCurrencyWarsData(catalog) - if err != nil || saved == nil { - t.Fatalf("read saved data: %v", err) - } - if saved.LFLCDNGALME == nil || saved.LFLCDNGALME.PKELAIPIHMF == nil || saved.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC == nil { - t.Fatal("permanent talent container missing") - } - found := false - for _, id := range saved.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL { - if id == 101 { - found = true - break + if !bytes.Equal(before, after) { + t.Fatalf("unsupported talent request changed persistent: %s", name) } } - if !found { - t.Errorf("permanent talent 101 was not persisted: %v", saved.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL) - } - - // 2. Enable Season Talent - seasonReq := &pb.GridFightSeasonTalentEnableCsReq{CKHCLKOALKE: 202} - seasonCmdID, ok := pb.CmdIDByName["GridFightSeasonTalentEnableCsReq"] - if !ok || typedHandlers[uint16(seasonCmdID)] == nil { - t.Fatal("handler GridFightSeasonTalentEnable not registered") - } - rawSeason, err := proto.Marshal(seasonReq) - if err != nil { - t.Fatalf("marshal seasonReq: %v", err) - } - if err := typedHandlers[uint16(seasonCmdID)](session, rawSeason); err != nil { - t.Fatalf("call GridFightSeasonTalentEnable: %v", err) - } - - saved, _, _ = readCurrencyWarsData(catalog) - if saved.LFLCDNGALME == nil || saved.LFLCDNGALME.HFPEJIDDDJG == nil || saved.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC == nil { - t.Fatal("season talent container missing") - } - foundSeason := false - for _, id := range saved.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC.BDGMNPIJDDL { - if id == 202 { - foundSeason = true - break - } - } - if !foundSeason { - t.Errorf("season talent 202 was not persisted: %v", saved.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC.BDGMNPIJDDL) - } - - // 3. Reset Talents - resetCmdID, ok := pb.CmdIDByName["GridFightPermanentTalentResetCsReq"] - if ok && typedHandlers[uint16(resetCmdID)] != nil { - rawReset, _ := proto.Marshal(&pb.GridFightPermanentTalentResetCsReq{}) - _ = typedHandlers[uint16(resetCmdID)](session, rawReset) - } - saved, _, _ = readCurrencyWarsData(catalog) - if len(saved.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL) != 0 { - t.Errorf("expected permanent talents to be empty after reset, got %v", saved.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL) - } } func TestCurrencyWarsArchiveHandlers(t *testing.T) { - record := &pb.OEDHMAHDECC{ - Reason: pb.PEIJNEFOCHI_PEIJNEFOCHI_JOJPGBCECLN, - IsOverlock: false, - } - entry := cwRecordArchive(record, 2, 1) - if entry == nil || entry.BAAGDILLLME == 0 { - t.Fatal("archive record failed") - } - - session := &Session{Token: 54321} - - // Toggle Favour - favourReq := &pb.FavourArchiveCsReq{BAAGDILLLME: entry.BAAGDILLLME, IECKKCHJMLP: true} - favourCmdID, ok := pb.CmdIDByName["FavourArchiveCsReq"] - if !ok || typedHandlers[uint16(favourCmdID)] == nil { - t.Fatal("FavourArchive handler missing") - } - rawFavour, err := proto.Marshal(favourReq) + c, err := common.LoadCurrencyWarsCatalog() if err != nil { - t.Fatalf("marshal favourReq: %v", err) + t.Fatal(err) } - if err := typedHandlers[uint16(favourCmdID)](session, rawFavour); err != nil { - t.Fatalf("call FavourArchive: %v", err) + data, revision, err := readCurrencyWarsData(c) + if err != nil { + t.Fatal(err) } - - foundFavour := false - globalCWArchive.mu.RLock() - for _, e := range globalCWArchive.entries { - if e.BAAGDILLLME == entry.BAAGDILLLME && e.IECKKCHJMLP { - foundFavour = true - break + if data == nil { + data, err = newCurrencyWarsData(c) + if err != nil { + t.Fatal(err) } } - globalCWArchive.mu.RUnlock() - if !foundFavour { - t.Errorf("expected archive %d to be favoured", entry.BAAGDILLLME) + session := &Session{Token: 54321, currencyWarsRevision: revision} + record := &pb.OEDHMAHDECC{Reason: pb.PEIJNEFOCHI_PEIJNEFOCHI_JOJPGBCECLN} + if err := session.saveCurrencyWarsTerminal(data, c, record); err != nil { + t.Fatal(err) } - - // Get Archive - getArchiveCmdID, ok := pb.CmdIDByName["GridFightGetArchiveCsReq"] - if !ok || typedHandlers[uint16(getArchiveCmdID)] == nil { - t.Fatal("GridFightGetArchive handler missing") + snapshot, err := cwArchiveSnapshot(c) + if err != nil { + t.Fatal(err) } - rawGetArchive, _ := proto.Marshal(&pb.GridFightGetArchiveCsReq{Type: pb.MLJIILDNDPL_MLJIILDNDPL_FAGECPGMPEH}) - if err := typedHandlers[uint16(getArchiveCmdID)](session, rawGetArchive); err != nil { - t.Fatalf("call GridFightGetArchive: %v", err) + entries, err := cwReadArchives(snapshot) + if err != nil || len(entries) == 0 { + t.Fatalf("read archive: %v", err) + } + entry := entries[len(entries)-1] + raw, _ := proto.Marshal(&pb.FavourArchiveCsReq{BAAGDILLLME: entry.BAAGDILLLME, IECKKCHJMLP: true}) + if err := typedHandlers[uint16(pb.CmdIDByName["FavourArchiveCsReq"])](session, raw); err != nil { + t.Fatal(err) + } + // A new read/session has no in-memory archive state to rely on. + snapshot, err = cwArchiveSnapshot(c) + if err != nil { + t.Fatal(err) + } + entries, err = cwReadArchives(snapshot) + if err != nil { + t.Fatal(err) + } + if !entries[len(entries)-1].IECKKCHJMLP { + t.Fatal("favourite was not persisted") + } + stale := &Session{currencyWarsRevision: revision} + if err := stale.saveCurrencyWarsTerminal(data, c, record); err == nil { + t.Fatal("stale settlement appended archive") + } + again, err := cwArchiveSnapshot(c) + if err != nil { + t.Fatal(err) + } + if len(again.Archives) != len(snapshot.Archives) { + t.Fatal("failed transaction changed archives") + } + if err := session.saveCurrencyWarsData(data, c); err != nil { + t.Fatal(err) + } + again, err = cwArchiveSnapshot(c) + if err != nil { + t.Fatal(err) + } + if len(again.Archives) != len(snapshot.Archives) { + t.Fatal("ordinary save lost archives") } } diff --git a/internal/gameserver/currency_wars_talent.go b/internal/gameserver/currency_wars_talent.go index b45df40..1e60dde 100644 --- a/internal/gameserver/currency_wars_talent.go +++ b/internal/gameserver/currency_wars_talent.go @@ -1,141 +1,25 @@ package gameserver -import ( - "fmt" - "slices" - - "gac-hkrpg-go/internal/common" - pb "gac-hkrpg-go/protocol/proto" -) +import pb "gac-hkrpg-go/protocol/proto" +// The reference talent service is a stub. No revision-bound talent costs, +// prerequisites, refund rules or match effects are available in our catalog. +// Keep the typed callbacks but reject mutation until those contracts exist. func init() { - cwHandle("GridFightPermanentTalentEnable", func(s *Session, req *pb.GridFightPermanentTalentEnableCsReq, rsp *pb.GridFightPermanentTalentEnableScRsp) error { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH) - if req == nil || req.CKHCLKOALKE == 0 { - return nil - } - catalog, err := common.LoadCurrencyWarsCatalog() - if err != nil { - return err - } - data, revision, err := readCurrencyWarsData(catalog) - if err != nil { - return err - } - if data == nil { - data, err = newCurrencyWarsData(catalog) - if err != nil { - return err - } - } - s.currencyWarsRevision = revision - if data.LFLCDNGALME == nil { - data.LFLCDNGALME = &pb.CLEDCMIOIPF{} - } - if data.LFLCDNGALME.PKELAIPIHMF == nil { - data.LFLCDNGALME.PKELAIPIHMF = &pb.CKPOBLIFMGN{} - } - if data.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC == nil { - data.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC = &pb.FMNKKAMOHEE{} - } - list := &data.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL - if !slices.Contains(*list, req.CKHCLKOALKE) { - *list = append(*list, req.CKHCLKOALKE) - } - if err = s.saveCurrencyWarsData(data, catalog); err != nil { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS) - return nil - } - rsp.CKHCLKOALKE = req.CKHCLKOALKE - rsp.Retcode = 0 - PacketLog(fmt.Sprintf("CW_PERMANENT_TALENT_ENABLE node=%d total=%d", req.CKHCLKOALKE, len(*list))) + cwHandle("GridFightPermanentTalentEnable", func(_ *Session, _ *pb.GridFightPermanentTalentEnableCsReq, rsp *pb.GridFightPermanentTalentEnableScRsp) error { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS) return nil }) - - cwHandle("GridFightSeasonTalentEnable", func(s *Session, req *pb.GridFightSeasonTalentEnableCsReq, rsp *pb.GridFightSeasonTalentEnableScRsp) error { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH) - if req == nil || req.CKHCLKOALKE == 0 { - return nil - } - catalog, err := common.LoadCurrencyWarsCatalog() - if err != nil { - return err - } - data, revision, err := readCurrencyWarsData(catalog) - if err != nil { - return err - } - if data == nil { - data, err = newCurrencyWarsData(catalog) - if err != nil { - return err - } - } - s.currencyWarsRevision = revision - if data.LFLCDNGALME == nil { - data.LFLCDNGALME = &pb.CLEDCMIOIPF{} - } - if data.LFLCDNGALME.HFPEJIDDDJG == nil { - data.LFLCDNGALME.HFPEJIDDDJG = &pb.AMEEHIIPMFP{} - } - if data.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC == nil { - data.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC = &pb.FMNKKAMOHEE{} - } - list := &data.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC.BDGMNPIJDDL - if !slices.Contains(*list, req.CKHCLKOALKE) { - *list = append(*list, req.CKHCLKOALKE) - } - if err = s.saveCurrencyWarsData(data, catalog); err != nil { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS) - return nil - } - rsp.CKHCLKOALKE = req.CKHCLKOALKE - rsp.Retcode = 0 - PacketLog(fmt.Sprintf("CW_SEASON_TALENT_ENABLE node=%d total=%d", req.CKHCLKOALKE, len(*list))) + cwHandle("GridFightSeasonTalentEnable", func(_ *Session, _ *pb.GridFightSeasonTalentEnableCsReq, rsp *pb.GridFightSeasonTalentEnableScRsp) error { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS) return nil }) - - cwHandle("GridFightPermanentTalentReset", func(s *Session, _ *pb.GridFightPermanentTalentResetCsReq, rsp *pb.GridFightPermanentTalentResetScRsp) error { - catalog, err := common.LoadCurrencyWarsCatalog() - if err != nil { - return err - } - data, revision, err := readCurrencyWarsData(catalog) - if err != nil { - return err - } - s.currencyWarsRevision = revision - if data != nil && data.LFLCDNGALME != nil && data.LFLCDNGALME.PKELAIPIHMF != nil && data.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC != nil { - data.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL = nil - if err = s.saveCurrencyWarsData(data, catalog); err != nil { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS) - return nil - } - } - rsp.Retcode = 0 - PacketLog("CW_PERMANENT_TALENT_RESET completed") + cwHandle("GridFightPermanentTalentReset", func(_ *Session, _ *pb.GridFightPermanentTalentResetCsReq, rsp *pb.GridFightPermanentTalentResetScRsp) error { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS) return nil }) - - cwHandle("GridFightSeasonTalentReset", func(s *Session, _ *pb.GridFightSeasonTalentResetCsReq, rsp *pb.GridFightSeasonTalentResetScRsp) error { - catalog, err := common.LoadCurrencyWarsCatalog() - if err != nil { - return err - } - data, revision, err := readCurrencyWarsData(catalog) - if err != nil { - return err - } - s.currencyWarsRevision = revision - if data != nil && data.LFLCDNGALME != nil && data.LFLCDNGALME.HFPEJIDDDJG != nil && data.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC != nil { - data.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC.BDGMNPIJDDL = nil - if err = s.saveCurrencyWarsData(data, catalog); err != nil { - rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS) - return nil - } - } - rsp.Retcode = 0 - PacketLog("CW_SEASON_TALENT_RESET completed") + cwHandle("GridFightSeasonTalentReset", func(_ *Session, _ *pb.GridFightSeasonTalentResetCsReq, rsp *pb.GridFightSeasonTalentResetScRsp) error { + rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS) return nil }) } diff --git a/internal/gameserver/currency_wars_traits.go b/internal/gameserver/currency_wars_traits.go index 34e6406..65076c1 100644 --- a/internal/gameserver/currency_wars_traits.go +++ b/internal/gameserver/currency_wars_traits.go @@ -9,7 +9,17 @@ import ( pb "gac-hkrpg-go/protocol/proto" ) -const benchFirstPos uint32 = 14 +func cwRoleOnBoard(c *common.CurrencyWarsCatalog, role *pb.CDEAABODKCP) bool { + if role == nil || role.Pos == 0 { + return false + } + front, err := c.IntegerRule("GridFight_Front_AvatarMaxNum") + if err != nil || front < 0 { + return false + } + back, err := c.IntegerRule("GridFight_Back_AvatarMaxNum") + return err == nil && back >= 0 && uint64(role.Pos) <= uint64(front)+uint64(back) +} func cwCalculateBoardTraits(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH) []*pb.FBNGHMHHHLL { if catalog == nil || run == nil { @@ -27,7 +37,7 @@ func cwCalculateBoardTraits(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEM tallies := make(map[uint32]uint32) members := make(map[uint32][]uint32) for _, role := range team.GridGameRoleList { - if role == nil || role.Pos >= benchFirstPos { + if !cwRoleOnBoard(catalog, role) { continue } variant, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar) @@ -46,7 +56,7 @@ func cwCalculateBoardTraits(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEM equipMap, _ := common.LoadCurrencyWarsEquipment(catalog) if equipMap != nil { for _, role := range team.GridGameRoleList { - if role == nil || role.Pos >= benchFirstPos { + if !cwRoleOnBoard(catalog, role) { continue } for _, uid := range role.PNJIPHJJONG { @@ -180,7 +190,7 @@ func cwCalculateBattleTraits(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAE tallies := make(map[uint32]uint32) members := make(map[uint32][]*pb.MHFNFINKJKP) for _, role := range team.GridGameRoleList { - if role == nil || role.Pos >= benchFirstPos { + if !cwRoleOnBoard(catalog, role) { continue } variant, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar) @@ -199,7 +209,7 @@ func cwCalculateBattleTraits(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAE equipMap, _ := common.LoadCurrencyWarsEquipment(catalog) if equipMap != nil { for _, role := range team.GridGameRoleList { - if role == nil || role.Pos >= benchFirstPos { + if !cwRoleOnBoard(catalog, role) { continue } for _, uid := range role.PNJIPHJJONG { diff --git a/internal/gameserver/gemini_regression_test.go b/internal/gameserver/gemini_regression_test.go new file mode 100644 index 0000000..22b82d7 --- /dev/null +++ b/internal/gameserver/gemini_regression_test.go @@ -0,0 +1,211 @@ +package gameserver + +import ( + "bytes" + "gac-hkrpg-go/internal/common" + "gac-hkrpg-go/internal/testutil" + kcplib "gac-hkrpg-go/pkg/kcp" + pb "gac-hkrpg-go/protocol/proto" + "google.golang.org/protobuf/proto" + "os" + "testing" +) + +func isolatedCWTest(t *testing.T) *common.CurrencyWarsCatalog { + t.Helper() + temporary := t.TempDir() + if err := testutil.CopyRuntime(testWorkspaceRoot, temporary); err != nil { + t.Fatal(err) + } + t.Chdir(temporary) + t.Setenv("GAC_CONFIG", "server.json") + if err := os.WriteFile("persistent", []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + c, err := common.LoadCurrencyWarsCatalog() + if err != nil { + t.Fatal(err) + } + return c +} + +// Use the real typed dispatcher and transport framing, so assertions examine +// the Client response rather than merely checking handler registration. +func replySession(t *testing.T) (*Session, func(proto.Message)) { + t.Helper() + client := kcplib.New(1, 1, false, func([]byte) error { return nil }) + s := NewSession(func(data []byte) error { return client.Input(data) }, 1, 1) + s.kcp.SetNodelay(true, 10, 2, true) + if err := s.kcp.Update(s.kcp.Now()); err != nil { + t.Fatal(err) + } + return s, func(response proto.Message) { + t.Helper() + wanted := pb.CmdIDByName[string(response.ProtoReflect().Descriptor().Name())] + for { + buf := make([]byte, 65536) + n, err := client.Recv(buf) + if err != nil { + t.Fatal(err) + } + packet, err := DecodeNetPacket(buf[:n]) + if err != nil { + t.Fatal(err) + } + if packet.CmdType != wanted { + continue + } + if err := proto.Unmarshal(packet.Body, response); err != nil { + t.Fatal(err) + } + return + } + } +} + +func callReviewRequest(t *testing.T, s *Session, request proto.Message) { + t.Helper() + raw, err := proto.Marshal(request) + if err != nil { + t.Fatal(err) + } + handler := typedHandlers[uint16(pb.CmdIDByName[string(request.ProtoReflect().Descriptor().Name())])] + if handler == nil { + t.Fatal("handler missing") + } + if err := handler(s, raw); err != nil { + t.Fatal(err) + } +} + +func TestUnsupportedCWMutationsReturnFailureAndPreserveSave(t *testing.T) { + isolatedCWTest(t) + before, _ := os.ReadFile("persistent") + cases := []struct{ req, rsp proto.Message }{ + {&pb.GridFightPermanentTalentEnableCsReq{CKHCLKOALKE: ^uint32(0)}, &pb.GridFightPermanentTalentEnableScRsp{}}, + {&pb.GridFightSeasonTalentEnableCsReq{CKHCLKOALKE: 1}, &pb.GridFightSeasonTalentEnableScRsp{}}, + {&pb.GridFightPermanentTalentResetCsReq{}, &pb.GridFightPermanentTalentResetScRsp{}}, + {&pb.GridFightSeasonTalentResetCsReq{}, &pb.GridFightSeasonTalentResetScRsp{}}, + {&pb.GridFightTakeWeeklyRewardCsReq{JKCEBHFIENI: []uint32{1, ^uint32(0)}}, &pb.GridFightTakeWeeklyRewardScRsp{}}, + {&pb.CycleScoreRewardTakeRewardCsReq{JKCEBHFIENI: []uint32{1}}, &pb.CycleScoreRewardTakeRewardScRsp{}}, + } + for _, tc := range cases { + s, receive := replySession(t) + callReviewRequest(t, s, tc.req) + receive(tc.rsp) + view := tc.rsp.ProtoReflect() + fd := view.Descriptor().Fields().ByName("retcode") + if view.Get(fd).Uint() == 0 { + t.Fatalf("unsupported operation succeeded: %s", view.Descriptor().Name()) + } + after, _ := os.ReadFile("persistent") + if !bytes.Equal(before, after) { + t.Fatal("unsupported mutation changed save") + } + } +} + +func TestCurrencyWarsPositionValidationBothHandlers(t *testing.T) { + c := isolatedCWTest(t) + data, err := newCurrencyWarsData(c) + if err != nil { + t.Fatal(err) + } + var roles []*pb.CDEAABODKCP + seen := map[uint32]bool{} + for _, v := range c.CapturedVariants { + if seen[v.Config.AvatarID] { + continue + } + seen[v.Config.AvatarID] = true + roles = append(roles, &pb.CDEAABODKCP{Id: v.Config.ID, RoleStar: v.Star, UniqueId: uint32(len(roles) + 1), Pos: uint32(len(roles) + 1)}) + if len(roles) == 2 { + break + } + } + if len(roles) != 2 { + t.Fatal("fixture needs two avatars") + } + data.DHFOFLFCLAH = &pb.MPIMAEMCEFH{UniqueId: 1, DivisionId: c.CapturedStages[0].ID, LJNGPCKPJLF: c.ActiveSeasonID, NAGDJGMMCBE: []*pb.GridFightGameInfo{ + {LEOIAOFBBFA: &pb.GridFightGameInfo_EJIPINIKNCK{EJIPINIKNCK: &pb.HKFECLCHBFJ{GridGameRoleList: roles}}}, + {LEOIAOFBBFA: &pb.GridFightGameInfo_FAOCFBOFCJP{FAOCFBOFCJP: &pb.NIHNGPKHEPC{AFFCFLFIFPH: 2, MICEOJBAKGN: 1}}}, + }} + writer := &Session{} + if err := writer.saveCurrencyWarsData(data, c); err != nil { + t.Fatal(err) + } + for _, batch := range []bool{false, true} { + for _, kind := range []string{"duplicate", "outside", "unknown", "battle", "stale", "swap"} { + s, receive := replySession(t) + s.currencyWarsRevision = writer.currencyWarsRevision + before, _ := os.ReadFile("persistent") + positions := []*pb.MHEGMHKHAAD{{UniqueId: 1, Pos: 2}, {UniqueId: 2, Pos: 1}} + switch kind { + case "duplicate": + positions[1].Pos = 2 + case "outside": + positions[0].Pos = ^uint32(0) + case "unknown": + positions[0].UniqueId = ^uint32(0) + case "battle": + CURRENT_BATTLE.Store(s.Token, &pb.SceneBattleInfo{}) + case "stale": + s.currencyWarsRevision = 0 + } + var code uint32 + if batch { + req := &pb.GEBBMDCOLCH{} + for _, p := range positions { + req.CONJCEMAHGN = append(req.CONJCEMAHGN, &pb.EPKFPIDHMOC{MDNAGANGNKK: p.UniqueId, JFCJNHNBMCB: p.Pos}) + } + callReviewRequest(t, s, req) + rsp := &pb.ALNFEBKHCFC{} + receive(rsp) + code = rsp.Retcode + } else { + callReviewRequest(t, s, &pb.GridFightUpdatePosCsReq{AAKDHMHCMLP: positions}) + rsp := &pb.GridFightUpdatePosScRsp{} + receive(rsp) + code = rsp.Retcode + } + CURRENT_BATTLE.Delete(s.Token) + after, _ := os.ReadFile("persistent") + if kind == "swap" { + if code != 0 { + t.Fatalf("valid swap rejected batch=%t code=%d", batch, code) + } + writer.currencyWarsRevision = s.currencyWarsRevision + } else if code == 0 || !bytes.Equal(before, after) { + t.Fatalf("invalid move changed save or succeeded: batch=%t case=%s code=%d", batch, kind, code) + } + } + } +} + +func TestCurrencyWarsUnplacedAndBenchRolesGiveNoTraits(t *testing.T) { + c := isolatedCWTest(t) + var sample common.CurrencyWarsRoleVariant + for _, v := range c.CapturedVariants { + if len(v.Config.TraitList) > 0 { + sample = v + break + } + } + if sample.Config.ID == 0 { + t.Fatal("trait role fixture missing") + } + front, _ := c.IntegerRule("GridFight_Front_AvatarMaxNum") + back, _ := c.IntegerRule("GridFight_Back_AvatarMaxNum") + role := &pb.CDEAABODKCP{Id: sample.Config.ID, RoleStar: sample.Star, UniqueId: 1} + run := &pb.MPIMAEMCEFH{NAGDJGMMCBE: []*pb.GridFightGameInfo{{LEOIAOFBBFA: &pb.GridFightGameInfo_EJIPINIKNCK{EJIPINIKNCK: &pb.HKFECLCHBFJ{GridGameRoleList: []*pb.CDEAABODKCP{role}}}}}} + for _, pos := range []uint32{0, uint32(front + back + 1)} { + role.Pos = pos + if len(cwCalculateBoardTraits(c, run)) != 0 || len(cwCalculateBattleTraits(c, run, nil)) != 0 { + t.Fatalf("off-board role contributes traits: pos=%d", pos) + } + } + role.Pos = 1 + if len(cwCalculateBoardTraits(c, run)) == 0 || len(cwCalculateBattleTraits(c, run, nil)) == 0 { + t.Fatal("active role traits missing") + } +} diff --git a/internal/gameserver/handlers_currency_wars.go b/internal/gameserver/handlers_currency_wars.go index d580661..af12f13 100644 --- a/internal/gameserver/handlers_currency_wars.go +++ b/internal/gameserver/handlers_currency_wars.go @@ -194,54 +194,16 @@ func init() { }) handle("CycleScoreRewardTakeReward", func(s *Session, req *goproto.CycleScoreRewardTakeRewardCsReq, rsp *goproto.CycleScoreRewardTakeRewardScRsp) error { - cfg, err := common.LoadRuntimeConfig() - if err != nil { - return err - } - rsp.Reward = &goproto.ItemList{} - if req != nil { - rsp.NGKOFMBBGNL = append(rsp.NGKOFMBBGNL, req.JKCEBHFIENI...) - } - rsp.NFEDANFBIBE = &goproto.GAAMBBFPCHB{ - EndTime: *cfg.ActivitySchedule.EndTime, - } - if cfg.ActivitySchedule.BeginTime != nil { - rsp.NFEDANFBIBE.IFEPJHGNCOI = *cfg.ActivitySchedule.BeginTime - } - if scoreCatalog, err := common.LoadCurrencyWarsScore(nil); err == nil { - rsp.NFEDANFBIBE.PMJGAJGIFOA = scoreCatalog.WeeklyCap - for _, r := range scoreCatalog.WeeklyRanks { - rsp.NFEDANFBIBE.OPLNKCOLCKJ = append(rsp.NFEDANFBIBE.OPLNKCOLCKJ, r.Rank) - } - } - rsp.Retcode = 0 - PacketLog(fmt.Sprintf("CYCLE_SCORE_REWARD_TAKE claims=%v", rsp.NGKOFMBBGNL)) + // Reward eligibility, grant mapping and atomic claim persistence are not + // verified yet. Never consume a claim or report an empty success. + rsp.Retcode = uint32(goproto.Retcode_RET_GRID_FIGHT_CONF_MISS) return nil }) cwHandle("GridFightTakeWeeklyReward", func(s *Session, req *goproto.GridFightTakeWeeklyRewardCsReq, rsp *goproto.GridFightTakeWeeklyRewardScRsp) error { - cfg, err := common.LoadRuntimeConfig() - if err != nil { - return err - } - rsp.Reward = &goproto.ItemList{} - if req != nil { - rsp.NGKOFMBBGNL = append(rsp.NGKOFMBBGNL, req.JKCEBHFIENI...) - } - rsp.MGBNEAHCDMA = &goproto.NDLNIKCCALK{ - EndTime: *cfg.ActivitySchedule.EndTime, - } - if cfg.ActivitySchedule.BeginTime != nil { - rsp.MGBNEAHCDMA.IFEPJHGNCOI = *cfg.ActivitySchedule.BeginTime - } - if scoreCatalog, err := common.LoadCurrencyWarsScore(nil); err == nil { - rsp.MGBNEAHCDMA.EDHKHJAPMKA = scoreCatalog.WeeklyCap - for _, r := range scoreCatalog.WeeklyRanks { - rsp.MGBNEAHCDMA.OPLNKCOLCKJ = append(rsp.MGBNEAHCDMA.OPLNKCOLCKJ, r.Rank) - } - } - rsp.Retcode = 0 - PacketLog(fmt.Sprintf("CW_TAKE_WEEKLY_REWARD claims=%v", rsp.NGKOFMBBGNL)) + // Reward eligibility, grant mapping and atomic claim persistence are not + // verified yet. Never consume a claim or report an empty success. + rsp.Retcode = uint32(goproto.Retcode_RET_GRID_FIGHT_CONF_MISS) return nil }) diff --git a/internal/gameserver/handlers_gacha.go b/internal/gameserver/handlers_gacha.go index 9093cca..429f0ac 100644 --- a/internal/gameserver/handlers_gacha.go +++ b/internal/gameserver/handlers_gacha.go @@ -11,15 +11,30 @@ import ( func init() { handle("GetGachaInfo", func(s *Session, req *pb.GetGachaInfoCsReq, rsp *pb.GetGachaInfoScRsp) error { - banners, _, err := common.LoadBrowsableGachaBanners() + player := s.JSON() + mode := 0 + if player != nil { + mode = player.WarpUnlockMode + } + + // Read API changes on reopening Warp without requiring a full login. + savedMode, err := common.ReadWarpUnlockMode() + if err != nil { + return err + } + if savedMode != nil { + mode = *savedMode + } + banners, err := common.LoadVisibleGachaBanners(mode) if err != nil { return err } if len(banners) == 0 { rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT) - PacketLog("GACHA entry rejected: no verified installed banner assets") + PacketLog("GACHA entry rejected: no visible banners configured") return nil } + config, err := common.LoadRuntimeConfig() if err != nil { return err @@ -32,73 +47,42 @@ func init() { } rsp.GachaInfoList = append(rsp.GachaInfoList, info) } - // Synthetic IDs are not native Client config rows. The presentation - // adapter registers its own pools; sending these IDs in GetGachaInfo has - // caused Client response handlers to throw. Keep the preview opt-in. - mapped, _, err := common.LoadLimitedGachaBanners() + + randVal, err := rand.Int(rand.Reader, big.NewInt(1000)) if err != nil { return err } - _, _, policy, err := common.LoadSandboxDrawPool() - if err != nil { - return err - } - count := policy.MappedBannerPreviewCount - if count > uint32(len(mapped)) { - count = uint32(len(mapped)) - } - for i := uint32(0); i < count; i++ { - banner := mapped[i] - info := &pb.GachaInfo{GachaId: banner.GachaID, BeginTime: *config.ActivitySchedule.BeginTime, EndTime: *config.ActivitySchedule.EndTime, GachaCeiling: &pb.GachaCeiling{}} - info.PrizeItemList = []uint32{banner.AvatarID} - info.ItemDetailList = append([]uint32{banner.AvatarID}, banner.FourStarIDs...) - rsp.GachaInfoList = append(rsp.GachaInfoList, info) - } - PacketLog(fmt.Sprintf("GACHA_INFO banners=%d mapped_preview=%d", len(banners), count)) + rsp.GachaRandom = 1000 + uint32(randVal.Int64()) + rsp.Retcode = 0 + PacketLog(fmt.Sprintf("GACHA_INFO count=%d mode=%d", len(rsp.GachaInfoList), mode)) return nil }) + handle("DoGacha", func(s *Session, req *pb.DoGachaCsReq, rsp *pb.DoGachaScRsp) error { rsp.GachaId = req.GachaId + rsp.GachaNum = req.GachaNum rsp.Retcode = uint32(pb.Retcode_RET_GACHA_ID_NOT_EXIST) - banners, _, err := common.LoadFeaturedGachaBanners() - if err != nil { - return err - } - var avatarID uint32 - for _, row := range banners { - if row.ID == req.GachaId { - avatarID = row.AvatarID - break - } - } - if avatarID == 0 { - mapped, _, err := common.LoadLimitedGachaBanners() - if err != nil { - return err - } - for _, row := range mapped { - if row.GachaID == req.GachaId { - return drawLimitedAvatar(s, req, rsp, row) - } - } - } - if avatarID != 0 { - return drawFeaturedAvatar(s, req, rsp, avatarID) - } - // Featured-only path found nothing. Verified browsable banners may - // still draw from the policy-declared sandbox pool: every pick is a - // duplicate grant on an avatar from the live imported roster, so no - // invented IDs or first-acquisition synthesis are involved. - sandboxDrawn, err := drawSandboxPool(s, req, rsp) - if err != nil { - return err - } - if sandboxDrawn { + + if _, active := CURRENT_BATTLE.Load(s.Token); active { + rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT) return nil } + + if req.GachaNum != 1 && req.GachaNum != 10 { + rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NUM_INVALID) + return nil + } + + player := s.JSON() + if player == nil { + return nil + } + // The imported banner lists target another Client. Reject draws until + // their pools, grants and protocol fields are verified for this Client. rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT) return nil }) + } // drawFeaturedAvatar grants req.GachaNum copies of one featured avatar using diff --git a/internal/gameserver/handlers_gacha_test.go b/internal/gameserver/handlers_gacha_test.go new file mode 100644 index 0000000..fe5a397 --- /dev/null +++ b/internal/gameserver/handlers_gacha_test.go @@ -0,0 +1,72 @@ +package gameserver + +import ( + "testing" + + "gac-hkrpg-go/internal/common" + pb "gac-hkrpg-go/protocol/proto" + "google.golang.org/protobuf/proto" +) + +func TestGetGachaInfoScRspWireTags(t *testing.T) { + if err := common.LoadBannersConfig(); err != nil { + t.Fatal(err) + } + + rsp := &pb.GetGachaInfoScRsp{ + Retcode: 0, + GachaRandom: 1234, + } + banners := common.GetVisibleBanners(1) + if len(banners) == 0 { + t.Fatal("expected visible banners") + } + for _, b := range banners { + rsp.GachaInfoList = append(rsp.GachaInfoList, b.ToProtoInfo()) + } + + bin, err := proto.Marshal(rsp) + if err != nil { + t.Fatalf("failed to marshal GetGachaInfoScRsp: %v", err) + } + + // Verify that tag 15 (gacha_info_list) is present: + // Field number 15, wire type 2 (length delimited) -> (15 << 3) | 2 = 122 = 0x7a + tag15Found := false + for _, b := range bin { + if b == 0x7a { + tag15Found = true + break + } + } + if !tag15Found { + t.Errorf("Tag 15 (0x7a) for gacha_info_list was NOT found in serialized payload!") + } + + // Unmarshal back to ensure clean round-trip + var decoded pb.GetGachaInfoScRsp + if err := proto.Unmarshal(bin, &decoded); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(decoded.GachaInfoList) != len(banners) { + t.Fatalf("expected %d banners, got %d", len(banners), len(decoded.GachaInfoList)) + } + + // Verify banner 1001 + var found1001 *pb.GachaInfo + for _, info := range decoded.GachaInfoList { + if info.GachaId == 1001 { + found1001 = info + break + } + } + if found1001 == nil { + t.Fatal("banner 1001 not found in decoded response") + } + if found1001.EndTime == 0 { + t.Errorf("banner 1001 EndTime should not be 0") + } + if found1001.GachaCeiling == nil { + t.Errorf("banner 1001 GachaCeiling should not be nil") + } +} diff --git a/internal/gameserver/handlers_player.go b/internal/gameserver/handlers_player.go index 0cdefec..cd1fc0e 100644 --- a/internal/gameserver/handlers_player.go +++ b/internal/gameserver/handlers_player.go @@ -179,6 +179,8 @@ const heartbeatLua = `local function beta_text() end end beta_text() + + ` func luaQuotedString(value string) string { diff --git a/internal/gameserver/handlers_scene.go b/internal/gameserver/handlers_scene.go index 0d5b667..49412d2 100644 --- a/internal/gameserver/handlers_scene.go +++ b/internal/gameserver/handlers_scene.go @@ -74,7 +74,9 @@ func sceneOfEntryForTeleport(res *common.JsonConfig, entryID, floorID, teleportI want := fmt.Sprintf("_F%d", floorID) for _, name := range names { cfg := configs[name] - if len(name) > len(want) && name[len(name)-len(want):] == want && sceneHasTeleport(cfg, teleportID) { + // The requested floor is authoritative even when its teleport is + // present only in teleports.json. IDs may repeat on other floors. + if len(name) > len(want) && name[len(name)-len(want):] == want { return name, cfg, true } } @@ -87,14 +89,6 @@ func sceneOfEntryForTeleport(res *common.JsonConfig, entryID, floorID, teleportI } } } - if floorID != 0 { - want := fmt.Sprintf("_F%d", floorID) - for _, name := range names { - if len(name) > len(want) && name[len(name)-len(want):] == want { - return name, configs[name], true - } - } - } return firstSceneOfEntry(res, entryID) } diff --git a/internal/gameserver/scene_teleport_floor_test.go b/internal/gameserver/scene_teleport_floor_test.go new file mode 100644 index 0000000..a38c31f --- /dev/null +++ b/internal/gameserver/scene_teleport_floor_test.go @@ -0,0 +1,23 @@ +package gameserver + +import ( + "gac-hkrpg-go/internal/common" + "testing" +) + +func TestSceneOfEntryForTeleportKeepsRequestedFloorWhenPointIsExternal(t *testing.T) { + res := &common.JsonConfig{LevelOutputConfigs: map[uint32]map[string]*common.LevelOutputConfig{ + 1: { + "P1_F10": {Scenes: map[uint32]common.SceneData{1: {Teleports: map[uint32]common.TeleportInfo{7: {}}}}}, + "P1_F20": {Scenes: map[uint32]common.SceneData{}}, + }, + }} + name, _, ok := sceneOfEntryForTeleport(res, 1, 20, 7) + if !ok || name != "P1_F20" { + t.Fatalf("requested floor lost: %q, %t", name, ok) + } + name, _, ok = sceneOfEntryForTeleport(res, 1, 0, 7) + if !ok || name != "P1_F10" { + t.Fatalf("floor-less lookup failed: %q, %t", name, ok) + } +} diff --git a/internal/gameserver/test_helpers_test.go b/internal/gameserver/test_helpers_test.go index b3479b4..e863d58 100644 --- a/internal/gameserver/test_helpers_test.go +++ b/internal/gameserver/test_helpers_test.go @@ -2,12 +2,15 @@ package gameserver import ( "fmt" + "gac-hkrpg-go/internal/testutil" "os" "path/filepath" "runtime" "testing" ) +var testWorkspaceRoot string + func TestMain(m *testing.M) { _, source, _, ok := runtime.Caller(0) if !ok { @@ -15,11 +18,22 @@ func TestMain(m *testing.M) { os.Exit(2) } root := filepath.Clean(filepath.Join(filepath.Dir(source), "..", "..")) - if err := os.Chdir(root); err != nil { + temporary, err := os.MkdirTemp("", "gac-gameserver-tests-") + if err != nil { + panic(err) + } + if err := testutil.CopyRuntime(root, temporary); err != nil { + panic(err) + } + testWorkspaceRoot = temporary + if err := os.Chdir(temporary); err != nil { fmt.Fprintf(os.Stderr, "cannot change to repository root %s: %v\n", root, err) os.Exit(2) } - os.Exit(m.Run()) + code := m.Run() + _ = os.Chdir(root) + _ = os.RemoveAll(temporary) + os.Exit(code) } func chdirRepositoryRoot(t *testing.T) { @@ -32,5 +46,5 @@ func chdirRepositoryRoot(t *testing.T) { if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { t.Fatalf("cannot locate repository root from %s: %v", source, err) } - t.Chdir(root) + t.Chdir(testWorkspaceRoot) } diff --git a/internal/sdkserver/android_gateway_test.go b/internal/sdkserver/android_gateway_test.go new file mode 100644 index 0000000..00c22ab --- /dev/null +++ b/internal/sdkserver/android_gateway_test.go @@ -0,0 +1,46 @@ +package sdkserver + +import ( + "encoding/base64" + "net/http/httptest" + "testing" + + "gac-hkrpg-go/internal/common" + "gac-hkrpg-go/protocol/proto" + google "google.golang.org/protobuf/proto" +) + +func TestAndroidGatewayResolvesBuildSuffix(t *testing.T) { + const version = "OSBETAAndroid4.6.54_D16927089_A16927089_L16927077" + metadata := VersionConfig{AssetBundleURL: "https://example.test/assets", AssetBundleURLB: "https://example.test/backup", ExResourceURL: "https://example.test/data", LuaURL: "https://example.test/lua", IfixURL: "https://example.test/ifix", LuaVersion: "123", IfixVersion: "4"} + state := &appState{runtime: common.RuntimeConfig{PublicGameHost: "127.0.0.1", GamePort: 23301}, hotfixMap: map[string]VersionConfig{ + "OSBETAAndroid4.6.54": metadata, + version: {}, // An earlier failed lookup may have cached an empty entry. + }} + rec := httptest.NewRecorder() + queryGateway(state)(rec, httptest.NewRequest("GET", "/query_gateway?version="+version, nil)) + if rec.Code != 200 { + t.Fatalf("HTTP status = %d", rec.Code) + } + wire, err := base64.StdEncoding.DecodeString(rec.Body.String()) + if err != nil { + t.Fatal(err) + } + var got proto.Gateserver + if err := google.Unmarshal(wire, &got); err != nil { + t.Fatal(err) + } + if got.Ip != "127.0.0.1" || got.Port != 23301 || got.AssetBundleUrl != metadata.AssetBundleURL || got.AssetBundleUrlB != metadata.AssetBundleURLB || got.ExResourceUrl != metadata.ExResourceURL || got.LuaUrl != metadata.LuaURL || got.IfixUrl != metadata.IfixURL || got.LuaVersion != metadata.LuaVersion || got.IfixVersion != metadata.IfixVersion { + t.Fatalf("unexpected gateway response: %v", &got) + } +} + +func TestAndroidGatewayPrefersExactBuildMetadata(t *testing.T) { + state := &appState{hotfixMap: map[string]VersionConfig{ + "OSBETAAndroid4.6.54": {AssetBundleURL: "release"}, + "OSBETAAndroid4.6.54_D1": {AssetBundleURL: "specific"}, + }} + if got := state.getOrInsertHotfix("OSBETAAndroid4.6.54_D1", ""); got.AssetBundleURL != "specific" { + t.Fatalf("exact build lost: %+v", got) + } +} diff --git a/internal/sdkserver/api.go b/internal/sdkserver/api.go index a7f07fd..b4e550f 100644 --- a/internal/sdkserver/api.go +++ b/internal/sdkserver/api.go @@ -699,8 +699,11 @@ func apiTeleport(w http.ResponseWriter, r *http.Request) { data.Position.RotY = *req.RotY } - data.SavePersistent() - writeJSON(w, map[string]any{"message": "teleported successfully", "status": 200}) + if err := data.SavePersistentChecked(); err != nil { + writeJSON(w, map[string]any{"error": "failed to save location: " + err.Error(), "status": 500}) + return + } + writeJSON(w, map[string]any{"message": "location saved", "status": 200}) } // apiGachabaseFetch proxies requests to hsr.gachabase.net to bypass browser CORS restrictions. @@ -828,7 +831,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) { switch rootCmd { case "/heal": - output = "[OK] Team HP, SP, and Energy fully restored." + output = "Healing through this API is not implemented." + success = false case "/max": for _, av := range data.Avatars { @@ -866,53 +870,156 @@ func apiCommand(w http.ResponseWriter, r *http.Request) { } case "/avatar": - if len(parts) > 1 { - aid, _ := strconv.ParseUint(parts[1], 10, 32) - if av, exists := data.Avatars[uint32(aid)]; exists { - if len(parts) > 2 { - lvl, _ := strconv.ParseUint(parts[2], 10, 32) - if lvl > 0 && lvl <= 80 { - av.Level = uint32(lvl) - } - } - if len(parts) > 3 { - rank, _ := strconv.ParseUint(parts[3], 10, 32) - av.Data.Rank = uint32(rank) - } - output = fmt.Sprintf("[OK] Avatar %d updated (Lv.%d, Rank %d).", aid, av.Level, av.Data.Rank) - } else { - output = fmt.Sprintf("[Error] Avatar ID %d not found in freesr-data.", aid) - success = false + if len(parts) < 3 || len(parts) > 4 { + output, success = "Usage: /avatar [rank 0-6]", false + break + } + aid, aidErr := strconv.ParseUint(parts[1], 10, 32) + level, levelErr := strconv.ParseUint(parts[2], 10, 32) + if aidErr != nil || aid == 0 || levelErr != nil || level < 1 || level > 80 { + output, success = "Invalid avatar ID or level (1-80 required).", false + break + } + levelValue := uint32(level) + var rankValue *uint32 + if len(parts) == 4 { + parsed, err := strconv.ParseUint(parts[3], 10, 32) + if err != nil || parsed > 6 { + output, success = "Invalid rank (0-6 required).", false + break } + rank := uint32(parsed) + rankValue = &rank + } + if err := common.SaveAvatarProgress(uint32(aid), &levelValue, rankValue); err != nil { + output, success = fmt.Sprintf("Cannot update avatar: %v", err), false } else { - output = "Usage: /avatar [level] [rank]" - success = false + output = fmt.Sprintf("Avatar %d saved. Reconnect the game to load the change.", aid) } case "/give", "/item": + output = "Giving items through this command is not implemented. Use the inventory editor." + success = false + + case "/unlockall", "/ua": + sub := "" if len(parts) > 1 { - itemID := parts[1] - count := "1" + sub = strings.ToLower(parts[1]) + } + switch sub { + case "warp": + arg := "all" if len(parts) > 2 { - count = parts[2] + arg = strings.ToLower(parts[2]) } - output = fmt.Sprintf("[OK] Item %s x%s added to inventory.", itemID, count) - } else { - output = "Usage: /give [count]" + mode := 1 + switch arg { + case "all", "on", "1", "true": + mode = 1 + case "off", "0", "none", "disable", "false": + mode = 0 + case "avatar", "character", "char", "2": + mode = 2 + case "lightcone", "lc", "weapon", "3": + mode = 3 + default: + output = "Usage: /unlockall warp [all|avatar|lightcone|off]" + success = false + } + if success { + data.WarpUnlockMode = mode + visible, visibilityErr := common.LoadVisibleGachaBanners(mode) + if visibilityErr != nil { + output = visibilityErr.Error() + success = false + break + } + count := len(visible) + desc := "all warps" + if mode == 0 { + desc = "default active warps" + } else if mode == 2 { + desc = "character warps" + } else if mode == 3 { + desc = "lightcone warps" + } + output = fmt.Sprintf("[OK] Set %s (%d banners visible). Reopen Warp to refresh.", desc, count) + } + case "all": + data.WarpUnlockMode = 1 + visible, visibilityErr := common.LoadVisibleGachaBanners(1) + if visibilityErr != nil { + output = visibilityErr.Error() + success = false + break + } + count := len(visible) + output = fmt.Sprintf("[OK] Selected all %d verified warps. Reopen Warp to refresh.", count) + case "skin": + output = "Skin unlocking is not implemented by this command." + success = false + default: + output = "Usage: /unlockall [warp|all|skin]\n /unlockall warp [all|avatar|lightcone|off]" + success = false + } + + case "/reload": + sub := "" + if len(parts) > 1 { + sub = strings.ToLower(parts[1]) + } + switch sub { + case "banner", "banners": + if err := common.ReloadBanners(); err != nil { + output = fmt.Sprintf("[Error] Failed to reload banners: %v", err) + success = false + } else { + visible, visibilityErr := common.LoadVisibleGachaBanners(data.WarpUnlockMode) + if visibilityErr != nil { + output = visibilityErr.Error() + success = false + break + } + count := len(visible) + output = fmt.Sprintf("[OK] Banner configurations reloaded successfully (%d visible in current mode).", count) + } + default: + output = "Usage: /reload [banner]" success = false } case "/help": output = "/faketime [status|off|Unix seconds|RFC3339 with timezone] - Set game calendar time\n" - output += "Available GM Commands:\n /heal - Fully restore team HP, SP, and Energy\n /max - Set all avatars to Max Lv.80 E6\n /teleport [floor_id] - Teleport to scene\n /avatar [level] [rank] - Modify avatar\n /give [count] - Add items to inventory\n /clear lineup - Clear active lineup slots" + output += "Available GM Commands:\n /max - Set all avatars to Max Lv.80 E6\n /teleport [floor_id] - Set scene\n /avatar [rank] - Modify avatar\n /clear lineup - Clear active lineup slots\n /unlockall warp [all|avatar|lightcone|off] - Toggle unlocked gacha banners\n /reload banner - Reload banner configurations" default: - output = fmt.Sprintf("[OK] Custom command '%s' dispatched to server.", cmd) + output = fmt.Sprintf("Unknown command: %s", cmd) + success = false } - raw, _ := json.MarshalIndent(data, "", " ") - _ = os.WriteFile("freesr-data.json", raw, 0o644) - data.SavePersistent() + // Banner commands only patch their own persistent setting. Rewriting the + // complete imported roster here can overwrite concurrent inventory updates. + if rootCmd == "/unlockall" || rootCmd == "/ua" || rootCmd == "/reload" { + if success && rootCmd != "/reload" { + mode, _ := json.Marshal(data.WarpUnlockMode) + if err := common.PatchPersistentFields(map[string]json.RawMessage{"warp_unlock_mode": mode}); err != nil { + success = false + output = err.Error() + } + } + } else if success && rootCmd != "/help" && rootCmd != "/avatar" { + raw, err := json.MarshalIndent(data, "", " ") + if err == nil { + err = os.WriteFile("freesr-data.json", raw, 0o644) + } + if err == nil { + err = data.SavePersistentChecked() + } + if err != nil { + success = false + output = err.Error() + } + } Log(fmt.Sprintf("[GM Command] %s -> %s", cmd, output)) diff --git a/internal/sdkserver/api_avatar_command_test.go b/internal/sdkserver/api_avatar_command_test.go new file mode 100644 index 0000000..d72e9b4 --- /dev/null +++ b/internal/sdkserver/api_avatar_command_test.go @@ -0,0 +1,98 @@ +package sdkserver + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestAvatarCommandPatchesOnlyRequestedAvatarFields(t *testing.T) { + chdirRepoRootSDK(t) + raw, err := os.ReadFile("freesr-data.json") + if err != nil { + t.Fatal(err) + } + var root map[string]json.RawMessage + if err = json.Unmarshal(raw, &root); err != nil { + t.Fatal(err) + } + var avatars map[string]json.RawMessage + if err = json.Unmarshal(root["avatars"], &avatars); err != nil { + t.Fatal(err) + } + var avatar map[string]json.RawMessage + if err = json.Unmarshal(avatars["1204"], &avatar); err != nil { + t.Fatal(err) + } + avatar["future_field"] = json.RawMessage(`{"value":1234567890123456789}`) + avatars["1204"], _ = json.Marshal(avatar) + root["avatars"], _ = json.Marshal(avatars) + root["future_root"] = json.RawMessage(`{"keep":true}`) + compact := func(value json.RawMessage) string { + if len(value) == 0 { + return "" + } + var b bytes.Buffer + if err := json.Compact(&b, value); err != nil { + t.Fatal(err) + } + return b.String() + } + beforeMaterials := compact(root["materials"]) + beforeOtherAvatar := compact(avatars["1213"]) + initial, _ := json.Marshal(root) + if err := os.WriteFile("freesr-data.json", initial, 0600); err != nil { + t.Fatal(err) + } + run := func(command string) bool { + body, _ := json.Marshal(map[string]string{"command": command}) + w := httptest.NewRecorder() + apiCommand(w, httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(string(body)))) + var result struct { + Success bool `json:"success"` + } + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result.Success + } + if !run("/avatar 1204 42 3") { + t.Fatal("valid avatar update failed") + } + updated, _ := os.ReadFile("freesr-data.json") + if err := json.Unmarshal(updated, &root); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(root["avatars"], &avatars); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(avatars["1204"], &avatar); err != nil { + t.Fatal(err) + } + var data struct { + Rank uint32 `json:"rank"` + } + if err := json.Unmarshal(avatar["data"], &data); err != nil { + t.Fatal(err) + } + var level uint32 + if err := json.Unmarshal(avatar["level"], &level); err != nil { + t.Fatal(err) + } + if level != 42 || data.Rank != 3 || compact(avatar["future_field"]) != `{"value":1234567890123456789}` || compact(root["future_root"]) != `{"keep":true}` || compact(root["materials"]) != beforeMaterials || compact(avatars["1213"]) != beforeOtherAvatar { + t.Fatal("avatar update lost or modified unrelated data") + } + for _, invalid := range []string{"/avatar 1204 81", "/avatar 1204 2 7", "/avatar nope 5", "/avatar 999999 5"} { + if run(invalid) { + t.Fatalf("accepted %s", invalid) + } + after, _ := os.ReadFile("freesr-data.json") + if string(after) != string(updated) { + t.Fatalf("invalid command rewrote save: %s", invalid) + } + } +} diff --git a/internal/sdkserver/api_command_completions.go b/internal/sdkserver/api_command_completions.go index 044889c..046d62f 100644 --- a/internal/sdkserver/api_command_completions.go +++ b/internal/sdkserver/api_command_completions.go @@ -85,6 +85,18 @@ func apiCommandCompletions(w http.ResponseWriter, r *http.Request) { } else { issues = append(issues, err.Error()) } + out["unlockall_args"] = []commandCompletion{ + {Value: "warp all", Label: "Warp (All Banners)"}, + {Value: "warp avatar", Label: "Warp (Character Banners Only)"}, + {Value: "warp lightcone", Label: "Warp (Light Cone Banners Only)"}, + {Value: "warp off", Label: "Warp (Default Banners Only)"}, + {Value: "all", Label: "Unlock All (Warps + Skins)"}, + {Value: "skin", Label: "All Skins"}, + } + out["reload_args"] = []commandCompletion{ + {Value: "banner", Label: "Reload Gacha Banners"}, + } + for key := range out { sort.Slice(out[key], func(i, j int) bool { if out[key][i].Value == out[key][j].Value { diff --git a/internal/sdkserver/api_command_truth_test.go b/internal/sdkserver/api_command_truth_test.go new file mode 100644 index 0000000..9ad19db --- /dev/null +++ b/internal/sdkserver/api_command_truth_test.go @@ -0,0 +1,48 @@ +package sdkserver + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestUnsupportedAndReadOnlyCommandsDoNotRewriteSave(t *testing.T) { + chdirRepoRootSDK(t) + beforeData, err := os.ReadFile("freesr-data.json") + if err != nil { + t.Fatal(err) + } + beforePersistent, err := os.ReadFile("persistent") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + command string + success bool + }{ + {"/give 200001 100", false}, {"/heal", false}, {"/not_a_command", false}, {"/help", true}, + } { + body, _ := json.Marshal(map[string]string{"command": tc.command}) + response := httptest.NewRecorder() + apiCommand(response, httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(string(body)))) + var result struct { + Success bool `json:"success"` + Output string `json:"output"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.Success != tc.success || result.Output == "" { + t.Fatalf("%s: %+v", tc.command, result) + } + } + afterData, _ := os.ReadFile("freesr-data.json") + afterPersistent, _ := os.ReadFile("persistent") + if !bytes.Equal(beforeData, afterData) || !bytes.Equal(beforePersistent, afterPersistent) { + t.Fatal("read-only or unsupported command rewrote user data") + } +} diff --git a/internal/sdkserver/api_gacha_command_test.go b/internal/sdkserver/api_gacha_command_test.go new file mode 100644 index 0000000..51c0a40 --- /dev/null +++ b/internal/sdkserver/api_gacha_command_test.go @@ -0,0 +1,80 @@ +package sdkserver + +import ( + "bytes" + "encoding/json" + "gac-hkrpg-go/internal/testutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func chdirRepoRootSDK(t *testing.T) { + t.Helper() + _, source, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate test helper source") + } + root := filepath.Clean(filepath.Join(filepath.Dir(source), "..", "..")) + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Fatalf("cannot locate repository root from %s: %v", source, err) + } + temporary := t.TempDir() + if err := testutil.CopyRuntime(root, temporary); err != nil { + t.Fatal(err) + } + t.Chdir(temporary) +} + +func TestApiCommandUnlockAllWarp(t *testing.T) { + chdirRepoRootSDK(t) + before, err := os.ReadFile("freesr-data.json") + if err != nil { + t.Fatal(err) + } + + runCmd := func(command string) (bool, string) { + body, _ := json.Marshal(map[string]string{"command": command}) + req := httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(string(body))) + rec := httptest.NewRecorder() + apiCommand(rec, req) + var res struct { + Success bool `json:"success"` + Output string `json:"output"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &res) + return res.Success, res.Output + } + + // 1. /unlockall warp all + ok, out := runCmd("/unlockall warp all") + if !ok || !strings.Contains(out, "Set all warps") { + t.Fatalf("unexpected output for /unlockall warp all: ok=%v, out=%s", ok, out) + } + + // 2. /unlockall warp off + ok, out = runCmd("/unlockall warp off") + if !ok || !strings.Contains(out, "default active warps") { + t.Fatalf("unexpected output for /unlockall warp off: ok=%v, out=%s", ok, out) + } + + // 3. /unlockall warp avatar + ok, out = runCmd("/unlockall warp avatar") + if !ok || !strings.Contains(out, "character warps") { + t.Fatalf("unexpected output for /unlockall warp avatar: ok=%v, out=%s", ok, out) + } + + // 4. /reload banner + ok, out = runCmd("/reload banner") + if !ok || !strings.Contains(out, "Banner configurations reloaded") { + t.Fatalf("unexpected output for /reload banner: ok=%v, out=%s", ok, out) + } + after, err := os.ReadFile("freesr-data.json") + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("banner commands rewrote avatar inventory: %v", err) + } +} diff --git a/internal/sdkserver/api_teleport_test.go b/internal/sdkserver/api_teleport_test.go new file mode 100644 index 0000000..9356ff3 --- /dev/null +++ b/internal/sdkserver/api_teleport_test.go @@ -0,0 +1,57 @@ +package sdkserver + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestTeleportPersistsLocationAndReportsWriteFailure(t *testing.T) { + chdirRepoRootSDK(t) + body := `{"plane_id":20411,"floor_id":20411001,"entry_id":2041101,"x":123,"y":-456,"z":789,"rot_y":321}` + invoke := func() map[string]any { + t.Helper() + w := httptest.NewRecorder() + apiTeleport(w, httptest.NewRequest(http.MethodPost, "/api/teleport", strings.NewReader(body))) + var result map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result + } + if result := invoke(); result["status"] != float64(200) { + t.Fatalf("save response: %v", result) + } + raw, err := os.ReadFile("persistent") + if err != nil { + t.Fatal(err) + } + var saved struct { + Scene struct { + FloorID uint32 `json:"floor_id"` + } + Position struct { + X int32 `json:"x"` + Y int32 `json:"y"` + } + } + if err := json.Unmarshal(raw, &saved); err != nil { + t.Fatal(err) + } + if saved.Scene.FloorID != 20411001 || saved.Position.X != 123 || saved.Position.Y != -456 { + t.Fatalf("saved location: %+v", saved) + } + if err := os.WriteFile("persistent", []byte("invalid json"), 0600); err != nil { + t.Fatal(err) + } + if result := invoke(); result["status"] != float64(500) { + t.Fatalf("expected save failure, got %v", result) + } + raw, _ = os.ReadFile("persistent") + if string(raw) != "invalid json" { + t.Fatalf("invalid save was overwritten: %q", raw) + } +} diff --git a/internal/sdkserver/server.go b/internal/sdkserver/server.go index f03e019..fce628d 100644 --- a/internal/sdkserver/server.go +++ b/internal/sdkserver/server.go @@ -22,10 +22,13 @@ import ( // VersionConfig is one entry of versions.json. type VersionConfig struct { - AssetBundleURL string `json:"asset_bundle_url"` - ExResourceURL string `json:"ex_resource_url"` - LuaURL string `json:"lua_url"` - IfixURL string `json:"ifix_url"` + AssetBundleURL string `json:"asset_bundle_url"` + AssetBundleURLB string `json:"asset_bundle_url_b"` + ExResourceURL string `json:"ex_resource_url"` + LuaURL string `json:"lua_url"` + IfixURL string `json:"ifix_url"` + LuaVersion string `json:"lua_version"` + IfixVersion string `json:"ifix_version"` } type appState struct { @@ -51,8 +54,19 @@ func (a *appState) getOrInsertHotfix(version, dispatchSeed string) VersionConfig a.mu.Lock() defer a.mu.Unlock() - if cfg, ok := a.hotfixMap[version]; ok { - return cfg + exact, hasExact := a.hotfixMap[version] + if hasExact && exact.AssetBundleURL != "" { + return exact + } + // Mobile clients append build identifiers after the release version. Prefer + // an exact entry, then look up the release in the configured metadata. + if release, _, ok := strings.Cut(version, "_"); ok { + if cfg, found := a.hotfixMap[release]; found { + return cfg + } + } + if hasExact { + return exact } if cfg, ok := a.hotfixMap["CNBETAWin"+version]; ok { return cfg @@ -190,15 +204,25 @@ func queryGateway(state *appState) http.HandlerFunc { if state.runtime.LuaOverlay.Enabled { cfg.LuaURL = state.runtime.PublicSDKURL + luaOverlayRoute } + assetURLB := cfg.AssetBundleURLB + if assetURLB == "" { + assetURLB = cfg.AssetBundleURL + } + ifixVersion := cfg.IfixVersion + if ifixVersion == "" { + ifixVersion = "0" + } rsp := &goproto.Gateserver{ Ip: state.runtime.PublicGameHost, Port: uint32(state.runtime.GamePort), AssetBundleUrl: cfg.AssetBundleURL, - AssetBundleUrlB: cfg.AssetBundleURL, + AssetBundleUrlB: assetURLB, ExResourceUrl: cfg.ExResourceURL, LuaUrl: cfg.LuaURL, - IfixVersion: "0", + IfixUrl: cfg.IfixURL, + IfixVersion: ifixVersion, + LuaVersion: cfg.LuaVersion, Unk1: true, Unk2: true, Unk3: true, diff --git a/internal/testutil/workspace.go b/internal/testutil/workspace.go new file mode 100644 index 0000000..40019df --- /dev/null +++ b/internal/testutil/workspace.go @@ -0,0 +1,49 @@ +// Package testutil provides isolated runtime fixtures for tests that write saves. +package testutil + +import ( + "io/fs" + "os" + "path/filepath" + "strings" +) + +// CopyRuntime copies data, never links writable saves to the source checkout. +// Local operator configuration and binaries are intentionally excluded. +func CopyRuntime(root, destination string) error { + entries, err := os.ReadDir(root) + if err != nil { + return err + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasSuffix(name, ".local.json") || !(strings.HasSuffix(name, ".json") || name == "persistent" || name == "go.mod") { + continue + } + data, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + return err + } + if err = os.WriteFile(filepath.Join(destination, name), data, 0600); err != nil { + return err + } + } + return filepath.WalkDir(filepath.Join(root, "resources"), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + target := filepath.Join(destination, relative) + if d.IsDir() { + return os.MkdirAll(target, 0700) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, 0600) + }) +} diff --git a/persistent b/persistent index 21bc3f2..2e17d92 100644 --- a/persistent +++ b/persistent @@ -1,5 +1,5 @@ { - "active_team_preset": 3, + "active_team_preset": 4, "aha_help_level": 3, "auto_technique_food": true, "camera_settings": { @@ -11,23 +11,23 @@ "assembly_sha256": "c5e02e6e984f7e5577c78b995a8efeee2d06ee22e67d484ad5e859c8bf21e144", "client_version": "4.6.53", "format_version": 1, - "revision": 989, + "revision": 991, "runtime": { - "run_id": 951 + "run_id": 990 }, "uid": 52022, - "wire_data": "GkBCHAoAMgoIARIGCCE4BmgBMgoIAhIGCEg4BmgCWAJiIBCFnQEgAkoQGgAiACoAMgA6AFoAcgB6AFIAWAFyAHgG" + "wire_data": "GkBCHAoAMgoIARIGCCE4BmgBMgoIAhIGCEg4BmgCWAJiIBCFnQEgAkoQGgAiACoAMgA6AFoAcgB6AFIAWAFyAHgGOugLGAIqBTACoiYAMgkqB/qvFc6M2hBI3gdSnwEaDQgEKgZKAvVOcGniFAAaTwgIKgZKAulScGmqLUJ6BAgEEAZ6BAgFEBB6BAgJEEh6BAgBEAJ6BAgCEAJ6BAgGECR6BAgHEDB6BAgIEDx6BAgKEAB6BAgLEAB6BAgDEAQaDAgJKgVKAQBwBfo1ABoNCBMqBkoCyT5wb/pfABoPCDgqBkoCwxdwa7plAiACGg8IXioGSgLdH3BrumUCIAFiJ2IlCGQQBRgKIAYwCTgDQANQZFgDYASyGARgA3gG0jQE1fuxA4JRAGKGBAqDBCplGAEo9s8IMAM4DkIFCPsHEGRCBQggELQBQgUINBDwAUIFCDkQ8AFCBQgiELgDQgUINRDgA0IFCCEQ4ANCBQg7EMIDQgUI9gcQZEIFCIEIEGRCBQgsELACQgUIMRC0AUIFCPUHEGQqZRgBKPbPCDAEOA9CBQg5EPABQgUIIBC0AUIFCCwQsAJCBQg1EOADQgUIOxDCA0IFCPUHEGRCBQj2BxBkQgUINBDwAUIFCPsHEGRCBQiBCBBkQgUIIRDgA0IFCDEQtAFCBQgiELgDKmUYASiugwgwBTgQQgUIIBC0AUIFCCwQsAJCBQg0EPABQgUINRDgA0IFCDkQ8AFCBQgiELgDQgUIOxDCA0IFCPUHEGRCBQj2BxBkQgUIgQgQZEIFCCEQ4ANCBQgxELQBQgUI+wcQZCplGAEousoIMAY4EUIFCCIQuANCBQj1BxBkQgUI9gcQZEIFCDEQtAFCBQg1EOADQgUIORDwAUIFCPsHEGRCBQghEOADQgUILBCwAkIFCDQQ8AFCBQg7EMIDQgUIIBC0AUIFCIEIEGQqZRgCKKK8BzAHOBJCBQg1EOADQgUI9QcQZEIFCPYHEGRCBQgxELQBQgUI+wcQZEIFCIEIEGRCBQggELQBQgUIIRDgA0IFCDsQwgNCBQgiELgDQgUILBCwAkIFCDkQ8AFCBQg0EPABYgIyAGLTAXrQAQoYEgQQSygBEgQQGSgCEgIoAxICKAQSAigFaAJ6sQGykAaWkQbe3Qam3wbu4Abi5AbuqwfSrAe2rQfirweqsQeOsgfysgfWswe6tAeetQfmtgfKtweSuQf2uQfaugeivAeGvQfi+geq/AeO/Qfy/QfW/ge6/wfmgQjKggiugwj2hAiGiAjGxgiqxwiOyAjyyAjWyQi6ygieywiCzAjmzAjKzQiSzwj2zwi+0QjWlAm6lQmelgmClwnmlwnKmAmumQnamwm+nAminQmGngmG8TBiAhoAYr8DErwDGhwSCAiT0bO/ATABEggI6d/kwQEwARIGCOGSfDABKAEyAmAPMgJgGDICYB848RVAAUoDSIgBWgBiFwgBKAFCERCBu7AhMgKEBziO0QVgBXABYhoIAigBQhQQg7uwISABMgFlOO3OBUABYApwAmIQCAMoAUIKEIW7sCEyAQFwBWIWCAQoAUIQEIa7sCEyAXE47s4FYApwA2IXCAUoAUIREIi7sCEyAoYHOKzQBWAKcAFiFggGKAFCEBCJu7AhMgFpOMjPBWAPcARiGwgBKAJCFRCKu7AhIAEyAskBOPfOBUABYApwAmIQCAIoAkIKEIy7sCEyAQJwBWIXCAMoAkIREI27sCEyAtUBOPjOBWAKcANiFwgEKAJCERCPu7AhMgKHBzit0AVgCnABYhcIBSgCQhEQkLuwITICzQE4yc8FYA9wBGIZCAEoA0ITEJG7sCEyAq0COIHPBUABYApwAmIQCAIoA0IKEJO7sCEyAQNwBWIXCAMoA0IREJS7sCEyAs0COILPBWAKcANiFwgEKANCERCWu7AhMgKIBziu0AVgCnABYhUIBSgDQg8Ql7uwITIC6xc4ys8FcARyAhoAYgJCAGIYUhZCCTIExz7JPljHPkIJMgSDVoRWWINWYgBiH2odMM0BQgkIwpMGEN67iDBCCQjSlgYQ1fCgMFiNnD1iA9JnAGICKgBiDYJ8ChoICAFA+sMGSAdwhZ0B" } }, "currency_wars_commands": [ { - "id": "ab08c817e3047182cb494d22b45e1ffc", - "uid": 52022, - "run_id": 951, "field": "role", - "value": 120402, + "id": "ab08c817e3047182cb494d22b45e1ffc", "quantity": 1, - "star": 4 + "run_id": 951, + "star": 4, + "uid": 52022, + "value": 120402 } ], "dressed_skins": {}, @@ -40,10 +40,10 @@ "1308": true }, "lineups": { - "0": 1408, - "1": 1412, - "2": 1313, - "3": 1414 + "0": 1506, + "1": 1501, + "2": 1511, + "3": 1503 }, "main_character": "MaleElation", "march_type": "MarchHunt", @@ -53,10 +53,10 @@ 4001 ], "position": { - "rot_y": 195849, - "x": -1115, - "y": -152077, - "z": -642542 + "rot_y": 155566, + "x": -92, + "y": 4278, + "z": 68083 }, "profile": { "display_avatars": [ @@ -85,9 +85,9 @@ "signature": "test" }, "scene": { - "entry_id": 1030402, - "floor_id": 10304001, - "plane_id": 10304 + "entry_id": 102020107, + "floor_id": 10202001, + "plane_id": 10202 }, "scene_group_properties": { "20571001": { @@ -192,6 +192,17 @@ "favourite": true, "leader_slot": 0, "name": "Team 8" + }, + "8": { + "avatars": { + "0": 1511, + "1": 1415, + "2": 0, + "3": 0 + }, + "favourite": true, + "leader_slot": 0, + "name": "Bug" } }, "technique_food_stock": { @@ -412,5 +423,6 @@ ] ] } - } + }, + "warp_unlock_mode": 0 } \ No newline at end of file diff --git a/resources/Banners.json b/resources/Banners.json new file mode 100644 index 0000000..5433c72 --- /dev/null +++ b/resources/Banners.json @@ -0,0 +1,1002 @@ +[ + { + "id": 1001, + "gachaType": "Normal", + "beginTime": 0, + "endTime": 2051226000, + "rateUpItems5": [23002, 1003, 1101, 1104, 23000, 23003], + "rateUpItems4": [1001, 1002, 1003, 1004, 1008, 1009, 1013, 1101, 1103, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1111, 1201, 1202, 1206, 1207, 1209, 1210, 1211, 1214, 1215, 1223, 1301, 1312, 21000, 21001, 21002, 21003, 21004, 21005, 21006, 21007, 21008, 21009, 21010, 21011, 21012, 21013, 21014, 21015, 21016, 21017, 21018, 21019, 21020, 21042, 21043, 21044, 21045, 21046, 21047, 21048, 23000, 23002, 23003, 23004, 23005, 23012, 23013, 21051] + }, + { + "id": 2120, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1505], + "rateUpItems4": [1110, 1206, 1210, 1505] + }, + { + "id": 2121, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1403], + "rateUpItems4": [1110, 1206, 1210, 1403] + }, + { + "id": 2122, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1313], + "rateUpItems4": [1110, 1206, 1210, 1313] + }, + { + "id": 2123, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1220], + "rateUpItems4": [1110, 1206, 1210, 1220] + }, + { + "id": 3120, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23058], + "rateUpItems4": [21004, 21007, 21010, 23058] + }, + { + "id": 3121, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23038], + "rateUpItems4": [21004, 21007, 21010, 23038] + }, + { + "id": 3122, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23034], + "rateUpItems4": [21004, 21007, 21010, 23034] + }, + { + "id": 3123, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23031], + "rateUpItems4": [21004, 21007, 21010, 23031] + }, + { + "id": 5001, + "gachaType": "CollaborationAvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1014], + "rateUpItems4": [1110, 1206, 1210, 1014] + }, + { + "id": 5002, + "gachaType": "CollaborationAvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1015], + "rateUpItems4": [1110, 1206, 1210, 1015] + }, + { + "id": 6001, + "gachaType": "CollaborationWeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23045], + "rateUpItems4": [21004, 21007, 21010, 23045] + }, + { + "id": 6002, + "gachaType": "CollaborationWeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23046], + "rateUpItems4": [21004, 21007, 21010, 23046] + }, + { + "id": 2013, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1102], + "rateUpItems4": [1111, 1206, 1210, 1102] + }, + { + "id": 2018, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1205], + "rateUpItems4": [1001, 1202, 1214, 1205] + }, + { + "id": 2021, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1307], + "rateUpItems4": [1202, 1210, 1312, 1307] + }, + { + "id": 2026, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1203], + "rateUpItems4": [1002, 1106, 1301, 1203] + }, + { + "id": 2028, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1212], + "rateUpItems4": [1103, 1110, 1111, 1212] + }, + { + "id": 2029, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1309], + "rateUpItems4": [1001, 1214, 1215, 1309] + }, + { + "id": 2032, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1208], + "rateUpItems4": [1106, 1109, 1111, 1208] + }, + { + "id": 2034, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1303], + "rateUpItems4": [1214, 1301, 1312, 1303] + }, + { + "id": 2035, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1314], + "rateUpItems4": [1009, 1103, 1105, 1314] + }, + { + "id": 2036, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1302], + "rateUpItems4": [1009, 1103, 1105, 1302] + }, + { + "id": 2039, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1218], + "rateUpItems4": [1008, 1109, 1210, 1218] + }, + { + "id": 2040, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1306], + "rateUpItems4": [1008, 1109, 1210, 1306] + }, + { + "id": 2041, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1220], + "rateUpItems4": [1009, 1111, 1223, 1220] + }, + { + "id": 2046, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1112], + "rateUpItems4": [1105, 1210, 1312, 1112] + }, + { + "id": 2047, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1317], + "rateUpItems4": [1110, 1207, 1214, 1317] + }, + { + "id": 2048, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1213], + "rateUpItems4": [1110, 1207, 1214, 1213] + }, + { + "id": 2049, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1308], + "rateUpItems4": [1001, 1106, 1108, 1308] + }, + { + "id": 2050, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1304], + "rateUpItems4": [1001, 1106, 1108, 1304] + }, + { + "id": 2052, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1204], + "rateUpItems4": [1008, 1201, 1202, 1204] + }, + { + "id": 2063, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1403], + "rateUpItems4": [1109, 1110, 1210, 1403] + }, + { + "id": 2064, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1221], + "rateUpItems4": [1109, 1110, 1210, 1221] + }, + { + "id": 2065, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1404], + "rateUpItems4": [1008, 1105, 1214, 1404] + }, + { + "id": 2066, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1217], + "rateUpItems4": [1008, 1105, 1214, 1217] + }, + { + "id": 2067, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1407], + "rateUpItems4": [1106, 1110, 1301, 1407] + }, + { + "id": 2072, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1305], + "rateUpItems4": [1002, 1103, 1223, 1305] + }, + { + "id": 2075, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1406], + "rateUpItems4": [1201, 1206, 1214, 1406] + }, + { + "id": 2084, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1410], + "rateUpItems4": [1008, 1009, 1109, 1410] + }, + { + "id": 2085, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1005], + "rateUpItems4": [1008, 1009, 1109, 1005] + }, + { + "id": 2086, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1412], + "rateUpItems4": [1002, 1108, 1201, 1412] + }, + { + "id": 2087, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1006], + "rateUpItems4": [1002, 1108, 1201, 1006] + }, + { + "id": 2088, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1413], + "rateUpItems4": [1210, 1214, 1312, 1413] + }, + { + "id": 2089, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1401], + "rateUpItems4": [1210, 1214, 1312, 1401] + }, + { + "id": 2090, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1414], + "rateUpItems4": [1103, 1206, 1215, 1414] + }, + { + "id": 2091, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1405], + "rateUpItems4": [1103, 1206, 1215, 1405] + }, + { + "id": 2099, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1321], + "rateUpItems4": [1001, 1111, 1301, 1321] + }, + { + "id": 2100, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1310], + "rateUpItems4": [1001, 1111, 1301, 1310] + }, + { + "id": 2101, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1225], + "rateUpItems4": [1008, 1105, 1108, 1225] + }, + { + "id": 2102, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1222], + "rateUpItems4": [1008, 1105, 1108, 1222] + }, + { + "id": 2103, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1402], + "rateUpItems4": [1002, 1202, 1207, 1402] + }, + { + "id": 2104, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1313], + "rateUpItems4": [1002, 1202, 1207, 1313] + }, + { + "id": 2109, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1501], + "rateUpItems4": [1106, 1201, 1215, 1501] + }, + { + "id": 2115, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1315], + "rateUpItems4": [1009, 1223, 1312, 1315] + }, + { + "id": 2116, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1506], + "rateUpItems4": [1109, 1214, 1301, 1506] + }, + { + "id": 2125, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1502], + "rateUpItems4": [1108, 1111, 1202, 1502] + }, + { + "id": 2126, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1415], + "rateUpItems4": [1001, 1008, 1207, 1415] + }, + { + "id": 2127, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1408], + "rateUpItems4": [1001, 1008, 1207, 1408] + }, + { + "id": 2128, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1510], + "rateUpItems4": [1103, 1215, 1223, 1510] + }, + { + "id": 2135, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1512], + "rateUpItems4": [1002, 1201, 1301, 1512] + }, + { + "id": 2136, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1409], + "rateUpItems4": [1002, 1201, 1301, 1409] + }, + { + "id": 2137, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1513], + "rateUpItems4": [1108, 1109, 1210, 1513] + }, + { + "id": 2138, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1504], + "rateUpItems4": [1108, 1109, 1210, 1504] + }, + { + "id": 2139, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1503], + "rateUpItems4": [1201, 1214, 1312, 1503] + }, + { + "id": 2140, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1505], + "rateUpItems4": [1201, 1214, 1312, 1505] + }, + { + "id": 2141, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1507], + "rateUpItems4": [1201, 1214, 1312, 1507] + }, + { + "id": 5003, + "gachaType": "CollaborationAvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1508], + "rateUpItems4": [1110, 1206, 1210, 1508] + }, + { + "id": 5004, + "gachaType": "CollaborationAvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1509], + "rateUpItems4": [1110, 1206, 1210, 1509] + }, + { + "id": 6003, + "gachaType": "CollaborationWeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23061], + "rateUpItems4": [21004, 21007, 21010, 23061] + }, + { + "id": 6004, + "gachaType": "CollaborationWeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23062], + "rateUpItems4": [21004, 21007, 21010, 23062] + }, + { + "id": 3013, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23001], + "rateUpItems4": [21003, 21005, 21015, 23001] + }, + { + "id": 3018, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23009], + "rateUpItems4": [21002, 21011, 21012, 23009] + }, + { + "id": 3021, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23022], + "rateUpItems4": [21015, 21018, 21042, 23022] + }, + { + "id": 3026, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23008], + "rateUpItems4": [21000, 21001, 21017, 23008] + }, + { + "id": 3028, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23014], + "rateUpItems4": [21007, 21013, 21043, 23014] + }, + { + "id": 3029, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23026], + "rateUpItems4": [21010, 21014, 21044, 23026] + }, + { + "id": 3032, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23011], + "rateUpItems4": [21009, 21012, 21020, 23011] + }, + { + "id": 3034, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23019], + "rateUpItems4": [21002, 21004, 21008, 23019] + }, + { + "id": 3035, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23028], + "rateUpItems4": [21007, 21016, 21045, 23028] + }, + { + "id": 3036, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23018], + "rateUpItems4": [21007, 21016, 21045, 23018] + }, + { + "id": 3039, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23029], + "rateUpItems4": [21008, 21012, 21046, 23029] + }, + { + "id": 3040, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23021], + "rateUpItems4": [21008, 21012, 21046, 23021] + }, + { + "id": 3041, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23031], + "rateUpItems4": [21006, 21010, 21015, 23031] + }, + { + "id": 3046, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23016], + "rateUpItems4": [21007, 21011, 21047, 23016] + }, + { + "id": 3047, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23033], + "rateUpItems4": [21019, 21045, 21048, 23033] + }, + { + "id": 3048, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23015], + "rateUpItems4": [21019, 21045, 21048, 23015] + }, + { + "id": 3049, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23024], + "rateUpItems4": [21002, 21013, 21044, 23024] + }, + { + "id": 3050, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23023], + "rateUpItems4": [21002, 21013, 21044, 23023] + }, + { + "id": 3052, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23010], + "rateUpItems4": [21006, 21010, 21046, 23010] + }, + { + "id": 3063, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23038], + "rateUpItems4": [21000, 21011, 21016, 23038] + }, + { + "id": 3064, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23030], + "rateUpItems4": [21000, 21011, 21016, 23030] + }, + { + "id": 3065, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23039], + "rateUpItems4": [21004, 21012, 21014, 23039] + }, + { + "id": 3066, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23017], + "rateUpItems4": [21004, 21012, 21014, 23017] + }, + { + "id": 3067, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23040], + "rateUpItems4": [21000, 21001, 21044, 23040] + }, + { + "id": 3072, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23020], + "rateUpItems4": [21005, 21016, 21045, 23020] + }, + { + "id": 3075, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23043], + "rateUpItems4": [21007, 21008, 21051, 23043] + }, + { + "id": 3084, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23047], + "rateUpItems4": [21008, 21042, 21046, 23047] + }, + { + "id": 3085, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23006], + "rateUpItems4": [21008, 21042, 21046, 23006] + }, + { + "id": 3086, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23048], + "rateUpItems4": [21001, 21006, 21010, 23048] + }, + { + "id": 3087, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23007], + "rateUpItems4": [21001, 21006, 21010, 23007] + }, + { + "id": 3088, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23049], + "rateUpItems4": [21018, 21045, 21048, 23049] + }, + { + "id": 3089, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23037], + "rateUpItems4": [21018, 21045, 21048, 23037] + }, + { + "id": 3090, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23051], + "rateUpItems4": [21003, 21009, 21014, 23051] + }, + { + "id": 3091, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23041], + "rateUpItems4": [21003, 21009, 21014, 23041] + }, + { + "id": 3099, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23050], + "rateUpItems4": [21001, 21004, 21043, 23050] + }, + { + "id": 3100, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23025], + "rateUpItems4": [21001, 21004, 21043, 23025] + }, + { + "id": 3101, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23035], + "rateUpItems4": [21000, 21005, 21020, 23035] + }, + { + "id": 3102, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23032], + "rateUpItems4": [21000, 21005, 21020, 23032] + }, + { + "id": 3103, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23036], + "rateUpItems4": [21016, 21018, 21051, 23036] + }, + { + "id": 3104, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23034], + "rateUpItems4": [21016, 21018, 21051, 23034] + }, + { + "id": 3109, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23053], + "rateUpItems4": [21011, 21015, 21054, 23053] + }, + { + "id": 3115, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23027], + "rateUpItems4": [21013, 21047, 21048, 23027] + }, + { + "id": 3116, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23057], + "rateUpItems4": [21008, 21042, 21046, 23057] + }, + { + "id": 3125, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23054], + "rateUpItems4": [21001, 21002, 21005, 23054] + }, + { + "id": 3126, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23052], + "rateUpItems4": [21012, 21017, 21018, 23052] + }, + { + "id": 3127, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23044], + "rateUpItems4": [21012, 21017, 21018, 23044] + }, + { + "id": 3128, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23060], + "rateUpItems4": [21006, 21019, 21045, 23060] + }, + { + "id": 3135, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23063], + "rateUpItems4": [21048, 21051, 21066, 23063] + }, + { + "id": 3136, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23042], + "rateUpItems4": [21048, 21051, 21066, 23042] + }, + { + "id": 3137, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23064], + "rateUpItems4": [21003, 21014, 21054, 23064] + }, + { + "id": 3138, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23056], + "rateUpItems4": [21003, 21014, 21054, 23056] + }, + { + "id": 3139, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23055], + "rateUpItems4": [21000, 21011, 21044, 23055] + }, + { + "id": 3140, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23058], + "rateUpItems4": [21000, 21011, 21044, 23058] + }, + { + "id": 3141, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23059], + "rateUpItems4": [21000, 21011, 21044, 23059] + } +] diff --git a/resources/BannersUnlockAll.json b/resources/BannersUnlockAll.json new file mode 100644 index 0000000..1302e97 --- /dev/null +++ b/resources/BannersUnlockAll.json @@ -0,0 +1,930 @@ +[ + { + "id": 2085, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1005], + "rateUpItems4": [1008, 1009, 1109, 1005] + }, + { + "id": 2087, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1006], + "rateUpItems4": [1002, 1108, 1201, 1006] + }, + { + "id": 5001, + "gachaType": "CollaborationAvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1014], + "rateUpItems4": [1110, 1206, 1210, 1014] + }, + { + "id": 5002, + "gachaType": "CollaborationAvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1015], + "rateUpItems4": [1110, 1206, 1210, 1015] + }, + { + "id": 2013, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1102], + "rateUpItems4": [1111, 1206, 1210, 1102] + }, + { + "id": 2046, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1112], + "rateUpItems4": [1105, 1210, 1312, 1112] + }, + { + "id": 2026, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1203], + "rateUpItems4": [1002, 1106, 1301, 1203] + }, + { + "id": 2052, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1204], + "rateUpItems4": [1008, 1201, 1202, 1204] + }, + { + "id": 2018, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1205], + "rateUpItems4": [1001, 1202, 1214, 1205] + }, + { + "id": 2032, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1208], + "rateUpItems4": [1106, 1109, 1111, 1208] + }, + { + "id": 2028, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1212], + "rateUpItems4": [1103, 1110, 1111, 1212] + }, + { + "id": 2048, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1213], + "rateUpItems4": [1110, 1207, 1214, 1213] + }, + { + "id": 2066, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1217], + "rateUpItems4": [1008, 1105, 1214, 1217] + }, + { + "id": 2039, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1218], + "rateUpItems4": [1008, 1109, 1210, 1218] + }, + { + "id": 2041, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1220], + "rateUpItems4": [1009, 1111, 1223, 1220] + }, + { + "id": 2064, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1221], + "rateUpItems4": [1109, 1110, 1210, 1221] + }, + { + "id": 2102, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1222], + "rateUpItems4": [1008, 1105, 1108, 1222] + }, + { + "id": 2101, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1225], + "rateUpItems4": [1008, 1105, 1108, 1225] + }, + { + "id": 2036, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1302], + "rateUpItems4": [1009, 1103, 1105, 1302] + }, + { + "id": 2034, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1303], + "rateUpItems4": [1214, 1301, 1312, 1303] + }, + { + "id": 2050, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1304], + "rateUpItems4": [1001, 1106, 1108, 1304] + }, + { + "id": 2072, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1305], + "rateUpItems4": [1002, 1103, 1223, 1305] + }, + { + "id": 2040, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1306], + "rateUpItems4": [1008, 1109, 1210, 1306] + }, + { + "id": 2021, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1307], + "rateUpItems4": [1202, 1210, 1312, 1307] + }, + { + "id": 2049, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1308], + "rateUpItems4": [1001, 1106, 1108, 1308] + }, + { + "id": 2029, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1309], + "rateUpItems4": [1001, 1214, 1215, 1309] + }, + { + "id": 2100, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1310], + "rateUpItems4": [1001, 1111, 1301, 1310] + }, + { + "id": 2104, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1313], + "rateUpItems4": [1002, 1202, 1207, 1313] + }, + { + "id": 2035, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1314], + "rateUpItems4": [1009, 1103, 1105, 1314] + }, + { + "id": 2115, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1315], + "rateUpItems4": [1009, 1223, 1312, 1315] + }, + { + "id": 2047, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1317], + "rateUpItems4": [1110, 1207, 1214, 1317] + }, + { + "id": 2099, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1321], + "rateUpItems4": [1001, 1111, 1301, 1321] + }, + { + "id": 2089, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1401], + "rateUpItems4": [1210, 1214, 1312, 1401] + }, + { + "id": 2103, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1402], + "rateUpItems4": [1002, 1202, 1207, 1402] + }, + { + "id": 2063, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1403], + "rateUpItems4": [1109, 1110, 1210, 1403] + }, + { + "id": 2065, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1404], + "rateUpItems4": [1008, 1105, 1214, 1404] + }, + { + "id": 2091, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1405], + "rateUpItems4": [1103, 1206, 1215, 1405] + }, + { + "id": 2075, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1406], + "rateUpItems4": [1201, 1206, 1214, 1406] + }, + { + "id": 2067, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1407], + "rateUpItems4": [1106, 1110, 1301, 1407] + }, + { + "id": 2127, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1408], + "rateUpItems4": [1001, 1008, 1207, 1408] + }, + { + "id": 2136, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1409], + "rateUpItems4": [1002, 1201, 1301, 1409] + }, + { + "id": 2084, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1410], + "rateUpItems4": [1008, 1009, 1109, 1410] + }, + { + "id": 2086, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1412], + "rateUpItems4": [1002, 1108, 1201, 1412] + }, + { + "id": 2088, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1413], + "rateUpItems4": [1210, 1214, 1312, 1413] + }, + { + "id": 2090, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1414], + "rateUpItems4": [1103, 1206, 1215, 1414] + }, + { + "id": 2126, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1415], + "rateUpItems4": [1001, 1008, 1207, 1415] + }, + { + "id": 2109, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1501], + "rateUpItems4": [1106, 1201, 1215, 1501] + }, + { + "id": 2125, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1502], + "rateUpItems4": [1108, 1111, 1202, 1502] + }, + { + "id": 2139, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1503], + "rateUpItems4": [1201, 1214, 1312, 1503] + }, + { + "id": 2138, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1504], + "rateUpItems4": [1108, 1109, 1210, 1504] + }, + { + "id": 2140, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1505], + "rateUpItems4": [1201, 1214, 1312, 1505] + }, + { + "id": 2116, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1506], + "rateUpItems4": [1109, 1214, 1301, 1506] + }, + { + "id": 2141, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1507], + "rateUpItems4": [1201, 1214, 1312, 1507] + }, + { + "id": 5003, + "gachaType": "CollaborationAvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1508], + "rateUpItems4": [1110, 1206, 1210, 1508] + }, + { + "id": 5004, + "gachaType": "CollaborationAvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1509], + "rateUpItems4": [1110, 1206, 1210, 1509] + }, + { + "id": 2128, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1510], + "rateUpItems4": [1103, 1215, 1223, 1510] + }, + { + "id": 2135, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1512], + "rateUpItems4": [1002, 1201, 1301, 1512] + }, + { + "id": 2137, + "gachaType": "AvatarUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [1513], + "rateUpItems4": [1108, 1109, 1210, 1513] + }, + { + "id": 3013, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23001], + "rateUpItems4": [21003, 21005, 21015, 23001] + }, + { + "id": 3085, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23006], + "rateUpItems4": [21008, 21042, 21046, 23006] + }, + { + "id": 3087, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23007], + "rateUpItems4": [21001, 21006, 21010, 23007] + }, + { + "id": 3026, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23008], + "rateUpItems4": [21000, 21001, 21017, 23008] + }, + { + "id": 3018, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23009], + "rateUpItems4": [21002, 21011, 21012, 23009] + }, + { + "id": 3052, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23010], + "rateUpItems4": [21006, 21010, 21046, 23010] + }, + { + "id": 3032, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23011], + "rateUpItems4": [21009, 21012, 21020, 23011] + }, + { + "id": 3028, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23014], + "rateUpItems4": [21007, 21013, 21043, 23014] + }, + { + "id": 3048, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23015], + "rateUpItems4": [21019, 21045, 21048, 23015] + }, + { + "id": 3046, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23016], + "rateUpItems4": [21007, 21011, 21047, 23016] + }, + { + "id": 3066, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23017], + "rateUpItems4": [21004, 21012, 21014, 23017] + }, + { + "id": 3036, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23018], + "rateUpItems4": [21007, 21016, 21045, 23018] + }, + { + "id": 3034, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23019], + "rateUpItems4": [21002, 21004, 21008, 23019] + }, + { + "id": 3072, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23020], + "rateUpItems4": [21005, 21016, 21045, 23020] + }, + { + "id": 3040, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23021], + "rateUpItems4": [21008, 21012, 21046, 23021] + }, + { + "id": 3021, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23022], + "rateUpItems4": [21015, 21018, 21042, 23022] + }, + { + "id": 3050, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23023], + "rateUpItems4": [21002, 21013, 21044, 23023] + }, + { + "id": 3049, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23024], + "rateUpItems4": [21002, 21013, 21044, 23024] + }, + { + "id": 3100, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23025], + "rateUpItems4": [21001, 21004, 21043, 23025] + }, + { + "id": 3029, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23026], + "rateUpItems4": [21010, 21014, 21044, 23026] + }, + { + "id": 3115, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23027], + "rateUpItems4": [21013, 21047, 21048, 23027] + }, + { + "id": 3035, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23028], + "rateUpItems4": [21007, 21016, 21045, 23028] + }, + { + "id": 3039, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23029], + "rateUpItems4": [21008, 21012, 21046, 23029] + }, + { + "id": 3064, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23030], + "rateUpItems4": [21000, 21011, 21016, 23030] + }, + { + "id": 3041, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23031], + "rateUpItems4": [21006, 21010, 21015, 23031] + }, + { + "id": 3102, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23032], + "rateUpItems4": [21000, 21005, 21020, 23032] + }, + { + "id": 3047, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23033], + "rateUpItems4": [21019, 21045, 21048, 23033] + }, + { + "id": 3104, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23034], + "rateUpItems4": [21016, 21018, 21051, 23034] + }, + { + "id": 3101, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23035], + "rateUpItems4": [21000, 21005, 21020, 23035] + }, + { + "id": 3103, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23036], + "rateUpItems4": [21016, 21018, 21051, 23036] + }, + { + "id": 3089, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23037], + "rateUpItems4": [21018, 21045, 21048, 23037] + }, + { + "id": 3063, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23038], + "rateUpItems4": [21000, 21011, 21016, 23038] + }, + { + "id": 3065, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23039], + "rateUpItems4": [21004, 21012, 21014, 23039] + }, + { + "id": 3067, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23040], + "rateUpItems4": [21000, 21001, 21044, 23040] + }, + { + "id": 3091, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23041], + "rateUpItems4": [21003, 21009, 21014, 23041] + }, + { + "id": 3136, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23042], + "rateUpItems4": [21048, 21051, 21066, 23042] + }, + { + "id": 3075, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23043], + "rateUpItems4": [21007, 21008, 21051, 23043] + }, + { + "id": 3127, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23044], + "rateUpItems4": [21012, 21017, 21018, 23044] + }, + { + "id": 6001, + "gachaType": "CollaborationWeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23045], + "rateUpItems4": [21004, 21007, 21010, 23045] + }, + { + "id": 6002, + "gachaType": "CollaborationWeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23046], + "rateUpItems4": [21004, 21007, 21010, 23046] + }, + { + "id": 3084, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23047], + "rateUpItems4": [21008, 21042, 21046, 23047] + }, + { + "id": 3086, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23048], + "rateUpItems4": [21001, 21006, 21010, 23048] + }, + { + "id": 3088, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23049], + "rateUpItems4": [21018, 21045, 21048, 23049] + }, + { + "id": 3099, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23050], + "rateUpItems4": [21001, 21004, 21043, 23050] + }, + { + "id": 3090, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23051], + "rateUpItems4": [21003, 21009, 21014, 23051] + }, + { + "id": 3126, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23052], + "rateUpItems4": [21012, 21017, 21018, 23052] + }, + { + "id": 3109, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23053], + "rateUpItems4": [21011, 21015, 21054, 23053] + }, + { + "id": 3125, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23054], + "rateUpItems4": [21001, 21002, 21005, 23054] + }, + { + "id": 3139, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23055], + "rateUpItems4": [21000, 21011, 21044, 23055] + }, + { + "id": 3138, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23056], + "rateUpItems4": [21003, 21014, 21054, 23056] + }, + { + "id": 3116, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23057], + "rateUpItems4": [21008, 21042, 21046, 23057] + }, + { + "id": 3140, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23058], + "rateUpItems4": [21000, 21011, 21044, 23058] + }, + { + "id": 3141, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23059], + "rateUpItems4": [21000, 21011, 21044, 23059] + }, + { + "id": 3128, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23060], + "rateUpItems4": [21006, 21019, 21045, 23060] + }, + { + "id": 6003, + "gachaType": "CollaborationWeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23061], + "rateUpItems4": [21004, 21007, 21010, 23061] + }, + { + "id": 6004, + "gachaType": "CollaborationWeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23062], + "rateUpItems4": [21004, 21007, 21010, 23062] + }, + { + "id": 3135, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23063], + "rateUpItems4": [21048, 21051, 21066, 23063] + }, + { + "id": 3137, + "gachaType": "WeaponUp", + "beginTime": 0, + "endTime": 4102416000, + "rateUpItems5": [23064], + "rateUpItems4": [21003, 21014, 21054, 23064] + } +] diff --git a/resources/GachaSandboxPolicy.json b/resources/GachaSandboxPolicy.json index c3a02f4..b8b1763 100644 --- a/resources/GachaSandboxPolicy.json +++ b/resources/GachaSandboxPolicy.json @@ -26,7 +26,7 @@ "asset_verification_note": "2026-10-24 Player.log: all six captured banner prefab paths were checked via RPG.Client.GlobalVars.s_AssetMgr.Exists without loading. Standard and Novice exist; all four captured limited character/lightcone prefabs (UI/Drawcard/GachaPanelLimited/*) are absent from the installed asset manifest. The captured Seele prefab UI/Drawcard/GachaPanelLimited/AvatarGacha_1102.prefab previously caused UIPrefabLoader ArgumentNullException inside GachaMainPage._create_pools_panel when sent. Do not enable a prefab solely because it exists in the flat table.", "mapped_banner_preview_count": 0, "native_presentation": { - "enabled": true, + "enabled": false, "template_gacha_id": 1001, "source": "Current initialized Standard-pool native cloning and GetInfo pre-bind restoration; user confirmed one-banner native frame, splash art and horizontal thumbnail on 2026-10-25. Five-star mapped tabs only, with three matching sandbox four-star cards; full multi-tab draw/exit still awaiting Client confirmation." }, @@ -38,4 +38,4 @@ "five_star_pity": 80, "four_star_pity": 10 } -} +} \ No newline at end of file