Save endgame progress, team presets, technique support and camera controls
This commit is contained in:
1 parent
a681affdb3
commit
639cef7d19
52 files changed
+11940
-766
No files matched your search
@@ -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 อัตโนมัติ
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ยืนยัน
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
"protocol/src/protocol.pb.zig"
|
||||
]
|
||||
},
|
||||
"challenge_round_limits": {
|
||||
"PF": 4,
|
||||
"AS": 30,
|
||||
"Default": 30
|
||||
},
|
||||
"technique_buffs": {
|
||||
"1208": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,4 +1,5 @@
|
||||
{
|
||||
"player_uid": 52010,
|
||||
"client_version": "4.6.53",
|
||||
"game_host": "0.0.0.0",
|
||||
"game_port": 23301,
|
||||
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
Reference in new issue
Block a user