fix: restore battle entry and character profile initialization

This commit is contained in:
CAtlas0784 committed 2026-10-17 11:58:20 +07:00
1 parent cfbbc7b970
commit 5c4d0d4149
16 files changed
+888 -228

No files matched your search

+18 -4
View File
@@ -138,12 +138,13 @@ go test ./...
| รายการ | สถานะ | | รายการ | สถานะ |
| --- | --- | | --- | --- |
| Error 2000 ตอน login | แก้ `GetCurBattleInfo` ให้ตอบสำเร็จพร้อมข้อมูลว่างเมื่อไม่มี battle; รอยืนยันในเกม | | Error 2000 ตอน login | แก้ `GetCurBattleInfo` ให้ตอบสำเร็จพร้อมข้อมูลว่างเมื่อไม่มี battle; รอยืนยันในเกม |
| Calyx เข้า fight ไม่ได้ | ยังอยู่ระหว่างตรวจ `NullReferenceException` ใน `CreateBattleServerCheckResult` | | Calyx | ผู้ใช้ยืนยันว่าเข้า Battle ได้แล้ว 17 ต.ค. หลังแก้เส้นทาง BattleInfo |
| ตีมอนแล้วค้างหน้า Weakness | พบ stack trace ในฟังก์ชันตรวจ battle เดียวกับ Calyx; ยังไม่ยืนยันว่าแก้แล้ว | | ตีมอนแล้วค้างหน้า Weakness | ผู้ใช้ยืนยัน Stagnant Shadow ที่เรียกมอนออกมาให้ตีเข้าสู้ได้แล้ว 17 ต.ค.; log มี `PVEBattleResult` ส่วนมอนทั่วไปยังไม่ได้ยืนยันแยก |
| เปิดหน้าตัวละครแล้วค้าง | ผู้ใช้ยืนยันว่าหายหลังเปิดเกมใหม่; ไม่พบ PlayerBoard/Inventory error ใน log รอบใหม่ |
| วาร์ปแมพใหม่ | ปรับลำดับ response/notify และข้อมูล scene ตาม upstream; ยังต้องยืนยันปลายทางในเกม | | วาร์ปแมพใหม่ | ปรับลำดับ response/notify และข้อมูล scene ตาม upstream; ยังต้องยืนยันปลายทางในเกม |
| `SceneCastSkill` | ใช้รายการ monster entity ที่ server สร้างเพื่อตรวจเป้าหมายก่อนส่ง battle | | `SceneCastSkill` | ใช้รายการ monster entity ที่ server สร้างเพื่อตรวจเป้าหมายก่อนส่ง battle |
ข้อมูลล่าสุดที่ตรวจในซอร์สและเอกสาร: **16 ตุลาคม 2026** ข้อมูลล่าสุดที่ตรวจในซอร์สและเอกสาร: **17 ตุลาคม 2026**
## เก็บข้อมูลเมื่อพบปัญหา ## เก็บข้อมูลเมื่อพบปัญหา
@@ -166,7 +167,20 @@ go test ./...
สำหรับการตรวจ battle ทีละส่วน ดู [บันทึกเทียบ upstream](docs/battle-investigation.md) ค่า `battle_diagnostics.client_battle_type` ใน local config เปิด probe ที่อ่าน payload ด้วย protobuf parser ของ Client และเขียน `[GAC_BATTLE_WIRE]` ลง Player.log ชื่อ class ต้องอ้างอิง stack trace ของ Client รุ่นนั้น ตั้งค่าว่างเพื่อปิด probe สำหรับการตรวจ battle ทีละส่วน ดู [บันทึกเทียบ upstream](docs/battle-investigation.md) ค่า `battle_diagnostics.client_battle_type` ใน local config เปิด probe ที่อ่าน payload ด้วย protobuf parser ของ Client และเขียน `[GAC_BATTLE_WIRE]` ลง Player.log ชื่อ class ต้องอ้างอิง stack trace ของ Client รุ่นนั้น ตั้งค่าว่างเพื่อปิด probe
ผลตรวจล่าสุดพบ relic `63095` ของ Dan Heng IL ใช้ main affix `11` ที่ไม่มีในตารางของลูกแก้ว จึงแก้ inventory เป็น `10` (Imaginary DMG) ใน `freesr-data.json` แล้ว ต้องเปิดเกมใหม่เพื่อโหลดข้อมูลและยืนยันการเข้า battle ผล build ไม่ใช่การยืนยันว่าอาการค้างหายแล้ว ประวัติรอบ 16 ต.ค. พบ main affix ผิดใน inventory ชุดเดิมและแก้ข้อมูลไปแล้ว ผลนั้นไม่ใช่หลักฐานว่า inventory ที่นำเข้าภายหลังหรือการเข้า Battle ปัจจุบันผ่าน ดูผลแต่ละรอบในบันทึกตรวจสอบ
### การแก้ Battle รอบ 17 ตุลาคม 2026
- `SceneCastSkill` อ่านทั้ง hit targets, assist IDs และ assist groups และตอบ `MonsterBattleType=TRIGGER_BATTLE` ตาม enum ของต้นฉบับ; ค่าเริ่มต้นศูนย์หมายถึง `NONE`
- โหลดธาตุตัวละครจาก `resources/AvatarConfig.json` เพิ่มเติม เพราะ `avatarConfigs` ใน `res.json` อาจว่าง
- กฎบัฟเฉพาะตัวละคร, global passive, entry attack และ invasion อยู่ใน `resources/BattleRules.json` พร้อม upstream revision ต้องนำไฟล์นี้ไปด้วยเมื่อแจก binary; ไม่มีการฝัง ID ของกฎเหล่านี้เป็น fallback ในตัว executable
- Battle SP แปลงจากอัตราส่วนในข้อมูลที่นำเข้าเป็นสเกล 10000 และ HP มอนที่เป็นศูนย์คงศูนย์ตามรูปแบบ upstream
- Invasion ใช้สถานะ challenge ที่โหลดจริง `battle_config.enable_invasion_support` เปิดบัฟ support เพิ่มได้ (ค่าเริ่มต้น false)
- เลิกสร้าง Material ID ด้วยสูตร `130000 + AvatarID` ซึ่ง Client หาไม่พบ และไม่ตอบข้อมูลว่างให้โมดูล RogueTourn/ChenLing ที่ยังไม่รองรับ
สถานะรอบนี้: build ผ่าน วาง binary ที่ root แล้ว ผู้ใช้ยืนยันทั้ง Calyx และ Stagnant Shadow เข้า Battle ได้ โดย log ของเส้นทางตีมอนเวลา 11:53:08 มี BattleInfo และเวลา 11:53:16 มี `PVEBattleResult` ผลนี้ยังไม่ใช่การรับรองโหมดต่อสู้ทุกประเภท
แก้โปรไฟล์เพิ่มเติม: field 4 ของ GetPlayerBoardDataScRsp คือการ์ดส่วนตัว, field 8 คือรูปโปรไฟล์ และ field 9 คือตัวละครช่วยเหลือ โค้ดเดิมใส่ ID ผิดประเภทและไม่มี head frame/display data ปรับให้ใช้ `resources/AvatarPlayerIcon.json`, `resources/ProfileConfig.json` และทีมจริง พร้อมเติม ArchiveData ทั้งสอง response ผู้ใช้ยืนยันหน้าตัวละครหายค้างหลังเปิดเกมใหม่
## โครงสร้างซอร์ส ## โครงสร้างซอร์ส
+38
View File
@@ -75,3 +75,41 @@ Calyx และ overworld ตายที่ stack เดียวกัน ส
4. เปลี่ยนเฉพาะส่วนที่มีหลักฐาน แล้วตรวจ Calyx และ overworld battle แยกกัน 4. เปลี่ยนเฉพาะส่วนที่มีหลักฐาน แล้วตรวจ Calyx และ overworld battle แยกกัน
สถานะ: ยังไม่มีการยืนยันว่าเข้า fight ได้ สถานะ: ยังไม่มีการยืนยันว่าเข้า fight ได้
## รอบ 17 ตุลาคม 2026: แก้เส้นทางร่วมและรอผล Client
ตรวจ source ปัจจุบันก่อนแก้แล้วพบดังนี้:
1. `HitMonsterBattleInfo` ไม่ได้ตั้ง `monster_battle_type` จึงส่งค่า NONE=0 แทน TRIGGER_BATTLE=1 (อ้างอิง enum ใน `protocol/src/protocol.pb.zig` ของ revision ที่ระบุข้างต้น) แก้ให้ระบุ enum ชัดเจน
2. เพิ่มการอ่าน `assist_monster_entity_info.entity_id_list` ควบคู่รายการเป้าหมายอีกสองช่อง ยังคงตรวจว่าเป็นมอนที่ส่งในฉากจริง และบันทึก incoming IDs, รายการมอนในฉากและผลจับคู่ ไม่มีการสร้างเป้าหมายปลอม
3. `res.json` ชุดปัจจุบันมี `avatarConfigs` ว่าง ตัวโหลดเดิมไม่ได้ใช้ `AvatarConfig.json` จึงหา entry attack buff ไม่ได้ เพิ่มการโหลดธาตุจากตารางนี้และ mapping ธาตุจาก resource กฎ Battle
4. ปรับ owner, targets และ dynamic values ของบัฟตาม PearlSR รวมถึง entry attack สำหรับ Calyx แยก invasion ของ challenge ออกจาก custom stage และสร้าง map ก่อนใส่ battle targets
5. กฎเฉพาะตัวละครแยกเป็น `resources/BattleRules.json` ระบุ revision ต้นทาง รองรับการเปิด/ปิด global passive จาก persistent ตามเดิม
6. แปลง Battle SP จากสัดส่วน Cur/Max เป็นสเกล 10000 และเลิกบังคับ Monster MaxHP=10000 เมื่อค่าต้นทางเป็นศูนย์
7. ลบการเดา Material IDs ด้วย 130000+AvatarID ซึ่งตรงกับ `TryCreateItemData fail` ใน Client และกัน automatic empty response ของ RogueTourn/ChenLing ที่มี NRE ใน log
ข้อจำกัด: log ตีมอนล่าสุดก่อนแก้แสดงว่าไม่พบเป้าหมายที่ผ่าน filter แต่ไม่มี incoming ID list จึงยังไม่ทราบเหตุที่จับคู่ไม่ผ่าน ส่วน capture Calyx ที่มีอยู่เป็นข้อมูลวันที่ 16 ต.ค. ไม่ใช่ทีมที่นำเข้าภายหลัง ชุด build รอบนี้เพิ่มข้อมูลที่จำเป็นต่อการแยกสองกรณีนี้
การคัดลอก config.json จาก PearlSR ไม่ได้ทำให้ Go อ่านไฟล์นั้น Go ยังคงใช้ freesr-data.json/persistent สำหรับทีมและ custom battle การใช้ custom stage กับดอกไม้มีในต้นฉบับเช่นกัน จึงไม่ถือเป็น regression ด้วยเหตุผลนั้นเพียงข้อเดียว
Build ผ่านและวาง gameserver-go.exe ที่ root แล้ว เปิด SDK/Game server พร้อมคอนโซลเพื่อให้ผู้ใช้ลองทั้ง Calyx และ overworld ผลใน Client ยังรอยืนยัน ไม่มีการแก้ข้อมูลทีมใน freesr-data.json
### ผลลองจริง 11:53 และการแก้หน้าตัวละคร
- 11:53:08 `SceneCastSkill` รับ assist target 30007 และจับคู่กับมอนในฉากได้ ส่ง response พร้อม BattleInfo ขนาด 2286 ไบต์ snapshot SHA-256 battle `f9bb3cf79debf6678f9f466e917e6625e87793aa5fbaf275d90a3f2f39a1fe94` stage 30511021
- 11:53:16 Client ส่ง PVEBattleResultCsReq และผู้ใช้ยืนยันว่าเข้าสู้ได้แล้ว ยังไม่ระบุผลดอกไม้โดยตรง
- พบตัวขวางการลองเพิ่ม: เปิด C แล้วติดหน้าตัวละคร Log มี `_SyncPlayerBoardData`, Inventory `_AddUniqueItems`/`_AddItem` และ GetItemsInTypes
- เทียบต้นฉบับพบ field 4 ของ GetPlayerBoardDataScRsp เป็น unlocked_personal_card_list แต่ Go ส่ง ID รูปโปรไฟล์; field 9 เป็น assist_avatar_id_list แต่ Go ส่ง ID การ์ด อีกทั้งไม่ได้ตั้ง HeadFrameInfo และ DisplayAvatarVec
- ใน assembly จุด return address 0xdd02ad8 อยู่หลัง throw ที่ 0xdd02ad3 ซึ่งรับ branch จากการตรวจ ItemData+0x18 เป็น null ที่ 0xdd02841 สอดคล้องกับการส่ง ID ของผิดประเภท ยังไม่อ้างว่าเป็นข้อมูลอาวุธ/relic เสีย
- แก้ profile fields ให้ตรง schema, อ่าน head icons จาก AvatarPlayerIcon.json และ owned cosmetics จาก ProfileConfig.json ที่ระบุ revision เติม ArchiveData จากข้อมูลผู้เล่นแทน null และไม่ตอบว่างให้ SocialPlay/PetMarble ซึ่งพบ exception เพิ่มในรอบใหม่
- Build รอบโปรไฟล์ผ่านและแทนที่ root executable พร้อมเปิดเซิร์ฟเวอร์ใหม่แล้ว ผลหน้าตัวละครรอเปิดเกมใหม่ยืนยัน
ผู้ใช้ชี้แจงผลสำเร็จว่าเป็น Stagnant Shadow สำหรับวัสดุเลื่อนขั้นที่เรียกมอนออกมาจากเงาแล้วตี ไม่ใช่ผลทดสอบ Calyx หรือมอนทั่วไปทั้งหมด คง Calyx เป็นรายการที่ยังรอตรวจ
หลังเปิดเกมรอบ 11:56 ผู้ใช้ยืนยันอาการหน้าตัวละครหายแล้ว และ Player.log รอบใหม่ไม่พบ PlayerBoard/Inventory/Archive exceptions เดิม ยังพบ `_OnGetCurBattleInfoScRsp` ตอน login หนึ่งครั้ง ซึ่งต้องแยกจากผลสำเร็จของการตีมอน และยังไม่มี Calyx request ใหม่ใน log รอบนี้
### ยืนยันผลท้ายรอบ
ผู้ใช้ตอบคำถามแยกเฉพาะ Calyx ว่า **Calyx เข้าได้แล้ว** จึงยืนยันผลลองใน Client ได้ทั้ง Calyx, Stagnant Shadow ผ่าน SceneCastSkill และการเปิดหน้าตัวละคร หลักฐาน Calyx ในส่วนนี้เป็นคำยืนยันโดยตรงของผู้ใช้ ไม่ใช่การอนุมานจาก build หรือจาก log ของ Stagnant Shadow ผลยังไม่ครอบคลุมโหมด challenge/มอนทั่วไปทุกชนิด และ login GetCurBattleInfo exception เป็นปัญหาที่บันทึกแยกไว้
ตรวจ log หลังคำยืนยันพบ Calyx request cmd 1305 เวลา 11:56:29 และ response cmd 1371 ขนาด 2279 ไบต์ capture เป็น StartQuickCocoonStageRsp stage 30511021, battle SHA-256 `092528d29fc9bdfc025b89d8904d8a4f3ca706bb2cf777726b03f5a29d9233f7` จึงมีหลักฐาน response ของรอบเดียวกันประกอบคำยืนยันด้วย
+27 -9
View File
@@ -199,6 +199,23 @@ func (a *AvatarJson) SPMaxVal() uint32 {
return 10000 return 10000
} }
// Battle SP is a ratio in ten-thousandths (PearlSR createBattleAvatar).
// FreeSR imports may instead carry absolute energy and its capacity; use
// their ratio so both absolute and already scaled imports serialize alike.
const battleRatioScale uint32 = 10000
func (a *AvatarJson) BattleSPCur() uint32 {
maximum := a.SPMaxVal()
if maximum == 0 {
return 0
}
current := a.SPCur()
if current > maximum {
current = maximum
}
return uint32(uint64(current) * uint64(battleRatioScale) / uint64(maximum))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Battle config (freesr-data.json) // Battle config (freesr-data.json)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -253,15 +270,16 @@ type RogueMagicScepter struct {
} }
type BattleConfig struct { type BattleConfig struct {
BattleType BattleType `json:"battle_type"` EnableInvasionSupport bool `json:"enable_invasion_support"`
Monsters [][]Monster `json:"monsters"` BattleType BattleType `json:"battle_type"`
Blessings []BattleBuffJson `json:"blessings"` Monsters [][]Monster `json:"monsters"`
StageID uint32 `json:"stage_id"` Blessings []BattleBuffJson `json:"blessings"`
CycleCount uint32 `json:"cycle_count"` StageID uint32 `json:"stage_id"`
PathResonanceID uint32 `json:"path_resonance_id"` CycleCount uint32 `json:"cycle_count"`
CustomStats []SubAffix `json:"custom_stats"` PathResonanceID uint32 `json:"path_resonance_id"`
Scepters []RogueMagicScepter `json:"scepters"` CustomStats []SubAffix `json:"custom_stats"`
CustomBattleLineup map[uint32]uint32 `json:"custom_battle_lineup"` Scepters []RogueMagicScepter `json:"scepters"`
CustomBattleLineup map[uint32]uint32 `json:"custom_battle_lineup"`
} }
func DefaultBattleConfig() BattleConfig { func DefaultBattleConfig() BattleConfig {
+10 -12
View File
@@ -206,8 +206,8 @@ func (a *AvatarJson) ToBattleAvatarProto(index uint32, lightcone *Lightcone, rel
EnhancedId: enhancedID, EnhancedId: enhancedID,
IGDEHGIKFJA: exRanks, IGDEHGIKFJA: exRanks,
SpBar: &goproto.SpBarInfo{ SpBar: &goproto.SpBarInfo{
CurSp: a.SPCur(), CurSp: a.BattleSPCur(),
MaxSp: a.SPMaxVal(), MaxSp: battleRatioScale,
}, },
} }
@@ -227,11 +227,12 @@ func (a *AvatarJson) ToBattleAvatarProto(index uint32, lightcone *Lightcone, rel
var buffs []*goproto.BattleBuff var buffs []*goproto.BattleBuff
for _, buffID := range a.Techniques { for _, buffID := range a.Techniques {
buffs = append(buffs, &goproto.BattleBuff{ buffs = append(buffs, &goproto.BattleBuff{
Id: buffID, Id: buffID,
Level: 1, Level: 1,
OwnerId: index, OwnerId: index,
WaveFlag: 0xffffffff, WaveFlag: 0xffffffff,
DynamicValues: map[string]float32{"SkillIndex": 2.0}, TargetIndexList: []uint32{0},
DynamicValues: map[string]float32{"SkillIndex": 0},
}) })
} }
@@ -310,13 +311,10 @@ func ToLineupInfo(lineups map[uint32]uint32) *goproto.LineupInfo {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func (m *Monster) ToSceneMonster() *goproto.SceneMonster { func (m *Monster) ToSceneMonster() *goproto.SceneMonster {
hp := m.MaxHP
if hp == 0 {
hp = 10000
}
return &goproto.SceneMonster{ return &goproto.SceneMonster{
MonsterId: m.MonsterID, MonsterId: m.MonsterID,
MaxHp: hp, // Zero requests the stage's normal HP, as in PearlSR addMonsterWaves.
MaxHp: m.MaxHP,
} }
} }
+18 -1
View File
@@ -75,6 +75,7 @@ type LevelOutputConfig struct {
type AvatarConfig struct { type AvatarConfig struct {
WeaknessBuffID uint32 `json:"weaknessBuffId"` WeaknessBuffID uint32 `json:"weaknessBuffId"`
DamageType string `json:"damageType"`
} }
type JsonConfig struct { type JsonConfig struct {
@@ -185,6 +186,23 @@ func GameRes() (*JsonConfig, error) {
} }
func loadSupplementaryResources(res *JsonConfig) { func loadSupplementaryResources(res *JsonConfig) {
// PearlSR's resource export keeps avatar data in AvatarConfig.json;
// res.json may legitimately contain an empty avatarConfigs object.
if raw, err := readResourceFile("resources/AvatarConfig.json"); err == nil {
var avatars struct {
Entries []struct {
ID uint32 `json:"AvatarID"`
DamageType string `json:"DamageType"`
} `json:"avatar_config"`
}
if err := json.Unmarshal(raw, &avatars); err == nil {
for _, avatar := range avatars.Entries {
cfg := res.AvatarConfigs[avatar.ID]
cfg.DamageType = avatar.DamageType
res.AvatarConfigs[avatar.ID] = cfg
}
}
}
entrancesByEntry := map[uint32]MapEntranceEntry{} entrancesByEntry := map[uint32]MapEntranceEntry{}
if raw, err := readResourceFile("resources/MapEntrance.json", "MapEntrance.json"); err == nil { if raw, err := readResourceFile("resources/MapEntrance.json", "MapEntrance.json"); err == nil {
var list []MapEntranceEntry var list []MapEntranceEntry
@@ -369,4 +387,3 @@ func Teleports() map[uint32]map[uint32]TeleportInfo {
}) })
return teleportDB return teleportDB
} }
+191
View File
@@ -0,0 +1,191 @@
package gameserver
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"gac-hkrpg-go/internal/common"
goproto "gac-hkrpg-go/protocol/proto"
)
// Game IDs and character exceptions are resource data with an upstream revision,
// 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 {
Revision string `json:"revision"`
} `json:"source"`
TechniqueBuffs map[uint32][]struct {
ID uint32 `json:"id"`
Owner string `json:"owner"`
DynamicValues map[string]float32 `json:"dynamic_values"`
} `json:"technique_buffs"`
GlobalPassives []struct {
Setting string `json:"setting"`
BuffID uint32 `json:"buff_id"`
OwnerSlot uint32 `json:"owner_slot"`
} `json:"global_passives"`
IgnoreToughness struct {
Avatars []uint32 `json:"avatars"`
BuffID uint32 `json:"buff_id"`
} `json:"ignore_toughness"`
BattleTargets []struct {
BattleType string `json:"battle_type"`
Targets map[uint32][]struct {
ID uint32 `json:"id"`
Progress uint32 `json:"progress"`
TotalProgress uint32 `json:"total_progress"`
} `json:"targets"`
} `json:"battle_targets"`
InvasionBuffs map[uint32]uint32 `json:"invasion_buffs"`
InvasionSupportBuff uint32 `json:"invasion_support_buff"`
}
var battleRulesOnce sync.Once
var loadedBattleRules battleRules
var battleRulesErr error
func loadBattleRules() (*battleRules, error) {
battleRulesOnce.Do(func() {
raw, err := os.ReadFile("resources/BattleRules.json")
if err != nil {
battleRulesErr = err
return
}
if err := json.Unmarshal(raw, &loadedBattleRules); err != nil {
battleRulesErr = err
return
}
if loadedBattleRules.Source.Revision == "" {
battleRulesErr = fmt.Errorf("battle rules need a source revision")
return
}
for _, rules := range loadedBattleRules.TechniqueBuffs {
for _, rule := range rules {
if rule.ID == 0 || (rule.Owner != "avatar" && rule.Owner != "leader") {
battleRulesErr = fmt.Errorf("invalid technique buff rule: %+v", rule)
return
}
}
}
for _, rule := range loadedBattleRules.GlobalPassives {
if rule.BuffID == 0 || rule.OwnerSlot >= 4 {
battleRulesErr = fmt.Errorf("invalid global passive rule: %+v", rule)
return
}
switch rule.Setting {
case "enable_sw_global", "enable_castorice_global", "enable_aha_global":
default:
battleRulesErr = fmt.Errorf("unknown global passive setting %q", rule.Setting)
return
}
}
})
if battleRulesErr != nil {
return nil, fmt.Errorf("load resources/BattleRules.json: %w", battleRulesErr)
}
return &loadedBattleRules, nil
}
func battleBuff(id, owner uint32, dynamic map[string]float32) *goproto.BattleBuff {
values := map[string]float32{"SkillIndex": 0}
for key, value := range dynamic {
values[key] = value
}
return &goproto.BattleBuff{Id: id, Level: 1, OwnerId: owner,
WaveFlag: ^uint32(0), TargetIndexList: []uint32{0}, DynamicValues: values}
}
func (r *battleRules) techniqueBuffs(a *common.AvatarJson, slot, leader uint32, imported []*goproto.BattleBuff) []*goproto.BattleBuff {
if len(a.Techniques) == 0 {
return nil
}
buffs := imported
// Explicit character rules in PearlSR replace the generic technique. The
// user's empty techniques array still disables techniques for that avatar.
if rules, ok := r.TechniqueBuffs[a.AvatarID]; ok {
buffs = nil
for _, rule := range rules {
owner := slot
if rule.Owner == "leader" {
owner = leader
}
buffs = append(buffs, battleBuff(rule.ID, owner, rule.DynamicValues))
}
}
if containsU32(r.IgnoreToughness.Avatars, a.AvatarID) {
found := false
for _, buff := range buffs {
if buff.Id == r.IgnoreToughness.BuffID {
found = true
}
}
if !found {
buffs = append(buffs, battleBuff(r.IgnoreToughness.BuffID, slot, nil))
}
}
// Global passives are handled once, with their configured owner and setting.
result := make([]*goproto.BattleBuff, 0, len(buffs))
for _, buff := range buffs {
global := false
for _, rule := range r.GlobalPassives {
if buff.Id == rule.BuffID {
global = true
break
}
}
if !global {
result = append(result, buff)
}
}
return result
}
func (r *battleRules) addGlobalPassives(battle *goproto.SceneBattleInfo, player *common.FreesrData) {
for _, rule := range r.GlobalPassives {
var enabled *bool
switch rule.Setting {
case "enable_sw_global":
enabled = player.EnableSWGlobal
case "enable_castorice_global":
enabled = player.EnableCastoriceGlobal
case "enable_aha_global":
enabled = player.EnableAhaGlobal
}
if enabled != nil && !*enabled {
continue
}
owner := rule.OwnerSlot
// A shorter lineup must not refer to an absent actor.
present := false
for _, avatar := range battle.BattleAvatarList {
if avatar.Index == owner {
present = true
}
}
if !present {
owner = battle.BattleAvatarList[0].Index
}
battle.BuffList = append(battle.BuffList, battleBuff(rule.BuffID, owner, nil))
}
}
func (r *battleRules) addTargets(battle *goproto.SceneBattleInfo, kind common.BattleType) {
for _, rule := range r.BattleTargets {
if !strings.EqualFold(rule.BattleType, string(kind)) {
continue
}
for key, targets := range rule.Targets {
list := &goproto.BattleTargetList{}
for _, target := range targets {
list.BattleTargetList = append(list.BattleTargetList, &goproto.BattleTarget{
Id: target.ID, Progress: target.Progress, TotalProgress: target.TotalProgress})
}
battle.BattleTargetInfo[key] = list
}
}
}
+5
View File
@@ -123,6 +123,11 @@ func rawNamed(request string, fn func(*Session, []byte) error) {
var dangerFamilies = []string{ var dangerFamilies = []string{
"GridFight", "ChessRogue", "EvolveBuild", "ChimeraDuel", "GridFight", "ChessRogue", "EvolveBuild", "ChimeraDuel",
"Jukebox", "CycleScore", "MusicAlbum", "DrinkMaker", "Jukebox", "CycleScore", "MusicAlbum", "DrinkMaker",
// Player.log 2026-10-17: these callbacks dereference state absent from
// the automatic empty response. Leave unsupported modules unanswered.
"RogueTourn", "ChenLing",
// Same failure observed in the fresh verification login.
"SocialPlay", "PetMarble",
} }
func isDangerCmd(cmdID uint16) bool { func isDangerCmd(cmdID uint16) bool {
+113 -186
View File
@@ -10,6 +10,10 @@ import (
goproto "gac-hkrpg-go/protocol/proto" goproto "gac-hkrpg-go/protocol/proto"
) )
// 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
func init() { func init() {
handle("StartCocoonStage", func(s *Session, req *goproto.StartCocoonStageCsReq, rsp *goproto.StartCocoonStageScRsp) error { handle("StartCocoonStage", func(s *Session, req *goproto.StartCocoonStageCsReq, rsp *goproto.StartCocoonStageScRsp) error {
Log(fmt.Sprintf("[BATTLE] StartCocoonStage: CocoonId=%d Wave=%d WorldLevel=%d PropEntityId=%d", req.CocoonId, req.Wave, req.WorldLevel, req.PropEntityId)) Log(fmt.Sprintf("[BATTLE] StartCocoonStage: CocoonId=%d Wave=%d WorldLevel=%d PropEntityId=%d", req.CocoonId, req.Wave, req.WorldLevel, req.PropEntityId))
@@ -17,7 +21,10 @@ func init() {
rsp.PropEntityId = req.PropEntityId rsp.PropEntityId = req.PropEntityId
rsp.CocoonId = req.CocoonId rsp.CocoonId = req.CocoonId
rsp.Wave = req.Wave rsp.Wave = req.Wave
battleInfo := createBattleInfo(s, 0, 0) battleInfo, err := createBattleInfo(s, 0, 0)
if err != nil {
return err
}
CURRENT_BATTLE.Store(s.Token, battleInfo) CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.BattleInfo = battleInfo rsp.BattleInfo = battleInfo
return nil return nil
@@ -29,7 +36,10 @@ func init() {
rsp.Retcode = 0 rsp.Retcode = 0
rsp.CocoonId = req.CocoonId rsp.CocoonId = req.CocoonId
rsp.Wave = req.Wave rsp.Wave = req.Wave
battleInfo := createBattleInfo(s, 0, 0) battleInfo, err := createBattleInfo(s, 0, 0)
if err != nil {
return err
}
CURRENT_BATTLE.Store(s.Token, battleInfo) CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.BattleInfo = battleInfo rsp.BattleInfo = battleInfo
@@ -38,7 +48,10 @@ func init() {
handleNamed("StartQuickFarmElement", "StartQuickFarmElementRsp", func(s *Session, req *goproto.StartQuickFarmElementCsReq, rsp *goproto.StartQuickFarmElementRsp) error { handleNamed("StartQuickFarmElement", "StartQuickFarmElementRsp", func(s *Session, req *goproto.StartQuickFarmElementCsReq, rsp *goproto.StartQuickFarmElementRsp) error {
Log(fmt.Sprintf("[BATTLE] StartQuickFarmElement: FarmID=%d WorldLevel=%d", req.PAOFHFLFFHD, req.WorldLevel)) Log(fmt.Sprintf("[BATTLE] StartQuickFarmElement: FarmID=%d WorldLevel=%d", req.PAOFHFLFFHD, req.WorldLevel))
battleInfo := createBattleInfo(s, 0, 0) battleInfo, err := createBattleInfo(s, 0, 0)
if err != nil {
return err
}
CURRENT_BATTLE.Store(s.Token, battleInfo) CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.Retcode = 0 rsp.Retcode = 0
@@ -86,7 +99,10 @@ func init() {
}) })
handle("SceneEnterStage", func(s *Session, req *goproto.SceneEnterStageCsReq, rsp *goproto.SceneEnterStageScRsp) error { handle("SceneEnterStage", func(s *Session, req *goproto.SceneEnterStageCsReq, rsp *goproto.SceneEnterStageScRsp) error {
battleInfo := createBattleInfo(s, 0, 0) battleInfo, err := createBattleInfo(s, 0, 0)
if err != nil {
return err
}
CURRENT_BATTLE.Store(s.Token, battleInfo) CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.BattleInfo = battleInfo rsp.BattleInfo = battleInfo
return nil return nil
@@ -100,26 +116,8 @@ func init() {
return nil return nil
}) })
// ================================================================ // Consume every target carrier in the current schema. Only entities sent
// SceneCastSkill — THE CAMERA-DETACH BUG FIX // as monsters in this scene may start a battle; never fabricate a target.
// (บัคกล้องหลุดจากตัวละครตอนกดตี)
//
// อาการ: กดโจมตีใน overworld แล้วกล้องหลุดออกจากตัวละคร
//
// สาเหตุ (จากโค้ด Rust เดิม): handler ตอบ_info + monster_battle_info
// ให้ client เข้าสู่การต่อสู้กับ target ที่ "ผ่าน filter แบบหลวมๆ"
// filter เดิม: id > 30000 (monster ในฉาก) || id < 1000 (avatar entity ของผู้เล่น!)
// ถ้า targets ว่างและอยู่ใน arena (entry >= 3000000) ยัง "หลอก" target
// เป็น 30001 อีกชั้น
// พอ client ได้_info กับ "monster" ที่จริงคือ prop/NPC/ตัวผู้เล่นเอง
// หรือ monster ที่ไม่มี event_id มันจะเริ่ม transition ที่โหลดไม่สำเร็จ
// → camera ไม่มี target ให้ตาม → กล้องหลุดจากตัวละคร
//
// FIX: ยอมรับเฉพาะ entity ที่ server spawn เป็น "monster จริง" ในฉากปัจจุบัน
// (เก็บไว้ใน Session.MonsterEntities ตอน load_scene) ถ้าไม่มี monster จริง
// ถูกโจมตี → ตอบ retcode 0 + cast_entity_id เฉยๆ ไม่เข้า
// client จะเล่นแอนิเมชันตีปกติ กล้องค้างกับตัวละคร
// ================================================================
handle("SceneCastSkill", func(s *Session, req *goproto.SceneCastSkillCsReq, rsp *goproto.SceneCastSkillScRsp) error { handle("SceneCastSkill", func(s *Session, req *goproto.SceneCastSkillCsReq, rsp *goproto.SceneCastSkillScRsp) error {
rsp.CastEntityId = req.CastEntityId rsp.CastEntityId = req.CastEntityId
@@ -138,6 +136,14 @@ func init() {
for _, id := range req.AssistMonsterEntityIdList { for _, id := range req.AssistMonsterEntityIdList {
addTarget(id) addTarget(id)
} }
for _, group := range req.AssistMonsterEntityInfo {
for _, id := range group.GetEntityIdList() {
addTarget(id)
}
}
SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d skill_index=%d hits=%v assists=%v groups=%v scene_monsters=%v matched=%v",
req.CastEntityId, req.CasterId, req.SkillIndex, req.HitTargetEntityIdList,
req.AssistMonsterEntityIdList, req.AssistMonsterEntityInfo, s.MonsterEntities, targets)
if len(targets) == 0 { if len(targets) == 0 {
// ไม่มี monster จริงถูกตี — ไม่เข้า (นี่คือ fix หลัก) // ไม่มี monster จริงถูกตี — ไม่เข้า (นี่คือ fix หลัก)
@@ -150,12 +156,16 @@ func init() {
SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d -> with %d monster(s)", SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d -> with %d monster(s)",
req.CastEntityId, req.CasterId, len(targets)) req.CastEntityId, req.CasterId, len(targets))
battleInfo := createBattleInfo(s, req.CasterId, req.SkillIndex) battleInfo, err := createBattleInfo(s, req.CasterId, req.SkillIndex)
if err != nil {
return err
}
CURRENT_BATTLE.Store(s.Token, battleInfo) CURRENT_BATTLE.Store(s.Token, battleInfo)
for _, id := range targets { for _, id := range targets {
rsp.MonsterBattleInfo = append(rsp.MonsterBattleInfo, &goproto.HitMonsterBattleInfo{ rsp.MonsterBattleInfo = append(rsp.MonsterBattleInfo, &goproto.HitMonsterBattleInfo{
TargetMonsterEntityId: id, TargetMonsterEntityId: id,
MonsterBattleType: monsterBattleTrigger,
}) })
} }
rsp.BattleInfo = battleInfo rsp.BattleInfo = battleInfo
@@ -183,23 +193,26 @@ func init() {
} }
// createBattleInfo is the Go port of.rs create_battle_info. // createBattleInfo is the Go port of.rs create_battle_info.
func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBattleInfo { func createBattleInfo(s *Session, casterID, skillIndex uint32) (*goproto.SceneBattleInfo, error) {
player := s.JSON() player := s.JSON()
if player == nil { if player == nil {
Log("data is not set!") return nil, fmt.Errorf("battle: player data is not loaded")
return &goproto.SceneBattleInfo{} }
rules, err := loadBattleRules()
if err != nil {
return nil, err
} }
battleInfo := &goproto.SceneBattleInfo{ battleInfo := &goproto.SceneBattleInfo{
StageId: player.BattleConfig.StageID, StageId: player.BattleConfig.StageID,
LogicRandomSeed: randUint32(), LogicRandomSeed: randUint32(),
BattleId: 1, BattleId: 1,
RoundsLimit: player.BattleConfig.CycleCount, RoundsLimit: player.BattleConfig.CycleCount,
WorldLevel: 6, WorldLevel: 6,
BattleTargetInfo: make(map[uint32]*goproto.BattleTargetList),
} }
isCalyx := casterID == 0 && skillIndex == 0
hasDahlia := false
firstAvatarID := uint32(0) firstAvatarID := uint32(0)
firstAvatarIdx := uint32(9999) firstAvatarIdx := uint32(9999)
@@ -217,14 +230,6 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBat
// avatars // avatars
lineup(func(avatarIndex, lineupID uint32) { lineup(func(avatarIndex, lineupID uint32) {
if firstAvatarID == 0 {
firstAvatarID = lineupID
firstAvatarIdx = avatarIndex
}
if !hasDahlia {
hasDahlia = lineupID == 1321
}
avatarID := lineupID avatarID := lineupID
isTrailblazer := lineupID == 8001 isTrailblazer := lineupID == 8001
isMarch := lineupID == 1001 isMarch := lineupID == 1001
@@ -239,81 +244,51 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBat
return return
} }
if firstAvatarID == 0 {
firstAvatarID = avatarID
firstAvatarIdx = avatarIndex
}
battleAvatar, techs := avatar.ToBattleAvatarProto( battleAvatar, techs := avatar.ToBattleAvatarProto(
avatarIndex, avatarIndex,
player.FindLightconeFor(avatarID), player.FindLightconeFor(avatarID),
player.FindRelicsFor(avatarID), player.FindRelicsFor(avatarID),
) )
battleInfo.BuffList = append(battleInfo.BuffList, techs...) battleInfo.BuffList = append(battleInfo.BuffList, rules.techniqueBuffs(avatar, avatarIndex, firstAvatarIdx, techs)...)
res, err := common.GameRes()
if casterID > 0 && avatarIndex == casterID-1 && err == nil {
if avatarConfig, okc := res.AvatarConfigs[avatarID]; okc && !containsU32(avatar.Techniques, 1000119) {
battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{
Id: avatarConfig.WeaknessBuffID,
Level: 1,
OwnerId: avatarIndex,
WaveFlag: 0xffffffff,
DynamicValues: map[string]float32{"SkillIndex": float32(skillIndex)},
})
}
}
battleInfo.BattleAvatarList = append(battleInfo.BattleAvatarList, battleAvatar) battleInfo.BattleAvatarList = append(battleInfo.BattleAvatarList, battleAvatar)
// hardcoded march buff
if avatar.AvatarID == 1224 {
battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{
Id: 122401,
Level: 3,
WaveFlag: 0xffffffff,
OwnerId: avatarIndex,
DynamicValues: map[string]float32{"#ADF_1": 3, "#ADF_2": 3},
TargetIndexList: []uint32{0},
})
}
}) })
if len(battleInfo.BattleAvatarList) == 0 {
// resolve character-specific technique buffs dynamically return nil, fmt.Errorf("battle: lineup has no available avatars")
firstAvatarAttackID := uint32(0)
if res, err := common.GameRes(); err == nil {
if cfg, ok := res.AvatarConfigs[firstAvatarID]; ok {
firstAvatarAttackID = cfg.WeaknessBuffID
}
} }
len0 := len(battleInfo.BuffList) // PearlSR always supplies an entry attack, including Calyx. Resolve the
hasReplaced := false // actor from the scene's actual slot IDs, rather than assuming cast_entity
var dahliaBuffs []*goproto.BattleBuff // identifies a lineup position.
for i, buff := range battleInfo.BuffList { attackSlot, attackID := firstAvatarIdx, firstAvatarID
isLast := i == len0-1 for _, avatar := range battleInfo.BattleAvatarList {
if casterID != 0 && avatar.Index+1 == casterID {
if buff.Id == 141202 || buff.Id == 141403 { attackSlot, attackID = avatar.Index, avatar.Id
buff.OwnerId = firstAvatarIdx break
continue
}
if hasDahlia && isCalyx && buff.Id >= 1000111 && buff.Id <= 1000117 && !hasReplaced && firstAvatarAttackID != 0 {
buff.Id = firstAvatarAttackID
buff.OwnerId = firstAvatarIdx
hasReplaced = true
}
if hasDahlia && isCalyx && !hasReplaced && firstAvatarAttackID != 0 && isLast {
dahliaBuffs = append(dahliaBuffs,
&goproto.BattleBuff{
Id: 1000121, Level: 1, WaveFlag: 0xffffffff,
TargetIndexList: []uint32{firstAvatarIdx},
},
&goproto.BattleBuff{
Id: firstAvatarAttackID, Level: 1, WaveFlag: 0xffffffff,
TargetIndexList: []uint32{firstAvatarIdx},
DynamicValues: map[string]float32{"SkillIndex": float32(firstAvatarIdx)},
},
)
} }
} }
battleInfo.BuffList = append(battleInfo.BuffList, dahliaBuffs...) res, err := common.GameRes()
if err != nil {
return nil, fmt.Errorf("battle: load avatar resources: %w", err)
}
cfg := res.AvatarConfigs[attackID]
attackBuffID := rules.EntryAttackBuffs[cfg.DamageType]
if attackBuffID == 0 {
attackBuffID = cfg.WeaknessBuffID
}
if attackBuffID != 0 {
entrySkillIndex := skillIndex
if casterID == 0 {
entrySkillIndex = 1
}
battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(attackBuffID, attackSlot, map[string]float32{"SkillIndex": float32(entrySkillIndex)}))
} else {
return nil, fmt.Errorf("battle: avatar %d has no entry attack in res.json", attackID)
}
// custom stats for avatars // custom stats for avatars
for _, stat := range player.BattleConfig.CustomStats { for _, stat := range player.BattleConfig.CustomStats {
@@ -344,15 +319,16 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBat
// blessings // blessings
for _, blessing := range player.BattleConfig.Blessings { for _, blessing := range player.BattleConfig.Blessings {
buff := &goproto.BattleBuff{ buff := &goproto.BattleBuff{
Id: blessing.ID, Id: blessing.ID,
Level: blessing.Level, Level: blessing.Level,
WaveFlag: 0xffffffff, WaveFlag: 0xffffffff,
OwnerId: 0xffffffff, OwnerId: 0xffffffff,
TargetIndexList: []uint32{0},
} }
if blessing.DynamicKey != nil { if blessing.DynamicKey != nil {
buff.DynamicValues = map[string]float32{blessing.DynamicKey.Key: float32(blessing.DynamicKey.Value)} buff.DynamicValues = map[string]float32{blessing.DynamicKey.Key: float32(blessing.DynamicKey.Value)}
} else { } else {
buff.DynamicValues = map[string]float32{} buff.DynamicValues = map[string]float32{"SkillIndex": 0}
} }
for _, dv := range blessing.DynamicValues { for _, dv := range blessing.DynamicValues {
if _, exists := buff.DynamicValues[dv.Key]; exists { if _, exists := buff.DynamicValues[dv.Key]; exists {
@@ -363,31 +339,7 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBat
battleInfo.BuffList = append(battleInfo.BuffList, buff) battleInfo.BuffList = append(battleInfo.BuffList, buff)
} }
// pf score object rules.addTargets(battleInfo, player.BattleConfig.BattleType)
if player.BattleConfig.BattleType == common.BattleTypePF {
if battleInfo.StageId >= 30309011 {
battleInfo.BattleTargetInfo[1] = &goproto.BattleTargetList{
BattleTargetList: []*goproto.BattleTarget{{Id: 10003}},
}
} else {
battleInfo.BattleTargetInfo[1] = &goproto.BattleTargetList{
BattleTargetList: []*goproto.BattleTarget{{Id: 10002}},
}
}
for i := uint32(2); i <= 4; i++ {
battleInfo.BattleTargetInfo[i] = &goproto.BattleTargetList{}
}
battleInfo.BattleTargetInfo[5] = &goproto.BattleTargetList{
BattleTargetList: []*goproto.BattleTarget{{Id: 2001}, {Id: 2002}},
}
}
// Apocalyptic Shadow
if player.BattleConfig.BattleType == common.BattleTypeAS {
battleInfo.BattleTargetInfo[1] = &goproto.BattleTargetList{
BattleTargetList: []*goproto.BattleTarget{{Id: 90005}},
}
}
// SU // SU
if player.BattleConfig.BattleType == common.BattleTypeSU { if player.BattleConfig.BattleType == common.BattleTypeSU {
@@ -403,58 +355,33 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBat
battleInfo.MonsterWaveList = common.ToSceneMonsterWaves(player.BattleConfig.Monsters) battleInfo.MonsterWaveList = common.ToSceneMonsterWaves(player.BattleConfig.Monsters)
battleInfo.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList)) battleInfo.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList))
// global buffs rules.addGlobalPassives(battleInfo, player)
hasCastorice, hasSilverWolf := false, false
hasAha := false
for _, buff := range battleInfo.BuffList {
if buff.Id == 140703 {
hasCastorice = true
}
if buff.Id == 150602 {
hasSilverWolf = true
}
if buff.Id == 151104 {
hasAha = true
}
}
if !hasCastorice && (player.EnableCastoriceGlobal == nil || *player.EnableCastoriceGlobal) {
battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{
Id: 140703, Level: 1, OwnerId: 0xffffffff, WaveFlag: 0xffffffff,
})
}
if !hasSilverWolf && (player.EnableSWGlobal == nil || *player.EnableSWGlobal) {
battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{
Id: 150602, Level: 1, OwnerId: 0xffffffff, WaveFlag: 0xffffffff,
})
}
if !hasAha && (player.EnableAhaGlobal == nil || *player.EnableAhaGlobal) {
battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{
Id: 151104, Level: 1, OwnerId: 0xffffffff, WaveFlag: 0xffffffff,
})
}
// Stage invasion buff (pearl-sr 4.6.53: e.g. Peak 11 stage 4 30511021/30511022 -> buff 3034004) // Invasion support belongs to an active challenge, not a custom battle
if invasionID := getStageInvasionID(battleInfo.StageId); invasionID != 0 { // merely reusing the same stage. The scene loader records that state.
battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{ if s.ChallengeStageID != 0 {
Id: 3034000 + invasionID, if invasionID := getStageInvasionID(s.ChallengeStageID); invasionID != 0 {
Level: 1, buffID, ok := rules.InvasionBuffs[invasionID]
OwnerId: firstAvatarIdx, if !ok {
WaveFlag: 0xffffffff, return nil, fmt.Errorf("battle: invasion %d has no buff resource", invasionID)
TargetIndexList: []uint32{0}, }
DynamicValues: map[string]float32{"SkillIndex": 1}, battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(buffID, firstAvatarIdx, map[string]float32{"SkillIndex": 1}))
}) if player.BattleConfig.EnableInvasionSupport {
// Aha Invasion support buff battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(rules.InvasionSupportBuff, firstAvatarIdx, map[string]float32{"SkillIndex": 1}))
battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{ }
Id: 3034013, }
Level: 1,
OwnerId: firstAvatarIdx,
WaveFlag: 0xffffffff,
TargetIndexList: []uint32{0},
DynamicValues: map[string]float32{"SkillIndex": 1},
})
} }
if len(battleInfo.MonsterWaveList) == 0 {
return nil, fmt.Errorf("battle: stage %d has no configured waves", battleInfo.StageId)
}
for i, wave := range battleInfo.MonsterWaveList {
if len(wave.MonsterList) == 0 {
return nil, fmt.Errorf("battle: stage %d wave %d is empty", battleInfo.StageId, i)
}
}
Log(fmt.Sprintf("[BATTLE] prepared stage=%d avatars=%d waves=%d buffs=%d challenge_stage=%d", battleInfo.StageId, len(battleInfo.BattleAvatarList), len(battleInfo.MonsterWaveList), len(battleInfo.BuffList), s.ChallengeStageID))
return battleInfo return battleInfo, nil
} }
type stageInvasionConfigData struct { type stageInvasionConfigData struct {
@@ -167,6 +167,7 @@ func loadChallengeScene(s *Session, entryID, targetGroupID, monsterID, eventID u
// single boss monster (entity 30001, registered for SceneCastSkill battles) // single boss monster (entity 30001, registered for SceneCastSkill battles)
monsterGroup := &goproto.SceneEntityGroupInfo{GroupId: targetGroupID} monsterGroup := &goproto.SceneEntityGroupInfo{GroupId: targetGroupID}
s.MonsterEntities = map[uint32]uint32{30001: eventID} s.MonsterEntities = map[uint32]uint32{30001: eventID}
s.ChallengeStageID = eventID
monsterGroup.EntityList = append(monsterGroup.EntityList, &goproto.SceneEntityInfo{ monsterGroup.EntityList = append(monsterGroup.EntityList, &goproto.SceneEntityInfo{
InstId: instID, InstId: instID,
GroupId: targetGroupID, GroupId: targetGroupID,
+26 -6
View File
@@ -37,6 +37,25 @@ func (sm *syncMap) Delete(k uint32) { muBattle.Lock(); sm.init(); delete(sm.m, k
func randUint32() uint32 { return rand.Uint32() } func randUint32() uint32 { return rand.Uint32() }
func playerArchive(s *Session) *goproto.ArchiveData {
archive := &goproto.ArchiveData{}
if player := s.JSON(); player != nil {
for id := range player.Avatars {
archive.ArchiveAvatarIdList = append(archive.ArchiveAvatarIdList, id)
}
sortU32Slice(archive.ArchiveAvatarIdList)
seen := map[uint32]bool{}
for _, equipment := range player.Lightcones {
if !seen[equipment.ItemID] {
seen[equipment.ItemID] = true
archive.ArchiveEquipmentIdList = append(archive.ArchiveEquipmentIdList, equipment.ItemID)
}
}
sortU32Slice(archive.ArchiveEquipmentIdList)
}
return archive
}
func init() { func init() {
handle("GetBag", func(s *Session, req *goproto.GetBagCsReq, rsp *goproto.GetBagScRsp) error { handle("GetBag", func(s *Session, req *goproto.GetBagCsReq, rsp *goproto.GetBagScRsp) error {
player := s.JSON() player := s.JSON()
@@ -55,17 +74,18 @@ func init() {
{Tid: 101, Num: 999999}, // Normal Pass {Tid: 101, Num: 999999}, // Normal Pass
{Tid: 102, Num: 999999}, // Special Pass {Tid: 102, Num: 999999}, // Special Pass
} }
for _, aid := range BaseAvatarIDs { // Avatar IDs do not imply item IDs. The old 130000+avatar formula
rsp.MaterialList = append(rsp.MaterialList, &goproto.Material{ // produced nonexistent materials and broke the client's inventory init.
Tid: 130000 + aid,
Num: 10,
})
}
return nil return nil
}) })
handle("GetArchiveData", func(s *Session, req *goproto.GetArchiveDataCsReq, rsp *goproto.GetArchiveDataScRsp) error { handle("GetArchiveData", func(s *Session, req *goproto.GetArchiveDataCsReq, rsp *goproto.GetArchiveDataScRsp) error {
rsp.Retcode = 0 rsp.Retcode = 0
rsp.ArchiveData = playerArchive(s)
return nil
})
handle("GetUpdatedArchiveData", func(s *Session, req *goproto.GetUpdatedArchiveDataCsReq, rsp *goproto.GetUpdatedArchiveDataScRsp) error {
rsp.ArchiveData = playerArchive(s)
return nil return nil
}) })
+35 -10
View File
@@ -95,24 +95,49 @@ func init() {
}) })
handle("GetPlayerBoardData", func(s *Session, req *goproto.GetPlayerBoardDataCsReq, rsp *goproto.GetPlayerBoardDataScRsp) error { handle("GetPlayerBoardData", func(s *Session, req *goproto.GetPlayerBoardDataCsReq, rsp *goproto.GetPlayerBoardDataScRsp) error {
profile, err := loadProfileResources()
if err != nil {
return err
}
rsp.Retcode = 0 rsp.Retcode = 0
rsp.CurrentHeadIconId = 200001 rsp.CurrentHeadIconId = profile.Icons[0]
rsp.CurrentPersonalCardId = 253001 rsp.CurrentPersonalCardId = profile.CurrentPersonalCard
rsp.HeadFrameInfo = profile.HeadFrame
rsp.NBIEJKLENOB = &goproto.DisplayAvatarVec{IsDisplay: true}
rsp.Signature = "Trailblazer" rsp.Signature = "Trailblazer"
rsp.JJDDEKIAOFC = []uint32{200001, 200002, 200101, 200102, 200103, 200104} // PearlSR 005d2c1 GetPlayerBoardDataScRsp: field 4 is personal
rsp.KJAABDABLJJ = []uint32{253001, 253002} // cards, field 8 is head icons, and field 9 is assist avatar IDs.
rsp.JJDDEKIAOFC = profile.PersonalCards
for _, id := range profile.Icons {
rsp.KNINLDPFLMA = append(rsp.KNINLDPFLMA, &goproto.HeadIconData{Id: id})
}
if player := s.JSON(); player != nil {
for _, slot := range player.SortedLineupKeys() {
id := player.Lineups[slot]
if _, ok := player.Avatars[id]; !ok {
continue
}
rsp.KJAABDABLJJ = append(rsp.KJAABDABLJJ, id)
rsp.NBIEJKLENOB.DisplayAvatarList = append(rsp.NBIEJKLENOB.DisplayAvatarList,
&goproto.DisplayAvatarData{AvatarId: id, Pos: slot})
}
}
SceneDebugLog("GET_PLAYER_BOARD_DATA sent") SceneDebugLog("GET_PLAYER_BOARD_DATA sent")
return nil return nil
}) })
handle("GetPhoneData", func(s *Session, req *goproto.GetPhoneDataCsReq, rsp *goproto.GetPhoneDataScRsp) error { handle("GetPhoneData", func(s *Session, req *goproto.GetPhoneDataCsReq, rsp *goproto.GetPhoneDataScRsp) error {
profile, err := loadProfileResources()
if err != nil {
return err
}
rsp.Retcode = 0 rsp.Retcode = 0
rsp.CurPhoneTheme = 1 rsp.CurPhoneTheme = profile.CurrentPhoneTheme
rsp.CurPhoneCase = 200001 rsp.CurPhoneCase = profile.CurrentPhoneCase
rsp.CurChatBubble = 220001 rsp.CurChatBubble = profile.CurrentChatBubble
rsp.OwnedPhoneThemes = []uint32{1} rsp.OwnedPhoneThemes = profile.PhoneThemes
rsp.OwnedPhoneCases = []uint32{200001} rsp.OwnedPhoneCases = profile.PhoneCases
rsp.OwnedChatBubbles = []uint32{220001} rsp.OwnedChatBubbles = profile.ChatBubbles
SceneDebugLog("GET_PHONE_DATA sent") SceneDebugLog("GET_PHONE_DATA sent")
return nil return nil
}) })
+1
View File
@@ -928,6 +928,7 @@ func loadScene(s *Session, entryID uint32, isEnterScene, sendNotify bool, telepo
s.PropEntities = map[uint32]PropEntry{} s.PropEntities = map[uint32]PropEntry{}
s.MonsterEntities = map[uint32]uint32{} s.MonsterEntities = map[uint32]uint32{}
s.ChallengeStageID = 0
for _, groupID := range sortedU32Keys2(scene.Scenes) { for _, groupID := range sortedU32Keys2(scene.Scenes) {
group := scene.Scenes[groupID] group := scene.Scenes[groupID]
+78
View File
@@ -0,0 +1,78 @@
package gameserver
import (
"encoding/json"
"fmt"
"os"
"sync"
goproto "gac-hkrpg-go/protocol/proto"
)
type profileResources struct {
Source struct {
Revision string `json:"revision"`
} `json:"source"`
PersonalCards []uint32 `json:"personal_cards"`
CurrentPersonalCard uint32 `json:"current_personal_card"`
HeadFrame *goproto.HeadFrameInfo `json:"head_frame"`
PhoneCases []uint32 `json:"phone_cases"`
PhoneThemes []uint32 `json:"phone_themes"`
ChatBubbles []uint32 `json:"chat_bubbles"`
CurrentPhoneCase uint32 `json:"current_phone_case"`
CurrentPhoneTheme uint32 `json:"current_phone_theme"`
CurrentChatBubble uint32 `json:"current_chat_bubble"`
Icons []uint32 `json:"-"`
}
var profileOnce sync.Once
var profileData profileResources
var profileError error
func loadProfileResources() (*profileResources, error) {
profileOnce.Do(func() {
raw, err := os.ReadFile("resources/ProfileConfig.json")
if err != nil {
profileError = err
return
}
if err := json.Unmarshal(raw, &profileData); err != nil {
profileError = err
return
}
if profileData.Source.Revision == "" || profileData.HeadFrame == nil || !containsU32(profileData.PersonalCards, profileData.CurrentPersonalCard) {
profileError = fmt.Errorf("profile source, head frame or selected personal card is invalid")
return
}
if !containsU32(profileData.PhoneCases, profileData.CurrentPhoneCase) {
profileError = fmt.Errorf("selected phone case is not owned")
return
}
raw, err = os.ReadFile("resources/AvatarPlayerIcon.json")
if err != nil {
profileError = err
return
}
var icons struct {
Entries []struct {
ID uint32 `json:"ID"`
} `json:"player_icon_config"`
}
if err := json.Unmarshal(raw, &icons); err != nil {
profileError = err
return
}
for _, icon := range icons.Entries {
if icon.ID != 0 {
profileData.Icons = append(profileData.Icons, icon.ID)
}
}
if len(profileData.Icons) == 0 {
profileError = fmt.Errorf("AvatarPlayerIcon.json has no icons")
}
})
if profileError != nil {
return nil, fmt.Errorf("load profile resources: %w", profileError)
}
return &profileData, nil
}
+2
View File
@@ -44,6 +44,8 @@ type Session struct {
// spawned in the current scene. Used by SceneCastSkill to decide whether an // spawned in the current scene. Used by SceneCastSkill to decide whether an
// attack actually hit a monster (the camera-detach bug fix). // attack actually hit a monster (the camera-detach bug fix).
MonsterEntities map[uint32]uint32 MonsterEntities map[uint32]uint32
// Set by the challenge scene loader, cleared when loading an overworld scene.
ChallengeStageID uint32
closed atomic.Bool closed atomic.Bool
closeOnce sync.Once closeOnce sync.Once
+264
View File
@@ -0,0 +1,264 @@
{
"source": {
"repository": "https://git.xeondev.com/HonkaiSlopRail/pearl-sr",
"revision": "005d2c12db4781071f4b5b68e8f364587886a710",
"files": [
"gameserver/src/manager/battle_mgr.zig",
"gameserver/src/data.zig",
"protocol/src/protocol.pb.zig"
]
},
"technique_buffs": {
"1208": [
{
"id": 120801,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 120802,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
}
],
"1224": [
{
"id": 122402,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 122403,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 122401,
"owner": "avatar",
"dynamic_values": {
"#ADF_1": 3.0,
"#ADF_2": 3.0
}
}
],
"1304": [
{
"id": 130403,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 3.0
}
}
],
"1306": [
{
"id": 130601,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 130602,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
}
],
"1308": [
{
"id": 130803,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
}
],
"1309": [
{
"id": 130901,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 130902,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
}
],
"1310": [
{
"id": 131001,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 131002,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 1000112,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
}
],
"1412": [
{
"id": 141201,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 1000121,
"owner": "leader",
"dynamic_values": {
"SkillIndex": 0.0
}
}
],
"1414": [
{
"id": 141401,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 1000121,
"owner": "leader",
"dynamic_values": {
"SkillIndex": 0.0
}
}
],
"1510": [
{
"id": 151001,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 151002,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
}
],
"1503": [
{
"id": 150301,
"owner": "avatar",
"dynamic_values": {
"SkillIndex": 0.0
}
},
{
"id": 1000121,
"owner": "leader",
"dynamic_values": {
"SkillIndex": 0.0
}
}
]
},
"global_passives": [
{
"setting": "enable_castorice_global",
"buff_id": 140703,
"owner_slot": 1
},
{
"setting": "enable_sw_global",
"buff_id": 150602,
"owner_slot": 1
},
{
"setting": "enable_aha_global",
"buff_id": 151104,
"owner_slot": 1
}
],
"ignore_toughness": {
"avatars": [
1006,
1308,
1317
],
"buff_id": 1000119
},
"battle_targets": [
{
"battle_type": "PF",
"targets": {
"1": [
{
"id": 10002,
"total_progress": 80000
}
],
"2": [],
"3": [],
"5": [
{
"id": 2001
},
{
"id": 2002
}
]
}
},
{
"battle_type": "AS",
"targets": {
"1": [
{
"id": 90005,
"progress": 2000
}
]
}
}
],
"invasion_buffs": {
"2": 3034002,
"3": 3034003,
"4": 3034004
},
"invasion_support_buff": 3034013,
"entry_attack_buffs": {
"Physical": 1000111,
"Fire": 1000112,
"Ice": 1000113,
"Thunder": 1000114,
"Wind": 1000115,
"Quantum": 1000116,
"Imaginary": 1000117
}
}
+61
View File
@@ -0,0 +1,61 @@
{
"source": {
"repository": "https://git.xeondev.com/HonkaiSlopRail/pearl-sr",
"revision": "005d2c12db4781071f4b5b68e8f364587886a710",
"files": [
"gameserver/src/services/profile.zig",
"gameserver/src/data.zig",
"protocol/src/protocol.pb.zig"
]
},
"personal_cards": [
253000,
253001,
253002,
253003,
253004,
253005
],
"current_personal_card": 253001,
"head_frame": {
"head_frame_item_id": 226004,
"head_frame_expire_time": 4294967295
},
"phone_cases": [
254000,
254001
],
"phone_themes": [
221000,
221001,
221002,
221003,
221004,
221005,
221006,
221007,
221008,
221009,
221010,
221011,
221012,
221013
],
"chat_bubbles": [
220000,
220001,
220002,
220003,
220004,
220005,
220006,
220007,
220008,
220009,
220010,
220012
],
"current_phone_case": 254001,
"current_phone_theme": 0,
"current_chat_bubble": 0
}