feat: port GaC-hkrpg-ps to Go with SceneCastSkill camera-detach fix
- mihoyo-variant KCP in pure Go (28-byte conv+token header), UDP gateway with 0xFF/0x145/0x194 handshake, ms-precision KCP clock + retransmit ticker - protobuf for 4.6.51 generated from the AstralOS StarRail.proto (4932 messages) plus CmdID map from // CmdID: comments (2114 ids) - sdkserver port: query_dispatch/query_gateway (Gateserver pb + base64), hotfix fetch, auth endpoints, SRTools freesr-data.json upload - gameserver handlers: login flow, scene load/spawn with real monster entity tracking, lineup, avatar/skin, missions, tutorials (1249+855), bag/equip, challenge incl. StartChallenge 1787 layout + Tierce 8973 + peak 8948, all raw tierce replies - SceneCastSkill camera bug fix: only real scene monsters (tracked per session) trigger battle; attacking props/NPCs/air replies retcode 0 without battle_info so the camera stays locked on the character - end-to-end smoke test simulating a client over real UDP: handshake, PlayerGetToken, and both SceneCastSkill paths - reuses the Rust PS data files unchanged (freesr-data.json, persistent, res.json, teleports.json, challenge_data.json, mission_unlocks.json)
This commit is contained in:
commit
59ea97b1a6
45 files changed
+1963868
No files matched your search
@@ -0,0 +1,413 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"gac-hkrpg-go/internal/common"
|
||||
goproto "gac-hkrpg-go/protocol/proto"
|
||||
)
|
||||
|
||||
func init() {
|
||||
handle("StartCocoonStage", func(s *Session, req *goproto.StartCocoonStageCsReq, rsp *goproto.StartCocoonStageScRsp) error {
|
||||
battleInfo := createBattleInfo(s, 0, 0)
|
||||
CURRENT_BATTLE.Store(s.Token, battleInfo)
|
||||
|
||||
rsp.PropEntityId = req.PropEntityId
|
||||
rsp.CocoonId = req.CocoonId
|
||||
rsp.Wave = req.Wave
|
||||
rsp.BattleInfo = battleInfo
|
||||
return nil
|
||||
})
|
||||
|
||||
// NOTE: 4.6.51 proto names this message StartQuickCocoonStageCsReq/Rsp
|
||||
// (the Rust code referenced an older QuickStartCocoonStage* name).
|
||||
handleNamed("StartQuickCocoonStage", "StartQuickCocoonStageRsp", func(s *Session, req *goproto.StartQuickCocoonStageCsReq, rsp *goproto.StartQuickCocoonStageRsp) error {
|
||||
battleInfo := createBattleInfo(s, 0, 0)
|
||||
CURRENT_BATTLE.Store(s.Token, battleInfo)
|
||||
|
||||
rsp.CocoonId = req.CocoonId
|
||||
rsp.Wave = req.Wave
|
||||
rsp.BattleInfo = battleInfo
|
||||
return nil
|
||||
})
|
||||
|
||||
handle("SceneEnterStage", func(s *Session, req *goproto.SceneEnterStageCsReq, rsp *goproto.SceneEnterStageScRsp) error {
|
||||
battleInfo := createBattleInfo(s, 0, 0)
|
||||
CURRENT_BATTLE.Store(s.Token, battleInfo)
|
||||
rsp.BattleInfo = battleInfo
|
||||
return nil
|
||||
})
|
||||
|
||||
handle("PVEBattleResult", func(s *Session, req *goproto.PVEBattleResultCsReq, rsp *goproto.PVEBattleResultScRsp) error {
|
||||
CURRENT_BATTLE.Delete(s.Token)
|
||||
rsp.EndStatus = req.EndStatus
|
||||
rsp.BattleId = req.BattleId
|
||||
rsp.StageId = req.StageId
|
||||
return nil
|
||||
})
|
||||
|
||||
// ================================================================
|
||||
// SceneCastSkill — THE CAMERA-DETACH BUG FIX
|
||||
// (บัคกล้องหลุดจากตัวละครตอนกดตี)
|
||||
//
|
||||
// อาการ: กดโจมตีใน overworld แล้วกล้องหลุดออกจากตัวละคร
|
||||
//
|
||||
// สาเหตุ (จากโค้ด Rust เดิม): handler ตอบ battle_info + monster_battle_info
|
||||
// ให้ client เข้าสู่การต่อสู้กับ target ที่ "ผ่าน filter แบบหลวมๆ"
|
||||
// filter เดิม: id > 30000 (monster ในฉาก) || id < 1000 (avatar entity ของผู้เล่น!)
|
||||
// ถ้า targets ว่างและอยู่ใน arena (entry >= 3000000) ยัง "หลอก" target
|
||||
// เป็น 30001 อีกชั้น
|
||||
// พอ client ได้ battle_info กับ "monster" ที่จริงคือ prop/NPC/ตัวผู้เล่นเอง
|
||||
// หรือ monster ที่ไม่มี event_id มันจะเริ่ม battle transition ที่โหลดไม่สำเร็จ
|
||||
// → battle camera ไม่มี target ให้ตาม → กล้องหลุดจากตัวละคร
|
||||
//
|
||||
// FIX: ยอมรับเฉพาะ entity ที่ server spawn เป็น "monster จริง" ในฉากปัจจุบัน
|
||||
// (เก็บไว้ใน Session.MonsterEntities ตอน load_scene) ถ้าไม่มี monster จริง
|
||||
// ถูกโจมตี → ตอบ retcode 0 + cast_entity_id เฉยๆ ไม่เข้า battle
|
||||
// client จะเล่นแอนิเมชันตีปกติ กล้องค้างกับตัวละคร
|
||||
// ================================================================
|
||||
handle("SceneCastSkill", func(s *Session, req *goproto.SceneCastSkillCsReq, rsp *goproto.SceneCastSkillScRsp) error {
|
||||
rsp.CastEntityId = req.CastEntityId
|
||||
|
||||
// เฉพาะ monster entity จริงเท่านั้นที่ trigger battle
|
||||
var targets []uint32
|
||||
seen := map[uint32]bool{}
|
||||
addTarget := func(id uint32) {
|
||||
if _, isMonster := s.MonsterEntities[id]; isMonster && !seen[id] {
|
||||
seen[id] = true
|
||||
targets = append(targets, id)
|
||||
}
|
||||
}
|
||||
for _, id := range req.HitTargetEntityIdList {
|
||||
addTarget(id)
|
||||
}
|
||||
for _, id := range req.AssistMonsterEntityIdList {
|
||||
addTarget(id)
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
// ไม่มี monster จริงถูกตี — ไม่เข้า battle (นี่คือ fix หลัก)
|
||||
SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d skill_index=%d -> no real monster target (battle skipped)",
|
||||
req.CastEntityId, req.CasterId, req.SkillIndex)
|
||||
rsp.Retcode = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d -> battle with %d monster(s)",
|
||||
req.CastEntityId, req.CasterId, len(targets))
|
||||
|
||||
battleInfo := createBattleInfo(s, req.CasterId, req.SkillIndex)
|
||||
CURRENT_BATTLE.Store(s.Token, battleInfo)
|
||||
|
||||
for _, id := range targets {
|
||||
rsp.MonsterBattleInfo = append(rsp.MonsterBattleInfo, &goproto.HitMonsterBattleInfo{
|
||||
TargetMonsterEntityId: id,
|
||||
})
|
||||
}
|
||||
rsp.BattleInfo = battleInfo
|
||||
return nil
|
||||
})
|
||||
|
||||
// ตอบ MP cost ให้ client (ตอบ cast_entity_id กลับ, ไม่ตัด MP จริง)
|
||||
handle("SceneCastSkillCostMp", func(s *Session, req *goproto.SceneCastSkillCostMpCsReq, rsp *goproto.SceneCastSkillCostMpScRsp) error {
|
||||
rsp.Retcode = 0
|
||||
rsp.CastEntityId = req.CastEntityId
|
||||
return nil
|
||||
})
|
||||
|
||||
handle("GetCurBattleInfo", func(s *Session, req *goproto.GetCurBattleInfoCsReq, rsp *goproto.GetCurBattleInfoScRsp) error {
|
||||
if info, ok := CURRENT_BATTLE.Load(s.Token); ok {
|
||||
rsp.Retcode = 0
|
||||
rsp.BattleInfo = info
|
||||
return nil
|
||||
}
|
||||
|
||||
if player := s.JSON(); player != nil && len(player.BattleConfig.Monsters) > 0 {
|
||||
battleInfo := createBattleInfo(s, 0, 0)
|
||||
CURRENT_BATTLE.Store(s.Token, battleInfo)
|
||||
rsp.Retcode = 0
|
||||
rsp.BattleInfo = battleInfo
|
||||
return nil
|
||||
}
|
||||
|
||||
rsp.Retcode = 2000 // RET_BATTLE_STAGE_NOT_MATCH
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// createBattleInfo is the Go port of battle.rs create_battle_info.
|
||||
func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBattleInfo {
|
||||
player := s.JSON()
|
||||
if player == nil {
|
||||
Log("data is not set!")
|
||||
return &goproto.SceneBattleInfo{}
|
||||
}
|
||||
|
||||
battleInfo := &goproto.SceneBattleInfo{
|
||||
StageId: player.BattleConfig.StageID,
|
||||
LogicRandomSeed: randUint32(),
|
||||
BattleId: 1,
|
||||
RoundsLimit: player.BattleConfig.CycleCount,
|
||||
WorldLevel: 6,
|
||||
}
|
||||
|
||||
isCalyx := casterID == 0 && skillIndex == 0
|
||||
hasDahlia := false
|
||||
firstAvatarID := uint32(0)
|
||||
firstAvatarIdx := uint32(9999)
|
||||
|
||||
lineup := func(yield func(index, avatarID uint32)) {
|
||||
if player.BattleConfig.CustomBattleLineup != nil {
|
||||
for _, idx := range sortedLineup(player.BattleConfig.CustomBattleLineup) {
|
||||
yield(idx, player.BattleConfig.CustomBattleLineup[idx])
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, idx := range player.SortedLineupKeys() {
|
||||
yield(idx, player.Lineups[idx])
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if isTrailblazer {
|
||||
avatarID = player.MainCharacter.ID()
|
||||
} else if isMarch {
|
||||
avatarID = player.MarchType.ID()
|
||||
}
|
||||
|
||||
avatar, ok := player.Avatars[avatarID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
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.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
|
||||
}
|
||||
}
|
||||
|
||||
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)},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
battleInfo.BuffList = append(battleInfo.BuffList, dahliaBuffs...)
|
||||
|
||||
// custom stats for avatars
|
||||
for _, stat := range player.BattleConfig.CustomStats {
|
||||
for _, avatar := range battleInfo.BattleAvatarList {
|
||||
if len(avatar.RelicList) == 0 {
|
||||
avatar.RelicList = append(avatar.RelicList, &goproto.BattleRelic{
|
||||
Id: 61011, MainAffixId: 1, Level: 1,
|
||||
})
|
||||
}
|
||||
matched := false
|
||||
for _, sub := range avatar.RelicList[0].SubAffixList {
|
||||
if sub.AffixId == stat.SubAffixID {
|
||||
sub.Cnt = stat.Count
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
avatar.RelicList[0].SubAffixList = append(avatar.RelicList[0].SubAffixList, &goproto.RelicAffix{
|
||||
AffixId: stat.SubAffixID,
|
||||
Cnt: stat.Count,
|
||||
Step: stat.Step,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blessings
|
||||
for _, blessing := range player.BattleConfig.Blessings {
|
||||
buff := &goproto.BattleBuff{
|
||||
Id: blessing.ID,
|
||||
Level: blessing.Level,
|
||||
WaveFlag: 0xffffffff,
|
||||
OwnerId: 0xffffffff,
|
||||
}
|
||||
if blessing.DynamicKey != nil {
|
||||
buff.DynamicValues = map[string]float32{blessing.DynamicKey.Key: float32(blessing.DynamicKey.Value)}
|
||||
} else {
|
||||
buff.DynamicValues = map[string]float32{}
|
||||
}
|
||||
for _, dv := range blessing.DynamicValues {
|
||||
if _, exists := buff.DynamicValues[dv.Key]; exists {
|
||||
continue
|
||||
}
|
||||
buff.DynamicValues[dv.Key] = float32(dv.Value)
|
||||
}
|
||||
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}},
|
||||
}
|
||||
}
|
||||
|
||||
// SU
|
||||
if player.BattleConfig.BattleType == common.BattleTypeSU {
|
||||
battleInfo.BattleEvent = append(battleInfo.BattleEvent, &goproto.BattleEventBattleInfo{
|
||||
BattleEventId: player.BattleConfig.PathResonanceID,
|
||||
Status: &goproto.BattleEventProperty{
|
||||
SpBar: &goproto.SpBarInfo{CurSp: 10000, MaxSp: 10000},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// monsters
|
||||
battleInfo.MonsterWaveList = common.ToSceneMonsterWaves(player.BattleConfig.Monsters)
|
||||
battleInfo.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList))
|
||||
for _, wave := range battleInfo.MonsterWaveList {
|
||||
wave.BattleStageId = battleInfo.StageId
|
||||
}
|
||||
|
||||
// global buffs
|
||||
hasCastorice, hasSilverWolf := false, false
|
||||
for _, buff := range battleInfo.BuffList {
|
||||
if buff.Id == 140703 {
|
||||
hasCastorice = true
|
||||
}
|
||||
if buff.Id == 150602 {
|
||||
hasSilverWolf = 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,
|
||||
})
|
||||
}
|
||||
|
||||
// Aha Global Passive (151104) — always on, same as the Rust build
|
||||
battleInfo.BuffList = append(battleInfo.BuffList, &goproto.BattleBuff{
|
||||
Id: 151104, Level: 1, OwnerId: 0xffffffff, WaveFlag: 0xffffffff,
|
||||
})
|
||||
|
||||
return battleInfo
|
||||
}
|
||||
|
||||
func sortedLineup(m map[uint32]uint32) []uint32 {
|
||||
keys := make([]uint32, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sortU32Slice(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func sortU32Slice(s []uint32) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsU32(list []uint32, v uint32) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user