feat: 增强踢人逻辑与BOSS脚本支持
All checks were successful
ci/woodpecker/push/my-first-workflow Pipeline was successful

优化踢人超时处理和僵尸连接清理,支持BOSS动作脚本并增加测试,修复事件匹配与战斗循环中的并发问题。
This commit is contained in:
xinian
2026-04-05 21:59:22 +08:00
committed by cnb
parent 36dd93b076
commit c021b40fbe
16 changed files with 457 additions and 151 deletions

View File

@@ -6,6 +6,16 @@ func AddClient(id uint32, client *ClientHandler) {
Clientmap.Store(id, client) // sync.Map存值
}
// 清理指定clientuid=100000*onlineID+port
func DeleteClientOnly(uid uint32) {
Clientmap.Delete(uid)
}
// 清理指定clientonlineID+port
func DeleteClient(id, port uint32) {
Clientmap.Delete(100000*id + port)
}
// 取值示例
func GetClient(id, port uint32) (*ClientHandler, bool) {
// 普通mapclient, ok := Clientmap[id]

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"log"
"time"
config "blazing/modules/config/service"
@@ -17,20 +18,62 @@ import (
// Define the server handler
type ServerHandler struct{}
const kickForwardTimeout = 3 * time.Second
// 实现踢人
func (*ServerHandler) Kick(_ context.Context, userid uint32) error {
useid1, _ := share.ShareManager.GetUserOnline(userid)
if useid1 == 0 {
useid1, err := share.ShareManager.GetUserOnline(userid)
if err != nil || useid1 == 0 {
// 请求到达时用户已离线,直接视为成功
return nil
}
cl, ok := cool.GetClientOnly(useid1)
if !ok {
if !ok || cl == nil {
// 目标服务器不在线,清理僵尸在线标记并视为成功
_ = share.ShareManager.DeleteUserOnline(userid)
cool.DeleteClientOnly(useid1)
return nil
}
cl.KickPerson(userid) //实现指定服务器踢人
return nil
resultCh := make(chan error, 1)
go func() {
resultCh <- cl.KickPerson(userid) // 实现指定服务器踢人
}()
select {
case callErr := <-resultCh:
if callErr == nil {
return nil
}
// 调用失败后兜底:用户若已离线/切服/目标服不在线都算成功
useid2, err2 := share.ShareManager.GetUserOnline(userid)
if err2 != nil || useid2 == 0 || useid2 != useid1 {
return nil
}
if cl2, ok2 := cool.GetClientOnly(useid2); !ok2 || cl2 == nil {
_ = share.ShareManager.DeleteUserOnline(userid)
cool.DeleteClientOnly(useid2)
return nil
}
// 仍在线则返回失败,不按成功处理
return callErr
case <-time.After(kickForwardTimeout):
// 仅防止无限等待;超时不算成功
useid2, err2 := share.ShareManager.GetUserOnline(userid)
if err2 != nil || useid2 == 0 || useid2 != useid1 {
return nil
}
if cl2, ok2 := cool.GetClientOnly(useid2); !ok2 || cl2 == nil {
_ = share.ShareManager.DeleteUserOnline(userid)
cool.DeleteClientOnly(useid2)
return nil
}
return fmt.Errorf("kick timeout, user still online: uid=%d server=%d", userid, useid2)
}
}
// 注册logic服务器

View File

@@ -84,13 +84,11 @@ func (s *Server) OnClose(c gnet.Conn, err error) (action gnet.Action) {
//logging.Infof("conn[%v] disconnected", c.RemoteAddr().String())
v, _ := c.Context().(*player.ClientData)
v.LF.Close()
// v.LF.Close()
//close(v.MsgChan)
if v.Player != nil {
v.Player.Save() //保存玩家数据
if v != nil {
v.Close()
if v.Player != nil {
v.Player.Save() //保存玩家数据
}
}
//}

View File

@@ -43,6 +43,7 @@ func (Controller) PlayerFightBoss(req *ChallengeBossInboundInfo, p *player.Playe
ai := player.NewAI_player(monsterInfo)
ai.CanCapture = resolveBossCaptureRate(bossConfigs[0].IsCapture, leadMonsterID)
ai.BossScript = bossConfigs[0].Script
ai.Prop[0] = 2
var fightC *fight.FightC

View File

@@ -110,7 +110,7 @@ func (h Controller) PetTawor(data *StartTwarInboundInfo, c *player.Player) (resu
result = &fight.S2C_ChoiceLevelRequestInfo{CurFightLevel: currentLevel}
appendTowerNextBossPreview(&result.BossID, bossList)
monsterInfo, ok := buildTowerMonsterInfo(currentBoss)
monsterInfo, bossScript, ok := buildTowerMonsterInfo(currentBoss)
if !ok {
return nil, errorcode.ErrorCodes.ErrPokemonNotExists
}
@@ -119,6 +119,7 @@ func (h Controller) PetTawor(data *StartTwarInboundInfo, c *player.Player) (resu
c.Fightinfo.Status = fightinfo.BattleMode.FIGHT_WITH_NPC
ai := player.NewAI_player(monsterInfo)
ai.BossScript = bossScript
_, err = fight.NewFight(c, ai, c.GetPetInfo(100), ai.GetPetInfo(0), func(foi model.FightOverInfo) {
if foi.Reason != 0 || foi.WinnerId != c.Info.UserID {
return
@@ -195,10 +196,10 @@ func appendTowerNextBossPreview(dst *[]uint32, bossList []configmodel.BaseTowerC
}
}
func buildTowerMonsterInfo(towerBoss configmodel.BaseTowerConfig) (*model.PlayerInfo, bool) {
func buildTowerMonsterInfo(towerBoss configmodel.BaseTowerConfig) (*model.PlayerInfo, string, bool) {
bosses := service.NewBossService().Get(towerBoss.BossIds[0])
if len(bosses) == 0 {
return nil, false
return nil, "", false
}
monsterInfo := &model.PlayerInfo{Nick: towerBoss.Name}
@@ -234,7 +235,7 @@ func buildTowerMonsterInfo(towerBoss configmodel.BaseTowerConfig) (*model.Player
monsterInfo.PetList = append(monsterInfo.PetList, *monster)
}
return monsterInfo, true
return monsterInfo, bosses[0].Script, true
}
func handleTowerFightWin(c *player.Player, cmd uint32, taskID int, currentLevel uint32) {

BIN
logic/fight.test Executable file

Binary file not shown.

View File

@@ -2,6 +2,8 @@ package input
import (
"blazing/logic/service/fight/info"
"blazing/logic/service/player"
configmodel "blazing/modules/config/model"
"github.com/gogf/gf/v2/util/grand"
)
@@ -22,6 +24,16 @@ func (our *Input) GetAction() {
return t.HookAction()
})
if aiPlayer, ok := our.Player.(*player.AI_player); ok && aiPlayer.BossScript != "" {
scriptBoss := &configmodel.BossConfig{Script: aiPlayer.BossScript}
nextByScript, err := scriptBoss.RunHookActionScript(next)
if err != nil {
return
}
next = nextByScript
}
if !next {
return
}

View File

@@ -162,7 +162,8 @@ func (f *FightC) collectPlayerActions(expectedSlots map[actionSlotKey]struct{})
waitr := time.Duration(f.waittime)*time.Millisecond*10 + 30*time.Second
timeout := time.After(waitr)
timeout := time.NewTimer(waitr)
defer timeout.Stop()
for len(actions) < len(expectedSlots) {
select {
@@ -266,11 +267,12 @@ func (f *FightC) collectPlayerActions(expectedSlots map[actionSlotKey]struct{})
actions[key] = paction
//fmt.Println("玩家执行动作:", pid, paction.Priority())
case <-timeout:
case <-timeout.C:
r := f.handleTimeout(expectedSlots, actions)
if r {
return flattenActionMap(actions)
}
timeout.Reset(waitr)
}
}
@@ -287,7 +289,7 @@ func (f *FightC) handleTimeout(expectedSlots map[actionSlotKey]struct{}, actions
}
player := f.getPlayerByID(key.PlayerID)
if player != nil {
go f.UseSkillAt(player, 0, key.ActorIndex, 0)
f.UseSkillAt(player, 0, key.ActorIndex, 0)
}
}
return false

View File

@@ -2,7 +2,8 @@ package player
import (
"blazing/common/utils"
"blazing/modules/config/model"
configmodel "blazing/modules/config/model"
playermodel "blazing/modules/player/model"
"sync/atomic"
"time"
@@ -10,15 +11,17 @@ import (
"github.com/samber/lo"
)
func (p *Player) IsMatch(t model.Event) bool {
_, ok := lo.Find(t.Weather, func(item int32) bool {
return item == int32(p.GetSpace().MapBossSInfo.Wer)
})
if !ok {
// 不在同一天气下
return false
func (p *Player) IsMatch(t configmodel.Event) bool {
if len(t.Weather) > 0 {
_, ok := lo.Find(t.Weather, func(item int32) bool {
return item == int32(p.GetSpace().MapBossSInfo.Wer)
})
if !ok {
// 不在同一天气下
return false
}
}
if t.StartTime != "" && t.EndTime != "" {
ok, _ := utils.IsCurrentTimeInRange(t.StartTime, t.EndTime)
if !ok {
@@ -26,8 +29,74 @@ func (p *Player) IsMatch(t model.Event) bool {
}
}
return true
if len(t.Week) > 0 {
week := int32(time.Now().Weekday())
if week == 0 {
week = 7
}
_, ok := lo.Find(t.Week, func(item int32) bool {
return item == week
})
if !ok {
return false
}
}
if len(t.Sprites) > 0 && !matchPetIDInList(t.Sprites, p.Info.PetList, p.Info.BackupPetList) {
return false
}
if len(t.FirstSprites) > 0 {
if len(p.Info.PetList) == 0 {
return false
}
firstPetID := int32(p.Info.PetList[0].ID)
_, ok := lo.Find(t.FirstSprites, func(item int32) bool {
return item == firstPetID
})
if !ok {
return false
}
}
if len(t.MustTask) > 0 {
for _, taskID := range t.MustTask {
if p.Info.GetTask(int(taskID)) != playermodel.Completed {
return false
}
}
}
if len(t.MustItem) > 0 {
if p.Service == nil || p.Service.Item == nil {
return false
}
for _, itemID := range t.MustItem {
if p.Service.Item.CheakItem(uint32(itemID)) <= 0 {
return false
}
}
}
return true
}
func matchPetIDInList(targetIDs []int32, petLists ...[]playermodel.PetInfo) bool {
for _, pets := range petLists {
for _, pet := range pets {
petID := int32(pet.ID)
_, ok := lo.Find(targetIDs, func(item int32) bool {
return item == petID
})
if ok {
return true
}
}
}
return false
}
// 应该根据怪物信息决定后端生成

View File

@@ -4,5 +4,5 @@ type AI_player struct {
baseplayer
CanCapture int
BossScript string
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/binary"
"encoding/hex"
"sync"
"sync/atomic"
"context"
@@ -102,6 +103,10 @@ func putPacketData(buf []byte) {
}
func (h *ClientData) PushEvent(v []byte, submit func(task func()) error) {
if h == nil || h.IsClosed() {
return
}
var header common.TomeeHeader
header.Len = binary.BigEndian.Uint32(v[0:4])
header.CMD = binary.BigEndian.Uint32(v[5:9])
@@ -111,9 +116,18 @@ func (h *ClientData) PushEvent(v []byte, submit func(task func()) error) {
copy(header.Data, v[17:])
}
_ = submit(func() {
h.LF.Producer().Write(header)
err := submit(func() {
if h.IsClosed() || h.LF == nil || !h.LF.Running() {
putPacketData(header.Data)
return
}
if err := h.LF.Producer().Write(header); err != nil {
putPacketData(header.Data)
}
})
if err != nil {
putPacketData(header.Data)
}
}
// 重写
@@ -241,6 +255,20 @@ type ClientData struct {
Wsmsg *WsCodec
Conn gnet.Conn
LF *lockfree.Lockfree[common.TomeeHeader]
closed int32
}
func (p *ClientData) IsClosed() bool {
return atomic.LoadInt32(&p.closed) == 1
}
func (p *ClientData) Close() {
if !atomic.CompareAndSwapInt32(&p.closed, 0, 1) {
return
}
if p.LF != nil && p.LF.Running() {
_ = p.LF.Close()
}
}
func (p *ClientData) GetPlayer(userid uint32) *Player { //TODO 这里待优化,可能存在内存泄漏问题

View File

@@ -1,13 +1,16 @@
package player
import "blazing/common/data/share"
func KickPlayer(userid uint32) error { //踢出玩家
//TODO 返回错误码
//var player *entity.Player
if player1, ok := Mainplayer.Load(userid); ok {
player1.Player.Kick(false)
return nil
}
//return player
// 已不在本服在线列表,视为离线并清理僵尸在线标记
_ = share.ShareManager.DeleteUserOnline(userid)
return nil
}

View File

@@ -221,14 +221,15 @@ func (ret *Space) init() {
}
func (p *Space) IsMatch(t model.Event) bool {
_, ok := lo.Find(t.Weather, func(item int32) bool {
return item == int32(p.MapBossSInfo.Wer)
})
if !ok {
return false
if len(t.Weather) > 0 {
_, ok := lo.Find(t.Weather, func(item int32) bool {
return item == int32(p.MapBossSInfo.Wer)
})
if !ok {
return false
}
}
if t.StartTime != "" && t.EndTime != "" {
ok, _ := utils.IsCurrentTimeInRange(t.StartTime, t.EndTime)
if !ok {
@@ -236,6 +237,20 @@ func (p *Space) IsMatch(t model.Event) bool {
}
}
if len(t.Week) > 0 {
week := int32(time.Now().Weekday())
if week == 0 {
week = 7
}
_, ok := lo.Find(t.Week, func(item int32) bool {
return item == week
})
if !ok {
return false
}
}
return true
}

View File

@@ -2,6 +2,10 @@ package model
import (
"blazing/cool"
"fmt"
"strings"
"github.com/dop251/goja"
)
const (
@@ -46,3 +50,47 @@ func NewBossConfig() *BossConfig {
func init() {
cool.CreateTable(&BossConfig{})
}
// RunHookActionScript 执行BOSS脚本中的 hookAction并传入 fight 的 hookaction 参数。
// 返回值遵循 HookAction 语义true 允许继续出手false 阻止继续出手。
func (b *BossConfig) RunHookActionScript(hookAction any) (bool, error) {
if b == nil || strings.TrimSpace(b.Script) == "" {
return true, nil
}
program, err := goja.Compile("boss_hook_action.js", b.Script, false)
if err != nil {
return false, fmt.Errorf("compile boss script: %w", err)
}
vm := goja.New()
if _, err = vm.RunProgram(program); err != nil {
return false, fmt.Errorf("run boss script: %w", err)
}
var (
callable goja.Callable
ok bool
)
for _, fnName := range []string{"hookAction", "HookAction", "hookaction"} {
callable, ok = goja.AssertFunction(vm.Get(fnName))
if ok {
break
}
}
if !ok {
return false, fmt.Errorf("boss script function not found: hookAction")
}
result, err := callable(goja.Undefined(), vm.ToValue(hookAction))
if err != nil {
return false, fmt.Errorf("execute boss hookAction: %w", err)
}
// 与既有HookAction默认行为保持一致未显式返回时视为允许继续出手。
if goja.IsUndefined(result) || goja.IsNull(result) {
return true, nil
}
return result.ToBoolean(), nil
}

View File

@@ -0,0 +1,44 @@
package model
import "testing"
type testHookAction struct {
Allow bool
Round int
}
func TestBossConfigRunHookActionScript(t *testing.T) {
boss := &BossConfig{
Script: `
function hookAction(hookaction) {
return hookaction.Allow && hookaction.Round >= 2;
}
`,
}
ok, err := boss.RunHookActionScript(testHookAction{Allow: true, Round: 2})
if err != nil {
t.Fatalf("RunHookActionScript returned error: %v", err)
}
if !ok {
t.Fatalf("RunHookActionScript = false, want true")
}
}
func TestBossConfigRunHookActionScriptEmptyReturnDefaultsTrue(t *testing.T) {
boss := &BossConfig{
Script: `
function hookAction(hookaction) {
var _ = hookaction;
}
`,
}
ok, err := boss.RunHookActionScript(testHookAction{Allow: false, Round: 1})
if err != nil {
t.Fatalf("RunHookActionScript returned error: %v", err)
}
if !ok {
t.Fatalf("RunHookActionScript = false, want true")
}
}

View File

@@ -179,19 +179,51 @@ func (s *InfoService) Gensession() string {
func (s *InfoService) Kick(id uint32) error {
useid1, err := share.ShareManager.GetUserOnline(id)
if err != nil {
return err
if err != nil || useid1 == 0 {
// 请求进入时已经离线,视为成功
return nil
}
cl, ok := cool.GetClientOnly(useid1)
if ok {
err := cl.KickPerson(id) //实现指定服务器踢人
if err != nil {
return err
}
if !ok || cl == nil {
// 目标服务器不在线,清理僵尸在线标记并视为成功
_ = share.ShareManager.DeleteUserOnline(id)
return nil
}
resultCh := make(chan error, 1)
go func() {
resultCh <- cl.KickPerson(id) // 实现指定服务器踢人
}()
select {
case callErr := <-resultCh:
if callErr == nil {
return nil
}
// 调用失败后兜底:若已离线/切服/目标服不在线则视为成功
useid2, err2 := share.ShareManager.GetUserOnline(id)
if err2 != nil || useid2 == 0 || useid2 != useid1 {
return nil
}
if cl2, ok2 := cool.GetClientOnly(useid2); !ok2 || cl2 == nil {
_ = share.ShareManager.DeleteUserOnline(id)
return nil
}
return callErr
case <-time.After(3 * time.Second):
// 防止异常场景下无限等待;超时不按成功处理
useid2, err2 := share.ShareManager.GetUserOnline(id)
if err2 != nil || useid2 == 0 || useid2 != useid1 {
return nil
}
if cl2, ok2 := cool.GetClientOnly(useid2); !ok2 || cl2 == nil {
_ = share.ShareManager.DeleteUserOnline(id)
return nil
}
return fmt.Errorf("kick timeout, user still online: uid=%d server=%d", id, useid2)
}
return nil
}
// saveToLocalFile 兜底保存将数据写入本地lose文件夹