From 5c4d0d41498a467af59e8fc6163b906ed11ab51f Mon Sep 17 00:00:00 2001 From: CAtlas0784 Date: Sat, 17 Oct 2026 11:58:20 +0700 Subject: [PATCH] fix: restore battle entry and character profile initialization --- README.md | 22 +- docs/battle-investigation.md | 38 +++ internal/common/models.go | 36 ++- internal/common/proto_convert.go | 22 +- internal/common/res.go | 19 +- internal/gameserver/battle_rules.go | 191 +++++++++++ internal/gameserver/dispatch.go | 5 + internal/gameserver/handlers_battle.go | 299 +++++++----------- .../gameserver/handlers_challenge_scene.go | 1 + internal/gameserver/handlers_misc.go | 32 +- internal/gameserver/handlers_player.go | 45 ++- internal/gameserver/handlers_scene.go | 1 + internal/gameserver/profile_resources.go | 78 +++++ internal/gameserver/session.go | 2 + resources/BattleRules.json | 264 ++++++++++++++++ resources/ProfileConfig.json | 61 ++++ 16 files changed, 888 insertions(+), 228 deletions(-) create mode 100644 internal/gameserver/battle_rules.go create mode 100644 internal/gameserver/profile_resources.go create mode 100644 resources/BattleRules.json create mode 100644 resources/ProfileConfig.json diff --git a/README.md b/README.md index 05f2d3d..5690a19 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,13 @@ go test ./... | รายการ | สถานะ | | --- | --- | | Error 2000 ตอน login | แก้ `GetCurBattleInfo` ให้ตอบสำเร็จพร้อมข้อมูลว่างเมื่อไม่มี battle; รอยืนยันในเกม | -| Calyx เข้า fight ไม่ได้ | ยังอยู่ระหว่างตรวจ `NullReferenceException` ใน `CreateBattleServerCheckResult` | -| ตีมอนแล้วค้างหน้า Weakness | พบ stack trace ในฟังก์ชันตรวจ battle เดียวกับ Calyx; ยังไม่ยืนยันว่าแก้แล้ว | +| Calyx | ผู้ใช้ยืนยันว่าเข้า Battle ได้แล้ว 17 ต.ค. หลังแก้เส้นทาง BattleInfo | +| ตีมอนแล้วค้างหน้า Weakness | ผู้ใช้ยืนยัน Stagnant Shadow ที่เรียกมอนออกมาให้ตีเข้าสู้ได้แล้ว 17 ต.ค.; log มี `PVEBattleResult` ส่วนมอนทั่วไปยังไม่ได้ยืนยันแยก | +| เปิดหน้าตัวละครแล้วค้าง | ผู้ใช้ยืนยันว่าหายหลังเปิดเกมใหม่; ไม่พบ PlayerBoard/Inventory error ใน log รอบใหม่ | | วาร์ปแมพใหม่ | ปรับลำดับ response/notify และข้อมูล scene ตาม upstream; ยังต้องยืนยันปลายทางในเกม | | `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 -ผลตรวจล่าสุดพบ 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 ผู้ใช้ยืนยันหน้าตัวละครหายค้างหลังเปิดเกมใหม่ ## โครงสร้างซอร์ส diff --git a/docs/battle-investigation.md b/docs/battle-investigation.md index acc26c0..a9780f7 100644 --- a/docs/battle-investigation.md +++ b/docs/battle-investigation.md @@ -75,3 +75,41 @@ Calyx และ overworld ตายที่ stack เดียวกัน ส 4. เปลี่ยนเฉพาะส่วนที่มีหลักฐาน แล้วตรวจ Calyx และ overworld battle แยกกัน สถานะ: ยังไม่มีการยืนยันว่าเข้า 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 ของรอบเดียวกันประกอบคำยืนยันด้วย diff --git a/internal/common/models.go b/internal/common/models.go index 19a3130..3386e80 100644 --- a/internal/common/models.go +++ b/internal/common/models.go @@ -199,6 +199,23 @@ func (a *AvatarJson) SPMaxVal() uint32 { 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) // --------------------------------------------------------------------------- @@ -253,15 +270,16 @@ type RogueMagicScepter struct { } type BattleConfig struct { - BattleType BattleType `json:"battle_type"` - Monsters [][]Monster `json:"monsters"` - Blessings []BattleBuffJson `json:"blessings"` - StageID uint32 `json:"stage_id"` - CycleCount uint32 `json:"cycle_count"` - PathResonanceID uint32 `json:"path_resonance_id"` - CustomStats []SubAffix `json:"custom_stats"` - Scepters []RogueMagicScepter `json:"scepters"` - CustomBattleLineup map[uint32]uint32 `json:"custom_battle_lineup"` + EnableInvasionSupport bool `json:"enable_invasion_support"` + BattleType BattleType `json:"battle_type"` + Monsters [][]Monster `json:"monsters"` + Blessings []BattleBuffJson `json:"blessings"` + StageID uint32 `json:"stage_id"` + CycleCount uint32 `json:"cycle_count"` + PathResonanceID uint32 `json:"path_resonance_id"` + CustomStats []SubAffix `json:"custom_stats"` + Scepters []RogueMagicScepter `json:"scepters"` + CustomBattleLineup map[uint32]uint32 `json:"custom_battle_lineup"` } func DefaultBattleConfig() BattleConfig { diff --git a/internal/common/proto_convert.go b/internal/common/proto_convert.go index 5c22d94..25da376 100644 --- a/internal/common/proto_convert.go +++ b/internal/common/proto_convert.go @@ -206,8 +206,8 @@ func (a *AvatarJson) ToBattleAvatarProto(index uint32, lightcone *Lightcone, rel EnhancedId: enhancedID, IGDEHGIKFJA: exRanks, SpBar: &goproto.SpBarInfo{ - CurSp: a.SPCur(), - MaxSp: a.SPMaxVal(), + CurSp: a.BattleSPCur(), + MaxSp: battleRatioScale, }, } @@ -227,11 +227,12 @@ func (a *AvatarJson) ToBattleAvatarProto(index uint32, lightcone *Lightcone, rel var buffs []*goproto.BattleBuff for _, buffID := range a.Techniques { buffs = append(buffs, &goproto.BattleBuff{ - Id: buffID, - Level: 1, - OwnerId: index, - WaveFlag: 0xffffffff, - DynamicValues: map[string]float32{"SkillIndex": 2.0}, + Id: buffID, + Level: 1, + OwnerId: index, + WaveFlag: 0xffffffff, + 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 { - hp := m.MaxHP - if hp == 0 { - hp = 10000 - } return &goproto.SceneMonster{ MonsterId: m.MonsterID, - MaxHp: hp, + // Zero requests the stage's normal HP, as in PearlSR addMonsterWaves. + MaxHp: m.MaxHP, } } diff --git a/internal/common/res.go b/internal/common/res.go index 36148c5..735111c 100644 --- a/internal/common/res.go +++ b/internal/common/res.go @@ -75,6 +75,7 @@ type LevelOutputConfig struct { type AvatarConfig struct { WeaknessBuffID uint32 `json:"weaknessBuffId"` + DamageType string `json:"damageType"` } type JsonConfig struct { @@ -185,6 +186,23 @@ func GameRes() (*JsonConfig, error) { } 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{} if raw, err := readResourceFile("resources/MapEntrance.json", "MapEntrance.json"); err == nil { var list []MapEntranceEntry @@ -369,4 +387,3 @@ func Teleports() map[uint32]map[uint32]TeleportInfo { }) return teleportDB } - diff --git a/internal/gameserver/battle_rules.go b/internal/gameserver/battle_rules.go new file mode 100644 index 0000000..ef8c254 --- /dev/null +++ b/internal/gameserver/battle_rules.go @@ -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 + } + } +} diff --git a/internal/gameserver/dispatch.go b/internal/gameserver/dispatch.go index 4a780c3..0ea0372 100644 --- a/internal/gameserver/dispatch.go +++ b/internal/gameserver/dispatch.go @@ -123,6 +123,11 @@ func rawNamed(request string, fn func(*Session, []byte) error) { var dangerFamilies = []string{ "GridFight", "ChessRogue", "EvolveBuild", "ChimeraDuel", "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 { diff --git a/internal/gameserver/handlers_battle.go b/internal/gameserver/handlers_battle.go index e37a006..e62b332 100644 --- a/internal/gameserver/handlers_battle.go +++ b/internal/gameserver/handlers_battle.go @@ -10,6 +10,10 @@ import ( 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() { 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)) @@ -17,7 +21,10 @@ func init() { rsp.PropEntityId = req.PropEntityId rsp.CocoonId = req.CocoonId 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) rsp.BattleInfo = battleInfo return nil @@ -29,7 +36,10 @@ func init() { rsp.Retcode = 0 rsp.CocoonId = req.CocoonId 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) rsp.BattleInfo = battleInfo @@ -38,7 +48,10 @@ func init() { 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)) - battleInfo := createBattleInfo(s, 0, 0) + battleInfo, err := createBattleInfo(s, 0, 0) + if err != nil { + return err + } CURRENT_BATTLE.Store(s.Token, battleInfo) rsp.Retcode = 0 @@ -86,7 +99,10 @@ func init() { }) 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) rsp.BattleInfo = battleInfo return nil @@ -100,26 +116,8 @@ func init() { return nil }) - // ================================================================ - // SceneCastSkill — THE CAMERA-DETACH BUG FIX - // (บัคกล้องหลุดจากตัวละครตอนกดตี) - // - // อาการ: กดโจมตีใน 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 จะเล่นแอนิเมชันตีปกติ กล้องค้างกับตัวละคร - // ================================================================ + // Consume every target carrier in the current schema. Only entities sent + // as monsters in this scene may start a battle; never fabricate a target. handle("SceneCastSkill", func(s *Session, req *goproto.SceneCastSkillCsReq, rsp *goproto.SceneCastSkillScRsp) error { rsp.CastEntityId = req.CastEntityId @@ -138,6 +136,14 @@ func init() { for _, id := range req.AssistMonsterEntityIdList { 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 { // ไม่มี monster จริงถูกตี — ไม่เข้า (นี่คือ fix หลัก) @@ -150,12 +156,16 @@ func init() { SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d -> with %d monster(s)", 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) for _, id := range targets { rsp.MonsterBattleInfo = append(rsp.MonsterBattleInfo, &goproto.HitMonsterBattleInfo{ TargetMonsterEntityId: id, + MonsterBattleType: monsterBattleTrigger, }) } rsp.BattleInfo = battleInfo @@ -183,23 +193,26 @@ func init() { } // 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() if player == nil { - Log("data is not set!") - return &goproto.SceneBattleInfo{} + return nil, fmt.Errorf("battle: player data is not loaded") + } + + rules, err := loadBattleRules() + if err != nil { + return nil, err } battleInfo := &goproto.SceneBattleInfo{ - StageId: player.BattleConfig.StageID, - LogicRandomSeed: randUint32(), - BattleId: 1, - RoundsLimit: player.BattleConfig.CycleCount, - WorldLevel: 6, + StageId: player.BattleConfig.StageID, + LogicRandomSeed: randUint32(), + BattleId: 1, + RoundsLimit: player.BattleConfig.CycleCount, + WorldLevel: 6, + BattleTargetInfo: make(map[uint32]*goproto.BattleTargetList), } - isCalyx := casterID == 0 && skillIndex == 0 - hasDahlia := false firstAvatarID := uint32(0) firstAvatarIdx := uint32(9999) @@ -217,14 +230,6 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBat // avatars lineup(func(avatarIndex, lineupID uint32) { - if firstAvatarID == 0 { - firstAvatarID = lineupID - firstAvatarIdx = avatarIndex - } - if !hasDahlia { - hasDahlia = lineupID == 1321 - } - avatarID := lineupID isTrailblazer := lineupID == 8001 isMarch := lineupID == 1001 @@ -239,81 +244,51 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBat return } + if firstAvatarID == 0 { + firstAvatarID = avatarID + firstAvatarIdx = avatarIndex + } + battleAvatar, techs := avatar.ToBattleAvatarProto( avatarIndex, player.FindLightconeFor(avatarID), player.FindRelicsFor(avatarID), ) - battleInfo.BuffList = append(battleInfo.BuffList, 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.BuffList = append(battleInfo.BuffList, rules.techniqueBuffs(avatar, avatarIndex, firstAvatarIdx, techs)...) 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}, - }) - } }) - - // resolve character-specific technique buffs dynamically - firstAvatarAttackID := uint32(0) - if res, err := common.GameRes(); err == nil { - if cfg, ok := res.AvatarConfigs[firstAvatarID]; ok { - firstAvatarAttackID = cfg.WeaknessBuffID - } + if len(battleInfo.BattleAvatarList) == 0 { + return nil, fmt.Errorf("battle: lineup has no available avatars") } - len0 := len(battleInfo.BuffList) - hasReplaced := false - var dahliaBuffs []*goproto.BattleBuff - for i, buff := range battleInfo.BuffList { - isLast := i == len0-1 - - if buff.Id == 141202 || buff.Id == 141403 { - buff.OwnerId = firstAvatarIdx - 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)}, - }, - ) + // PearlSR always supplies an entry attack, including Calyx. Resolve the + // actor from the scene's actual slot IDs, rather than assuming cast_entity + // identifies a lineup position. + attackSlot, attackID := firstAvatarIdx, firstAvatarID + for _, avatar := range battleInfo.BattleAvatarList { + if casterID != 0 && avatar.Index+1 == casterID { + attackSlot, attackID = avatar.Index, avatar.Id + break } } - 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 for _, stat := range player.BattleConfig.CustomStats { @@ -344,15 +319,16 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBat // blessings for _, blessing := range player.BattleConfig.Blessings { buff := &goproto.BattleBuff{ - Id: blessing.ID, - Level: blessing.Level, - WaveFlag: 0xffffffff, - OwnerId: 0xffffffff, + Id: blessing.ID, + Level: blessing.Level, + WaveFlag: 0xffffffff, + OwnerId: 0xffffffff, + TargetIndexList: []uint32{0}, } if blessing.DynamicKey != nil { buff.DynamicValues = map[string]float32{blessing.DynamicKey.Key: float32(blessing.DynamicKey.Value)} } else { - buff.DynamicValues = map[string]float32{} + buff.DynamicValues = map[string]float32{"SkillIndex": 0} } for _, dv := range blessing.DynamicValues { 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) } - // pf score object - 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}}, - } - } + rules.addTargets(battleInfo, player.BattleConfig.BattleType) // SU 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.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList)) - // global buffs - 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, - }) - } + rules.addGlobalPassives(battleInfo, player) - // Stage invasion buff (pearl-sr 4.6.53: e.g. Peak 11 stage 4 30511021/30511022 -> buff 3034004) - if invasionID := getStageInvasionID(battleInfo.StageId); invasionID != 0 { - battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{ - Id: 3034000 + invasionID, - Level: 1, - OwnerId: firstAvatarIdx, - WaveFlag: 0xffffffff, - TargetIndexList: []uint32{0}, - DynamicValues: map[string]float32{"SkillIndex": 1}, - }) - // Aha Invasion support buff - battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{ - Id: 3034013, - Level: 1, - OwnerId: firstAvatarIdx, - WaveFlag: 0xffffffff, - TargetIndexList: []uint32{0}, - DynamicValues: map[string]float32{"SkillIndex": 1}, - }) + // Invasion support belongs to an active challenge, not a custom battle + // merely reusing the same stage. The scene loader records that state. + if s.ChallengeStageID != 0 { + if invasionID := getStageInvasionID(s.ChallengeStageID); invasionID != 0 { + buffID, ok := rules.InvasionBuffs[invasionID] + if !ok { + return nil, fmt.Errorf("battle: invasion %d has no buff resource", invasionID) + } + battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(buffID, firstAvatarIdx, map[string]float32{"SkillIndex": 1})) + if player.BattleConfig.EnableInvasionSupport { + battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(rules.InvasionSupportBuff, firstAvatarIdx, 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 { diff --git a/internal/gameserver/handlers_challenge_scene.go b/internal/gameserver/handlers_challenge_scene.go index 02e19cb..0c1746b 100644 --- a/internal/gameserver/handlers_challenge_scene.go +++ b/internal/gameserver/handlers_challenge_scene.go @@ -167,6 +167,7 @@ func loadChallengeScene(s *Session, entryID, targetGroupID, monsterID, eventID u // single boss monster (entity 30001, registered for SceneCastSkill battles) monsterGroup := &goproto.SceneEntityGroupInfo{GroupId: targetGroupID} s.MonsterEntities = map[uint32]uint32{30001: eventID} + s.ChallengeStageID = eventID monsterGroup.EntityList = append(monsterGroup.EntityList, &goproto.SceneEntityInfo{ InstId: instID, GroupId: targetGroupID, diff --git a/internal/gameserver/handlers_misc.go b/internal/gameserver/handlers_misc.go index c4a73ac..e9b0c52 100644 --- a/internal/gameserver/handlers_misc.go +++ b/internal/gameserver/handlers_misc.go @@ -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 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() { handle("GetBag", func(s *Session, req *goproto.GetBagCsReq, rsp *goproto.GetBagScRsp) error { player := s.JSON() @@ -55,17 +74,18 @@ func init() { {Tid: 101, Num: 999999}, // Normal Pass {Tid: 102, Num: 999999}, // Special Pass } - for _, aid := range BaseAvatarIDs { - rsp.MaterialList = append(rsp.MaterialList, &goproto.Material{ - Tid: 130000 + aid, - Num: 10, - }) - } + // Avatar IDs do not imply item IDs. The old 130000+avatar formula + // produced nonexistent materials and broke the client's inventory init. return nil }) handle("GetArchiveData", func(s *Session, req *goproto.GetArchiveDataCsReq, rsp *goproto.GetArchiveDataScRsp) error { 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 }) diff --git a/internal/gameserver/handlers_player.go b/internal/gameserver/handlers_player.go index 38d7fe6..b0fa58b 100644 --- a/internal/gameserver/handlers_player.go +++ b/internal/gameserver/handlers_player.go @@ -95,24 +95,49 @@ func init() { }) handle("GetPlayerBoardData", func(s *Session, req *goproto.GetPlayerBoardDataCsReq, rsp *goproto.GetPlayerBoardDataScRsp) error { + profile, err := loadProfileResources() + if err != nil { + return err + } rsp.Retcode = 0 - rsp.CurrentHeadIconId = 200001 - rsp.CurrentPersonalCardId = 253001 + rsp.CurrentHeadIconId = profile.Icons[0] + rsp.CurrentPersonalCardId = profile.CurrentPersonalCard + rsp.HeadFrameInfo = profile.HeadFrame + rsp.NBIEJKLENOB = &goproto.DisplayAvatarVec{IsDisplay: true} rsp.Signature = "Trailblazer" - rsp.JJDDEKIAOFC = []uint32{200001, 200002, 200101, 200102, 200103, 200104} - rsp.KJAABDABLJJ = []uint32{253001, 253002} + // PearlSR 005d2c1 GetPlayerBoardDataScRsp: field 4 is personal + // 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") return nil }) handle("GetPhoneData", func(s *Session, req *goproto.GetPhoneDataCsReq, rsp *goproto.GetPhoneDataScRsp) error { + profile, err := loadProfileResources() + if err != nil { + return err + } rsp.Retcode = 0 - rsp.CurPhoneTheme = 1 - rsp.CurPhoneCase = 200001 - rsp.CurChatBubble = 220001 - rsp.OwnedPhoneThemes = []uint32{1} - rsp.OwnedPhoneCases = []uint32{200001} - rsp.OwnedChatBubbles = []uint32{220001} + rsp.CurPhoneTheme = profile.CurrentPhoneTheme + rsp.CurPhoneCase = profile.CurrentPhoneCase + rsp.CurChatBubble = profile.CurrentChatBubble + rsp.OwnedPhoneThemes = profile.PhoneThemes + rsp.OwnedPhoneCases = profile.PhoneCases + rsp.OwnedChatBubbles = profile.ChatBubbles SceneDebugLog("GET_PHONE_DATA sent") return nil }) diff --git a/internal/gameserver/handlers_scene.go b/internal/gameserver/handlers_scene.go index 40fa992..b4c56c8 100644 --- a/internal/gameserver/handlers_scene.go +++ b/internal/gameserver/handlers_scene.go @@ -928,6 +928,7 @@ func loadScene(s *Session, entryID uint32, isEnterScene, sendNotify bool, telepo s.PropEntities = map[uint32]PropEntry{} s.MonsterEntities = map[uint32]uint32{} + s.ChallengeStageID = 0 for _, groupID := range sortedU32Keys2(scene.Scenes) { group := scene.Scenes[groupID] diff --git a/internal/gameserver/profile_resources.go b/internal/gameserver/profile_resources.go new file mode 100644 index 0000000..3a95f7f --- /dev/null +++ b/internal/gameserver/profile_resources.go @@ -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 +} diff --git a/internal/gameserver/session.go b/internal/gameserver/session.go index 1b6e08c..5e0ee43 100644 --- a/internal/gameserver/session.go +++ b/internal/gameserver/session.go @@ -44,6 +44,8 @@ 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 + // Set by the challenge scene loader, cleared when loading an overworld scene. + ChallengeStageID uint32 closed atomic.Bool closeOnce sync.Once diff --git a/resources/BattleRules.json b/resources/BattleRules.json new file mode 100644 index 0000000..6207f15 --- /dev/null +++ b/resources/BattleRules.json @@ -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 + } +} diff --git a/resources/ProfileConfig.json b/resources/ProfileConfig.json new file mode 100644 index 0000000..c9de605 --- /dev/null +++ b/resources/ProfileConfig.json @@ -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 +}