Save endgame progress, team presets, technique support and camera controls

This commit is contained in:
CAtlas0784 committed 2026-10-17 23:44:36 +07:00
1 parent a681affdb3
commit 639cef7d19
52 files changed
+11940 -766

No files matched your search

+1
View File
@@ -16,3 +16,4 @@
- SDK และ game server ที่เปิดผ่าน launcher ต้องแสดงคอนโซลและ log ให้ผู้ใช้เห็น ห้ามซ่อนหน้าต่างหรือ redirect จนคอนโซลว่าง เว้นแต่ผู้ใช้ขอเอง เมนูแบบ interactive ปิดหลังทำรายการเสร็จ 5 วินาที โดยไม่ปิดเซิร์ฟเวอร์ตามไปด้วย
- งาน progression/EX ต้องรักษาเส้นทาง Battle ที่ยืนยันกับ Client แล้ว การซิงก์ EX ส่งเฉพาะ avatar/material ที่เปลี่ยน ห้ามล้าง inventory ทั้งชุดเพื่อซิงก์สวิตช์ และให้นำการเปลี่ยนจากภายนอกเข้า Client หลังจบ Battle
- ตัวอ่าน persistent ต้องไม่เขียนไฟล์กลับ การบันทึกจาก session เก่าต้องไม่ทับค่าที่ API เปลี่ยน และต้องเก็บ EX false ได้ ห้ามปลด EX อัตโนมัติจากการขึ้น E6 หรือปลดทุกตัวเมื่ออ่าน request ID ไม่ได้
- สคริปต์วิเคราะห์ Client ต้องไม่สแกนทุก type/method ของ assembly บน main thread ผ่าน heartbeat ระหว่างเล่น ให้ตรวจเฉพาะ type ที่ระบุและจำกัดงานต่อเฟรม เก็บ probe นอก scripts/ เมื่อไม่ได้ใช้งาน เพราะทุกไฟล์ .lua ใน scripts/ ถูกส่งเข้า Client อัตโนมัติ
+54 -1
View File
@@ -41,6 +41,7 @@ SDK และ game server เปิดหน้าต่างคอนโซล
| ค่าใน config | Environment override | ใช้สำหรับ |
| --- | --- | --- |
| `player_uid` | `GAC_PLAYER_UID` | UID ผู้เล่น ใช้ร่วมกันทั้ง SDK และ game server; ต้องเป็นจำนวนเต็ม 1–4294967295 |
| `client_version` | `GAC_CLIENT_VERSION` | เวอร์ชันที่แสดงใน log/API |
| `game_host`, `game_port` | `GAC_GAME_HOST`, `GAC_GAME_PORT` | ที่อยู่ bind ของ game server |
| `sdk_host`, `sdk_port` | `GAC_SDK_HOST`, `GAC_SDK_PORT` | ที่อยู่ bind ของ SDK server |
@@ -145,7 +146,8 @@ go test ./...
| Calyx | ผู้ใช้ยืนยันว่าเข้า Battle ได้แล้ว 17 ต.ค. หลังแก้เส้นทาง BattleInfo |
| ตีมอนแล้วค้างหน้า Weakness | ผู้ใช้ยืนยัน Stagnant Shadow ที่เรียกมอนออกมาให้ตีเข้าสู้ได้แล้ว 17 ต.ค.; log มี `PVEBattleResult` ส่วนมอนทั่วไปยังไม่ได้ยืนยันแยก |
| เปิดหน้าตัวละครแล้วค้าง | ผู้ใช้ยืนยันว่าหายหลังเปิดเกมใหม่; ไม่พบ PlayerBoard/Inventory error ใน log รอบใหม่ |
| วาร์ปแมพใหม่ | ปรับลำดับ response/notify และข้อมูล scene ตาม upstream; ยังต้องยืนยันปลายทางในเกม |
| วาร์ปแมพใหม่ | ผู้ใช้ยืนยัน Starfroster/Ferry Relay Station และ Moment of Joy วาร์ปได้แล้ว 17 ต.ค.; กรองกลุ่มที่ Client ระบุ `IsDelete=true` ด้วย SceneMetadata จากตาราง Client |
| Endgame | ตรวจข้อมูลทุกด่านที่ติดตั้ง 1,747 รายการห้อง/node บน 34 floors; เพิ่มจุดเกิดจริง, PF 4 cycles และ flow เปลี่ยนทีม/ห้อง/สรุปผลสำหรับด่านหลัก, Tierce และ Peak แล้ว รอผลใน Client ดู [ผล audit และข้อจำกัด](docs/endgame-audit.md) |
| `SceneCastSkill` | ใช้รายการ monster entity ที่ server สร้างเพื่อตรวจเป้าหมายก่อนส่ง battle |
ข้อมูลล่าสุดที่ตรวจในซอร์สและเอกสาร: **17 ตุลาคม 2026**
@@ -240,3 +242,54 @@ python tools/audit_protocol_handlers.py "<path-to-pearl-sr-checkout>"
## ที่มา
พอร์ตจาก `GaC-hkrpg-ps` ภาษา Rust โดยใช้แนวทางและข้อมูลประกอบจาก [HonkaiSlopRail/pearl-sr](https://git.xeondev.com/HonkaiSlopRail/pearl-sr) ภาษา Zig การแก้ใน Go ต้องตรวจเทียบทั้ง handler, payload และเวอร์ชัน Client ที่ใช้งานจริง
### Traces: หน้าตัวละครและ GaC-Srtool
- `AvatarPathData.avatar_path_skill_tree.point_id` สำหรับหน้า Traces ใช้เลข `AnchorType` จาก `AvatarSkillTreeConfig.json` ส่วน `BattleAvatar.skilltree_list.point_id` ใช้ `PointID` เต็ม อ้างอิง PearlSR commit `005d2c12db4781071f4b5b68e8f364587886a710` ใน `avatar_mgr.createSkillTree` และ `battle_mgr.createBattleAvatar`
- แปลง `data.skills` ในเซฟเป็นเลข Anchor เฉพาะตอนส่งข้อมูลหน้าตัวละคร ไม่เปลี่ยน ID ในเซฟหรือ Battle และไม่บังคับอัป Traces ให้เต็ม
- GaC-Srtool บันทึก Traces ผ่านการส่ง `freesr-data` ทั้งชุด ไม่ใช่ `/api/freesr/avatars` ซึ่งรับเฉพาะข้อมูลทั่วไปของตัวละคร โค้ดเว็บที่แก้ในโปรเจกต์ GaC-srtool เพิ่มพารามิเตอร์ `closeModal = true` ให้ `saveAvatarDetails` เพื่อแก้ ReferenceError ก่อนถึงขั้นส่งเซฟ ต้องใช้เว็บฉบับที่มีการแก้นี้ด้วย
- การแก้เซฟจากเว็บถูกนำเข้าเกมโดย watcher; ระหว่าง Battle จะรอจนจบก่อนส่ง sync การยืนยันผลหน้า Traces และการบันทึกจากเว็บยังต้องตรวจใน Client
### Technique points และอาหาร
- `resources/TechniqueRules.json` เก็บค่า MP, cap ของทีม, ค่าใช้ Technique ตามตาราง `MazeSkill` ของ Client และอาหารที่ฟื้น MP โดยไม่มีผลลด HP มี revision ต้นทางและ hash ของ GameAssembly ที่อ่านข้อมูล
- Phainon / Himeko Nova ใช้ cap 7 ตาม `lineup_mgr.buildLineup` ของ PearlSR revision ที่ระบุใน resource ตัวโหลดตรวจสมาชิกจาก **ค่า AvatarID ในช่องทีม** ไม่ใช่ key ของช่อง ไม่มีการบวก cap ทั้งสองตัวทับกันตาม upstream
- แจกอาหารใน resource ชนิดละ `initial_food_count` หนึ่งครั้งเมื่อเปิดกระเป๋า บันทึกจำนวนคงเหลือใน `persistent.technique_food_stock` กินด้วย `UseItem` หรือกินอัตโนมัติเมื่อแต้มไม่พอใช้ Technique; ห้ามกินระหว่าง Battle
- `persistent.auto_technique_food` เปิดโหมด auto; ค่าเริ่มต้นมาจาก `auto_food_default` ใน resource ปิด/เปิดได้ผ่าน `POST /api/technique` ด้วย `{"auto_food":false}` / `{"auto_food":true}` และอ่านสถานะด้วย `GET /api/technique` ใช้ SDK URL จาก config ตัวเฝ้าดูนำสวิตช์ใหม่เข้า session หลัง Battle โดยไม่ล้าง inventory
- MP ที่ใช้แล้วเก็บใน session; เริ่มห้อง Challenge ใหม่คืนค่าเริ่มต้นตาม resource การเปลี่ยนตัวละคร/วาร์ปไม่เติม MP เต็มโดยอัตโนมัติ
- Technique ที่ไม่เปิด Battle อ้างอิง `IgnoreBattle` และ `getBattleType` ของ PearlSR ตอบผล `NO_BATTLE` ให้มอนที่โดน ไม่บังคับเข้า Battle จากการแปะ debuff
สถานะ: ผู้ใช้ยืนยัน **PF ปกติเข้าสู้และเปลี่ยนห้องได้** ก่อนรอบแก้ Technique/อาหาร ส่วนรอบนี้ยังต้องยืนยัน Traces, แต้ม, อาหาร/auto และ Starward ห้อง 3 จาก Client ห้ามถือว่า build ผ่านเป็นผลยืนยันการเล่น
### Starward: ทีมและคะแนนที่เซฟไว้
- ผู้ใช้ยืนยันว่า PF Starward เล่นจบครบ 3 ห้องได้แล้ว
- เก็บทีมและบัฟรายห้องใน `persistent.tierce_progress` ตอนตั้งทีม/เริ่มเล่น อ่านกลับใน GetChallengeTierceData และเริ่มห้องที่เลือกด้วยทีมของห้องนั้น ไม่ใช้ทีมแรกแทนเมื่อเล่นแยกห้อง
- บันทึกคะแนนจริงจาก PVEBattleResult แยกแต่ละ node พร้อมผลที่ดีที่สุด การลองห้องเดียวใช้ผลที่เซฟของห้องอื่นประกอบ; การเล่นครบชุดเริ่มคะแนนของรอบใหม่จากศูนย์
- `resources/ChallengeTargetConfig.json` เป็นข้อมูลเกณฑ์จาก Client: TOTAL_SCORE ใช้ตัดสินดาวสำหรับ PF/AS Starward พร้อมส่ง FinishedTargetList ทั้งหน้าสรุปและข้อมูลที่อ่านกลับ คะแนน 97,000 ได้เกณฑ์ PF 60,000 / 75,000 / 90,000 แต่ยังไม่ถึงเกณฑ์พิเศษ 99,000
- รอบนี้ยังไม่เพิ่มการแจก reward, การจัดอันดับออนไลน์ หรือการประเมิน target ประเภท ROUNDS_LEFT/DEAD_AVATAR ของ MoC ส่วนข้อมูลประวัติ Endgame ปกติ/Peak เดิมยังมีรายการสำเร็จรูปตามข้อจำกัดในเอกสาร audit
- ต้องตรวจใน Client ว่ากลับมาแล้วทีมยังอยู่ เลือกเล่นห้องเดียวได้ และหน้าสรุปดาวตรงกับคะแนน Build ไม่ใช่การยืนยันผลเล่น
### วาร์ป MoC จากสมุดผ่านฉากรถไฟ
แก้ GetPamSkinData ที่เคยส่งสกินปอมปอม ID0 ซึ่งไม่มีในตาราง Client และทำให้ NPC โหลดล้ม อ่าน default/รายการที่ปลดจาก ProfileConfig และตรวจ ConfigEntityPath กับ PamSkinConfig.json แทน ค่าต้นทางมี revision/assembly hash ตรวจสอบได้ ยังรอผู้ใช้ยืนยันการวาร์ปรอบแก้สกินนี้
### Preset ทีมในเกม
- เปิดไว้ 20 ช่องผ่าน `resources/TeamPresetConfig.json` (`preset_count` เป็นนโยบายจำนวนทีมที่ผู้ใช้ขอเพิ่ม; ขอบเขตสมาชิก 4 และชื่อ 14 ตัวอักษรอ้างอิง Client)
- GetAllLineupData ส่งรายการครบพร้อม index ปัจจุบัน รองรับสลับทีม ตั้งชื่อ เพิ่ม/ถอด/สลับสมาชิก และ MarkPresetLineup สำหรับทีมโปรด/Quick Lineup
- บันทึกใน `persistent.team_presets` และ `active_team_preset` เก็บ `lineups` เดิมเป็นสมาชิกทีมที่กำลังใช้อยู่เพื่อใช้กับ API เดิม ไม่เขียนไฟล์ตอนอ่านเพื่อสร้าง preset เปล่า
- เริ่มใช้ด้วยทีมเดิมในช่องแรก ช่องอื่นยังว่างจนจัดสมาชิกครั้งแรก จากนั้นเลือกทีมที่เซฟไว้ได้โดยไม่ต้องจัดใหม่ทุกครั้ง ปฏิเสธการสลับไปทีมว่างและการแก้ทีมระหว่าง Battle
- เมื่อมีการเซฟ preset การสู้ทั่วไป/Calyx ใช้สมาชิกทีมที่เลือก แต่คง stage/waves จากข้อมูลเดิม ห้อง Endgame ใช้ทีมประจำห้องของ ChallengeRun
- Build ผ่านยังไม่ยืนยันว่า UI ของ Client แสดงครบ 20 ช่องและใช้งาน Quick Lineup ได้ทุกหน้า ต้องตรวจเพิ่มในเกม การจัดทีม Endgame โดย request ExtraLineupType ยังคงเป็นคนละเส้นทางกับการแก้ preset ทีมเดินโลก
### Freecam: คีย์บอร์ด
- `Tab`: เปิด/ปิด Freecam; ลูกศร: เลื่อนกล้อง; `I/K`: ก้มเงย; `J/L`: หันซ้ายขวา
- `Page Up` / `Page Down`: ขึ้น/ลง; `+` / `-`: เพิ่ม/ลดความเร็ว
- Freecam ไม่อ่านเมาส์และไม่ใช้ `E/Q` คุมกล้อง เพื่อให้กดสกิลระหว่างสู้ได้โดยกล้องไม่ลอยขึ้นลง ปุ่มเมาส์ยังทำงานตามคำสั่งของเกมตามปกติ
- สคริปต์อัปเดตผ่าน heartbeat โดยไม่ต้อง build หรือรีสตาร์ตเซิร์ฟเวอร์ การใช้งานใน Fight ยังรอยืนยันจาก Client
### Hide UI
`Backslash` ซ่อนเฉพาะ HUD ที่ระบุชื่อไว้ใน `scripts/hide_ui.lua` เช่น HUD เดินโลก/ต่อสู้ ตัวเลขลอยและ waypoint โดยไม่ปิด UIRoot, กล้อง UI หรือ Canvas ทั้งฉาก จึงเหลือหน้าตัวละคร หน้ารายละเอียด EX Eidolon และหน้าต่างรางวัลไว้ ปิดโหมดแล้วคืนค่า CanvasGroup เดิมของ HUD ที่แก้ ถ้าเคยใช้สคริปต์ซ่อนทั้ง UI รุ่นเก่า แนะนำเปิดเกมใหม่หนึ่งครั้ง เพราะรุ่นเก่าไม่ได้เก็บค่า alpha/interactable เดิมทั้งหมดไว้ ผลหน้า EX ยังรอยืนยันจาก Client
+8
View File
@@ -14,6 +14,14 @@ func main() {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if _, err := common.LoadTechniqueRules(); err != nil {
fmt.Fprintln(os.Stderr, "technique resources:", err)
os.Exit(1)
}
if _, err := common.LoadTeamPresetRules(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("============================================================")
fmt.Printf(" [GaC hkrpg GO] Gameserver (Client %s)\n", cfg.ClientVersion)
fmt.Printf(" Listening on UDP %s:%d (KCP Gateway)\n", cfg.GameHost, cfg.GamePort)
+79
View File
@@ -0,0 +1,79 @@
# Endgame audit — 17 ตุลาคม 2026
## ตรวจข้อมูลทุกห้องใน resource ชุดที่ติดตั้ง
อ่าน RuntimeFloor และ RuntimeGroup ผ่าน GameCoreConfigLoader ของ Client แบบอ่านอย่างเดียว ครอบคลุมทุก MapEntrance ที่ ChallengeMazeConfig, ChallengeMazeTierceConfig และ ChallengePeakConfig อ้างถึง ไม่ได้สั่งเข้าแมพหรือแก้ actor/save ใน Client ข้อมูล SceneMetadata มี SHA-256 ของ GameAssembly และ capture ที่ใช้
| โหมด | รายการห้อง/node ที่ตรวจ |
| --- | ---: |
| Forgotten Hall / Memory / MoC | 1,268 |
| Apocalyptic Shadow | 184 |
| Pure Fiction | 224 |
| Tierce / Starward ห้องที่สาม | 16 |
| Anomaly Arbitration / Peak | 44 |
| Peak Hard | 11 |
| รวม | 1,747 |
มี 843 ด่านหลัก, 16 ด่าน Tierce, 44 ด่าน Peak และ Hard variants 11 รายการ ใช้ 37 entrances บน 34 floors นับรายการข้อมูลที่อ้างถึง ไม่ใช่จำนวนห้องกายภาพที่ต่างกันทั้งหมด SceneMetadata ยังเก็บอีกสอง floors ของ teleport ที่ผู้ใช้ยืนยันแล้ว รวมเป็น 36 floors
`tools/audit_endgame_resources.py` ตรวจ:
- entrance อ้างถึง floor จริงและมี metadata กลุ่มครบ
- กลุ่มมอนเป็น Server group ที่ยังไม่ถูกลบ
- มี start anchor ของ floor และตำแหน่ง monster instance ตาม ConfigList
- ตัวโหลด Go มี scene monster source ของกลุ่มนั้น
- event ของด่านมี StageConfig และทุก wave มีมอน
ผล audit **ไม่พบข้อมูลขาดตามเงื่อนไขข้างต้น** รันซ้ำได้ด้วย `python tools/audit_endgame_resources.py --output <report.json>` เครื่องมือนี้อ่านข้อมูล ไม่ใช่การลองเล่นทุกห้องในเกม
## ปัญหาที่พบจากการลอง PF
- Battle capture ของด่าน 30326041 ส่ง rounds_limit=0 เพราะ PF ไม่มี ChallengeCountDown ในตาราง แล้ว Go ไปใช้ cycle_count=0 ของ custom battle ผลใน Client จบหลัง cycle แรก
- ย้าย mode round limits ไป resources/BattleRules.json: PF 4, AS/Default 30 ที่มาคือ PearlSR revision `005d2c12db4781071f4b5b68e8f364587886a710`, `ChallegeStageManager.createChallegeStage` ไม่ใช้ custom cycle เป็น fallback ของ Endgame
- PF ธรรมดาส่งสอง LineupInfo ด้วย ExtraLineupType เดียวกัน แก้ทีมสองเป็น enum LineupChallenge2 และเก็บทั้งสองทีมใน run
- PVEBattleResult เดิมตอบรับอย่างเดียว ไม่มีการเปลี่ยนห้องหรือแจ้งผล Challenge เพิ่ม run state ใน session พร้อม score/rounds ที่ Client ส่งจริง และป้องกันผลซ้ำหรือ stage ไม่ตรงกับ battle ที่ active
- ด่านหลักสลับทีม/ห้องที่สองผ่าน SyncLineupNotify, ChallengeLineupNotify และ EnterSceneByServerScNotify แล้วแจ้ง ChallengeSettleNotify เมื่อจบ run
- Tierce ใช้ OnStageSettle/OnBigSettle และ request StartNext/Restart พร้อม CurTierceChallenge ปัจจุบัน เก็บทีมกับ buff แยกแต่ละ session ไม่ใช้ cache รวมทุกผู้เล่น
- Peak เปลี่ยนจาก arena/level/monster ที่กำหนดตายตัวเป็น Entrance, MazeGroupID, NpcMonsterIDList, EventIDList และ TagList ของ resource; Hard ใช้ HardEventIDList/HardTagList และสวิตช์ของ group นั้น เพิ่ม current-state/settle/confirm handlers
- ออกจาก Challenge ล้าง run และกลับไปใช้ custom battle เดิม ไม่บันทึกทีม/wave ของ Endgame ทับ Freesr/persistent
## Protocol ที่ยังใช้ชื่อ obfuscated
ตรวจชื่อ callback/event จาก Client ที่กำลังรันผ่าน read-only reflection เพิ่มจาก schema ต้นฉบับ:
| ตาราง protocol | ความสัมพันธ์ที่ Client ระบุ |
| --- | --- |
| DOPHNDHILPA oneof field 9 / JCKGBFJJFFC | OnStageSettle |
| DOPHNDHILPA oneof field 7 / GEKNAKDAMJF | OnBigSettle |
| DOPHNDHILPA oneof field 3 / PEOKJEOOOJD | OnPendingStageSettle; ไม่เดาความหมายตัวเลขภายใน |
| DOPHNDHILPA oneof field 8 / FLIPNFHAMPO | OnMedal ไม่ใช่การเปลี่ยน stage |
| LHAKNKHBLBI | callback `<StartNext>b__25_0` |
| JMPJCAPKNGI | callback `<Restart>b__21_0` |
| OKEDFNJJGHB | callback `<ConfirmStageSettle>b__32_0` |
Handler resolve CmdID จากชื่อในตาราง revision เดียวกัน ไม่เพิ่ม alias ตัวเลขข้ามเวอร์ชัน ชื่อ request ฝั่ง Tierce ยังเป็นชื่อ obfuscated ของคู่ response ใน schema ความครบของ UI callback และลำดับ settlement หลังการเปลี่ยนนี้ยังต้องยืนยันใน Client ไม่ตีความ field ที่ยังไม่มีชื่อเพื่อแจกคะแนน/เหรียญหรือ reward สมมติ
## สถานะการยืนยัน
- ผู้ใช้ยืนยัน Starfroster/Ferry Relay Station และ Moment of Joy วาร์ปได้แล้ว
- PF/Tierce build ก่อนหน้านี้มี PVEBattleResult และผู้ใช้เล่นได้ แต่ติดหนึ่ง cycle/ไม่ไปห้องต่อ ไม่ถือว่าผ่าน flow
- Build ที่รวม cycle, run state และ settlement ชุดนี้ **รอผลลอง PF ธรรมดา, Tierce และโหมดอื่น** การตรวจ 1,747 รายการไม่ยืนยันว่าเล่นผ่านทั้งหมด
- รายการปลดล็อก/ประวัติที่เมนู GetChallenge/GetChallengePeakData แสดง ยังมีข้อมูลจำลองของ implementation เดิม ส่วน reward, ดาว/เหรียญ และการเก็บผล Endgame ข้ามการเปิดเกมยังไม่ใช่ระบบ progression ที่ยืนยันครบ ตัว run ใหม่ไม่สร้างผลรางวัลหรือดาวจากการชนะเฉย ๆ
## ผลจากผู้ใช้และการแก้รอบ Technique
ผู้ใช้ยืนยัน PF ปกติเข้าสู้และเปลี่ยนทีม/ห้องได้แล้ว แต่ Starward ค้างตอนเข้าห้อง 3 Log Client ระบุ `TeamManager._CreateMemberEntity` เกิด `IndexOutOfRangeException` ระหว่างสร้าง leader ส่วน disassembly อ่าน `TeamData._leaderSlotIdId` ก่อนนำไป index `_MemberDatas` จึงตรวจเส้นทางติดตั้งทีมเป็นลำดับแรก
แก้ Tierce ให้ node 0/1/2 ส่ง ExtraLineupType `LineupChallenge` / `LineupChallenge2` / `LineupChallenge3` ตามชื่อ enum ใน protocol แทนการส่งทับทีมแรกทุก node และ GetCurLineupData คืนทีม Challenge ที่กำลังใช้อยู่ การเชื่อมโยงอาการกับทีมที่ว่างยังเป็นข้อสันนิษฐานจาก log/โค้ด ต้องยืนยันห้อง 3 ใน Client หลังเปลี่ยนนี้
Technique ไม่เปิด Battle อ้างอิงรายการ IgnoreBattle ของ PearlSR และตอบ MonsterBattleType.NO_BATTLE (4) จาก protocol ของ revision เดียวกัน ไม่สร้าง BattleInfo สำหรับผลนี้ ส่วนการโจมตีมอนปกติคงเส้นทาง resource stage เดิม
## Starward ผ่านห้อง 3 และการเซฟทีม/คะแนน
ผู้ใช้ยืนยันเล่นครบ 3 ห้องและจบรอบได้ ผล Client ที่ตรง server log ของ challenge 20275 คือ 33,000 + 28,500 + 35,500 = 97,000 การแยกประเภททีมแก้อาการห้อง 3 ในรอบที่ผู้ใช้ลองแล้ว
เพิ่ม persistent.tierce_progress สำหรับทีม บัฟ และ best result ของแต่ละ node เปลี่ยน GetChallengeTierceData จากข้อมูลทุกห้องชนะสำเร็จรูปเป็นทีม/ผลจริงที่เซฟไว้ รองรับ single-node retry โดยนำผลห้องอื่นที่เซฟแล้วมาประกอบ
อ่าน ChallengeTargetConfigRow ผ่าน Client lookup GBPODLFHCBI.LMBKNCAIDLC และเก็บตารางพร้อม assembly hash ใน ChallengeTargetConfig.json: TOTAL_SCORE enum7 มี PF targets 4001/4002/4003 = 60k/75k/90k และ target4000=99k; AS 5001/5002/5003=6000/7800/9900, target5000=10200 ไม่อนุมานดาวจากสถานะ WIN อย่างเดียว MoC targetประเภท ROUNDS_LEFT(6)/DEAD_AVATAR(2) ยังไม่ประเมินในรอบนี้
ทั้ง StageSettle และ BigSettle ส่ง FinishedTargetList เมื่อมีผลชนะครบทุก node ส่วน GetData คืน best record/targets ที่บันทึก การแจก reward และประวัติของโหมดปกติ/Peak ยังไม่รวมในส่วนนี้ การเก็บทีม/ดาวรอบใหม่ยังรอ Client ยืนยัน
+69
View File
@@ -0,0 +1,69 @@
# ตรวจ teleport และ Endgame — 17 ต.ค. 2026
## หลักฐานจาก Client
- `Player.log` แจ้ง `[HoyoGroupManager] 尝试激活的Group不存在, FloorID=20571001 FloorName=Starfroster GroupID=216` ก่อน `MapDef.InitServerGroups` เกิด NullReferenceException
- เริ่ม Endgame มี NullReferenceException ที่ `ChallengeBossInstance._SyncExtraInfo` ผ่าน `_OnCmdStartChallengeScRsp`
- จึงมีปัญหาหลัง Client ได้รับแพ็กเก็ต แม้ server จะส่ง response/notify สำเร็จ
## สิ่งที่แก้
เทียบซอร์ส PearlSR revision `005d2c12db4781071f4b5b68e8f364587886a710` ใน `scene_mgr.zig` และ `challenge_mgr.zig`:
- Go ใช้ `LevelOutputConfig.PlaneType` เป็น protocol `SceneInfo.GameModeType` ทั้งที่เป็นคนละ enum; supplementary scene ยังมีค่าเริ่มต้นเป็น 0 แก้ adventure เป็น mode 1 และ challenge เป็น mode 4 ตามต้นฉบับ
- เติม world metadata จาก MapEntrance/MazePlane แทน world 0 ของ supplementary scene และนำการแทน world ด้วย ID เฉพาะออกจากสองตัวโหลดนี้
- โหลด `FloorSavedValuesConfig.json` ซึ่งมีใน repository แต่ไม่ได้ถูกอ่าน เติมค่าที่ขาดตาม floor โดยรักษาค่าที่ root config ระบุไว้ ส่งให้ทั้ง adventure และ challenge ต้นฉบับใช้ MaxValue เป็นสถานะปลดล็อกเช่นเดียวกัน
- เพิ่ม `CurChallenge.StageInfo.CurBossBuffs` สำหรับ AS และ `CurStoryBuffs` สำหรับ PF พร้อม buff ที่เลือกและ maze buff จาก challenge data; ก่อนหน้านี้ไม่มี StageInfo ทั้งที่ Client ใช้สร้าง instance
- เปิด state ของ prop/monster group ใน challenge ตามต้นฉบับ และเพิ่ม log scene metadata / challenge state ที่ `server_debug.log`
## ขอบเขตและสิ่งที่ต้องยืนยัน
ยังไม่ได้ยืนยันผลใน Client หลังการแก้ เรายังไม่ทราบว่า Group 216 หายเพราะ scene initialization ที่ผิด หรือ resource/asset คนละ revision; `resources/res.json` ตรงกับ checkout PearlSR ข้างต้น จึงไม่ได้ลบหรือเปลี่ยนหมายเลขกลุ่มนี้เพื่อหลบ error
ต้องลองวาร์ป Starfroster และ Moment of Joy รวมทั้งเริ่ม Endgame แล้วอ่าน Player.log รอบใหม่ ถ้ายังมี missing group หลังแก้ metadata/saved values ต้องเทียบ floor asset ของ Client ต่อ การเปลี่ยนรอบนี้ไม่ได้รับรอง Peak/Tierce หรือการผ่านทุก node ของ Endgame และยังมี fallback arena/position เก่าใน handlers ที่ต้องตรวจแยก ไม่ได้อ้างว่าเอา hardcode ออกทั้ง repository
ไม่ได้แก้ battle converter, Calyx response, progression/EX หรือ inventory sync ในรอบนี้ จุดย้อนกลับที่ผู้ใช้ยืนยันก่อนเริ่มงานคือ tag `stable-2026-10-17-battle-eidolon` (commit `a681aff`)
## ผลลองรอบ 19:44 และการแก้ต่อ
ผู้ใช้ยืนยัน AS/PF บางห้องเข้าได้แต่เกิดใต้แมพ, Tierce 30225 ค้าง, PF Starward เข้าห้องได้แต่หลังตีมอนค้าง Weakness และ teleport ของแมพใหม่ยังไม่สำเร็จ ผลนี้ยังไม่ใช่การยืนยันว่ารอบแก้ก่อนสำเร็จ
พบข้อมูลผิดที่ตรวจซอร์สได้:
- root scene ของ entrance 3014003 ถูกตั้งชื่อ floor 30140001 แต่ MapEntrance ระบุ **30140003**; 3012602 ก็ถูกตั้งเป็น 30126001 แทน **30126002** เปลี่ยนให้ single-floor scene ใช้ plane/floor จาก MapEntrance จริง
- Tierce 30225 ใน challenge_data ระบุ entrance 3012602, monster 2034012, event 420554 แต่ตาราง resource ปัจจุบันระบุ **3012402 / 3024012 / 420584** ตัวโหลดจึงให้ ChallengeMazeConfig, ChallengeMazeTierceConfig และ StageConfig ปัจจุบันมีลำดับความสำคัญเหนือข้อมูล compiled เก่า
- อ่าน PreChallengeMazeID ของ Tierce จากตาราง: node 0/1 ใช้ข้อมูลสองฝั่งของด่านก่อนหน้า ส่วน node 2 ใช้ด่าน Tierce ไม่เลือกด่าน Tierce ตั้งแต่ node 0 และไม่เดา remap ตามเลข season
- เปลี่ยน response Tierce ให้ส่งเฉพาะ lineup ของ node ที่ active ตาม upstream
- นำสูตรตำแหน่งเดิมที่ยก Y ไปหา prop สูงสุดและเลื่อนเข้าหาจุดกำเนิดโลกออก ใช้ Anchor เมื่อมี; ถ้าตารางไม่มี Anchor ใช้ตำแหน่งมอนในกลุ่มจริงแทน ไม่สร้างกลุ่มหรือพิกัดสมมติ หากไม่มีตำแหน่งจะตอบ error พร้อมบันทึกเหตุผล การเกิดตรงจุดมอนนี้ยังต้องตรวจ Client โดยเฉพาะห้องที่ไม่มี Anchor
- เก็บ BattleConfig ของ AS/PF/MoC/Tierce ใน session; ทีม มอน และ buff มาจาก node/stage ที่เลือก ไม่เขียนทับ custom config ใน persistent และไม่ถูก Freesr reload เปลี่ยน wave ไปกลางห้อง จำนวนรอบใช้ ChallengeCountDown เมื่อระบุไว้ มิฉะนั้นใช้ค่าตั้ง rounds ของผู้ใช้เช่นต้นฉบับ AS/PF ไม่ระบุ countdown ในตาราง
- log PF Starward รอบสุดท้ายมี SceneCastSkill สอง request ที่ไม่มีทั้ง hit/assist targets เซิร์ฟเวอร์จึงส่ง response ว่างและ **ไม่ได้ส่ง BattleInfo** ในสอง request นั้น ยังไม่สรุปว่าเป็น error โหลดมอนของ Battle; เพิ่ม request detail เมื่อเกิดใน Challenge เพื่อแยกจากปัญหา wave
ตอนตรวจรอบนี้ Player.log เป็น session หลังเปิดเกมใหม่แล้ว ไม่มี exception ของการกด teleport รอบก่อน จึงยังต้องอ่าน log หลังทำซ้ำโดยไม่ออกเกมก่อน ไม่ได้ลบ Group 216 หรืออ้างว่าการเปลี่ยน floor ของ Challenge แก้ teleport ของ Starfroster/Moment of Joy แล้ว
Build ผ่านและวางที่ root ไม่เท่ากับ Client ผ่าน ยังรอลองหลังการแก้ข้างต้น Peak ยังมีเส้นทาง legacy แยกจาก session config ที่แก้ใน AS/PF/Tierce รอบนี้
## ยืนยันต้นเหตุจากตาราง Client หลังลองวาร์ปซ้ำ
หลังผู้ใช้กด Ferry Relay Station เก็บ exception `MapDef.InitServerGroups` ได้อีกครั้ง แม้ mode/world ถูกต้องแล้ว ตรวจแบบอ่านอย่างเดียวผ่าน `GameCoreConfigLoader.LoadRtLevelFloorInfo(MapDef.GetRtFloorInfoPath(plane, floor))` และ `GroupInstanceCommonMap` พบ:
- Starfroster floor 20571001 group 216 มี `LoadSide=Server`, `IsHoyoGroup=true`, **`IsDelete=true`**
- floor 20572001 group 98 มี **`IsDelete=true`** เช่นกัน ทั้งสองกลุ่มยังถูกส่งใน resources/res.json จึงไม่ใช่หลักฐานว่า Client ขาด asset ทั้งแมพ
- อ่าน StartGroupIndex/StartAnchorID และ AnchorList ของ runtime group ได้จุดเกิดจริงของห้อง Challenge ที่ Anchor.json เดิมไม่มี รวมทั้ง PF 30140003, Starward 30142001, AS 30141001/30132001 และ Tierce 30124002/30126002
เพิ่ม `resources/SceneMetadata.json` ครอบคลุม **8 floors ที่อ่านจริง** พร้อม SHA-256 ของ GameAssembly และ log capture ไม่มี path เครื่องใน resource ตัวโหลดใช้ metadata เฉพาะ Client version ที่ตรง runtime config และจำนวน group ครบ แล้วกรองกลุ่มที่ถูกลบ, เป็น Client-owned หรือไม่มีในตาราง floor นั้น ใช้กฎเดียวกันทุก floor ที่มี metadata ไม่มีเงื่อนไขเจาะจง group 216/98 ใน Go
เติม start anchor และตำแหน่งมอนจาก runtime group โดยแปลง group transform เข้า world และ metres/degrees เป็นหน่วย protocol จุดเกิดของห้องที่ครอบคลุมจึงใช้ Anchor จริง ไม่ต้องยืนตรงมอนหรือเดาความสูง prop ส่วน floor อื่นยังใช้ข้อมูลเดิมและไม่อ้างว่าตรวจทุกแมพแล้ว ตัวแปลง log เป็น resource อยู่ที่ `tools/export_scene_metadata.py`; รับ log, assembly, client version และ output เป็น arguments และปฏิเสธ group capture ที่ไม่ครบ
Script อ่านข้อมูลชั่วคราวถูกนำออกจาก scripts/ หลังเก็บข้อมูลเสร็จ ไม่เปลี่ยน scene/actor, patch method หรือ save ใน Client ผลวาร์ป/สู้หลัง build ที่รวม metadata **ยังรอผู้ใช้ยืนยัน**
### ผลยืนยันวาร์ปหลัง build ที่รวม metadata
ผู้ใช้ตอบคำถามลอง Ferry Relay Station และ Moment of Joy ว่า **ได้แล้ว** จึงยืนยันการวาร์ปสองปลายทางนี้ใน Client ได้จริง Log รอบเดียวกันมี entry 2057101 teleport 2057103/2057102 และ entry 2057201 teleport 2057202 โดย group list ไม่มีกลุ่มที่ถูกลบ 216/98 แล้ว ไม่พบ `MapDef.InitServerGroups`/missing-group error ใน Player.log รอบนี้ ผลนี้ยังไม่ยืนยัน PF/AS/Tierce หรือการตีมอนของ Endgame หลัง build ใหม่
## MoC จากสมุด: ค้างท้ายหน้าโหลดในฉากรถไฟ
คำขอ EnterScene ระบุ entrance 100000104, floor 10000000 และ teleport 2206 Client ล้มที่ `MapNpcDef.get_ConfigEntityPath` ขณะโหลด MapNPC 227-400001 ตาราง RuntimeGroup ของ Client ระบุ NPCID=3001 (ปอมปอม) ในกลุ่มนี้
GetPamSkinData ของ Go เดิมตอบ cur_skin=0 และ unlock_skin_list=[0] แต่ Client PamSkinConfigRow lookup ยืนยันว่า ID0 คืน nil ขณะที่ ID252000 มี ConfigEntityPath จริง และตรงกับค่าปกติใน PearlSR services/scene.zig revision005d2c12
แก้ handler ให้อ่านค่าจาก ProfileConfig.pam_skins/current_pam_skin และตรวจทุก ID กับ PamSkinConfig.json ที่ export จาก Client พร้อม SHA-256 ของ GameAssembly ไม่แก้กลุ่ม227ทิ้ง ไม่เปลี่ยนเส้นทาง Battle ผลการวาร์ปหลังแก้นี้ยังรอ Client ยืนยัน
+44 -10
View File
@@ -331,17 +331,41 @@ func DefaultScene() Scene {
return Scene{PlaneID: 20411, FloorID: 20411001, EntryID: 2041101}
}
// Tierce teams and best measured node results survive leaving a scene/login.
type TierceNodeProgress struct {
Score uint32 `json:"score"`
Won bool `json:"won"`
Result []byte `json:"result,omitempty"`
}
type TierceProgress struct {
Teams [][]uint32 `json:"teams"`
Buffs []uint32 `json:"buffs"`
Nodes map[uint32]TierceNodeProgress `json:"nodes"`
}
type TeamPreset struct {
Name string `json:"name"`
Avatars map[uint32]uint32 `json:"avatars"`
LeaderSlot uint32 `json:"leader_slot"`
Favourite bool `json:"favourite"`
}
type Persistent struct {
Lineups map[uint32]uint32 `json:"lineups"`
Position Position `json:"position"`
Scene Scene `json:"scene"`
MainCharacter MultiPathAvatar `json:"main_character"`
MarchType MultiPathAvatar `json:"march_type"`
EnableSWGlobal *bool `json:"enable_sw_global"`
EnableCastoriceGlobal *bool `json:"enable_castorice_global"`
EnableAhaGlobal *bool `json:"enable_aha_global"`
DressedSkins map[uint32]uint32 `json:"dressed_skins"`
ExRanksUnlocked map[uint32]bool `json:"ex_ranks_unlocked"`
TeamPresets map[uint32]*TeamPreset `json:"team_presets"`
ActiveTeamPreset uint32 `json:"active_team_preset"`
TierceProgress map[uint32]*TierceProgress `json:"tierce_progress"`
TechniqueFoodStock map[uint32]uint32 `json:"technique_food_stock"`
AutoTechniqueFood *bool `json:"auto_technique_food"`
Lineups map[uint32]uint32 `json:"lineups"`
Position Position `json:"position"`
Scene Scene `json:"scene"`
MainCharacter MultiPathAvatar `json:"main_character"`
MarchType MultiPathAvatar `json:"march_type"`
EnableSWGlobal *bool `json:"enable_sw_global"`
EnableCastoriceGlobal *bool `json:"enable_castorice_global"`
EnableAhaGlobal *bool `json:"enable_aha_global"`
DressedSkins map[uint32]uint32 `json:"dressed_skins"`
ExRanksUnlocked map[uint32]bool `json:"ex_ranks_unlocked"`
}
// DefaultPersistent mirrors the Rust Default impl.
@@ -368,6 +392,11 @@ func DefaultPersistent() Persistent {
// ---------------------------------------------------------------------------
type FreesrData struct {
TeamPresets map[uint32]*TeamPreset `json:"-"`
ActiveTeamPreset uint32 `json:"-"`
TierceProgress map[uint32]*TierceProgress `json:"-"`
TechniqueFoodStock map[uint32]uint32 `json:"-"`
AutoTechniqueFood *bool `json:"-"`
persistentBaseline map[string]any
Lightcones []Lightcone `json:"lightcones"`
@@ -413,6 +442,11 @@ func LoadFreesrData() (*FreesrData, error) {
var p Persistent
if err := json.Unmarshal(pers, &p); err == nil {
data.Lineups = p.Lineups
data.TeamPresets = p.TeamPresets
data.ActiveTeamPreset = p.ActiveTeamPreset
data.TierceProgress = p.TierceProgress
data.TechniqueFoodStock = p.TechniqueFoodStock
data.AutoTechniqueFood = p.AutoTechniqueFood
data.Position = p.Position
data.Scene = p.Scene
data.MainCharacter = p.MainCharacter
+1 -1
View File
@@ -39,7 +39,7 @@ func (f *FreesrData) persistentSnapshot() Persistent {
ex[id] = avatar.ExRankUnlocked
}
}
return Persistent{Lineups: f.Lineups, Position: f.Position, Scene: f.Scene,
return Persistent{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, ExRanksUnlocked: ex}
+22 -31
View File
@@ -162,34 +162,24 @@ func (a *AvatarJson) ToAvatarPathDataProto(lightcone *Lightcone, relics []*Relic
out.EquipRelicList = append(out.EquipRelicList, r.ToEquipRelic())
}
if len(a.Data.Skills) > 0 {
for pointID, level := range a.Data.Skills {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pointID,
Level: level,
})
}
} else if len(a.Data.SkillsByAnchorType) > 0 {
res, _ := GameRes()
for anchorType, level := range a.Data.SkillsByAnchorType {
pointID := anchorType
if res != nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
if pid, ok := res.AvatarSkillTreePoints[a.AvatarID][anchorType]; ok && pid != 0 {
pointID = pid
}
// AvatarPathData uses AnchorType numbers, whereas BattleAvatar uses full
// PointIDs. PearlSR avatar_mgr.createSkillTree and battle_mgr intentionally
// serialize different IDs for these two protocol messages.
if res, err := GameRes(); err == nil {
points := res.AvatarSkillTreePoints[a.AvatarID]
for anchor, pointID := range points {
var level uint32
if len(a.Data.Skills) > 0 {
level = a.Data.Skills[pointID]
} else if len(a.Data.SkillsByAnchorType) > 0 {
level = a.Data.SkillsByAnchorType[anchor]
} else {
// Keep the existing no-import level behavior, using valid anchors.
level = 1
}
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pointID,
Level: level,
})
}
} else {
if res, err := GameRes(); err == nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
for _, pid := range res.AvatarSkillTreePoints[a.AvatarID] {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pid,
Level: 1,
})
if level > 0 {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree,
&goproto.AvatarPathSkillTree{PointId: anchor, Level: level})
}
}
}
@@ -314,15 +304,16 @@ func ToLineupAvatars(f *FreesrData) []*goproto.LineupAvatar {
// ToLineupInfo builds LineupInfo from a lineup map (order: ascending slot).
func ToLineupInfo(lineups map[uint32]uint32) *goproto.LineupInfo {
maxMp := uint32(5)
if _, ok := lineups[1408]; ok {
maxMp = 8
var maxMp, currentMP uint32
if rules, err := LoadTechniqueRules(); err == nil {
maxMp = rules.MaxMP(lineups)
currentMP = rules.InitialMP
}
lineup := &goproto.LineupInfo{
ExtraLineupType: goproto.ExtraLineupType_ExtraLineupType_LineupNone,
Name: "Squad 1",
Mp: maxMp,
Mp: currentMP,
MaxMp: maxMp,
}
+5 -5
View File
@@ -36,7 +36,7 @@ func TestStandaloneBattleRelicWire(t *testing.T) {
}
func TestAvatarPathSkillTreePointIds(t *testing.T) {
// 1. avatar with explicit Skills map uses real PointIDs directly
// AvatarPathData uses resource AnchorType numbers; BattleAvatar uses PointIDs.
av := AvatarJson{
AvatarID: 1213,
Data: AvatarData{
@@ -55,8 +55,8 @@ func TestAvatarPathSkillTreePointIds(t *testing.T) {
if len(proto.AvatarPathSkillTree) != 2 {
t.Fatalf("expected 2 skill tree nodes, got %d", len(proto.AvatarPathSkillTree))
}
if proto.AvatarPathSkillTree[0].PointId != 1213001 || proto.AvatarPathSkillTree[1].PointId != 1213002 {
t.Fatalf("point ids must be real config point IDs, got %v", proto.AvatarPathSkillTree)
if proto.AvatarPathSkillTree[0].PointId != 1 || proto.AvatarPathSkillTree[1].PointId != 2 {
t.Fatalf("path point ids must be resource anchor numbers, got %v", proto.AvatarPathSkillTree)
}
// 2. avatar with only SkillsByAnchorType resolves from AvatarSkillTreeConfig.json
@@ -73,8 +73,8 @@ func TestAvatarPathSkillTreePointIds(t *testing.T) {
if len(protoAnchor.AvatarPathSkillTree) != 1 {
t.Fatalf("expected 1 skill tree node, got %d", len(protoAnchor.AvatarPathSkillTree))
}
if protoAnchor.AvatarPathSkillTree[0].PointId != 1001001 {
t.Fatalf("expected resolved point ID 1001001 for March Point01, got %d", protoAnchor.AvatarPathSkillTree[0].PointId)
if protoAnchor.AvatarPathSkillTree[0].PointId != 1 {
t.Fatalf("expected anchor 1 for March Point01, got %d", protoAnchor.AvatarPathSkillTree[0].PointId)
}
}
+73
View File
@@ -81,6 +81,8 @@ type AvatarConfig struct {
}
type JsonConfig struct {
MapEntrances map[uint32]MapEntranceEntry `json:"-"`
SceneAnchors map[uint32][]AnchorItem `json:"-"`
// entryId -> "P<plane>_F<floor>" -> config
LevelOutputConfigs map[uint32]map[string]*LevelOutputConfig `json:"levelOutputConfigs"`
AvatarConfigs map[uint32]AvatarConfig `json:"avatarConfigs"`
@@ -192,6 +194,7 @@ func GameRes() (*JsonConfig, error) {
_ = Teleports() // ensure teleportDB is initialized
loadSupplementaryResources(gameRes)
applySceneMetadata(gameRes)
})
return gameRes, gameResErr
}
@@ -266,6 +269,8 @@ func loadSupplementaryResources(res *JsonConfig) {
}
planesByID := map[uint32]MazePlaneEntry{}
res.MapEntrances = entrancesByEntry
res.SceneAnchors = map[uint32][]AnchorItem{}
if raw, err := readResourceFile("resources/MazePlane.json", "MazePlane.json"); err == nil {
var list []MazePlaneEntry
if json.Unmarshal(raw, &list) != nil {
@@ -366,6 +371,7 @@ func loadSupplementaryResources(res *JsonConfig) {
name: {
IsEnteredSceneInfo: true,
Scenes: groups,
WorldID: planesByID[sc.PlaneID].WorldID,
},
}
}
@@ -377,6 +383,7 @@ func loadSupplementaryResources(res *JsonConfig) {
var aFile AnchorConfigFile
if json.Unmarshal(raw, &aFile) == nil && len(aFile.AnchorConfig) > 0 {
for _, item := range aFile.AnchorConfig {
res.SceneAnchors[item.EntryID] = item.Anchor
floorID := uint32(0)
if ent, ok := entrancesByEntry[item.EntryID]; ok && ent.FloorID != 0 {
floorID = ent.FloorID
@@ -415,6 +422,72 @@ func loadSupplementaryResources(res *JsonConfig) {
}
}
}
// Supplementary scenes do not carry world metadata. Resolve it from the
// installed entrance/plane tables, including entries supplied by Anchor.json.
floorSavedValues := map[uint32]map[string]int32{}
if raw, err := readResourceFile("resources/FloorSavedValuesConfig.json"); err == nil {
var table struct {
Floors []struct {
FloorID uint32 `json:"FloorID"`
Values []struct {
Name string `json:"Name"`
MaxValue int32 `json:"MaxValue"`
} `json:"SavedValues"`
} `json:"saved_values"`
}
if err := json.Unmarshal(raw, &table); err == nil {
// Same unlocked scene defaults as PearlSR 005d2c12 scene_mgr.zig.
for _, floor := range table.Floors {
values := map[string]int32{}
for _, value := range floor.Values {
if value.Name != "" {
values[value.Name] = value.MaxValue
}
}
floorSavedValues[floor.FloorID] = values
}
} else {
LogInfo("[SCENE] Invalid FloorSavedValuesConfig: %v", err)
}
}
for entryID, configs := range res.LevelOutputConfigs {
entrance, ok := entrancesByEntry[entryID]
if !ok {
continue
}
// Legacy dumps label every entrance in a plane with its first floor.
// An entrance has an explicit floor in MapEntrance; do not derive it.
if len(configs) == 1 {
canonical := fmt.Sprintf("P%d_F%d", entrance.PlaneID, entrance.FloorID)
for name, config := range configs {
if name != canonical {
delete(configs, name)
configs[canonical] = config
}
break
}
}
plane, ok := planesByID[entrance.PlaneID]
if !ok {
continue
}
for name, config := range configs {
config.WorldID = plane.WorldID
var configPlane, configFloor uint32
if n, _ := fmt.Sscanf(name, "P%d_F%d", &configPlane, &configFloor); n != 2 {
continue
}
if config.SavedValues == nil {
config.SavedValues = map[string]int32{}
}
for name, value := range floorSavedValues[configFloor] {
if _, exists := config.SavedValues[name]; !exists {
config.SavedValues[name] = value
}
}
}
}
}
// Teleports lazily parses teleports.json ({floor_id: {teleport_id: info}}).
+11
View File
@@ -15,6 +15,7 @@ import (
// RuntimeConfig is shared by both server processes. Deployment values belong
// in configuration rather than in handlers or executable entry points.
type RuntimeConfig struct {
PlayerUID uint32 `json:"player_uid"`
BattleDiagnostics BattleDiagnosticsConfig `json:"battle_diagnostics"`
ClientVersion string `json:"client_version"`
GameHost string `json:"game_host"`
@@ -67,6 +68,16 @@ func LoadRuntimeConfig() (RuntimeConfig, error) {
*dest = uint16(n)
}
}
if value, ok := os.LookupEnv("GAC_PLAYER_UID"); ok {
n, err := strconv.ParseUint(value, 10, 32)
if err != nil || n == 0 {
return cfg, fmt.Errorf("GAC_PLAYER_UID must be an integer between 1 and 4294967295")
}
cfg.PlayerUID = uint32(n)
}
if cfg.PlayerUID == 0 {
return cfg, fmt.Errorf("runtime config requires a nonzero player_uid")
}
if cfg.GamePort == 0 || cfg.SDKPort == 0 || strings.TrimSpace(cfg.ClientVersion) == "" {
return cfg, fmt.Errorf("runtime config requires client_version, game_port and sdk_port")
}
+74
View File
@@ -0,0 +1,74 @@
package common
import (
"encoding/json"
"fmt"
)
// Scene metadata is exported from the installed Client's RuntimeFloor tables.
// A complete group list allows us to exclude deleted/client-owned groups
// without maintaining special cases for individual maps or group IDs.
type sceneMetadata struct {
ClientVersion string `json:"client_version"`
Floors map[uint32]struct {
StartAnchor *AnchorItem `json:"start_anchor"`
Monsters map[uint32]map[uint32]TeleportInfo `json:"monsters"`
GroupCount int `json:"group_count"`
Groups map[uint32]struct {
LoadSide string `json:"load_side"`
Deleted bool `json:"is_deleted"`
} `json:"groups"`
} `json:"floors"`
}
func applySceneMetadata(res *JsonConfig) {
raw, err := ReadResourceFile("resources/SceneMetadata.json")
if err != nil {
return
}
var metadata sceneMetadata
if err := json.Unmarshal(raw, &metadata); err != nil {
LogInfo("[SCENE] Invalid SceneMetadata: %v", err)
return
}
config, err := LoadRuntimeConfig()
if err != nil || metadata.ClientVersion != config.ClientVersion {
LogInfo("[SCENE] SceneMetadata Client revision does not match runtime config; not applied")
return
}
for entryID, configs := range res.LevelOutputConfigs {
for name, scene := range configs {
var planeID, floorID uint32
if n, _ := fmt.Sscanf(name, "P%d_F%d", &planeID, &floorID); n != 2 {
continue
}
floor, ok := metadata.Floors[floorID]
if !ok {
continue
}
if floor.GroupCount == 0 || len(floor.Groups) != floor.GroupCount {
LogInfo("[SCENE] Incomplete group metadata for floor %d; not applied", floorID)
continue
}
if floor.StartAnchor != nil {
res.SceneAnchors[entryID] = []AnchorItem{*floor.StartAnchor}
}
for groupID := range scene.Scenes {
group, exists := floor.Groups[groupID]
if !exists || group.Deleted || group.LoadSide == "Client" {
delete(scene.Scenes, groupID)
LogInfo("[SCENE] Excluded group entry=%d floor=%d group=%d present=%t deleted=%t side=%s", entryID, floorID, groupID, exists, group.Deleted, group.LoadSide)
continue
}
data := scene.Scenes[groupID]
for index := range data.Monsters {
monster := &data.Monsters[index]
if placement, ok := floor.Monsters[groupID][monster.InstID]; ok {
monster.Pos, monster.Rot = placement.Pos, placement.Rot
}
}
scene.Scenes[groupID] = data
}
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package common
import (
"encoding/json"
"fmt"
"sync"
)
type TeamPresetRules struct {
Count uint32 `json:"preset_count"`
MaxMembers uint32 `json:"max_members"`
MaxNameLength int `json:"max_name_length"`
}
var teamPresetOnce sync.Once
var teamPresetRules TeamPresetRules
var teamPresetError error
func LoadTeamPresetRules() (*TeamPresetRules, error) {
teamPresetOnce.Do(func() {
raw, err := ReadResourceFile("resources/TeamPresetConfig.json")
if err != nil {
teamPresetError = err
return
}
if err := json.Unmarshal(raw, &teamPresetRules); err != nil {
teamPresetError = err
return
}
if teamPresetRules.Count == 0 || teamPresetRules.MaxMembers == 0 || teamPresetRules.MaxNameLength < 1 {
teamPresetError = fmt.Errorf("invalid TeamPresetConfig")
}
})
return &teamPresetRules, teamPresetError
}
func (f *FreesrData) TeamPreset(index uint32) *TeamPreset {
original := f.TeamPresets[index]
p := &TeamPreset{Name: fmt.Sprintf("Team %d", index+1), Avatars: map[uint32]uint32{}, Favourite: true}
if original != nil {
p.Name = original.Name
p.LeaderSlot = original.LeaderSlot
p.Favourite = original.Favourite
for slot, id := range original.Avatars {
p.Avatars[slot] = id
}
}
// Preserve edits made through the existing /api/persistent lineups field.
if index == f.ActiveTeamPreset {
p.Avatars = map[uint32]uint32{}
for slot, id := range f.Lineups {
p.Avatars[slot] = id
}
}
return p
}
func (f *FreesrData) SaveTeamPreset(index uint32, preset *TeamPreset) error {
if f.TeamPresets == nil {
f.TeamPresets = map[uint32]*TeamPreset{}
}
if preset.Avatars[preset.LeaderSlot] == 0 {
found := false
for slot, id := range preset.Avatars {
if id != 0 && (!found || slot < preset.LeaderSlot) {
preset.LeaderSlot = slot
found = true
}
}
}
old, oldLineup := f.TeamPresets[index], f.Lineups
f.TeamPresets[index] = preset
if index == f.ActiveTeamPreset {
f.Lineups = map[uint32]uint32{}
for slot, id := range preset.Avatars {
f.Lineups[slot] = id
}
}
if err := f.SavePersistentChecked(); err != nil {
if old == nil {
delete(f.TeamPresets, index)
} else {
f.TeamPresets[index] = old
}
f.Lineups = oldLineup
return err
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package common
import (
"encoding/json"
"fmt"
"sync"
)
type TechniqueFood struct {
RecoverMP uint32 `json:"recover_mp"`
MaxUse uint32 `json:"max_use"`
PileLimit uint32 `json:"pile_limit"`
}
type MazeSkillRule struct {
ID uint32 `json:"MazeSkillId"`
MPCost uint32 `json:"MPCost"`
Trigger string `json:"SkillTriggerKey"`
}
type TechniqueRules struct {
ClientVersion string `json:"client_version"`
BaseMaxMP uint32 `json:"base_max_mp"`
InitialMP uint32 `json:"initial_mp"`
MaxMPByAvatar map[uint32]uint32 `json:"max_mp_by_avatar"`
NonAttackTechniques []uint32 `json:"non_attack_techniques"`
Avatars map[uint32][]MazeSkillRule `json:"avatars"`
Foods map[uint32]TechniqueFood `json:"foods"`
InitialFoodCount uint32 `json:"initial_food_count"`
AutoFoodDefault bool `json:"auto_food_default"`
}
var techniqueOnce sync.Once
var techniqueRules TechniqueRules
var techniqueErr error
func LoadTechniqueRules() (*TechniqueRules, error) {
techniqueOnce.Do(func() {
raw, err := ReadResourceFile("resources/TechniqueRules.json")
if err != nil {
techniqueErr = err
return
}
if err = json.Unmarshal(raw, &techniqueRules); err != nil {
techniqueErr = err
return
}
cfg, err := LoadRuntimeConfig()
if err != nil {
techniqueErr = err
return
}
if techniqueRules.ClientVersion != cfg.ClientVersion || techniqueRules.BaseMaxMP == 0 || techniqueRules.InitialMP > techniqueRules.BaseMaxMP {
techniqueErr = fmt.Errorf("invalid or mismatched TechniqueRules client resource")
return
}
for id, f := range techniqueRules.Foods {
if id == 0 || f.RecoverMP == 0 || f.MaxUse == 0 || techniqueRules.InitialFoodCount > f.PileLimit {
techniqueErr = fmt.Errorf("invalid technique food %d", id)
return
}
}
})
return &techniqueRules, techniqueErr
}
func (r *TechniqueRules) MaxMP(avatars map[uint32]uint32) uint32 {
maximum := r.BaseMaxMP
// PearlSR buildLineup selects the largest team cap; it does not add both caps.
for _, id := range avatars {
if value := r.MaxMPByAvatar[id]; value > maximum {
maximum = value
}
}
return maximum
}
// Called only by an explicit inventory/cast operation, never by save readers.
func (f *FreesrData) EnsureTechniqueFoods() error {
if f.TechniqueFoodStock != nil && f.AutoTechniqueFood != nil {
return nil
}
r, err := LoadTechniqueRules()
if err != nil {
return err
}
if f.TechniqueFoodStock == nil {
f.TechniqueFoodStock = map[uint32]uint32{}
for id := range r.Foods {
f.TechniqueFoodStock[id] = r.InitialFoodCount
}
}
if f.AutoTechniqueFood == nil {
value := r.AutoFoodDefault
f.AutoTechniqueFood = &value
}
return f.SavePersistentChecked()
}
// Refresh just the setting, and advance its merge baseline so a subsequent
// session position save cannot undo an external API toggle.
func (f *FreesrData) RefreshTechniqueSettings(p *Persistent) {
f.AutoTechniqueFood = p.AutoTechniqueFood
if snapshot, err := jsonObject(f.persistentSnapshot()); err == nil {
if f.persistentBaseline == nil {
f.persistentBaseline = map[string]any{}
}
f.persistentBaseline["auto_technique_food"] = snapshot["auto_technique_food"]
}
}
+3 -2
View File
@@ -14,8 +14,9 @@ import (
// not executable defaults. Missing or invalid rules must not silently produce a
// different battle.
type battleRules struct {
EntryAttackBuffs map[string]uint32 `json:"entry_attack_buffs"`
Source struct {
ChallengeRoundLimits map[common.BattleType]uint32 `json:"challenge_round_limits"`
EntryAttackBuffs map[string]uint32 `json:"entry_attack_buffs"`
Source struct {
Revision string `json:"revision"`
} `json:"source"`
TechniqueBuffs map[uint32][]struct {
+406
View File
@@ -0,0 +1,406 @@
package gameserver
import (
"fmt"
"gac-hkrpg-go/internal/common"
"sort"
goproto "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
type challengeRoom struct {
Entrance, Group, Monster, Event uint32
Avatars []uint32
Buffs []uint32
}
// PearlSR 005d2c12 challenge_mgr.zig defines the three Tierce nodes 0..2.
const tierceNodeCount = 3
func rememberTierceTeams(s *Session, id uint32, teams [][]uint32, buffs []uint32, single bool, node uint32) error {
if s.TierceTeams == nil {
s.TierceTeams = map[uint32][][]uint32{}
}
if s.TierceBuffs == nil {
s.TierceBuffs = map[uint32][]uint32{}
}
p := s.JSON()
if p == nil {
return fmt.Errorf("player data is unavailable")
}
if p.TierceProgress == nil {
p.TierceProgress = map[uint32]*common.TierceProgress{}
}
saved := p.TierceProgress[id]
if saved == nil {
saved = &common.TierceProgress{Nodes: map[uint32]common.TierceNodeProgress{}}
p.TierceProgress[id] = saved
}
if s.TierceTeams[id] == nil {
s.TierceTeams[id] = saved.Teams
}
if s.TierceBuffs[id] == nil {
s.TierceBuffs[id] = saved.Buffs
}
if len(s.TierceTeams[id]) != tierceNodeCount {
s.TierceTeams[id] = make([][]uint32, tierceNodeCount)
}
if len(s.TierceBuffs[id]) != tierceNodeCount {
s.TierceBuffs[id] = make([]uint32, tierceNodeCount)
}
for index, team := range teams {
target := index
if single && len(teams) == 1 {
target = int(node)
}
if target >= tierceNodeCount {
continue
}
if len(team) > 0 {
s.TierceTeams[id][target] = append([]uint32(nil), dedupeTruncate4(team)...)
}
if index < len(buffs) {
s.TierceBuffs[id][target] = buffs[index]
}
}
saved.Teams, saved.Buffs = s.TierceTeams[id], s.TierceBuffs[id]
return p.SavePersistentChecked()
}
// A run owns its teams, score and room independently of Freesr and other
// sessions. Rooms come from the installed ChallengeMaze/Tierce/Peak tables.
type challengeRun struct {
ID uint32
Tierce, Peak, Single, PendingNext, Finished bool
Node uint32
Rooms []challengeRoom
Scores []uint32
Rounds uint32
RoundCosts []uint32
Cur *goproto.CurChallenge
TierceCur *goproto.CurTierceChallenge
PeakBuff uint32
Results []*goproto.PMMOFOBCINL
Win bool
}
func resourceRoom(stage ChallengeStageData, second bool, avatars []uint32, buff uint32) challengeRoom {
r := challengeRoom{Entrance: stage.Entrance, Group: stage.Group1, Monster: stage.Monster1, Event: stage.Event1}
if second {
r.Entrance, r.Group, r.Monster, r.Event = stage.Entrance2, stage.Group2, stage.Monster2, stage.Event2
}
r.Avatars = append([]uint32(nil), dedupeTruncate4(avatars)...)
r.Buffs = []uint32{buff, stage.Buff}
return r
}
func (run *challengeRun) lineupType() goproto.ExtraLineupType {
// Each Tierce node has its own protocol team. Updating team 1 while the
// client switches to team 3 leaves an empty TeamData and leader slot -1.
if run.Tierce {
switch run.Node {
case 1:
return goproto.ExtraLineupType_ExtraLineupType_LineupChallenge2
case 2:
return goproto.ExtraLineupType_ExtraLineupType_LineupChallenge3
}
}
if !run.Tierce && !run.Peak && run.Node > 0 {
return goproto.ExtraLineupType_ExtraLineupType_LineupChallenge2
}
return goproto.ExtraLineupType_ExtraLineupType_LineupChallenge
}
func (run *challengeRun) activeLineup(plane uint32) *goproto.LineupInfo {
return lineupInfoFromAvatars(run.Rooms[run.Node].Avatars, plane, run.lineupType(), 0)
}
func challengeCurBuffs(id uint32, buffs []uint32) *goproto.ChallengeCurBuffInfo {
var valid []uint32
seen := map[uint32]bool{}
for _, buff := range buffs {
if buff != 0 && !seen[buff] {
valid = append(valid, buff)
seen[buff] = true
}
}
switch {
case id > 20000 && id < 30000:
return &goproto.ChallengeCurBuffInfo{CurStoryBuffs: &goproto.ChallengeStoryBuffList{BuffList: valid}}
case id > 30000:
return &goproto.ChallengeCurBuffInfo{CurBossBuffs: &goproto.ChallengeBossBuffList{BuffList: valid, ChallengeBossConst: 1}}
}
return nil
}
func advanceChallengeRoom(s *Session) (*goproto.SceneInfo, error) {
run := s.ChallengeRun
if run == nil || !run.PendingNext || int(run.Node)+1 >= len(run.Rooms) {
return nil, fmt.Errorf("challenge has no pending next room")
}
next := run.Node + 1
r := run.Rooms[next]
if len(r.Avatars) == 0 {
return nil, fmt.Errorf("challenge %d node %d has no saved team", run.ID, next)
}
previousConfig := s.ChallengeBattleConfig
if err := setChallengeBattleConfig(s, r.Avatars, run.ID, r.Event, r.Buffs); err != nil {
return nil, err
}
scene, _, err := loadChallengeScene(s, r.Entrance, r.Group, r.Monster, r.Event, r.Avatars)
if err != nil {
s.ChallengeBattleConfig = previousConfig
return nil, err
}
run.Node, run.PendingNext = next, false
if run.Cur != nil {
run.Cur.ExtraLineupType = run.lineupType()
run.Cur.StageInfo = challengeCurBuffs(run.ID, r.Buffs)
}
if run.TierceCur != nil {
run.TierceCur.FINOCEGDBDI = next
run.TierceCur.LineupList = []*goproto.LineupInfo{run.activeLineup(scene.PlaneId)}
}
SceneDebugLog("[CHALLENGE] advanced id=%d tierce=%t node=%d entry=%d stage=%d team=%v", run.ID, run.Tierce, next, r.Entrance, r.Event, r.Avatars)
return scene, nil
}
func sendChallengeRoom(s *Session, scene *goproto.SceneInfo) error {
run := s.ChallengeRun
lineup := run.activeLineup(scene.PlaneId)
if err := s.Send(&goproto.SyncLineupNotify{Lineup: lineup}); err != nil {
return err
}
if !run.Tierce && !run.Peak {
if err := s.Send(&goproto.ChallengeLineupNotify{ExtraLineupType: run.lineupType()}); err != nil {
return err
}
}
return s.Send(&goproto.EnterSceneByServerScNotify{Scene: scene, Lineup: lineup})
}
func init() {
handle("EnterChallengeNextPhase", func(s *Session, _ *goproto.EnterChallengeNextPhaseCsReq, rsp *goproto.EnterChallengeNextPhaseScRsp) error {
if run := s.ChallengeRun; run == nil || run.Tierce || run.Peak {
rsp.Retcode = uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)
return nil
}
scene, err := advanceChallengeRoom(s)
if err != nil {
rsp.Retcode = uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)
return nil
}
rsp.Scene = scene
return s.Send(&goproto.SyncLineupNotify{Lineup: s.ChallengeRun.activeLineup(scene.PlaneId)})
})
// PearlSR 005d2c12 retains obfuscated command names. Client callback
// metadata confirms <StartNext>b__25_0(LHAKNKHBLBI) and
// <Restart>b__21_0(JMPJCAPKNGI); resolve IDs by these table names.
rawNamed("MHJNOBBOFOC", func(s *Session, _ []byte) error {
if run := s.ChallengeRun; run == nil || !run.Tierce {
return s.Send(&goproto.LHAKNKHBLBI{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
scene, err := advanceChallengeRoom(s)
if err != nil {
return s.Send(&goproto.LHAKNKHBLBI{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
if err := s.Send(&goproto.SyncLineupNotify{Lineup: s.ChallengeRun.activeLineup(scene.PlaneId)}); err != nil {
return err
}
return s.Send(&goproto.LHAKNKHBLBI{Scene: scene, CurTierceChallenge: s.ChallengeRun.TierceCur})
})
rawNamed("PKKFNODINEM", func(s *Session, _ []byte) error {
scene, err := restartChallengeRoom(s)
if err != nil {
return s.Send(&goproto.JMPJCAPKNGI{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
return s.Send(&goproto.JMPJCAPKNGI{Scene: scene})
})
rawNamed("MENLHHCJNCE", func(s *Session, _ []byte) error { return s.Send(&goproto.OKEDFNJJGHB{}) })
handle("RestartChallengePhase", func(s *Session, _ *goproto.RestartChallengePhaseCsReq, rsp *goproto.RestartChallengePhaseScRsp) error {
scene, err := restartChallengeRoom(s)
if err != nil {
rsp.Retcode = uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)
} else {
rsp.Scene = scene
}
return nil
})
}
func restartChallengeRoom(s *Session) (*goproto.SceneInfo, error) {
run := s.ChallengeRun
if run == nil {
return nil, fmt.Errorf("no challenge to restart")
}
r := run.Rooms[run.Node]
if err := setChallengeBattleConfig(s, r.Avatars, run.ID, r.Event, r.Buffs); err != nil {
return nil, err
}
scene, _, err := loadChallengeScene(s, r.Entrance, r.Group, r.Monster, r.Event, r.Avatars)
if err != nil {
return nil, err
}
run.Finished, run.PendingNext = false, false
run.Win = false
run.Scores[run.Node] = 0
if len(run.RoundCosts) > int(run.Node) {
run.RoundCosts[run.Node] = 0
}
run.Rounds = 0
for _, rounds := range run.RoundCosts {
run.Rounds += rounds
}
var results []*goproto.PMMOFOBCINL
for _, result := range run.Results {
if result.FINOCEGDBDI != run.Node {
results = append(results, result)
}
}
run.Results = results
if run.Cur != nil {
run.Cur.Status = goproto.ChallengeStatus_CHALLENGE_DOING
run.Cur.RoundCount = run.Rounds
run.Cur.ScoreId = run.Scores[0]
if len(run.Scores) > 1 {
run.Cur.ScoreTwo = run.Scores[1]
}
}
CURRENT_BATTLE.Delete(s.Token)
return scene, nil
}
func finishTierceNode(s *Session, req *goproto.PVEBattleResultCsReq) error {
run := s.ChallengeRun
lineup := &goproto.GLLFJFJBIBC{}
for slot, id := range run.Rooms[run.Node].Avatars {
avatar := s.JSON().Avatars[id]
if avatar == nil {
continue
}
info := &goproto.CLDOIEGCNLD{AvatarId: id, AvatarType: uint32(goproto.AvatarType_AVATAR_FORMAL_TYPE), Slot: uint32(slot), Level: avatar.Level, PAGPNOKLNOM: &goproto.ChallengeBossEquipmentInfo{}, OKCGHKDMFAM: &goproto.ChallengeBossAvatarRelicInfo{}}
if cone := s.JSON().FindLightconeFor(id); cone != nil {
info.PAGPNOKLNOM = &goproto.ChallengeBossEquipmentInfo{UniqueId: cone.InternalUID, Tid: cone.ItemID, Level: cone.Level, Promotion: cone.Promotion, Rank: cone.Rank}
}
lineup.MNABNJOEHLF = append(lineup.MNABNJOEHLF, info)
}
result := &goproto.PMMOFOBCINL{FINOCEGDBDI: run.Node, ScoreId: run.Scores[run.Node], EndStatus: req.EndStatus, Lineup: lineup}
if req.Stt != nil {
for _, targets := range req.Stt.BattleTargetInfo {
result.BattleTargetList = append(result.BattleTargetList, targets.GetBattleTargetList()...)
}
}
run.Results = append(run.Results, result)
if err := saveTierceResult(s, result); err != nil {
return err
}
var total uint32
for _, score := range run.Scores {
total += score
}
allWon := len(run.Results) == tierceNodeCount
for _, nodeResult := range run.Results {
if nodeResult.EndStatus != goproto.BattleEndStatus_BATTLE_END_WIN {
allWon = false
}
}
var finishedTargets []uint32
if run.Finished && allWon {
finishedTargets = tierceTargets(run.ID, total)
}
win := req.EndStatus == goproto.BattleEndStatus_BATTLE_END_WIN
if run.Finished {
win = win && allWon
}
if run.Finished && getChallengeData().Tierce[run.ID].ClearScore > 0 {
win = win && total >= getChallengeData().Tierce[run.ID].ClearScore
}
run.Win = win
// Verified Client events: this oneof is OnStageSettle; the field-8
// alternative is OnMedal and must not be used to switch stages.
if err := s.Send(&goproto.DOPHNDHILPA{MMCGBGDJIPN: &goproto.DOPHNDHILPA_IANBCDJPDJF{IANBCDJPDJF: &goproto.JCKGBFJJFFC{PAIAEPJIDPE: result, IsWin: win, FinishedTargetList: finishedTargets}}}); err != nil {
return err
}
if run.Finished {
sort.Slice(run.Results, func(i, j int) bool { return run.Results[i].FINOCEGDBDI < run.Results[j].FINOCEGDBDI })
var score uint32
for _, value := range run.Scores {
score += value
}
if err := s.Send(&goproto.DOPHNDHILPA{MMCGBGDJIPN: &goproto.DOPHNDHILPA_DFNNKENBOEN{DFNNKENBOEN: &goproto.GEKNAKDAMJF{ChallengeId: run.ID, IsWin: win, FinishedTargetList: finishedTargets, EGEHLOPFELK: &goproto.ChallengeTierceRecord{TotalScore: score}, GBMBAHHDONN: run.Results}}}); err != nil {
return err
}
}
return errResponseAlreadySent
}
// Scores are supplied by the Client; Tierce targets are evaluated against the
// installed target table. A win alone does not grant score targets or rewards.
func finishChallengeBattle(s *Session, req *goproto.PVEBattleResultCsReq, rsp *goproto.PVEBattleResultScRsp, active bool) error {
run := s.ChallengeRun
if !active || run == nil || run.Finished || run.PendingNext || req.StageId != run.Rooms[run.Node].Event {
return nil
}
if req.EndStatus == goproto.BattleEndStatus_BATTLE_END_QUIT {
return nil
}
if req.Stt != nil {
run.Scores[run.Node] = req.Stt.ChallengeScore
if len(run.RoundCosts) != len(run.Rooms) {
run.RoundCosts = make([]uint32, len(run.Rooms))
}
run.RoundCosts[run.Node] = req.Stt.RoundCnt
run.Rounds = 0
for _, rounds := range run.RoundCosts {
run.Rounds += rounds
}
}
if run.Cur != nil {
run.Cur.RoundCount = run.Rounds
run.Cur.ScoreId = run.Scores[0]
if len(run.Scores) > 1 {
run.Cur.ScoreTwo = run.Scores[1]
}
}
SceneDebugLog("[CHALLENGE] result id=%d node=%d status=%s stage=%d scores=%v rounds=%d", run.ID, run.Node, req.EndStatus, req.StageId, run.Scores, run.Rounds)
if err := s.Send(rsp); err != nil {
return err
}
win := req.EndStatus == goproto.BattleEndStatus_BATTLE_END_WIN
run.Win = win
if win && !run.Single && int(run.Node)+1 < len(run.Rooms) {
run.PendingNext = true
if run.Tierce {
return finishTierceNode(s, req)
}
scene, err := advanceChallengeRoom(s)
if err != nil {
return err
}
if err := sendChallengeRoom(s, scene); err != nil {
return err
}
return errResponseAlreadySent
}
run.Finished = true
if run.Tierce {
return finishTierceNode(s, req)
}
if run.Peak {
if err := s.Send(&goproto.ChallengePeakSettleScNotify{PeakId: run.ID, IsWin: win, CyclesUsed: run.Rounds}); err != nil {
return err
}
return errResponseAlreadySent
}
if run.Cur != nil {
run.Cur.Status = goproto.ChallengeStatus_CHALLENGE_FAILED
if win {
run.Cur.Status = goproto.ChallengeStatus_CHALLENGE_FINISH
}
if err := s.Send(&goproto.ChallengeSettleNotify{ChallengeId: run.ID, IsWin: win, CurChallenge: proto.Clone(run.Cur).(*goproto.CurChallenge), Reward: &goproto.ItemList{}, ChallengeScore: run.Cur.ScoreId, ScoreTwo: run.Cur.ScoreTwo}); err != nil {
return err
}
}
return errResponseAlreadySent
}
+5
View File
@@ -21,6 +21,11 @@ type Gateway struct {
}
func NewGateway(host string, port uint16) (*Gateway, error) {
cfg, err := common.LoadRuntimeConfig()
if err != nil {
return nil, err
}
playerUID = cfg.PlayerUID
addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, itoa(int(port))))
if err != nil {
return nil, err
@@ -6,7 +6,8 @@ import (
goproto "gac-hkrpg-go/protocol/proto"
)
const playerUID uint32 = 1337
// Loaded from the shared runtime configuration before the gateway accepts sessions.
var playerUID uint32
func init() {
handle("PlayerGetToken", func(s *Session, req *goproto.PlayerGetTokenCsReq, rsp *goproto.PlayerGetTokenScRsp) error {
@@ -23,7 +24,7 @@ func init() {
rsp.ServerTimestampMs = uint64(time.Now().UnixMilli())
rsp.Stamina = 240
rsp.BasicInfo = &goproto.PlayerBasicInfo{
Nickname: "RobinSR",
Nickname: "GaySR",
Level: 70,
WorldLevel: 6,
Stamina: 240,
+55 -6
View File
@@ -12,7 +12,11 @@ import (
// PearlSR 005d2c1 protocol/src/protocol.pb.zig: MonsterBattleType.
// The Go schema retains obfuscated enum labels; zero is NONE, not TRIGGER.
const monsterBattleTrigger goproto.MonsterBattleType = 1
const (
monsterBattleTrigger goproto.MonsterBattleType = 1
// PearlSR protocol.pb.zig: MONSTER_BATTLE_TYPE_NO_BATTLE.
monsterBattleNoBattle goproto.MonsterBattleType = 4
)
func init() {
handle("StartCocoonStage", func(s *Session, req *goproto.StartCocoonStageCsReq, rsp *goproto.StartCocoonStageScRsp) error {
@@ -109,7 +113,11 @@ func init() {
})
handle("PVEBattleResult", func(s *Session, req *goproto.PVEBattleResultCsReq, rsp *goproto.PVEBattleResultScRsp) error {
CURRENT_BATTLE.Delete(s.Token)
current, active := CURRENT_BATTLE.Load(s.Token)
active = active && current.StageId == req.StageId
if active {
CURRENT_BATTLE.Delete(s.Token)
}
rsp.EndStatus = req.EndStatus
rsp.BattleId = req.BattleId
rsp.StageId = req.StageId
@@ -130,7 +138,7 @@ func init() {
req.BattleId, report.TeamTotalDamage, report.RoundCnt, report.TotalBattleTurns, statusStr))
}
}
return nil
return finishChallengeBattle(s, req, rsp, active)
})
// Consume every target carrier in the current schema. Only entities sent
@@ -163,6 +171,9 @@ func init() {
req.AssistMonsterEntityIdList, req.AssistMonsterEntityInfo, s.MonsterEntities, targets)
if len(targets) == 0 {
if s.ChallengeStageID != 0 {
SceneDebugLog("[CHALLENGE] attack without monster target request=%s", req.String())
}
// ไม่มี monster จริงถูกตี — ไม่เข้า (นี่คือ fix หลัก)
SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d skill_index=%d -> no real monster target (battle skipped)",
req.CastEntityId, req.CasterId, req.SkillIndex)
@@ -173,6 +184,25 @@ func init() {
SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d -> with %d monster(s)",
req.CastEntityId, req.CasterId, len(targets))
// PearlSR getBattleType acknowledges non-attacking techniques without
// starting a battle. The monster still needs a NO_BATTLE result.
if req.SkillIndex == 1 {
rules, err := common.LoadTechniqueRules()
if err != nil {
return err
}
avatarID := s.techniqueAvatar(req.CasterId)
for _, id := range rules.NonAttackTechniques {
if avatarID == id {
for _, target := range targets {
rsp.MonsterBattleInfo = append(rsp.MonsterBattleInfo, &goproto.HitMonsterBattleInfo{TargetMonsterEntityId: target, MonsterBattleType: monsterBattleNoBattle})
}
SceneDebugLog("[TECHNIQUE] non-battle avatar=%d targets=%v", avatarID, targets)
return nil
}
}
}
battleInfo, err := createBattleInfo(s, req.CasterId, req.SkillIndex)
if err != nil {
return err
@@ -189,11 +219,12 @@ func init() {
return nil
})
// ตอบ MP cost ให้ client (ตอบ cast_entity_id กลับ, ไม่ตัด MP จริง)
// Cost and recovery use the same session MP as lineup and consumables.
handle("SceneCastSkillCostMp", func(s *Session, req *goproto.SceneCastSkillCostMpCsReq, rsp *goproto.SceneCastSkillCostMpScRsp) error {
rsp.Retcode = 0
rsp.CastEntityId = req.CastEntityId
return nil
code, err := s.payTechnique(req.CastEntityId, req.SkillIndex)
rsp.Retcode = code
return err
})
handle("GetCurBattleInfo", func(s *Session, req *goproto.GetCurBattleInfoCsReq, rsp *goproto.GetCurBattleInfoScRsp) error {
@@ -215,6 +246,24 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32) (*goproto.SceneBa
if player == nil {
return nil, fmt.Errorf("battle: player data is not loaded")
}
if s.ChallengeBattleConfig != nil {
// Keep challenge waves and team local to this session. External
// Freesr reloads must not replace the stage the player just entered.
battlePlayer := *player
battlePlayer.BattleConfig = *s.ChallengeBattleConfig
player = &battlePlayer
} else if len(player.TeamPresets) > 0 {
// A selected native preset is the party the player sees in the scene.
// Keep the imported stage/waves while taking members from that party.
battlePlayer := *player
battlePlayer.BattleConfig.CustomBattleLineup = map[uint32]uint32{}
for slot, id := range player.Lineups {
if id != 0 {
battlePlayer.BattleConfig.CustomBattleLineup[slot] = id
}
}
player = &battlePlayer
}
rules, err := loadBattleRules()
if err != nil {
+204 -13
View File
@@ -3,6 +3,7 @@ package gameserver
import (
"encoding/json"
"fmt"
"gac-hkrpg-go/internal/common"
"os"
"sort"
"sync"
@@ -12,12 +13,14 @@ import (
// ChallengeDataConfig mirrors challenge_data.json (Rust: ChallengeConfigData).
type ChallengeDataConfig struct {
StagesList []uint32 `json:"stages_list"`
GroupsList []uint32 `json:"groups_list"`
Challenges map[uint32]ChallengeStageData `json:"challenges"`
Tierce map[uint32]ChallengeTierceStageData `json:"tierce"`
Stages map[uint32]StageBattleData `json:"stages"`
Peak map[string]PeakStageData `json:"peak"`
TargetRules map[uint32]ChallengeTargetRule `json:"-"`
Countdowns map[uint32]uint32 `json:"-"`
StagesList []uint32 `json:"stages_list"`
GroupsList []uint32 `json:"groups_list"`
Challenges map[uint32]ChallengeStageData `json:"challenges"`
Tierce map[uint32]ChallengeTierceStageData `json:"tierce"`
Stages map[uint32]StageBattleData `json:"stages"`
Peak map[string]PeakStageData `json:"peak"`
}
type ChallengeStageData struct {
@@ -33,13 +36,22 @@ type ChallengeStageData struct {
Buff uint32 `json:"buff"`
}
type ChallengeTargetRule struct {
ID uint32 `json:"ID"`
Type uint32 `json:"ChallengeTargetType"`
Param uint32 `json:"ChallengeTargetParam1"`
}
type ChallengeTierceStageData struct {
Entrance uint32 `json:"entrance"`
Group uint32 `json:"group"`
Monster uint32 `json:"monster"`
Event uint32 `json:"event"`
Targets []uint32 `json:"targets"`
Score uint32 `json:"score"`
SpecialTarget uint32 `json:"-"`
ClearScore uint32 `json:"-"`
PreChallengeID uint32 `json:"-"`
Entrance uint32 `json:"entrance"`
Group uint32 `json:"group"`
Monster uint32 `json:"monster"`
Event uint32 `json:"event"`
Targets []uint32 `json:"targets"`
Score uint32 `json:"score"`
}
type StageBattleData struct {
@@ -48,6 +60,11 @@ type StageBattleData struct {
}
type PeakStageData struct {
Entrance uint32 `json:"-"`
Group uint32 `json:"-"`
GroupID uint32 `json:"-"`
Buffs []uint32 `json:"-"`
HardBuffs []uint32 `json:"-"`
EventID uint32 `json:"event_id"`
HardEventID uint32 `json:"hard_event_id"`
MonsterID uint32 `json:"monster_id"`
@@ -73,10 +90,166 @@ func getChallengeData() *ChallengeDataConfig {
Log("Failed to parse challenge_data.json: " + err.Error())
challengeData = ChallengeDataConfig{}
}
challengeData.Countdowns = map[uint32]uint32{}
challengeData.TargetRules = map[uint32]ChallengeTargetRule{}
if raw, err := common.ReadResourceFile("resources/ChallengeTargetConfig.json"); err == nil {
var table struct {
Entries []ChallengeTargetRule `json:"challenge_targets"`
}
if json.Unmarshal(raw, &table) == nil {
for _, entry := range table.Entries {
challengeData.TargetRules[entry.ID] = entry
}
}
}
if challengeData.Challenges == nil {
challengeData.Challenges = map[uint32]ChallengeStageData{}
}
if challengeData.Tierce == nil {
challengeData.Tierce = map[uint32]ChallengeTierceStageData{}
}
if challengeData.Stages == nil {
challengeData.Stages = map[uint32]StageBattleData{}
}
if raw, err := common.ReadResourceFile("resources/ChallengeMazeConfig.json"); err == nil {
var table struct {
Entries []struct {
ID uint32 `json:"ID"`
Countdown uint32 `json:"ChallengeCountDown"`
GroupID uint32 `json:"GroupID"`
Entrance uint32 `json:"MapEntranceID"`
Entrance2 uint32 `json:"MapEntranceID2"`
Group1 uint32 `json:"MazeGroupID1"`
Group2 uint32 `json:"MazeGroupID2"`
Monsters1 []uint32 `json:"NpcMonsterIDList1"`
Monsters2 []uint32 `json:"NpcMonsterIDList2"`
Events1 []uint32 `json:"EventIDList1"`
Events2 []uint32 `json:"EventIDList2"`
Buff uint32 `json:"MazeBuffID"`
} `json:"challenge_config"`
}
if err := json.Unmarshal(raw, &table); err == nil {
for _, entry := range table.Entries {
challengeData.Countdowns[entry.ID] = entry.Countdown
challengeData.Challenges[entry.ID] = ChallengeStageData{GroupID: entry.GroupID, Entrance: entry.Entrance, Entrance2: entry.Entrance2, Group1: entry.Group1, Group2: entry.Group2, Monster1: lastResourceID(entry.Monsters1), Monster2: lastResourceID(entry.Monsters2), Event1: lastResourceID(entry.Events1), Event2: lastResourceID(entry.Events2), Buff: entry.Buff}
}
}
}
if raw, err := common.ReadResourceFile("resources/ChallengeMazeTierceConfig.json"); err == nil {
var table struct {
Entries []struct {
ID uint32 `json:"ID"`
PreChallengeID uint32 `json:"PreChallengeMazeID"`
Entrance uint32 `json:"MapEntranceID"`
Group uint32 `json:"MazeGroupID"`
Monsters []uint32 `json:"NpcMonsterIDList"`
Events []uint32 `json:"EventIDList"`
Targets []uint32 `json:"ChallengeTargetID"`
SpecialTarget uint32 `json:"ChallengeTierceTargetID"`
ClearScore uint32 `json:"IDBJENCBJHM"`
} `json:"challenge_tierce_config"`
}
if err := json.Unmarshal(raw, &table); err == nil {
for _, entry := range table.Entries {
value := challengeData.Tierce[entry.ID]
value.PreChallengeID, value.Entrance, value.Group = entry.PreChallengeID, entry.Entrance, entry.Group
value.SpecialTarget, value.ClearScore = entry.SpecialTarget, entry.ClearScore
value.Monster, value.Event, value.Targets = lastResourceID(entry.Monsters), lastResourceID(entry.Events), entry.Targets
challengeData.Tierce[entry.ID] = value
}
}
}
if raw, err := common.ReadResourceFile("resources/StageConfig.json"); err == nil {
var table struct {
Entries []struct {
ID uint32 `json:"StageID"`
Level uint32 `json:"Level"`
Monsters [][]uint32 `json:"MonsterList"`
} `json:"stage_config"`
}
if err := json.Unmarshal(raw, &table); err == nil {
for _, entry := range table.Entries {
challengeData.Stages[entry.ID] = StageBattleData{Level: entry.Level, Monsters: entry.Monsters}
}
}
}
loadPeakResources(&challengeData)
})
return &challengeData
}
func loadPeakResources(data *ChallengeDataConfig) {
if data.Peak == nil {
data.Peak = map[string]PeakStageData{}
}
if raw, err := common.ReadResourceFile("resources/ChallengePeakConfig.json"); err == nil {
var table struct {
Rows []struct {
ID uint32 `json:"ID"`
Entrance uint32 `json:"MapEntranceID"`
Group uint32 `json:"MazeGroupID"`
Events []uint32 `json:"EventIDList"`
Monsters []uint32 `json:"NpcMonsterIDList"`
Buffs []uint32 `json:"TagList"`
Targets []uint32 `json:"NormalTargetList"`
} `json:"challenge_peak_config"`
}
if err := json.Unmarshal(raw, &table); err == nil {
for _, row := range table.Rows {
value := data.Peak[itoa(int(row.ID))]
value.Entrance, value.Group, value.EventID, value.MonsterID = row.Entrance, row.Group, lastResourceID(row.Events), lastResourceID(row.Monsters)
value.Buffs, value.Targets = row.Buffs, row.Targets
data.Peak[itoa(int(row.ID))] = value
}
}
}
if raw, err := common.ReadResourceFile("resources/ChallengePeakBossConfig.json"); err == nil {
var table struct {
Rows []struct {
ID uint32 `json:"ID"`
Events []uint32 `json:"HardEventIDList"`
Buffs []uint32 `json:"HardTagList"`
Target uint32 `json:"HardTarget"`
} `json:"challenge_peak_boss_config"`
}
if err := json.Unmarshal(raw, &table); err == nil {
for _, row := range table.Rows {
value := data.Peak[itoa(int(row.ID))]
value.IsBoss = true
value.HardEventID, value.HardBuffs, value.HardTarget = lastResourceID(row.Events), row.Buffs, row.Target
data.Peak[itoa(int(row.ID))] = value
}
}
}
if raw, err := common.ReadResourceFile("resources/ChallengePeakGroupConfig.json"); err == nil {
var table struct {
Rows []struct {
ID uint32 `json:"ID"`
Pre []uint32 `json:"PreLevelIDList"`
Boss uint32 `json:"BossLevelID"`
} `json:"challenge_peak_group_config"`
}
if err := json.Unmarshal(raw, &table); err == nil {
for _, row := range table.Rows {
for _, id := range append(row.Pre, row.Boss) {
key := itoa(int(id))
value := data.Peak[key]
value.GroupID = row.ID
data.Peak[key] = value
}
}
}
}
}
func lastResourceID(ids []uint32) uint32 {
if len(ids) == 0 {
return 0
}
return ids[len(ids)-1]
}
func getChallengeStagesList() []uint32 {
cd := getChallengeData()
if len(cd.StagesList) > 0 {
@@ -206,6 +379,10 @@ func init() {
handle("GetCurChallenge", func(s *Session, req *goproto.GetCurChallengeCsReq, rsp *goproto.GetCurChallengeScRsp) error {
rsp.Retcode = 0
if run := s.ChallengeRun; run != nil && !run.Tierce && !run.Peak {
rsp.CurChallenge = run.Cur
rsp.LineupList = []*goproto.LineupInfo{run.activeLineup(0)}
}
return nil
})
@@ -274,8 +451,22 @@ func init() {
})
handle("GetCurChallengePeak", func(s *Session, req *goproto.GetCurChallengePeakCsReq, rsp *goproto.GetCurChallengePeakScRsp) error {
// Match upstream's empty current-state response until a Peak is active.
rsp.Retcode = 0
if run := s.ChallengeRun; run != nil && run.Peak {
rsp.PeakId, rsp.BossBuffId, rsp.HasPassed = run.ID, run.PeakBuff, run.Finished && run.Win
}
return nil
})
handle("SetChallengePeakBossHardMode", func(s *Session, req *goproto.SetChallengePeakBossHardModeCsReq, rsp *goproto.SetChallengePeakBossHardModeScRsp) error {
if s.PeakHardModes == nil {
s.PeakHardModes = map[uint32]bool{}
}
s.PeakHardModes[req.PeakGroupId] = req.IsHardMode
rsp.PeakGroupId, rsp.IsHardMode = req.PeakGroupId, req.IsHardMode
return nil
})
handle("ConfirmChallengePeakSettle", func(s *Session, req *goproto.ConfirmChallengePeakSettleCsReq, rsp *goproto.ConfirmChallengePeakSettleScRsp) error {
rsp.PeakId, rsp.IOBCIMIPEOM = req.PeakId, req.IOBCIMIPEOM
return nil
})
+191 -296
View File
@@ -2,7 +2,6 @@ package gameserver
import (
"fmt"
"sort"
"google.golang.org/protobuf/proto"
@@ -22,6 +21,7 @@ type decodedChallengeStart struct {
thirdAvatars []uint32
stageInfoList [][]uint32
buffID uint32
buffIDs []uint32
}
// ---------------------------------------------------------------------------
@@ -62,51 +62,6 @@ func skipWireField(wireType uint32, buf *[]byte) {
}
}
func mapChallengeStageID(id uint32) uint32 {
cd := getChallengeData()
if _, ok := cd.Challenges[id]; ok {
return id
}
if _, ok := cd.Tierce[id]; ok {
return id
}
switch {
case id >= 5400 && id <= 5700: // MoC 1035/1036 -> 5301..5312
rem := id % 100
if rem < 1 {
rem = 1
}
if rem > 12 {
rem = 12
}
return 5300 + rem
case id >= 20200 && id <= 20300: // Pure Fiction -> 20251..20254
rem := id % 10
if rem < 1 {
rem = 1
}
if rem > 4 {
rem = 4
}
return 20250 + rem
case id >= 30200 && id <= 30300: // Apocalyptic Shadow -> 30201..30204
rem := id % 10
if rem < 1 {
rem = 1
}
if rem > 4 {
rem = 4
}
return 30200 + rem
case id > 1:
if _, ok := cd.Challenges[id]; !ok {
return id - 1
}
return id
}
return id
}
func decodeStartChallengeReq(payload []byte) decodedChallengeStart {
var pbReq goproto.StartChallengeCsReq
if err := proto.Unmarshal(payload, &pbReq); err != nil {
@@ -131,9 +86,11 @@ func decodeStartChallengeReq(payload []byte) decodedChallengeStart {
if pbReq.StageInfo != nil {
if info := pbReq.StageInfo.BossInfo; info != nil {
req.buffID = info.BuffOne
req.buffIDs = []uint32{info.BuffOne, info.BuffTwo}
}
if info := pbReq.StageInfo.StoryInfo; info != nil && req.buffID == 0 {
req.buffID = info.BuffOne
req.buffIDs = []uint32{info.BuffOne, info.BuffTwo}
}
}
return req
@@ -147,6 +104,7 @@ func decodeStartChallengeTierceReq(payload []byte) decodedChallengeStart {
req := decodedChallengeStart{challengeID: pbReq.ChallengeId,
stageIndex: pbReq.FINOCEGDBDI, isSingleStage: pbReq.MPKMIKBFEHG}
for index, stage := range pbReq.LineupTierceList {
req.buffIDs = append(req.buffIDs, stage.BuffId)
var avatars []uint32
for _, avatar := range stage.TierceAvatarIdList {
if avatar.Id != 0 {
@@ -170,13 +128,15 @@ func decodeStartChallengeTierceReq(payload []byte) decodedChallengeStart {
return req
}
func decodeSetChallengeTierceLineupReq(payload []byte) (uint32, [][]uint32) {
func decodeSetChallengeTierceLineupReq(payload []byte) (uint32, [][]uint32, []uint32) {
var req goproto.SetChallengeTierceLineupCsReq
if err := proto.Unmarshal(payload, &req); err != nil {
return 0, nil
return 0, nil, nil
}
var stages [][]uint32
var buffs []uint32
for _, stage := range req.StageInfoList {
buffs = append(buffs, stage.BuffId)
var avatars []uint32
for _, avatar := range stage.TierceAvatarIdList {
if avatar.Id != 0 {
@@ -185,54 +145,70 @@ func decodeSetChallengeTierceLineupReq(payload []byte) (uint32, [][]uint32) {
}
stages = append(stages, avatars)
}
return req.ChallengeId, stages
return req.ChallengeId, stages, buffs
}
// ---------------------------------------------------------------------------
// config setup shared by start handlers
// ---------------------------------------------------------------------------
func setChallengeBattleConfig(player *common.FreesrData, avatars []uint32, challengeID uint32, eventID uint32, buff uint32) {
customLineup := map[uint32]uint32{}
for i, aid := range avatars {
customLineup[uint32(i)] = aid
// Build challenge configuration from the selected resource stage, never from
// Freesr custom monsters. Only the current session owns this configuration.
func setChallengeBattleConfig(s *Session, avatars []uint32, challengeID, eventID uint32, buffs []uint32) error {
stage, ok := getChallengeData().Stages[eventID]
if !ok || len(stage.Monsters) == 0 {
return fmt.Errorf("challenge stage %d has no resource waves", eventID)
}
player.BattleConfig.CustomBattleLineup = customLineup
player.BattleConfig.StageID = eventID
isPF := challengeID >= 20000 && challengeID < 30000
isAS := challengeID >= 30000
if isPF {
player.BattleConfig.CycleCount = 4
player.BattleConfig.BattleType = common.BattleTypePF
} else if isAS {
player.BattleConfig.CycleCount = 30
player.BattleConfig.BattleType = common.BattleTypeAS
} else {
player.BattleConfig.CycleCount = 30
player.BattleConfig.BattleType = common.BattleTypeDefault
config := common.BattleConfig{StageID: eventID, CustomBattleLineup: map[uint32]uint32{}}
for i, id := range avatars {
config.CustomBattleLineup[uint32(i)] = id
}
if sb, ok := getChallengeData().Stages[eventID]; ok {
var waves [][]common.Monster
for _, wave := range sb.Monsters {
var ms []common.Monster
for _, mid := range wave {
ms = append(ms, common.Monster{Level: sb.Level, MonsterID: mid})
}
waves = append(waves, ms)
switch {
case challengeID >= 20000 && challengeID < 30000:
config.BattleType = common.BattleTypePF
case challengeID >= 30000:
config.BattleType = common.BattleTypeAS
default:
config.BattleType = common.BattleTypeDefault
}
// ChallengeCountDown is supplied by the installed challenge table.
configID := challengeID
if tierce, ok := getChallengeData().Tierce[challengeID]; ok {
configID = tierce.PreChallengeID
}
config.CycleCount = getChallengeData().Countdowns[configID]
if config.CycleCount == 0 {
// AS/PF rows omit ChallengeCountDown. Their limits come from the
// sourced challenge rules, not the user's custom battle configuration.
rules, err := loadBattleRules()
if err != nil {
return err
}
config.CycleCount = rules.ChallengeRoundLimits[config.BattleType]
if config.CycleCount == 0 {
return fmt.Errorf("missing challenge round limit for %s", config.BattleType)
}
player.BattleConfig.Monsters = waves
}
if buff != 0 {
player.BattleConfig.Blessings = []common.BattleBuffJson{{
ID: buff,
Level: 1,
}}
for _, wave := range stage.Monsters {
if len(wave) == 0 {
return fmt.Errorf("challenge stage %d has an empty wave", eventID)
}
monsters := make([]common.Monster, 0, len(wave))
for _, id := range wave {
monsters = append(monsters, common.Monster{Level: stage.Level, MonsterID: id})
}
config.Monsters = append(config.Monsters, monsters)
}
player.SavePersistent()
seen := map[uint32]bool{}
for _, id := range buffs {
if id != 0 && !seen[id] {
config.Blessings = append(config.Blessings, common.BattleBuffJson{ID: id, Level: 1})
seen[id] = true
}
}
s.ChallengeBattleConfig = &config
SceneDebugLog("[CHALLENGE] battle source=resource challenge=%d stage=%d waves=%v buffs=%v", challengeID, eventID, stage.Monsters, buffs)
return nil
}
func lineupInfoFromAvatars(avatars []uint32, planeID uint32, extraType goproto.ExtraLineupType, index uint32) *goproto.LineupInfo {
@@ -257,7 +233,7 @@ func challengeAvatarsFallback(s *Session) []uint32 {
}
return out
}
return []uint32{1304, 1313, 1406, 1004}
return nil
}
func dedupeTruncate4(in []uint32) []uint32 {
@@ -291,36 +267,32 @@ func handleStartChallenge(s *Session, payload []byte) error {
// cap team at 4 (client TeamManager IndexOutOfRange otherwise)
chosenAvatars = dedupeTruncate4(chosenAvatars)
baseID := mapChallengeStageID(req.challengeID)
baseID := req.challengeID
cd := getChallengeData()
entrance, group, monster, event, buff := uint32(3000101), uint32(5), uint32(4033010), uint32(30124011), uint32(0)
if c, ok := cd.Challenges[baseID]; ok {
entrance, group, monster, event = c.Entrance, c.Group1, c.Monster1, c.Event1
if req.buffID != 0 {
buff = req.buffID
} else {
buff = c.Buff
}
} else if t, ok := cd.Tierce[baseID]; ok {
entrance, group, monster, event = t.Entrance, t.Group, t.Monster, t.Event
buff = req.buffID
stage, ok := cd.Challenges[baseID]
if !ok {
return s.Send(&goproto.StartChallengeScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
if res, err := common.GameRes(); err == nil && !mapHasEntry(res, entrance) {
Log("[CHALLENGE] Arena entrance missing in res.json, fallback to 3000101")
entrance = 3000101
entrance, group, monster, event := stage.Entrance, stage.Group1, stage.Monster1, stage.Event1
buff := req.buffID
if buff == 0 {
buff = stage.Buff
}
Log("[CHALLENGE] Start: challenge_id=" + itoa(int(req.challengeID)) + " (mapped " + itoa(int(baseID)) + ")")
Log("[CHALLENGE] Arena: entrance=" + itoa(int(entrance)) + " group=" + itoa(int(group)) + " monster=" + itoa(int(monster)) + " event=" + itoa(int(event)))
if player := s.JSON(); player != nil {
setChallengeBattleConfig(player, chosenAvatars, req.challengeID, event, buff)
previousConfig := s.ChallengeBattleConfig
if err := setChallengeBattleConfig(s, chosenAvatars, req.challengeID, event, []uint32{buff, cd.Challenges[baseID].Buff}); err != nil {
SceneDebugLog("[CHALLENGE] start rejected: %v", err)
return s.Send(&goproto.StartChallengeScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
sceneInfo, _, err := loadChallengeScene(s, entrance, group, monster, event, chosenAvatars)
if err != nil {
return err
s.ChallengeBattleConfig = previousConfig
SceneDebugLog("[CHALLENGE] scene rejected: %v", err)
return s.Send(&goproto.StartChallengeScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
var lineups []*goproto.LineupInfo
@@ -331,16 +303,45 @@ func handleStartChallenge(s *Session, payload []byte) error {
secondAvatars = chosenAvatars
}
secondAvatars = dedupeTruncate4(secondAvatars)
lineups = append(lineups, lineupInfoFromAvatars(secondAvatars, sceneInfo.PlaneId, goproto.ExtraLineupType(1), 1))
lineups = append(lineups, lineupInfoFromAvatars(secondAvatars, sceneInfo.PlaneId, goproto.ExtraLineupType_ExtraLineupType_LineupChallenge2, 0))
curChallenge := &goproto.CurChallenge{
ChallengeId: req.challengeID,
Status: goproto.ChallengeStatus_CHALLENGE_DOING,
RoundCount: 0,
ScoreId: scoreIDFor(req.challengeID),
ScoreId: 0,
ScoreTwo: 0,
ExtraLineupType: goproto.ExtraLineupType(1),
}
// PearlSR 005d2c12 challenge_mgr.zig creates this mode-specific state
// before the Client recovers its challenge instance. Omitting it causes
// ChallengeBossInstance._SyncExtraInfo to dereference a missing message.
mazeBuff := cd.Challenges[baseID].Buff
buffs := make([]uint32, 0, 2)
for _, id := range []uint32{req.buffID, mazeBuff} {
if id != 0 {
buffs = append(buffs, id)
}
}
switch {
case req.challengeID > 20000 && req.challengeID < 30000:
curChallenge.StageInfo = &goproto.ChallengeCurBuffInfo{
CurStoryBuffs: &goproto.ChallengeStoryBuffList{BuffList: buffs},
}
case req.challengeID > 30000:
curChallenge.StageInfo = &goproto.ChallengeCurBuffInfo{
CurBossBuffs: &goproto.ChallengeBossBuffList{BuffList: buffs, ChallengeBossConst: 1},
}
}
rooms := []challengeRoom{resourceRoom(stage, false, chosenAvatars, req.buffID)}
if stage.Entrance2 != 0 && stage.Event2 != 0 {
var secondBuff uint32
if len(req.buffIDs) > 1 {
secondBuff = req.buffIDs[1]
}
rooms = append(rooms, resourceRoom(stage, true, secondAvatars, secondBuff))
}
s.ChallengeRun = &challengeRun{ID: req.challengeID, Rooms: rooms, Scores: make([]uint32, len(rooms)), Cur: curChallenge}
rsp := &goproto.StartChallengeScRsp{
Retcode: 0,
@@ -348,171 +349,68 @@ func handleStartChallenge(s *Session, payload []byte) error {
LineupList: lineups,
Scene: sceneInfo,
}
SceneDebugLog("[CHALLENGE] start id=%d mapped=%d entry=%d floor=%d world=%d mode=%d buff=%v storyState=%t bossState=%t", req.challengeID, baseID, sceneInfo.EntryId, sceneInfo.FloorId, sceneInfo.WorldId, sceneInfo.GameModeType, buffs, curChallenge.GetStageInfo().GetCurStoryBuffs() != nil, curChallenge.GetStageInfo().GetCurBossBuffs() != nil)
return s.Send(rsp)
}
func scoreIDFor(challengeID uint32) uint32 {
if challengeID >= 20000 && challengeID < 30000 {
return 40000
}
return 0
}
// handleStartChallengeTierce uses the current Tierce request schema.
func handleStartChallengeTierce(s *Session, payload []byte) error {
req := decodeStartChallengeTierceReq(payload)
if req.challengeID == 0 {
return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
baseID := mapChallengeStageID(req.challengeID)
baseID := req.challengeID
Log("handle_start_challenge_tierce: challenge_id=" + itoa(int(req.challengeID)) + " (mapped " + itoa(int(baseID)) + ")")
tierceLineupsMu.Lock()
if len(req.stageInfoList) > 0 {
tierceLineups[req.challengeID] = req.stageInfoList
if err := rememberTierceTeams(s, req.challengeID, req.stageInfoList, req.buffIDs, req.isSingleStage, req.stageIndex); err != nil {
return err
}
tierceLineupsMu.Unlock()
var chosenAvatars []uint32
switch req.stageIndex {
case 0:
chosenAvatars = req.firstAvatars
case 1:
if len(req.secondAvatars) > 0 {
chosenAvatars = req.secondAvatars
} else {
chosenAvatars = req.firstAvatars
}
case 2:
if len(req.thirdAvatars) > 0 {
chosenAvatars = req.thirdAvatars
} else {
chosenAvatars = req.firstAvatars
}
req.stageInfoList = s.TierceTeams[req.challengeID]
req.buffIDs = s.TierceBuffs[req.challengeID]
if int(req.stageIndex) < len(req.buffIDs) {
req.buffID = req.buffIDs[req.stageIndex]
}
if len(chosenAvatars) == 0 {
tierceLineupsMu.Lock()
if list, ok := tierceLineups[req.challengeID]; ok && int(req.stageIndex) < len(list) {
chosenAvatars = list[req.stageIndex]
}
tierceLineupsMu.Unlock()
if req.stageIndex >= tierceNodeCount {
return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
chosenAvatars := req.stageInfoList[req.stageIndex]
if len(chosenAvatars) == 0 {
chosenAvatars = challengeAvatarsFallback(s)
return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_LINEUP_IS_EMPTY)})
}
cd := getChallengeData()
entrance, group, monster, event, buff := uint32(3000101), uint32(5), uint32(4033010), uint32(30124011), uint32(0)
if req.stageIndex == 0 {
if t, ok := cd.Tierce[baseID]; ok {
entrance, group, monster, event, buff = t.Entrance, t.Group, t.Monster, t.Event, req.buffID
} else if c, ok2 := cd.Challenges[baseID]; ok2 {
entrance, group, monster, event = c.Entrance, c.Group1, c.Monster1, c.Event1
if req.buffID != 0 {
buff = req.buffID
} else {
buff = c.Buff
}
}
} else if req.stageIndex == 1 {
// Stage 2 (Node 2)
if c, ok := cd.Challenges[baseID-1]; ok && c.Entrance2 != 0 {
entrance, group, monster, event = c.Entrance2, c.Group2, c.Monster2, c.Event2
if req.buffID != 0 {
buff = req.buffID
} else {
buff = c.Buff
}
} else if c, ok2 := cd.Challenges[baseID]; ok2 && c.Entrance2 != 0 {
entrance, group, monster, event = c.Entrance2, c.Group2, c.Monster2, c.Event2
if req.buffID != 0 {
buff = req.buffID
} else {
buff = c.Buff
}
} else if t, ok3 := cd.Tierce[baseID]; ok3 {
entrance, group, monster, event, buff = t.Entrance, t.Group, t.Monster, t.Event, req.buffID
}
} else {
if t, ok := cd.Tierce[baseID]; ok {
entrance, group, monster, event, buff = t.Entrance, t.Group, t.Monster, t.Event, req.buffID
}
// PearlSR challenge_mgr.zig: Tierce nodes 0/1 use the preceding
// ChallengeMazeConfig's first/second node; node 2 uses Tierce config.
tierce, tierceOK := cd.Tierce[req.challengeID]
base, baseOK := cd.Challenges[tierce.PreChallengeID]
if !tierceOK || !baseOK || req.stageIndex > 2 {
return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
if res, err := common.GameRes(); err == nil && !mapHasEntry(res, entrance) {
Log("[CHALLENGE] Tierce arena entrance missing in res.json, fallback to 3000101")
entrance = 3000101
entrance, group, monster, event := base.Entrance, base.Group1, base.Monster1, base.Event1
switch req.stageIndex {
case 1:
entrance, group, monster, event = base.Entrance2, base.Group2, base.Monster2, base.Event2
case 2:
entrance, group, monster, event = tierce.Entrance, tierce.Group, tierce.Monster, tierce.Event
}
if player := s.JSON(); player != nil {
setChallengeBattleConfig(player, chosenAvatars, req.challengeID, event, buff)
previousConfig := s.ChallengeBattleConfig
if err := setChallengeBattleConfig(s, chosenAvatars, req.challengeID, event, []uint32{req.buffID, base.Buff}); err != nil {
SceneDebugLog("[CHALLENGE] tierce start rejected: %v", err)
return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
sceneInfo, _, err := loadChallengeScene(s, entrance, group, monster, event, chosenAvatars)
if err != nil {
return err
s.ChallengeBattleConfig = previousConfig
SceneDebugLog("[CHALLENGE] tierce scene rejected: %v", err)
return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
// build all lineups for tierce_info
var allLineups [][]uint32
firstLineup := req.firstAvatars
if len(firstLineup) == 0 && len(req.stageInfoList) > 0 {
firstLineup = req.stageInfoList[0]
}
if len(firstLineup) == 0 {
tierceLineupsMu.Lock()
if list, ok := tierceLineups[req.challengeID]; ok && len(list) > 0 {
firstLineup = list[0]
}
tierceLineupsMu.Unlock()
}
if len(firstLineup) == 0 {
firstLineup = chosenAvatars
}
allLineups = append(allLineups, firstLineup)
if !req.isSingleStage {
secondLineup := req.secondAvatars
if len(secondLineup) == 0 && len(req.stageInfoList) > 1 {
secondLineup = req.stageInfoList[1]
}
if len(secondLineup) == 0 {
tierceLineupsMu.Lock()
if list, ok := tierceLineups[req.challengeID]; ok && len(list) > 1 {
secondLineup = list[1]
}
tierceLineupsMu.Unlock()
}
if len(secondLineup) == 0 {
secondLineup = firstLineup
}
allLineups = append(allLineups, secondLineup)
thirdLineup := req.thirdAvatars
if len(thirdLineup) == 0 && len(req.stageInfoList) > 2 {
thirdLineup = req.stageInfoList[2]
}
if len(thirdLineup) == 0 {
tierceLineupsMu.Lock()
if list, ok := tierceLineups[req.challengeID]; ok && len(list) > 2 {
thirdLineup = list[2]
}
tierceLineupsMu.Unlock()
}
if len(thirdLineup) > 0 || req.stageIndex == 2 {
if len(thirdLineup) == 0 {
thirdLineup = firstLineup
}
allLineups = append(allLineups, thirdLineup)
}
}
var tierceLineupList []*goproto.LineupInfo
for stIdx, stageAvatars := range allLineups {
tierceLineupList = append(tierceLineupList, lineupInfoFromAvatars(stageAvatars, sceneInfo.PlaneId, goproto.ExtraLineupType(1), uint32(stIdx)))
}
// The response carries the active node's lineup, as upstream does.
// Other nodes are cached for subsequent requests, not installed as active teams.
tierceLineupList := []*goproto.LineupInfo{lineupInfoFromAvatars(chosenAvatars, sceneInfo.PlaneId, goproto.ExtraLineupType(1), 0)}
SceneDebugLog("[CHALLENGE] tierce id=%d node=%d entry=%d floor=%d group=%d event=%d avatars=%v", req.challengeID, req.stageIndex, entrance, sceneInfo.FloorId, group, event, chosenAvatars)
// Generated response resolves its command ID through Session.Send.
rsp := &goproto.StartChallengeTierceScRsp{
@@ -525,18 +423,52 @@ func handleStartChallengeTierce(s *Session, payload []byte) error {
MPKMIKBFEHG: req.isSingleStage,
},
}
teams := req.stageInfoList
if len(teams) == 0 {
teams = s.TierceTeams[req.challengeID]
}
rooms := make([]challengeRoom, tierceNodeCount)
for node := range rooms {
var team []uint32
if node < len(teams) {
team = teams[node]
}
if uint32(node) == req.stageIndex {
team = chosenAvatars
}
var selectedBuff uint32
if node < len(req.buffIDs) {
selectedBuff = req.buffIDs[node]
}
rooms[node] = resourceRoom(base, node == 1, team, selectedBuff)
if node == 2 {
rooms[node].Entrance, rooms[node].Group, rooms[node].Monster, rooms[node].Event = tierce.Entrance, tierce.Group, tierce.Monster, tierce.Event
}
}
s.ChallengeRun = &challengeRun{ID: req.challengeID, Tierce: true, Single: req.isSingleStage, Node: req.stageIndex, Rooms: rooms, Scores: make([]uint32, len(rooms)), TierceCur: rsp.CurTierceChallenge}
// A single-node retry keeps the other nodes' measured best results.
if req.isSingleStage {
if saved := s.JSON().TierceProgress[req.challengeID]; saved != nil {
for node, result := range saved.Nodes {
if node < tierceNodeCount && node != req.stageIndex {
s.ChallengeRun.Scores[node] = result.Score
s.ChallengeRun.Results = append(s.ChallengeRun.Results, tierceNodeResult(node, result, s.TierceTeams[req.challengeID][node]))
}
}
}
}
rsp.CurTierceChallenge.LineupList = []*goproto.LineupInfo{s.ChallengeRun.activeLineup(sceneInfo.PlaneId)}
return s.Send(rsp)
}
func handleSetChallengeTierceLineup(s *Session, payload []byte) error {
challengeID, stages := decodeSetChallengeTierceLineupReq(payload)
challengeID, stages, buffs := decodeSetChallengeTierceLineupReq(payload)
if challengeID == 0 {
return s.Send(&goproto.SetChallengeTierceLineupScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
if len(stages) > 0 {
tierceLineupsMu.Lock()
tierceLineups[challengeID] = stages
tierceLineupsMu.Unlock()
if err := rememberTierceTeams(s, challengeID, stages, buffs, false, 0); err != nil {
return err
}
return s.Send(&goproto.SetChallengeTierceLineupScRsp{})
}
@@ -556,7 +488,6 @@ func leaveChallengeScene(s *Session, response proto.Message) error {
}
// loadChallengeScene preserves the player's saved overworld scene.
entryID := player.Scene.EntryID
player.BattleConfig.CustomBattleLineup = nil
CURRENT_BATTLE.Delete(s.Token)
if err := s.Send(&goproto.QuitBattleScNotify{}); err != nil {
return err
@@ -578,51 +509,15 @@ func handleTakeChallengeReward(s *Session, payload []byte) error {
return s.Send(&goproto.TakeChallengeRewardScRsp{GroupId: req.GroupId})
}
// buildGetChallengeTierceDataScRspReal builds the generated current response.
func buildGetChallengeTierceDataScRspReal() *goproto.GetChallengeTierceDataScRsp {
cd := getChallengeData()
type tierceEntry struct {
id uint32
targets []uint32
score uint32
}
var tierceStages []tierceEntry
for id, data := range cd.Tierce {
var targets []uint32
switch {
case len(data.Targets) > 0:
targets = data.Targets
case id >= 30000:
targets = []uint32{5001, 5002, 5003, 5000}
case id >= 20000:
targets = []uint32{4001, 4002, 4003, 4000}
default:
targets = []uint32{601, 602, 603, 600}
// Return saved teams and measured scores, including genuinely unplayed stages.
func buildGetChallengeTierceDataScRspReal(s *Session) *goproto.GetChallengeTierceDataScRsp {
rsp := &goproto.GetChallengeTierceDataScRsp{}
for _, id := range sortedU32Keys2(getChallengeData().Tierce) {
var saved *common.TierceProgress
if p := s.JSON(); p != nil {
saved = p.TierceProgress[id]
}
score := data.Score
if score == 0 {
if id >= 20000 && id < 30000 {
score = 40000
} else {
score = 4000
}
}
tierceStages = append(tierceStages, tierceEntry{id, targets, score})
}
sort.Slice(tierceStages, func(i, j int) bool { return tierceStages[i].id < tierceStages[j].id })
var groups []*goproto.ChallengeTierceGroup
for _, e := range tierceStages {
groups = append(groups, &goproto.ChallengeTierceGroup{
ChallengeId: e.id,
IsWin: true,
FinishedTargetList: e.targets,
})
}
return &goproto.GetChallengeTierceDataScRsp{
ChallengeTierceGroups: groups,
Retcode: 0,
rsp.ChallengeTierceGroups = append(rsp.ChallengeTierceGroups, buildTierceGroup(id, saved))
}
return rsp
}
+46 -119
View File
@@ -3,8 +3,6 @@ package gameserver
import (
"errors"
"fmt"
"math"
"sync"
"google.golang.org/protobuf/proto"
@@ -16,11 +14,6 @@ import (
// tierce lineup store + challenge scene loading
// ---------------------------------------------------------------------------
var (
tierceLineups = map[uint32][][]uint32{}
tierceLineupsMu sync.Mutex
)
// loadChallengeScene is the Go port of scene.rs load_challenge_scene.
func loadChallengeScene(s *Session, entryID, targetGroupID, monsterID, eventID uint32, avatarIDs []uint32) (*goproto.SceneInfo, *goproto.MotionInfo, error) {
player := s.JSON()
@@ -41,101 +34,38 @@ func loadChallengeScene(s *Session, entryID, targetGroupID, monsterID, eventID u
return nil, nil, err
}
// 1. locate the boss monster position from scene config
var monsPos common.Position
var instID uint32 = 1
fallbackPos := common.Position{X: -61000, Y: -2141, Z: -170700, RotY: 90000}
if grp, okg := scene.Scenes[targetGroupID]; okg && len(grp.Monsters) > 0 {
m := grp.Monsters[0]
monsPos = common.Position{X: m.Pos.X, Y: m.Pos.Y, Z: m.Pos.Z, RotY: m.Rot.Y}
instID = m.InstID
} else {
found := false
for _, gid := range sortedU32Keys2(scene.Scenes) {
g := scene.Scenes[gid]
if len(g.Monsters) > 0 {
m := g.Monsters[0]
monsPos = common.Position{X: m.Pos.X, Y: m.Pos.Y, Z: m.Pos.Z, RotY: m.Rot.Y}
instID = m.InstID
found = true
break
}
}
if !found {
monsPos = fallbackPos
}
// Use the selected group's resource placement, with no guessed arena
// height or offset towards world origin (arena floors need not be there).
group, ok := scene.Scenes[targetGroupID]
if !ok || len(group.Monsters) == 0 {
return nil, nil, fmt.Errorf("challenge entrance %d group %d has no monster placement", entryID, targetGroupID)
}
// 2. position the player safely between boss and arena center
// 2. detect arena floor platform height from props in scene
// In some challenge arenas (e.g. entrance 3012602 / AS Starward 30225),
// the boss monster pos in config is placed at Y = -193081 (22m below the floor),
// while the arena stage prop (102259) is at Y = -170770.
// Spawning at monsPos.Y puts the player under the map into the void.
hasFloorProp := false
var floorY int32 = -2147483648
for _, g := range scene.Scenes {
for _, p := range g.Props {
dx := float64(p.Pos.X)
dz := float64(p.Pos.Z)
if math.Sqrt(dx*dx+dz*dz) < 150000.0 { // within 150 meters of center
if p.Pos.Y > floorY {
floorY = p.Pos.Y
hasFloorProp = true
}
}
}
}
if hasFloorProp && floorY > monsPos.Y {
monsPos.Y = floorY
placement := group.Monsters[0]
monsPos := common.Position{X: placement.Pos.X, Y: placement.Pos.Y, Z: placement.Pos.Z, RotY: placement.Rot.Y}
instID := placement.InstID
playerMotion := monsPos.ToMotionInfo()
spawnSource := "monster-placement"
if anchors := res.SceneAnchors[entryID]; len(anchors) > 0 {
anchor := anchors[0] // PearlSR scene_mgr.zig getAnchorMotion.
playerMotion = (&common.Position{X: anchor.Pos.X, Y: anchor.Pos.Y, Z: anchor.Pos.Z, RotY: anchor.Rot.Y}).ToMotionInfo()
spawnSource = "anchor"
}
SceneDebugLog("[CHALLENGE] spawn entry=%d floor=%d source=%s player=%v monster=%v", entryID, floorID, spawnSource, playerMotion, monsPos)
centerVX := float64(-monsPos.X)
centerVZ := float64(-monsPos.Z)
distToCenter := math.Sqrt(centerVX*centerVX + centerVZ*centerVZ)
s.resetTechniqueRoom()
var spawnX, spawnY, spawnZ, spawnRotY, bossRotY int32
if distToCenter > 1000.0 {
nx := centerVX / distToCenter
nz := centerVZ / distToCenter
offset := math.Min(6000.0, distToCenter*0.6)
spawnX = monsPos.X + int32(nx*offset)
spawnZ = monsPos.Z + int32(nz*offset)
pDeg := int32(math.Round(math.Atan2(-nx, -nz) * 180.0 / math.Pi))
if pDeg < 0 {
pDeg += 360
}
bDeg := int32(math.Round(math.Atan2(nx, nz) * 180.0 / math.Pi))
if bDeg < 0 {
bDeg += 360
}
spawnY = monsPos.Y
spawnRotY = pDeg * 1000
bossRotY = bDeg * 1000
} else {
spawnX, spawnY, spawnZ, spawnRotY, bossRotY = 0, monsPos.Y, 6000, 180000, 0
}
monsPos.RotY = bossRotY
playerMotion := &goproto.MotionInfo{
Pos: &goproto.Vector{X: spawnX, Y: spawnY, Z: spawnZ},
Rot: &goproto.Vector{Y: spawnRotY},
}
// NOTE: keep player.Scene intact so returning to overworld or relogging spawns in overworld
sceneInfo := &goproto.SceneInfo{
FloorId: floorID,
PlaneId: planeID,
EntryId: entryID,
GameModeType: scene.PlaneType,
GameModeType: sceneModeChallenge,
LeaderEntityId: 1,
WorldId: scene.WorldID,
SceneIdentifier: &goproto.SceneIdentifier{FloorId: floorID},
SceneMissionInfo: &goproto.MissionStatusBySceneInfo{},
}
if sceneInfo.WorldId == 100 {
sceneInfo.WorldId = 501
FloorSavedData: scene.SavedValues,
}
sceneInfo.LightenSectionList = scene.Sections
@@ -144,7 +74,7 @@ func loadChallengeScene(s *Session, entryID, targetGroupID, monsterID, eventID u
s.PropEntities = map[uint32]PropEntry{}
for _, gid := range sortedU32Keys2(scene.Scenes) {
group := scene.Scenes[gid]
groupInfo := &goproto.SceneEntityGroupInfo{GroupId: gid}
groupInfo := &goproto.SceneEntityGroupInfo{State: 1, GroupId: gid}
for i := range group.Props {
prop := &group.Props[i]
propEntityID++
@@ -165,7 +95,7 @@ func loadChallengeScene(s *Session, entryID, targetGroupID, monsterID, eventID u
}
// single boss monster (entity 30001, registered for SceneCastSkill battles)
monsterGroup := &goproto.SceneEntityGroupInfo{GroupId: targetGroupID}
monsterGroup := &goproto.SceneEntityGroupInfo{State: 1, GroupId: targetGroupID}
s.MonsterEntities = map[uint32]uint32{30001: eventID}
s.ChallengeStageID = eventID
monsterGroup.EntityList = append(monsterGroup.EntityList, &goproto.SceneEntityInfo{
@@ -203,7 +133,7 @@ func loadChallengeScene(s *Session, entryID, targetGroupID, monsterID, eventID u
AvatarType: goproto.AvatarType_AVATAR_FORMAL_TYPE,
BaseAvatarId: aid,
MapLayer: 0,
Uid: 1337,
Uid: playerUID,
},
},
})
@@ -229,10 +159,6 @@ func handleStartChallengePeak(s *Session, req *goproto.StartChallengePeakCsReq,
}
eventID := stage.EventID
monsterID := stage.MonsterID
buff := stage.DefaultBuff
if req.BossBuffId != 0 {
buff = req.BossBuffId
}
Log("[PEAK] Start: peak_id=" + itoa(int(peakID)) + " event=" + itoa(int(eventID)) + " monster=" + itoa(int(monsterID)))
avatars := dedupeTruncate4(req.PeakAvatarIdList)
@@ -240,34 +166,35 @@ func handleStartChallengePeak(s *Session, req *goproto.StartChallengePeakCsReq,
avatars = challengeAvatarsFallback(s)
}
if player := s.JSON(); player != nil {
customLineup := map[uint32]uint32{}
for i, aid := range avatars {
customLineup[uint32(i)] = aid
}
player.BattleConfig.CustomBattleLineup = customLineup
player.BattleConfig.StageID = eventID
player.BattleConfig.CycleCount = 30
player.BattleConfig.BattleType = common.BattleTypeDefault
player.BattleConfig.Monsters = [][]common.Monster{{{Level: 68, MonsterID: monsterID}}}
if buff != 0 {
player.BattleConfig.Blessings = []common.BattleBuffJson{{ID: buff, Level: 1}}
}
player.SavePersistent()
buffs := append([]uint32(nil), stage.Buffs...)
if stage.IsBoss && s.PeakHardModes[stage.GroupID] {
eventID = stage.HardEventID
buffs = append([]uint32(nil), stage.HardBuffs...)
}
// standard arena 3000101 (group 2 = boss position)
sceneInfo, _, err := loadChallengeScene(s, 3000101, 2, monsterID, eventID, avatars)
if err != nil {
Log("[PEAK] Failed to load peak arena scene!")
if req.BossBuffId != 0 {
buffs = append(buffs, req.BossBuffId)
}
if stage.Entrance == 0 || stage.Group == 0 || eventID == 0 || len(avatars) == 0 {
rsp.Retcode = uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)
return nil
}
custom := map[uint32]uint32{}
for i, aid := range avatars {
custom[uint32(i)] = aid
previousConfig := s.ChallengeBattleConfig
if err := setChallengeBattleConfig(s, avatars, peakID, eventID, buffs); err != nil {
SceneDebugLog("[PEAK] stage rejected: %v", err)
rsp.Retcode = uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)
return nil
}
lineupInfo := common.ToLineupInfo(custom)
s.ChallengeBattleConfig.BattleType = common.BattleTypeAA
sceneInfo, _, err := loadChallengeScene(s, stage.Entrance, stage.Group, monsterID, eventID, avatars)
if err != nil {
s.ChallengeBattleConfig = previousConfig
SceneDebugLog("[PEAK] scene rejected: %v", err)
rsp.Retcode = uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)
return nil
}
s.ChallengeRun = &challengeRun{ID: peakID, Peak: true, Single: true, Rooms: []challengeRoom{{Entrance: stage.Entrance, Group: stage.Group, Monster: monsterID, Event: eventID, Avatars: avatars, Buffs: buffs}}, Scores: make([]uint32, 1), PeakBuff: req.BossBuffId}
lineupInfo := lineupInfoFromAvatars(avatars, sceneInfo.PlaneId, goproto.ExtraLineupType_ExtraLineupType_LineupChallenge, 0)
return s.Send(&goproto.EnterSceneByServerScNotify{
Scene: sceneInfo,
+191 -85
View File
@@ -3,110 +3,212 @@ package gameserver
import (
"gac-hkrpg-go/internal/common"
goproto "gac-hkrpg-go/protocol/proto"
"strings"
"unicode/utf16"
"unicode/utf8"
)
func commonToLineupAvatars(f *common.FreesrData) []*goproto.LineupAvatar {
return common.ToLineupAvatars(f)
}
func init() {
handle("GetAllLineupData", func(s *Session, req *goproto.GetAllLineupDataCsReq, rsp *goproto.GetAllLineupDataScRsp) error {
player := s.JSON()
if player == nil {
return nil
handle("GetAllLineupData", func(s *Session, _ *goproto.GetAllLineupDataCsReq, rsp *goproto.GetAllLineupDataScRsp) error {
var err error
rsp.LineupList, err = allPresetLineups(s)
if p := s.JSON(); p != nil {
rsp.CurIndex = p.ActiveTeamPreset
}
rsp.LineupList = append(rsp.LineupList, &goproto.LineupInfo{
ExtraLineupType: goproto.ExtraLineupType_ExtraLineupType_LineupNone,
Name: "Squad 1",
Mp: 5,
MaxMp: 5,
AvatarList: commonToLineupAvatars(player),
})
return err
})
handle("GetCurLineupData", func(s *Session, _ *goproto.GetCurLineupDataCsReq, rsp *goproto.GetCurLineupDataScRsp) error {
rsp.Lineup = s.techniqueLineup()
return nil
})
handle("GetCurLineupData", func(s *Session, req *goproto.GetCurLineupDataCsReq, rsp *goproto.GetCurLineupDataScRsp) error {
player := s.JSON()
if player == nil {
return nil
}
rsp.Lineup = &goproto.LineupInfo{
ExtraLineupType: goproto.ExtraLineupType_ExtraLineupType_LineupNone,
Name: "Squad 1",
Mp: 5,
MaxMp: 5,
AvatarList: commonToLineupAvatars(player),
}
return nil
})
handle("JoinLineup", func(s *Session, req *goproto.JoinLineupCsReq, rsp *goproto.JoinLineupScRsp) error {
player := s.JSON()
if player == nil {
code, err := s.presetEditCode(req.Index, req.ExtraLineupType, req.IsVirtual)
rsp.Retcode = code
if err != nil || code != 0 {
return err
}
saved := s.JSON().TeamPreset(req.Index)
saved.Avatars[req.Slot] = req.BaseAvatarId
if code := validatePresetMembers(s.JSON(), saved.Avatars); code != 0 {
rsp.Retcode = code
return nil
}
player.Lineups[req.Slot] = req.BaseAvatarId
player.SavePersistent()
return refreshLineup(s)
return s.savePreset(req.Index, saved)
})
handle("ReplaceLineup", func(s *Session, req *goproto.ReplaceLineupCsReq, rsp *goproto.ReplaceLineupScRsp) error {
player := s.JSON()
if player == nil {
code, err := s.presetEditCode(req.Index, req.ExtraLineupType, req.IsVirtual)
rsp.Retcode = code
if err != nil || code != 0 {
return err
}
saved := s.JSON().TeamPreset(req.Index)
saved.Avatars = map[uint32]uint32{}
for _, a := range req.LineupSlotList {
saved.Avatars[a.Slot] = a.Id
}
if code := validatePresetMembers(s.JSON(), saved.Avatars); code != 0 {
rsp.Retcode = code
return nil
}
for _, item := range req.LineupSlotList {
player.Lineups[item.Slot] = item.Id
}
player.SavePersistent()
return refreshLineup(s)
})
handle("SwapLineup", func(s *Session, req *goproto.SwapLineupCsReq, rsp *goproto.SwapLineupScRsp) error {
player := s.JSON()
if player == nil {
if req.Index == s.JSON().ActiveTeamPreset && len(common.ToLineupAvatars(&common.FreesrData{Lineups: saved.Avatars, Avatars: s.JSON().Avatars})) == 0 {
rsp.Retcode = uint32(goproto.Retcode_RET_LINEUP_IS_EMPTY)
return nil
}
// MPIEGFCNBEN / ENBIBCPPLDC are the obfuscated src/dst slots; the swap
// is symmetric so the mapping direction doesn't matter.
srcSlot, dstSlot := req.MPIEGFCNBEN, req.ENBIBCPPLDC
srcAvatar := player.Lineups[srcSlot]
dstAvatar := player.Lineups[dstSlot]
player.Lineups[srcSlot] = dstAvatar
player.Lineups[dstSlot] = srcAvatar
player.SavePersistent()
return refreshLineup(s)
})
handle("SwitchLineupIndex", func(s *Session, req *goproto.SwitchLineupIndexCsReq, rsp *goproto.SwitchLineupIndexScRsp) error {
return refreshLineup(s)
})
handle("GetLineupAvatarData", func(s *Session, req *goproto.GetLineupAvatarDataCsReq, rsp *goproto.GetLineupAvatarDataScRsp) error {
player := s.JSON()
if player != nil {
for _, slot := range player.SortedLineupKeys() {
avatarID := player.Lineups[slot]
if avatarID == 0 {
continue
saved.LeaderSlot = req.LeaderSlot
if saved.Avatars[saved.LeaderSlot] == 0 {
for _, slot := range sortedU32Keys2(saved.Avatars) {
if saved.Avatars[slot] != 0 {
saved.LeaderSlot = slot
break
}
rsp.AvatarDataList = append(rsp.AvatarDataList, &goproto.LineupAvatarData{
AvatarType: goproto.AvatarType_AVATAR_FORMAL_TYPE,
Hp: 10000,
Id: avatarID,
})
}
}
rsp.Retcode = 0
return nil
return s.savePreset(req.Index, saved)
})
handle("SwapLineup", func(s *Session, req *goproto.SwapLineupCsReq, rsp *goproto.SwapLineupScRsp) error {
code, err := s.presetEditCode(req.Index, req.ExtraLineupType, req.IsVirtual)
rsp.Retcode = code
if err != nil || code != 0 {
return err
}
saved := s.JSON().TeamPreset(req.Index)
src, dst := req.MPIEGFCNBEN, req.ENBIBCPPLDC
saved.Avatars[src], saved.Avatars[dst] = saved.Avatars[dst], saved.Avatars[src]
if code := validatePresetMembers(s.JSON(), saved.Avatars); code != 0 {
rsp.Retcode = code
return nil
}
if saved.LeaderSlot == src {
saved.LeaderSlot = dst
} else if saved.LeaderSlot == dst {
saved.LeaderSlot = src
}
return s.savePreset(req.Index, saved)
})
handle("SwitchLineupIndex", func(s *Session, req *goproto.SwitchLineupIndexCsReq, rsp *goproto.SwitchLineupIndexScRsp) error {
code, err := s.presetEditCode(req.Index, goproto.ExtraLineupType_ExtraLineupType_LineupNone, false)
rsp.Retcode = code
if err != nil || code != 0 {
return err
}
if s.ChallengeRun != nil {
rsp.Retcode = uint32(goproto.Retcode_RET_LINEUP_NOT_ALLOW_EDIT)
return nil
}
p := s.JSON()
saved := p.TeamPreset(req.Index)
tmp := *p
tmp.Lineups = saved.Avatars
if len(common.ToLineupAvatars(&tmp)) == 0 {
rsp.Retcode = uint32(goproto.Retcode_RET_LINEUP_IS_EMPTY)
return nil
}
// Retain the outgoing team, including edits made through existing APIs.
if err := p.SaveTeamPreset(p.ActiveTeamPreset, p.TeamPreset(p.ActiveTeamPreset)); err != nil {
return err
}
previous := p.ActiveTeamPreset
p.ActiveTeamPreset = req.Index
if err := p.SaveTeamPreset(req.Index, saved); err != nil {
p.ActiveTeamPreset = previous
return err
}
rsp.Index = req.Index
return s.publishPreset(req.Index, saved)
})
handle("SetLineupName", func(s *Session, req *goproto.SetLineupNameCsReq, rsp *goproto.SetLineupNameScRsp) error {
code, err := s.presetEditCode(req.Index, goproto.ExtraLineupType_ExtraLineupType_LineupNone, false)
rsp.Retcode = code
if err != nil || code != 0 {
return err
}
rules, err := common.LoadTeamPresetRules()
if err != nil {
return err
}
name := strings.TrimSpace(req.Name)
if !utf8.ValidString(name) || name == "" || len(utf16.Encode([]rune(name))) > rules.MaxNameLength {
rsp.Retcode = uint32(goproto.Retcode_RET_LINEUP_NAME_FORMAT_ERROR)
return nil
}
saved := s.JSON().TeamPreset(req.Index)
saved.Name = name
rsp.Index = req.Index
rsp.Name = name
return s.savePreset(req.Index, saved)
})
handle("MarkPresetLineup", func(s *Session, req *goproto.MarkPresetLineupCsReq, rsp *goproto.MarkPresetLineupScRsp) error {
code, err := s.presetEditCode(req.Index, goproto.ExtraLineupType_ExtraLineupType_LineupNone, false)
rsp.Retcode = code
if err != nil || code != 0 {
return err
}
saved := s.JSON().TeamPreset(req.Index)
saved.Favourite = req.IECKKCHJMLP
rsp.Index = req.Index
rsp.IECKKCHJMLP = req.IECKKCHJMLP
return s.savePreset(req.Index, saved)
})
handle("QuitLineup", func(s *Session, req *goproto.QuitLineupCsReq, rsp *goproto.QuitLineupScRsp) error {
code, err := s.presetEditCode(req.Index, req.ExtraLineupType, req.IsVirtual)
rsp.Retcode = code
if err != nil || code != 0 {
return err
}
saved := s.JSON().TeamPreset(req.Index)
for slot, id := range saved.Avatars {
if id == req.BaseAvatarId {
delete(saved.Avatars, slot)
}
}
var nonempty bool
for _, id := range saved.Avatars {
if id != 0 {
nonempty = true
}
}
if !nonempty && req.Index == s.JSON().ActiveTeamPreset {
rsp.Retcode = uint32(goproto.Retcode_RET_LINEUP_ONLY_ONE_MEMBER)
return nil
}
if saved.Avatars[saved.LeaderSlot] == 0 {
for _, slot := range sortedU32Keys2(saved.Avatars) {
if saved.Avatars[slot] != 0 {
saved.LeaderSlot = slot
break
}
}
}
return s.savePreset(req.Index, saved)
})
handle("GetLineupAvatarData", func(s *Session, _ *goproto.GetLineupAvatarDataCsReq, rsp *goproto.GetLineupAvatarDataScRsp) error {
if lineup := s.techniqueLineup(); lineup != nil {
for _, a := range lineup.AvatarList {
rsp.AvatarDataList = append(rsp.AvatarDataList, &goproto.LineupAvatarData{AvatarType: a.AvatarType, Hp: a.Hp, Id: a.Id})
}
}
return nil
})
handle("ChangeLineupLeader", func(s *Session, req *goproto.ChangeLineupLeaderCsReq, rsp *goproto.ChangeLineupLeaderScRsp) error {
rsp.Slot = req.Slot
if lineup := s.techniqueLineup(); lineup != nil {
for _, a := range lineup.AvatarList {
if a.Slot == req.Slot {
s.leaderSlot = req.Slot
rsp.Slot = req.Slot
if s.ChallengeRun == nil {
p := s.JSON()
saved := p.TeamPreset(p.ActiveTeamPreset)
saved.LeaderSlot = req.Slot
return p.SaveTeamPreset(p.ActiveTeamPreset, saved)
}
return nil
}
}
}
rsp.Retcode = uint32(goproto.Retcode_RET_LINEUP_NOT_VALID_LEADER)
return nil
})
}
@@ -118,13 +220,7 @@ func refreshLineup(s *Session) error {
return nil
}
lineup := &goproto.LineupInfo{
ExtraLineupType: goproto.ExtraLineupType_ExtraLineupType_LineupNone,
Name: "Squad 1",
AvatarList: commonToLineupAvatars(player),
MaxMp: 5,
Mp: 5,
}
lineup := s.techniqueLineup()
notify := &goproto.SceneGroupRefreshScNotify{
FloorId: player.Scene.FloorID,
@@ -135,8 +231,18 @@ func refreshLineup(s *Session) error {
State: 0,
GroupRefreshType: goproto.SceneGroupRefreshType_SCENE_GROUP_REFRESH_TYPE_LOADED,
}
for _, slot := range sortedU32Keys2(s.partyActorSlots) {
if player.Lineups[slot] == 0 {
group.RefreshEntity = append(group.RefreshEntity, &goproto.SceneEntityRefreshInfo{RefreshType: &goproto.SceneEntityRefreshInfo_DeleteEntity{DeleteEntity: slot + 1}})
}
}
s.partyActorSlots = map[uint32]bool{}
for _, slot := range player.SortedLineupKeys() {
avatarID := player.Lineups[slot]
if avatarID == 0 {
continue
}
s.partyActorSlots[slot] = true
group.RefreshEntity = append(group.RefreshEntity, &goproto.SceneEntityRefreshInfo{
RefreshType: &goproto.SceneEntityRefreshInfo_AddEntity{
AddEntity: &goproto.SceneEntityInfo{
+5
View File
@@ -83,6 +83,11 @@ func init() {
rsp.MaterialList = append(rsp.MaterialList, material)
}
}
foods, err := techniqueMaterials(player)
if err != nil {
return err
}
rsp.MaterialList = append(rsp.MaterialList, foods...)
// Avatar IDs do not imply item IDs. The old 130000+avatar formula
// produced nonexistent materials and broke the client's inventory init.
return nil
+1 -1
View File
@@ -157,7 +157,7 @@ const heartbeatLua = `local function beta_text()
if gameObject then
local textComponent = gameObject:GetComponentInChildren(typeof(CS.RPG.Client.LocalizedText))
if textComponent then
textComponent.text = "<color=#FF2D00><b>GaC hkrpg GO server</b></color>"
textComponent.text = "<color=#3E6BE6><b>GaC hkrpg GO แล้วเรามาเกกันนะ</b></color>"
end
end
end
+4 -1
View File
@@ -58,6 +58,9 @@ func init() {
handle("GetChallengeTierceController", func(s *Session, req *goproto.GetChallengeTierceControllerCsReq, rsp *goproto.GetChallengeTierceControllerScRsp) error {
rsp.Retcode = 0
if run := s.ChallengeRun; run != nil && run.Tierce {
rsp.CurTierceChallenge = run.TierceCur
}
return nil
})
handle("StartChallengePeak", handleStartChallengePeak)
@@ -130,7 +133,7 @@ func challengeGroupStatsReply(s *Session, payload []byte) error {
// challengeTierceReply sends only the response paired with the current request.
func challengeTierceReply(s *Session, payload []byte) error {
return s.Send(buildGetChallengeTierceDataScRspReal())
return s.Send(buildGetChallengeTierceDataScRspReal(s))
}
func readUvarint(b []byte) (uint64, int) {
+28 -6
View File
@@ -16,6 +16,13 @@ import (
// Scene loading (Go port of handlers/scene.rs)
// ---------------------------------------------------------------------------
// Protocol scene modes from PearlSR 005d2c12, manager/scene_mgr.zig and
// services/challenge.zig. MazePlane.PlaneType uses a different enumeration.
const (
sceneModeAdventure uint32 = 1
sceneModeChallenge uint32 = 4
)
// firstSceneOfEntry returns the (configName, config) pair for an entry.
// Rust iterated a HashMap arbitrarily; we pick the lexicographically first
// name for determinism.
@@ -291,7 +298,7 @@ func init() {
}
if err := s.Send(&goproto.EnterSceneByServerScNotify{
Scene: sceneInfo,
Lineup: common.ToLineupInfo(player.Lineups),
Lineup: s.techniqueLineup(),
}); err != nil {
return err
}
@@ -889,15 +896,12 @@ func loadScene(s *Session, entryID uint32, isEnterScene, sendNotify bool, telepo
FloorId: floorID,
PlaneId: planeID,
EntryId: entryID,
GameModeType: scene.PlaneType,
GameModeType: sceneModeAdventure,
LeaderEntityId: 1,
WorldId: scene.WorldID,
SceneIdentifier: sceneIdent,
FloorSavedData: scene.SavedValues,
}
if sceneInfo.WorldId == 100 {
sceneInfo.WorldId = 501
}
sceneInfo.LightenSectionList = scene.Sections
missionInfo := &goproto.MissionStatusBySceneInfo{}
@@ -929,6 +933,8 @@ func loadScene(s *Session, entryID uint32, isEnterScene, sendNotify bool, telepo
s.PropEntities = map[uint32]PropEntry{}
s.MonsterEntities = map[uint32]uint32{}
s.ChallengeStageID = 0
s.ChallengeBattleConfig = nil
s.ChallengeRun = nil
for _, groupID := range sortedU32Keys2(scene.Scenes) {
group := scene.Scenes[groupID]
@@ -1003,9 +1009,15 @@ func loadScene(s *Session, entryID uint32, isEnterScene, sendNotify bool, telepo
}
// player entities (lineup slots 0..3 -> entity ids 1..4)
s.partyActorSlots = map[uint32]bool{}
s.leaderSlot = player.TeamPreset(player.ActiveTeamPreset).LeaderSlot
playerGroup := &goproto.SceneEntityGroupInfo{State: 0, GroupId: 0}
for _, slot := range player.SortedLineupKeys() {
avatarID := player.Lineups[slot]
if avatarID == 0 {
continue
}
s.partyActorSlots[slot] = true
playerGroup.EntityList = append(playerGroup.EntityList, &goproto.SceneEntityInfo{
EntityId: slot + 1,
Motion: playerPos,
@@ -1019,7 +1031,17 @@ func loadScene(s *Session, entryID uint32, isEnterScene, sendNotify bool, telepo
},
})
}
if player.Lineups[s.leaderSlot] == 0 {
for _, slot := range player.SortedLineupKeys() {
if player.Lineups[slot] != 0 {
s.leaderSlot = slot
break
}
}
}
sceneInfo.LeaderEntityId = s.leaderSlot + 1
sceneInfo.EntityGroupList = append(sceneInfo.EntityGroupList, playerGroup)
SceneDebugLog("[SCENE] entry=%d floor=%d plane=%d world=%d mode=%d savedValues=%d groups=%v", entryID, floorID, planeID, sceneInfo.WorldId, sceneInfo.GameModeType, len(sceneInfo.FloorSavedData), sortedU32Keys2(scene.Scenes))
if isEnterScene {
// in-memory only: the scene is persisted by SceneEntityMove once the
@@ -1034,7 +1056,7 @@ func loadScene(s *Session, entryID uint32, isEnterScene, sendNotify bool, telepo
if sendNotify {
if err := s.Send(&goproto.EnterSceneByServerScNotify{
Scene: sceneInfo,
Lineup: common.ToLineupInfo(player.Lineups),
Lineup: s.techniqueLineup(),
}); err != nil {
return nil, err
}
+7 -3
View File
@@ -54,9 +54,13 @@ func init() {
// Implement real RotaterData before answering this one.
handle("GetPamSkinData", func(s *Session, req *goproto.GetPamSkinDataCsReq, rsp *goproto.GetPamSkinDataScRsp) error {
rsp.Retcode = 0
rsp.CurSkin = 0
rsp.UnlockSkinList = []uint32{0}
profile, err := loadProfileResources()
if err != nil {
return err
}
rsp.CurSkin = profile.CurrentPamSkin
rsp.UnlockSkinList = append([]uint32(nil), profile.PamSkins...)
SceneDebugLog("GET_PAM_SKIN current=%d owned=%v", rsp.CurSkin, rsp.UnlockSkinList)
return nil
})
+34 -1
View File
@@ -10,7 +10,9 @@ import (
)
type profileResources struct {
Source struct {
PamSkins []uint32 `json:"pam_skins"`
CurrentPamSkin uint32 `json:"current_pam_skin"`
Source struct {
Revision string `json:"revision"`
} `json:"source"`
PersonalCards []uint32 `json:"personal_cards"`
@@ -44,6 +46,37 @@ func loadProfileResources() (*profileResources, error) {
profileError = fmt.Errorf("profile source, head frame or selected personal card is invalid")
return
}
if profileData.CurrentPamSkin == 0 || !containsU32(profileData.PamSkins, profileData.CurrentPamSkin) {
profileError = fmt.Errorf("selected Pam skin is missing or not owned")
return
}
pamRaw, err := common.ReadResourceFile("resources/PamSkinConfig.json")
if err != nil {
profileError = err
return
}
var pamTable struct {
Rows []struct {
ID uint32 `json:"SkinID"`
Path string `json:"ConfigEntityPath"`
} `json:"pam_skin_config"`
}
if err := json.Unmarshal(pamRaw, &pamTable); err != nil {
profileError = err
return
}
validSkins := map[uint32]bool{}
for _, row := range pamTable.Rows {
if row.ID != 0 && row.Path != "" {
validSkins[row.ID] = true
}
}
for _, id := range profileData.PamSkins {
if !validSkins[id] {
profileError = fmt.Errorf("Pam skin %d has no Client entity config", id)
return
}
}
if !containsU32(profileData.PhoneCases, profileData.CurrentPhoneCase) {
profileError = fmt.Errorf("selected phone case is not owned")
return
+1
View File
@@ -26,6 +26,7 @@ func syncPersistedExRanks(s *Session) error {
if err != nil {
return err
}
player.RefreshTechniqueSettings(p)
changed := player.RefreshExRanks(p)
if s.pendingExRankSync == nil {
s.pendingExRankSync = make(map[uint32]bool)
+14 -2
View File
@@ -45,9 +45,18 @@ type Session struct {
// spawned in the current scene. Used by SceneCastSkill to decide whether an
// attack actually hit a monster (the camera-detach bug fix).
MonsterEntities map[uint32]uint32
partyActorSlots map[uint32]bool
// Set by the challenge scene loader, cleared when loading an overworld scene.
ChallengeStageID uint32
pendingExRankSync map[uint32]bool
ChallengeStageID uint32
ChallengeBattleConfig *common.BattleConfig
ChallengeRun *challengeRun
PeakHardModes map[uint32]bool
TierceTeams map[uint32][][]uint32
TierceBuffs map[uint32][]uint32
pendingExRankSync map[uint32]bool
techniqueMP *uint32
leaderSlot uint32
paidTechniqueCasts map[uint32]bool
closed atomic.Bool
closeOnce sync.Once
@@ -89,6 +98,9 @@ func (s *Session) JSON() *common.FreesrData {
// SetJSON installs (or reloads) the player data.
func (s *Session) SetJSON(d *common.FreesrData) {
s.jsonMu.Lock()
if s.json == nil && d != nil {
s.leaderSlot = d.TeamPreset(d.ActiveTeamPreset).LeaderSlot
}
s.json = d
s.jsonMu.Unlock()
}
+103
View File
@@ -0,0 +1,103 @@
package gameserver
import (
"gac-hkrpg-go/internal/common"
goproto "gac-hkrpg-go/protocol/proto"
)
func presetLineup(p *common.FreesrData, index uint32) *goproto.LineupInfo {
saved := p.TeamPreset(index)
lineup := common.ToLineupInfo(saved.Avatars)
tmp := *p
tmp.Lineups = saved.Avatars
lineup.AvatarList = common.ToLineupAvatars(&tmp)
lineup.Index = index
lineup.Name = saved.Name
lineup.LeaderSlot = saved.LeaderSlot
lineup.PlaneId = p.Scene.PlaneID
lineup.IECKKCHJMLP = saved.Favourite && len(lineup.AvatarList) > 0
if saved.Avatars[lineup.LeaderSlot] == 0 {
for _, a := range lineup.AvatarList {
lineup.LeaderSlot = a.Slot
break
}
}
return lineup
}
func allPresetLineups(s *Session) ([]*goproto.LineupInfo, error) {
rules, err := common.LoadTeamPresetRules()
if err != nil {
return nil, err
}
p := s.JSON()
if p == nil {
return nil, nil
}
var out []*goproto.LineupInfo
for index := uint32(0); index < rules.Count; index++ {
out = append(out, presetLineup(p, index))
}
return out, nil
}
func (s *Session) presetEditCode(index uint32, extra goproto.ExtraLineupType, virtual bool) (uint32, error) {
rules, err := common.LoadTeamPresetRules()
if err != nil {
return 0, err
}
if s.JSON() == nil {
return uint32(goproto.Retcode_RET_LINEUP_NOT_EXIST), nil
}
if index >= rules.Count {
return uint32(goproto.Retcode_RET_LINEUP_INVALID_INDEX), nil
}
if extra != goproto.ExtraLineupType_ExtraLineupType_LineupNone || virtual {
return uint32(goproto.Retcode_RET_LINEUP_TYPE_NOT_MATCH), nil
}
if _, active := CURRENT_BATTLE.Load(s.Token); active {
return uint32(goproto.Retcode_RET_LINEUP_NOT_ALLOW_EDIT), nil
}
return 0, nil
}
func (s *Session) savePreset(index uint32, saved *common.TeamPreset) error {
if err := s.JSON().SaveTeamPreset(index, saved); err != nil {
return err
}
return s.publishPreset(index, saved)
}
func (s *Session) publishPreset(index uint32, saved *common.TeamPreset) error {
if index == s.JSON().ActiveTeamPreset && s.ChallengeRun == nil {
s.leaderSlot = saved.LeaderSlot
if err := refreshLineup(s); err != nil {
return err
}
}
lineups, err := allPresetLineups(s)
if err != nil {
return err
}
SceneDebugLog("[TEAM PRESET] saved index=%d name=%q team=%v", index, saved.Name, saved.Avatars)
return s.Send(&goproto.SyncAllPresetLineupNotify{CurIndex: s.JSON().ActiveTeamPreset, LineupList: lineups})
}
func validatePresetMembers(p *common.FreesrData, avatars map[uint32]uint32) uint32 {
rules, err := common.LoadTeamPresetRules()
if err != nil {
return uint32(goproto.Retcode_RET_LINEUP_NOT_EXIST)
}
seen := map[uint32]bool{}
for slot, id := range avatars {
if slot >= rules.MaxMembers {
return uint32(goproto.Retcode_RET_LINEUP_INVALID_MEMBER_POS)
}
if id == 0 {
continue
}
if p.Avatars[id] == nil {
return uint32(goproto.Retcode_RET_LINEUP_AVATAR_NOT_EXIST)
}
if seen[id] {
return uint32(goproto.Retcode_RET_LINEUP_AVATAR_ALREADY_IN)
}
seen[id] = true
}
return 0
}
+173
View File
@@ -0,0 +1,173 @@
package gameserver
import (
"fmt"
"gac-hkrpg-go/internal/common"
goproto "gac-hkrpg-go/protocol/proto"
)
func (s *Session) techniqueLineup() *goproto.LineupInfo {
var lineup *goproto.LineupInfo
if run := s.ChallengeRun; run != nil {
lineup = run.activeLineup(0)
} else if p := s.JSON(); p != nil {
lineup = presetLineup(p, p.ActiveTeamPreset)
} else {
return nil
}
rules, err := common.LoadTechniqueRules()
if err != nil {
return lineup
}
if s.techniqueMP == nil {
v := rules.InitialMP
s.techniqueMP = &v
}
if *s.techniqueMP > lineup.MaxMp {
*s.techniqueMP = lineup.MaxMp
}
lineup.Mp = *s.techniqueMP
if int(s.leaderSlot) < len(lineup.AvatarList) {
lineup.LeaderSlot = s.leaderSlot
}
return lineup
}
func (s *Session) techniqueAvatar(entity uint32) uint32 {
lineup := s.techniqueLineup()
if lineup == nil {
return 0
}
if entity == 0 {
entity = lineup.LeaderSlot + 1
}
for _, a := range lineup.AvatarList {
if a.Slot+1 == entity {
return a.Id
}
}
return 0
}
func (s *Session) resetTechniqueRoom() {
s.techniqueMP = nil
s.leaderSlot = 0
s.paidTechniqueCasts = nil
}
func (s *Session) syncTechnique(reason goproto.SyncLineupReason) error {
return s.Send(&goproto.SyncLineupNotify{Lineup: s.techniqueLineup(), ReasonList: []goproto.SyncLineupReason{reason}})
}
func techniqueMaterials(p *common.FreesrData) ([]*goproto.Material, error) {
if err := p.EnsureTechniqueFoods(); err != nil {
return nil, err
}
var out []*goproto.Material
for _, id := range sortedU32Keys2(p.TechniqueFoodStock) {
out = append(out, &goproto.Material{Tid: id, Num: p.TechniqueFoodStock[id]})
}
return out, nil
}
func (s *Session) consumeTechniqueFood(id, count uint32) error {
p := s.JSON()
if p == nil {
return fmt.Errorf("no player")
}
if _, active := CURRENT_BATTLE.Load(s.Token); active {
return fmt.Errorf("cannot eat during battle")
}
rules, err := common.LoadTechniqueRules()
if err != nil {
return err
}
food, ok := rules.Foods[id]
if !ok || count == 0 || count > food.MaxUse {
return fmt.Errorf("unsupported food or count")
}
if err := p.EnsureTechniqueFoods(); err != nil {
return err
}
if p.TechniqueFoodStock[id] < count {
return fmt.Errorf("food %d is exhausted", id)
}
lineup := s.techniqueLineup()
if lineup == nil || lineup.Mp >= lineup.MaxMp {
return fmt.Errorf("technique points are full")
}
oldStock, oldMP := p.TechniqueFoodStock[id], *s.techniqueMP
p.TechniqueFoodStock[id] -= count
gain := uint64(food.RecoverMP)*uint64(count) + uint64(oldMP)
if gain > uint64(lineup.MaxMp) {
gain = uint64(lineup.MaxMp)
}
*s.techniqueMP = uint32(gain)
if err := p.SavePersistentChecked(); err != nil {
p.TechniqueFoodStock[id] = oldStock
*s.techniqueMP = oldMP
return err
}
if err := s.Send(&goproto.PlayerSyncScNotify{MaterialList: []*goproto.Material{{Tid: id, Num: p.TechniqueFoodStock[id]}}}); err != nil {
return err
}
SceneDebugLog("[TECHNIQUE] food=%d count=%d mp=%d/%d remaining=%d", id, count, *s.techniqueMP, lineup.MaxMp, p.TechniqueFoodStock[id])
return s.syncTechnique(goproto.SyncLineupReason_SyncLineupReason_SyncReasonMpAdd)
}
func (s *Session) payTechnique(castID, skillIndex uint32) (uint32, error) {
if s.paidTechniqueCasts[castID] {
return 0, nil
}
rules, err := common.LoadTechniqueRules()
if err != nil {
return 0, err
}
avatar := s.techniqueAvatar(0)
skills := rules.Avatars[avatar]
if int(skillIndex) >= len(skills) {
return uint32(goproto.Retcode_RET_MAZE_NO_ABILITY), nil
}
cost := skills[skillIndex].MPCost
lineup := s.techniqueLineup()
if lineup == nil {
return uint32(goproto.Retcode_RET_LINEUP_IS_EMPTY), nil
}
p := s.JSON()
if err := p.EnsureTechniqueFoods(); err != nil {
return 0, err
}
if cost > lineup.MaxMp {
return uint32(goproto.Retcode_RET_MAZE_MP_NOT_ENOUGH), nil
}
for *s.techniqueMP < cost && p.AutoTechniqueFood != nil && *p.AutoTechniqueFood {
var selected uint32
for _, id := range sortedU32Keys2(rules.Foods) {
if p.TechniqueFoodStock[id] > 0 {
selected = id
break
}
}
if selected == 0 {
break
}
if err := s.consumeTechniqueFood(selected, 1); err != nil {
return 0, err
}
}
if *s.techniqueMP < cost {
return uint32(goproto.Retcode_RET_MAZE_MP_NOT_ENOUGH), nil
}
*s.techniqueMP -= cost
if s.paidTechniqueCasts == nil {
s.paidTechniqueCasts = map[uint32]bool{}
}
s.paidTechniqueCasts[castID] = true
SceneDebugLog("[TECHNIQUE] cast=%d avatar=%d skill=%d cost=%d mp=%d/%d", castID, avatar, skillIndex, cost, *s.techniqueMP, lineup.MaxMp)
return 0, s.Send(&goproto.SceneCastSkillMpUpdateScNotify{CastEntityId: castID, Mp: *s.techniqueMP})
}
func init() {
handle("UseItem", func(s *Session, req *goproto.UseItemCsReq, rsp *goproto.UseItemScRsp) error {
rsp.UseItemId = req.UseItemId
rsp.UseItemCount = req.UseItemCount
if err := s.consumeTechniqueFood(req.UseItemId, req.UseItemCount); err != nil {
SceneDebugLog("[TECHNIQUE] use item rejected: %v", err)
rsp.Retcode = uint32(goproto.Retcode_RET_ITEM_USE_TARGET_TYPE_INVALID)
}
return nil
})
}
+110
View File
@@ -0,0 +1,110 @@
package gameserver
import (
"gac-hkrpg-go/internal/common"
goproto "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
// ChallengeTargetConfigRow.ChallengeTargetType, captured from this Client.
const challengeTargetTotalScore uint32 = 7
func tierceTargets(id, total uint32) []uint32 {
stage := getChallengeData().Tierce[id]
ids := append([]uint32(nil), stage.Targets...)
if stage.SpecialTarget != 0 {
ids = append(ids, stage.SpecialTarget)
}
var finished []uint32
for _, targetID := range ids {
r, ok := getChallengeData().TargetRules[targetID]
if ok && r.Type == challengeTargetTotalScore && total >= r.Param {
finished = append(finished, targetID)
}
}
return finished
}
func tierceBestRecord(saved *common.TierceProgress) (uint32, bool) {
var total uint32
allWon := true
for node := uint32(0); node < tierceNodeCount; node++ {
n, ok := saved.Nodes[node]
total += n.Score
if !ok || !n.Won {
allWon = false
}
}
return total, allWon
}
func tierceNodeResult(node uint32, n common.TierceNodeProgress, teams ...[]uint32) *goproto.PMMOFOBCINL {
result := &goproto.PMMOFOBCINL{}
if len(n.Result) > 0 && proto.Unmarshal(n.Result, result) == nil {
return result
}
// Imported confirmed scores have no historical equipment snapshot. Supply
// known team IDs and empty wrappers rather than inventing gear/stats.
result.Lineup = &goproto.GLLFJFJBIBC{}
if len(teams) > 0 {
for slot, id := range teams[0] {
result.Lineup.MNABNJOEHLF = append(result.Lineup.MNABNJOEHLF, &goproto.CLDOIEGCNLD{AvatarId: id, Slot: uint32(slot), AvatarType: uint32(goproto.AvatarType_AVATAR_FORMAL_TYPE), PAGPNOKLNOM: &goproto.ChallengeBossEquipmentInfo{}, OKCGHKDMFAM: &goproto.ChallengeBossAvatarRelicInfo{}})
}
}
result.FINOCEGDBDI = node
result.ScoreId = n.Score
if n.Won {
result.EndStatus = goproto.BattleEndStatus_BATTLE_END_WIN
}
return result
}
func saveTierceResult(s *Session, result *goproto.PMMOFOBCINL) error {
saved := s.JSON().TierceProgress[s.ChallengeRun.ID]
if saved.Nodes == nil {
saved.Nodes = map[uint32]common.TierceNodeProgress{}
}
node := result.FINOCEGDBDI
old, known := saved.Nodes[node]
won := result.EndStatus == goproto.BattleEndStatus_BATTLE_END_WIN
if !known || result.ScoreId > old.Score || (result.ScoreId == old.Score && won && !old.Won) {
raw, err := proto.Marshal(result)
if err != nil {
return err
}
saved.Nodes[node] = common.TierceNodeProgress{Score: result.ScoreId, Won: won, Result: raw}
}
return s.JSON().SavePersistentChecked()
}
func buildTierceGroup(id uint32, saved *common.TierceProgress) *goproto.ChallengeTierceGroup {
group := &goproto.ChallengeTierceGroup{ChallengeId: id}
if saved == nil {
return group
}
total, allWon := tierceBestRecord(saved)
group.Record = &goproto.ChallengeTierceRecord{TotalScore: total}
group.IsWin = allWon
if limit := getChallengeData().Tierce[id].ClearScore; limit != 0 && total < limit {
group.IsWin = false
}
if allWon {
group.FinishedTargetList = tierceTargets(id, total)
}
for node, team := range saved.Teams {
lineup := &goproto.CMOPLAHDEPN{FINOCEGDBDI: uint32(node)}
if node < len(saved.Buffs) {
lineup.BuffId = saved.Buffs[node]
}
for _, aid := range team {
lineup.TierceAvatarIdList = append(lineup.TierceAvatarIdList, &goproto.AvatarLineup{Id: aid, AvatarType: goproto.AvatarType_AVATAR_FORMAL_TYPE})
}
group.MOGEEAJCFNO = append(group.MOGEEAJCFNO, lineup)
}
for node := uint32(0); node < tierceNodeCount; node++ {
if n, ok := saved.Nodes[node]; ok {
var team []uint32
if int(node) < len(saved.Teams) {
team = saved.Teams[node]
}
group.HGKCEEOONGA = append(group.HGKCEEOONGA, tierceNodeResult(node, n, team))
}
}
return group
}
+1 -3
View File
@@ -38,6 +38,7 @@ func corsWrapper(next http.HandlerFunc) http.HandlerFunc {
func registerAPIRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/status", corsWrapper(apiStatus))
mux.HandleFunc("/api/technique", corsWrapper(apiTechnique))
mux.HandleFunc("/api/freesr", corsWrapper(apiFreesrData))
mux.HandleFunc("/api/freesr/avatars", corsWrapper(apiFreesrAvatars))
mux.HandleFunc("/api/persistent", corsWrapper(apiPersistentData))
@@ -605,7 +606,6 @@ func apiGachabaseFetch(w http.ResponseWriter, r *http.Request) {
}
}
if targetURL == "" {
writeJSON(w, map[string]any{"error": "missing url or id/type parameters", "status": 400})
return
@@ -833,5 +833,3 @@ func apiBattleReportReset(w http.ResponseWriter, r *http.Request) {
"message": "Battle report cleared",
})
}
+38
View File
@@ -0,0 +1,38 @@
package sdkserver
import (
"encoding/json"
"gac-hkrpg-go/internal/common"
"net/http"
)
// GET is read-only. POST changes only the auto-food switch in persistent.
func apiTechnique(w http.ResponseWriter, r *http.Request) {
apiMu.Lock()
defer apiMu.Unlock()
data, err := common.LoadFreesrData()
if err != nil {
writeJSON(w, map[string]any{"status": 500, "error": err.Error()})
return
}
switch r.Method {
case http.MethodPost:
var req struct {
AutoFood *bool `json:"auto_food"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AutoFood == nil {
writeJSON(w, map[string]any{"status": 400, "error": "auto_food must be a boolean"})
return
}
data.AutoTechniqueFood = req.AutoFood
if err := data.SavePersistentChecked(); err != nil {
writeJSON(w, map[string]any{"status": 500, "error": err.Error()})
return
}
case http.MethodGet:
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, map[string]any{"status": 200, "auto_food": data.AutoTechniqueFood, "food_stock": data.TechniqueFoodStock})
}
+18 -18
View File
@@ -218,21 +218,21 @@ type accountPayload struct {
Retcode int `json:"retcode"`
}
func standardAccount() map[string]any {
func (a *appState) standardAccount() map[string]any {
return map[string]any{
"area_code": "**",
"email": "ReversedRooms",
"country": "RU",
"is_email_verify": "1",
"token": "mostsecuretokenever",
"uid": "1337",
"uid": fmt.Sprint(a.runtime.PlayerUID),
}
}
func loginReply(w http.ResponseWriter) {
func (a *appState) loginReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{
"data": map[string]any{
"account": standardAccount(),
"account": a.standardAccount(),
"device_grant_required": false,
"reactivate_required": false,
"realperson_required": false,
@@ -243,15 +243,15 @@ func loginReply(w http.ResponseWriter) {
})
}
func granterLoginReply(w http.ResponseWriter) {
func (a *appState) granterLoginReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{
"data": map[string]any{
"account_type": 1,
"combo_id": "1337",
"combo_id": fmt.Sprint(a.runtime.PlayerUID),
"combo_token": "9065ad8507d5a1991cb6fddacac5999b780bbd92",
"data": `{"guest":false}`,
"heartbeat": false,
"open_id": "1337",
"open_id": fmt.Sprint(a.runtime.PlayerUID),
},
"message": "OK",
"retcode": 0,
@@ -270,13 +270,13 @@ func riskyCheckReply(w http.ResponseWriter) {
})
}
func apnLoginReply(w http.ResponseWriter) {
func (a *appState) apnLoginReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{
"data": map[string]any{
"token": map[string]any{"token": "mostsecuretokenever", "token_type": 1},
"user_info": map[string]any{
"aid": "1337",
"mid": "1337",
"aid": fmt.Sprint(a.runtime.PlayerUID),
"mid": fmt.Sprint(a.runtime.PlayerUID),
"is_email_verify": 1,
"area_code": "**",
"country": "US",
@@ -289,13 +289,13 @@ func apnLoginReply(w http.ResponseWriter) {
})
}
func apnVerifyReply(w http.ResponseWriter) {
func (a *appState) apnVerifyReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{
"data": map[string]any{
"tokens": []any{map[string]any{"token": "mostsecuretokenever", "token_type": 1}},
"user_info": map[string]any{
"aid": "1337",
"mid": "1337",
"aid": fmt.Sprint(a.runtime.PlayerUID),
"mid": fmt.Sprint(a.runtime.PlayerUID),
"is_email_verify": 1,
"area_code": "**",
"country": "US",
@@ -361,12 +361,12 @@ func Listen() error {
mux := http.NewServeMux()
mux.HandleFunc("/query_dispatch", func(w http.ResponseWriter, r *http.Request) { queryDispatch(w, r, runtime) })
mux.HandleFunc("/query_gateway", queryGateway(state))
mux.HandleFunc("/{product_name}/mdk/shield/api/login", func(w http.ResponseWriter, r *http.Request) { loginReply(w) })
mux.HandleFunc("/{product_name}/mdk/shield/api/verify", func(w http.ResponseWriter, r *http.Request) { loginReply(w) })
mux.HandleFunc("/{product_name}/combo/granter/login/v2/login", func(w http.ResponseWriter, r *http.Request) { granterLoginReply(w) })
mux.HandleFunc("/{product_name}/mdk/shield/api/login", func(w http.ResponseWriter, r *http.Request) { state.loginReply(w) })
mux.HandleFunc("/{product_name}/mdk/shield/api/verify", func(w http.ResponseWriter, r *http.Request) { state.loginReply(w) })
mux.HandleFunc("/{product_name}/combo/granter/login/v2/login", func(w http.ResponseWriter, r *http.Request) { state.granterLoginReply(w) })
mux.HandleFunc("/account/risky/api/check", func(w http.ResponseWriter, r *http.Request) { riskyCheckReply(w) })
mux.HandleFunc("/account/ma-cn-passport/app/loginByPassword", func(w http.ResponseWriter, r *http.Request) { apnLoginReply(w) })
mux.HandleFunc("/account/ma-cn-session/app/verify", func(w http.ResponseWriter, r *http.Request) { apnVerifyReply(w) })
mux.HandleFunc("/account/ma-cn-passport/app/loginByPassword", func(w http.ResponseWriter, r *http.Request) { state.apnLoginReply(w) })
mux.HandleFunc("/account/ma-cn-session/app/verify", func(w http.ResponseWriter, r *http.Request) { state.apnVerifyReply(w) })
mux.HandleFunc("/srtools", corsWrapper(srToolsSave))
registerAPIRoutes(mux)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+211 -10
View File
@@ -1,4 +1,6 @@
{
"active_team_preset": 2,
"auto_technique_food": true,
"dressed_skins": {},
"enable_aha_global": true,
"enable_castorice_global": true,
@@ -9,22 +11,221 @@
"1308": true
},
"lineups": {
"0": 1213,
"1": 1306,
"2": 1403,
"0": 1407,
"1": 1413,
"2": 1415,
"3": 1409
},
"main_character": "FemaleHarmony",
"march_type": "MarchHunt",
"position": {
"rot_y": 167180,
"x": 72091,
"y": 7000,
"z": 110301
"rot_y": 340000,
"x": 25709,
"y": 3473,
"z": -155597
},
"scene": {
"entry_id": 2050101,
"floor_id": 20501001,
"plane_id": 20501
"entry_id": 2057201,
"floor_id": 20572001,
"plane_id": 20572
},
"team_presets": {
"0": {
"avatars": {
"0": 1308,
"1": 1403,
"2": 1507,
"3": 1414
},
"favourite": true,
"leader_slot": 0,
"name": "Team 1"
},
"1": {
"avatars": {
"0": 1510,
"1": 1306,
"2": 1512,
"3": 1217
},
"favourite": true,
"leader_slot": 0,
"name": "SW5513 N1"
},
"2": {
"avatars": {
"0": 1407,
"1": 1413,
"2": 1415,
"3": 1409
},
"favourite": true,
"leader_slot": 0,
"name": "SW5513 N2"
},
"3": {
"avatars": {
"0": 1408,
"1": 1412,
"2": 1313,
"3": 1414
},
"favourite": true,
"leader_slot": 0,
"name": "SW5513 N3"
},
"4": {
"avatars": {
"0": 1506,
"1": 1501,
"2": 1511,
"3": 1503
},
"favourite": true,
"leader_slot": 2,
"name": "SW20275 N2"
},
"5": {
"avatars": {
"0": 1408,
"1": 1412,
"2": 1512,
"3": 1217
},
"favourite": true,
"leader_slot": 0,
"name": "SW20275 N3"
}
},
"technique_food_stock": {
"400006": 96,
"401033": 100,
"402029": 100,
"408002": 100,
"408205": 100,
"408404": 100,
"408412": 100,
"408418": 100,
"408426": 100,
"408443": 100,
"408453": 100,
"408604": 100,
"408605": 100,
"408636": 100
},
"tierce_progress": {
"20275": {
"buffs": [
3031359,
3031362,
3031359
],
"nodes": {
"0": {
"score": 33000,
"won": true
},
"1": {
"score": 28500,
"won": true
},
"2": {
"score": 35500,
"won": true
}
},
"teams": [
[
1308,
1403,
1507,
1414
],
[
1506,
1501,
1511,
1503
],
[
1408,
1412,
1512,
1217
]
]
},
"30215": {
"buffs": [
3111094,
3111092,
3111065
],
"nodes": {
"0": {
"result": "EgcIlb8FEKwPMKwPSAF6bgoYCOILEAMgUDINCLEBEJG0ARhQIAYoAToAChoI3QsQAxgBIFAyDQi0ARCNtAEYUCAGKAE6AAoaCOcLEAMYAiBQMg0InAEQmbQBGFAgBigBOgAKGgjfCxADGAMgUDINCLMBEI+0ARhQIAYoAToA",
"score": 1964,
"won": true
},
"1": {
"result": "EgcIlb8FEP4NMP4NQAFIAXpsChcIgAsQAyBQMgwIahCEtAEYUCAGKAE6AAoaCIQLEAMYASBQMg0ItgEQiLQBGFAgBigBOgAKGQihChADGAIgUDIMCEcQ+rMBGFAgBigBOgAKGgiGCxADGAMgUDINCIcBEIu0ARhQIAYoAToA",
"score": 1790,
"won": true
},
"2": {
"result": "EgcIlb8FENAPMNAPQAJIAXpuChgI/woQAyBQMg0IrAEQgLQBGFAgBigBOgAKGgiFCxADGAEgUDINCKgBEIm0ARhQIAYoAToAChoIhwsQAxgCIFAyDQi1ARCMtAEYUCAGKAE6AAoaCIELEAMYAyBQMg0IqwEQgrQBGFAgBigBOgA=",
"score": 2000,
"won": true
}
},
"teams": [
[
1506,
1501,
1511,
1503
],
[
1408,
1412,
1313,
1414
],
[
1407,
1413,
1415,
1409
]
]
},
"5513": {
"buffs": [
0,
0,
0
],
"nodes": {},
"teams": [
[
1510,
1306,
1512,
1217
],
[
1407,
1413,
1415,
1409
],
[
1408,
1412,
1313,
1414
]
]
}
}
}
+5
View File
@@ -8,6 +8,11 @@
"protocol/src/protocol.pb.zig"
]
},
"challenge_round_limits": {
"PF": 4,
"AS": 30,
"Default": 30
},
"technique_buffs": {
"1208": [
{
+153
View File
@@ -0,0 +1,153 @@
{
"source": {
"client_version": "4.6.53",
"assembly_sha256": "c5e02e6e984f7e5577c78b995a8efeee2d06ee22e67d484ad5e859c8bf21e144",
"table": "ChallengeTargetConfigRow"
},
"challenge_targets": [
{
"ChallengeTargetName": {
"hash": -1268508559,
"hash64": 13289745288470897026
},
"ChallengeTargetParam2": 0,
"ID": 600,
"ChallengeTargetType": 6,
"RewardID": 100344,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 33
},
{
"ChallengeTargetName": {
"hash": -128044084,
"hash64": 12508471847671960592
},
"ChallengeTargetParam2": 0,
"ID": 601,
"ChallengeTargetType": 6,
"RewardID": 100342,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 15
},
{
"ChallengeTargetName": {
"hash": -128044084,
"hash64": 12508471847671960592
},
"ChallengeTargetParam2": 0,
"ID": 602,
"ChallengeTargetType": 6,
"RewardID": 100343,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 30
},
{
"ChallengeTargetName": {
"hash": -128044080,
"hash64": 15997122175768211822
},
"ChallengeTargetParam2": 0,
"ID": 603,
"ChallengeTargetType": 2,
"RewardID": 100344,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 0
},
{
"ChallengeTargetName": {
"hash": -1005863068,
"hash64": 2824638414496714228
},
"ChallengeTargetParam2": 0,
"ID": 4000,
"ChallengeTargetType": 7,
"RewardID": 0,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 99000
},
{
"ChallengeTargetName": {
"hash": -1856409346,
"hash64": 11408150447023752054
},
"ChallengeTargetParam2": 0,
"ID": 4001,
"ChallengeTargetType": 7,
"RewardID": 0,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 60000
},
{
"ChallengeTargetName": {
"hash": -1856409346,
"hash64": 11408150447023752054
},
"ChallengeTargetParam2": 0,
"ID": 4002,
"ChallengeTargetType": 7,
"RewardID": 0,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 75000
},
{
"ChallengeTargetName": {
"hash": -1856409346,
"hash64": 11408150447023752054
},
"ChallengeTargetParam2": 0,
"ID": 4003,
"ChallengeTargetType": 7,
"RewardID": 0,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 90000
},
{
"ChallengeTargetName": {
"hash": -1005863068,
"hash64": 2824638414496714228
},
"ChallengeTargetParam2": 0,
"ID": 5000,
"ChallengeTargetType": 7,
"RewardID": 0,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 10200
},
{
"ChallengeTargetName": {
"hash": -1856409346,
"hash64": 11408150447023752054
},
"ChallengeTargetParam2": 0,
"ID": 5001,
"ChallengeTargetType": 7,
"RewardID": 0,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 6000
},
{
"ChallengeTargetName": {
"hash": -1856409346,
"hash64": 11408150447023752054
},
"ChallengeTargetParam2": 0,
"ID": 5002,
"ChallengeTargetType": 7,
"RewardID": 0,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 7800
},
{
"ChallengeTargetName": {
"hash": -1856409346,
"hash64": 11408150447023752054
},
"ChallengeTargetParam2": 0,
"ID": 5003,
"ChallengeTargetType": 7,
"RewardID": 0,
"ChallengeTargetParam3": 0,
"ChallengeTargetParam1": 9900
}
]
}
+44
View File
@@ -0,0 +1,44 @@
{
"source": {
"client_version": "4.6.53",
"assembly_sha256": "c5e02e6e984f7e5577c78b995a8efeee2d06ee22e67d484ad5e859c8bf21e144",
"table": "PamSkinConfigRow"
},
"pam_skin_config": [
{
"JsonPath": "Config/ConfigCharacter/NPC/Special/NPC_Special_Pam_00_Config.json",
"SkinIcon": "SpriteOutput/AvatarShopIcon/Pam/252000.png",
"ConfigEntityPath": "Config/ConfigEntity/NPC/Special/NPC_Special_Pam_00_Entity.json",
"ManikinPrefab": "Characters/CharacterPrefabs/Manikin/Special/Pam_00/Manikin_Special_Pam_00.prefab",
"SkinID": 252000
},
{
"JsonPath": "Config/ConfigCharacter/NPC/Special/NPC_Special_Pam_00_Config.json",
"SkinIcon": "SpriteOutput/AvatarShopIcon/Pam/252001.png",
"ConfigEntityPath": "Config/ConfigEntity/NPC/Special/NPC_Special_PamChef_00_Entity.json",
"ManikinPrefab": "Characters/CharacterPrefabs/Manikin/Special/PamChef_00/Manikin_Special_PamChef_00.prefab",
"SkinID": 252001
},
{
"JsonPath": "Config/ConfigCharacter/NPC/Special/NPC_Special_Pam_00_Config.json",
"SkinIcon": "SpriteOutput/AvatarShopIcon/Pam/252002.png",
"ConfigEntityPath": "Config/ConfigEntity/NPC/Special/NPC_Special_PamNighty_00_Entity.json",
"ManikinPrefab": "Characters/CharacterPrefabs/Manikin/Special/PamNighty_00/Manikin_Special_PamNighty_00.prefab",
"SkinID": 252002
},
{
"JsonPath": "Config/ConfigCharacter/NPC/Special/NPC_Special_Pam_00_Config.json",
"SkinIcon": "SpriteOutput/AvatarShopIcon/Pam/252003.png",
"ConfigEntityPath": "Config/ConfigEntity/NPC/Special/NPC_Special_PamGentle_00_Entity.json",
"ManikinPrefab": "Characters/CharacterPrefabs/Manikin/Special/PamGentle_00/Manikin_Special_PamGentle_00.prefab",
"SkinID": 252003
},
{
"JsonPath": "Config/ConfigCharacter/NPC/Special/NPC_Special_Pam_00_Config.json",
"SkinIcon": "SpriteOutput/AvatarShopIcon/Pam/252004.png",
"ConfigEntityPath": "Config/ConfigEntity/NPC/Special/NPC_Special_PamGentle_01_Entity.json",
"ManikinPrefab": "Characters/CharacterPrefabs/Manikin/Special/PamGentle_01/Manikin_Special_PamGentle_01.prefab",
"SkinID": 252004
}
]
}
+10 -2
View File
@@ -5,7 +5,8 @@
"files": [
"gameserver/src/services/profile.zig",
"gameserver/src/data.zig",
"protocol/src/protocol.pb.zig"
"protocol/src/protocol.pb.zig",
"gameserver/src/services/scene.zig"
]
},
"personal_cards": [
@@ -57,5 +58,12 @@
],
"current_phone_case": 254001,
"current_phone_theme": 0,
"current_chat_bubble": 0
"current_chat_bubble": 0,
"pam_skins": [
252000,
252001,
252002,
252003
],
"current_pam_skin": 252000
}
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
{
"source": {
"client_version": "4.6.53",
"assembly_sha256": "c5e02e6e984f7e5577c78b995a8efeee2d06ee22e67d484ad5e859c8bf21e144",
"member_limit": "RPG.Client.TeamModule._MAX_TEAM_MEMBER=4",
"name_limit": "ConstValueCommon.TeamName_Max=14",
"preset_count": "Server policy for user-requested extra saved teams; native TeamModule keeps regular teams in a List"
},
"preset_count": 20,
"max_members": 4,
"max_name_length": 14
}
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -82,7 +82,7 @@ try {
Merge-Settings $settings (Get-Content -LiteralPath $localPath -Raw | ConvertFrom-Json)
}
$variables = @{
client_version='GAC_CLIENT_VERSION'; game_host='GAC_GAME_HOST'; game_port='GAC_GAME_PORT';
player_uid='GAC_PLAYER_UID'; client_version='GAC_CLIENT_VERSION'; game_host='GAC_GAME_HOST'; game_port='GAC_GAME_PORT';
sdk_host='GAC_SDK_HOST'; sdk_port='GAC_SDK_PORT'; public_game_host='GAC_PUBLIC_GAME_HOST';
public_sdk_url='GAC_PUBLIC_SDK_URL'
}
@@ -90,6 +90,10 @@ try {
$value = [Environment]::GetEnvironmentVariable($variables[$key])
if ($null -ne $value) { $settings | Add-Member -MemberType NoteProperty -Name $key -Value $value -Force }
}
$configuredUID = [uint32]0
if (-not [uint32]::TryParse([string]$settings.player_uid, [ref]$configuredUID) -or $configuredUID -eq 0) {
throw 'player_uid must be an integer between 1 and 4294967295.'
}
foreach ($key in @('game_port','sdk_port')) {
$port = 0
if (-not [int]::TryParse([string]$settings.$key, [ref]$port) -or $port -lt 1 -or $port -gt [uint16]::MaxValue) {
+34 -12
View File
@@ -5,10 +5,13 @@ local Time = CS.UnityEngine.Time
local Camera = CS.UnityEngine.Camera
local GameObject = CS.UnityEngine.GameObject
-- Camera control tuning, in degrees per second (independent of frame rate).
local KEYBOARD_TURN_SPEED = 90.0
_G.__FREECAM_ENABLED = _G.__FREECAM_ENABLED or false
_G.__FREECAM_SPEED = _G.__FREECAM_SPEED or 15.0
local RAINBOW_TEXT = "<color=#bb00ff>นี่คือเวอร์ชั่นทดสอบของเกย์ C is gay and i love to play </color> <color=#FF0000>Ho</color><color=#FF7F00>nk</color><color=#FFFF00>ai</color> <color=#00FF00>St</color><color=#0000FF>ar</color> <color=#4B0082>Gay</color>"
local RAINBOW_TEXT = "<color=#bb00ff>นี่คือเวอร์ชั่นทดสอบของเกย์ </color> <color=#FF0000>Ho</color><color=#FF7F00>nk</color><color=#FFFF00>ai</color> <color=#00FF00>St</color><color=#0000FF>ar</color> <color=#4B0082>Gay</color>"
local function show_ui(msg)
pcall(function()
@@ -71,8 +74,23 @@ end
function _G.__FREECAM_TICK()
pcall(function()
-- Render callbacks run for several cameras/passes in one frame. Consume
-- key-down events and integrate movement only once per Unity frame.
local frame = Time.frameCount
if _G.__FC_INPUT_FRAME == frame then
if _G.__FREECAM_ENABLED then
local cam = get_active_cam()
if cam and _G.__FC_POS then
cam.transform.position = _G.__FC_POS
cam.transform.eulerAngles = _G.__FC_EULER
end
end
return
end
_G.__FC_INPUT_FRAME = frame
if Input.GetKeyDown(KeyCode.Tab) then
_G.__FREECAM_ENABLED = not _G.__FREECAM_ENABLED
_G.__FC_LAST_TIME = nil
local cam = get_active_cam()
if _G.__FREECAM_ENABLED then
if cam then
@@ -80,7 +98,7 @@ function _G.__FREECAM_TICK()
_G.__FC_EULER = cam.transform.eulerAngles
set_cinemachine_enabled(cam, false)
end
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrow Keys: Fly | Right-Click: Look | WASD: Walk</color>")
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | I/J/K/L: Look</color>")
else
if cam then
set_cinemachine_enabled(cam, true)
@@ -107,19 +125,23 @@ function _G.__FREECAM_TICK()
-- Speed adjustment (+ / -)
if Input.GetKeyDown(KeyCode.Equals) or Input.GetKeyDown(KeyCode.KeypadPlus) then
_G.__FREECAM_SPEED = math.min(300.0, _G.__FREECAM_SPEED * 1.5)
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrow Keys: Fly</color>")
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | I/J/K/L: Look</color>")
end
if Input.GetKeyDown(KeyCode.Minus) or Input.GetKeyDown(KeyCode.KeypadMinus) then
_G.__FREECAM_SPEED = math.max(0.5, _G.__FREECAM_SPEED / 1.5)
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrow Keys: Fly</color>")
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | I/J/K/L: Look</color>")
end
-- Mouse Look (Hold Right Mouse Button)
if Input.GetMouseButton(1) then
local rotX = Input.GetAxis("Mouse X") or 0
local rotY = Input.GetAxis("Mouse Y") or 0
_G.__FC_EULER = Vector3(_G.__FC_EULER.x - rotY * 2.5, _G.__FC_EULER.y + rotX * 2.5, 0)
end
-- Keyboard-only look: no mouse click is needed, so turning
-- the camera does not require pressing a battle action button.
local pitch, yaw = 0, 0
if Input.GetKey(KeyCode.I) then pitch = pitch - 1 end
if Input.GetKey(KeyCode.K) then pitch = pitch + 1 end
if Input.GetKey(KeyCode.J) then yaw = yaw - 1 end
if Input.GetKey(KeyCode.L) then yaw = yaw + 1 end
_G.__FC_EULER = Vector3(
_G.__FC_EULER.x + pitch * KEYBOARD_TURN_SPEED * dt,
_G.__FC_EULER.y + yaw * KEYBOARD_TURN_SPEED * dt, 0)
-- Compute 3D direction vectors from camera Euler angles
local radY = _G.__FC_EULER.y * (math.pi / 180.0)
@@ -144,10 +166,10 @@ function _G.__FREECAM_TICK()
if Input.GetKey(KeyCode.LeftArrow) then
move = move - right
end
if Input.GetKey(KeyCode.PageUp) or Input.GetKey(KeyCode.E) or Input.GetKey(KeyCode.RightShift) then
if Input.GetKey(KeyCode.PageUp) then
move = move + Vector3(0, 1, 0)
end
if Input.GetKey(KeyCode.PageDown) or Input.GetKey(KeyCode.Q) or Input.GetKey(KeyCode.RightControl) then
if Input.GetKey(KeyCode.PageDown) then
move = move - Vector3(0, 1, 0)
end
+88 -135
View File
@@ -1,158 +1,111 @@
-- ==============================================================================
-- Hide UI Lua Script for Honkai: Star Rail
-- Press "ฃ" (Backslash '\' on keyboard) to toggle UI visibility
-- Completely hides 100% of UI elements (HUD, Minimap, UID, Texts, Battle UI, WorldUI)
-- Matches the clean visual result of 3Dmigoto/SRMI shader-override mod!
-- ==============================================================================
-- Backslash toggles HUD only. Keep pages, dialogs and UI cameras visible.
local Input = CS.UnityEngine.Input
local KeyCode = CS.UnityEngine.KeyCode
local Time = CS.UnityEngine.Time
local GameObject = CS.UnityEngine.GameObject
local CanvasGroup = CS.UnityEngine.CanvasGroup
local Canvas = CS.UnityEngine.Canvas
local Camera = CS.UnityEngine.Camera
-- Names observed under UIRoot in this Client, plus the existing battle HUD.
-- Never target an ancestor shared by HUD and character/reward dialogs.
local HUD_PATHS = {
"UIRoot/Page/MazeMainPage(Clone)",
"UIRoot/Page/BattleGamePhaseUI(Clone)",
"UIRoot/BelowPage/AdventureWaypointsPage(Clone)",
"UIRoot/BelowPage/MazeJumpingText(Clone)",
"UIRoot/BelowPage/MazeMonsterWarningPage(Clone)",
"UIRoot/AboveDialog/BetaHintDialog(Clone)",
"VersionText"
}
-- The old script did not record CanvasGroup state. Undo its blanket hide once
-- on live upgrade; a fresh Client session avoids any lost legacy fade state.
if _G.__HIDE_UI_REGISTERED and _G.__HIDE_UI_SCHEMA ~= 2 and _G.__HIDE_UI_ENABLED then
pcall(function()
local cams = Camera.allCameras
for i = 0, cams.Length - 1 do
local mask = (_G.__ORIG_CAM_MASKS or {})[cams[i]:GetInstanceID()]
if mask ~= nil then cams[i].cullingMask = mask end
end
local function restore(obj)
if obj then
local cg = obj:GetComponent(typeof(CanvasGroup))
if cg then cg.alpha = 1; cg.blocksRaycasts = true; cg.interactable = true end
end
end
local root = GameObject.Find("UIRoot")
if root then
restore(root)
local t = root.transform
for i = 0, t.childCount - 1 do
local c = t:GetChild(i); restore(c.gameObject)
for j = 0, c.childCount - 1 do restore(c:GetChild(j).gameObject) end
end
end
local canvases = GameObject.FindObjectsOfType(typeof(CS.UnityEngine.Canvas))
for i = 0, canvases.Length - 1 do restore(canvases[i].gameObject) end
end)
end
_G.__HIDE_UI_SCHEMA = 2
_G.__HIDE_UI_ENABLED = _G.__HIDE_UI_ENABLED or false
_G.__HIDE_UI_LAST_FRAME = _G.__HIDE_UI_LAST_FRAME or 0
_G.__ORIG_CAM_MASKS = _G.__ORIG_CAM_MASKS or {}
_G.__HIDE_UI_PERIODIC = _G.__HIDE_UI_PERIODIC or 0
_G.__HIDE_UI_TARGETS = _G.__HIDE_UI_TARGETS or {}
local function set_canvas_group_state(obj, visible)
if not obj then return end
pcall(function()
local cg = obj:GetComponent(typeof(CanvasGroup))
if not cg then
cg = obj:AddComponent(typeof(CanvasGroup))
end
if cg then
cg.alpha = visible and 1.0 or 0.0
cg.blocksRaycasts = visible
cg.interactable = visible
end
end)
end
local function apply_camera_culling(visible)
pcall(function()
local allCams = Camera.allCameras
if allCams and allCams.Length > 0 then
for i = 0, allCams.Length - 1 do
local c = allCams[i]
if c and c.name then
local lower = string.lower(c.name)
-- Target UICamera and UI overlay cameras, never touch Main Camera
if (string.find(lower, "ui") ~= nil) and (string.find(lower, "main") == nil) then
local camId = c:GetInstanceID()
if not visible then
if not _G.__ORIG_CAM_MASKS[camId] and c.cullingMask ~= 0 then
_G.__ORIG_CAM_MASKS[camId] = c.cullingMask
end
c.cullingMask = 0
else
if _G.__ORIG_CAM_MASKS[camId] ~= nil and _G.__ORIG_CAM_MASKS[camId] ~= 0 then
c.cullingMask = _G.__ORIG_CAM_MASKS[camId]
else
c.cullingMask = 32 -- Default layer 5 (UI)
end
end
end
end
end
end
end)
end
local function apply_ui_root(visible)
pcall(function()
local uiRoot = GameObject.Find("/UIRoot") or GameObject.Find("UIRoot")
if uiRoot then
set_canvas_group_state(uiRoot, visible)
local tr = uiRoot.transform
for i = 0, tr.childCount - 1 do
local child = tr:GetChild(i).gameObject
set_canvas_group_state(child, visible)
local ctr = child.transform
for j = 0, ctr.childCount - 1 do
set_canvas_group_state(ctr:GetChild(j).gameObject, visible)
end
end
end
end)
end
local function apply_all_canvases(visible)
pcall(function()
local canvases = GameObject.FindObjectsOfType(typeof(Canvas))
if canvases and canvases.Length > 0 then
for i = 0, canvases.Length - 1 do
local cv = canvases[i]
if cv and cv.gameObject then
set_canvas_group_state(cv.gameObject, visible)
end
end
end
end)
end
local function set_ui_visible(visible)
pcall(function()
-- 1. UI Camera Culling Mask
apply_camera_culling(visible)
-- 2. Entire /UIRoot tree (Page, HUD, Dialog, AboveDialog, Top, Popup, Guide, etc.)
apply_ui_root(visible)
-- 3. Specific known clones / watermarks
local function restore_hud()
for id, state in pairs(_G.__HIDE_UI_TARGETS) do
pcall(function()
local specific = {
"UIRoot/Page/BattleGamePhaseUI(Clone)",
"UIRoot/AboveDialog/BetaHintDialog(Clone)",
"VersionText"
}
for _, path in ipairs(specific) do
local obj = GameObject.Find(path)
if obj then
set_canvas_group_state(obj, visible)
if state.group then
if state.added then
CS.UnityEngine.Object.Destroy(state.group)
else
state.group.alpha = state.alpha
state.group.blocksRaycasts = state.raycast
state.group.interactable = state.interactable
end
end
end)
_G.__HIDE_UI_TARGETS[id] = nil
end
end
-- 4. All active Canvases in scene (ScreenSpace & WorldSpace billboards/nameplates)
apply_all_canvases(visible)
end)
local function hide_hud()
for _, path in ipairs(HUD_PATHS) do
pcall(function()
local obj = GameObject.Find(path)
if not obj then return end
local id = obj:GetInstanceID()
local state = _G.__HIDE_UI_TARGETS[id]
if not state then
local group = obj:GetComponent(typeof(CanvasGroup))
local added = not group
if added then group = obj:AddComponent(typeof(CanvasGroup)) end
state = {group=group, added=added, alpha=group.alpha,
raycast=group.blocksRaycasts, interactable=group.interactable}
_G.__HIDE_UI_TARGETS[id] = state
end
state.group.alpha = 0
state.group.blocksRaycasts = false
state.group.interactable = false
end)
end
end
function _G.__HIDE_UI_TICK()
pcall(function()
local curFrame = Time.frameCount
if _G.__HIDE_UI_LAST_FRAME == curFrame then
return
end
_G.__HIDE_UI_LAST_FRAME = curFrame
-- Physical key '\' / 'ฃ' (KeyCode.Backslash)
if Input.GetKeyDown(KeyCode.Backslash) then
local frame = Time.frameCount
if _G.__HIDE_UI_LAST_FRAME == frame then return end
_G.__HIDE_UI_LAST_FRAME = frame
local toggled = Input.GetKeyDown(KeyCode.Backslash)
if toggled then
_G.__HIDE_UI_ENABLED = not _G.__HIDE_UI_ENABLED
set_ui_visible(not _G.__HIDE_UI_ENABLED)
if _G.__HIDE_UI_ENABLED then
CS.UnityEngine.Debug.Log("<color=#FF00FF>[UI Toggle]</color> <color=#FF5555>UI Hidden (ซ่อน UI ทั้งหมด 100% เรียบร้อย)</color>")
else
CS.UnityEngine.Debug.Log("<color=#FF00FF>[UI Toggle]</color> <color=#55FF55>UI Restored (แสดง UI ตามปกติแล้ว)</color>")
end
if not _G.__HIDE_UI_ENABLED then restore_hud() end
end
-- While hidden, keep enforcing to prevent newly spawned UI from appearing
-- Only the small named HUD list is checked, four times a second.
-- New pages/dialogs never inherit a hidden UIRoot or UI camera.
if _G.__HIDE_UI_ENABLED then
apply_camera_culling(false)
apply_ui_root(false)
_G.__HIDE_UI_PERIODIC = (_G.__HIDE_UI_PERIODIC or 0) + 1
if _G.__HIDE_UI_PERIODIC >= 30 then
_G.__HIDE_UI_PERIODIC = 0
apply_all_canvases(false)
local now = Time.unscaledTime
if toggled or now >= (_G.__HIDE_UI_NEXT_SCAN or 0) then
_G.__HIDE_UI_NEXT_SCAN = now + 0.25
hide_hud()
end
end
end)
@@ -173,5 +126,5 @@ if not _G.__HIDE_UI_REGISTERED then
pcall(function() CS.UnityEngine.Application.onBeforeRender('+', function() _G.__HIDE_UI_TICK() end) end)
pcall(function() CS.UnityEngine.Application.onBeforeRender = CS.UnityEngine.Application.onBeforeRender + function() _G.__HIDE_UI_TICK() end end)
CS.UnityEngine.Debug.Log("<color=#FF00FF>[UI Toggle Script]</color> 100% Clean HideUI loaded! Press 'ฃ' to toggle UI.")
CS.UnityEngine.Debug.Log("<color=#FF00FF>[UI Toggle Script]</color> HUD-only HideUI loaded! Press 'ฃ' to toggle UI.")
end
+1
View File
@@ -1,4 +1,5 @@
{
"player_uid": 52010,
"client_version": "4.6.53",
"game_host": "0.0.0.0",
"game_port": 23301,
+79
View File
@@ -0,0 +1,79 @@
"""Audit every installed Endgame room against stage tables and Client metadata.
This reads data only. It does not establish that a room or battle plays in the
Client, nor does it validate reward/settlement callbacks.
"""
import argparse
import collections
import json
import pathlib
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path(__file__).resolve().parents[1])
parser.add_argument("--output", type=pathlib.Path, required=True)
args = parser.parse_args()
def read(name):
return json.loads((args.root / name).read_text(encoding="utf-8"))
metadata = read("resources/SceneMetadata.json")["floors"]
entrances = {row["ID"]: row for row in read("resources/MapEntrance.json")["map_entrance_config"]}
stages = {row["StageID"]: row for row in read("resources/StageConfig.json")["stage_config"]}
root_scenes = read("res.json")["levelOutputConfigs"]
supplementary = {row["entryID"]: row for row in read("resources/res.json")["scene_config"]}
checks = []
def check(mode, identifier, node, entry, group, events, config_ids):
problems = []
entrance = entrances.get(entry)
floor_id = entrance["FloorID"] if entrance else None
floor = metadata.get(str(floor_id))
if floor is None or len(floor["groups"]) != floor["group_count"]:
problems.append("missing or incomplete Client floor metadata")
else:
group_data = floor["groups"].get(str(group))
if not group_data or group_data["is_deleted"] or group_data["load_side"] == "Client":
problems.append("invalid server group")
if not floor.get("start_anchor"):
problems.append("missing Client start anchor")
placements = floor.get("monsters", {}).get(str(group), {})
for config_id in config_ids:
if str(config_id) not in placements:
problems.append(f"missing Client monster placement {config_id}")
source = root_scenes.get(str(entry), {})
if source:
monsters = [m for scene in source.values() for m in scene["scenes"].get(str(group), {}).get("monsters", [])]
else:
monsters = [m for m in supplementary.get(entry, {}).get("monsters", []) if m["groupId"] == group]
if not monsters:
problems.append("missing scene monster source used by Go loader")
for event in events:
stage = stages.get(event)
if not stage or not stage["MonsterList"] or any(not wave for wave in stage["MonsterList"]):
problems.append(f"missing stage waves {event}")
checks.append({"mode": mode, "id": identifier, "node": node, "entry": entry,
"floor": floor_id, "group": group, "events": events, "issues": problems})
maze = read("resources/ChallengeMazeConfig.json")["challenge_config"]
tierce = read("resources/ChallengeMazeTierceConfig.json")["challenge_tierce_config"]
peak = read("resources/ChallengePeakConfig.json")["challenge_peak_config"]
for row in maze:
mode = "PF" if 20000 < row["ID"] < 30000 else "AS" if row["ID"] > 30000 else "Memory/MoC"
for index in (1, 2):
suffix = str(index)
if row.get("EventIDList" + suffix):
check(mode, row["ID"], index - 1, row["MapEntranceID" + ("2" if index == 2 else "")],
row["MazeGroupID" + suffix], row["EventIDList" + suffix], row["ConfigList" + suffix])
for row in tierce:
check("Tierce", row["ID"], 2, row["MapEntranceID"], row["MazeGroupID"], row["EventIDList"], row["ConfigList"])
for row in peak:
# Peak's selected monster instance is resolved from its configured group.
check("Peak", row["ID"], 0, row["MapEntranceID"], row["MazeGroupID"], row["EventIDList"], [])
by_id = {row["ID"]: row for row in peak}
for row in read("resources/ChallengePeakBossConfig.json")["challenge_peak_boss_config"]:
base = by_id[row["ID"]]
check("Peak hard", row["ID"], 0, base["MapEntranceID"], base["MazeGroupID"], row["HardEventIDList"], [])
report = {"scope": "resource and Client metadata audit; no gameplay verification",
"counts": dict(collections.Counter(row["mode"] for row in checks)),
"checked_rooms": len(checks), "floors": len({row["floor"] for row in checks}),
"issues": [row for row in checks if row["issues"]]}
args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(json.dumps({key: value for key, value in report.items() if key != "issues"}))
print(f"Issues: {len(report['issues'])}; details: {args.output}")
+74
View File
@@ -0,0 +1,74 @@
"""Export complete RuntimeFloor group captures from a Client Player.log.
The capture must include FLOOR/GROUP/END records from a read-only Client
GameCoreConfigLoader probe. Inputs and Client revision are explicit arguments.
"""
import argparse
import hashlib
import json
import pathlib
import re
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("log", type=pathlib.Path)
parser.add_argument("--assembly", type=pathlib.Path, required=True)
parser.add_argument("--client-version", required=True)
parser.add_argument("--output", type=pathlib.Path, required=True)
parser.add_argument("--merge", action="store_true", help="Preserve compatible previously captured floors in output")
args = parser.parse_args()
floors, completed = {}, set()
placements = {}
placement_completed = set()
for line in args.log.read_text(encoding="utf-8", errors="replace").splitlines():
if "[GAC_SCENE_PLACEMENT] " in line:
row = line.split("[GAC_SCENE_PLACEMENT] ", 1)[1]
end_placement = re.fullmatch(r"END (\d+)", row)
if end_placement:
placement_completed.add(end_placement[1])
position = re.fullmatch(r"(ANCHOR|MONSTER) (\d+) (\d+)(?: (\d+))? pos=([^,]+),([^,]+),([^ ]+) rot=(.+)", row)
if position:
kind, floor, first_id, inst_id, x, y, z, rot = position.groups()
# Unity metres/degrees -> protocol position/rotation units.
value = {"pos": {"x": round(float(x)*1000), "y": round(float(y)*1000), "z": round(float(z)*1000)}, "rot": {"x": 0, "y": round(float(rot)*1000), "z": 0}}
target = placements.setdefault(floor, {"monsters": {}})
if kind == "ANCHOR":
value["ID"] = int(first_id)
target["start_anchor"] = value
else:
target["monsters"].setdefault(first_id, {})[inst_id] = value
continue
if "[GAC_SCENE_META]" not in line:
continue
row = line.split("[GAC_SCENE_META] ", 1)[1]
start = re.fullmatch(r"FLOOR (\d+) (\d+) startGroup=(\d+) anchor=(\d+) count=(\d+)", row)
group = re.fullmatch(r"GROUP (\d+) (\d+) side=(\w+): \d+ hoyo=(true|false) deleted=(true|false)", row)
end = re.fullmatch(r"END (\d+)", row)
if start:
plane, floor, start_group, anchor, count = start.groups()
floors[floor] = {"plane_id": int(plane), "group_count": int(count), "groups": {}}
completed.discard(floor)
elif group:
floor, group_id, side, hoyo, deleted = group.groups()
if floor in floors:
floors[floor]["groups"][group_id] = {"load_side": side, "is_hoyo_group": hoyo == "true", "is_deleted": deleted == "true"}
elif end:
completed.add(end[1])
if not floors or any(key not in completed or len(value["groups"]) != value["group_count"] for key, value in floors.items()):
raise SystemExit("Incomplete scene metadata capture; no output written")
for key, value in placements.items():
if key in floors and key in placement_completed:
floors[key].update(value)
result = {"client_version": args.client_version, "source": {
"kind": "Client GameCoreConfigLoader.LoadRtLevelFloorInfo / GroupInstanceCommonMap",
"assembly_sha256": hashlib.file_digest(args.assembly.open("rb"), "sha256").hexdigest(),
"capture_sha256": hashlib.sha256(args.log.read_bytes()).hexdigest(),
}, "floors": floors}
if args.merge and args.output.exists():
previous = json.loads(args.output.read_text(encoding="utf-8"))
if previous["client_version"] != result["client_version"] or previous["source"]["assembly_sha256"] != result["source"]["assembly_sha256"]:
raise SystemExit("Cannot merge metadata from different Clients")
previous["floors"].update(floors)
result["floors"] = previous["floors"]
result["source"]["previous_capture_sha256"] = previous["source"].get("previous_capture_sha256", []) + [previous["source"]["capture_sha256"]]
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Exported {len(floors)} floors to {args.output}")
+74
View File
@@ -0,0 +1,74 @@
-- Read-only template. Replace __FLOOR_INPUTS__ with Lua pairs from the
-- installed MapEntrance table, and __CAPTURE_KEY__ with a unique string.
-- Read a bounded batch per heartbeat; never enter a map or alter entities.
do
local key = '__CAPTURE_KEY__'
_G.__gacSceneExports = _G.__gacSceneExports or {}
local progress = _G.__gacSceneExports[key] or 1
local floors = __FLOOR_INPUTS__
local function log(kind, text)
CS.UnityEngine.Debug.Log('[GAC_SCENE_' .. kind .. '] ' .. tostring(text))
end
local function world(group, item)
local yaw = group.RotY * math.pi / 180
return group.PosX + item.PosX * math.cos(yaw) + item.PosZ * math.sin(yaw),
group.PosY + item.PosY,
group.PosZ - item.PosX * math.sin(yaw) + item.PosZ * math.cos(yaw),
group.RotY + item.RotY
end
local function position(kind, prefix, group, item)
local x, y, z, rot = world(group, item)
log(kind, prefix .. ' pos=' .. tostring(x) .. ',' .. tostring(y) .. ',' .. tostring(z) .. ' rot=' .. tostring(rot))
end
local batchSize = 4
for index = progress, math.min(progress + batchSize - 1, #floors) do
local pair = floors[index]
local ok, err = pcall(function()
local path = CS.RPG.Client.MapDef.GetRtFloorInfoPath(pair[1], pair[2])
local floor = CS.RPG.GameCore.GameCoreConfigLoader.LoadRtLevelFloorInfo(path)
if floor == nil then error('missing RuntimeFloor ' .. path) end
local floorID = tostring(pair[2])
log('META', 'FLOOR ' .. tostring(pair[1]) .. ' ' .. floorID
.. ' startGroup=' .. tostring(floor.StartGroupIndex) .. ' anchor=' .. tostring(floor.StartAnchorID)
.. ' count=' .. tostring(floor.GroupInstanceCommonMap.Count))
local iterator = floor.GroupInstanceCommonMap:GetEnumerator()
while iterator:MoveNext() do
local entry = iterator.Current
log('META', 'GROUP ' .. floorID .. ' ' .. tostring(entry.Key)
.. ' side=' .. tostring(entry.Value.LoadSide) .. ' hoyo=' .. tostring(entry.Value.IsHoyoGroup)
.. ' deleted=' .. tostring(entry.Value.IsDelete))
end
log('META', 'END ' .. floorID)
for i = 0, floor.GroupInstanceList.Length - 1 do
local group = floor.GroupInstanceList[i]
local common = floor.GroupInstanceCommonMap[group.ID]
if not group.IsDelete and common ~= nil and not common.IsDelete
and (i == floor.StartGroupIndex or tostring(common.LoadSide):find('Server', 1, true)) then
local data = CS.RPG.GameCore.GameCoreConfigLoader.LoadRtLevelGroupInfo(group.GroupPath)
if data == nil then error('missing RuntimeGroup ' .. tostring(group.GroupPath)) end
if i == floor.StartGroupIndex and data.AnchorList ~= nil then
for j = 0, data.AnchorList.Length - 1 do
local anchor = data.AnchorList[j]
if not anchor.IsDelete and anchor.ID == floor.StartAnchorID then
position('PLACEMENT', 'ANCHOR ' .. floorID .. ' ' .. tostring(anchor.ID), group, anchor)
end
end
end
if data.MonsterList ~= nil then
for j = 0, data.MonsterList.Length - 1 do
local monster = data.MonsterList[j]
if not monster.IsDelete and not monster.IsClientOnly then
position('PLACEMENT', 'MONSTER ' .. floorID .. ' ' .. tostring(group.ID)
.. ' ' .. tostring(monster.ID), group, monster)
end
end
end
end
end
log('PLACEMENT', 'END ' .. floorID)
end)
if not ok then log('META', 'ERROR ' .. tostring(pair[2]) .. ' ' .. tostring(err)) end
progress = index + 1
end
_G.__gacSceneExports[key] = progress
end