Files
2026-10-28 18:54:43 +07:00

274 lines
8.1 KiB
Go

package gameserver
import (
"encoding/json"
"fmt"
"sync"
"gac-hkrpg-go/internal/common"
goproto "gac-hkrpg-go/protocol/proto"
)
type worldEvent struct {
EventID uint32 `json:"EventID"`
WorldLevel uint32 `json:"WorldLevel"`
StageID uint32 `json:"StageID"`
}
type cocoonResource struct {
ID uint32 `json:"ID"`
WorldLevel uint32 `json:"WorldLevel"`
CocoonType uint32 `json:"CocoonType"`
StageID uint32 `json:"StageID"`
StageIDs []uint32 `json:"StageIDList"`
MaxCount uint32 `json:"MaxChallengeCnt"`
}
// CocoonType values from the Client's Cocoon table. Only Calyx entries may
// use FreeSR custom battles; Stagnant Shadow and Cavern must use Client stages.
const (
clientCocoonTypeCalyx uint32 = 1
clientCocoonTypeStagnantShadow uint32 = 2
clientCocoonTypeCavernCorrosion uint32 = 3
)
func canUseSrtoolCustomCocoon(row cocoonResource) bool {
return row.CocoonType == clientCocoonTypeCalyx
}
type worldBattleResources struct {
ClientVersion string `json:"client_version"`
WorldLevel uint32 `json:"world_level"`
Events []worldEvent `json:"events"`
Cocoons []cocoonResource `json:"cocoons"`
NPCMonsters map[uint32]struct {
Rank uint32 `json:"rank"`
} `json:"npc_monsters"`
InstantDefeatAvatars []uint32 `json:"instant_defeat_avatars"`
InstantDefeatRanks []uint32 `json:"instant_defeat_ranks"`
}
type sceneMonsterIdentity struct {
MonsterID uint32
GroupID uint32
}
// Match both the technique owner and the actual NPC rank exported from Client.
// Challenge bosses and tool-controlled encounters always use their battle flow.
func (s *Session) instantDefeatTargets(req *goproto.SceneCastSkillCsReq, targets []uint32) []uint32 {
if req.SkillIndex != 1 || s.ChallengeBattleConfig != nil || s.ChallengeStageID != 0 || common.SrtoolCustomBattleActive() {
return nil
}
data, err := loadWorldBattleResources()
if err != nil {
return nil
}
owner := s.techniqueAvatar(req.CasterId)
allowed := false
for _, id := range data.InstantDefeatAvatars {
if id == owner {
allowed = true
break
}
}
if !allowed {
return nil
}
var defeated []uint32
for _, entity := range targets {
identity, ok := s.SceneMonsterIdentities[entity]
if !ok {
continue
}
row, ok := data.NPCMonsters[identity.MonsterID]
if !ok {
continue
}
for _, rank := range data.InstantDefeatRanks {
if rank == row.Rank {
defeated = append(defeated, entity)
break
}
}
}
return defeated
}
func (s *Session) removeSceneMonsters(ids []uint32) error {
notify := &goproto.SceneGroupRefreshScNotify{FloorId: s.CurrentSceneFloor}
groups := map[uint32]*goproto.SceneGroupRefreshInfo{}
for _, id := range ids {
m, ok := s.SceneMonsterIdentities[id]
if !ok {
continue
}
group := groups[m.GroupID]
if group == nil {
group = &goproto.SceneGroupRefreshInfo{GroupId: m.GroupID, State: 1, GroupRefreshType: goproto.SceneGroupRefreshType_SCENE_GROUP_REFRESH_TYPE_LOADED}
groups[m.GroupID] = group
}
group.RefreshEntity = append(group.RefreshEntity, &goproto.SceneEntityRefreshInfo{RefreshType: &goproto.SceneEntityRefreshInfo_DeleteEntity{DeleteEntity: id}})
delete(s.MonsterEntities, id)
delete(s.SceneMonsterIdentities, id)
}
for _, id := range sortedU32Keys2(groups) {
notify.GroupRefreshInfo = append(notify.GroupRefreshInfo, groups[id])
}
if len(notify.GroupRefreshInfo) == 0 {
return nil
}
return s.Send(notify)
}
var worldBattleOnce sync.Once
var worldBattleData worldBattleResources
var worldBattleError error
func loadWorldBattleResources() (*worldBattleResources, error) {
worldBattleOnce.Do(func() {
var raw []byte
raw, worldBattleError = common.ReadResourceFile("resources/WorldBattleConfig.json")
if worldBattleError != nil {
return
}
worldBattleError = json.Unmarshal(raw, &worldBattleData)
if worldBattleError != nil {
return
}
cfg, err := common.LoadRuntimeConfig()
if err != nil {
worldBattleError = err
return
}
if cfg.ClientVersion != worldBattleData.ClientVersion || len(worldBattleData.Events) == 0 || len(worldBattleData.Cocoons) == 0 {
worldBattleError = fmt.Errorf("world battle resource missing or Client revision mismatch")
}
})
return &worldBattleData, worldBattleError
}
func resourceBattleConfig(stageID uint32) (*common.BattleConfig, error) {
stage, ok := getChallengeData().Stages[stageID]
if !ok || stage.Level == 0 || len(stage.Monsters) == 0 {
return nil, fmt.Errorf("stage %d has no verified waves", stageID)
}
rules, err := loadBattleRules()
if err != nil {
return nil, err
}
config := &common.BattleConfig{StageID: stageID, BattleType: common.BattleTypeDefault, CycleCount: rules.ChallengeRoundLimits[common.BattleTypeDefault]}
for _, wave := range stage.Monsters {
if len(wave) == 0 {
return nil, fmt.Errorf("stage %d has an empty wave", stageID)
}
var monsters []common.Monster
for _, id := range wave {
monsters = append(monsters, common.Monster{MonsterID: id, Level: stage.Level})
}
config.Monsters = append(config.Monsters, monsters)
}
return config, nil
}
func (s *Session) worldEncounterConfig(targets []uint32) (*common.BattleConfig, error) {
if s.ChallengeBattleConfig != nil {
return s.ChallengeBattleConfig, nil
}
data, err := loadWorldBattleResources()
if err != nil {
return nil, err
}
if len(targets) == 0 {
return nil, fmt.Errorf("battle has no scene monster")
}
var combined *common.BattleConfig
seen := map[uint32]bool{}
for _, target := range targets {
eventID := s.MonsterEntities[target]
if seen[eventID] {
continue
}
seen[eventID] = true
config, err := s.eventBattleConfig(eventID, data.WorldLevel)
if err != nil {
return nil, err
}
if combined == nil {
combined = config
} else {
combined.Monsters = append(combined.Monsters, config.Monsters...)
}
}
return combined, nil
}
func (s *Session) eventBattleConfig(eventID, worldLevel uint32) (*common.BattleConfig, error) {
data, err := loadWorldBattleResources()
if err != nil {
return nil, err
}
for _, row := range data.Events {
if row.EventID == eventID && row.WorldLevel == worldLevel {
config, err := resourceBattleConfig(row.StageID)
if config != nil {
level := worldLevel
config.WorldLevel = &level
}
return config, err
}
}
return nil, fmt.Errorf("scene event %d world level %d has no Client stage mapping", eventID, worldLevel)
}
func (s *Session) cocoonBattleConfig(id, worldLevel, wave uint32) (*common.BattleConfig, error) {
data, err := loadWorldBattleResources()
if err != nil {
return nil, err
}
for _, row := range data.Cocoons {
if row.ID != id || row.WorldLevel != worldLevel {
continue
}
if row.MaxCount != 0 && wave > row.MaxCount {
return nil, fmt.Errorf("cocoon wave exceeds Client limit")
}
if common.SrtoolCustomBattleActive() && canUseSrtoolCustomCocoon(row) {
// The Client's Cocoon table identifies Calyx separately from
// Stagnant Shadow and Cavern of Corrosion. Custom battles are limited
// to Calyx; all other cocoon encounters use their Client stage rows.
player := s.JSON()
if player == nil {
return nil, fmt.Errorf("player data is not loaded")
}
config := player.BattleConfig
if len(config.Monsters) != 0 {
return &config, nil
}
// An empty custom wave list means the user selected “use Client
// stages” in Srtool. Fall through to the installed Calyx stage rows.
}
stages := row.StageIDs
if len(stages) == 0 && row.StageID != 0 {
stages = []uint32{row.StageID}
}
if len(stages) == 0 {
return nil, fmt.Errorf("cocoon %d has no stages", id)
}
config, err := resourceBattleConfig(stages[0])
if err != nil {
return nil, err
}
level := worldLevel
config.WorldLevel = &level
// Quick-start sends zero for its default single stage. Explicit counts
// repeat complete resource stages; never replace their monster lists.
for i := uint32(1); i < wave; i++ {
next, err := resourceBattleConfig(stages[int(i)%len(stages)])
if err != nil {
return nil, err
}
config.Monsters = append(config.Monsters, next.Monsters...)
}
return config, nil
}
return nil, fmt.Errorf("unknown cocoon %d at world level %d", id, worldLevel)
}